Compare commits
20 Commits
6b4cd5b6c8
...
Redesign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b6797713 | ||
|
|
a1c4ee4298 | ||
|
|
3599d850f6 | ||
|
|
52d516dd8f | ||
|
|
ca0b9352f9 | ||
|
|
d384a43aa2 | ||
|
|
855d191517 | ||
|
|
491e373cc3 | ||
|
|
18a97b293a | ||
|
|
519864b962 | ||
|
|
5276729a69 | ||
|
|
28268a38c0 | ||
|
|
8f8ce0240b | ||
|
|
840554c223 | ||
|
|
cc422255a2 | ||
|
|
87f3d98621 | ||
|
|
a759155b15 | ||
|
|
0180b450b3 | ||
|
|
c3f477788e | ||
|
|
7b5053f42a |
@@ -25,21 +25,25 @@ jobs:
|
||||
- name: Получить исходный код
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Проверить запуск CI-контейнеров без bind mount
|
||||
run: bash scripts/test-run-ci-container.sh
|
||||
|
||||
- name: Backend unit и component tests
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "${{ github.workspace }}/backend:/workspace"
|
||||
-w /workspace
|
||||
"${{ env.MAVEN_IMAGE }}"
|
||||
mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test
|
||||
run: |
|
||||
bash scripts/run-ci-container.sh \
|
||||
backend \
|
||||
"${{ env.MAVEN_IMAGE }}" \
|
||||
-- \
|
||||
mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test
|
||||
|
||||
- name: Frontend static и unit tests
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "${{ github.workspace }}/frontend:/workspace"
|
||||
-w /workspace
|
||||
"${{ env.NODE_IMAGE }}"
|
||||
sh -c "npm ci && npm run check"
|
||||
run: |
|
||||
bash scripts/run-ci-container.sh \
|
||||
frontend \
|
||||
"${{ env.NODE_IMAGE }}" \
|
||||
--copy backend /backend \
|
||||
-- \
|
||||
sh -c "npm ci && npm run check"
|
||||
|
||||
- name: Проверить Docker Compose
|
||||
env:
|
||||
@@ -141,11 +145,14 @@ jobs:
|
||||
name: Проверить опубликованные образы
|
||||
needs: [build-and-push-backend, build-and-push-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
BACKEND_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}@${{ needs.build-and-push-backend.outputs.digest }}
|
||||
FRONTEND_REF: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}@${{ needs.build-and-push-frontend.outputs.digest }}
|
||||
steps:
|
||||
- name: Получить исходный код
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Войти в Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
@@ -154,25 +161,28 @@ jobs:
|
||||
password: ${{ secrets.ZUEV_TOKEN }}
|
||||
|
||||
- name: Проверить backend на HIGH и CRITICAL уязвимости
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "$HOME/.docker:/root/.docker:ro"
|
||||
"${{ env.TRIVY_IMAGE }}"
|
||||
image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
|
||||
"$BACKEND_REF"
|
||||
run: |
|
||||
bash scripts/run-ci-container.sh \
|
||||
"$HOME/.docker" \
|
||||
"${{ env.TRIVY_IMAGE }}" \
|
||||
--env DOCKER_CONFIG=/workspace \
|
||||
-- \
|
||||
image --timeout 15m --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
|
||||
"$BACKEND_REF"
|
||||
|
||||
- name: Проверить frontend на HIGH и CRITICAL уязвимости
|
||||
run: >-
|
||||
docker run --rm
|
||||
-v "$HOME/.docker:/root/.docker:ro"
|
||||
"${{ env.TRIVY_IMAGE }}"
|
||||
image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
|
||||
"$FRONTEND_REF"
|
||||
run: |
|
||||
bash scripts/run-ci-container.sh \
|
||||
"$HOME/.docker" \
|
||||
"${{ env.TRIVY_IMAGE }}" \
|
||||
--env DOCKER_CONFIG=/workspace \
|
||||
-- \
|
||||
image --timeout 15m --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
|
||||
"$FRONTEND_REF"
|
||||
|
||||
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
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -3,7 +3,7 @@
|
||||
## Обзор проекта
|
||||
|
||||
Проект представляет собой систему управления университетским расписанием.
|
||||
- **Backend**: Java 17, Spring Boot 3.2.5 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
||||
- **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
||||
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
|
||||
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
|
||||
- **Локальный URL**: localhost:80
|
||||
@@ -62,8 +62,14 @@ docker compose logs -f backend
|
||||
## Критические правила для агентов
|
||||
|
||||
### Flyway миграции
|
||||
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
|
||||
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
||||
- До отдельного решения владельца проект работает в режиме пересобираемого baseline:
|
||||
**все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
|
||||
только в `V1__init.sql`**.
|
||||
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
|
||||
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
|
||||
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
|
||||
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
|
||||
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
|
||||
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
||||
|
||||
### Языковые требования
|
||||
|
||||
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
@@ -63,18 +63,22 @@
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265`
|
||||
- `docs/README.md:45-54`
|
||||
- `backend/src/main/resources/db/migration/V2__test_data.sql`
|
||||
- `docs/README.md`
|
||||
|
||||
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
|
||||
**Проблема:** схема и данные разделены: V1 теперь не содержит доменных записей, однако
|
||||
`V2__test_data.sql` всё равно автоматически применяется общим Flyway location ко всем tenant-БД.
|
||||
V2 создаёт администратора с публично документированным коротким паролем, несколько известных
|
||||
demo-пользователей и большой демонстрационный набор. Пароли 150 массовых преподавателей случайны
|
||||
и непубличны, но это не устраняет риск доступного администратора и загрязнения production-БД.
|
||||
|
||||
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
|
||||
|
||||
**Как исправить:**
|
||||
|
||||
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
|
||||
- Исключать `V2__test_data.sql` из production Flyway locations либо включать её только явным
|
||||
dev/test-профилем.
|
||||
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
|
||||
- Разнести demo/dev seed и обязательную production-схему.
|
||||
- Ротировать пароли уже созданных tenant-БД.
|
||||
|
||||
## Высокий приоритет
|
||||
|
||||
292
DEVOPS.md
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 и удаление БД я не выполняю без отдельного явного разрешения: это меняет
|
||||
внешнюю систему и может прервать работу либо уничтожить данные.
|
||||
676
UX_IMPROVEMENT_PLAN.md
Normal file
676
UX_IMPROVEMENT_PLAN.md
Normal file
@@ -0,0 +1,676 @@
|
||||
# План улучшения пользовательского взаимодействия Magistr
|
||||
|
||||
## 1. Назначение документа
|
||||
|
||||
Этот документ описывает целевое пользовательское взаимодействие для всех ролей Magistr и поэтапный план его улучшения. Главная цель — сделать систему удобной не только на демонстрационных данных, но и в реальной работе университета: при сотнях групп и преподавателей, десятках календарных графиков, большом аудиторном фонде, нескольких версиях расписания и постоянном потоке изменений.
|
||||
|
||||
План составлен по текущей реализации frontend, ролевой матрице, API и бизнес-логике проекта. Это экспертный UX-аудит кода и документации, а не замена наблюдению за реальными пользователями. Перед крупными изменениями гипотезы следует проверить на сотрудниках учебного отдела, кафедр, преподавателях и студентах.
|
||||
|
||||
## 2. Краткий вывод
|
||||
|
||||
В Magistr уже реализована значительная часть предметной логики: ролевой доступ, календарные графики, конструктор и версии расписания, анализ качества, разовые изменения, отсутствия, пожелания преподавателей, нагрузка и отдельные кабинеты конечных пользователей.
|
||||
|
||||
Основная UX-проблема — функции организованы преимущественно как набор отдельных форм, таблиц и справочников. Живой пользователь мыслит не сущностями базы данных, а задачами:
|
||||
|
||||
- «подготовить новый учебный год»;
|
||||
- «проверить готовность данных кафедр»;
|
||||
- «составить и опубликовать расписание»;
|
||||
- «найти замену отсутствующему преподавателю»;
|
||||
- «быстро понять, где у меня следующая пара»;
|
||||
- «узнать, приняли ли мою заявку».
|
||||
|
||||
При росте данных длинные селекты, таблицы без серверной пагинации, ручная настройка каждой группы и графика, а также потеря контекста между разделами станут главным ограничением. Поэтому приоритет — не визуальный редизайн сам по себе, а переход к интерфейсу задач, очередей, массовых операций и устойчивого рабочего контекста.
|
||||
|
||||
## 3. Принципы целевого интерфейса
|
||||
|
||||
1. **Сначала текущая задача, затем справочник.** Главный экран роли должен отвечать на вопрос «что требует моего внимания сегодня?».
|
||||
2. **Контекст показывается только там, где влияет на результат.** Учебный год, семестр и версия выбираются каскадом внутри профильной вкладки и сохраняются при возврате; общая панель не занимает место в остальных разделах.
|
||||
3. **Поиск вместо прокрутки.** Любая коллекция более 20–30 элементов должна иметь поиск, фильтры, сортировку и понятный счётчик результатов.
|
||||
4. **Массовые действия — обязательны.** Повторяющиеся операции над группами, графиками, дисциплинами и заявками нельзя заставлять выполнять по одной записи.
|
||||
5. **Безопасность изменений.** До сохранения пользователь видит последствия, конфликты и область действия; после сохранения — подтверждение, журнал и возможность отмены там, где это допустимо.
|
||||
6. **Прогрессивное раскрытие.** Редкие и сложные параметры скрываются до необходимости, а основной сценарий остаётся коротким.
|
||||
7. **Одинаковые паттерны во всех кабинетах.** Поиск, фильтры, статусы, пустые состояния, подтверждения, боковые панели и сообщения работают одинаково.
|
||||
8. **Мобильный интерфейс ориентирован на просмотр и оперативные действия.** Большие редакторы могут оставаться desktop-first, но расписание, заявки, статусы и замены должны быть удобны с телефона.
|
||||
9. **Доступность является критерием готовности.** Полная клавиатурная навигация, видимый фокус, корректные подписи, контраст, понятные ошибки и отсутствие зависимости только от цвета.
|
||||
|
||||
## 4. Текущее состояние и основные риски
|
||||
|
||||
### Что уже сделано хорошо
|
||||
|
||||
- Роли направляются в подходящие кабинеты, а недоступные разделы скрываются.
|
||||
- Учебный отдел и администратор могут пройти путь от правил до версии, анализа качества и публикации.
|
||||
- Для точечных изменений есть сравнение «было / станет» и журнал изменений.
|
||||
- Кафедра имеет собственную рабочую область, а преподаватель — пожелания, отсутствия и заявки.
|
||||
- Студенческое и преподавательское расписание имеют недельную навигацию и сохраняют часть локальных настроек.
|
||||
- У календарного графика есть диапазонное заполнение, а не только изменение каждой даты по отдельности.
|
||||
- Система использует архивирование и сохраняет исторический контекст.
|
||||
|
||||
### Критические UX-риски при больших объёмах
|
||||
|
||||
| Риск | Как проявится у пользователя | Последствие |
|
||||
|---|---|---|
|
||||
| Полная загрузка коллекций | Долгое открытие разделов групп, пользователей, дисциплин и расписания | Ощущение зависания, лишний трафик, тяжёлый DOM |
|
||||
| Длинные селекты | Поиск нужной группы, преподавателя или графика среди сотен элементов | Ошибочный выбор и потеря времени |
|
||||
| Нет системной пагинации и сортировки | Таблицы становятся длинными, позиция после действия теряется | Невозможность эффективно обрабатывать реестры |
|
||||
| Операции по одной записи | Назначение графиков, подготовка групп и привязок повторяется вручную | Большое число кликов и высокий риск пропусков |
|
||||
| Контекст хранится фрагментарно | Год, семестр, версия и фильтры приходится выбирать повторно | Ошибки работы «не в том семестре» или «не в той версии» |
|
||||
| Разделы отражают модель данных | Пользователь сам собирает бизнес-процесс из нескольких вкладок | Непонятно, что делать дальше и готов ли процесс |
|
||||
| Нативные `confirm` и `prompt` | Опасные действия имеют мало контекста, комментарии вводятся в системное окно | Слабая предсказуемость и доступность |
|
||||
| Нет общего центра уведомлений | Пользователь узнаёт о новых заявках и решениях только после открытия раздела | Задержка согласований и публикаций |
|
||||
| Нет общей модели несохранённых изменений | Переход может привести к потере сложной настройки | Повторная работа и недоверие к системе |
|
||||
| Ограниченный экспорт и обмен | Расписание и отчёты сложно передать вне системы | Возврат к скриншотам и ручным таблицам |
|
||||
|
||||
## 5. Пользовательские сценарии по ролям
|
||||
|
||||
### 5.1. Администратор
|
||||
|
||||
#### Реальные цели
|
||||
|
||||
- подготовить структуру и справочники университета;
|
||||
- создать и сопровождать пользователей;
|
||||
- контролировать качество исходных данных;
|
||||
- одобрять заявки кафедр на преподавателей;
|
||||
- помогать устранять ошибки доступа и конфигурации;
|
||||
- видеть состояние системы, а не вручную обходить все разделы.
|
||||
|
||||
#### Типичный сценарий сейчас
|
||||
|
||||
Администратор входит на дашборд, затем отдельно открывает пользователей, структуру вуза, группы, дисциплины, аудитории, календарные графики и заявки. Формы создания постоянно видимы над реестрами, даже если большую часть времени пользователь только ищет или проверяет записи.
|
||||
|
||||
#### Целевой сценарий
|
||||
|
||||
1. На главной странице администратор видит очередь внимания: ожидающие заявки, неполные данные, архивные зависимости, ошибки проверки и недавние действия.
|
||||
2. Выбирает учебный год внутри мастера подготовки периода.
|
||||
3. Открывает мастер «Подготовить учебный год» с шагами и прогрессом.
|
||||
4. Исправляет только отмеченные проблемы через глубокие ссылки на конкретную запись.
|
||||
5. Выполняет массовое создание или импорт, получает предварительный просмотр и отчёт по строкам.
|
||||
6. После завершения видит статус готовности по каждому блоку.
|
||||
|
||||
#### Улучшения
|
||||
|
||||
- заменить постоянные формы создания кнопкой «Добавить», открывающей боковую панель;
|
||||
- добавить глобальный поиск по пользователям, группам, кафедрам, дисциплинам и аудиториям;
|
||||
- добавить фильтры по статусу, роли, кафедре и периоду действия;
|
||||
- показывать историю записи и зависимые объекты до архивирования;
|
||||
- добавить импорт пользователей, групп, аудиторий и дисциплин с dry-run-проверкой;
|
||||
- ввести мастер первоначальной настройки тенанта и нового учебного года;
|
||||
- показывать «здоровье данных»: группы без графика, дисциплины без преподавателей, аудитории без вместимости/оборудования, незакрытые заявки.
|
||||
|
||||
### 5.2. Учебный отдел
|
||||
|
||||
#### Реальные цели
|
||||
|
||||
- подготовить календарную и временную основу семестра;
|
||||
- убедиться, что кафедры предоставили данные;
|
||||
- составить расписание без конфликтов;
|
||||
- сравнить, проверить и опубликовать версию;
|
||||
- быстро обрабатывать отсутствия, переносы и замены;
|
||||
- находить свободные аудитории и понимать последствия изменений.
|
||||
|
||||
#### Целевой основной путь «Составить и опубликовать расписание»
|
||||
|
||||
1. Выбрать учебный год, семестр и версию в локальной панели конструктора расписания.
|
||||
2. Открыть чек-лист готовности: календарные графики назначены, дисциплины заполнены, преподаватели привязаны, пожелания рассмотрены, временная сетка настроена.
|
||||
3. Создать черновик из опубликованной версии или с нуля.
|
||||
4. Работать в конструкторе с поиском, пакетным добавлением и видимой матрицей ресурсов.
|
||||
5. Получать конфликт сразу в месте редактирования, с предложенными вариантами решения.
|
||||
6. Запустить анализ качества, перейти из проблемы прямо к правилу, вернуться с сохранёнными фильтрами.
|
||||
7. Сравнить версию, проверить число изменений и затронутых групп/преподавателей.
|
||||
8. Опубликовать с причиной и получить подтверждение доставки изменений конечным пользователям.
|
||||
|
||||
#### Целевой оперативный путь «Изменение в течение семестра»
|
||||
|
||||
1. Открыть единую очередь инцидентов: отсутствия, заявки преподавателей, конфликты и ручные изменения.
|
||||
2. Увидеть приоритет, срок, автора и затронутые занятия.
|
||||
3. Выбрать предложенную замену или найти ресурс через контекстный поиск.
|
||||
4. До подтверждения увидеть «было / станет», новые конфликты и список уведомляемых людей.
|
||||
5. Применить решение пакетом и получить запись в журнале.
|
||||
|
||||
#### Улучшения
|
||||
|
||||
- объединить версии, качество и конструктор общим пошаговым контуром, не удаляя отдельные экспертные экраны;
|
||||
- добавить глобальную панель «Учебный год · Семестр · Версия»;
|
||||
- ввести автосохранение черновика или явный индикатор «Сохранено / Есть изменения / Ошибка»;
|
||||
- добавить undo для локально обратимых действий и журнал последних операций;
|
||||
- сделать массовое назначение правил и слотов нескольким группам;
|
||||
- добавить боковую инспекцию группы, преподавателя и аудитории без ухода со страницы;
|
||||
- показывать конфликты и пожелания непосредственно в визуальной сетке;
|
||||
- добавить командную палитру или быстрый поиск для опытных диспетчеров;
|
||||
- сохранить представления фильтров: «1 курс ИТ», «вечерние аудитории корпуса Б», «кафедра ВТ».
|
||||
|
||||
### 5.3. Кафедра
|
||||
|
||||
#### Реальные цели
|
||||
|
||||
- поддерживать список дисциплин и преподавателей своей кафедры;
|
||||
- назначать преподавателей на дисциплины;
|
||||
- передавать учебному отделу корректные исходные данные;
|
||||
- контролировать нагрузку;
|
||||
- согласовывать пожелания и отсутствия;
|
||||
- отслеживать результат заявок.
|
||||
|
||||
#### Проблема текущей компоновки
|
||||
|
||||
В одной рабочей области одновременно находятся период, импорт дисциплин, добавление преподавателя, заявка на преподавателя, нагрузка, заявки, дисциплины и комментарии. При повседневной работе это создаёт длинную страницу и смешивает редкие настройки с ежедневными задачами.
|
||||
|
||||
#### Целевой сценарий
|
||||
|
||||
1. Главная кафедры показывает готовность к семестру и очередь действий.
|
||||
2. Раздел «Подготовка семестра» группирует дисциплины, преподавателей, привязки и пожелания.
|
||||
3. Раздел «Нагрузка» показывает отклонения, недогруз и перегруз, а не только список метрик.
|
||||
4. Раздел «Заявки» объединяет создание преподавателя, отсутствия и изменения занятий со статусами и сроками.
|
||||
5. После отправки данных кафедра видит, что принято, отклонено или требует уточнения.
|
||||
|
||||
#### Улучшения
|
||||
|
||||
- разделить рабочую область на «Обзор», «Дисциплины», «Преподаватели и нагрузка», «Заявки»;
|
||||
- добавить поиск и фильтры по дисциплинам и преподавателям;
|
||||
- поддержать вставку строк из Excel/буфера и загрузку файла вместо ручного добавления строк импорта;
|
||||
- добавить массовую привязку преподавателей к дисциплинам;
|
||||
- показывать полноту данных по каждой дисциплине;
|
||||
- отображать нагрузку с нормой, отклонением и объяснением расчёта;
|
||||
- дать кафедре читаемый timeline заявки и явное следующее действие;
|
||||
- уведомлять о решениях учебного отдела и администратора.
|
||||
|
||||
### 5.4. Пользователь просмотра расписаний
|
||||
|
||||
#### Реальные цели
|
||||
|
||||
- быстро найти расписание группы, преподавателя, аудитории или кафедры;
|
||||
- переключаться между несколькими результатами;
|
||||
- распечатать, экспортировать или отправить ссылку;
|
||||
- понимать, актуально ли расписание и есть ли разовые изменения.
|
||||
|
||||
#### Улучшения
|
||||
|
||||
- единая поисковая строка с типизированными результатами: «группа», «преподаватель», «аудитория»;
|
||||
- недавние и избранные расписания;
|
||||
- URL, содержащий выбранный объект, семестр, дату и режим представления;
|
||||
- кнопки «Скопировать ссылку», «Печать», «PDF», «ICS»;
|
||||
- явная отметка «Опубликовано …», легенда замен и отмен;
|
||||
- режим сравнения двух расписаний для поиска общего свободного времени;
|
||||
- сохранение фильтров после перезагрузки и возврата на страницу.
|
||||
|
||||
### 5.5. Преподаватель
|
||||
|
||||
#### Реальные цели
|
||||
|
||||
- за несколько секунд увидеть ближайшую пару и аудиторию;
|
||||
- понять, что изменилось с последнего просмотра;
|
||||
- сообщить об отсутствии;
|
||||
- задать доступность и пожелания до составления расписания;
|
||||
- запросить перенос, аудиторию или отмену и отследить решение.
|
||||
|
||||
#### Целевой сценарий
|
||||
|
||||
1. После входа преподаватель видит «Сегодня» и карточку следующего занятия.
|
||||
2. Изменённые, перенесённые и отменённые пары выделены и объяснены текстом.
|
||||
3. Из карточки занятия доступны контекстные действия: маршрут до аудитории, заявка на изменение, добавление в календарь.
|
||||
4. Заявка создаётся с уже заполненными данными занятия; система сразу показывает допустимые даты, пары и аудитории.
|
||||
5. Статус заявки меняется в понятном timeline и сопровождается уведомлением.
|
||||
|
||||
#### Улучшения
|
||||
|
||||
- сделать «Сегодня / Неделя / Семестр» тремя явными режимами;
|
||||
- добавить карточку следующего занятия и блок недавних изменений;
|
||||
- показывать корпус, этаж и оборудование аудитории;
|
||||
- сохранить черновик длинной заявки при случайном закрытии;
|
||||
- объяснять разницу между строгой недоступностью и мягким пожеланием до выбора режима;
|
||||
- добавить массовое заполнение предпочтений: копирование дня, диапазон пар, типовая неделя;
|
||||
- показывать дедлайн подачи пожеланий и статус согласования;
|
||||
- добавить экспорт в личный календарь и подписку ICS.
|
||||
|
||||
### 5.6. Студент
|
||||
|
||||
#### Реальные цели
|
||||
|
||||
- один раз выбрать свою группу;
|
||||
- быстро увидеть занятия сегодня и завтра;
|
||||
- узнать об отмене, переносе или смене аудитории;
|
||||
- перейти к нужной неделе без знания чётности;
|
||||
- поделиться расписанием или добавить его в календарь.
|
||||
|
||||
#### Проблема масштаба
|
||||
|
||||
Выбор группы обычным списком неудобен при сотнях групп. Пользователь не должен каждый раз разбираться в полном университетском справочнике.
|
||||
|
||||
#### Улучшения
|
||||
|
||||
- первый запуск: поиск группы по названию с подсказками по специальности, курсу и кафедре;
|
||||
- после выбора сделать группу постоянным профилем, а смену вынести в отдельное действие;
|
||||
- стартовый режим «Сегодня», ниже — «Завтра» и ближайшее изменение;
|
||||
- добавить быстрый переход «Эта неделя / Следующая неделя»;
|
||||
- показывать статус обновления расписания и понятную легенду изменений;
|
||||
- добавить shareable URL, печать, PDF и ICS;
|
||||
- предусмотреть кеш последнего расписания для слабой сети;
|
||||
- на мобильном устройстве использовать карточки дней и sticky-навигацию, а не уменьшенную широкую таблицу.
|
||||
|
||||
## 6. Сквозные процессы между ролями
|
||||
|
||||
### 6.1. Подготовка семестра
|
||||
|
||||
```text
|
||||
Администратор настраивает структуру и справочники
|
||||
↓
|
||||
Кафедра заполняет дисциплины, преподавателей и пожелания
|
||||
↓
|
||||
Учебный отдел проверяет готовность календарей и данных
|
||||
↓
|
||||
Учебный отдел создаёт и проверяет черновик
|
||||
↓
|
||||
Публикация → преподаватели, студенты и просмотр расписаний
|
||||
```
|
||||
|
||||
Для процесса нужен единый статус готовности с владельцем каждой проблемы. Пользователь должен видеть не просто «ошибка», а «что не готово → кто исправляет → куда перейти».
|
||||
|
||||
### 6.2. Отсутствие и замена
|
||||
|
||||
```text
|
||||
Преподаватель сообщает об отсутствии
|
||||
↓
|
||||
Кафедра подтверждает факт
|
||||
↓
|
||||
Учебный отдел выбирает решения по затронутым занятиям
|
||||
↓
|
||||
Расписание изменяется, решение попадает в аудит
|
||||
↓
|
||||
Преподаватель и затронутые группы получают уведомление
|
||||
```
|
||||
|
||||
Все роли должны видеть один и тот же номер инцидента, единые статусы и хронологию, но разные доступные действия.
|
||||
|
||||
### 6.3. Заявка на изменение занятия
|
||||
|
||||
Нужна единая модель статусов: `Черновик → Отправлена → Проверяется → Требует уточнения / Одобрена / Отклонена → Применена`. Технические статусы API можно сохранить, но UI должен переводить их в понятный язык и показывать следующее действие.
|
||||
|
||||
## 7. Масштабирование интерфейса
|
||||
|
||||
### 7.1. Реестры
|
||||
|
||||
Для групп, пользователей, аудиторий, дисциплин, заявок и правил необходимо:
|
||||
|
||||
- серверная пагинация или cursor pagination;
|
||||
- серверные поиск, фильтрация и сортировка;
|
||||
- размер страницы 25/50/100;
|
||||
- строка поиска с задержкой 250–400 мс и отменой предыдущего запроса;
|
||||
- sticky-заголовок таблицы;
|
||||
- сохранение фильтров в URL;
|
||||
- выбор строк и массовая панель действий;
|
||||
- счётчик «Показано N из M»;
|
||||
- настраиваемые столбцы для сложных реестров;
|
||||
- виртуализация только там, где пагинация мешает задаче, например в большой визуальной матрице.
|
||||
|
||||
### 7.2. Поиск сущностей
|
||||
|
||||
Селекты с большими наборами следует заменить асинхронным комбобоксом:
|
||||
|
||||
- поиск начинается после 2 символов;
|
||||
- результат показывает различающие атрибуты;
|
||||
- группы: название, курс, специальность, форма, кафедра;
|
||||
- преподаватели: ФИО, должность, кафедры;
|
||||
- аудитории: название, корпус, этаж, вместимость, оборудование;
|
||||
- графики: учебный год, специальность, профиль, форма, число курсов;
|
||||
- выбранное значение остаётся видимым чипом;
|
||||
- клавиатура и экранный диктор поддерживаются полностью.
|
||||
|
||||
### 7.3. Календарные графики
|
||||
|
||||
При десятках похожих графиков требуются:
|
||||
|
||||
- пользовательское название или короткий alias графика;
|
||||
- поиск и фасетные фильтры по году, специальности, профилю и форме;
|
||||
- клонирование графика из прошлого года с предварительным просмотром сдвига дат;
|
||||
- массовое создание на основе шаблона;
|
||||
- массовое назначение выбранным совместимым группам;
|
||||
- индикатор использования: сколько групп назначено;
|
||||
- сравнение двух графиков и подсветка различий;
|
||||
- защита от потери несохранённой сетки;
|
||||
- автосохранение черновика сетки локально или на сервере;
|
||||
- undo/redo для заполнения диапазона и изменения ячеек;
|
||||
- проверка до сохранения с переходом к ошибочной дате;
|
||||
- закреплённые заголовки курса, месяца и легенды при прокрутке.
|
||||
|
||||
### 7.4. Конструктор расписания
|
||||
|
||||
- master-detail-компоновка: слева фильтруемый список правил, справа редактор выбранного правила;
|
||||
- создание из шаблона и дублирование правила;
|
||||
- массовое назначение групп, преподавателя, слотов и диапазонов недель;
|
||||
- фильтры по заполненности, конфликтам, кафедре, курсу и дисциплине;
|
||||
- визуальная сетка загружает только выбранный набор групп;
|
||||
- конфликт объясняется человеческим языком и содержит ссылку на конфликтующее правило;
|
||||
- перед удалением показывается количество затронутых занятий;
|
||||
- публикация заблокирована до прохождения обязательных проверок.
|
||||
|
||||
## 8. Целевая информационная архитектура
|
||||
|
||||
### Администратор
|
||||
|
||||
- Обзор и задачи
|
||||
- Подготовка учебного года
|
||||
- Пользователи и доступ
|
||||
- Структура и справочники
|
||||
- Расписание
|
||||
- Оперативные изменения
|
||||
- Отчёты и аудит
|
||||
- Настройки
|
||||
|
||||
### Учебный отдел
|
||||
|
||||
- Обзор и очередь
|
||||
- Подготовка семестра
|
||||
- Конструктор расписания
|
||||
- Проверка и публикация
|
||||
- Просмотр расписаний
|
||||
- Изменения и замены
|
||||
- Ресурсы и загруженность
|
||||
|
||||
### Кафедра
|
||||
|
||||
- Обзор
|
||||
- Подготовка семестра
|
||||
- Дисциплины
|
||||
- Преподаватели и нагрузка
|
||||
- Расписание кафедры
|
||||
- Заявки и согласования
|
||||
|
||||
Названия меню должны описывать рабочие задачи. Технические сущности остаются внутри экранов и документации, но не обязаны быть верхним уровнем навигации.
|
||||
|
||||
## 9. Общие компоненты и паттерны
|
||||
|
||||
Необходимо сформировать единый набор компонентов для Vanilla JS:
|
||||
|
||||
- асинхронный combobox;
|
||||
- панель фильтров с чипами активных условий и кнопкой «Сбросить»;
|
||||
- таблица с серверной пагинацией, сортировкой, выбором строк и массовыми действиями;
|
||||
- боковая панель просмотра/редактирования;
|
||||
- диалог подтверждения с описанием последствий;
|
||||
- toast для краткого результата и inline-сообщение для ошибки поля/раздела;
|
||||
- skeleton-состояние вместо скачка пустой таблицы;
|
||||
- стандартизированные empty/error/permission states;
|
||||
- status badge с текстом и иконкой;
|
||||
- breadcrumb или ссылка «Назад к результатам» с восстановлением позиции;
|
||||
- индикатор сохранения и защита несохранённых изменений;
|
||||
- timeline для заявок и аудита;
|
||||
- центр уведомлений и счётчики очередей.
|
||||
|
||||
Системные `confirm()` и `prompt()` следует постепенно заменить доступными проектными диалогами. Сообщение об успешном действии должно называть объект и результат: «График назначен 18 группам», а не просто «Успешно».
|
||||
|
||||
## 10. Состояния интерфейса
|
||||
|
||||
Каждый рабочий экран должен проектироваться минимум для следующих состояний:
|
||||
|
||||
1. первоначальная загрузка;
|
||||
2. данные загружены;
|
||||
3. пустой справочник;
|
||||
4. фильтр не дал результатов;
|
||||
5. частичная ошибка;
|
||||
6. полная ошибка с повтором;
|
||||
7. нет прав;
|
||||
8. объект изменён другим пользователем;
|
||||
9. есть несохранённые изменения;
|
||||
10. действие выполняется;
|
||||
11. действие успешно завершено;
|
||||
12. действие завершено частично, например при массовом импорте.
|
||||
|
||||
Для конкурентного редактирования версий, календарей и правил желательно использовать optimistic locking и понятный диалог: кто и когда изменил объект, можно ли обновить данные или сохранить копию.
|
||||
|
||||
## 11. Уведомления
|
||||
|
||||
Минимально необходимы уведомления внутри приложения:
|
||||
|
||||
- новая заявка для ответственной роли;
|
||||
- решение по заявке;
|
||||
- приближение дедлайна пожеланий;
|
||||
- публикация новой версии;
|
||||
- перенос, отмена или смена аудитории;
|
||||
- конфликт или ошибка массовой операции.
|
||||
|
||||
Уведомление должно вести к конкретному объекту, иметь статус прочтения и не раскрывать данные другой кафедры. Email/web-push можно добавить позднее, используя те же события.
|
||||
|
||||
## 12. Доступность и адаптивность
|
||||
|
||||
- обеспечить WCAG 2.2 AA как целевой уровень;
|
||||
- проверить контраст обеих тем;
|
||||
- добавить `aria-sort` таблицам и объявление количества результатов;
|
||||
- реализовать корректные combobox/listbox-роли для поиска;
|
||||
- возвращать фокус после закрытия модального окна;
|
||||
- закрывать диалоги по `Escape`, удерживать фокус внутри открытого диалога;
|
||||
- не использовать цвет как единственный признак конфликта, статуса или чётности;
|
||||
- увеличить интерактивные зоны минимум до 44×44 px на мобильных экранах;
|
||||
- для расписаний на телефоне показывать один день/список занятий, а не горизонтально сжатую таблицу;
|
||||
- уважать `prefers-reduced-motion`;
|
||||
- сделать печатные стили для расписаний и отчётов.
|
||||
|
||||
## 13. Приоритетный roadmap
|
||||
|
||||
### Этап 0. Проверка гипотез и базовые метрики — 1–2 недели
|
||||
|
||||
- провести 5–7 интервью с учебным отделом, 3–5 с кафедрами, 5 с преподавателями и 5 со студентами;
|
||||
- наблюдать выполнение реальных задач без подсказок;
|
||||
- замерить время, число кликов, ошибки и точки возврата;
|
||||
- зафиксировать объёмы: группы, графики, правила, преподаватели, аудитории, заявки;
|
||||
- построить карту основных процессов и согласовать владельцев статусов.
|
||||
|
||||
**Результат:** подтверждённый список top-10 проблем и исходные значения метрик.
|
||||
|
||||
### Этап 1. Быстрые улучшения frontend — 2–4 недели
|
||||
|
||||
- сохранять в URL и восстанавливать фильтры, вкладки, семестр и выбранный объект;
|
||||
- добавить поиск, сортировку и sticky-заголовки в текущие таблицы;
|
||||
- заменить опасные `confirm/prompt` проектными диалогами;
|
||||
- унифицировать загрузку, пустые состояния и ошибки;
|
||||
- добавить защиту несохранённых изменений в календарь и конструктор;
|
||||
- улучшить первый выбор группы студента;
|
||||
- добавить режим «Сегодня» преподавателю и студенту;
|
||||
- добавить ссылки «Скопировать» и печатные стили просмотра расписания.
|
||||
|
||||
**Результат:** заметное сокращение ежедневного трения без крупной переделки backend.
|
||||
|
||||
### Этап 2. Масштабные реестры и поиск — 4–8 недель
|
||||
|
||||
- добавить API пагинации, поиска, сортировки и фасетных фильтров;
|
||||
- внедрить общий компонент серверной таблицы;
|
||||
- внедрить асинхронный combobox;
|
||||
- добавить массовые операции для групп, графиков и привязок;
|
||||
- добавить предварительный просмотр и отчёт массовых операций;
|
||||
- обеспечить стабильные deep links на сущности.
|
||||
|
||||
**Результат:** интерфейс остаётся работоспособным на полном объёме данных университета.
|
||||
|
||||
### Этап 3. Интерфейс сквозных задач — 6–10 недель
|
||||
|
||||
- локальные каскады учебного года, семестра и версии в профильных вкладках;
|
||||
- чек-лист готовности семестра;
|
||||
- ролевые дашборды с очередями действий;
|
||||
- единая очередь инцидентов и согласований;
|
||||
- timeline заявок;
|
||||
- связанный путь «конструктор → качество → сравнение → публикация»;
|
||||
- центр уведомлений.
|
||||
|
||||
**Результат:** пользователь проходит бизнес-процесс, не собирая его вручную из вкладок.
|
||||
|
||||
### Этап 4. Продвинутые инструменты — после стабилизации
|
||||
|
||||
- шаблоны и клонирование календарных графиков;
|
||||
- импорт из Excel с сопоставлением колонок;
|
||||
- сравнение расписаний и поиск общего свободного времени;
|
||||
- экспорт PDF/Excel/ICS и календарная подписка;
|
||||
- оптимистические блокировки и совместное редактирование;
|
||||
- командная палитра и горячие клавиши для опытных пользователей;
|
||||
- персональные сохранённые представления.
|
||||
|
||||
## 14. Приоритеты по модели RICE без числовой фиксации
|
||||
|
||||
| Инициатива | Охват | Влияние | Сложность | Приоритет |
|
||||
|---|---|---|---|---|
|
||||
| Серверный поиск и пагинация | Все внутренние роли | Очень высокое | Высокая | P0 |
|
||||
| Локальные каскады года/семестра/версии | Администратор, учебный отдел, кафедра | Очень высокое | Средняя | P0 |
|
||||
| Защита несохранённых изменений | Учебный отдел | Очень высокое | Средняя | P0 |
|
||||
| Асинхронные комбобоксы | Все роли | Высокое | Средняя | P0 |
|
||||
| URL-состояние и глубокие ссылки | Все роли | Высокое | Низкая/средняя | P0 |
|
||||
| Массовое назначение графиков группам | Администратор, учебный отдел | Очень высокое | Средняя/высокая | P0 |
|
||||
| Ролевые очереди задач | Внутренние роли | Высокое | Средняя | P1 |
|
||||
| Единый контур публикации | Учебный отдел | Высокое | Средняя | P1 |
|
||||
| Уведомления | Все роли | Высокое | Высокая | P1 |
|
||||
| Импорт с предварительной проверкой | Администратор, кафедра | Высокое | Высокая | P1 |
|
||||
| «Сегодня» и ближайшая пара | Преподаватель, студент | Высокое | Низкая | P1 |
|
||||
| PDF/ICS/ссылки | Просмотр, преподаватель, студент | Среднее | Средняя | P2 |
|
||||
| Командная палитра | Опытные внутренние пользователи | Среднее | Средняя | P2 |
|
||||
|
||||
## 15. Изменения backend, необходимые для полноценного UX
|
||||
|
||||
Frontend-улучшений недостаточно для больших объёмов. Понадобятся:
|
||||
|
||||
- единый контракт пагинации и сортировки для реестров;
|
||||
- полнотекстовый или индексированный поиск по нормализованным полям;
|
||||
- batch-endpoints с атомарным и частичным режимами;
|
||||
- dry-run для импорта, назначения и публикации;
|
||||
- endpoint готовности семестра с кодами проблем и deep-link metadata;
|
||||
- endpoint глобального поиска в рамках прав пользователя;
|
||||
- события и хранилище уведомлений;
|
||||
- optimistic locking/version field для редактируемых сущностей;
|
||||
- агрегированные endpoints для дашбордов, чтобы не собирать их множеством клиентских запросов;
|
||||
- экспортные задания для тяжёлых PDF/Excel;
|
||||
- аудит массовых операций с автором, причиной и набором затронутых объектов.
|
||||
|
||||
Все контракты должны сохранять мультитенантную и ролевую изоляцию. Сервер обязан повторно применять ограничения роли независимо от фильтров frontend.
|
||||
|
||||
## 16. UX-метрики
|
||||
|
||||
### Операционные
|
||||
|
||||
- медианное время создания и назначения календарного графика;
|
||||
- время поиска конкретной группы, преподавателя и аудитории;
|
||||
- время от регистрации отсутствия до применения всех решений;
|
||||
- время от создания черновика до публикации;
|
||||
- число ручных действий на 10 групп или 10 правил;
|
||||
- процент массовых операций с частичными ошибками.
|
||||
|
||||
### Качественные
|
||||
|
||||
- success rate ключевого сценария без помощи;
|
||||
- число возвратов и повторных сохранений;
|
||||
- доля ошибок «не тот семестр / не та версия / не та кафедра»;
|
||||
- доля пользователей, понимающих следующий шаг;
|
||||
- SUS или UMUX-Lite по каждой роли;
|
||||
- CSAT после обработки заявки или публикации.
|
||||
|
||||
### Технические UX-метрики
|
||||
|
||||
- p75 времени появления полезного содержимого;
|
||||
- p75 ответа поиска;
|
||||
- размер DOM больших таблиц и сеток;
|
||||
- частота ошибок API по экрану и действию;
|
||||
- доля прерванных загрузок и повторных запросов;
|
||||
- Core Web Vitals для публичных/конечных кабинетов.
|
||||
|
||||
Целевые значения следует зафиксировать после этапа 0. Без исходных измерений произвольные проценты улучшения будут недостоверны.
|
||||
|
||||
## 17. Критерии готовности ключевых улучшений
|
||||
|
||||
### Реестр больших данных готов, если
|
||||
|
||||
- 10 000 записей не загружаются целиком в браузер;
|
||||
- поиск возвращает результат за приемлемое для проекта целевое время;
|
||||
- фильтры и страница восстанавливаются после возврата;
|
||||
- массовая операция показывает область действия до подтверждения;
|
||||
- частичные ошибки перечислены по объектам и доступны для выгрузки;
|
||||
- управление возможно с клавиатуры.
|
||||
|
||||
### Календарный график готов, если
|
||||
|
||||
- пользователь находит нужный график без просмотра полного списка;
|
||||
- может создать его из шаблона и назначить нескольким совместимым группам;
|
||||
- видит несохранённые изменения;
|
||||
- может отменить последнее редактирование;
|
||||
- ошибка указывает точный курс и дату;
|
||||
- уход со страницы не приводит к молчаливой потере работы.
|
||||
|
||||
### Публикация готова, если
|
||||
|
||||
- текущий семестр и версия видны постоянно;
|
||||
- до публикации показаны ошибки, предупреждения и diff;
|
||||
- указано число затронутых групп, преподавателей и занятий;
|
||||
- причина обязательна и попадает в аудит;
|
||||
- пользователь получает однозначное подтверждение результата;
|
||||
- конечные роли видят актуальность и изменения.
|
||||
|
||||
## 18. План пользовательского тестирования
|
||||
|
||||
Для каждого прототипа проводить короткие сценарные тесты:
|
||||
|
||||
| Роль | Проверочное задание |
|
||||
|---|---|
|
||||
| Администратор | Создать 20 групп импортом, исправить ошибки и назначить графики |
|
||||
| Учебный отдел | Создать черновик, устранить конфликт, проверить качество и опубликовать |
|
||||
| Учебный отдел | Обработать отсутствие с несколькими затронутыми занятиями |
|
||||
| Кафедра | Добавить дисциплины и преподавателей, проверить готовность семестра |
|
||||
| Просмотр | Найти расписание аудитории и отправить ссылку коллеге |
|
||||
| Преподаватель | Указать недоступность и подать заявку на перенос |
|
||||
| Студент | Выбрать группу и найти аудиторию следующей пары |
|
||||
|
||||
Фиксировать время, ошибки, вопросы пользователя, неверные ожидания и субъективную уверенность. Тестировать минимум на реальных объёмах, а не на 3–5 элементах.
|
||||
|
||||
## 19. Что не следует делать первым
|
||||
|
||||
- полностью менять визуальный стиль без исправления поиска, масштаба и процессов;
|
||||
- объединять все роли в один универсальный экран;
|
||||
- скрывать сложность только дополнительными модальными окнами;
|
||||
- переносить всю фильтрацию на клиент;
|
||||
- добавлять массовые действия без preview, отчёта и аудита;
|
||||
- полагаться только на цвет для статусов;
|
||||
- автоматически применять пожелания преподавателей без явного решения ответственной роли;
|
||||
- внедрять drag-and-drop как единственный способ редактирования расписания.
|
||||
|
||||
## 20. Рекомендуемый первый пакет реализации
|
||||
|
||||
Первый практический пакет должен дать максимальный эффект без полной перестройки системы:
|
||||
|
||||
1. локальные каскады «Учебный год / Семестр / Версия» только в профильных вкладках;
|
||||
2. URL-состояние для вкладок и фильтров;
|
||||
3. асинхронный поиск групп, преподавателей, аудиторий и графиков;
|
||||
4. серверная пагинация для групп, пользователей, дисциплин и заявок;
|
||||
5. массовое назначение календарного графика выбранным группам с dry-run;
|
||||
6. предупреждение о несохранённых изменениях календаря и конструктора;
|
||||
7. проектные диалоги вместо `confirm/prompt`;
|
||||
8. «Сегодня» и «Следующая пара» в кабинетах преподавателя и студента;
|
||||
9. единые состояния загрузки, отсутствия данных и ошибки;
|
||||
10. базовые события аналитики для измерения результата.
|
||||
|
||||
После этого пакета следует переходить к чек-листу готовности семестра, ролевым очередям и единому процессу публикации.
|
||||
|
||||
### 20.1. Статус реализации первого пакета — 19 августа 2026
|
||||
|
||||
| № | Статус | Что реализовано |
|
||||
|---|---|---|
|
||||
| 1 | Выполнено, решение уточнено | Общая панель контекста удалена. В `schedule`, `schedule-versions` и `schedule-quality` используются локальные каскады учебного года, семестра и версии; в `schedule-view` — года и семестра, поскольку экран показывает только опубликованное расписание. |
|
||||
| 2 | Выполнено | Вкладки, фильтры, сортировка, размер и номер страницы сохраняются в query string. Просмотр расписания сохраняет локальные год и семестр; преподаватель — вкладку и неделю, студент — группу и неделю. |
|
||||
| 3 | Выполнено | Добавлены серверные endpoints и общий `AsyncCombobox` для групп, преподавателей, аудиторий и совместимых календарных графиков. Первый выбор группы студента и выбор преподавателя при регистрации отсутствия больше не загружают полный справочник. |
|
||||
| 4 | Выполнено | Группы, пользователи, дисциплины и заявки преподавателей используют серверные поиск, фильтры, сортировку и пагинацию 25/50/100 с общим `PageResponse`. Для страницы дисциплин загружаются только её привязки преподавателей, а подгруппы запрашиваются только для открытой или выбранной в конструкторе группы. |
|
||||
| 5 | Отложено по решению заказчика | Массовое назначение одного календарного графика нескольким совместимым группам и dry-run в этот пакет не входят. Текущее одиночное назначение сохранено. |
|
||||
| 6 | Выполнено | Календарная сетка, привязки дисциплин и конструктор правил отслеживают несохранённые изменения, показывают индикатор на кнопке и предупреждают при уходе, смене графика или версии. |
|
||||
| 7 | Выполнено | Нативные `confirm`, `prompt` и `alert` в административных и settings-модулях заменены единым доступным проектным диалогом. |
|
||||
| 8 | Выполнено | В кабинетах преподавателя и студента появились блок «Сегодня» и карточка «Следующая пара» с поиском занятия в горизонте 14 дней. |
|
||||
| 9 | Выполнено | Для серверных реестров внедрены общие loading/empty/error-состояния с повтором; для ближайших занятий применяется единый компонент состояния. |
|
||||
| 10 | Выполнено | Добавлены прикладные UX-события: смена раздела, загрузка реестра/расписания, выбор группы, сохранение правила, сетки и привязок. В production события преобразуются в OpenTelemetry spans. |
|
||||
|
||||
Дополнительно закрыты три согласованных требования масштабирования: серверные
|
||||
поиск/фильтрация/сортировка/пагинация реестров, асинхронные селекты больших справочников и
|
||||
восстановление фильтров/страницы из URL. Ролевое ограничение кафедры повторно применяется
|
||||
backend и не зависит от переданного frontend-фильтра.
|
||||
|
||||
Проверка реализации: `npm run check` выполняет синтаксический контроль новых модулей и все
|
||||
18 frontend-наборов; отдельный тест закрепляет выбор сегодняшних занятий, текущей и следующей
|
||||
пары. Production-сборка frontend и 339 backend-тестов через полный `mvn test` в Java 17
|
||||
контейнере проходят успешно. Массовое назначение из пункта 5 остаётся единственным
|
||||
сознательно незавершённым пунктом этого пакета.
|
||||
|
||||
## 21. Источники анализа в проекте
|
||||
|
||||
- `docs/BUSINESS_LOGIC.md` — роли и бизнес-процессы;
|
||||
- `docs/FRONTEND.md` — маршруты и текущее поведение экранов;
|
||||
- `docs/API.md` — возможности и ограничения API;
|
||||
- `frontend/admin/js/role-capabilities.js` — фактическая матрица вкладок;
|
||||
- `frontend/admin/views/` и `frontend/admin/js/views/` — административные сценарии;
|
||||
- `frontend/teacher/` — кабинет преподавателя;
|
||||
- `frontend/student/` — кабинет студента.
|
||||
55
UX_SITE_CHECKLIST.md
Normal file
55
UX_SITE_CHECKLIST.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Чек-лист проверки UX-изменений на сайте
|
||||
|
||||
## Перед проверкой
|
||||
|
||||
1. Запустите проект: `docker compose up -d --build`.
|
||||
2. Откройте [http://localhost:80](http://localhost:80) или production-сайт.
|
||||
3. Для полной проверки нужны пользователи с ролями `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `TEACHER` и `STUDENT`.
|
||||
4. Пагинацию можно полноценно проверить, если в справочнике больше 25 записей.
|
||||
|
||||
## Что проверить
|
||||
|
||||
| № | Роль и куда зайти | Что нажать | Что должно быть видно |
|
||||
|---|---|---|---|
|
||||
| 1 | `ADMIN` → **Дашборд**, **Группы обучения** или **Пользователи** | Просто откройте вкладки | Под заголовком нет общей панели **КОНТЕКСТ**; она не занимает место в разделах, которым учебный период не нужен |
|
||||
| 2 | `ADMIN` → **Группы обучения** | Введите текст в **Поиск**, выберите форму обучения и сортировку, затем обновите страницу | Фильтры остаются выбранными, а в адресной строке появляются параметры `groupQuery`, `groupEducationForm`, `groupSort`, `groupPage` |
|
||||
| 3 | `ADMIN` → **Группы обучения**, **Пользователи**, **Дисциплины**, **Заявки** | Используйте поиск, фильтры, сортировку, **Назад / Далее** и список **На странице: 25 / 50 / 100** | Таблица обновляется без полной загрузки всех записей; показан диапазон вида «Показано 1–25 из …» |
|
||||
| 4 | `ADMIN` или `EDUCATION_OFFICE` → **Просмотр расписаний** | Откройте вкладку, не нажимая на поле **Группа**; затем раскройте **Дополнительные фильтры**, выберите **Учебный год** и **Семестр** и нажмите **Показать** | При открытии список групп свёрнут; семестры ограничиваются выбранным годом, значения сохраняются в URL; экран показывает опубликованное расписание |
|
||||
| 5 | `ADMIN` → **Группы обучения** → у группы кнопка **Календарь** | Выберите учебный год и начните печатать название графика | Совместимые календарные графики ищутся асинхронно; весь справочник заранее не загружается. Сохранять назначение для проверки не обязательно |
|
||||
| 6 | `ADMIN` или `EDUCATION_OFFICE` → **Конструктор правил** | В верхней карточке по очереди выберите **Учебный год**, **Семестр**, **Версию расписания**; затем раскройте **Группы** и найдите преподавателя или аудиторию | Каждый селект ограничивает следующий; загружаются версии только выбранного семестра, а большие справочники ищутся через поисковые списки |
|
||||
| 7 | `ADMIN` или `EDUCATION_OFFICE` → **Запросы преподавателей** → блок **Зарегистрировать отсутствие** | Начните вводить ФИО преподавателя из любой кафедры | Поиск находит преподавателя, а список результатов отображается поверх блока **Реестр инцидентов**, не прячется за ним |
|
||||
| 8 | `ADMIN` → **Календарный график** → **Сетки** | Выберите график → **Загрузить сетку** → измените ячейку или примените **Заполнить диапазон** → нажмите другую вкладку слева | Кнопка меняется на **Сохранить сетку •**, затем появляется диалог **Несохранённые изменения** с действиями **Остаться** и **Покинуть без сохранения** |
|
||||
| 9 | `ADMIN` → **Конструктор правил** | Сначала сразу перейдите в другой раздел, затем вернитесь, измените любое поле правила и снова попробуйте уйти | Без изменений переход выполняется без предупреждения; после ручного изменения появляется диалог **Несохранённые изменения** |
|
||||
| 10 | `ADMIN` → **Группы обучения** | У активной группы нажмите **Архив**, затем в диалоге нажмите **Отмена** | Появляется проектный диалог **Архивировать группу?**, а не системное окно браузера; данные после отмены не меняются |
|
||||
| 11 | `TEACHER` → **Расписание** | Просто откройте кабинет; затем нажмите **Сегодня** | Над недельным расписанием видны карточки **Сегодня** и **Следующая пара**; кнопка возвращает к текущей неделе |
|
||||
| 12 | `STUDENT` → страница расписания | Начните вводить название группы, выберите её и нажмите **Сегодня** | Группа ищется асинхронно; появляются карточки **Сегодня** и **Следующая пара**; выбранная группа сохраняется после перезагрузки |
|
||||
| 13 | `TEACHER` → **Мои пожелания** или **Заявки на изменение** | Переключите вкладку и обновите страницу | Открытая вкладка восстанавливается из URL; выбранная неделя также сохраняется |
|
||||
| 14 | Любой серверный реестр, например `ADMIN` → **Дисциплины** | Введите заведомо отсутствующую строку, например `___нет_такой_записи___` | Появляется понятное пустое состояние **Дисциплины не найдены**, а не пустая таблица |
|
||||
| 15 | `ADMIN` или `EDUCATION_OFFICE` → **Версии расписания**, затем **Анализ качества** | На обеих вкладках смените **Учебный год** и **Семестр**; в анализе дополнительно выберите **Версию** | В «Версиях» обновляются карточки публикации/черновиков выбранного семестра, в «Анализе качества» — список доступных версий |
|
||||
|
||||
## Дополнительные проверки
|
||||
|
||||
### Ошибка и повторная загрузка
|
||||
|
||||
1. Откройте DevTools → **Network** → включите **Offline**.
|
||||
2. Обновите реестр групп, пользователей, дисциплин или заявок.
|
||||
3. Должно появиться сообщение об ошибке и кнопка **Повторить**.
|
||||
4. Верните **Online** и нажмите **Повторить** — данные должны загрузиться.
|
||||
|
||||
### UX-аналитика
|
||||
|
||||
До выполнения действий вставьте в консоль браузера:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('magistr:ux-event', event => console.log(event.detail));
|
||||
```
|
||||
|
||||
После смены вкладки, фильтра или загрузки расписания в консоли должны появляться UX-события. На production они также преобразуются в OpenTelemetry spans.
|
||||
|
||||
### Ролевое ограничение кафедры
|
||||
|
||||
Войдите как `DEPARTMENT` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий.
|
||||
|
||||
## Что пока не проверяем
|
||||
|
||||
Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**.
|
||||
@@ -16,7 +16,10 @@ RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \
|
||||
|
||||
FROM eclipse-temurin:17-jre-alpine@sha256:02320dd4ce20e243dfb915c686089cf9315c763084fafbb12d5c9993aee18b57
|
||||
|
||||
# Best practice: run as a non-root user
|
||||
# Устанавливаем исправления безопасности, опубликованные после сборки базового образа.
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Запускаем приложение от непривилегированного пользователя.
|
||||
RUN addgroup -S spring && adduser -S spring -G spring
|
||||
USER spring:spring
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<version>3.5.16</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<postgresql.version>42.7.12</postgresql.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -44,6 +45,10 @@
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,16 +7,21 @@ import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicCalendarGridService;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@@ -25,11 +30,7 @@ import java.util.stream.Collectors;
|
||||
public class AcademicCalendarController {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository profileRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicCalendarGridService calendarGridService;
|
||||
@@ -37,22 +38,14 @@ public class AcademicCalendarController {
|
||||
private final AcademicStructureService academicStructureService;
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicCalendarGridService calendarGridService,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.calendarPeriodRepository = calendarPeriodRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.calendarGridService = calendarGridService;
|
||||
@@ -69,6 +62,46 @@ public class AcademicCalendarController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AcademicCalendarDto> getCalendarsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long academicYearId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return PageResponse.from(calendarRepository.searchOptions(
|
||||
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
|
||||
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
|
||||
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
|
||||
.map(this::toCalendarDto));
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
public List<AcademicCalendarDto> searchCalendarOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long academicYearId,
|
||||
@RequestParam(required = false) Long specialtyId,
|
||||
@RequestParam(required = false) Long profileId,
|
||||
@RequestParam(required = false) Long studyFormId,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
List<AcademicCalendarDto> options = new ArrayList<>(calendarRepository.searchOptions(
|
||||
normalizedQuery,
|
||||
academicYearId,
|
||||
specialtyId,
|
||||
profileId,
|
||||
studyFormId,
|
||||
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("title").ascending())
|
||||
).stream()
|
||||
.map(this::toCalendarDto)
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.id()))) {
|
||||
calendarRepository.findByIdWithDetails(selectedId).map(this::toCalendarDto).ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
|
||||
return calendarRepository.findByIdWithDetails(id)
|
||||
@@ -79,10 +112,7 @@ public class AcademicCalendarController {
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
|
||||
try {
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
applyCalendar(calendar, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
|
||||
return ResponseEntity.ok(toCalendarDto(academicStructureService.createCalendar(request)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
@@ -108,8 +138,10 @@ public class AcademicCalendarController {
|
||||
if (!calendarRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream()
|
||||
.map(this::toGridDayDto)
|
||||
return ResponseEntity.ok(calendarPeriodRepository.findByCalendarIdWithActivity(id).stream()
|
||||
.flatMap(period -> period.getStartDate()
|
||||
.datesUntil(period.getEndDate().plusDays(1))
|
||||
.map(date -> toGridDayDto(period, date)))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -149,41 +181,6 @@ public class AcademicCalendarController {
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные календарного графика");
|
||||
}
|
||||
if (request.courseCount() == null || request.courseCount() <= 0) {
|
||||
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
||||
}
|
||||
if (request.courseCount() > 8) {
|
||||
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
|
||||
}
|
||||
if (request.academicYearId() == null || request.specialtyId() == null
|
||||
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
|
||||
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
|
||||
}
|
||||
|
||||
AcademicYear year = academicYearRepository.findById(request.academicYearId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
||||
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
|
||||
SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден"));
|
||||
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
throw new IllegalArgumentException("Профиль не относится к выбранной специальности");
|
||||
}
|
||||
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
|
||||
|
||||
calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm));
|
||||
calendar.setAcademicYear(year);
|
||||
calendar.setSpeciality(speciality);
|
||||
calendar.setSpecialtyProfile(profile);
|
||||
calendar.setStudyForm(studyForm);
|
||||
calendar.setCourseCount(request.courseCount());
|
||||
}
|
||||
|
||||
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
|
||||
if (rows.size() > 1000) {
|
||||
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
|
||||
@@ -254,15 +251,19 @@ public class AcademicCalendarController {
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) {
|
||||
AcademicCalendarActivityType activityType = day.getActivityType();
|
||||
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarPeriod period, LocalDate date) {
|
||||
AcademicCalendarActivityType activityType = period.getActivityType();
|
||||
AcademicYear academicYear = period.getAcademicCalendar().getAcademicYear();
|
||||
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
|
||||
int daysBeforeYearStartInFirstWeek = academicYear.getStartDate().getDayOfWeek().getValue() - 1;
|
||||
int weekNumber = Math.toIntExact((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7 + 1);
|
||||
return new AcademicCalendarGridDayDto(
|
||||
day.getId(),
|
||||
day.getAcademicCalendar().getId(),
|
||||
day.getCourseNumber(),
|
||||
day.getDate(),
|
||||
day.getWeekNumber(),
|
||||
day.getDayOfWeek(),
|
||||
period.getId(),
|
||||
period.getAcademicCalendar().getId(),
|
||||
period.getCourseNumber(),
|
||||
date,
|
||||
weekNumber,
|
||||
date.getDayOfWeek().getValue(),
|
||||
activityType.getId(),
|
||||
activityType.getCode(),
|
||||
activityType.getName(),
|
||||
|
||||
@@ -10,11 +10,15 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.EquipmentRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/classrooms")
|
||||
@@ -40,6 +44,26 @@ public class ClassroomController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<ClassroomResponse> searchClassroomOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
List<ClassroomResponse> options = new ArrayList<>(classroomRepository.searchActiveOptions(
|
||||
normalizedQuery,
|
||||
LifecycleEntity.STATUS_ARCHIVED,
|
||||
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("name").ascending())
|
||||
).stream()
|
||||
.map(this::mapToResponse)
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||
classroomRepository.findById(selectedId).map(this::mapToResponse).ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.AcademicCalendarSubject;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
@@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -98,6 +108,118 @@ public class GroupController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<GroupResponse> getGroupsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) List<Long> educationFormId,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) Integer yearStartStudy,
|
||||
@RequestParam(defaultValue = "true") boolean includeArchived,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "name") String sort,
|
||||
@RequestParam(defaultValue = "asc") String direction) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
Specification<StudentGroup> specification = groupSpecification(
|
||||
query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived
|
||||
);
|
||||
Page<StudentGroup> groups = groupRepository.findAll(
|
||||
specification,
|
||||
PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction))
|
||||
);
|
||||
return PageResponse.from(groups.map(this::mapToResponse));
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
public List<GroupResponse> searchGroupOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<GroupResponse> options = new ArrayList<>(groupRepository.findAll(
|
||||
groupSpecification(query, null, effectiveDepartmentId, null, false),
|
||||
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("name").ascending())
|
||||
).stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.map(this::mapToResponse)
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||
groupRepository.findById(selectedId)
|
||||
.filter(group -> effectiveDepartmentId == null
|
||||
|| Objects.equals(group.getDepartmentId(), effectiveDepartmentId))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.map(this::mapToResponse)
|
||||
.ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||
? currentUser.departmentId()
|
||||
: requestedDepartmentId;
|
||||
}
|
||||
|
||||
private Specification<StudentGroup> groupSpecification(String query,
|
||||
List<Long> educationFormIds,
|
||||
Long departmentId,
|
||||
Integer yearStartStudy,
|
||||
boolean includeArchived) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (!includeArchived) {
|
||||
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||
}
|
||||
if (educationFormIds != null && !educationFormIds.isEmpty()) {
|
||||
predicates.add(root.get("educationForm").get("id").in(educationFormIds));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
if (yearStartStudy != null) {
|
||||
predicates.add(builder.equal(root.get("yearStartStudy"), yearStartStudy));
|
||||
}
|
||||
String normalizedQuery = normalizeQuery(query);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("name")), pattern),
|
||||
builder.like(builder.lower(root.get("speciality").get("specialityCode")), pattern),
|
||||
builder.like(builder.lower(root.get("speciality").get("specialityName")), pattern),
|
||||
builder.like(builder.lower(root.get("specialtyProfile").get("name")), pattern),
|
||||
builder.like(builder.lower(root.get("educationForm").get("name")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort groupSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "yearStartStudy" -> "yearStartStudy";
|
||||
case "groupSize" -> "groupSize";
|
||||
case "status" -> "status";
|
||||
default -> "name";
|
||||
};
|
||||
return "desc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).descending()
|
||||
: Sort.by(property).ascending();
|
||||
}
|
||||
|
||||
private int normalizePage(int page) {
|
||||
return Math.max(page, 0);
|
||||
}
|
||||
|
||||
private int normalizeSize(int size) {
|
||||
return Math.max(1, Math.min(size, 100));
|
||||
}
|
||||
|
||||
private String normalizeQuery(String query) {
|
||||
return query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@GetMapping("/{departmentId}")
|
||||
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleRuleConflictException;
|
||||
import com.magistr.app.service.ScheduleRuleService;
|
||||
@@ -34,8 +35,18 @@ public class ScheduleRuleAdminController {
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
|
||||
@RequestParam(name = "groupId", required = false) Long groupId) {
|
||||
return scheduleRuleService.getAll(semesterId, groupId);
|
||||
@RequestParam(name = "groupId", required = false) Long groupId,
|
||||
@RequestParam(name = "versionId", required = false) Long versionId) {
|
||||
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<ScheduleRuleDto> getPage(
|
||||
@RequestParam Long versionId,
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return scheduleRuleService.getPage(versionId, query, page, size);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@@ -46,6 +57,7 @@ public class ScheduleRuleAdminController {
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
|
||||
try {
|
||||
requireScheduleVersion(request);
|
||||
return ResponseEntity.ok(scheduleRuleService.create(request));
|
||||
} catch (ScheduleRuleConflictException exception) {
|
||||
return conflictResponse(exception);
|
||||
@@ -55,6 +67,7 @@ public class ScheduleRuleAdminController {
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
|
||||
try {
|
||||
requireScheduleVersion(request);
|
||||
return ResponseEntity.ok(scheduleRuleService.update(id, request));
|
||||
} catch (ScheduleRuleConflictException exception) {
|
||||
return conflictResponse(exception);
|
||||
@@ -77,4 +90,10 @@ public class ScheduleRuleAdminController {
|
||||
body.put("conflictReasons", exception.getConflictReasons());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
|
||||
}
|
||||
|
||||
private void requireScheduleVersion(ScheduleRuleDto request) {
|
||||
if (request == null || request.scheduleVersionId() == null) {
|
||||
throw new IllegalArgumentException("Выберите версию расписания");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,13 @@ import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@RestController
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
|
||||
public class SubgroupController {
|
||||
@@ -36,8 +39,16 @@ public class SubgroupController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/subgroups")
|
||||
public List<SubgroupDto> getAll() {
|
||||
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
|
||||
public List<SubgroupDto> getAll(@RequestParam(required = false) List<Long> groupId) {
|
||||
if (groupId != null && groupId.size() > 100) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп");
|
||||
}
|
||||
List<Subgroup> selected = groupId == null
|
||||
? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()
|
||||
: groupId.isEmpty()
|
||||
? List.of()
|
||||
: subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList());
|
||||
return selected.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.dto.SubjectResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
@@ -11,9 +13,14 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -53,6 +60,74 @@ public class SubjectController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public PageResponse<SubjectResponse> getSubjectsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(defaultValue = "false") boolean includeArchived,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "name") String sort,
|
||||
@RequestParam(defaultValue = "asc") String direction) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
var subjects = subjectRepository.findAll(
|
||||
subjectSpecification(query, effectiveDepartmentId, includeArchived),
|
||||
PageRequest.of(
|
||||
Math.max(page, 0),
|
||||
Math.max(1, Math.min(size, 100)),
|
||||
subjectSort(sort, direction)
|
||||
)
|
||||
);
|
||||
return PageResponse.from(subjects.map(this::toResponse));
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||
? currentUser.departmentId()
|
||||
: requestedDepartmentId;
|
||||
}
|
||||
|
||||
private Specification<Subject> subjectSpecification(String query,
|
||||
Long departmentId,
|
||||
boolean includeArchived) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (!includeArchived) {
|
||||
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("name")), pattern),
|
||||
builder.like(builder.lower(root.get("code")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort subjectSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "code" -> "code";
|
||||
case "departmentId" -> "departmentId";
|
||||
case "status" -> "status";
|
||||
default -> "name";
|
||||
};
|
||||
return "desc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).descending()
|
||||
: Sort.by(property).ascending();
|
||||
}
|
||||
|
||||
private SubjectResponse toResponse(Subject subject) {
|
||||
return new SubjectResponse(subject.getId(), subject.getName(), subject.getCode(), subject.getDepartmentId());
|
||||
}
|
||||
|
||||
@GetMapping("/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ReviewTeacherCreationRequest;
|
||||
import com.magistr.app.dto.TeacherCreationRequestResponse;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
@@ -12,11 +13,16 @@ import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -55,6 +61,63 @@ public class TeacherCreationRequestController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<TeacherCreationRequestResponse> getRequestsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) TeacherCreationRequestStatus status,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "createdAt") String sort,
|
||||
@RequestParam(defaultValue = "desc") String direction) {
|
||||
var requests = teacherCreationRequestRepository.findAll(
|
||||
requestSpecification(query, status, departmentId),
|
||||
PageRequest.of(
|
||||
Math.max(page, 0),
|
||||
Math.max(1, Math.min(size, 100)),
|
||||
requestSort(sort, direction)
|
||||
)
|
||||
);
|
||||
return PageResponse.from(requests.map(this::toResponse));
|
||||
}
|
||||
|
||||
private Specification<TeacherCreationRequest> requestSpecification(String query,
|
||||
TeacherCreationRequestStatus status,
|
||||
Long departmentId) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (status != null) {
|
||||
predicates.add(builder.equal(root.get("status"), status));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("department").get("id"), departmentId));
|
||||
}
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("username")), pattern),
|
||||
builder.like(builder.lower(root.get("fullName")), pattern),
|
||||
builder.like(builder.lower(root.get("jobTitle")), pattern),
|
||||
builder.like(builder.lower(root.get("department").get("departmentName")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort requestSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "fullName" -> "fullName";
|
||||
case "username" -> "username";
|
||||
case "status" -> "status";
|
||||
default -> "createdAt";
|
||||
};
|
||||
return "asc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).ascending()
|
||||
: Sort.by(property).descending();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
@Transactional
|
||||
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,13 @@ import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/teacher-subjects")
|
||||
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||
@@ -53,15 +56,18 @@ public class TeacherSubjectController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TeacherSubjectResponse> getAll() {
|
||||
return teacherSubjectRepository.findAll().stream()
|
||||
public List<TeacherSubjectResponse> getAll(@RequestParam(required = false) List<Long> subjectId) {
|
||||
if (subjectId != null && subjectId.size() > 100) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин");
|
||||
}
|
||||
List<TeacherSubject> assignments = subjectId == null
|
||||
? teacherSubjectRepository.findAll()
|
||||
: subjectId.isEmpty()
|
||||
? List.of()
|
||||
: teacherSubjectRepository.findBySubjectIdInWithDetails(subjectId.stream().distinct().toList());
|
||||
return assignments.stream()
|
||||
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
|
||||
.map(ts -> new TeacherSubjectResponse(
|
||||
ts.getUserId(),
|
||||
ts.getUser().getUsername(),
|
||||
ts.getSubjectId(),
|
||||
ts.getSubject().getName()
|
||||
))
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -136,4 +142,14 @@ public class TeacherSubjectController {
|
||||
)
|
||||
&& departmentId.equals(subject.getDepartmentId());
|
||||
}
|
||||
|
||||
private TeacherSubjectResponse toResponse(TeacherSubject teacherSubject) {
|
||||
return new TeacherSubjectResponse(
|
||||
teacherSubject.getUserId(),
|
||||
teacherSubject.getUser().getUsername(),
|
||||
teacherSubject.getUser().getFullName(),
|
||||
teacherSubject.getSubjectId(),
|
||||
teacherSubject.getSubject().getName()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.magistr.app.dto.CreateUserRequest;
|
||||
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
@@ -22,11 +23,16 @@ import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -80,6 +86,23 @@ public class UserController {
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<UserResponse> getUsersPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Role role,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(defaultValue = "true") boolean includeArchived,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "fullName") String sort,
|
||||
@RequestParam(defaultValue = "asc") String direction) {
|
||||
var users = userRepository.findAll(
|
||||
userSpecification(query, role, departmentId, includeArchived),
|
||||
PageRequest.of(normalizePage(page), normalizeSize(size), userSort(sort, direction))
|
||||
);
|
||||
return PageResponse.from(users.map(this::toUserResponse));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<UserResponse> getTeachers() {
|
||||
@@ -91,6 +114,92 @@ public class UserController {
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/options")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<UserResponse> searchTeacherOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
Long effectiveDepartmentId = currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||
? currentUser.departmentId()
|
||||
: departmentId;
|
||||
LocalDate today = businessTime.today();
|
||||
List<UserResponse> options = new ArrayList<>(userRepository.searchTeacherOptions(
|
||||
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||
Role.TEACHER,
|
||||
LifecycleEntity.STATUS_ARCHIVED,
|
||||
effectiveDepartmentId,
|
||||
today,
|
||||
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("fullName").ascending())
|
||||
).stream()
|
||||
.map(user -> effectiveDepartmentId == null
|
||||
? toUserResponse(user)
|
||||
: toUserResponse(user, effectiveDepartmentId))
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||
userRepository.findById(selectedId)
|
||||
.filter(user -> user.getRole() == Role.TEACHER)
|
||||
.filter(user -> !user.isArchivedRecord())
|
||||
.filter(user -> effectiveDepartmentId == null
|
||||
|| teacherDepartmentService.hasAssignmentAtDate(user.getId(), effectiveDepartmentId, today))
|
||||
.map(user -> effectiveDepartmentId == null
|
||||
? toUserResponse(user)
|
||||
: toUserResponse(user, effectiveDepartmentId))
|
||||
.ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private Specification<User> userSpecification(String query,
|
||||
Role role,
|
||||
Long departmentId,
|
||||
boolean includeArchived) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (!includeArchived) {
|
||||
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||
}
|
||||
if (role != null) {
|
||||
predicates.add(builder.equal(root.get("role"), role));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("username")), pattern),
|
||||
builder.like(builder.lower(root.get("fullName")), pattern),
|
||||
builder.like(builder.lower(root.get("jobTitle")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort userSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "username" -> "username";
|
||||
case "role" -> "role";
|
||||
case "status" -> "status";
|
||||
default -> "fullName";
|
||||
};
|
||||
return "desc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).descending()
|
||||
: Sort.by(property).ascending();
|
||||
}
|
||||
|
||||
private int normalizePage(int page) {
|
||||
return Math.max(page, 0);
|
||||
}
|
||||
|
||||
private int normalizeSize(int size) {
|
||||
return Math.max(1, Math.min(size, 100));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||
|
||||
@@ -53,7 +53,7 @@ public class WorkloadController {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarizeTeacherWorkload(lessons, startDate, endDate);
|
||||
return summarizeTeacherWorkload(lessons, startDate, endDate, effectiveDepartmentId);
|
||||
}
|
||||
|
||||
@GetMapping("/classrooms")
|
||||
@@ -98,15 +98,19 @@ public class WorkloadController {
|
||||
|
||||
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
LocalDate endDate,
|
||||
Long scopedDepartmentId) {
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = scopedDepartmentId == null
|
||||
? assignmentsByTeacher(lessons, startDate, endDate)
|
||||
: Map.of();
|
||||
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
|
||||
.filter(lesson -> lesson.teacherId() != null)
|
||||
.collect(Collectors.groupingBy(
|
||||
lesson -> new TeacherDepartmentKey(
|
||||
lesson.teacherId(),
|
||||
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
|
||||
scopedDepartmentId != null
|
||||
? scopedDepartmentId
|
||||
: departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
|
||||
),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
|
||||
27
backend/src/main/java/com/magistr/app/dto/PageResponse.java
Normal file
27
backend/src/main/java/com/magistr/app/dto/PageResponse.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
long totalItems,
|
||||
int totalPages,
|
||||
boolean first,
|
||||
boolean last
|
||||
) {
|
||||
public static <T> PageResponse<T> from(Page<T> page) {
|
||||
return new PageResponse<>(
|
||||
page.getContent(),
|
||||
page.getNumber(),
|
||||
page.getSize(),
|
||||
page.getTotalElements(),
|
||||
page.getTotalPages(),
|
||||
page.isFirst(),
|
||||
page.isLast()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -4,12 +4,14 @@ public class TeacherSubjectResponse {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String fullName;
|
||||
private Long subjectId;
|
||||
private String subjectName;
|
||||
|
||||
public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
|
||||
public TeacherSubjectResponse(Long userId, String username, String fullName, Long subjectId, String subjectName) {
|
||||
this.userId = userId;
|
||||
this.username = username;
|
||||
this.fullName = fullName;
|
||||
this.subjectId = subjectId;
|
||||
this.subjectName = subjectName;
|
||||
}
|
||||
@@ -22,6 +24,10 @@ public class TeacherSubjectResponse {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public Long getSubjectId() {
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -3,14 +3,17 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long> {
|
||||
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long>, JpaSpecificationExecutor<AcademicCalendar> {
|
||||
|
||||
long countByStudyFormId(Long studyFormId);
|
||||
|
||||
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query(value = """
|
||||
select calendar
|
||||
from AcademicCalendar calendar
|
||||
join fetch calendar.academicYear academicYear
|
||||
join fetch calendar.speciality speciality
|
||||
join fetch calendar.specialtyProfile specialtyProfile
|
||||
join fetch calendar.studyForm studyForm
|
||||
where (:query = ''
|
||||
or lower(calendar.title) like concat('%', :query, '%')
|
||||
or lower(academicYear.title) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityCode) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityName) like concat('%', :query, '%')
|
||||
or lower(specialtyProfile.name) like concat('%', :query, '%')
|
||||
or lower(studyForm.name) like concat('%', :query, '%'))
|
||||
and (:academicYearId is null or academicYear.id = :academicYearId)
|
||||
and (:specialtyId is null or speciality.id = :specialtyId)
|
||||
and (:profileId is null or specialtyProfile.id = :profileId)
|
||||
and (:studyFormId is null or studyForm.id = :studyFormId)
|
||||
""",
|
||||
countQuery = """
|
||||
select count(calendar)
|
||||
from AcademicCalendar calendar
|
||||
join calendar.academicYear academicYear
|
||||
join calendar.speciality speciality
|
||||
join calendar.specialtyProfile specialtyProfile
|
||||
join calendar.studyForm studyForm
|
||||
where (:query = ''
|
||||
or lower(calendar.title) like concat('%', :query, '%')
|
||||
or lower(academicYear.title) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityCode) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityName) like concat('%', :query, '%')
|
||||
or lower(specialtyProfile.name) like concat('%', :query, '%')
|
||||
or lower(studyForm.name) like concat('%', :query, '%'))
|
||||
and (:academicYearId is null or academicYear.id = :academicYearId)
|
||||
and (:specialtyId is null or speciality.id = :specialtyId)
|
||||
and (:profileId is null or specialtyProfile.id = :profileId)
|
||||
and (:studyFormId is null or studyForm.id = :studyFormId)
|
||||
""")
|
||||
Page<AcademicCalendar> searchOptions(@Param("query") String query,
|
||||
@Param("academicYearId") Long academicYearId,
|
||||
@Param("specialtyId") Long specialtyId,
|
||||
@Param("profileId") Long profileId,
|
||||
@Param("studyFormId") Long studyFormId,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,29 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Classroom;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
|
||||
public interface ClassroomRepository extends JpaRepository<Classroom, Long>, JpaSpecificationExecutor<Classroom> {
|
||||
Optional<Classroom> findByName(String name);
|
||||
|
||||
List<Classroom> findByStatusNot(String status);
|
||||
|
||||
@Query("""
|
||||
select classroom
|
||||
from Classroom classroom
|
||||
where classroom.status <> :archivedStatus
|
||||
and (:query = ''
|
||||
or lower(classroom.name) like concat('%', :query, '%')
|
||||
or lower(coalesce(classroom.building, '')) like concat('%', :query, '%'))
|
||||
""")
|
||||
Page<Classroom> searchActiveOptions(@Param("query") String query,
|
||||
@Param("archivedStatus") String archivedStatus,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -10,7 +11,7 @@ import org.springframework.data.repository.query.Param;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long>, JpaSpecificationExecutor<StudentGroup> {
|
||||
|
||||
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -13,13 +15,32 @@ import java.util.Collection;
|
||||
|
||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||
|
||||
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
|
||||
@Query("""
|
||||
select r.id from ScheduleRule r
|
||||
join r.scheduleVersion version
|
||||
join r.subject subject
|
||||
where r.status <> 'ARCHIVED'
|
||||
and version.id = :versionId
|
||||
and (:query = '' or str(r.id) = :query
|
||||
or lower(subject.name) like concat('%', :query, '%')
|
||||
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
|
||||
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
|
||||
order by r.id desc
|
||||
""")
|
||||
Page<Long> searchPageIds(
|
||||
@Param("versionId") Long versionId, @Param("query") String query,
|
||||
Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups g
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.scheduleVersion
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
@@ -30,6 +51,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where g.id = :groupId
|
||||
and sem.id = :semesterId
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdAndSemesterId(
|
||||
@@ -41,6 +63,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -54,6 +77,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id = :semesterId
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterId(
|
||||
@@ -65,6 +89,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups matchingGroup
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -78,6 +103,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where matchingGroup.id in :groupIds
|
||||
and sem.id in :semesterIds
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdsAndSemesterIds(
|
||||
@@ -89,6 +115,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -102,6 +129,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id in :semesterIds
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterIds(
|
||||
@@ -112,6 +140,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -124,6 +153,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where r.status <> 'ARCHIVED'
|
||||
and version.status = 'PUBLISHED'
|
||||
order by r.id desc
|
||||
""")
|
||||
List<ScheduleRule> findAllWithDetails();
|
||||
@@ -156,12 +186,38 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
@Param("excludeRuleId") Long excludeRuleId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct rule
|
||||
from ScheduleRule rule
|
||||
left join fetch rule.subject
|
||||
left join fetch rule.semester semester
|
||||
left join fetch semester.academicYear
|
||||
left join fetch rule.scheduleVersion version
|
||||
left join fetch rule.groups groups
|
||||
left join fetch rule.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where version.id = :versionId
|
||||
and rule.status <> 'ARCHIVED'
|
||||
and (:excludeRuleId is null or rule.id <> :excludeRuleId)
|
||||
order by rule.id
|
||||
""")
|
||||
List<ScheduleRule> findActiveByVersionIdWithDetails(
|
||||
@Param("versionId") Long versionId,
|
||||
@Param("excludeRuleId") Long excludeRuleId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.scheduleVersion
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
@@ -173,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
where r.id = :id
|
||||
""")
|
||||
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.scheduleVersion
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where r.id in :ids
|
||||
""")
|
||||
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
""")
|
||||
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
|
||||
|
||||
@Query("""
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
join fetch subgroup.studentGroup studentGroup
|
||||
where studentGroup.id in :groupIds
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
order by studentGroup.name, subgroup.name
|
||||
""")
|
||||
List<Subgroup> findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List<Long> groupIds);
|
||||
|
||||
@Query("""
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
|
||||
@@ -2,11 +2,12 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Subject;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||
public interface SubjectRepository extends JpaRepository<Subject, Long>, JpaSpecificationExecutor<Subject> {
|
||||
Optional<Subject> findByNameIgnoreCase(String name);
|
||||
|
||||
List<Subject> findByDepartmentId(Long departmentId);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.TeacherCreationRequest;
|
||||
import com.magistr.app.model.TeacherCreationRequestStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
|
||||
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long>, JpaSpecificationExecutor<TeacherCreationRequest> {
|
||||
|
||||
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
|
||||
|
||||
|
||||
@@ -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,48 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.TeacherSubject;
|
||||
import com.magistr.app.model.TeacherSubjectId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject, TeacherSubjectId> {
|
||||
List<TeacherSubject> findByUserId(Long userId);
|
||||
List<TeacherSubject> findBySubjectId(Long subjectId);
|
||||
|
||||
@Query("""
|
||||
select teacherSubject
|
||||
from TeacherSubject teacherSubject
|
||||
join fetch teacherSubject.user
|
||||
join fetch teacherSubject.subject
|
||||
where teacherSubject.subjectId in :subjectIds
|
||||
order by teacherSubject.subject.name, teacherSubject.user.fullName
|
||||
""")
|
||||
List<TeacherSubject> findBySubjectIdInWithDetails(@Param("subjectIds") List<Long> subjectIds);
|
||||
|
||||
@Query(value = """
|
||||
select subject_link.user_id
|
||||
from teacher_subjects subject_link
|
||||
where subject_link.subject_id = :subjectId
|
||||
and (
|
||||
not exists (
|
||||
select 1
|
||||
from teacher_lesson_types configured_type
|
||||
where configured_type.user_id = subject_link.user_id
|
||||
and configured_type.subject_id = subject_link.subject_id
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from teacher_lesson_types supported_type
|
||||
where supported_type.user_id = subject_link.user_id
|
||||
and supported_type.subject_id = subject_link.subject_id
|
||||
and supported_type.lesson_type_id = :lessonTypeId
|
||||
)
|
||||
)
|
||||
order by subject_link.user_id
|
||||
""", nativeQuery = true)
|
||||
List<Long> findQualifiedTeacherIds(
|
||||
@Param("subjectId") Long subjectId,
|
||||
@Param("lessonTypeId") Long lessonTypeId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,18 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
@@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
|
||||
|
||||
@Query(value = """
|
||||
select user
|
||||
from User user
|
||||
where user.role = :role
|
||||
and user.status <> :archivedStatus
|
||||
and (:query = ''
|
||||
or lower(user.username) like concat('%', :query, '%')
|
||||
or lower(user.fullName) like concat('%', :query, '%')
|
||||
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
|
||||
and (:departmentId is null or exists (
|
||||
select assignment.id
|
||||
from TeacherDepartmentAssignment assignment
|
||||
where assignment.teacher.id = user.id
|
||||
and assignment.department.id = :departmentId
|
||||
and assignment.validFrom <= :date
|
||||
and (assignment.validTo is null or assignment.validTo >= :date)
|
||||
))
|
||||
""",
|
||||
countQuery = """
|
||||
select count(user)
|
||||
from User user
|
||||
where user.role = :role
|
||||
and user.status <> :archivedStatus
|
||||
and (:query = ''
|
||||
or lower(user.username) like concat('%', :query, '%')
|
||||
or lower(user.fullName) like concat('%', :query, '%')
|
||||
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
|
||||
and (:departmentId is null or exists (
|
||||
select assignment.id
|
||||
from TeacherDepartmentAssignment assignment
|
||||
where assignment.teacher.id = user.id
|
||||
and assignment.department.id = :departmentId
|
||||
and assignment.validFrom <= :date
|
||||
and (assignment.validTo is null or assignment.validTo >= :date)
|
||||
))
|
||||
""")
|
||||
Page<User> searchTeacherOptions(@Param("query") String query,
|
||||
@Param("role") Role role,
|
||||
@Param("archivedStatus") String archivedStatus,
|
||||
@Param("departmentId") Long departmentId,
|
||||
@Param("date") LocalDate date,
|
||||
Pageable pageable);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select user from User user where user.id = :id")
|
||||
Optional<User> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@@ -3,10 +3,10 @@ package com.magistr.app.service;
|
||||
import com.magistr.app.dto.AcademicCalendarGridDayDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarActivityType;
|
||||
import com.magistr.app.model.AcademicCalendarDay;
|
||||
import com.magistr.app.model.AcademicCalendarPeriod;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -16,25 +16,27 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AcademicCalendarGridService {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicCalendarGridService(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.calendarPeriodRepository = calendarPeriodRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
@@ -47,18 +49,18 @@ public class AcademicCalendarGridService {
|
||||
throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика");
|
||||
}
|
||||
|
||||
List<AcademicCalendarDay> replacement = buildReplacement(calendar, rows);
|
||||
List<AcademicCalendarPeriod> replacement = buildReplacement(calendar, rows);
|
||||
|
||||
calendarDayRepository.deleteByCalendarId(id);
|
||||
calendarDayRepository.flush();
|
||||
calendarDayRepository.saveAll(replacement);
|
||||
calendarDayRepository.flush();
|
||||
calendarPeriodRepository.deleteByCalendarId(id);
|
||||
calendarPeriodRepository.flush();
|
||||
calendarPeriodRepository.saveAll(replacement);
|
||||
calendarPeriodRepository.flush();
|
||||
clearScheduleCacheAfterCommit();
|
||||
}
|
||||
|
||||
private List<AcademicCalendarDay> buildReplacement(AcademicCalendar calendar,
|
||||
List<AcademicCalendarGridDayDto> rows) {
|
||||
List<AcademicCalendarDay> replacement = new ArrayList<>(rows.size());
|
||||
private List<AcademicCalendarPeriod> buildReplacement(AcademicCalendar calendar,
|
||||
List<AcademicCalendarGridDayDto> rows) {
|
||||
List<ValidatedGridDay> validatedDays = new ArrayList<>(rows.size());
|
||||
Set<GridBusinessKey> uniqueRows = new HashSet<>();
|
||||
|
||||
for (AcademicCalendarGridDayDto row : rows) {
|
||||
@@ -75,19 +77,42 @@ public class AcademicCalendarGridService {
|
||||
if (!uniqueRows.add(businessKey)) {
|
||||
throw new IllegalArgumentException("Для курса и даты можно указать только одну активность");
|
||||
}
|
||||
validatedDays.add(new ValidatedGridDay(
|
||||
row.courseNumber(),
|
||||
row.date(),
|
||||
resolveActivityType(row)
|
||||
));
|
||||
}
|
||||
|
||||
AcademicCalendarDay day = new AcademicCalendarDay();
|
||||
day.setAcademicCalendar(calendar);
|
||||
day.setCourseNumber(row.courseNumber());
|
||||
day.setDate(row.date());
|
||||
day.setWeekNumber(row.weekNumber());
|
||||
day.setDayOfWeek(row.dayOfWeek());
|
||||
day.setActivityType(resolveActivityType(row));
|
||||
replacement.add(day);
|
||||
validatedDays.sort(Comparator
|
||||
.comparing(ValidatedGridDay::courseNumber)
|
||||
.thenComparing(ValidatedGridDay::date));
|
||||
|
||||
List<AcademicCalendarPeriod> replacement = new ArrayList<>();
|
||||
AcademicCalendarPeriod currentPeriod = null;
|
||||
for (ValidatedGridDay day : validatedDays) {
|
||||
if (canExtend(currentPeriod, day)) {
|
||||
currentPeriod.setEndDate(day.date());
|
||||
continue;
|
||||
}
|
||||
currentPeriod = new AcademicCalendarPeriod();
|
||||
currentPeriod.setAcademicCalendar(calendar);
|
||||
currentPeriod.setCourseNumber(day.courseNumber());
|
||||
currentPeriod.setStartDate(day.date());
|
||||
currentPeriod.setEndDate(day.date());
|
||||
currentPeriod.setActivityType(day.activityType());
|
||||
replacement.add(currentPeriod);
|
||||
}
|
||||
return replacement;
|
||||
}
|
||||
|
||||
private boolean canExtend(AcademicCalendarPeriod period, ValidatedGridDay day) {
|
||||
return period != null
|
||||
&& period.getCourseNumber().equals(day.courseNumber())
|
||||
&& period.getEndDate().plusDays(1).equals(day.date())
|
||||
&& Objects.equals(period.getActivityType().getId(), day.activityType().getId());
|
||||
}
|
||||
|
||||
private void validateRequiredFields(AcademicCalendarGridDayDto row) {
|
||||
if (row.courseNumber() == null
|
||||
|| row.date() == null
|
||||
@@ -155,4 +180,9 @@ public class AcademicCalendarGridService {
|
||||
|
||||
private record GridBusinessKey(Integer courseNumber, LocalDate date) {
|
||||
}
|
||||
|
||||
private record ValidatedGridDay(Integer courseNumber,
|
||||
LocalDate date,
|
||||
AcademicCalendarActivityType activityType) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 @@ package com.magistr.app.service;
|
||||
import com.magistr.app.dto.AcademicYearDto;
|
||||
import com.magistr.app.dto.SemesterDto;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionHistory;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -12,73 +18,92 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AcademicPeriodService {
|
||||
|
||||
private static final String YEAR_CONFLICT_MESSAGE =
|
||||
"Название или период учебного года конфликтует с существующим учебным годом";
|
||||
"Учебный год или его период конфликтует с существующим учебным годом";
|
||||
private static final String SEMESTER_CONFLICT_MESSAGE =
|
||||
"Тип или период семестра конфликтует с существующим семестром учебного года";
|
||||
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final ScheduleVersionRepository scheduleVersionRepository;
|
||||
private final ScheduleVersionHistoryRepository scheduleVersionHistoryRepository;
|
||||
|
||||
public AcademicPeriodService(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
ScheduleVersionRepository scheduleVersionRepository,
|
||||
ScheduleVersionHistoryRepository scheduleVersionHistoryRepository) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.scheduleVersionRepository = scheduleVersionRepository;
|
||||
this.scheduleVersionHistoryRepository = scheduleVersionHistoryRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicYear createYear(AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
String title = request.title().trim();
|
||||
YearPeriod period = resolveYearPeriod(request);
|
||||
List<SemesterDto> semesterRequests = validateRequestedSemesters(
|
||||
request.semesters(), period.startDate(), period.endDate(), true
|
||||
);
|
||||
String title = academicYearTitle(period.startDate(), period.endDate());
|
||||
if (academicYearRepository.existsByTitle(title)
|
||||
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
|
||||
|| academicYearRepository.existsOverlapping(period.startDate(), period.endDate())) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, title, request.startDate(), request.endDate());
|
||||
applyYear(year, title, period.startDate(), period.endDate());
|
||||
AcademicYear saved = saveYear(year);
|
||||
for (SemesterDto semesterRequest : semesterRequests) {
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(saved);
|
||||
applySemester(semester, semesterRequest);
|
||||
Semester savedSemester = saveSemester(semester);
|
||||
createInitialPublishedSchedule(savedSemester);
|
||||
}
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicYear updateYear(Long id, AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
YearPeriod period = resolveYearPeriod(request);
|
||||
AcademicYear year = lockYear(id);
|
||||
String title = request.title().trim();
|
||||
String title = academicYearTitle(period.startDate(), period.endDate());
|
||||
|
||||
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|
||||
|| academicYearRepository.existsOverlappingExcluding(
|
||||
id, request.startDate(), request.endDate())) {
|
||||
id, period.startDate(), period.endDate())) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
boolean semesterOutsideNewRange = semesterRepository
|
||||
.findByAcademicYearIdOrderByStartDateAsc(id)
|
||||
.stream()
|
||||
.anyMatch(semester -> !contains(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
));
|
||||
if (semesterOutsideNewRange) {
|
||||
throw new IllegalArgumentException(
|
||||
"Новые границы учебного года не включают все его семестры"
|
||||
);
|
||||
}
|
||||
|
||||
applyYear(year, title, request.startDate(), request.endDate());
|
||||
List<Semester> existingSemesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(id);
|
||||
List<SemesterDto> requestedSemesters = request.semesters() == null
|
||||
? List.of()
|
||||
: request.semesters();
|
||||
List<SemesterDto> effectiveSemesterRequests = mergeSemesterRequests(
|
||||
existingSemesters, requestedSemesters, period.startDate(), period.endDate()
|
||||
);
|
||||
|
||||
applyYear(year, title, period.startDate(), period.endDate());
|
||||
AcademicYear saved = saveYear(year);
|
||||
synchronizeSemesters(saved, existingSemesters, effectiveSemesterRequests, requestedSemesters);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
@@ -102,6 +127,7 @@ public class AcademicPeriodService {
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
Semester saved = saveSemester(semester);
|
||||
createInitialPublishedSchedule(saved);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
@@ -126,19 +152,15 @@ public class AcademicPeriodService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
private void validateYear(AcademicYearDto request) {
|
||||
private YearPeriod resolveYearPeriod(AcademicYearDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные учебного года обязательны");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название учебного года обязательно");
|
||||
if (request.startDate() == null) {
|
||||
throw new IllegalArgumentException("Дата начала учебного года обязательна");
|
||||
}
|
||||
validateDateRange(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
"Даты начала и окончания учебного года обязательны",
|
||||
"Дата окончания учебного года не может быть раньше даты начала"
|
||||
);
|
||||
LocalDate endDate = LocalDate.of(request.startDate().getYear() + 1, 6, 30);
|
||||
return new YearPeriod(request.startDate(), endDate);
|
||||
}
|
||||
|
||||
private void validateSemester(SemesterDto request) {
|
||||
@@ -176,6 +198,110 @@ public class AcademicPeriodService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<SemesterDto> validateRequestedSemesters(List<SemesterDto> requests,
|
||||
LocalDate yearStart,
|
||||
LocalDate yearEnd,
|
||||
boolean required) {
|
||||
List<SemesterDto> semesters = requests == null ? List.of() : new ArrayList<>(requests);
|
||||
if (required && semesters.isEmpty()) {
|
||||
throw new IllegalArgumentException("Учебный год нельзя создать без семестров");
|
||||
}
|
||||
|
||||
Set<SemesterType> types = new HashSet<>();
|
||||
Set<Long> ids = new HashSet<>();
|
||||
for (SemesterDto semester : semesters) {
|
||||
validateSemester(semester);
|
||||
if (!types.add(semester.semesterType())) {
|
||||
throw new ScheduleConflictException("Типы семестров учебного года не должны повторяться");
|
||||
}
|
||||
if (semester.id() != null && !ids.add(semester.id())) {
|
||||
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
|
||||
}
|
||||
if (!contains(yearStart, yearEnd, semester.startDate(), semester.endDate())) {
|
||||
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
|
||||
}
|
||||
}
|
||||
|
||||
List<SemesterDto> sorted = new ArrayList<>(semesters);
|
||||
sorted.sort(Comparator.comparing(SemesterDto::startDate));
|
||||
for (int index = 1; index < sorted.size(); index++) {
|
||||
SemesterDto previous = sorted.get(index - 1);
|
||||
SemesterDto current = sorted.get(index);
|
||||
if (!previous.endDate().isBefore(current.startDate())) {
|
||||
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private List<SemesterDto> mergeSemesterRequests(List<Semester> existingSemesters,
|
||||
List<SemesterDto> requestedSemesters,
|
||||
LocalDate yearStart,
|
||||
LocalDate yearEnd) {
|
||||
Map<Long, Semester> existingById = new HashMap<>();
|
||||
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
|
||||
|
||||
Map<Long, SemesterDto> requestedById = new HashMap<>();
|
||||
List<SemesterDto> newRequests = new ArrayList<>();
|
||||
for (SemesterDto request : requestedSemesters) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные семестра обязательны");
|
||||
}
|
||||
if (request.id() == null) {
|
||||
newRequests.add(request);
|
||||
continue;
|
||||
}
|
||||
if (!existingById.containsKey(request.id())) {
|
||||
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
|
||||
}
|
||||
if (requestedById.put(request.id(), request) != null) {
|
||||
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
|
||||
}
|
||||
}
|
||||
|
||||
List<SemesterDto> effective = new ArrayList<>();
|
||||
for (Semester semester : existingSemesters) {
|
||||
effective.add(requestedById.getOrDefault(semester.getId(), toSemesterRequest(semester)));
|
||||
}
|
||||
effective.addAll(newRequests);
|
||||
return validateRequestedSemesters(effective, yearStart, yearEnd, true);
|
||||
}
|
||||
|
||||
private void synchronizeSemesters(AcademicYear year,
|
||||
List<Semester> existingSemesters,
|
||||
List<SemesterDto> effectiveRequests,
|
||||
List<SemesterDto> requestedSemesters) {
|
||||
if (effectiveRequests.isEmpty() || requestedSemesters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, Semester> existingById = new HashMap<>();
|
||||
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
|
||||
for (SemesterDto request : requestedSemesters) {
|
||||
Semester semester = request.id() == null ? new Semester() : existingById.get(request.id());
|
||||
if (semester == null) {
|
||||
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
|
||||
}
|
||||
boolean created = semester.getId() == null;
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
Semester saved = saveSemester(semester);
|
||||
if (created) {
|
||||
createInitialPublishedSchedule(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SemesterDto toSemesterRequest(Semester semester) {
|
||||
return new SemesterDto(
|
||||
semester.getId(),
|
||||
semester.getAcademicYear().getId(),
|
||||
semester.getAcademicYear().getTitle(),
|
||||
semester.getSemesterType(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean contains(LocalDate outerStart,
|
||||
LocalDate outerEnd,
|
||||
LocalDate innerStart,
|
||||
@@ -213,6 +339,10 @@ public class AcademicPeriodService {
|
||||
year.setEndDate(endDate);
|
||||
}
|
||||
|
||||
private String academicYearTitle(LocalDate startDate, LocalDate endDate) {
|
||||
return startDate.getYear() + "-" + endDate.getYear();
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
@@ -235,6 +365,28 @@ public class AcademicPeriodService {
|
||||
}
|
||||
}
|
||||
|
||||
private void createInitialPublishedSchedule(Semester semester) {
|
||||
Instant now = Instant.now();
|
||||
ScheduleVersion version = new ScheduleVersion();
|
||||
version.setSemester(semester);
|
||||
version.setVersionNumber(1);
|
||||
version.setName("Основное расписание");
|
||||
version.setStatus(ScheduleVersionStatus.PUBLISHED);
|
||||
version.setPublishedAt(now);
|
||||
ScheduleVersion saved = scheduleVersionRepository.saveAndFlush(version);
|
||||
|
||||
saveVersionHistory(saved, "CREATED", "Основное расписание создано вместе с семестром");
|
||||
saveVersionHistory(saved, "PUBLISHED", "Начальная публикация расписания");
|
||||
}
|
||||
|
||||
private void saveVersionHistory(ScheduleVersion version, String action, String reason) {
|
||||
ScheduleVersionHistory history = new ScheduleVersionHistory();
|
||||
history.setVersion(version);
|
||||
history.setAction(action);
|
||||
history.setReason(reason);
|
||||
scheduleVersionHistoryRepository.save(history);
|
||||
}
|
||||
|
||||
private void clearScheduleCacheAfterCommit() {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()
|
||||
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
@@ -248,4 +400,7 @@ public class AcademicPeriodService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record YearPeriod(LocalDate startDate, LocalDate endDate) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarActivityType;
|
||||
import com.magistr.app.model.AcademicCalendarPeriod;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
@@ -28,14 +31,18 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@Service
|
||||
public class AcademicStructureService {
|
||||
|
||||
private static final String THEORY_ACTIVITY_CODE = "Т";
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
@@ -47,7 +54,8 @@ public class AcademicStructureService {
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
@@ -58,7 +66,8 @@ public class AcademicStructureService {
|
||||
SubgroupRepository subgroupRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.calendarPeriodRepository = calendarPeriodRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
@@ -82,6 +91,41 @@ public class AcademicStructureService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicCalendar createCalendar(AcademicCalendarDto request) {
|
||||
validateCalendarRequest(request);
|
||||
CalendarDimensions dimensions = resolveCalendarDimensions(request);
|
||||
AcademicCalendarActivityType theoryActivity = activityTypeRepository.findByCode(THEORY_ACTIVITY_CODE)
|
||||
.filter(activity -> Boolean.TRUE.equals(activity.getAllowSchedule()))
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Код активности «Т», разрешающий обычные занятия, не найден"
|
||||
));
|
||||
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
calendar.setTitle(AcademicCalendarTitleBuilder.build(
|
||||
dimensions.academicYear(),
|
||||
dimensions.speciality(),
|
||||
dimensions.profile(),
|
||||
dimensions.studyForm()
|
||||
));
|
||||
applyCalendarDimensions(calendar, dimensions, request.courseCount());
|
||||
AcademicCalendar saved = saveCalendar(calendar);
|
||||
|
||||
List<AcademicCalendarPeriod> defaultPeriods = new ArrayList<>(request.courseCount());
|
||||
for (int courseNumber = 1; courseNumber <= request.courseCount(); courseNumber++) {
|
||||
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
|
||||
period.setAcademicCalendar(saved);
|
||||
period.setCourseNumber(courseNumber);
|
||||
period.setStartDate(dimensions.academicYear().getStartDate());
|
||||
period.setEndDate(dimensions.academicYear().getEndDate());
|
||||
period.setActivityType(theoryActivity);
|
||||
defaultPeriods.add(period);
|
||||
}
|
||||
calendarPeriodRepository.saveAll(defaultPeriods);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
|
||||
validateCalendarRequest(request);
|
||||
@@ -106,7 +150,7 @@ public class AcademicStructureService {
|
||||
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
if (calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
id, request.courseCount())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
|
||||
@@ -118,7 +162,7 @@ public class AcademicStructureService {
|
||||
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsOutsideDateRange(
|
||||
if (calendarPeriodRepository.existsOutsideDateRange(
|
||||
id,
|
||||
candidate.academicYear().getStartDate(),
|
||||
candidate.academicYear().getEndDate())) {
|
||||
|
||||
@@ -24,7 +24,7 @@ public class ScheduleGeneratorService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
|
||||
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
private static final long MAX_RANGE_DAYS = 120;
|
||||
private static final long MAX_RANGE_DAYS = 200;
|
||||
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
@@ -95,6 +95,30 @@ public class ScheduleGeneratorService {
|
||||
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForVersion(Long versionId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
if (versionId == null) {
|
||||
throw new IllegalArgumentException("Версия расписания обязательна");
|
||||
}
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findActiveByVersionIdWithDetails(versionId, null);
|
||||
List<StudentGroup> groups = rules.stream()
|
||||
.flatMap(rule -> rule.getGroups().stream())
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
StudentGroup::getId,
|
||||
group -> group,
|
||||
(first, ignored) -> first,
|
||||
LinkedHashMap::new
|
||||
))
|
||||
.values()
|
||||
.stream()
|
||||
.toList();
|
||||
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
|
||||
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
@@ -113,6 +137,24 @@ public class ScheduleGeneratorService {
|
||||
.map(StudentGroup::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
|
||||
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
List<ScheduleRule> rules,
|
||||
List<Semester> semesters) {
|
||||
if (groups.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (semesters.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> groupIds = groups.stream()
|
||||
.map(StudentGroup::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
|
||||
.createScheduleSnapshot(groups, semesters, endDate);
|
||||
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
|
||||
@@ -286,7 +328,7 @@ public class ScheduleGeneratorService {
|
||||
}
|
||||
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
||||
if (inclusiveDays > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class ScheduleOverrideService {
|
||||
|
||||
private static final long MAX_RANGE_DAYS = 200;
|
||||
|
||||
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
|
||||
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
|
||||
|
||||
@@ -56,6 +58,7 @@ public class ScheduleOverrideService {
|
||||
private final PostgreSqlAdvisoryLock advisoryLock;
|
||||
private final AcademicDateService academicDateService;
|
||||
private final EffectiveTimeSlotService effectiveTimeSlotService;
|
||||
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
||||
|
||||
@Autowired
|
||||
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
@@ -106,6 +109,11 @@ public class ScheduleOverrideService {
|
||||
);
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
|
||||
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
|
||||
if ((startDate == null) != (endDate == null)) {
|
||||
@@ -184,6 +192,43 @@ public class ScheduleOverrideService {
|
||||
return validateAndPersist(null, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет кандидат мастера замены тем же механизмом, что и обычное точечное изменение,
|
||||
* но не блокирует строки и не сохраняет результат.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public void validateCandidate(ScheduleOverrideDto request) {
|
||||
OverrideCommand command = validateAndNormalize(request);
|
||||
if (scheduleOverrideRepository
|
||||
.findByBaseRuleSlotIdAndLessonDate(command.baseRuleSlotId(), command.lessonDate())
|
||||
.isPresent()) {
|
||||
throw new ScheduleConflictException(
|
||||
"Для выбранной базовой пары и даты уже существует точечное изменение"
|
||||
);
|
||||
}
|
||||
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
|
||||
List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
|
||||
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
|
||||
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
|
||||
.toList();
|
||||
if (sourceOccurrences.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"На выбранную дату базовая пара не формируется по правилам расписания"
|
||||
);
|
||||
}
|
||||
ResolvedReferences references = resolveReferences(command, sourceOccurrences);
|
||||
validateTargetDate(command, references.baseRuleSlot(), sourceOccurrences);
|
||||
if (!"CANCEL".equals(command.action())) {
|
||||
validateEffectiveResult(
|
||||
null,
|
||||
command,
|
||||
buildCandidate(command, references),
|
||||
baseDay,
|
||||
sourceOccurrences
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
|
||||
OverrideCommand command = validateAndNormalize(request);
|
||||
@@ -294,6 +339,7 @@ public class ScheduleOverrideService {
|
||||
.map(EffectiveSourceTuple::from)
|
||||
.collect(Collectors.toSet());
|
||||
validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
|
||||
validateTeacherAvailability(candidate, candidateOccurrences);
|
||||
if (sourceTuples.equals(candidateTuples)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
|
||||
@@ -308,6 +354,27 @@ public class ScheduleOverrideService {
|
||||
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
|
||||
}
|
||||
|
||||
private void validateTeacherAvailability(
|
||||
ScheduleOverride candidate,
|
||||
List<RenderedLessonDto> candidateOccurrences
|
||||
) {
|
||||
if (teacherAvailabilityPolicyService == null
|
||||
|| candidate.getBaseRuleSlot() == null
|
||||
|| candidate.getBaseRuleSlot().getScheduleRule() == null
|
||||
|| candidate.getBaseRuleSlot().getScheduleRule().getSemester() == null) {
|
||||
return;
|
||||
}
|
||||
Long semesterId = candidate.getBaseRuleSlot().getScheduleRule().getSemester().getId();
|
||||
for (RenderedLessonDto occurrence : candidateOccurrences) {
|
||||
teacherAvailabilityPolicyService.assertLessonAvailable(
|
||||
occurrence.teacherId(),
|
||||
semesterId,
|
||||
occurrence.date(),
|
||||
occurrence.timeSlotId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) {
|
||||
return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
|
||||
&& (Objects.equals(lesson.originalLessonDate(), command.lessonDate())
|
||||
@@ -641,8 +708,8 @@ public class ScheduleOverrideService {
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
||||
}
|
||||
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) {
|
||||
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней");
|
||||
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 200 дней");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package com.magistr.app.service;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -17,32 +18,45 @@ import java.util.stream.Collectors;
|
||||
public class ScheduleQueryService {
|
||||
|
||||
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
|
||||
private static final long MAX_RANGE_DAYS = 120;
|
||||
private static final long MAX_RANGE_DAYS = 200;
|
||||
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final GroupRepository groupRepository;
|
||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
private final AcademicDateService academicDateService;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
|
||||
@Autowired
|
||||
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicDateService academicDateService) {
|
||||
AcademicDateService academicDateService,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupRepository = groupRepository;
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
this.academicDateService = academicDateService;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
}
|
||||
|
||||
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicDateService academicDateService) {
|
||||
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
|
||||
groupLifecycleService, academicDateService, null);
|
||||
}
|
||||
|
||||
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository, groupLifecycleService, null);
|
||||
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
|
||||
groupLifecycleService, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,10 +206,19 @@ public class ScheduleQueryService {
|
||||
.findByLessonDateBetweenWithDetails(startDate, endDate);
|
||||
generatedLessons = new ArrayList<>(generatedLessons);
|
||||
addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
|
||||
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
|
||||
List<RenderedLessonDto> effectiveLessons = applyOverrides(generatedLessons, null, overrideSnapshot);
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(effectiveLessons, departmentId, startDate, endDate);
|
||||
List<RenderedLessonDto> lessons = effectiveLessons.stream()
|
||||
.filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate))
|
||||
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
|
||||
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
|
||||
.filter(lesson -> departmentId == null || teacherDepartmentService
|
||||
.hasDepartmentAssignmentAtDate(
|
||||
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
|
||||
departmentId,
|
||||
lesson.date()
|
||||
))
|
||||
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
|
||||
.filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId))
|
||||
.filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId))
|
||||
@@ -204,6 +227,29 @@ public class ScheduleQueryService {
|
||||
return deduplicate(lessons);
|
||||
}
|
||||
|
||||
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
|
||||
List<RenderedLessonDto> lessons,
|
||||
Long departmentId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
if (departmentId == null) {
|
||||
return Map.of();
|
||||
}
|
||||
if (teacherDepartmentService == null) {
|
||||
throw new IllegalStateException("Сервис кафедральных связей преподавателей недоступен");
|
||||
}
|
||||
Set<Long> teacherIds = lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
return teacherDepartmentService.findAssignmentsForTeachersBetween(
|
||||
teacherIds,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
|
||||
private void addOverrideSourceOccurrences(
|
||||
List<RenderedLessonDto> generatedLessons,
|
||||
Collection<ScheduleOverride> overrideSnapshot,
|
||||
@@ -380,7 +426,7 @@ public class ScheduleQueryService {
|
||||
}
|
||||
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
||||
if (inclusiveDays > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,12 +441,10 @@ public class ScheduleQueryService {
|
||||
.map(List::of)
|
||||
.orElse(List.of());
|
||||
}
|
||||
List<StudentGroup> groups;
|
||||
if (departmentId != null) {
|
||||
groups = groupRepository.findByDepartmentId(departmentId);
|
||||
} else {
|
||||
groups = groupRepository.findAll();
|
||||
}
|
||||
// Кафедральный scope определяется по преподавателю на дату занятия,
|
||||
// а не по выпускающей кафедре учебной группы. Фильтрация выполняется
|
||||
// после генерации и применения точечных изменений.
|
||||
List<StudentGroup> groups = groupRepository.findAll();
|
||||
groups = groups.stream()
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
.toList();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.LessonType;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
@@ -10,6 +12,8 @@ import com.magistr.app.model.ScheduleLessonCategory;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subgroup;
|
||||
@@ -20,11 +24,15 @@ import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.LessonTypesRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -40,6 +48,7 @@ import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
@@ -70,6 +79,9 @@ public class ScheduleRuleService {
|
||||
private final LessonTypesRepository lessonTypesRepository;
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
||||
private ScheduleVersionRepository scheduleVersionRepository;
|
||||
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
|
||||
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
@@ -93,9 +105,46 @@ public class ScheduleRuleService {
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
|
||||
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
void setScheduleVersionRepository(ScheduleVersionRepository scheduleVersionRepository) {
|
||||
this.scheduleVersionRepository = scheduleVersionRepository;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
void setScheduleRuleSlotRepository(ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ScheduleRuleDto> getPage(Long versionId, String query, int page, int size) {
|
||||
var ids = scheduleRuleRepository.searchPageIds(versionId,
|
||||
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100))));
|
||||
if (ids.isEmpty()) {
|
||||
return new PageResponse<>(List.of(), ids.getNumber(), ids.getSize(), ids.getTotalElements(),
|
||||
ids.getTotalPages(), ids.isFirst(), ids.isLast());
|
||||
}
|
||||
var details = scheduleRuleRepository.findPageWithDetails(ids.getContent()).stream()
|
||||
.collect(java.util.stream.Collectors.toMap(ScheduleRule::getId, this::toDto));
|
||||
return PageResponse.from(ids.map(details::get));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||
return getAll(semesterId, groupId, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId, Long scheduleVersionId) {
|
||||
List<ScheduleRule> source = scheduleVersionId == null
|
||||
? scheduleRuleRepository.findAllWithDetails()
|
||||
: scheduleRuleRepository.findActiveByVersionIdWithDetails(scheduleVersionId, null);
|
||||
return source.stream()
|
||||
.filter(ScheduleRule::isActiveRecord)
|
||||
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
|
||||
.filter(rule -> groupId == null || rule.getGroups().stream()
|
||||
@@ -120,11 +169,17 @@ public class ScheduleRuleService {
|
||||
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, semester);
|
||||
ScheduleVersion version = resolveEditableVersion(request.scheduleVersionId(), semester.getId());
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, semester, version);
|
||||
validateCandidate(candidate);
|
||||
validateAgainstSavedRules(candidate, null);
|
||||
|
||||
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
|
||||
if (saved.getVersionGroupId() == null && saved.getId() != null) {
|
||||
saved.setVersionGroupId(saved.getId());
|
||||
saved = scheduleRuleRepository.saveAndFlush(saved);
|
||||
}
|
||||
scheduleGeneratorService.clearCache();
|
||||
return toDto(saved);
|
||||
}
|
||||
@@ -137,6 +192,7 @@ public class ScheduleRuleService {
|
||||
// определён по актуальному состоянию и два update одного правила не расходятся.
|
||||
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||
lockAndEnsureEditableRule(managedRule);
|
||||
Long oldSemesterId = managedRule.getSemester().getId();
|
||||
|
||||
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
|
||||
@@ -147,11 +203,22 @@ public class ScheduleRuleService {
|
||||
throw new IllegalArgumentException("Семестр не найден");
|
||||
}
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, targetSemester);
|
||||
if (managedRule.getScheduleVersion() != null
|
||||
&& request.scheduleVersionId() != null
|
||||
&& !Objects.equals(managedRule.getScheduleVersion().getId(), request.scheduleVersionId())) {
|
||||
throw new IllegalArgumentException("Нельзя переносить правило между версиями расписания");
|
||||
}
|
||||
if (managedRule.getScheduleVersion() != null
|
||||
&& !Objects.equals(managedRule.getScheduleVersion().getSemester().getId(), targetSemester.getId())) {
|
||||
throw new IllegalArgumentException("Нельзя переносить правило в другой семестр внутри версии");
|
||||
}
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, targetSemester, managedRule.getScheduleVersion());
|
||||
candidate.setVersionGroupId(managedRule.getVersionGroupId());
|
||||
validateCandidate(candidate);
|
||||
validateAgainstSavedRules(candidate, managedRule.getId());
|
||||
|
||||
applyCandidate(managedRule, candidate);
|
||||
applyCandidate(managedRule, candidate, request.slots());
|
||||
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return toDto(saved);
|
||||
@@ -161,6 +228,7 @@ public class ScheduleRuleService {
|
||||
public void archive(Long id) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||
lockAndEnsureEditableRule(rule);
|
||||
Long semesterId = rule.getSemester().getId();
|
||||
semesterRepository.findByIdForUpdate(semesterId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
|
||||
@@ -267,7 +335,7 @@ public class ScheduleRuleService {
|
||||
normalizeLessonFormat(slot.lessonFormat());
|
||||
}
|
||||
|
||||
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester) {
|
||||
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester, ScheduleVersion version) {
|
||||
Subject subject = subjectRepository.findById(request.subjectId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
||||
if (subject.isArchivedRecord()) {
|
||||
@@ -286,6 +354,11 @@ public class ScheduleRuleService {
|
||||
ScheduleRule candidate = new ScheduleRule();
|
||||
candidate.setSubject(subject);
|
||||
candidate.setSemester(semester);
|
||||
candidate.setScheduleVersion(version);
|
||||
Long currentUserId = AuthContext.currentUserId();
|
||||
if (currentUserId != null) {
|
||||
userRepository.findById(currentUserId).ifPresent(candidate::setCreatedBy);
|
||||
}
|
||||
candidate.setLectureAcademicHours(request.lectureAcademicHours());
|
||||
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
|
||||
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
|
||||
@@ -424,6 +497,21 @@ public class ScheduleRuleService {
|
||||
List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
|
||||
validateExactDuplicates(slots);
|
||||
validateInternalConflicts(slots);
|
||||
validateTeacherAvailability(candidate, slots);
|
||||
}
|
||||
|
||||
private void validateTeacherAvailability(ScheduleRule candidate, List<ScheduleRuleSlot> slots) {
|
||||
if (teacherAvailabilityPolicyService == null) return;
|
||||
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = activeWeeksBySlot(slots);
|
||||
for (ScheduleRuleSlot slot : slots) {
|
||||
teacherAvailabilityPolicyService.assertRuleSlotAvailable(
|
||||
teacherId(slot),
|
||||
candidate.getSemester(),
|
||||
slot.getDayOfWeek(),
|
||||
timeSlotId(slot),
|
||||
activeWeeks.get(slot)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {
|
||||
@@ -483,8 +571,12 @@ public class ScheduleRuleService {
|
||||
|
||||
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
|
||||
ScheduleRuleConflict conflict = scheduleRuleRepository
|
||||
.findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
|
||||
List<ScheduleRule> savedRules = candidate.getScheduleVersion() == null
|
||||
? scheduleRuleRepository.findActiveBySemesterIdWithDetails(
|
||||
candidate.getSemester().getId(), excludeRuleId)
|
||||
: scheduleRuleRepository.findActiveByVersionIdWithDetails(
|
||||
candidate.getScheduleVersion().getId(), excludeRuleId);
|
||||
ScheduleRuleConflict conflict = savedRules
|
||||
.stream()
|
||||
.filter(ScheduleRule::isActiveRecord)
|
||||
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
||||
@@ -737,7 +829,9 @@ public class ScheduleRuleService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) {
|
||||
private void applyCandidate(ScheduleRule managedRule,
|
||||
ScheduleRule candidate,
|
||||
List<ScheduleRuleSlotDto> requestedSlots) {
|
||||
managedRule.setSubject(candidate.getSubject());
|
||||
managedRule.setSemester(candidate.getSemester());
|
||||
managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
|
||||
@@ -749,11 +843,54 @@ public class ScheduleRuleService {
|
||||
|
||||
managedRule.getGroups().clear();
|
||||
managedRule.getGroups().addAll(candidate.getGroups());
|
||||
managedRule.getSlots().clear();
|
||||
for (ScheduleRuleSlot slot : candidate.getSlots()) {
|
||||
slot.setScheduleRule(managedRule);
|
||||
managedRule.getSlots().add(slot);
|
||||
List<ScheduleRuleSlot> candidateSlots = new ArrayList<>(candidate.getSlots());
|
||||
Map<Long, ScheduleRuleSlot> currentSlotsById = managedRule.getSlots().stream()
|
||||
.filter(slot -> slot.getId() != null)
|
||||
.collect(Collectors.toMap(ScheduleRuleSlot::getId, Function.identity()));
|
||||
Set<Long> retainedSlotIds = new HashSet<>();
|
||||
Set<ScheduleRuleSlot> updatedSlots = new LinkedHashSet<>();
|
||||
|
||||
for (int index = 0; index < candidateSlots.size(); index++) {
|
||||
ScheduleRuleSlot candidateSlot = candidateSlots.get(index);
|
||||
Long requestedSlotId = requestedSlots.get(index).id();
|
||||
if (requestedSlotId == null) {
|
||||
candidateSlot.setScheduleRule(managedRule);
|
||||
updatedSlots.add(candidateSlot);
|
||||
continue;
|
||||
}
|
||||
|
||||
ScheduleRuleSlot existingSlot = currentSlotsById.get(requestedSlotId);
|
||||
if (existingSlot == null) {
|
||||
throw new IllegalArgumentException("Слот не принадлежит редактируемому правилу");
|
||||
}
|
||||
retainedSlotIds.add(requestedSlotId);
|
||||
copySlot(existingSlot, candidateSlot);
|
||||
updatedSlots.add(existingSlot);
|
||||
}
|
||||
|
||||
List<Long> removedSlotIds = currentSlotsById.keySet().stream()
|
||||
.filter(id -> !retainedSlotIds.contains(id))
|
||||
.toList();
|
||||
if (!removedSlotIds.isEmpty()
|
||||
&& scheduleRuleSlotRepository != null
|
||||
&& scheduleRuleSlotRepository.hasProtectedReferences(removedSlotIds)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Нельзя удалить слот: на него ссылаются точечные изменения, заявки преподавателей или решения по отсутствиям"
|
||||
);
|
||||
}
|
||||
managedRule.getSlots().clear();
|
||||
managedRule.getSlots().addAll(updatedSlots);
|
||||
}
|
||||
|
||||
private void copySlot(ScheduleRuleSlot target, ScheduleRuleSlot source) {
|
||||
target.setDayOfWeek(source.getDayOfWeek());
|
||||
target.setParity(source.getParity());
|
||||
target.setTimeSlot(source.getTimeSlot());
|
||||
target.setSubgroups(source.getSubgroups());
|
||||
target.setTeacher(source.getTeacher());
|
||||
target.setClassroom(source.getClassroom());
|
||||
target.setLessonType(source.getLessonType());
|
||||
target.setLessonFormat(source.getLessonFormat());
|
||||
}
|
||||
|
||||
private ScheduleRuleDto toDto(ScheduleRule rule) {
|
||||
@@ -783,10 +920,77 @@ public class ScheduleRuleService {
|
||||
rule.getPracticeStartWeek(),
|
||||
groups.stream().map(StudentGroup::getId).toList(),
|
||||
groups.stream().map(StudentGroup::getName).toList(),
|
||||
slots
|
||||
slots,
|
||||
rule.getScheduleVersion() == null ? null : rule.getScheduleVersion().getId()
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> validateVersion(Long scheduleVersionId) {
|
||||
if (scheduleVersionId == null) {
|
||||
return List.of("Версия расписания не выбрана");
|
||||
}
|
||||
List<ScheduleRule> rules = scheduleRuleRepository
|
||||
.findActiveByVersionIdWithDetails(scheduleVersionId, null);
|
||||
if (rules.isEmpty()) {
|
||||
return List.of("В черновике нет ни одного правила расписания");
|
||||
}
|
||||
LinkedHashSet<String> issues = new LinkedHashSet<>();
|
||||
for (ScheduleRule rule : rules) {
|
||||
if (rule.getSlots().isEmpty()) {
|
||||
issues.add("Правило «" + rule.getSubject().getName() + "» не содержит слотов");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
validateCandidate(rule);
|
||||
validateAgainstSavedRules(rule, rule.getId());
|
||||
} catch (IllegalArgumentException | ScheduleRuleConflictException exception) {
|
||||
issues.add("Правило «" + rule.getSubject().getName() + "»: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
return List.copyOf(issues);
|
||||
}
|
||||
|
||||
private ScheduleVersion resolveEditableVersion(Long versionId, Long semesterId) {
|
||||
if (versionId == null) {
|
||||
return null;
|
||||
}
|
||||
if (scheduleVersionRepository == null) {
|
||||
throw new IllegalStateException("Хранилище версий расписания недоступно");
|
||||
}
|
||||
ScheduleVersion version = scheduleVersionRepository.findByIdForUpdate(versionId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
|
||||
if (!Objects.equals(version.getSemester().getId(), semesterId)) {
|
||||
throw new IllegalArgumentException("Версия относится к другому семестру");
|
||||
}
|
||||
ensureEditableVersion(version);
|
||||
return version;
|
||||
}
|
||||
|
||||
private void ensureEditableVersion(ScheduleVersion version) {
|
||||
if (version.getStatus() == ScheduleVersionStatus.ARCHIVED) {
|
||||
throw new IllegalArgumentException(
|
||||
"Архивное расписание доступно только для просмотра; выберите опубликованное расписание или черновик"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureEditableRule(ScheduleRule rule) {
|
||||
if (rule.getScheduleVersion() != null) {
|
||||
ensureEditableVersion(rule.getScheduleVersion());
|
||||
}
|
||||
}
|
||||
|
||||
private void lockAndEnsureEditableRule(ScheduleRule rule) {
|
||||
if (rule.getScheduleVersion() != null && scheduleVersionRepository != null) {
|
||||
ScheduleVersion lockedVersion = scheduleVersionRepository
|
||||
.findByIdForUpdate(rule.getScheduleVersion().getId())
|
||||
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
|
||||
rule.setScheduleVersion(lockedVersion);
|
||||
}
|
||||
ensureEditableRule(rule);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,23 @@ public class TeacherDepartmentService {
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public boolean hasDepartmentAssignmentAtDate(
|
||||
Collection<TeacherDepartmentAssignment> assignments,
|
||||
Long departmentId,
|
||||
LocalDate date
|
||||
) {
|
||||
if (assignments == null || departmentId == null || date == null) {
|
||||
return false;
|
||||
}
|
||||
return assignments.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(assignment -> isActiveAt(assignment, date))
|
||||
.map(TeacherDepartmentAssignment::getDepartment)
|
||||
.filter(Objects::nonNull)
|
||||
.map(Department::getId)
|
||||
.anyMatch(departmentId::equals);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
|
||||
DepartmentTransferRequest request,
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- ===============================
|
||||
-- Справочники высшего уровня
|
||||
@@ -18,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO departments (name, code) VALUES
|
||||
('Кафедра ИБ', 1),
|
||||
('Кафедра ВТ', 2),
|
||||
('Кафедра КТ', 3);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS specialties (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
@@ -35,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO specialties (name, specialty_code) VALUES
|
||||
('Информационная безопасность', '10.03.01'),
|
||||
('Информатика и вычислительная техника', '09.03.01'),
|
||||
('Программная инженерия', '09.03.04')
|
||||
ON CONFLICT (specialty_code) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
|
||||
@@ -56,11 +46,6 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||||
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
||||
);
|
||||
|
||||
INSERT INTO specialty_profiles (specialty_id, name)
|
||||
SELECT id, 'Без профиля'
|
||||
FROM specialties
|
||||
ON CONFLICT (specialty_id, name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
-- Пользователи и роли
|
||||
-- ==========================================
|
||||
@@ -82,15 +67,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
||||
INSERT INTO users (username, password, role, full_name, job_title, department_id)
|
||||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
|
||||
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
|
||||
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
|
||||
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
|
||||
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -188,16 +164,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
|
||||
ON teacher_department_assignments(teacher_id, department_id)
|
||||
WHERE valid_to IS NULL;
|
||||
|
||||
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
|
||||
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
|
||||
FROM users u
|
||||
WHERE u.role = 'TEACHER'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM teacher_department_assignments existing
|
||||
WHERE existing.teacher_id = u.id
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
@@ -242,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO education_forms (name) VALUES
|
||||
('Бакалавриат'),
|
||||
('Магистратура'),
|
||||
('Специалитет')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
-- Учебные группы
|
||||
-- ==========================================
|
||||
@@ -271,19 +231,6 @@ CREATE TABLE IF NOT EXISTS student_groups (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
-- Тестовая базовая группа для работы
|
||||
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
|
||||
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
|
||||
FROM (VALUES
|
||||
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
|
||||
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
|
||||
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
|
||||
JOIN education_forms ef ON ef.name = data.education_form_name
|
||||
JOIN departments dept ON dept.name = data.department_name
|
||||
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
|
||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
|
||||
|
||||
-- ==========================================
|
||||
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
||||
-- ==========================================
|
||||
@@ -372,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_subgroup_student_capacity();
|
||||
|
||||
INSERT INTO subgroups (group_id, name, student_capacity)
|
||||
SELECT
|
||||
sg.id,
|
||||
data.name,
|
||||
CASE
|
||||
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
|
||||
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
|
||||
END
|
||||
FROM student_groups sg
|
||||
CROSS JOIN (VALUES
|
||||
('Подгруппа 1', 1),
|
||||
('Подгруппа 2', 2)
|
||||
) AS data(name, part_number)
|
||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
||||
ON CONFLICT (group_id, name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
-- Справочники
|
||||
-- ==========================================
|
||||
@@ -413,14 +344,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
|
||||
COMMENT ON INDEX uq_subjects_name_ci IS
|
||||
'Глобально уникальное название дисциплины без учёта регистра';
|
||||
|
||||
INSERT INTO subjects (name, department_id) VALUES
|
||||
('Высшая математика', 1),
|
||||
('Философия', 1),
|
||||
('Информатика', 1),
|
||||
('Базы данных', 1),
|
||||
('Английский язык', 1)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subject_comments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||
@@ -440,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
|
||||
duration_minutes INT DEFAULT 90
|
||||
);
|
||||
|
||||
INSERT INTO lesson_types (name, color_code) VALUES
|
||||
('Лекция', '#FF6B6B'),
|
||||
('Практика', '#4ECDC4'),
|
||||
('Лабораторная работа', '#45B7D1')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Оборудование
|
||||
CREATE TABLE IF NOT EXISTS equipments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -460,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO equipments (name) VALUES
|
||||
('Проектор'),
|
||||
('ПК'),
|
||||
('Лаборатория'),
|
||||
('Интерактивная доска'),
|
||||
('Документ-камера'),
|
||||
('Аудиосистема')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Аудитории
|
||||
CREATE TABLE IF NOT EXISTS classrooms (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -487,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
||||
('101 Ленинская', 120, 'Главный корпус', 1),
|
||||
('202 IT Lab', 20, 'Корпус IT', 2),
|
||||
('303 Обычная', 30, 'Главный корпус', 3)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Привязка оборудования к аудиториям (Many-to-Many)
|
||||
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||
@@ -502,20 +404,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||
PRIMARY KEY (classroom_id, equipment_id)
|
||||
);
|
||||
|
||||
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
||||
SELECT c.id, e.id,
|
||||
CASE
|
||||
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
||||
WHEN e.name = 'ПК' THEN 1
|
||||
ELSE 1
|
||||
END
|
||||
FROM classrooms c, equipments e
|
||||
WHERE
|
||||
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
||||
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
||||
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
||||
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
||||
@@ -574,11 +462,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
|
||||
ON time_slot_scopes(day_of_week)
|
||||
WHERE apply_mode = 'WEEKDAY';
|
||||
|
||||
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
|
||||
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
|
||||
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_slots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
||||
@@ -596,21 +479,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
||||
|
||||
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
|
||||
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
|
||||
FROM (VALUES
|
||||
(1, '08:00', '09:30', 90),
|
||||
(2, '09:40', '11:10', 90),
|
||||
(3, '11:40', '13:10', 90),
|
||||
(4, '13:20', '14:50', 90),
|
||||
(5, '15:00', '16:30', 90),
|
||||
(6, '16:50', '18:20', 90),
|
||||
(7, '18:30', '20:00', 90)
|
||||
) AS data(order_number, start_time, end_time, duration_minutes)
|
||||
CROSS JOIN time_slot_scopes scope
|
||||
WHERE scope.code IN ('default', 'saturday')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
assignment_date DATE NOT NULL UNIQUE,
|
||||
@@ -628,11 +496,6 @@ CREATE TABLE IF NOT EXISTS academic_years (
|
||||
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
||||
);
|
||||
|
||||
INSERT INTO academic_years (title, start_date, end_date) VALUES
|
||||
('2024-2025', '2024-09-01', '2025-06-30'),
|
||||
('2025-2026', '2025-09-01', '2026-06-30')
|
||||
ON CONFLICT (title) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semesters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||||
@@ -644,16 +507,6 @@ CREATE TABLE IF NOT EXISTS semesters (
|
||||
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
||||
);
|
||||
|
||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||||
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
|
||||
FROM academic_years
|
||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||||
|
||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||||
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
|
||||
FROM academic_years
|
||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
||||
@@ -666,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
||||
description TEXT
|
||||
);
|
||||
|
||||
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
|
||||
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
|
||||
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
|
||||
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
|
||||
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
|
||||
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
|
||||
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
|
||||
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
|
||||
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
|
||||
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
|
||||
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
|
||||
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
|
||||
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
|
||||
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendars (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
@@ -699,37 +536,25 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
|
||||
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
|
||||
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
|
||||
|
||||
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
|
||||
SELECT
|
||||
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
|
||||
ay.id,
|
||||
specialty.id,
|
||||
profile.id,
|
||||
study_form.id,
|
||||
4
|
||||
FROM academic_years ay
|
||||
CROSS JOIN education_forms study_form
|
||||
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
|
||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
||||
WHERE ay.title = '2025-2026'
|
||||
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_days (
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||
course_number INT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
week_number INT NOT NULL,
|
||||
day_of_week INT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id),
|
||||
CONSTRAINT chk_calendar_days_course_positive CHECK (course_number > 0),
|
||||
CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0),
|
||||
CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date)
|
||||
CONSTRAINT chk_calendar_periods_course_positive CHECK (course_number > 0),
|
||||
CONSTRAINT chk_calendar_periods_dates CHECK (end_date >= start_date),
|
||||
CONSTRAINT ex_calendar_periods_no_overlap
|
||||
EXCLUDE USING gist (
|
||||
calendar_id WITH =,
|
||||
course_number WITH =,
|
||||
(daterange(start_date, end_date, '[]')) WITH &&
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup
|
||||
ON academic_calendar_days(calendar_id, course_number, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_periods_schedule
|
||||
ON academic_calendar_periods(calendar_id, course_number, start_date, end_date);
|
||||
|
||||
CREATE OR REPLACE FUNCTION calculate_academic_calendar_week_number(
|
||||
academic_year_start DATE,
|
||||
@@ -750,21 +575,6 @@ AS $$
|
||||
) + 1
|
||||
$$;
|
||||
|
||||
INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id)
|
||||
SELECT
|
||||
calendar.id,
|
||||
course.course_number,
|
||||
days.date::DATE,
|
||||
calculate_academic_calendar_week_number(ay.start_date, days.date::DATE),
|
||||
EXTRACT(ISODOW FROM days.date::DATE)::INT,
|
||||
activity.id
|
||||
FROM academic_calendars calendar
|
||||
JOIN academic_years ay ON ay.id = calendar.academic_year_id
|
||||
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
|
||||
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
|
||||
CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date)
|
||||
ON CONFLICT (calendar_id, course_number, date) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||
@@ -793,21 +603,6 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
|
||||
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
|
||||
ON student_group_calendar_assignments(group_id, academic_year_id);
|
||||
|
||||
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
|
||||
SELECT
|
||||
sg.id,
|
||||
ay.id,
|
||||
calendar.id
|
||||
FROM student_groups sg
|
||||
JOIN academic_years ay ON ay.title = '2025-2026'
|
||||
JOIN academic_calendars calendar
|
||||
ON calendar.academic_year_id = ay.id
|
||||
AND calendar.specialty_id = sg.specialty_id
|
||||
AND calendar.specialty_profile_id = sg.specialty_profile_id
|
||||
AND calendar.study_form_id = sg.education_form_id
|
||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
||||
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||
@@ -984,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
||||
|
||||
CREATE TEMP TABLE tmp_default_rules AS
|
||||
SELECT
|
||||
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
|
||||
rule_key,
|
||||
subject_id,
|
||||
semester_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
FROM (
|
||||
SELECT
|
||||
data.rule_key,
|
||||
subj.id AS subject_id,
|
||||
sem.id AS semester_id,
|
||||
data.lecture_academic_hours,
|
||||
data.laboratory_academic_hours,
|
||||
data.practice_academic_hours,
|
||||
data.lecture_start_week,
|
||||
data.laboratory_start_week,
|
||||
data.practice_start_week
|
||||
FROM (VALUES
|
||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
|
||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
|
||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
|
||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
|
||||
) AS data(
|
||||
rule_key,
|
||||
subject_name,
|
||||
year_title,
|
||||
semester_type,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
)
|
||||
JOIN subjects subj ON subj.name = data.subject_name
|
||||
JOIN academic_years ay ON ay.title = data.year_title
|
||||
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
||||
) prepared_rules;
|
||||
|
||||
INSERT INTO schedule_rules (
|
||||
id,
|
||||
subject_id,
|
||||
semester_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
)
|
||||
SELECT
|
||||
schedule_rule_id,
|
||||
subject_id,
|
||||
semester_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
FROM tmp_default_rules;
|
||||
|
||||
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
||||
SELECT
|
||||
rules.schedule_rule_id,
|
||||
sg.id
|
||||
FROM (VALUES
|
||||
('stream_math', 'ИВТ-21-1'),
|
||||
('stream_math', 'ИБ-41м'),
|
||||
('ivt_informatics_lab', 'ИВТ-21-1'),
|
||||
('ivt_databases_practice', 'ИВТ-21-1'),
|
||||
('ib_philosophy', 'ИБ-41м')
|
||||
) AS data(rule_key, group_name)
|
||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||||
JOIN student_groups sg ON sg.name = data.group_name
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO schedule_rule_slots (
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
)
|
||||
SELECT
|
||||
rules.schedule_rule_id,
|
||||
data.day_of_week,
|
||||
data.parity,
|
||||
ts.id,
|
||||
teacher.id,
|
||||
classroom.id,
|
||||
lt.id,
|
||||
data.lesson_format
|
||||
FROM (VALUES
|
||||
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
|
||||
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
|
||||
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
|
||||
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
||||
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||||
JOIN time_slot_scopes scope ON scope.code = 'default'
|
||||
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
|
||||
JOIN users teacher ON teacher.username = data.teacher_username
|
||||
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
||||
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
||||
|
||||
-- ==========================================
|
||||
-- Функция обновления timestamp
|
||||
-- ==========================================
|
||||
@@ -1134,7 +814,7 @@ COMMENT ON TABLE academic_years IS 'Учебные годы';
|
||||
COMMENT ON TABLE semesters IS 'Семестры учебного года';
|
||||
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
|
||||
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
|
||||
COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика';
|
||||
COMMENT ON TABLE academic_calendar_periods IS 'Непересекающиеся периоды активности календарного учебного графика';
|
||||
COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
|
||||
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
|
||||
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
||||
@@ -1275,12 +955,11 @@ COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля
|
||||
COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms';
|
||||
COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике';
|
||||
|
||||
COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика';
|
||||
COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса';
|
||||
COMMENT ON COLUMN academic_calendar_days.date IS 'Дата';
|
||||
COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году';
|
||||
COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO';
|
||||
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности';
|
||||
COMMENT ON COLUMN academic_calendar_periods.calendar_id IS 'ID календарного учебного графика';
|
||||
COMMENT ON COLUMN academic_calendar_periods.course_number IS 'Номер курса';
|
||||
COMMENT ON COLUMN academic_calendar_periods.start_date IS 'Первая дата периода включительно';
|
||||
COMMENT ON COLUMN academic_calendar_periods.end_date IS 'Последняя дата периода включительно';
|
||||
COMMENT ON COLUMN academic_calendar_periods.activity_type_id IS 'ID кода активности';
|
||||
COMMENT ON FUNCTION calculate_academic_calendar_week_number(DATE, DATE)
|
||||
IS 'Номер недели календарного учебного графика по периодам понедельник–воскресенье';
|
||||
|
||||
@@ -1365,7 +1044,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
cancel_violations,
|
||||
move_violations,
|
||||
replace_violations,
|
||||
@@ -1464,7 +1143,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
lecture_violations,
|
||||
laboratory_violations,
|
||||
practice_violations
|
||||
@@ -1505,7 +1184,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23505',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
duplicate_slot_groups
|
||||
);
|
||||
END IF;
|
||||
@@ -1571,7 +1250,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
duration_violations,
|
||||
overlap_violations,
|
||||
scope_violations
|
||||
@@ -1579,8 +1258,6 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
|
||||
ALTER TABLE time_slots
|
||||
ADD CONSTRAINT chk_time_slots_duration_matches_range
|
||||
CHECK (
|
||||
@@ -1729,7 +1406,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
academic_year_overlap_violations,
|
||||
semester_bounds_violations,
|
||||
semester_overlap_violations
|
||||
@@ -1818,7 +1495,7 @@ CREATE TRIGGER trg_academic_years_include_semesters
|
||||
DO $$
|
||||
DECLARE
|
||||
assignment_violations BIGINT;
|
||||
calendar_day_violations BIGINT;
|
||||
calendar_period_violations BIGINT;
|
||||
calendar_subject_violations BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
@@ -1840,13 +1517,13 @@ BEGIN
|
||||
END NOT BETWEEN 1 AND calendar.course_count;
|
||||
|
||||
SELECT count(*)
|
||||
INTO calendar_day_violations
|
||||
FROM academic_calendar_days calendar_day
|
||||
JOIN academic_calendars calendar ON calendar.id = calendar_day.calendar_id
|
||||
INTO calendar_period_violations
|
||||
FROM academic_calendar_periods calendar_period
|
||||
JOIN academic_calendars calendar ON calendar.id = calendar_period.calendar_id
|
||||
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
|
||||
WHERE calendar_day.course_number > calendar.course_count
|
||||
OR calendar_day.date < academic_year.start_date
|
||||
OR calendar_day.date > academic_year.end_date;
|
||||
WHERE calendar_period.course_number > calendar.course_count
|
||||
OR calendar_period.start_date < academic_year.start_date
|
||||
OR calendar_period.end_date > academic_year.end_date;
|
||||
|
||||
SELECT count(*)
|
||||
INTO calendar_subject_violations
|
||||
@@ -1855,14 +1532,14 @@ BEGIN
|
||||
WHERE calendar_subject.semester_number > calendar.course_count * 2;
|
||||
|
||||
IF assignment_violations > 0
|
||||
OR calendar_day_violations > 0
|
||||
OR calendar_period_violations > 0
|
||||
OR calendar_subject_violations > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: несовместимые назначения графиков=%s, строки сетки вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
assignment_violations,
|
||||
calendar_day_violations,
|
||||
calendar_period_violations,
|
||||
calendar_subject_violations
|
||||
);
|
||||
END IF;
|
||||
@@ -1990,12 +1667,12 @@ BEGIN
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM academic_calendar_days calendar_day
|
||||
WHERE calendar_day.calendar_id = OLD.id
|
||||
FROM academic_calendar_periods calendar_period
|
||||
WHERE calendar_period.calendar_id = OLD.id
|
||||
AND (
|
||||
calendar_day.course_number > NEW.course_count
|
||||
OR calendar_day.date < academic_year_start
|
||||
OR calendar_day.date > academic_year_end
|
||||
calendar_period.course_number > NEW.course_count
|
||||
OR calendar_period.start_date < academic_year_start
|
||||
OR calendar_period.end_date > academic_year_end
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION USING
|
||||
@@ -2059,7 +1736,7 @@ CREATE TRIGGER trg_student_groups_protect_calendar_assignments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_incompatible_student_group_change();
|
||||
|
||||
CREATE OR REPLACE FUNCTION enforce_calendar_day_dimensions()
|
||||
CREATE OR REPLACE FUNCTION enforce_calendar_period_dimensions()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -2078,22 +1755,22 @@ BEGIN
|
||||
IF calendar_course_count IS NOT NULL
|
||||
AND (
|
||||
NEW.course_number > calendar_course_count
|
||||
OR NEW.date < academic_year_start
|
||||
OR NEW.date > academic_year_end
|
||||
OR NEW.start_date < academic_year_start
|
||||
OR NEW.end_date > academic_year_end
|
||||
) THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = 'Строка сетки выходит за измерения календарного графика';
|
||||
MESSAGE = 'Период активности выходит за измерения календарного графика';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_calendar_days_dimensions
|
||||
BEFORE INSERT OR UPDATE OF calendar_id, course_number, date
|
||||
ON academic_calendar_days
|
||||
CREATE TRIGGER trg_calendar_periods_dimensions
|
||||
BEFORE INSERT OR UPDATE OF calendar_id, course_number, start_date, end_date
|
||||
ON academic_calendar_periods
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION enforce_calendar_day_dimensions();
|
||||
EXECUTE FUNCTION enforce_calendar_period_dimensions();
|
||||
|
||||
CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions()
|
||||
RETURNS TRIGGER
|
||||
@@ -2132,11 +1809,11 @@ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM academic_calendars calendar
|
||||
JOIN academic_calendar_days calendar_day ON calendar_day.calendar_id = calendar.id
|
||||
JOIN academic_calendar_periods calendar_period ON calendar_period.calendar_id = calendar.id
|
||||
WHERE calendar.academic_year_id = OLD.id
|
||||
AND (
|
||||
calendar_day.date < NEW.start_date
|
||||
OR calendar_day.date > NEW.end_date
|
||||
calendar_period.start_date < NEW.start_date
|
||||
OR calendar_period.end_date > NEW.end_date
|
||||
)
|
||||
) OR EXISTS (
|
||||
SELECT 1
|
||||
@@ -2178,3 +1855,291 @@ ALTER TABLE teacher_department_assignments
|
||||
COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap
|
||||
ON teacher_department_assignments
|
||||
IS 'У преподавателя не могут пересекаться периоды двух основных кафедр';
|
||||
|
||||
-- ==========================================
|
||||
-- Отсутствия преподавателей и мастер замены
|
||||
-- ==========================================
|
||||
CREATE TABLE teacher_absences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
requested_by BIGINT REFERENCES users(id),
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_absences_period CHECK (end_date >= start_date),
|
||||
CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000),
|
||||
CONSTRAINT chk_teacher_absences_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_teacher_period
|
||||
ON teacher_absences(teacher_id, start_date, end_date);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_status
|
||||
ON teacher_absences(status);
|
||||
|
||||
CREATE TABLE teacher_absence_decisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE,
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
resolution VARCHAR(30) NOT NULL,
|
||||
decision_status VARCHAR(20) NOT NULL,
|
||||
schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
|
||||
comment TEXT,
|
||||
decided_by BIGINT REFERENCES users(id),
|
||||
decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE (
|
||||
absence_id,
|
||||
base_rule_slot_id,
|
||||
lesson_date
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_resolution CHECK (
|
||||
resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT')
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_status CHECK (
|
||||
decision_status IN ('APPLIED', 'REJECTED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absence_decisions_absence
|
||||
ON teacher_absence_decisions(absence_id, lesson_date);
|
||||
|
||||
COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой';
|
||||
COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED';
|
||||
COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены';
|
||||
COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия';
|
||||
|
||||
-- ==========================================
|
||||
-- Пожелания и заявки преподавателей
|
||||
-- ==========================================
|
||||
CREATE TABLE teacher_preferences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id),
|
||||
preference_type VARCHAR(30) NOT NULL,
|
||||
day_of_week SMALLINT,
|
||||
time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
preference_date DATE,
|
||||
comment TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
requested_by BIGINT REFERENCES users(id),
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_preferences_type CHECK (
|
||||
preference_type IN (
|
||||
'HARD_UNAVAILABLE',
|
||||
'SOFT_PREFERRED',
|
||||
'SOFT_UNWANTED',
|
||||
'CONSECUTIVE',
|
||||
'NO_GAPS'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_day CHECK (
|
||||
day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_payload CHECK (
|
||||
(
|
||||
preference_type = 'HARD_UNAVAILABLE'
|
||||
AND (
|
||||
(preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL)
|
||||
OR
|
||||
(preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL)
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED')
|
||||
AND preference_date IS NULL
|
||||
AND day_of_week IS NOT NULL
|
||||
AND time_slot_id IS NOT NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
preference_type IN ('CONSECUTIVE', 'NO_GAPS')
|
||||
AND preference_date IS NULL
|
||||
AND day_of_week IS NULL
|
||||
AND time_slot_id IS NULL
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_comment CHECK (
|
||||
comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_teacher_semester
|
||||
ON teacher_preferences(teacher_id, semester_id, status);
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_slot
|
||||
ON teacher_preferences(semester_id, day_of_week, time_slot_id)
|
||||
WHERE status = 'APPROVED';
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_date
|
||||
ON teacher_preferences(teacher_id, preference_date)
|
||||
WHERE status = 'APPROVED' AND preference_date IS NOT NULL;
|
||||
|
||||
CREATE TABLE teacher_change_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
request_type VARCHAR(30) NOT NULL,
|
||||
target_lesson_date DATE,
|
||||
requested_time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
requested_classroom_id BIGINT REFERENCES classrooms(id),
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_change_requests_type CHECK (
|
||||
request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL')
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_reason CHECK (
|
||||
length(trim(reason)) BETWEEN 1 AND 2000
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_payload CHECK (
|
||||
(
|
||||
request_type = 'MOVE'
|
||||
AND requested_time_slot_id IS NOT NULL
|
||||
AND requested_classroom_id IS NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
request_type = 'CHANGE_CLASSROOM'
|
||||
AND target_lesson_date IS NULL
|
||||
AND requested_time_slot_id IS NULL
|
||||
AND requested_classroom_id IS NOT NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
request_type = 'CANCEL'
|
||||
AND target_lesson_date IS NULL
|
||||
AND requested_time_slot_id IS NULL
|
||||
AND requested_classroom_id IS NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_change_requests_teacher
|
||||
ON teacher_change_requests(teacher_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_teacher_change_requests_status
|
||||
ON teacher_change_requests(status, created_at);
|
||||
|
||||
CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson
|
||||
ON teacher_change_requests(base_rule_slot_id, lesson_date)
|
||||
WHERE status = 'PENDING';
|
||||
|
||||
CREATE TABLE teacher_change_request_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
comment TEXT,
|
||||
actor_id BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_change_request_history_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_change_request_history_request
|
||||
ON teacher_change_request_history(request_id, created_at, id);
|
||||
|
||||
COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр';
|
||||
COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания';
|
||||
COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия';
|
||||
COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя';
|
||||
|
||||
-- ==========================================
|
||||
-- Черновики, версии, публикация и аудит расписания
|
||||
-- ==========================================
|
||||
CREATE TABLE schedule_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
||||
version_number INT NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
based_on_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
|
||||
restored_from_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
|
||||
change_reason TEXT,
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
published_by BIGINT REFERENCES users(id),
|
||||
published_at TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_schedule_versions_number UNIQUE (semester_id, version_number),
|
||||
CONSTRAINT chk_schedule_versions_number CHECK (version_number > 0),
|
||||
CONSTRAINT chk_schedule_versions_name CHECK (length(trim(name)) BETWEEN 1 AND 160),
|
||||
CONSTRAINT chk_schedule_versions_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
|
||||
CONSTRAINT chk_schedule_versions_publish_fields CHECK (
|
||||
status <> 'PUBLISHED' OR published_at IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_schedule_versions_published_semester
|
||||
ON schedule_versions(semester_id)
|
||||
WHERE status = 'PUBLISHED';
|
||||
|
||||
CREATE INDEX idx_schedule_versions_semester_history
|
||||
ON schedule_versions(semester_id, version_number DESC);
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ADD COLUMN schedule_version_id BIGINT;
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ALTER COLUMN schedule_version_id SET NOT NULL,
|
||||
ADD CONSTRAINT fk_schedule_rules_version
|
||||
FOREIGN KEY (schedule_version_id) REFERENCES schedule_versions(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_schedule_rules_version
|
||||
ON schedule_rules(schedule_version_id, status);
|
||||
|
||||
CREATE UNIQUE INDEX uq_schedule_rules_version_group
|
||||
ON schedule_rules(schedule_version_id, version_group_id)
|
||||
WHERE version_group_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE schedule_version_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
version_id BIGINT NOT NULL REFERENCES schedule_versions(id) ON DELETE CASCADE,
|
||||
action VARCHAR(30) NOT NULL,
|
||||
actor_id BIGINT REFERENCES users(id),
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_schedule_version_history_action CHECK (
|
||||
action IN ('CREATED', 'PUBLISHED', 'ARCHIVED', 'RESTORED')
|
||||
),
|
||||
CONSTRAINT chk_schedule_version_history_reason CHECK (
|
||||
reason IS NULL OR length(trim(reason)) BETWEEN 1 AND 2000
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_schedule_version_history_version
|
||||
ON schedule_version_history(version_id, created_at DESC, id DESC);
|
||||
|
||||
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
|
||||
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
|
||||
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
|
||||
COMMENT ON COLUMN schedule_versions.restored_from_version_id IS 'Предыдущая опубликованная версия при восстановлении';
|
||||
COMMENT ON COLUMN schedule_rules.schedule_version_id IS 'Версия расписания, которой принадлежит правило';
|
||||
COMMENT ON COLUMN schedule_rules.version_group_id IS 'Стабильный идентификатор правила между версиями';
|
||||
COMMENT ON TABLE schedule_version_history IS 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';
|
||||
|
||||
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,17 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.AcademicCalendarGridDayDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarActivityType;
|
||||
import com.magistr.app.model.AcademicCalendarPeriod;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.service.AcademicCalendarGridService;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
@@ -20,22 +19,42 @@ import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AcademicCalendarControllerTest {
|
||||
|
||||
@Test
|
||||
void createCalendarBuildsTitleWithoutClientValue() {
|
||||
@org.junit.jupiter.api.DisplayName("Страница графиков ограничивает размер и сохраняет фильтр учебного года")
|
||||
void returnsBoundedCalendarPage() throws Exception {
|
||||
AcademicCalendarRepository repository = mock(AcademicCalendarRepository.class);
|
||||
var pageable = org.springframework.data.domain.PageRequest.of(0, 100,
|
||||
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
|
||||
.and(org.springframework.data.domain.Sort.by("title", "id")));
|
||||
when(repository.searchOptions("профиль", 9L, null, null, null, pageable))
|
||||
.thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(), pageable, 0));
|
||||
var controller = new AcademicCalendarController(repository,
|
||||
mock(AcademicCalendarPeriodRepository.class), mock(SubjectRepository.class),
|
||||
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarGridService.class),
|
||||
mock(ScheduleGeneratorService.class), mock(AcademicStructureService.class));
|
||||
org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build()
|
||||
.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.get("/api/admin/academic-calendars/page")
|
||||
.param("query", " ПРОФИЛЬ ").param("academicYearId", "9")
|
||||
.param("page", "-1").param("size", "1000"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.size").value(100))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.page").value(0))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createCalendarReturnsCalendarCreatedByTransactionalService() {
|
||||
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
||||
AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class);
|
||||
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
|
||||
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
|
||||
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
|
||||
AcademicStructureService academicStructureService = mock(AcademicStructureService.class);
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
year.setId(1L);
|
||||
@@ -51,26 +70,6 @@ class AcademicCalendarControllerTest {
|
||||
studyForm.setId(4L);
|
||||
studyForm.setName("Очная");
|
||||
|
||||
when(academicYearRepository.findById(1L)).thenReturn(Optional.of(year));
|
||||
when(specialtiesRepository.findById(2L)).thenReturn(Optional.of(speciality));
|
||||
when(profileRepository.findById(3L)).thenReturn(Optional.of(profile));
|
||||
when(educationFormRepository.findById(4L)).thenReturn(Optional.of(studyForm));
|
||||
when(calendarRepository.save(any(AcademicCalendar.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
AcademicCalendarController controller = new AcademicCalendarController(
|
||||
calendarRepository,
|
||||
mock(AcademicCalendarDayRepository.class),
|
||||
academicYearRepository,
|
||||
specialtiesRepository,
|
||||
profileRepository,
|
||||
educationFormRepository,
|
||||
mock(SubjectRepository.class),
|
||||
mock(AcademicCalendarSubjectRepository.class),
|
||||
mock(AcademicCalendarGridService.class),
|
||||
mock(ScheduleGeneratorService.class),
|
||||
mock(AcademicStructureService.class)
|
||||
);
|
||||
AcademicCalendarDto request = new AcademicCalendarDto(
|
||||
null,
|
||||
null,
|
||||
@@ -87,12 +86,97 @@ class AcademicCalendarControllerTest {
|
||||
null,
|
||||
4
|
||||
);
|
||||
AcademicCalendar saved = new AcademicCalendar();
|
||||
saved.setTitle("09.03.04 — Разработка ПО — Очная — 2026-2027");
|
||||
saved.setAcademicYear(year);
|
||||
saved.setSpeciality(speciality);
|
||||
saved.setSpecialtyProfile(profile);
|
||||
saved.setStudyForm(studyForm);
|
||||
saved.setCourseCount(4);
|
||||
when(academicStructureService.createCalendar(request)).thenReturn(saved);
|
||||
|
||||
AcademicCalendarController controller = new AcademicCalendarController(
|
||||
calendarRepository,
|
||||
mock(AcademicCalendarPeriodRepository.class),
|
||||
mock(SubjectRepository.class),
|
||||
mock(AcademicCalendarSubjectRepository.class),
|
||||
mock(AcademicCalendarGridService.class),
|
||||
mock(ScheduleGeneratorService.class),
|
||||
academicStructureService
|
||||
);
|
||||
|
||||
var response = controller.createCalendar(request);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class);
|
||||
assertThat(((AcademicCalendarDto) response.getBody()).title())
|
||||
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027");
|
||||
.isEqualTo(saved.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGridExpandsPeriodIntoDailyRows() {
|
||||
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
||||
AcademicCalendarPeriodRepository periodRepository = mock(AcademicCalendarPeriodRepository.class);
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
year.setId(1L);
|
||||
year.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
year.setEndDate(LocalDate.of(2027, 6, 30));
|
||||
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
calendar.setId(10L);
|
||||
calendar.setAcademicYear(year);
|
||||
|
||||
AcademicCalendarActivityType activityType = new AcademicCalendarActivityType();
|
||||
activityType.setId(20L);
|
||||
activityType.setCode("Т");
|
||||
activityType.setName("Теоретическое обучение");
|
||||
activityType.setAllowSchedule(true);
|
||||
|
||||
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
|
||||
period.setId(30L);
|
||||
period.setAcademicCalendar(calendar);
|
||||
period.setCourseNumber(1);
|
||||
period.setStartDate(LocalDate.of(2026, 9, 6));
|
||||
period.setEndDate(LocalDate.of(2026, 9, 8));
|
||||
period.setActivityType(activityType);
|
||||
|
||||
when(calendarRepository.existsById(10L)).thenReturn(true);
|
||||
when(periodRepository.findByCalendarIdWithActivity(10L)).thenReturn(List.of(period));
|
||||
|
||||
AcademicCalendarController controller = new AcademicCalendarController(
|
||||
calendarRepository,
|
||||
periodRepository,
|
||||
mock(SubjectRepository.class),
|
||||
mock(AcademicCalendarSubjectRepository.class),
|
||||
mock(AcademicCalendarGridService.class),
|
||||
mock(ScheduleGeneratorService.class),
|
||||
mock(AcademicStructureService.class)
|
||||
);
|
||||
|
||||
var response = controller.getGrid(10L);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isInstanceOf(List.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<AcademicCalendarGridDayDto> rows = (List<AcademicCalendarGridDayDto>) response.getBody();
|
||||
assertThat(rows)
|
||||
.extracting(AcademicCalendarGridDayDto::date)
|
||||
.containsExactly(
|
||||
LocalDate.of(2026, 9, 6),
|
||||
LocalDate.of(2026, 9, 7),
|
||||
LocalDate.of(2026, 9, 8)
|
||||
);
|
||||
assertThat(rows)
|
||||
.extracting(AcademicCalendarGridDayDto::weekNumber)
|
||||
.containsExactly(1, 2, 2);
|
||||
assertThat(rows)
|
||||
.extracting(AcademicCalendarGridDayDto::dayOfWeek)
|
||||
.containsExactly(7, 1, 2);
|
||||
assertThat(rows)
|
||||
.allSatisfy(row -> {
|
||||
assertThat(row.id()).isEqualTo(30L);
|
||||
assertThat(row.activityTypeId()).isEqualTo(20L);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user