Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5804ff3fe | |||
| 27edf8d959 | |||
| 33d106430c | |||
| f3b0f0ad48 | |||
| 0e6a7064d2 | |||
| 05f73d356a | |||
| 82c2dc36a6 | |||
| b959feedbf | |||
| dcdfcdd396 | |||
| a8bfd9f456 | |||
| 1bf8d4ca11 | |||
| 2b8fe77fac | |||
| e2b6227015 | |||
| 95d460c80a | |||
| 19a46b0df8 | |||
| b86e126ceb | |||
| ba48ed1a0e | |||
| e4a6cb8167 | |||
| a1fa862ed4 | |||
| aa97657721 | |||
| 1ee1b71e3e | |||
| 82512eb1db | |||
| a2672042d7 | |||
| 36fb71da02 | |||
| 7bd4acb7a6 | |||
| 48ede1420d | |||
| d076f4e98d | |||
| 7f2aabbf29 | |||
| 185cc22e34 | |||
| 509f26de7b | |||
| 0dedabc2ae | |||
| dc56f9a661 | |||
| a07338314a | |||
| 11738b6e4a | |||
| 1d45780294 | |||
| 0ffa032ade | |||
| 9d9f390548 | |||
| c84c2a6cb4 | |||
| 8034d5d0fe | |||
| fee84cef4d | |||
| 13db1388a3 | |||
| 3e91ecdd96 | |||
| 2e4873bb4b | |||
| da158cfd84 | |||
| 5476c1bab5 | |||
| a28a397205 | |||
| 13d929eb5b | |||
| 8f640d4324 | |||
| 6a6eb1a273 | |||
| d37ab69962 | |||
| 376291d3c0 |
@@ -1,10 +1,6 @@
|
|||||||
name: Deploy to IIS
|
name: Deploy to IIS
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -29,7 +25,7 @@ jobs:
|
|||||||
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
||||||
|
|
||||||
- name: Publish (local)
|
- name: Publish (local)
|
||||||
run: dotnet publish backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o "$LOCAL_PUBLISH"
|
run: dotnet publish backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o "$LOCAL_PUBLISH"
|
||||||
|
|
||||||
- name: Install cifs-utils
|
- name: Install cifs-utils
|
||||||
run: |
|
run: |
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
name: Build & Deploy to SharePoint Online
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js 22
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 10000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
for i in 1 2 3; do
|
||||||
|
npm install --legacy-peer-deps && break
|
||||||
|
echo "Attempt $i failed, retrying in 20s..."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build & package SPFx solution
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload .sppkg artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: approval-route-admin-sppkg
|
||||||
|
path: frontend/sharepoint/solution/approval-route-admin.sppkg
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Install CLI for Microsoft 365
|
||||||
|
run: |
|
||||||
|
for i in 1 2 3; do
|
||||||
|
npm install -g @pnp/cli-microsoft365@9 && break
|
||||||
|
echo "Attempt $i failed, retrying in 20s..."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
- name: Login to Microsoft 365 (certificate)
|
||||||
|
run: |
|
||||||
|
m365 login \
|
||||||
|
--authType certificate \
|
||||||
|
--appId "${{ secrets.SPO_CLIENT_ID }}" \
|
||||||
|
--certificateBase64Encoded "${{ secrets.SPO_CERT_BASE64 }}" \
|
||||||
|
--thumbprint "${{ secrets.SPO_CERT_THUMBPRINT }}" \
|
||||||
|
--tenant "${{ secrets.SPO_TENANT_ID }}"
|
||||||
|
|
||||||
|
- name: Upload & Deploy to App Catalog
|
||||||
|
env:
|
||||||
|
SOLUTION_ID: "ce823ddf-e35c-460f-a92d-ddb4ef5fc494"
|
||||||
|
CATALOG_SITE: "https://tnsukz.sharepoint.com/sites/appcatalog"
|
||||||
|
SPPKG: "sharepoint/solution/approval-route-admin.sppkg"
|
||||||
|
FILE_NAME: "approval-route-admin.sppkg"
|
||||||
|
run: |
|
||||||
|
# Получаем токен через уже аутентифицированный m365 CLI
|
||||||
|
TOKEN=$(m365 util accesstoken get --resource https://tnsukz.sharepoint.com --output text)
|
||||||
|
echo "Token length: ${#TOKEN}"
|
||||||
|
|
||||||
|
echo ">>> Uploading to App Catalog"
|
||||||
|
HTTP=$(curl -s -o /tmp/upload.json -w "%{http_code}" -X POST \
|
||||||
|
"${CATALOG_SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/appcatalog/AppCatalog')/Files/add(overwrite=true,url='${FILE_NAME}')" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "X-RequestDigest: 0x0" \
|
||||||
|
-H "Accept: application/json;odata=nometadata" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${SPPKG}" || echo "000")
|
||||||
|
echo "Upload HTTP: ${HTTP}"
|
||||||
|
cat /tmp/upload.json
|
||||||
|
[ "$HTTP" = "200" ] || [ "$HTTP" = "201" ] || exit 1
|
||||||
|
|
||||||
|
echo ">>> Deploying app (id: ${SOLUTION_ID})"
|
||||||
|
HTTP=$(curl -s -o /tmp/deploy.json -w "%{http_code}" -X POST \
|
||||||
|
"${CATALOG_SITE}/_api/web/tenantappcatalog/AvailableApps/GetById('${SOLUTION_ID}')/deploy" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "X-RequestDigest: 0x0" \
|
||||||
|
-H "Accept: application/json;odata=nometadata" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"skipFeatureDeployment":true}' || echo "000")
|
||||||
|
echo "Deploy HTTP: ${HTTP}"
|
||||||
|
cat /tmp/deploy.json
|
||||||
|
[ "$HTTP" = "200" ] || [ "$HTTP" = "204" ] || exit 1
|
||||||
|
|
||||||
|
echo ">>> Done"
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
backend-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -38,3 +38,37 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||||
run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release
|
run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js 22
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 10000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
for i in 1 2 3; do
|
||||||
|
npm install --legacy-peer-deps && break
|
||||||
|
echo "Attempt $i failed, retrying in 20s..."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
|
||||||
|
# heft test = build (typescript+sass+lint) и следом Jest — тот же смысл,
|
||||||
|
# что и npm run build, но без package-solution (упаковка .sppkg здесь не
|
||||||
|
# нужна, это часть deploy_frontend.yml, а не проверки на PR).
|
||||||
|
- name: Run frontend unit tests (Jest via heft)
|
||||||
|
run: npx heft test --clean
|
||||||
|
|||||||
+15
-1
@@ -1,8 +1,22 @@
|
|||||||
|
node_modules/
|
||||||
|
lib/
|
||||||
|
lib-commonjs/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
temp/
|
||||||
|
jest-output/
|
||||||
|
*.sppkg
|
||||||
|
.heft/
|
||||||
|
sharepoint/solution/
|
||||||
|
frontend/.idea/
|
||||||
|
.vs/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.example
|
.env.example
|
||||||
*.user
|
*.user
|
||||||
|
workspace.xml
|
||||||
**/bin/
|
**/bin/
|
||||||
**/obj/
|
**/obj/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
logs/
|
||||||
|
appsettings.Local.json
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Стайл-гайд для Клода
|
||||||
|
|
||||||
|
- У `enum` первое значение (0) — всегда `None`.
|
||||||
|
- Сборка не должна выдавать ни одного warning.
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
# Tnsu.ApprovalRoute
|
# Tnsu.ApprovalRoute
|
||||||
|
|
||||||
Каркас проекта на .NET 10, повторяющий архитектуру [tnsu-inventory](../tnsu-inventory): 4 слоя (Api / Application / Domain / Infrastructure), MediatR, EF Core + PostgreSQL. Бизнес-логика не реализована — только структура решения и минимальная точка входа.
|
Сервис маршрутов согласования: типы заявок (`RequestType`) и шаблонные маршруты согласования (`TemplateRoute` → `TemplateRouteStep`) с разрешением подходящего маршрута под конкретные параметры (проект, тип заявки, сумма и т.д.).
|
||||||
|
|
||||||
|
Архитектура повторяет [tnsu-inventory](../tnsu-inventory): 4 слоя (Api / Application / Domain / Infrastructure), MediatR-стиль сервисов, EF Core + PostgreSQL.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|------------|
|
||||||
|
| Backend | .NET 10, ASP.NET Core (MVC controllers), EF Core |
|
||||||
|
| БД | PostgreSQL |
|
||||||
|
| Авторизация | Entra ID (JWT Bearer) либо dev-схема (`X-Dev-User-Email`) при отсутствии конфигурации Entra |
|
||||||
|
| API-документация | Scalar (`/scalar`) на основе OpenAPI |
|
||||||
|
| Интеграция | `api.tnsu.kz` (портал 1С) |
|
||||||
|
| Frontend | SPFx-веб-парт (React + FluentUI) — админка маршрутов согласования на портале SharePoint |
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
@@ -9,22 +22,55 @@ backend/
|
|||||||
Directory.Build.props # общие настройки таргета (net10.0, nullable, etc.)
|
Directory.Build.props # общие настройки таргета (net10.0, nullable, etc.)
|
||||||
Directory.Packages.props # централизованные версии пакетов
|
Directory.Packages.props # централизованные версии пакетов
|
||||||
Tnsu.ApprovalRoute.sln
|
Tnsu.ApprovalRoute.sln
|
||||||
src/
|
Tnsu.ApprovalRoute.Domain/ # сущности и enum'ы домена
|
||||||
Tnsu.ApprovalRoute.Domain/ # сущности домена (пусто)
|
Tnsu.ApprovalRoute.Application/ # сервисы, DTO, валидаторы
|
||||||
Tnsu.ApprovalRoute.Application/ # MediatR-хендлеры, DI-регистрация слоя
|
Tnsu.ApprovalRoute.Infrastructure/ # EF Core DbContext, миграции, клиент tnsu-portal
|
||||||
Tnsu.ApprovalRoute.Infrastructure/ # EF Core DbContext, DI-регистрация слоя
|
Tnsu.ApprovalRoute.Api/ # ASP.NET Core Controllers, Program.cs, авторизация
|
||||||
Tnsu.ApprovalRoute.Api/ # ASP.NET Core Minimal API, Program.cs
|
tests/
|
||||||
|
Tnsu.ApprovalRoute.IntegrationTests/
|
||||||
|
frontend/
|
||||||
|
src/webparts/approvalAdmin/ # SPFx веб-парт «ApprovalAdmin» (React, FluentUI)
|
||||||
|
docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Frontend (SPFx)
|
||||||
|
|
||||||
|
Админка маршрутов согласования встраивается веб-частью SPFx на портал SharePoint (аналогично `tnsu-inventory`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run start # heft start — локальный SharePoint Workbench
|
||||||
|
npm run build # heft test + package-solution --production
|
||||||
|
```
|
||||||
|
|
||||||
|
Свойства веб-части: `apiBaseUrl`, `useDevAuth`, `devUserEmail`, `aadResourceId`, `portalBaseUrl`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|------|----------|
|
||||||
|
| CRUD | `/api/request-types` | Типы заявок |
|
||||||
|
| CRUD | `/api/template-routes` | Шаблонные маршруты согласования |
|
||||||
|
| GET | `/api/template-routes/resolve` | Подбор подходящего маршрута по параметрам |
|
||||||
|
| CRUD | `/api/template-routes/{routeId}/steps` | Шаги шаблонного маршрута |
|
||||||
|
| GET | `/health` | Health-check |
|
||||||
|
|
||||||
## Запуск локально
|
## Запуск локально
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
dotnet run --project src/Tnsu.ApprovalRoute.Api
|
dotnet run --project Tnsu.ApprovalRoute.Api
|
||||||
```
|
```
|
||||||
|
|
||||||
Требуется PostgreSQL (строка подключения — `ConnectionStrings:Postgres` в `appsettings.json`).
|
Требуется PostgreSQL (строка подключения — `ConnectionStrings:Postgres` в `appsettings.json`).
|
||||||
|
|
||||||
|
Авторизация:
|
||||||
|
- Entra ID — задать `Entra:TenantId` и `Entra:Audience` в конфигурации
|
||||||
|
- Без Entra — используется dev-схема, текущий пользователь передаётся заголовком `X-Dev-User-Email`
|
||||||
|
|
||||||
|
После запуска документация API доступна на `/scalar`.
|
||||||
|
|
||||||
## Запуск через Docker Compose
|
## Запуск через Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -32,4 +78,17 @@ cp .env.example .env
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Поднимает `postgres` и `api` (health-check на `/health`).
|
Поднимает `postgres` и `api`.
|
||||||
|
|
||||||
|
| Сервис | URL |
|
||||||
|
|--------|-----|
|
||||||
|
| API | http://localhost:8082 |
|
||||||
|
| API health | http://localhost:8082/health |
|
||||||
|
| Postgres | localhost:5435 |
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"Version": 1,
|
||||||
|
"WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\",
|
||||||
|
"Documents": [
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DocumentGroupContainers": [
|
||||||
|
{
|
||||||
|
"Orientation": 0,
|
||||||
|
"VerticalTabListWidth": 256,
|
||||||
|
"DocumentGroups": [
|
||||||
|
{
|
||||||
|
"DockedWidth": 200,
|
||||||
|
"SelectedChildIndex": 0,
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 0,
|
||||||
|
"Title": "Program.cs",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ViewState": "AgIAABgAAAAAAAAAAAAAAB4AAAAmAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
|
||||||
|
"WhenOpened": "2026-07-28T06:33:51.834Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 1,
|
||||||
|
"Title": "appsettings.json",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ViewState": "AgIAAAAAAAAAAAAAAAAAABoAAAAAAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
|
||||||
|
"WhenOpened": "2026-07-28T06:02:34.015Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"Version": 1,
|
||||||
|
"WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\",
|
||||||
|
"Documents": [
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DocumentGroupContainers": [
|
||||||
|
{
|
||||||
|
"Orientation": 0,
|
||||||
|
"VerticalTabListWidth": 256,
|
||||||
|
"DocumentGroups": [
|
||||||
|
{
|
||||||
|
"DockedWidth": 200,
|
||||||
|
"SelectedChildIndex": 0,
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 0,
|
||||||
|
"Title": "Program.cs",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ViewState": "AgIAABUAAAAAAAAAAAAAADgAAAABAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
|
||||||
|
"WhenOpened": "2026-07-28T06:33:51.834Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 1,
|
||||||
|
"Title": "appsettings.json",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ViewState": "AgIAAAAAAAAAAAAAAAAAABoAAAAAAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
|
||||||
|
"WhenOpened": "2026-07-28T06:02:34.015Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<!--
|
||||||
|
Транзитивные зависимости тоже подчиняются версиям ниже. Нужно из-за Hangfire:
|
||||||
|
он тянет Newtonsoft.Json 11.0.1 с известной уязвимостью, и без пиннинга её
|
||||||
|
не поднять, не добавляя пакет в проекты, которым он сам по себе не нужен.
|
||||||
|
-->
|
||||||
|
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||||
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
|
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.24" />
|
||||||
|
<PackageVersion Include="Hangfire.Core" Version="1.8.24" />
|
||||||
|
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||||
@@ -15,10 +25,27 @@
|
|||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
<!--
|
||||||
|
Только чтобы поднять транзитивную версию из Microsoft.AspNetCore.OpenApi (2.0.0
|
||||||
|
с уязвимостью GHSA-v5pm-xwqc-g5wc, исправлена в 2.7.5) — напрямую не используется.
|
||||||
|
Убрать, когда Microsoft.AspNetCore.OpenApi сам подтянет исправленную версию.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
|
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
||||||
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageVersion Include="NPOI" Version="2.7.6" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
|
<!--
|
||||||
|
Только чтобы поднять транзитивную версию из NPOI (8.0.2 с известной
|
||||||
|
уязвимостью, см. GHSA-8q5v-6pqq-x66h и соседние advisories той же пачки)
|
||||||
|
— напрямую не используется. Убрать, когда NPOI сам подтянет исправленную.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.11" />
|
||||||
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthSchemes
|
||||||
|
{
|
||||||
|
/// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary>
|
||||||
|
public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Токен приложения (client_credentials) — вызовы без участия пользователя,
|
||||||
|
/// например Power Automate, который по расписанию забирает напоминания.
|
||||||
|
/// </summary>
|
||||||
|
public const string AppOnly = "AppOnly";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AuthPolicies
|
||||||
|
{
|
||||||
|
/// <summary>Только пользовательские токены.</summary>
|
||||||
|
public const string Delegated = "Delegated";
|
||||||
|
|
||||||
|
/// <summary>Только токены приложений.</summary>
|
||||||
|
public const string AppOnly = "AppOnlyPolicy";
|
||||||
|
|
||||||
|
/// <summary>Любой из двух — политика по умолчанию для всех контроллеров.</summary>
|
||||||
|
public const string Any = "AnyAuth";
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthSetup
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Две схемы аутентификации вместо одной: помимо пользовательского токена
|
||||||
|
/// (админка) API должен принимать токен ПРИЛОЖЕНИЯ — эндпоинт напоминаний
|
||||||
|
/// вызывает Power Automate по расписанию, там нет ни пользователя, ни
|
||||||
|
/// интерактивного входа.
|
||||||
|
///
|
||||||
|
/// Сделано по образцу api-tnsu-portal/AuthRegistration.cs, включая настройки
|
||||||
|
/// Entra — приложение регистрируется то же самое.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var tenantId = configuration["Entra:TenantId"];
|
||||||
|
var audience = configuration["Entra:Audience"];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
|
||||||
|
|
||||||
|
AddEntraAuth(services, configuration, tenantId, audience);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddEntraAuth(
|
||||||
|
IServiceCollection services, IConfiguration configuration, string tenantId, string audience)
|
||||||
|
{
|
||||||
|
// Белый список приложений, которым разрешён app-only доступ. Пустой список
|
||||||
|
// означает «не ограничивать» — иначе на пустой конфигурации сломались бы
|
||||||
|
// вообще все app-only вызовы, что при выкладке выглядит как необъяснимый 401.
|
||||||
|
var allowedAppIds = configuration.GetSection("Entra:AllowedAppIds").Get<string[]>() ?? [];
|
||||||
|
|
||||||
|
var validationParameters = () => new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidAudiences = [audience, $"api://{audience}"],
|
||||||
|
ValidIssuers =
|
||||||
|
[
|
||||||
|
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||||
|
$"https://sts.windows.net/{tenantId}/"
|
||||||
|
],
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true
|
||||||
|
};
|
||||||
|
|
||||||
|
services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = AuthSchemes.Bearer;
|
||||||
|
options.DefaultChallengeScheme = AuthSchemes.Bearer;
|
||||||
|
})
|
||||||
|
.AddJwtBearer(AuthSchemes.Bearer, options =>
|
||||||
|
{
|
||||||
|
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||||
|
options.TokenValidationParameters = validationParameters();
|
||||||
|
// Без этого JwtBearerHandler переименовывает короткие имена claim'ов
|
||||||
|
// из токена в длинные ClaimTypes-URI (upn → ClaimTypes.Upn,
|
||||||
|
// unique_name → ClaimTypes.Name — а НЕ ClaimTypes.Email!) ещё до
|
||||||
|
// того, как код увидит ClaimsPrincipal. CurrentUserAccessor ищет
|
||||||
|
// ровно сырые имена ("upn", "unique_name") — с ремаппингом они не
|
||||||
|
// совпадали ни с чем, и email инициатора не находился вообще
|
||||||
|
// (не только у токенов без claim'а email, как этот — у ЛЮБЫХ).
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthentication()
|
||||||
|
.AddJwtBearer(AuthSchemes.AppOnly, options =>
|
||||||
|
{
|
||||||
|
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||||
|
options.TokenValidationParameters = validationParameters();
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
// Подпись, издатель и аудитория у app-only токена те же, что у
|
||||||
|
// пользовательского, поэтому единственное, что реально отличает
|
||||||
|
// «свой» вызывающий сервис от любого приложения тенанта, — appid.
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
var claims = context.Principal?.Claims;
|
||||||
|
var appId = claims?.FirstOrDefault(c => c.Type == "appid")?.Value
|
||||||
|
?? claims?.FirstOrDefault(c => c.Type == "azp")?.Value;
|
||||||
|
|
||||||
|
if (allowedAppIds.Length > 0 && !allowedAppIds.Contains(appId))
|
||||||
|
{
|
||||||
|
context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("Auth.AppOnly")
|
||||||
|
.LogWarning("Приложение «{AppId}» отклонено — его нет в Entra:AllowedAppIds", appId);
|
||||||
|
context.Fail($"Приложение «{appId}» не входит в список разрешённых.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(AuthPolicies.Delegated, policy =>
|
||||||
|
{
|
||||||
|
policy.AuthenticationSchemes.Add(AuthSchemes.Bearer);
|
||||||
|
policy.RequireAuthenticatedUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddPolicy(AuthPolicies.AppOnly, policy =>
|
||||||
|
{
|
||||||
|
policy.AuthenticationSchemes.Add(AuthSchemes.AppOnly);
|
||||||
|
policy.RequireAuthenticatedUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
// RequireAssertion, а не RequireAuthenticatedUser: политика с двумя
|
||||||
|
// схемами падает, если ОДНА из них отвергла токен, даже когда вторая
|
||||||
|
// его приняла. Здесь достаточно, чтобы аутентифицировала любая.
|
||||||
|
options.AddPolicy(AuthPolicies.Any, policy =>
|
||||||
|
{
|
||||||
|
policy.AddAuthenticationSchemes(AuthSchemes.Bearer, AuthSchemes.AppOnly);
|
||||||
|
policy.RequireAssertion(ctx => ctx.User.Identities.Any(i => i.IsAuthenticated));
|
||||||
|
});
|
||||||
|
|
||||||
|
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -7,5 +7,7 @@ public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor
|
|||||||
{
|
{
|
||||||
public string? Email =>
|
public string? Email =>
|
||||||
httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
|
httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
|
||||||
?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username");
|
?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username")
|
||||||
|
?? httpContextAccessor.HttpContext?.User.FindFirstValue("upn")
|
||||||
|
?? httpContextAccessor.HttpContext?.User.FindFirstValue("unique_name");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Hangfire.Dashboard;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Простая Basic Auth для дашборда Hangfire — единственный логин/пароль из
|
||||||
|
/// конфигурации (Hangfire:Dashboard), а не полноценная схема аутентификации: сам
|
||||||
|
/// дашборд не участвует в остальной авторизации API (это отдельная страница,
|
||||||
|
/// открываемая прямо в браузере, не по JWT).
|
||||||
|
///
|
||||||
|
/// Пустые логин/пароль (не заданы в конфигурации) означают «дашборд закрыт для
|
||||||
|
/// всех» — не «открыт для всех»: пустая строка в конфиге не должна случайно снять
|
||||||
|
/// защиту.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HangfireDashboardBasicAuthFilter(string username, string password) : IDashboardAuthorizationFilter
|
||||||
|
{
|
||||||
|
public bool Authorize(DashboardContext context)
|
||||||
|
{
|
||||||
|
var httpContext = context.GetHttpContext();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) &&
|
||||||
|
TryGetBasicAuthCredentials(httpContext.Request.Headers.Authorization, out var user, out var pass) &&
|
||||||
|
FixedTimeEquals(user, username) && FixedTimeEquals(pass, password))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = "Basic realm=\"Hangfire\"";
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetBasicAuthCredentials(
|
||||||
|
string? authorizationHeader, out string username, out string password)
|
||||||
|
{
|
||||||
|
username = string.Empty;
|
||||||
|
password = string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(authorizationHeader) ||
|
||||||
|
!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var decoded = Encoding.UTF8.GetString(
|
||||||
|
Convert.FromBase64String(authorizationHeader["Basic ".Length..].Trim()));
|
||||||
|
var separatorIndex = decoded.IndexOf(':');
|
||||||
|
if (separatorIndex < 0) return false;
|
||||||
|
|
||||||
|
username = decoded[..separatorIndex];
|
||||||
|
password = decoded[(separatorIndex + 1)..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
// Битый base64 в заголовке — не наша забота, просто не авторизуем.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сравнение за постоянное время: длина логина/пароля в Basic Auth и так видна
|
||||||
|
// по заголовку, но посимвольное сравнение через == утекало бы ЕЩЁ и через
|
||||||
|
// тайминг совпадения символов — дешёво закрыть и это.
|
||||||
|
private static bool FixedTimeEquals(string a, string b) =>
|
||||||
|
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
|
||||||
|
/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
|
||||||
|
/// появления ProbationEnding.
|
||||||
|
///
|
||||||
|
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
||||||
|
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
||||||
|
/// открыть под пользователем, чтобы посмотреть, что сервис реально отдаёт.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
|
[Route("api/reminders")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RemindersController(ReminderService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) =>
|
||||||
|
(await service.GetRemindersAsync(feed, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузки отчётов файлами. В отличие от остальных контроллеров отдаёт не
|
||||||
|
/// ApiResponse, а сам файл: получатель — кнопка «скачать» в портале и Power
|
||||||
|
/// Automate, обоим нужен готовый xlsx, а не JSON с телом в base64.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
|
[Route("api/reports")]
|
||||||
|
public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string XlsxContentType =
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отчёт по вакансиям за период — лист «Список вакансий (все)» по данным
|
||||||
|
/// заявок на подбор и на приём.
|
||||||
|
///
|
||||||
|
/// Период отбирает заявки по «Дате подачи заявки» (колонка H отчёта). Заявки
|
||||||
|
/// на приём периодом НЕ ограничены: у вакансии, поданной в начале интервала,
|
||||||
|
/// приём мог оформляться уже после его конца.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("vacancies")]
|
||||||
|
[Produces(XlsxContentType)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Vacancies(
|
||||||
|
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await vacancies.GetAsync(from, to, ct);
|
||||||
|
if (result.IsFailed)
|
||||||
|
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
||||||
|
|
||||||
|
var report = result.Value;
|
||||||
|
var file = VacancyReportWorkbook.Build(report);
|
||||||
|
|
||||||
|
// Имя файла с кириллицей и пробелами — через ContentDispositionHeaderValue:
|
||||||
|
// он сам добавит filename* в RFC 5987, иначе браузер сохранит файл
|
||||||
|
// с искажённым именем.
|
||||||
|
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||||
|
disposition.SetHttpFileName(VacancyReportWorkbook.FileName(report));
|
||||||
|
Response.Headers.ContentDisposition = disposition.ToString();
|
||||||
|
|
||||||
|
return File(file, XlsxContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||||
|
/// POST — потому что в теле список id типов (см. RouteExportService).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("routes")]
|
||||||
|
[Produces(XlsxContentType)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Routes([FromBody] RouteExportRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
|
||||||
|
if (result.IsFailed)
|
||||||
|
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
||||||
|
|
||||||
|
var file = result.Value;
|
||||||
|
|
||||||
|
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||||
|
disposition.SetHttpFileName(file.FileName);
|
||||||
|
Response.Headers.ContentDisposition = disposition.ToString();
|
||||||
|
|
||||||
|
return File(file.Content, XlsxContentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение заявок и маршрутов согласования от имени приложения (см.
|
||||||
|
/// RequestManageService) — для вкладок «Редактирование заявок» и «Заявки по
|
||||||
|
/// согласующему»: результат не зависит от прав вызывающего пользователя на
|
||||||
|
/// списки SharePoint, в отличие от прежнего прямого PnP-чтения с фронтенда.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/request-manage")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestManageController(RequestManageService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Заявки типа requestTypeId по всем партициям (архив + текущая), постранично.</summary>
|
||||||
|
[HttpGet("search")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ManagedRequestPageDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Search(
|
||||||
|
[FromQuery] int requestTypeId,
|
||||||
|
// string? (не string) — [ApiController] с Nullable enable иначе считает
|
||||||
|
// query обязательным параметром модели, а IHttpClient.buildUrl (см.
|
||||||
|
// httpClient.ts) вырезает из URL пустые query-параметры целиком: пустое
|
||||||
|
// поле поиска (обычное состояние при открытии комбобокса) присылало
|
||||||
|
// запрос вовсе БЕЗ query, и такой запрос падал 400 ещё до контроллера.
|
||||||
|
[FromQuery] string? query,
|
||||||
|
[FromQuery] int page,
|
||||||
|
[FromQuery] int? pageSize,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
(await service.SearchAsync(requestTypeId, query ?? string.Empty, page < 1 ? 1 : page, pageSize, ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>Заявка + полный маршрут согласования (обе фазы, по StepOrder).</summary>
|
||||||
|
[HttpGet("matrix")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ManagedRequestMatrixDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Matrix(
|
||||||
|
[FromQuery] int requestTypeId, [FromQuery] string listId, [FromQuery] int requestId, CancellationToken ct) =>
|
||||||
|
(await service.GetMatrixAsync(requestTypeId, listId, requestId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Активные строки согласования одного человека сразу по всем типам заявок и
|
||||||
|
/// партициям. statuses — через запятую (см. REQUEST_STATUS во фронтенде).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("by-approver")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserApprovalRowDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ByApprover(
|
||||||
|
[FromQuery] string approverEmail, [FromQuery] string? statuses, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var activeStatuses = (statuses ?? string.Empty)
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return (await service.FindActiveApprovalRowsForApproverAsync(approverEmail ?? string.Empty, activeStatuses, ct))
|
||||||
|
.ToActionResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройка доступа «видеть все заявки типа», а не только свои. Список
|
||||||
|
/// email'ов по типу дёргает и портал заявок (проверить текущего пользователя),
|
||||||
|
/// и Power Automate — поэтому политика AnyAuth, как у RemindersController.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
|
[Route("api/request-type-access")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestTypeAccessController(RequestTypeAccessService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeAccessDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{requestTypeId:int}/emails")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ListEmails(int requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListEmailsAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeAccessDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Create(CreateRequestTypeAccessRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас
|
||||||
|
/// и вся серия для админки/портала.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/request-types")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Серии партиций ВСЕХ типов заявок разом — для портала: чтобы искать по
|
||||||
|
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
|
||||||
|
/// запрашивать серию по каждому типу отдельно.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title">
|
||||||
|
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||||
|
/// без учёта регистра, по вхождению подстроки.
|
||||||
|
/// </param>
|
||||||
|
[HttpGet("lists")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ListAll([FromQuery] string? title, CancellationToken ct) =>
|
||||||
|
(await service.ListAllAsync(title, ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
|
||||||
|
[HttpGet("{requestTypeId:int}/lists")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestListPartitionDto>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> List(int requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Партиция, в которую нужно писать новые заявки этого типа.
|
||||||
|
///
|
||||||
|
/// Не чистый GET: если партиция добрала порог элементов, запрос ставит в очередь
|
||||||
|
/// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{requestTypeId:int}/lists/current")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CurrentListPartitionDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Current(int requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.GetCurrentAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Поиск шагов шаблонов маршрутов НЕ по конкретному маршруту, а сквозной, по всем
|
||||||
|
/// маршрутам разом — отдельный top-level контроллер, а не вложенный под
|
||||||
|
/// /api/template-routes/{routeId}/steps (тот же приём, что RequestTypeListsController
|
||||||
|
/// отдельно от RequestTypesController).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/template-route-steps")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class TemplateRouteStepSearchController(TemplateRouteStepService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «заменить согласующего everywhere».</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string executorEmail, CancellationToken ct) =>
|
||||||
|
(await service.ListByExecutorEmailAsync(executorEmail, ct)).ToActionResult();
|
||||||
|
}
|
||||||
+5
-2
@@ -11,11 +11,14 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
|||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("resolve")]
|
// POST, а не GET: запрос принимает список условий (field_name/value) переменной длины,
|
||||||
|
// который не ложится на плоскую query-строку без индексной эквилибристики. Единственный
|
||||||
|
// потребитель — диагностический экран «Проверка маршрута» в админке, внешних вызовов нет.
|
||||||
|
[HttpPost("resolve")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
public async Task<IActionResult> Resolve([FromBody] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
||||||
(await service.ResolveAsync(request, ct)).ToActionResult();
|
(await service.ResolveAsync(request, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY Directory.Build.props Directory.Packages.props ./
|
||||||
|
COPY Tnsu.ApprovalRoute.Domain/Tnsu.ApprovalRoute.Domain.csproj Tnsu.ApprovalRoute.Domain/
|
||||||
|
COPY Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj Tnsu.ApprovalRoute.Application/
|
||||||
|
COPY Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj Tnsu.ApprovalRoute.Infrastructure/
|
||||||
|
COPY Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj Tnsu.ApprovalRoute.Api/
|
||||||
|
RUN dotnet restore Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj
|
||||||
|
COPY Tnsu.ApprovalRoute.Domain/ Tnsu.ApprovalRoute.Domain/
|
||||||
|
COPY Tnsu.ApprovalRoute.Application/ Tnsu.ApprovalRoute.Application/
|
||||||
|
COPY Tnsu.ApprovalRoute.Infrastructure/ Tnsu.ApprovalRoute.Infrastructure/
|
||||||
|
COPY Tnsu.ApprovalRoute.Api/ Tnsu.ApprovalRoute.Api/
|
||||||
|
RUN dotnet publish Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["dotnet", "Tnsu.ApprovalRoute.Api.dll"]
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Extensions.Hosting.AsyncInitialization;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.PostgreSql;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.OpenApi;
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
using Serilog;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Filters;
|
||||||
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
|
const string CorsPolicyName = "Frontend";
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Локальные оверрайды, не попадающие в git (см. .gitignore) — на своей машине
|
||||||
|
// можно, например, занизить ListPartitioning:ItemCountThreshold, чтобы не
|
||||||
|
// накапливать тысячи тестовых заявок для проверки создания партиций.
|
||||||
|
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
|
||||||
|
|
||||||
|
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||||
|
.ReadFrom.Configuration(context.Configuration)
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.WriteTo.File(
|
||||||
|
"logs/log-.txt",
|
||||||
|
rollingInterval: RollingInterval.Month,
|
||||||
|
retainedFileCountLimit: 6));
|
||||||
|
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders =
|
||||||
|
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
var corsAllowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(CorsPolicyName, policy => policy
|
||||||
|
.WithOrigins(corsAllowedOrigins)
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
|
||||||
|
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
|
||||||
|
.WithExposedHeaders("Content-Disposition"));
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
|
builder.Services.AddApprovalRouteApplication();
|
||||||
|
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||||
|
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
||||||
|
builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports"));
|
||||||
|
builder.Services.Configure<AutoApproveOptions>(builder.Configuration.GetSection("AutoApprove"));
|
||||||
|
|
||||||
|
builder.Services.AddHangfire(configuration => configuration
|
||||||
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||||
|
.UseSimpleAssemblyNameTypeSerializer()
|
||||||
|
.UseRecommendedSerializerSettings()
|
||||||
|
.UsePostgreSqlStorage(postgres =>
|
||||||
|
postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres"))));
|
||||||
|
|
||||||
|
builder.Services.AddHangfireServer();
|
||||||
|
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||||
|
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
||||||
|
builder.Services.AddOpenApi(options =>
|
||||||
|
{
|
||||||
|
options.AddDocumentTransformer((document, _, _) =>
|
||||||
|
{
|
||||||
|
document.Components ??= new OpenApiComponents();
|
||||||
|
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
|
||||||
|
document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer",
|
||||||
|
BearerFormat = "JWT",
|
||||||
|
Description = "Введите JWT токен"
|
||||||
|
};
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
options.AddOperationTransformer((operation, context, _) =>
|
||||||
|
{
|
||||||
|
operation.Security =
|
||||||
|
[
|
||||||
|
new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[new OpenApiSecuritySchemeReference("Bearer", context.Document)] = []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.Services.AddHealthChecks();
|
||||||
|
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
|
||||||
|
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
|
app.MapOpenApi();
|
||||||
|
|
||||||
|
app.MapScalarApiReference(options =>
|
||||||
|
{
|
||||||
|
options
|
||||||
|
.WithTitle("Tnsu Approval Route")
|
||||||
|
.AddPreferredSecuritySchemes("Bearer")
|
||||||
|
.AddHttpAuthentication("Bearer", _ => { });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
app.UseCors(CorsPolicyName);
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
|
{
|
||||||
|
Authorization =
|
||||||
|
[
|
||||||
|
new HangfireDashboardBasicAuthFilter(
|
||||||
|
app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty,
|
||||||
|
app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Рекуррентная задача регистрируется ПОСЛЕ Build (нужен собранный контейнер) и
|
||||||
|
// на каждом старте: AddOrUpdate идемпотентен и заодно подхватывает изменившееся
|
||||||
|
// расписание из конфигурации — иначе задача навсегда осталась бы с тем cron, с
|
||||||
|
// которым её создали в первый раз.
|
||||||
|
var autoApprove = app.Services.GetRequiredService<IOptions<AutoApproveOptions>>().Value;
|
||||||
|
var recurringJobs = app.Services.GetRequiredService<IRecurringJobManager>();
|
||||||
|
if (autoApprove.Enabled)
|
||||||
|
{
|
||||||
|
recurringJobs.AddOrUpdate<AutoApproveJob>(
|
||||||
|
AutoApproveJob.RecurringJobId,
|
||||||
|
job => job.RunAsync(CancellationToken.None),
|
||||||
|
autoApprove.Cron,
|
||||||
|
// Cron в настройке — в UTC, как и всё остальное время в сервисе; иначе
|
||||||
|
// расписание поехало бы при смене часового пояса машины.
|
||||||
|
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Выключили настройкой — снимаем задачу, а не оставляем её вхолостую крутиться.
|
||||||
|
recurringJobs.RemoveIfExists(AutoApproveJob.RecurringJobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
await app.InitAndRunAsync();
|
||||||
|
|
||||||
|
public partial class Program;
|
||||||
+9
@@ -9,6 +9,15 @@
|
|||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:5443;http://localhost:5080",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
@@ -3,8 +3,13 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="appsettings.*.json" DependentUpon="appsettings.json" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||||
|
<PackageReference Include="Hangfire.AspNetCore" />
|
||||||
|
<PackageReference Include="Hangfire.PostgreSql" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
@@ -12,5 +17,8 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Scalar.AspNetCore" />
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||||
|
},
|
||||||
|
"ListPartitioning": {
|
||||||
|
"ItemCountThreshold": 99999
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
|
"Hangfire": "Warning",
|
||||||
|
"System.Net.Http.HttpClient": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Postgres": "Host=db-master.tnsu.kz;Database=tnsu_approval_route;Username=dbuser;Password=p}{6{Pa]{JQwP1H"
|
||||||
|
},
|
||||||
|
"Entra": {
|
||||||
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
|
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"AllowedAppIds": []
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"https://tnsukz.sharepoint.com",
|
||||||
|
"https://localhost:4321"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AutoApprove": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Cron": "0 * * * *",
|
||||||
|
"MaxRowsPerRun": 200
|
||||||
|
},
|
||||||
|
"ListPartitioning": {
|
||||||
|
"ItemCountThreshold": 4900,
|
||||||
|
"StaleLockAfter": "00:30:00",
|
||||||
|
"CheckThresholdOnRead": true
|
||||||
|
},
|
||||||
|
"Hangfire": {
|
||||||
|
"Dashboard": {
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": "RzaT2krczbRpE36QAucdCXR"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SharePoint": {
|
||||||
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
|
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"CertificateSettingName": "AzurePfx",
|
||||||
|
"CertificatePassword": "Rk.xDjccnfyjdktybz1",
|
||||||
|
"NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint"
|
||||||
|
},
|
||||||
|
"TnsuPortal": {
|
||||||
|
"BaseUrl": "https://api.tnsu.kz",
|
||||||
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
|
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"Scope": "",
|
||||||
|
"DirectorTansuEmail": "a.samayeva@tnsu.kz",
|
||||||
|
"ManagingDirectorTansuEmail": "nkim@tnsu.kz"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application;
|
||||||
|
|
||||||
|
public static class ApplicationServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<ReminderService>();
|
||||||
|
services.AddScoped<RequestTypeService>();
|
||||||
|
services.AddScoped<RequestTypeAccessService>();
|
||||||
|
services.AddScoped<RequestManageService>();
|
||||||
|
services.AddScoped<ListPartitionRegistry>();
|
||||||
|
services.AddScoped<ListPartitionService>();
|
||||||
|
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||||
|
services.AddScoped<CreateNextListPartitionJob>();
|
||||||
|
services.AddScoped<AutoApproveService>();
|
||||||
|
services.AddScoped<AutoApproveJob>();
|
||||||
|
services.AddScoped<VacancyReportService>();
|
||||||
|
services.AddScoped<RouteExportService>();
|
||||||
|
services.AddScoped<TemplateRouteService>();
|
||||||
|
services.AddScoped<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конвенция имён списков портала заявок: у каждой пары списков имя данных и имя
|
||||||
|
/// согласования отличаются только суффиксом после длинного тире (см.
|
||||||
|
/// Get-NumberedListTitle в request-portal/provisioning/Provision-Lists.ps1 —
|
||||||
|
/// именно провижинер эти имена и ставит).
|
||||||
|
///
|
||||||
|
/// Отдельным классом, а не приватным методом сервиса: правило нужно и
|
||||||
|
/// напоминаниям (ReminderService), и автосогласованию (AutoApproveService), и
|
||||||
|
/// разъехаться между ними оно не должно — переименование одного списка из пары
|
||||||
|
/// молча оставило бы один из сценариев без строк.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ApprovalListNaming
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// «Заявка на приём 1 — данные» → «Заявка на приём 1 — согласование».
|
||||||
|
/// null — имя не по конвенции, список согласования по нему не найти.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ApprovalListTitleFor(string dataListTitle)
|
||||||
|
{
|
||||||
|
var index = dataListTitle.LastIndexOf('—');
|
||||||
|
if (index < 0) return null;
|
||||||
|
|
||||||
|
var suffix = dataListTitle[(index + 1)..].Trim();
|
||||||
|
return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"{dataListTitle[..index].TrimEnd()} — согласование"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
||||||
|
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
||||||
|
/// именно он эти списки и создаёт.
|
||||||
|
///
|
||||||
|
/// Общая для напоминаний (ReminderService) и автосогласования
|
||||||
|
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
|
||||||
|
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
|
||||||
|
/// просроченным шаг считается в одном месте и не считается в другом.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ApprovalRow(
|
||||||
|
int Id,
|
||||||
|
int TicketId,
|
||||||
|
string Status,
|
||||||
|
string Phase,
|
||||||
|
DateTimeOffset? Date,
|
||||||
|
string? ApproverEmail,
|
||||||
|
string? ApproverName,
|
||||||
|
int? SlaHours,
|
||||||
|
bool? AskOverdueReason,
|
||||||
|
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
|
||||||
|
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
|
||||||
|
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
|
||||||
|
// сценария — см. RowSelect у каждого из них.
|
||||||
|
double Order = 0,
|
||||||
|
bool AutoApprove = false)
|
||||||
|
{
|
||||||
|
public static ApprovalRow? TryParse(JsonElement item)
|
||||||
|
{
|
||||||
|
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||||
|
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
||||||
|
|
||||||
|
var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||||
|
? a
|
||||||
|
: (JsonElement?)null;
|
||||||
|
|
||||||
|
return new ApprovalRow(
|
||||||
|
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||||
|
ticketId,
|
||||||
|
GetString(item, "ApprovalStatus") ?? string.Empty,
|
||||||
|
// Пустая фаза — старые строки, заведённые до появления колонки;
|
||||||
|
// трактуем как «Согласование» (так же поступает request-portal).
|
||||||
|
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
||||||
|
TryGetDate(item, "Date"),
|
||||||
|
approverObject is { } ao ? GetString(ao, "EMail") : null,
|
||||||
|
// Title у персонального поля — не всегда заполнен (например, у
|
||||||
|
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
|
||||||
|
approverObject is { } an ? GetString(an, "Title") : null,
|
||||||
|
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
||||||
|
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null,
|
||||||
|
// StepOrder — Number, и он бывает ДРОБНЫМ: делегат вклинивается между
|
||||||
|
// шагами долей (см. ApproverService.delegate в request-portal),
|
||||||
|
// поэтому здесь double, а не int.
|
||||||
|
TryGetDouble(item, "StepOrder", out var order) ? order : 0,
|
||||||
|
TryGetBool(item, "AutoApprove", out var auto) && auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Целое из JSON — с поправкой на то, КАК SharePoint отдаёт Number-колонки.
|
||||||
|
///
|
||||||
|
/// Одного TryGetInt32 здесь мало: у колонки типа Number значение приходит
|
||||||
|
/// дробным («1.0», а не «1»), а TryGetInt32 на числе с десятичной точкой
|
||||||
|
/// возвращает false — и поле молча становилось null. На фронтенде этой
|
||||||
|
/// проблемы нет вовсе: в JavaScript 1.0 и 1 — одно и то же число, поэтому
|
||||||
|
/// там те же данные читались верно, а на бэкенде терялись.
|
||||||
|
///
|
||||||
|
/// Строку тоже принимаем: с odata=verbose (и у части полей) SharePoint
|
||||||
|
/// отдаёт числа строками.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryGetInt(JsonElement e, string name, out int value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return false;
|
||||||
|
|
||||||
|
if (v.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
if (v.TryGetInt32(out value)) return true;
|
||||||
|
if (!v.TryGetDouble(out var d) || d is < int.MinValue or > int.MaxValue) return false;
|
||||||
|
value = (int)Math.Round(d);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.ValueKind != JsonValueKind.String) return false;
|
||||||
|
if (!double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||||
|
return false;
|
||||||
|
if (parsed is < int.MinValue or > int.MaxValue) return false;
|
||||||
|
|
||||||
|
value = (int)Math.Round(parsed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Дробное число из JSON — с теми же оговорками, что и <see cref="TryGetInt"/>.</summary>
|
||||||
|
private static bool TryGetDouble(JsonElement e, string name, out double value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return false;
|
||||||
|
|
||||||
|
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out value);
|
||||||
|
|
||||||
|
return v.ValueKind == JsonValueKind.String &&
|
||||||
|
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetBool(JsonElement e, string name, out bool value)
|
||||||
|
{
|
||||||
|
value = false;
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return false;
|
||||||
|
|
||||||
|
if (v.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||||
|
{
|
||||||
|
value = v.GetBoolean();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Да/Нет-колонка тоже может прийти строкой («true»/«false») — см. TryGetInt.
|
||||||
|
return v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? TryGetDate(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) &&
|
||||||
|
v.ValueKind == JsonValueKind.String &&
|
||||||
|
DateTimeOffset.TryParse(v.GetString(), out var parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ApprovalPhases
|
||||||
|
{
|
||||||
|
public const string Approval = "Согласование";
|
||||||
|
public const string Execution = "Исполнение";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
|
||||||
|
/// Один в один с REQUEST_STATUS в request-portal
|
||||||
|
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ApprovalStatuses
|
||||||
|
{
|
||||||
|
public const string Queued = "Ждёт очереди";
|
||||||
|
public const string OnApproval = "На согласовании";
|
||||||
|
public const string OnExecution = "На исполнении";
|
||||||
|
public const string Approved = "Согласовано";
|
||||||
|
public const string Executed = "Выполнено";
|
||||||
|
public const string Rejected = "Отклонено";
|
||||||
|
public const string Returned = "Возвращена на доработку";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значения колонки RequestStatus — статус ЗАЯВКИ целиком (список «… — данные»).
|
||||||
|
/// Набор ДРУГОЙ, чем у строки маршрута: «Ждёт очереди»/«На исполнении» у заявки
|
||||||
|
/// не бывает, а «Выполнение»/«Закрыта» не бывает у строки (см. тот же
|
||||||
|
/// statuses.ts, где оба набора сведены).
|
||||||
|
/// </summary>
|
||||||
|
internal static class RequestStatuses
|
||||||
|
{
|
||||||
|
public const string OnApproval = "На согласовании";
|
||||||
|
public const string Approved = "Согласовано";
|
||||||
|
public const string Rejected = "Отклонено";
|
||||||
|
public const string Returned = "Возвращена на доработку";
|
||||||
|
public const string Execution = "Выполнение";
|
||||||
|
public const string Closed = "Закрыта";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рекуррентная задача автосогласования — тонкая обёртка над
|
||||||
|
/// <see cref="AutoApproveService"/>: всё, что она добавляет сверху, — расписание,
|
||||||
|
/// защита от наложения запусков и одна строка в логе с итогом.
|
||||||
|
///
|
||||||
|
/// Повторы отключены (Attempts = 0) намеренно, в отличие от
|
||||||
|
/// CreateNextListPartitionJob: там повтор нужен, потому что пропуск задачи ломает
|
||||||
|
/// сценарий целиком (список не создан — писать некуда). Здесь же следующий запуск
|
||||||
|
/// придёт сам, через час, и сделает ровно ту же работу с актуальным состоянием —
|
||||||
|
/// уже согласованные строки в выборку не попадут. Повторять упавший проход раньше
|
||||||
|
/// этого смысла нет, а висящие в очереди повторы только прятали бы настоящую
|
||||||
|
/// причину сбоя.
|
||||||
|
/// </summary>
|
||||||
|
[AutomaticRetry(Attempts = 0)]
|
||||||
|
[DisableConcurrentExecution(timeoutInSeconds: 5 * 60)]
|
||||||
|
public sealed class AutoApproveJob(AutoApproveService service, ILogger<AutoApproveJob> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор рекуррентной задачи в Hangfire. Константой — по нему задача и
|
||||||
|
/// заводится, и снимается при выключении настройки (см. Program.cs): разъехавшись,
|
||||||
|
/// эти два имени оставили бы в дашборде задачу, которую уже некому снять.
|
||||||
|
/// </summary>
|
||||||
|
public const string RecurringJobId = "auto-approve";
|
||||||
|
|
||||||
|
public async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var approved = await service.RunAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Автосогласование: строк согласовано автоматически — {Count}", approved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
public sealed class AutoApproveOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Включено ли автосогласование вообще. Выключение снимает рекуррентную задачу
|
||||||
|
/// из Hangfire (см. Program.cs), а не просто делает её пустой: иначе в дашборде
|
||||||
|
/// осталась бы задача, которая регулярно запускается и ничего не делает.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Расписание запуска (cron, UTC). Раз в час: SLA задаётся в ЧАСАХ, поэтому
|
||||||
|
/// час — предельная точность, которая вообще имеет смысл, а более частый опрос
|
||||||
|
/// всех списков SharePoint ничего не ускоряет по существу.
|
||||||
|
///
|
||||||
|
/// Следствие: шаг согласуется не в сам момент истечения SLA, а на ближайшем
|
||||||
|
/// запуске после него — то есть в пределах часа позже. Это осознанно: раньше
|
||||||
|
/// срока не согласуется никогда, а опоздание в пределах часа для срока,
|
||||||
|
/// измеряемого часами, роли не играет.
|
||||||
|
/// </summary>
|
||||||
|
public string Cron { get; set; } = "0 * * * *";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Комментарий, который задача пишет в строку согласования вместо комментария
|
||||||
|
/// согласующего. Нужен, чтобы в матрице заявки было видно, что решение принял не
|
||||||
|
/// человек: других признаков у автосогласованной строки нет — статус и дата у неё
|
||||||
|
/// такие же, как у обычной.
|
||||||
|
/// </summary>
|
||||||
|
public string Comment { get; set; } = "Согласовано автоматически: истёк срок SLA по этому шагу.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сколько строк автосогласовать максимум за один запуск. Предохранитель на
|
||||||
|
/// случай массового включения флага или сдвига дат: без него один запуск мог бы
|
||||||
|
/// пройти по всему архиву партиций и написать в SharePoint тысячи изменений,
|
||||||
|
/// упёршись в троттлинг. Остаток уедет в следующий запуск — задача идемпотентна
|
||||||
|
/// (уже решённые строки в выборку активных не попадают).
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRowsPerRun { get; set; } = 200;
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Автосогласование по SLA: находит строки списков согласования, у которых
|
||||||
|
/// включён флаг «Автосогласование» (колонка AutoApprove) и истёк срок SLA, и
|
||||||
|
/// принимает за согласующего положительное решение.
|
||||||
|
///
|
||||||
|
/// Это ЗЕРКАЛО клиентской логики решения из request-portal
|
||||||
|
/// (shared/approval/approverDecision.service.ts — decide, и approvalList.service.ts —
|
||||||
|
/// setDecision/activateNextQueued): та же последовательность «пометить строку →
|
||||||
|
/// продвинуть очередь → пересчитать статус заявки», просто выполненная сервером
|
||||||
|
/// от имени приложения, а не браузером от имени человека. Расхождение между ними
|
||||||
|
/// означало бы, что автосогласованная заявка встаёт в состояние, в которое
|
||||||
|
/// портал её сам никогда не приводит, поэтому правила здесь повторены буквально,
|
||||||
|
/// а не «по смыслу» (см. ActivationTargets и ComputeRequestStatus).
|
||||||
|
///
|
||||||
|
/// В отличие от <see cref="Reminders.ReminderService"/> (срез «кто просрочил»,
|
||||||
|
/// без записи) задача именно ПИШЕТ в SharePoint, поэтому обход здесь
|
||||||
|
/// последовательный: параллелить запись по типам заявок — это упереться в
|
||||||
|
/// троттлинг и потерять предсказуемость порядка, а объём работы за один запуск
|
||||||
|
/// ограничен сверху (см. AutoApproveOptions.MaxRowsPerRun).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoApproveService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IOptions<AutoApproveOptions> options,
|
||||||
|
ILogger<AutoApproveService> logger)
|
||||||
|
{
|
||||||
|
// Активные строки С ВКЛЮЧЁННЫМ автосогласованием — отбор целиком на стороне
|
||||||
|
// SharePoint, чтобы не тащить сюда весь список ради единиц строк.
|
||||||
|
//
|
||||||
|
// Да/Нет-колонка в $filter сравнивается с 1/0, а не с true/false.
|
||||||
|
//
|
||||||
|
// У строк, заведённых ДО появления колонки AutoApprove, значение пустое, и
|
||||||
|
// под `eq 1` они не попадают — это верно по смыслу: включить автосогласование
|
||||||
|
// задним числом нельзя, флаг проставляется при отправке заявки (портал) или
|
||||||
|
// вручную в админке.
|
||||||
|
private const string CandidateFilter =
|
||||||
|
$"$filter=(ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}')" +
|
||||||
|
" and AutoApprove eq 1";
|
||||||
|
|
||||||
|
private const string RowSelect =
|
||||||
|
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,AutoApprove,StepOrder,TicketId&$top=5000";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один проход по всем типам заявок. Возвращает число автосогласованных строк —
|
||||||
|
/// его пишет в лог задача (см. AutoApproveJob), и по нему же удобно понять из
|
||||||
|
/// дашборда Hangfire, делал ли запуск хоть что-нибудь.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
// В отличие от напоминаний, ТИПЫ БЕЗ SlaHours отсюда НЕ отсеиваются: у типа
|
||||||
|
// SLA может быть не задан, а у конкретного шага — задан, и именно такой шаг
|
||||||
|
// чаще всего и делают автосогласуемым («это согласование формальное, ждём
|
||||||
|
// сутки и идём дальше»). Шаг, у которого SLA нет ни там, ни там, отсеется
|
||||||
|
// ниже поштучно (см. DeadlineOf).
|
||||||
|
var types = await db.RequestTypes.AsNoTracking()
|
||||||
|
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var approved = 0;
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
if (approved >= options.Value.MaxRowsPerRun) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
approved += await RunForTypeAsync(type, now, options.Value.MaxRowsPerRun - approved, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// Недоступный список одного типа не должен останавливать остальные —
|
||||||
|
// ровно как в напоминаниях.
|
||||||
|
logger.LogError(e, "Автосогласование по типу заявки «{Type}» не выполнено", type.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> RunForTypeAsync(
|
||||||
|
RequestType type, DateTimeOffset now, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
|
// Архив обходится наравне с текущей партицией: заявка, уехавшая в архив,
|
||||||
|
// продолжает ждать решения так же, как в текущем списке, и её шаг точно так
|
||||||
|
// же просрочивается (см. тот же довод в ReminderService).
|
||||||
|
var partitions = archived
|
||||||
|
.Select(x => (x.ListId, x.Number))
|
||||||
|
.Append((current.ListId, current.Number));
|
||||||
|
|
||||||
|
var approved = 0;
|
||||||
|
foreach (var (listId, number) in partitions)
|
||||||
|
{
|
||||||
|
if (approved >= limit) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
approved += await RunForPartitionAsync(type, listId, now, limit - approved, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
e, "Автосогласование по списку {ListId} (партиция №{Number}) типа «{Type}» не выполнено",
|
||||||
|
listId, number, type.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> RunForPartitionAsync(
|
||||||
|
RequestType type, string listId, DateTimeOffset now, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var siteUrl = type.SiteUrl!;
|
||||||
|
|
||||||
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(dataListTitle)) return 0;
|
||||||
|
|
||||||
|
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
|
||||||
|
if (approvalListTitle is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти",
|
||||||
|
dataListTitle);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = await ReadRowsAsync(
|
||||||
|
siteUrl, approvalListTitle, $"{RowSelect}&{CandidateFilter}", cancellationToken);
|
||||||
|
if (candidates.Count == 0) return 0;
|
||||||
|
|
||||||
|
var approved = 0;
|
||||||
|
// По заявкам, а не по строкам подряд: решение по одной строке двигает
|
||||||
|
// очередь и меняет статус всей заявки, поэтому строки одной заявки нельзя
|
||||||
|
// обрабатывать по устаревшему снимку — внутри группы состояние
|
||||||
|
// перечитывается (см. ApproveAsync).
|
||||||
|
foreach (var ticket in candidates.GroupBy(r => r.TicketId))
|
||||||
|
{
|
||||||
|
if (approved >= limit) return approved;
|
||||||
|
|
||||||
|
foreach (var candidate in ticket.OrderBy(r => r.Order))
|
||||||
|
{
|
||||||
|
if (approved >= limit) return approved;
|
||||||
|
|
||||||
|
var deadline = DeadlineOf(candidate, type);
|
||||||
|
if (deadline is null || deadline > now) continue;
|
||||||
|
|
||||||
|
var done = await ApproveAsync(
|
||||||
|
type, siteUrl, approvalListTitle, listId, candidate, deadline.Value, now, cancellationToken);
|
||||||
|
if (done) approved++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Момент, после которого шаг считается просроченным. null — SLA не задан ни на
|
||||||
|
/// строке, ни на типе заявки, либо не заполнена дата активации: отсчитывать не
|
||||||
|
/// от чего, и автосогласовать такой шаг нельзя — он «висел» бы бесконечно, а
|
||||||
|
/// сработал бы в произвольный момент после того, как SLA всё-таки настроят.
|
||||||
|
/// </summary>
|
||||||
|
private DateTimeOffset? DeadlineOf(ApprovalRow row, RequestType type)
|
||||||
|
{
|
||||||
|
if (row.Date is not { } activatedAt)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
|
||||||
|
"но дата активации (Date) не заполнена — пропущена",
|
||||||
|
row.Id, row.TicketId, type.Id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Как и в напоминаниях: SLA строки перекрывает SLA типа заявки, и ноль —
|
||||||
|
// это ноль («сразу»), а не «не задано».
|
||||||
|
var slaHours = row.SlaHours ?? type.SlaHours;
|
||||||
|
if (slaHours is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
|
||||||
|
"но SLA не задан ни на шаге, ни на типе заявки — пропущена",
|
||||||
|
row.Id, row.TicketId, type.Id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activatedAt.AddHours(slaHours.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Решение по одной строке. Возвращает false, если строку решать уже не нужно —
|
||||||
|
/// её успел закрыть человек между выборкой кандидатов и этим моментом.
|
||||||
|
///
|
||||||
|
/// Проверка «строка всё ещё активна» делается по СВЕЖЕМУ чтению, а не по
|
||||||
|
/// кандидату из выборки: между ними проходит сколько угодно времени (обход
|
||||||
|
/// партиций, сеть), и записать «Согласовано» поверх только что поставленного
|
||||||
|
/// человеком «Отклонено» — худшее, что эта задача может сделать. Полностью
|
||||||
|
/// гонку это не закрывает (решение может прийти и в миллисекунду между
|
||||||
|
/// проверкой и записью), но сводит окно к одному HTTP-запросу вместо всего
|
||||||
|
/// прохода; блокировок на элемент списка SharePoint не предоставляет.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> ApproveAsync(
|
||||||
|
RequestType type, string siteUrl, string approvalListTitle, string dataListUrl,
|
||||||
|
ApprovalRow candidate, DateTimeOffset deadline, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = await ReadTicketRowsAsync(siteUrl, approvalListTitle, candidate.TicketId, cancellationToken);
|
||||||
|
|
||||||
|
var row = rows.FirstOrDefault(r => r.Id == candidate.Id);
|
||||||
|
if (row is null || !IsActive(row))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Строка {Row} заявки {Ticket} уже не ждёт решения — автосогласование не требуется",
|
||||||
|
candidate.Id, candidate.TicketId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фаза исполнения закрывается статусом «Выполнено», а не «Согласовано» —
|
||||||
|
// это те же два значения, что ставит DecisionPanel портала для решений
|
||||||
|
// approve и execute.
|
||||||
|
var decision = row.Phase == ApprovalPhases.Execution
|
||||||
|
? ApprovalStatuses.Executed
|
||||||
|
: ApprovalStatuses.Approved;
|
||||||
|
|
||||||
|
await sharePoint.UpdateItemByListTitleAsync(
|
||||||
|
siteUrl, approvalListTitle, row.Id,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ApprovalStatus = decision,
|
||||||
|
// Дата РЕШЕНИЯ перезаписывает дату активации — так же, как
|
||||||
|
// setDecision в портале (колонка Date у строки одна).
|
||||||
|
Date = now.UtcDateTime,
|
||||||
|
Comment = options.Value.Comment
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Строка {Row} заявки {Ticket} типа «{Type}» автосогласована ({Decision}): срок SLA истёк {Deadline:u}",
|
||||||
|
row.Id, row.TicketId, type.Name, decision, deadline);
|
||||||
|
|
||||||
|
// Дальше считаем по снимку, в котором решение УЖЕ применено: перечитывать
|
||||||
|
// список после каждой записи незачем — мы сами знаем, что изменили, а
|
||||||
|
// лишние чтения на каждом шаге заметно удлиняют проход.
|
||||||
|
var updated = rows
|
||||||
|
.Select(r => r.Id == row.Id ? r with { Status = decision } : r)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var activated = ActivationTargets(updated);
|
||||||
|
foreach (var target in activated)
|
||||||
|
{
|
||||||
|
await sharePoint.UpdateItemByListTitleAsync(
|
||||||
|
siteUrl, approvalListTitle, target.Row.Id,
|
||||||
|
// Активация — это статус ПЛЮС отметка времени «получил на
|
||||||
|
// согласование»: от неё отсчитывается SLA следующего шага (см.
|
||||||
|
// setActive в портале). Без даты шаг выпал бы и из напоминаний, и
|
||||||
|
// из автосогласования.
|
||||||
|
new { ApprovalStatus = target.Status, Date = now.UtcDateTime },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var target in activated)
|
||||||
|
updated[updated.FindIndex(r => r.Id == target.Row.Id)] = target.Row with { Status = target.Status };
|
||||||
|
|
||||||
|
await sharePoint.UpdateItemByListUrlAsync(
|
||||||
|
siteUrl, dataListUrl, row.TicketId,
|
||||||
|
new { RequestStatus = ComputeRequestStatus(updated) },
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsActive(ApprovalRow row) =>
|
||||||
|
row.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кого активировать после положительного решения — зеркало
|
||||||
|
/// ApprovalListService.activateNextQueued из request-portal.
|
||||||
|
///
|
||||||
|
/// Среди ещё ждущих строк текущей фазы активируются те, что делят НАИМЕНЬШИЙ
|
||||||
|
/// StepOrder: обычно это одна строка, несколько — параллельный шаг. Фаза
|
||||||
|
/// исполнения активируется, только когда фаза согласования пройдена целиком;
|
||||||
|
/// если согласование остановлено (отклонение/возврат), не активируется никто.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<(ApprovalRow Row, string Status)> ActivationTargets(IReadOnlyList<ApprovalRow> rows)
|
||||||
|
{
|
||||||
|
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
|
||||||
|
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
|
||||||
|
|
||||||
|
if (approvalRows.Any(r => r.Status is ApprovalStatuses.Rejected or ApprovalStatuses.Returned))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
|
||||||
|
|
||||||
|
var (phaseRows, activeStatus) = approvalDone
|
||||||
|
? (execRows, ApprovalStatuses.OnExecution)
|
||||||
|
: (approvalRows, ApprovalStatuses.OnApproval);
|
||||||
|
|
||||||
|
// В фазе уже есть активная строка — очередь двигать не нужно (так бывает при
|
||||||
|
// параллельном шаге, где решение принято ещё не всеми).
|
||||||
|
if (phaseRows.Any(r => r.Status == activeStatus)) return [];
|
||||||
|
|
||||||
|
var queued = phaseRows.Where(r => r.Status == ApprovalStatuses.Queued).ToList();
|
||||||
|
if (queued.Count == 0) return [];
|
||||||
|
|
||||||
|
var minOrder = queued.Min(r => r.Order);
|
||||||
|
return queued.Where(r => r.Order == minOrder).Select(r => (r, activeStatus)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Статус ЗАЯВКИ по строкам маршрута — зеркало computeRequestStatus из
|
||||||
|
/// request-portal (approverDecision.service.ts). Порядок проверок важен и
|
||||||
|
/// повторён дословно: отклонение и возврат старше незавершённого согласования,
|
||||||
|
/// а «Согласовано» — финал только для маршрута без фазы исполнения.
|
||||||
|
/// </summary>
|
||||||
|
private static string ComputeRequestStatus(IReadOnlyList<ApprovalRow> rows)
|
||||||
|
{
|
||||||
|
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
|
||||||
|
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
|
||||||
|
|
||||||
|
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Rejected)) return RequestStatuses.Rejected;
|
||||||
|
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Returned)) return RequestStatuses.Returned;
|
||||||
|
|
||||||
|
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
|
||||||
|
if (!approvalDone) return RequestStatuses.OnApproval;
|
||||||
|
|
||||||
|
if (execRows.Count == 0) return RequestStatuses.Approved;
|
||||||
|
if (execRows.All(r => r.Status == ApprovalStatuses.Executed)) return RequestStatuses.Closed;
|
||||||
|
return RequestStatuses.Execution;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<IReadOnlyList<ApprovalRow>> ReadTicketRowsAsync(
|
||||||
|
string siteUrl, string approvalListTitle, int ticketId, CancellationToken cancellationToken) =>
|
||||||
|
ReadRowsAsync(siteUrl, approvalListTitle, $"{RowSelect}&$filter=TicketId eq {ticketId}", cancellationToken);
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||||
|
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await sharePoint.GetItemsByListTitleAsync(siteUrl, listTitle, odataQuery, cancellationToken);
|
||||||
|
return items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Русские названия типов руководителя и ролей в проекте — для всего, что видит
|
||||||
|
/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя
|
||||||
|
/// enum'а («DirectionHead») в тексте для пользователя недопустимо.
|
||||||
|
/// </summary>
|
||||||
|
public static class StepLabels
|
||||||
|
{
|
||||||
|
public static string Of(HeadType headType) => headType switch
|
||||||
|
{
|
||||||
|
HeadType.Chief => "Непосредственный руководитель",
|
||||||
|
HeadType.DirectionHead => "Руководитель направления",
|
||||||
|
HeadType.DirectorTansu => "Директор Tansu",
|
||||||
|
HeadType.ManagingDirectorTansu => "Управляющий директор Tansu",
|
||||||
|
HeadType.DepartmentHead => "Руководитель указанного отдела",
|
||||||
|
HeadType.CompanyDirectors => "Директора компаний инициатора",
|
||||||
|
_ => headType.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Название с маленькой буквы — для середины фразы («Не найден руководитель направления»).</summary>
|
||||||
|
public static string InSentence(HeadType headType) => Lower(Of(headType));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Все названия мужского рода («найден руководитель проекта»), кроме «Безопасность» —
|
||||||
|
/// для неё нужна отдельная формулировка, иначе выйдет «найден безопасность».
|
||||||
|
/// </summary>
|
||||||
|
public static string InSentence(ProjectRole projectRole) =>
|
||||||
|
projectRole == ProjectRole.Security ? "сотрудник службы безопасности" : Lower(Of(projectRole));
|
||||||
|
|
||||||
|
private static string Lower(string label) => char.ToLowerInvariant(label[0]) + label[1..];
|
||||||
|
|
||||||
|
public static string Of(ProjectRole projectRole) => projectRole switch
|
||||||
|
{
|
||||||
|
ProjectRole.ProjectManager => "Руководитель проекта",
|
||||||
|
ProjectRole.Planner => "Планировщик",
|
||||||
|
ProjectRole.ManagerHSE => "Менеджер HSE",
|
||||||
|
ProjectRole.Security => "Безопасность",
|
||||||
|
ProjectRole.Keeper => "Заведующий склада",
|
||||||
|
_ => projectRole.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Hangfire;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в
|
||||||
|
/// реестр и переключить на неё запись.
|
||||||
|
///
|
||||||
|
/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной
|
||||||
|
/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое
|
||||||
|
/// выполнение — разные события во времени: задача может полежать в очереди, быть
|
||||||
|
/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда.
|
||||||
|
/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но
|
||||||
|
/// не с точки зрения блокировки: settled только выполнение может её удержать, и
|
||||||
|
/// проверять это надо на каждом фактическом входе в код, создающий список в
|
||||||
|
/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на
|
||||||
|
/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос,
|
||||||
|
/// поймавший тот же порог до переключения списка) синхронизировались бы неверно.
|
||||||
|
///
|
||||||
|
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
|
||||||
|
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
|
||||||
|
/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если
|
||||||
|
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
|
||||||
|
/// а не молотить впустую.
|
||||||
|
/// </summary>
|
||||||
|
[AutomaticRetry(Attempts = 3)]
|
||||||
|
public sealed class CreateNextListPartitionJob(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ListPartitioningLock provisioningLock,
|
||||||
|
ListPartitionRegistry registry,
|
||||||
|
ISharePointListProvisioner provisioner,
|
||||||
|
TnsuPortalCallerToken callerToken,
|
||||||
|
IOptions<ListPartitioningOptions> options,
|
||||||
|
ILogger<CreateNextListPartitionJob> logger)
|
||||||
|
{
|
||||||
|
/// <param name="authorization">
|
||||||
|
/// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см.
|
||||||
|
/// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать
|
||||||
|
/// новый список на уведомления от имени этого пользователя: своего HttpContext
|
||||||
|
/// у фоновой задачи нет, а client_credentials сейчас не настроен (см.
|
||||||
|
/// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не
|
||||||
|
/// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть
|
||||||
|
/// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание
|
||||||
|
/// списка (см. ISharePointWebhookSubscriber).
|
||||||
|
///
|
||||||
|
/// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и
|
||||||
|
/// виден в его дашборде.
|
||||||
|
/// </param>
|
||||||
|
public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
callerToken.Set(authorization);
|
||||||
|
|
||||||
|
// Единственная настоящая защита от параллельного запуска: атомарный захват
|
||||||
|
// флага в строке request_types. Проверка перед постановкой в очередь —
|
||||||
|
// только чтобы не плодить лишние задачи; гонку разруливает именно здесь.
|
||||||
|
if (!await provisioningLock.TryAcquireAsync(
|
||||||
|
requestTypeId, options.Value.StaleLockAfter, cancellationToken))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Провижининг списка для типа заявок {RequestTypeId} уже идёт — задача пропущена.",
|
||||||
|
requestTypeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestType = await db.RequestTypes
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||||
|
|
||||||
|
if (requestType is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Тип заявок {RequestTypeId} не найден — провижининг отменён.", requestTypeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"У типа заявок {RequestTypeId} не настроен список SharePoint — провижининг отменён.",
|
||||||
|
requestTypeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас
|
||||||
|
// перестаёт быть текущей — записываем её в архив (request_list_partitions
|
||||||
|
// хранит только архив, см. ListPartitionRegistry). До этого момента она
|
||||||
|
// нигде отдельной строкой не значилась.
|
||||||
|
var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken);
|
||||||
|
|
||||||
|
var nextNumber = archivedCurrent.Number + 1;
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
|
||||||
|
"по структуре {SourceListId}.",
|
||||||
|
nextNumber, requestTypeId, requestType.Name, requestType.ListId);
|
||||||
|
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
ProvisionedList created;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Партиция всегда создаётся на том же сайте, что и текущая (см.
|
||||||
|
// комментарий на RequestListPartition о недопустимости разъезда серии
|
||||||
|
// по сайтам) — сайт берём из RequestType, а не откуда-либо ещё, так что
|
||||||
|
// расхождение структурно невозможно.
|
||||||
|
created = await provisioner.CreateNextListPairAsync(
|
||||||
|
requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Логируем с контекстом (номер партиции, тип заявок) здесь, а не
|
||||||
|
// полагаемся только на то, что исключение долетит до лога Hangfire:
|
||||||
|
// там оно будет без этих подробностей, вперемешку с логами других задач.
|
||||||
|
// Дальше даём ему пойти в Hangfire как обычно — так сработает
|
||||||
|
// AutomaticRetry(Attempts = 3).
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Провижининг партиции №{Number} для типа заявок {RequestTypeId} не удался за {ElapsedMs} мс.",
|
||||||
|
nextNumber, requestTypeId, stopwatch.ElapsedMilliseconds);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новая партиция строкой в request_list_partitions пока не появляется —
|
||||||
|
// она получит свою запись только когда САМА станет архивом, следующим
|
||||||
|
// вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry).
|
||||||
|
// Здесь переключаем только указатель.
|
||||||
|
requestType.ListId = created.ListId;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
|
||||||
|
"{RequestTypeId} переключена на неё.",
|
||||||
|
nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
|
||||||
|
// держала бы тип заблокированным до истечения StaleLockAfter.
|
||||||
|
await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
/// <summary>Одна партиция серии — для админки/портала (вся серия) и для ответа «текущий список».</summary>
|
||||||
|
/// <param name="IsCurrent">
|
||||||
|
/// true — в эту партицию идёт запись новых заявок; остальные партиции серии — архив только для чтения.
|
||||||
|
/// </param>
|
||||||
|
public sealed record RequestListPartitionDto(
|
||||||
|
int Number,
|
||||||
|
string SiteUrl,
|
||||||
|
string ListId,
|
||||||
|
string? Title,
|
||||||
|
bool IsCurrent,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>Вся серия партиций одного типа заявок — единица ответа bulk-эндпоинта для портала.</summary>
|
||||||
|
public sealed record RequestTypeListSeriesDto(
|
||||||
|
int RequestTypeId,
|
||||||
|
IReadOnlyList<RequestListPartitionDto> Partitions);
|
||||||
|
|
||||||
|
/// <summary>Ответ «куда писать новую заявку».</summary>
|
||||||
|
/// <param name="ItemCount">
|
||||||
|
/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
|
||||||
|
/// null, если SharePoint не ответил (тогда условие создания следующей партиции не проверялось).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ProvisioningQueued">
|
||||||
|
/// true — этим запросом поставлена в очередь задача создания следующей партиции серии.
|
||||||
|
/// </param>
|
||||||
|
public sealed record CurrentListPartitionDto(
|
||||||
|
int RequestTypeId,
|
||||||
|
int Number,
|
||||||
|
string SiteUrl,
|
||||||
|
string ListId,
|
||||||
|
string? Title,
|
||||||
|
int? ItemCount = null,
|
||||||
|
bool ProvisioningQueued = false);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
|
||||||
|
/// эндпоинту, и фоновой задаче.
|
||||||
|
///
|
||||||
|
/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали
|
||||||
|
/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда
|
||||||
|
/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync).
|
||||||
|
/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId,
|
||||||
|
/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция
|
||||||
|
/// реально стала архивом.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ListPartitionRegistry(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ILogger<ListPartitionRegistry> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей.
|
||||||
|
///
|
||||||
|
/// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление
|
||||||
|
/// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая
|
||||||
|
/// партиция уже успела записаться архивом, а переключение указателя на новую ещё
|
||||||
|
/// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы
|
||||||
|
/// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см.
|
||||||
|
/// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом
|
||||||
|
/// переходном состоянии.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<RequestListPartition>> GetArchivedAsync(
|
||||||
|
RequestType requestType, CancellationToken cancellationToken) =>
|
||||||
|
await db.RequestListPartitions.AsNoTracking()
|
||||||
|
.Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId)
|
||||||
|
.OrderBy(x => x.Number)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions
|
||||||
|
/// на запись: строка появится там только когда эта партиция реально станет
|
||||||
|
/// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным
|
||||||
|
/// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не
|
||||||
|
/// кешируется — кешировать было бы негде без строки в БД.
|
||||||
|
///
|
||||||
|
/// archived — уже загруженный список архивных партиций этого типа (см.
|
||||||
|
/// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая
|
||||||
|
/// сторона (которой archived обычно уже нужен для остальной серии) не делала
|
||||||
|
/// один и тот же запрос дважды.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RequestListPartitionDto> BuildCurrentAsync(
|
||||||
|
RequestType requestType, IReadOnlyList<RequestListPartition> archived, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1;
|
||||||
|
var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken);
|
||||||
|
|
||||||
|
return new RequestListPartitionDto(
|
||||||
|
number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент,
|
||||||
|
/// когда она перестаёт быть текущей (единственный вызывающий —
|
||||||
|
/// CreateNextListPartitionJob, прямо перед тем как переключить
|
||||||
|
/// RequestType.ListId на следующую партицию).
|
||||||
|
///
|
||||||
|
/// Читает свой собственный, НЕфильтрованный список существующих партиций —
|
||||||
|
/// намеренно не переиспользует GetArchivedAsync(): та фильтрует по
|
||||||
|
/// ListId != RequestType.ListId, и ровно между этим вызовом и переключением
|
||||||
|
/// указателя (см. CreateNextListPartitionJob) только что записанная строка
|
||||||
|
/// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не
|
||||||
|
/// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на
|
||||||
|
/// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже
|
||||||
|
/// записанное и вернуть его.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RequestListPartition> ArchiveCurrentAsync(
|
||||||
|
RequestType requestType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var series = await db.RequestListPartitions
|
||||||
|
.Where(x => x.RequestTypeId == requestType.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId);
|
||||||
|
if (existing is not null) return existing;
|
||||||
|
|
||||||
|
var entity = new RequestListPartition
|
||||||
|
{
|
||||||
|
RequestTypeId = requestType.Id,
|
||||||
|
Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1,
|
||||||
|
ListId = requestType.ListId!,
|
||||||
|
Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.RequestListPartitions.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.",
|
||||||
|
entity.ListId, requestType.Id, entity.Number);
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок нужен только для отображения в админке/портале — из-за его
|
||||||
|
// отсутствия чтение партиции падать не должно.
|
||||||
|
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Не удалось прочитать заголовок списка {ListId}.", listId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Hangfire;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда
|
||||||
|
/// писать) и вся серия целиком (архив + текущая) для админки и портала.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ListPartitionService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ListPartitionRegistry registry,
|
||||||
|
IBackgroundJobClient backgroundJobs,
|
||||||
|
TnsuPortalCallerToken callerToken,
|
||||||
|
IOptions<ListPartitioningOptions> options,
|
||||||
|
ILogger<ListPartitionService> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Партиция, в которую сейчас нужно писать новые заявки.
|
||||||
|
///
|
||||||
|
/// Побочный эффект: если партиция набрала порог элементов, ставит в очередь
|
||||||
|
/// задачу создания следующей. Побочный эффект в GET — сознательный компромисс
|
||||||
|
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
|
||||||
|
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
|
||||||
|
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<CurrentListPartitionDto>> GetCurrentAsync(
|
||||||
|
int requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestType = await db.RequestTypes
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||||
|
|
||||||
|
if (requestType is null)
|
||||||
|
return Result.Fail<CurrentListPartitionDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
|
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
|
||||||
|
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
||||||
|
|
||||||
|
// Синтезируется на лету, не пишется в request_list_partitions — см.
|
||||||
|
// ListPartitionRegistry.BuildCurrentAsync: строка появится там только когда
|
||||||
|
// партиция реально станет архивом.
|
||||||
|
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
|
||||||
|
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||||
|
|
||||||
|
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
|
||||||
|
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(new CurrentListPartitionDto(
|
||||||
|
requestTypeId, current.Number, requestType.SiteUrl, current.ListId, current.Title, itemCount, queued));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Вся серия партиций типа заявок, от первой к последней — для админки.</summary>
|
||||||
|
public async Task<Result<IReadOnlyList<RequestListPartitionDto>>> ListAsync(
|
||||||
|
int requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestType = await db.RequestTypes
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||||
|
|
||||||
|
if (requestType is null)
|
||||||
|
return Result.Fail<IReadOnlyList<RequestListPartitionDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
|
return Result.Ok(await BuildSeriesAsync(requestType, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во
|
||||||
|
/// всех партициях (архив + текущая) любого типа, порталу нужен весь список
|
||||||
|
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
|
||||||
|
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title">
|
||||||
|
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||||
|
/// без учёта регистра, по вхождению подстроки. null — фильтр не применяется.
|
||||||
|
/// </param>
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(
|
||||||
|
string? title, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestTypes = await db.RequestTypes
|
||||||
|
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var result = new List<RequestTypeListSeriesDto>(requestTypes.Count);
|
||||||
|
foreach (var requestType in requestTypes)
|
||||||
|
{
|
||||||
|
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(title))
|
||||||
|
partitions = partitions
|
||||||
|
.Where(x => x.Title is not null && x.Title.Contains(title, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (partitions.Count == 0) continue;
|
||||||
|
|
||||||
|
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<RequestTypeListSeriesDto>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<RequestListPartitionDto>> BuildSeriesAsync(
|
||||||
|
RequestType requestType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Тип без настроенного списка — пустая серия, а не ошибка: в админке такой
|
||||||
|
// тип заявок вполне может существовать до привязки к SharePoint.
|
||||||
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
|
||||||
|
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||||
|
|
||||||
|
var result = new List<RequestListPartitionDto>(archived.Count + 1);
|
||||||
|
result.AddRange(archived.Select(x =>
|
||||||
|
new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt)));
|
||||||
|
result.Add(current);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать
|
||||||
|
/// адрес списка даже когда SharePoint недоступен, иначе портал не сможет
|
||||||
|
/// принять заявку из-за сбоя необязательной проверки порога.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int?> TryGetItemCountAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Value.CheckThresholdOnRead) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await sharePoint.GetItemCountAsync(siteUrl, listId, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Не удалось получить число элементов списка {ListId} — порог не проверен.", listId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает
|
||||||
|
/// true, только если задача действительно поставлена.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> TryEnqueueProvisioningAsync(
|
||||||
|
RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false;
|
||||||
|
|
||||||
|
// Следующая партиция уже создана, а запись на неё ещё не переключили —
|
||||||
|
// задача не нужна (и переключать здесь нельзя: этим владеет провижининг).
|
||||||
|
var hasNext = await db.RequestListPartitions
|
||||||
|
.AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
|
||||||
|
if (hasNext) return false;
|
||||||
|
|
||||||
|
// Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
|
||||||
|
// постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
|
||||||
|
// самой задачи (см. CreateNextListPartitionJob).
|
||||||
|
if (requestType.ListPartitioningStartedAt is not null) return false;
|
||||||
|
|
||||||
|
// Токен ЭТОГО запроса уезжает в задачу: подписать новый список на
|
||||||
|
// уведомления нужно от имени пользователя, а своего токена у фоновой задачи
|
||||||
|
// нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и
|
||||||
|
// срок жизни).
|
||||||
|
backgroundJobs.Enqueue<CreateNextListPartitionJob>(
|
||||||
|
job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None));
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
|
||||||
|
"поставлена задача создания партиции №{NextNumber}.",
|
||||||
|
requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
/// <summary>Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации).</summary>
|
||||||
|
public sealed class ListPartitioningOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Порог элементов в списке данных, по достижении которого ставится задача на
|
||||||
|
/// создание следующей партиции (пары списков).
|
||||||
|
///
|
||||||
|
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
|
||||||
|
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
|
||||||
|
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
|
||||||
|
/// ниже, чтобы задача успела создать следующую партицию до того, как текущая
|
||||||
|
/// начнёт упираться.
|
||||||
|
/// </summary>
|
||||||
|
public int ItemCountThreshold { get; set; } = 4500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Через сколько блокировка провижининга считается брошенной и может быть
|
||||||
|
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
|
||||||
|
/// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не
|
||||||
|
/// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно
|
||||||
|
/// приостановить автосоздание партиций, не трогая остальную логику.
|
||||||
|
/// </summary>
|
||||||
|
public bool CheckThresholdOnRead { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <param name="Email">
|
||||||
|
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
|
||||||
|
/// именем ради обратной совместимости с потребителями, читавшими это поле до
|
||||||
|
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="HoursOverdue">
|
||||||
|
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||||
|
/// Выходные не учитываются — считаются календарные часы.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AskOverdueReason">
|
||||||
|
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||||
|
/// наследованием (шаг перекрывает тип заявки).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
|
||||||
|
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
|
||||||
|
/// <param name="RequestId">Id заявки (TicketId строки списка согласования).</param>
|
||||||
|
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
||||||
|
/// <param name="RequestUniqueCode">
|
||||||
|
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
||||||
|
/// заявки его нет или строка данных не найдена.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ApprovalOverdueReminderDto(
|
||||||
|
string Email,
|
||||||
|
int HoursOverdue,
|
||||||
|
bool AskOverdueReason,
|
||||||
|
int RequestTypeId,
|
||||||
|
string RequestTypeName,
|
||||||
|
int RequestId,
|
||||||
|
string Status,
|
||||||
|
string? RequestUniqueCode,
|
||||||
|
string Url,
|
||||||
|
string? ReceiverName = null)
|
||||||
|
: ReminderDto(Email, ReceiverName, Url);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
internal enum EmploymentStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Статус не определяли. EmploymentOf такого не возвращает — значение существует
|
||||||
|
/// только как значение по умолчанию (default(EmploymentStatus)); трактуется
|
||||||
|
/// наравне с <see cref="NotFound"/>, то есть «трудоустройство не подтверждено».
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
|
||||||
|
NotFound = 1,
|
||||||
|
|
||||||
|
/// <summary>Работает хотя бы в одной компании.</summary>
|
||||||
|
Employed = 2,
|
||||||
|
|
||||||
|
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
|
||||||
|
Dismissed = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Оба справочника api.tnsu.kz разом, разобранные по ФИО: дата увольнения есть
|
||||||
|
/// только в /Employee/FullBase, а email — только в /Employee/All, поэтому нужны
|
||||||
|
/// оба. Ищем ПО ФИО, потому что точечного поиска по нему у api.tnsu.kz нет
|
||||||
|
/// (эндпоинта /Employee/ByFio не существует), а в заявке на приём кандидат
|
||||||
|
/// записан именно ФИО (колонка CandidateName) — временное решение, см.
|
||||||
|
/// <see cref="ITnsuPortalClient.GetAllEmployeesAsync"/>.
|
||||||
|
///
|
||||||
|
/// ФИО не уникально: один человек может числиться в нескольких компаниях (тогда
|
||||||
|
/// это несколько записей одного и того же человека), но с тем же успехом могут
|
||||||
|
/// совпасть ФИО двух разных людей — различить эти два случая по одному ФИО нельзя,
|
||||||
|
/// поэтому все совпадения трактуются как записи одного человека.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EmployeeDirectorySnapshot
|
||||||
|
{
|
||||||
|
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
|
||||||
|
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
|
||||||
|
|
||||||
|
public EmployeeDirectorySnapshot(
|
||||||
|
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
||||||
|
{
|
||||||
|
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
||||||
|
_contactsByFio = contacts.ToLookup(e => Normalize(e.Fio));
|
||||||
|
|
||||||
|
// Обратный индекс email → ФИО — нужен, чтобы подписать напоминание именем
|
||||||
|
// получателя, когда известен только его email (руководитель кандидата,
|
||||||
|
// см. GetManagerAsync). При дублях email в справочнике побеждает первая
|
||||||
|
// запись — этого достаточно для отображения имени, не для идентификации.
|
||||||
|
_fioByEmail = contacts
|
||||||
|
.Where(e => !string.IsNullOrWhiteSpace(e.Email))
|
||||||
|
.GroupBy(e => e.Email!.Trim().ToLowerInvariant())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Fio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Уволенным считаем только того, у кого дата увольнения стоит во ВСЕХ его
|
||||||
|
/// записях: сотрудник, уволенный из одной компании холдинга, но оставшийся в
|
||||||
|
/// другой, продолжает работать — и напоминание ему по-прежнему нужно.
|
||||||
|
/// </summary>
|
||||||
|
public EmploymentStatus EmploymentOf(string fio)
|
||||||
|
{
|
||||||
|
var entries = _fullBaseByFio[Normalize(fio)].ToList();
|
||||||
|
if (entries.Count == 0) return EmploymentStatus.NotFound;
|
||||||
|
|
||||||
|
return entries.All(e => e.DismissalDate is not null)
|
||||||
|
? EmploymentStatus.Dismissed
|
||||||
|
: EmploymentStatus.Employed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Первый непустой email среди записей этого ФИО. У одного человека записей
|
||||||
|
/// несколько (по компании на каждую), а email у него один и тот же — но
|
||||||
|
/// заполнен он может быть не в каждой записи.
|
||||||
|
/// </summary>
|
||||||
|
public string? EmailOf(string fio) =>
|
||||||
|
_contactsByFio[Normalize(fio)]
|
||||||
|
.Select(e => e.Email)
|
||||||
|
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
||||||
|
|
||||||
|
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
|
||||||
|
public string? NameByEmail(string email) =>
|
||||||
|
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
||||||
|
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
||||||
|
/// двойные пробелы, и неразрывный пробел — см. поле department в /Employee/All).
|
||||||
|
/// </summary>
|
||||||
|
private static string Normalize(string fio) =>
|
||||||
|
string.Join(' ', fio.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка списка «Прием на работу — данные», разобранная из ответа SharePoint.
|
||||||
|
/// Имена колонок — как в request-portal (provisioning.json формы RecruitingHire).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record HireRow(
|
||||||
|
int Id,
|
||||||
|
string Status,
|
||||||
|
string? CandidateName,
|
||||||
|
DateTimeOffset? HireDate,
|
||||||
|
string? ProbationPeriod,
|
||||||
|
string? RequestUniqueCode)
|
||||||
|
{
|
||||||
|
public static HireRow? TryParse(JsonElement item)
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) return null;
|
||||||
|
|
||||||
|
return new HireRow(
|
||||||
|
id,
|
||||||
|
GetString(item, "RequestStatus") ?? string.Empty,
|
||||||
|
GetString(item, "CandidateName"),
|
||||||
|
TryGetDate(item, "HireDate"),
|
||||||
|
GetString(item, "ProbationPeriod"),
|
||||||
|
GetString(item, "RequestUniqueCode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
private static DateTimeOffset? TryGetDate(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) &&
|
||||||
|
v.ValueKind == JsonValueKind.String &&
|
||||||
|
DateTimeOffset.TryParse(v.GetString(), out var parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Статусы списка данных («… — данные», колонка RequestStatus) — считаем приём завершённым по ним.</summary>
|
||||||
|
internal static class HireRequestStatuses
|
||||||
|
{
|
||||||
|
public const string Approved = "Согласовано";
|
||||||
|
public const string Closed = "Закрыта";
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <param name="ReceiverEmail">
|
||||||
|
/// Email руководителя принимаемого сотрудника — кому адресовано напоминание.
|
||||||
|
/// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение
|
||||||
|
/// под вторым именем.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
|
||||||
|
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
|
||||||
|
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
|
||||||
|
/// <param name="HireDate">Дата приёма (колонка HireDate).</param>
|
||||||
|
/// <param name="ProbationEndsAt">Дата окончания испытательного срока (HireDate + срок).</param>
|
||||||
|
/// <param name="DaysUntilProbationEnds">Сколько дней осталось до конца испытательного срока.</param>
|
||||||
|
public sealed record ProbationEndingReminderDto(
|
||||||
|
string ReceiverEmail,
|
||||||
|
string CandidateEmail,
|
||||||
|
string CandidateName,
|
||||||
|
string? ProbationPeriod,
|
||||||
|
DateTimeOffset HireDate,
|
||||||
|
DateTimeOffset ProbationEndsAt,
|
||||||
|
int DaysUntilProbationEnds,
|
||||||
|
string Url,
|
||||||
|
string? ReceiverName = null)
|
||||||
|
: ReminderDto(ReceiverEmail, ReceiverName, Url);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
|
||||||
|
/// <param name="ReceiverName">
|
||||||
|
/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
|
||||||
|
/// в старой строке списка без заполненного Title персонального поля) — тогда null.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Url">
|
||||||
|
/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
|
||||||
|
/// потому что не константна: requestId и партиция списка (server-relative url,
|
||||||
|
/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
|
||||||
|
/// дублировать эту логику снаружи незачем.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
|
||||||
|
/// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
|
||||||
|
/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
|
||||||
|
/// (Power Automate ссылается на неё, работая со списком согласования конкретного
|
||||||
|
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
|
||||||
|
///
|
||||||
|
/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
|
||||||
|
/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
|
||||||
|
/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
|
||||||
|
/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
|
||||||
|
/// со значением "ApprovalOverdue"/"ProbationEnding".
|
||||||
|
/// </remarks>
|
||||||
|
[JsonPolymorphic]
|
||||||
|
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
|
||||||
|
[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
|
||||||
|
public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Какой вид напоминаний запросить у <see cref="ReminderService.GetRemindersAsync(ReminderFeed, CancellationToken)"/>.
|
||||||
|
///
|
||||||
|
/// <see cref="None"/> («вид не указан») трактуется как <see cref="ApprovalOverdue"/>:
|
||||||
|
/// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и
|
||||||
|
/// вызов без явного выбора обязан работать как раньше.
|
||||||
|
/// </summary>
|
||||||
|
public enum ReminderFeed
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
ApprovalOverdue = 1,
|
||||||
|
ProbationEnding = 2,
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кто прямо сейчас просрочил свой шаг согласования или исполнения.
|
||||||
|
///
|
||||||
|
/// Срез «на текущий момент», без памяти о том, что уже отправлено: повторный
|
||||||
|
/// вызов вернёт ту же заявку, пока по ней не примут решение. Так и задумано —
|
||||||
|
/// рассылкой и защитой от повторов занимается вызывающая сторона (Power
|
||||||
|
/// Automate по расписанию).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IOptions<RemindersOptions> options,
|
||||||
|
ILogger<ReminderService> logger)
|
||||||
|
{
|
||||||
|
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
|
||||||
|
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
|
||||||
|
private const string ActiveStatusFilter =
|
||||||
|
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
|
||||||
|
/// это был единственный вид напоминаний, и без явного выбора им и остаётся.
|
||||||
|
/// </summary>
|
||||||
|
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей
|
||||||
|
/// стороны. Момент «сейчас» берётся здесь же, из <see cref="TimeProvider"/>, а не
|
||||||
|
/// пробрасывается вызывающей стороной — во всех проде это <c>TimeProvider.System</c>
|
||||||
|
/// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/
|
||||||
|
/// GetProbationEndingAsync по-прежнему принимают <c>now</c> явным параметром: так
|
||||||
|
/// их сценарии остаются детерминированными без своего TimeProvider на каждый тест.
|
||||||
|
/// </summary>
|
||||||
|
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
|
||||||
|
ReminderFeed feed, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = timeProvider.GetUtcNow();
|
||||||
|
return feed switch
|
||||||
|
{
|
||||||
|
ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
|
||||||
|
_ => GetOverdueAsync(now, cancellationToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
||||||
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Точка входа — типы заявок с настроенным SLA. Тип без SLA не участвует
|
||||||
|
// в напоминаниях вообще, даже если у его шагов SLA задан: включение
|
||||||
|
// напоминаний — решение уровня типа заявки.
|
||||||
|
var types = await db.RequestTypes.AsNoTracking()
|
||||||
|
.Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не
|
||||||
|
// потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких
|
||||||
|
// веток Task.WhenAll ниже валит его исключением ("A second operation
|
||||||
|
// was started on this context instance before a previous operation
|
||||||
|
// completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо
|
||||||
|
// проглатывает — часть напоминаний молча пропадала бы без всякой
|
||||||
|
// видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность
|
||||||
|
// здесь не стоит ощутимого времени.
|
||||||
|
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||||
|
foreach (var type in types)
|
||||||
|
archivedByType[type.Id] = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
|
|
||||||
|
// Дальше типы независимы (разные сайты/списки в SharePoint, БД уже не
|
||||||
|
// трогают) — обход распараллелен, иначе время растёт линейно с числом
|
||||||
|
// типов: каждый тип сам по себе делает несколько последовательных
|
||||||
|
// HTTP-запросов.
|
||||||
|
var results = await Task.WhenAll(
|
||||||
|
types.Select(type => GetOverdueForTypeSafeAsync(type, archivedByType[type.Id], now, cancellationToken)));
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<ReminderDto>>(results.SelectMany(r => r).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeSafeAsync(
|
||||||
|
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await GetOverdueForTypeAsync(type, archived, now, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// Недоступный список одного типа не должен обнулять напоминания по
|
||||||
|
// всем остальным: Power Automate получит то, что удалось собрать.
|
||||||
|
logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в
|
||||||
|
/// архив, продолжает ждать чьего-то решения точно так же, как в текущем
|
||||||
|
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
||||||
|
/// новых заявок завели следующий список.
|
||||||
|
///
|
||||||
|
/// archived — уже прочитан вызывающей стороной (GetOverdueAsync,
|
||||||
|
/// последовательно для всех типов разом) — здесь БД больше не трогаем,
|
||||||
|
/// только HTTP к SharePoint, поэтому вызов можно параллелить по типам.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||||||
|
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
|
var partitions = archived
|
||||||
|
.Select(x => (x.ListId, x.Number))
|
||||||
|
.Append((current.ListId, current.Number));
|
||||||
|
|
||||||
|
var reminders = new List<ReminderDto>();
|
||||||
|
foreach (var (listId, number) in partitions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// Недоступная партиция (например, архивный список переименовали
|
||||||
|
// или удалили вручную) не должна обнулять напоминания по
|
||||||
|
// остальным партициям того же типа.
|
||||||
|
logger.LogError(
|
||||||
|
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
|
||||||
|
listId, number, type.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
|
||||||
|
RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var siteUrl = type.SiteUrl!;
|
||||||
|
|
||||||
|
// Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url
|
||||||
|
// список найдётся всегда.
|
||||||
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(dataListTitle))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
|
||||||
|
number, type.Name, listId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
|
||||||
|
if (approvalListTitle is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти",
|
||||||
|
dataListTitle);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активные строки — прямым $filter по полю ApprovalStatus.
|
||||||
|
//
|
||||||
|
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
|
||||||
|
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
|
||||||
|
// строки не нашёл: индекс мог не подхватить именно эту managed property
|
||||||
|
// для этого списка (она формируется автоматически, и после провижининга
|
||||||
|
// колонок SLA переиндексация могла ещё не пройти) — сама причина не
|
||||||
|
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
|
||||||
|
// он не отстаёт от реального состояния списка (не зависит от индексации
|
||||||
|
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
|
||||||
|
//
|
||||||
|
// Дата активации строки — прямо в ней самой (колонка Date): request-portal
|
||||||
|
// проставляет её в момент перехода строки в «На согласовании»/«На
|
||||||
|
// исполнении» (см. approvalList.service.ts/setActive) и никак иначе,
|
||||||
|
// поэтому больше не нужно ни читать весь маршрут заявки, ни дату
|
||||||
|
// создания заявки, ни выводить активацию из решений соседних шагов.
|
||||||
|
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
|
||||||
|
if (active.Count == 0) return [];
|
||||||
|
|
||||||
|
var uniqueCodes = await ReadUniqueCodesAsync(
|
||||||
|
siteUrl, type.ListId!, active.Select(r => r.TicketId).Distinct(), cancellationToken);
|
||||||
|
|
||||||
|
var reminders = new List<ReminderDto>();
|
||||||
|
foreach (var row in active)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
|
||||||
|
|
||||||
|
if (row.Date is not { } activatedAt)
|
||||||
|
{
|
||||||
|
// Строка активна, но Date пуста — так бывает у строк, заведённых
|
||||||
|
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
||||||
|
// почему-то не проставил её при активации. Без даты старта SLA не
|
||||||
|
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
||||||
|
//
|
||||||
|
// RequestTypeId — всегда, чтобы искать TicketId в правильном типе
|
||||||
|
// заявок. RequestUniqueCode — то, по чему заявку реально ищут в
|
||||||
|
// портале, добавляется, только если он уже есть (строка данных
|
||||||
|
// могла ещё не появиться или заявка старше появления колонки).
|
||||||
|
var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code
|
||||||
|
? $"requestTypeId={type.Id}, code={code}"
|
||||||
|
: $"requestTypeId={type.Id}";
|
||||||
|
logger.LogWarning(
|
||||||
|
"Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена",
|
||||||
|
row.Id, row.TicketId, identifier);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLA строки перекрывает типовой. У строк, созданных до появления
|
||||||
|
// колонки, значения нет — тогда работает SLA типа заявки.
|
||||||
|
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
|
||||||
|
var deadline = activatedAt.AddHours(slaHours);
|
||||||
|
if (deadline >= now) continue;
|
||||||
|
|
||||||
|
reminders.Add(new ApprovalOverdueReminderDto(
|
||||||
|
row.ApproverEmail!,
|
||||||
|
(int)Math.Floor((now - deadline).TotalHours),
|
||||||
|
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
|
||||||
|
type.Id,
|
||||||
|
type.Name,
|
||||||
|
row.TicketId,
|
||||||
|
row.Status,
|
||||||
|
uniqueCodes.GetValueOrDefault(row.TicketId),
|
||||||
|
BuildUrl(type, row, listId),
|
||||||
|
row.ApproverName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
|
||||||
|
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
|
||||||
|
/// откроется панелью поверх раздела (rpmode=approve).
|
||||||
|
///
|
||||||
|
/// listId — server-relative url партиции (см. shared/routing.ts в
|
||||||
|
/// request-portal, параметр rplist): без него архивная заявка открылась бы
|
||||||
|
/// так, будто лежит в ТЕКУЩЕЙ партиции типа, и портал не нашёл бы её.
|
||||||
|
/// </summary>
|
||||||
|
private string BuildUrl(RequestType type, ApprovalRow row, string listId) =>
|
||||||
|
options.Value.RequestUrlTemplate
|
||||||
|
.Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/'))
|
||||||
|
.Replace("{pageUrl}", type.PageUrl)
|
||||||
|
.Replace("{requestId}", row.TicketId.ToString())
|
||||||
|
.Replace("{listId}", Uri.EscapeDataString(listId))
|
||||||
|
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
|
||||||
|
|
||||||
|
private const string RowSelect =
|
||||||
|
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||||
|
"TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||||
|
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await sharePoint.GetItemsByListTitleAsync(
|
||||||
|
siteUrl, listTitle, $"{RowSelect}&{filter}", cancellationToken);
|
||||||
|
|
||||||
|
return Parse(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
||||||
|
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RequestUniqueCode заявок из списка данных — только по заявкам, у которых
|
||||||
|
/// сейчас есть просроченный шаг (а не по всему списку: он может быть большим).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyDictionary<int, string?>> ReadUniqueCodesAsync(
|
||||||
|
string siteUrl, string dataListUrl, IEnumerable<int> ticketIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}"));
|
||||||
|
if (filter.Length == 0) return new Dictionary<int, string?>();
|
||||||
|
|
||||||
|
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
siteUrl, dataListUrl, $"$select=Id,RequestUniqueCode&$filter={filter}", cancellationToken);
|
||||||
|
|
||||||
|
var result = new Dictionary<int, string?>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) continue;
|
||||||
|
|
||||||
|
result[id] = item.TryGetProperty("RequestUniqueCode", out var code) && code.ValueKind == JsonValueKind.String
|
||||||
|
? code.GetString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока.
|
||||||
|
///
|
||||||
|
/// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую
|
||||||
|
/// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам,
|
||||||
|
/// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в
|
||||||
|
/// портале, и напоминание уходило бы на день раньше срока.
|
||||||
|
///
|
||||||
|
/// Константой, а не настройкой: смещение фиксировано для всей организации и не
|
||||||
|
/// меняется от окружения (перехода на летнее время нет).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
|
||||||
|
new Dictionary<string, int>
|
||||||
|
{
|
||||||
|
["1 месяц"] = 1,
|
||||||
|
["2 месяца"] = 2,
|
||||||
|
["3 месяца"] = 3,
|
||||||
|
["6 месяцев"] = 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кому скоро выходить из испытательного срока — по заявкам «Заявка на приём»
|
||||||
|
/// (recruiting-hire). В отличие от <see cref="GetOverdueAsync"/> НЕ завязано на
|
||||||
|
/// SlaHours типа заявки: это отдельный вид напоминаний со своим триггером
|
||||||
|
/// (дата приёма + испытательный срок), а не просрочка шага согласования.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetProbationEndingAsync(
|
||||||
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var type = await db.RequestTypes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.PageUrl == options.Value.RecruitingHirePageUrl && x.ListId != null && x.SiteUrl != null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (type is null) return Result.Ok<IReadOnlyList<ReminderDto>>([]);
|
||||||
|
|
||||||
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
|
// Испытательный срок — не больше 6 месяцев (см. ProbationMonthsByOption),
|
||||||
|
// поэтому заявка, способная дать напоминание сегодня, обязана лежать в
|
||||||
|
// текущей партиции или в партиции прямо перед ней: более старая партиция
|
||||||
|
// физически не может содержать ещё актуальный испытательный срок. Читать
|
||||||
|
// весь архив, как это делает GetOverdueAsync (там заявка может висеть
|
||||||
|
// непринятой годами), здесь незачем — только лишняя нагрузка на SharePoint.
|
||||||
|
var lastArchived = archived.OrderByDescending(x => x.Number).FirstOrDefault();
|
||||||
|
var partitions = new List<(string ListId, int Number)> { (current.ListId, current.Number) };
|
||||||
|
if (lastArchived is not null) partitions.Add((lastArchived.ListId, lastArchived.Number));
|
||||||
|
|
||||||
|
// Обе базы сотрудников читаются по одному разу на весь обход партиций (а не
|
||||||
|
// на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны
|
||||||
|
// сразу и дата увольнения (только в FullBase), и email (только в All).
|
||||||
|
var directory = new EmployeeDirectorySnapshot(
|
||||||
|
await portalClient.GetFullBaseAsync(cancellationToken),
|
||||||
|
await portalClient.GetAllEmployeesAsync(cancellationToken));
|
||||||
|
|
||||||
|
var reminders = new List<ReminderDto>();
|
||||||
|
foreach (var (listId, number) in partitions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reminders.AddRange(
|
||||||
|
await GetProbationEndingForPartitionAsync(type, listId, directory, now, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
e, "Напоминания об испытательном сроке по списку {ListId} (партиция №{Number}) не собраны",
|
||||||
|
listId, number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<ReminderDto>>(reminders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string HireRowSelect =
|
||||||
|
"$select=Id,RequestStatus,CandidateName,HireDate,ProbationPeriod,RequestUniqueCode&$top=5000";
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ReminderDto>> GetProbationEndingForPartitionAsync(
|
||||||
|
RequestType type, string listId, EmployeeDirectorySnapshot directory,
|
||||||
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var siteUrl = type.SiteUrl!;
|
||||||
|
|
||||||
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
|
||||||
|
|
||||||
|
// HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две
|
||||||
|
// последних партиции (см. GetProbationEndingAsync): более старый приём уже
|
||||||
|
// не может дать напоминание, читать его строки незачем даже в пределах
|
||||||
|
// партиции, где они могут лежать вперемешку со свежими.
|
||||||
|
var since = now.AddMonths(-6);
|
||||||
|
var filter =
|
||||||
|
$"$filter=(RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}')" +
|
||||||
|
$" and HireDate ge datetime'{since:yyyy-MM-ddTHH:mm:ssZ}'";
|
||||||
|
var items = await sharePoint.GetItemsByListTitleAsync(
|
||||||
|
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
|
||||||
|
var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
|
|
||||||
|
var reminders = new List<ReminderDto>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var reminder = await TryBuildProbationReminderAsync(row, directory, now, cancellationToken);
|
||||||
|
if (reminder is not null) reminders.Add(reminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ни type, ни listId здесь больше не нужны: ссылка в этом виде напоминаний —
|
||||||
|
/// константа из настроек (см. ProbationReminderUrl), а поля самой заявки
|
||||||
|
/// (RequestTypeId и т.п.) в ProbationEndingReminderDto не входят.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ReminderDto?> TryBuildProbationReminderAsync(
|
||||||
|
HireRow row, EmployeeDirectorySnapshot directory,
|
||||||
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null;
|
||||||
|
|
||||||
|
if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months))
|
||||||
|
return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
|
||||||
|
|
||||||
|
// Обе даты — в местном поясе (см. LocalOffset): и дату приёма, и «сегодня»
|
||||||
|
// надо брать по тому же календарю, который видит человек в портале.
|
||||||
|
var hireDateLocal = hireDate.ToOffset(LocalOffset);
|
||||||
|
var probationEndsAt = hireDateLocal.AddMonths(months);
|
||||||
|
var daysLeft = (probationEndsAt.Date - now.ToOffset(LocalOffset).Date).Days;
|
||||||
|
if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null;
|
||||||
|
|
||||||
|
var candidateName = row.CandidateName.Trim();
|
||||||
|
|
||||||
|
// Перечислены оба «продолжающих» исхода явно, а «пропустить» — это default:
|
||||||
|
// так любое НЕ подтверждённое трудоустройство (в том числе EmploymentStatus.None,
|
||||||
|
// если он когда-нибудь сюда доедет) приводит к пропуску с предупреждением, а не
|
||||||
|
// молча считается «работает».
|
||||||
|
switch (directory.EmploymentOf(candidateName))
|
||||||
|
{
|
||||||
|
case EmploymentStatus.Employed:
|
||||||
|
break;
|
||||||
|
case EmploymentStatus.Dismissed:
|
||||||
|
return null; // уже уволен — напоминать не о чем.
|
||||||
|
default:
|
||||||
|
logger.LogWarning(
|
||||||
|
"Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено",
|
||||||
|
row.Id, candidateName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateEmail = directory.EmailOf(candidateName);
|
||||||
|
if (string.IsNullOrWhiteSpace(candidateEmail))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Заявка на приём {RequestId}: у кандидата «{Fio}» не найден email в справочнике — напоминание пропущено",
|
||||||
|
row.Id, candidateName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var managerEmail = await portalClient.GetManagerAsync(candidateEmail, "chief", cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(managerEmail))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Заявка на приём {RequestId}: у кандидата «{Fio}» не найден руководитель — напоминание пропущено",
|
||||||
|
row.Id, candidateName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProbationEndingReminderDto(
|
||||||
|
ReceiverEmail: managerEmail,
|
||||||
|
CandidateEmail: candidateEmail,
|
||||||
|
CandidateName: candidateName,
|
||||||
|
ProbationPeriod: row.ProbationPeriod,
|
||||||
|
// Тоже в местном поясе — иначе в письме дата приёма была бы на сутки
|
||||||
|
// раньше той, от которой посчитан ProbationEndsAt рядом.
|
||||||
|
HireDate: hireDateLocal,
|
||||||
|
ProbationEndsAt: probationEndsAt,
|
||||||
|
DaysUntilProbationEnds: daysLeft,
|
||||||
|
// Не ссылка на саму заявку на приём (она уже закрыта, решать по ней
|
||||||
|
// нечего) — константный адрес раздела, где руководитель заводит новую
|
||||||
|
// заявку по итогам испытательного срока. См. ProbationReminderUrl.
|
||||||
|
Url: options.Value.ProbationReminderUrl,
|
||||||
|
ReceiverName: directory.NameByEmail(managerEmail));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
public sealed class RemindersOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Шаблон ссылки на заявку. Подстановки: {siteUrl}, {pageUrl}, {requestId},
|
||||||
|
/// {listId}, {nav}.
|
||||||
|
///
|
||||||
|
/// Портал — одностраничный и живёт в КОРНЕ сайта, отдельного пути к странице
|
||||||
|
/// нет; всё состояние передаётся параметрами запроса (см. shared/routing.ts
|
||||||
|
/// в request-portal). Пример живой ссылки:
|
||||||
|
/// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11&rplist=%2Fsites%2Frequests%2FLists%2F...
|
||||||
|
///
|
||||||
|
/// {pageUrl} — это PageUrl типа заявки, то есть КЛЮЧ ФОРМЫ (it-1c-ticket), а
|
||||||
|
/// не путь к странице: в портале он и подставляется в rptype.
|
||||||
|
///
|
||||||
|
/// {listId} — server-relative url КОНКРЕТНОЙ партиции, где лежит строка
|
||||||
|
/// согласования (параметр rplist). Без него портал открывал бы заявку так,
|
||||||
|
/// будто она лежит в ТЕКУЩЕЙ партиции типа, и архивная заявка не находилась
|
||||||
|
/// бы вовсе (см. IRoute.listId в request-portal/shared/routing.ts).
|
||||||
|
///
|
||||||
|
/// Вынесено в конфигурацию, чтобы смена разметки ссылок в портале правилась
|
||||||
|
/// настройкой, а не пересборкой сервиса.
|
||||||
|
/// </summary>
|
||||||
|
public string RequestUrlTemplate { get; set; } =
|
||||||
|
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PageUrl типа заявки «Заявка на приём» (см. request-portal, forms/RecruitingHire/model.ts,
|
||||||
|
/// FORM_ID) — по нему ищется RequestType для напоминаний об окончании испытательного срока.
|
||||||
|
/// Не завязано на SlaHours: этот вид напоминаний не про просрочку шага согласования.
|
||||||
|
/// </summary>
|
||||||
|
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// За сколько дней до конца испытательного срока присылать напоминание — неделя,
|
||||||
|
/// чтобы у руководителя было время подготовить решение по итогам срока.
|
||||||
|
///
|
||||||
|
/// Дни КАЛЕНДАРНЫЕ, не рабочие: напоминание строится ровно в тот день, когда до
|
||||||
|
/// конца срока остаётся столько дней, и попади он на выходной — уйдёт в выходной.
|
||||||
|
/// </summary>
|
||||||
|
public int ProbationReminderDaysBeforeEnd { get; set; } = 7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Куда ведёт ссылка в напоминании об окончании испытательного срока — на
|
||||||
|
/// раздел «Подать заявку» портала, а НЕ на саму заявку на приём: от
|
||||||
|
/// руководителя по этому напоминанию требуется завести НОВУЮ заявку (решение
|
||||||
|
/// по итогам испытательного срока), а не открыть уже закрытую старую.
|
||||||
|
///
|
||||||
|
/// Поэтому ссылка константная, без подстановок: адрес конкретной формы в
|
||||||
|
/// портал вообще не передать — параметр rptype без rpid/rpsource
|
||||||
|
/// игнорируется, и маршрут сваливается в раздел «Подать заявку» (см.
|
||||||
|
/// parseRoute в request-portal/shared/routing.ts). Отдельная настройка, а не
|
||||||
|
/// хардкод, — чтобы адрес правился конфигурацией.
|
||||||
|
/// </summary>
|
||||||
|
public string ProbationReminderUrl { get; set; } =
|
||||||
|
"https://tnsukz.sharepoint.com/sites/requests?rpnav=new";
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка ФАЗЫ ИСПОЛНЕНИЯ из списка «Заявка на подбор — согласование». Отчёту она
|
||||||
|
/// даёт две колонки, которых нет в самой заявке: рекрутёр (исполнитель шага) и
|
||||||
|
/// дата закрытия вакансии.
|
||||||
|
///
|
||||||
|
/// Дата закрытия — это колонка Date выполненной строки: request-portal
|
||||||
|
/// перезаписывает её в момент решения по шагу (см. setDecision в
|
||||||
|
/// shared/approval/approvalList.service.ts), так что у строки со статусом
|
||||||
|
/// «Выполнено» там именно дата закрытия, а не дата получения шага в работу.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ExecutionRow(
|
||||||
|
int TicketId,
|
||||||
|
int StepOrder,
|
||||||
|
string Status,
|
||||||
|
string? ExecutorName,
|
||||||
|
DateOnly? Date)
|
||||||
|
{
|
||||||
|
public const string Select =
|
||||||
|
$"$select=Id,TicketId,ApprovalStatus,StepOrder,Date,TansuSiteApprover/Title" +
|
||||||
|
$"&$expand=TansuSiteApprover&$filter=Phase eq '{Phase}'&$top=5000";
|
||||||
|
|
||||||
|
private const string Phase = "Исполнение";
|
||||||
|
|
||||||
|
/// <summary>Статус выполненной строки исполнения — по нему считаем вакансию закрытой.</summary>
|
||||||
|
public const string Done = "Выполнено";
|
||||||
|
|
||||||
|
public static ExecutionRow? TryParse(JsonElement item)
|
||||||
|
{
|
||||||
|
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||||
|
var ticketId = SharePointJson.Int(item, "TicketId");
|
||||||
|
if (ticketId is null) return null;
|
||||||
|
|
||||||
|
return new ExecutionRow(
|
||||||
|
ticketId.Value,
|
||||||
|
SharePointJson.Int(item, "StepOrder") ?? 0,
|
||||||
|
SharePointJson.String(item, "ApprovalStatus") ?? string.Empty,
|
||||||
|
SharePointJson.PersonTitle(item, "TansuSiteApprover"),
|
||||||
|
SharePointJson.Date(item, "Date"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка списка «Прием на работу — данные» в том объёме, в каком она нужна отчёту:
|
||||||
|
/// на какую заявку на подбор ссылается, кого и когда приняли.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record HireLinkRow(
|
||||||
|
int SelectionRequestId,
|
||||||
|
string? SelectionRequestListId,
|
||||||
|
string Status,
|
||||||
|
string? CandidateName,
|
||||||
|
string? ReplacedEmployee,
|
||||||
|
DateOnly? HireDate)
|
||||||
|
{
|
||||||
|
public const string Select =
|
||||||
|
"$select=Id,RequestStatus,SelectionRequestId,SelectionRequestListId," +
|
||||||
|
"CandidateName,ReplacedEmployee,HireDate&$top=5000";
|
||||||
|
|
||||||
|
/// <summary>Заявка на приём без ссылки на подбор отчёту бесполезна — её отбрасываем.</summary>
|
||||||
|
public static HireLinkRow? TryParse(JsonElement item)
|
||||||
|
{
|
||||||
|
var selectionId = SharePointJson.Int(item, "SelectionRequestId");
|
||||||
|
if (selectionId is null or <= 0) return null;
|
||||||
|
|
||||||
|
return new HireLinkRow(
|
||||||
|
selectionId.Value,
|
||||||
|
SharePointJson.String(item, "SelectionRequestListId"),
|
||||||
|
SharePointJson.String(item, "RequestStatus") ?? string.Empty,
|
||||||
|
SharePointJson.String(item, "CandidateName"),
|
||||||
|
SharePointJson.String(item, "ReplacedEmployee"),
|
||||||
|
SharePointJson.Date(item, "HireDate"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>Настройки отчётов (секция Reports в конфигурации).</summary>
|
||||||
|
public sealed class ReportsOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PageUrl типа заявки «Заявка на подбор» (см. request-portal,
|
||||||
|
/// forms/RecruitingSelection/model.ts, FORM_ID) — по нему ищется RequestType,
|
||||||
|
/// из партиций которого берутся сами вакансии.
|
||||||
|
/// </summary>
|
||||||
|
public string RecruitingSelectionPageUrl { get; set; } = "recruiting-selection";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PageUrl типа заявки «Приём на работу» — из его партиций берутся закрытия
|
||||||
|
/// вакансий: заявка на приём ссылается на заявку на подбор колонками
|
||||||
|
/// SelectionRequestId / SelectionRequestListId.
|
||||||
|
/// </summary>
|
||||||
|
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>Готовый файл выгрузки и его имя.</summary>
|
||||||
|
public sealed record RouteExportFile(byte[] Content, string FileName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов
|
||||||
|
/// заявок, превращает их в понятные человеку строки (роль, исполнитель,
|
||||||
|
/// организация, условие, комментарий) и отдаёт на вёрстку в
|
||||||
|
/// <see cref="RouteExportWorkbook"/>.
|
||||||
|
///
|
||||||
|
/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага,
|
||||||
|
/// <see cref="RouteStepStatus"/>), у каждой своя нумерация.
|
||||||
|
/// </summary>
|
||||||
|
|
||||||
|
public sealed class RouteExportService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
|
IOptions<TnsuPortalOptions> portalOptions)
|
||||||
|
{
|
||||||
|
private const int MaxTypesInFileName = 7;
|
||||||
|
private const int MaxTypeNameLength = 20;
|
||||||
|
|
||||||
|
private const string Empty = "—";
|
||||||
|
private const string Always = "Всегда";
|
||||||
|
private const string AnyCompany = "любая компания";
|
||||||
|
private const string AnyDepartment = "любой отдел";
|
||||||
|
private const string ByRequest = "Определяется по заявке";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Технические имена колонок списка, которые в таблице показываем по-человечески
|
||||||
|
/// (CompaniesJson — организации заявки).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> ConditionFieldAliases = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["CompaniesJson"] = "организация"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
|
||||||
|
{
|
||||||
|
[ComparisonOperator.GreaterThan] = ">",
|
||||||
|
[ComparisonOperator.LessThan] = "<",
|
||||||
|
[ComparisonOperator.Equal] = "=",
|
||||||
|
[ComparisonOperator.NotEqual] = "≠",
|
||||||
|
[ComparisonOperator.Contains] = "содержит",
|
||||||
|
[ComparisonOperator.NotContains] = "не содержит"
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<Result<RouteExportFile>> BuildAsync(IReadOnlyList<int>? requestTypeIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (requestTypeIds is not { Count: > 0 })
|
||||||
|
return Result.Fail("Не выбрано ни одного типа заявки.");
|
||||||
|
|
||||||
|
var types = await db.RequestTypes.AsNoTracking()
|
||||||
|
.Where(x => requestTypeIds.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (types.Count == 0)
|
||||||
|
return Result.Fail("Выбранные типы заявок не найдены.");
|
||||||
|
|
||||||
|
var routes = await db.TemplateRoutes.AsNoTracking()
|
||||||
|
.Where(x => requestTypeIds.Contains(x.RequestTypeId))
|
||||||
|
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||||
|
// Две вложенные коллекции (шаги и их условия) одним запросом дали бы декартово
|
||||||
|
// произведение строк — режем на отдельные запросы.
|
||||||
|
.AsSplitQuery()
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var directory = await LoadDirectoryAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sheets = types
|
||||||
|
.Select(type => BuildSheet(type, routes.Where(r => r.RequestTypeId == type.Id).ToList(), directory))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Ok(new RouteExportFile(
|
||||||
|
RouteExportWorkbook.Build(sheets),
|
||||||
|
FileName(types.Select(t => t.Name).ToList(), DateOnly.FromDateTime(DateTime.Now))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// «Маршруты согласования (подбор, прием на работу) 13.10.2026.xlsx» — в скобках
|
||||||
|
/// сокращённые названия типов (не больше <see cref="MaxTypesInFileName"/>, остальные
|
||||||
|
/// сворачиваются в «и др.»), в конце — дата выгрузки.
|
||||||
|
/// </summary>
|
||||||
|
public static string FileName(IReadOnlyList<string> typeNames, DateOnly date)
|
||||||
|
{
|
||||||
|
var shown = typeNames.Take(MaxTypesInFileName).Select(Abbreviate).Where(n => n.Length > 0).ToList();
|
||||||
|
var list = string.Join(", ", shown) + (typeNames.Count > MaxTypesInFileName ? " и др." : string.Empty);
|
||||||
|
|
||||||
|
var name = $"Маршруты согласования ({list}) {date:dd.MM.yyyy}.xlsx";
|
||||||
|
return string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? ' ' : c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Заявка на подбор персонала» → «подбор персонала»: служебное начало названия
|
||||||
|
// ничего не говорит, а длинное название раздуло бы имя файла.
|
||||||
|
private static string Abbreviate(string typeName)
|
||||||
|
{
|
||||||
|
var name = typeName.Trim();
|
||||||
|
foreach (var prefix in new[] { "Заявка на ", "Заявка о ", "Заявка " })
|
||||||
|
{
|
||||||
|
if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
name = name[prefix.Length..];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Length <= MaxTypeNameLength)
|
||||||
|
return name;
|
||||||
|
|
||||||
|
var cut = name[..MaxTypeNameLength];
|
||||||
|
var lastSpace = cut.LastIndexOf(' ');
|
||||||
|
return (lastSpace > MaxTypeNameLength / 2 ? cut[..lastSpace] : cut).TrimEnd() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PortalDirectory> LoadDirectoryAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Справочники кешируются клиентом портала (см. CachingTnsuPortalClient).
|
||||||
|
var employees = await portalClient.GetAllEmployeesAsync(cancellationToken);
|
||||||
|
var companies = await portalClient.GetCompaniesAsync(cancellationToken);
|
||||||
|
var departments = await portalClient.GetDepartmentsAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new PortalDirectory(
|
||||||
|
employees,
|
||||||
|
companies.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First().Name),
|
||||||
|
departments.GroupBy(d => d.Id).ToDictionary(g => g.Key, g => g.First().Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private RouteExportSheet BuildSheet(RequestType type, IReadOnlyList<TemplateRoute> routes, PortalDirectory directory)
|
||||||
|
{
|
||||||
|
var sections = routes
|
||||||
|
.Select(route =>
|
||||||
|
{
|
||||||
|
return new RouteExportSection(
|
||||||
|
routes.Count < 2 ? null : RouteTitle(route, directory),
|
||||||
|
BuildGroups(route.Steps, directory));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new RouteExportSheet(type.Name, sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RouteTitle(TemplateRoute route, PortalDirectory directory)
|
||||||
|
{
|
||||||
|
var company = route.CompanyId is { } c ? directory.CompanyName(c) : AnyCompany;
|
||||||
|
var department = route.DepartmentId is { } d ? directory.DepartmentName(d) : AnyDepartment;
|
||||||
|
return $"Маршрут: {company} · {department}{(route.ByProject ? " · по проекту" : string.Empty)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<RouteExportGroup> BuildGroups(IEnumerable<TemplateRouteStep> steps, PortalDirectory directory)
|
||||||
|
{
|
||||||
|
var ordered = steps.OrderBy(s => s.OrderNo).ToList();
|
||||||
|
var approvers = ordered.Where(s => s.Status != RouteStepStatus.Execution).ToList();
|
||||||
|
var executors = ordered.Where(s => s.Status == RouteStepStatus.Execution).ToList();
|
||||||
|
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new RouteExportGroup("Согласующие", BuildRows(approvers, directory)),
|
||||||
|
new RouteExportGroup("Исполнители", BuildRows(executors, directory))
|
||||||
|
}
|
||||||
|
.Where(g => g.Rows.Count > 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параллельные шаги (равный OrderNo) получают один и тот же номер.
|
||||||
|
private List<RouteExportRow> BuildRows(IReadOnlyList<TemplateRouteStep> steps, PortalDirectory directory)
|
||||||
|
{
|
||||||
|
var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList();
|
||||||
|
|
||||||
|
return steps
|
||||||
|
.Select(step =>
|
||||||
|
{
|
||||||
|
var (role, executor, organization) = DescribeRoleAndExecutor(step, directory);
|
||||||
|
return new RouteExportRow(
|
||||||
|
orderNos.IndexOf(step.OrderNo) + 1,
|
||||||
|
role,
|
||||||
|
executor,
|
||||||
|
organization,
|
||||||
|
DescribeCondition(step),
|
||||||
|
DescribeComment(step));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Роль» — должность или тип руководителя, «исполнитель» — конкретный человек,
|
||||||
|
// если он известен уже при настройке маршрута. Директор Tansu и Управляющий
|
||||||
|
// директор Tansu — тоже конкретные люди (их email лежит в настройках).
|
||||||
|
private (string Role, string Executor, string Organization) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||||
|
{
|
||||||
|
var employee = directory.FindByEmail(step.ExecutorEmail);
|
||||||
|
return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail, directory.CompaniesOf(step.ExecutorEmail));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.Position))
|
||||||
|
{
|
||||||
|
var holder = directory.FindByPosition(step.Position);
|
||||||
|
return (step.Position, holder?.Fio ?? Empty, directory.CompaniesOf(holder?.Email));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
|
||||||
|
return (StepLabels.Of(projectRole), ByRequest, Empty);
|
||||||
|
|
||||||
|
if (step.HeadType is { } headType && headType != HeadType.None)
|
||||||
|
{
|
||||||
|
var fixedEmail = headType switch
|
||||||
|
{
|
||||||
|
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||||
|
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (fixedEmail is not null)
|
||||||
|
// Нет в справочнике — лучше показать email, чем пустую ячейку.
|
||||||
|
return (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
|
||||||
|
|
||||||
|
return (StepLabels.Of(headType), ByRequest, Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Empty, Empty, Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeCondition(TemplateRouteStep step)
|
||||||
|
{
|
||||||
|
if (step.Conditions.Count == 0)
|
||||||
|
return Always;
|
||||||
|
|
||||||
|
var joiner = step.ConditionsLogic == ConditionsLogic.Or ? " ИЛИ " : " И ";
|
||||||
|
return string.Join(joiner, step.Conditions.OrderBy(c => c.OrderNo).Select(c =>
|
||||||
|
{
|
||||||
|
var field = ConditionFieldAliases.GetValueOrDefault(c.FieldName, c.FieldName);
|
||||||
|
var symbol = OperatorSymbols.GetValueOrDefault(c.Operation, string.Empty);
|
||||||
|
return $"{field} {symbol} {c.Value}";
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Галочка «можно переназначить» — шаг может передать другому: согласующий
|
||||||
|
// назначает согласующего, исполнитель — исполнителя.
|
||||||
|
private static string DescribeComment(TemplateRouteStep step) =>
|
||||||
|
!step.AllowReassignToOtherApprovers
|
||||||
|
? string.Empty
|
||||||
|
: step.Status == RouteStepStatus.Execution ? "может назначить исполнителя" : "может назначить согласующего";
|
||||||
|
|
||||||
|
private static string EmptyIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? Empty : value;
|
||||||
|
|
||||||
|
private sealed class PortalDirectory(
|
||||||
|
IReadOnlyList<EmployeeContactInfo> employees,
|
||||||
|
IReadOnlyDictionary<Guid, string> companies,
|
||||||
|
IReadOnlyDictionary<Guid, string> departments)
|
||||||
|
{
|
||||||
|
// Справочник даёт строку на каждую компанию сотрудника — берём первую с
|
||||||
|
// заполненной должностью, остальные для ФИО/должности ничего не добавляют.
|
||||||
|
public EmployeeContactInfo? FindByEmail(string email) =>
|
||||||
|
employees.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(e => !string.IsNullOrWhiteSpace(e.Position))
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
public EmployeeContactInfo? FindByPosition(string position) =>
|
||||||
|
employees.FirstOrDefault(e => string.Equals(e.Position?.Trim(), position.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// Организация — компания СОТРУДНИКА, а не маршрута. Справочник даёт строку на
|
||||||
|
// каждую компанию, где он числится (совместители), поэтому выводим все.
|
||||||
|
public string CompaniesOf(string? email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
return Empty;
|
||||||
|
|
||||||
|
var names = employees
|
||||||
|
.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrWhiteSpace(e.Company))
|
||||||
|
.Select(e => e.Company)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
return names.Count == 0 ? Empty : string.Join(", ", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пока справочник не отдал имя (или запись удалена) — усечённый Guid, как в админке.
|
||||||
|
public string CompanyName(Guid id) => companies.GetValueOrDefault(id) ?? ShortGuid(id);
|
||||||
|
|
||||||
|
public string DepartmentName(Guid id) => departments.GetValueOrDefault(id) ?? ShortGuid(id);
|
||||||
|
|
||||||
|
private static string ShortGuid(Guid id) => id.ToString()[..8];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using NPOI.SS.UserModel;
|
||||||
|
using NPOI.SS.Util;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
|
||||||
|
public sealed record RouteExportRow(
|
||||||
|
int Number,
|
||||||
|
string Role,
|
||||||
|
string Executor,
|
||||||
|
string Organization,
|
||||||
|
string Condition,
|
||||||
|
string Comment);
|
||||||
|
|
||||||
|
/// <summary>Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация).</summary>
|
||||||
|
public sealed record RouteExportGroup(string Caption, IReadOnlyList<RouteExportRow> Rows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
|
||||||
|
/// (по компании/отделу) и их нужно различать на одном листе; у единственного
|
||||||
|
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
|
||||||
|
|
||||||
|
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
||||||
|
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
||||||
|
|
||||||
|
/// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
|
||||||
|
public sealed record RouteExportRequest(IReadOnlyList<int>? RequestTypeIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
||||||
|
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
||||||
|
///
|
||||||
|
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
|
||||||
|
/// <see cref="RouteExportService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class RouteExportWorkbook
|
||||||
|
{
|
||||||
|
private const int MaxSheetNameLength = 31;
|
||||||
|
private const string InvalidSheetNameChars = "[]:*?/\\";
|
||||||
|
|
||||||
|
private static readonly (string Title, double Width)[] Columns =
|
||||||
|
[
|
||||||
|
(" ", 6.0),
|
||||||
|
("Роль ", 41.1),
|
||||||
|
("Исполнитель ", 42.2),
|
||||||
|
("Организация ", 28.0),
|
||||||
|
("Условие ", 66.2),
|
||||||
|
("Комментарий ", 36.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
|
||||||
|
{
|
||||||
|
var workbook = new XSSFWorkbook();
|
||||||
|
|
||||||
|
var headerStyle = CreateStyle(workbook, bold: true);
|
||||||
|
var cellStyle = CreateStyle(workbook, bold: false);
|
||||||
|
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
||||||
|
|
||||||
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var sheetData in sheets)
|
||||||
|
{
|
||||||
|
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
|
||||||
|
for (var i = 0; i < Columns.Length; i++)
|
||||||
|
sheet.SetColumnWidth(i, (int)(Columns[i].Width * 256));
|
||||||
|
|
||||||
|
var rowIndex = 0;
|
||||||
|
foreach (var section in sheetData.Sections)
|
||||||
|
{
|
||||||
|
if (rowIndex > 0) rowIndex++; // пустая строка только между маршрутами
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||||
|
{
|
||||||
|
var titleRow = sheet.CreateRow(rowIndex);
|
||||||
|
var titleCell = titleRow.CreateCell(0);
|
||||||
|
titleCell.SetCellValue(section.Title);
|
||||||
|
titleCell.CellStyle = titleStyle;
|
||||||
|
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
||||||
|
rowIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шапка колонок — один раз на маршрут: исполнители идут сразу под
|
||||||
|
// согласующими, их отделяет только строка-подпись.
|
||||||
|
var firstGroup = true;
|
||||||
|
foreach (var group in section.Groups)
|
||||||
|
{
|
||||||
|
var captionRow = sheet.CreateRow(rowIndex);
|
||||||
|
var captionCell = captionRow.CreateCell(0);
|
||||||
|
captionCell.SetCellValue(group.Caption);
|
||||||
|
captionCell.CellStyle = titleStyle;
|
||||||
|
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
||||||
|
rowIndex++;
|
||||||
|
|
||||||
|
if (firstGroup)
|
||||||
|
{
|
||||||
|
var header = sheet.CreateRow(rowIndex++);
|
||||||
|
for (var i = 0; i < Columns.Length; i++)
|
||||||
|
{
|
||||||
|
var cell = header.CreateCell(i);
|
||||||
|
cell.SetCellValue(Columns[i].Title);
|
||||||
|
cell.CellStyle = headerStyle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstGroup = false;
|
||||||
|
|
||||||
|
foreach (var row in group.Rows)
|
||||||
|
{
|
||||||
|
var excelRow = sheet.CreateRow(rowIndex++);
|
||||||
|
var numberCell = excelRow.CreateCell(0);
|
||||||
|
numberCell.SetCellValue(row.Number);
|
||||||
|
numberCell.CellStyle = cellStyle;
|
||||||
|
WriteText(excelRow, 1, row.Role, cellStyle);
|
||||||
|
WriteText(excelRow, 2, row.Executor, cellStyle);
|
||||||
|
WriteText(excelRow, 3, row.Organization, cellStyle);
|
||||||
|
WriteText(excelRow, 4, row.Condition, cellStyle);
|
||||||
|
WriteText(excelRow, 5, row.Comment, cellStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.Write(stream, leaveOpen: true);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteText(IRow row, int column, string value, ICellStyle style)
|
||||||
|
{
|
||||||
|
var cell = row.CreateCell(column);
|
||||||
|
cell.SetCellValue(value);
|
||||||
|
cell.CellStyle = style;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ICellStyle CreateStyle(IWorkbook workbook, bool bold, bool border = true)
|
||||||
|
{
|
||||||
|
var font = workbook.CreateFont();
|
||||||
|
font.IsBold = bold;
|
||||||
|
|
||||||
|
var style = workbook.CreateCellStyle();
|
||||||
|
style.SetFont(font);
|
||||||
|
style.WrapText = true;
|
||||||
|
style.VerticalAlignment = VerticalAlignment.Top;
|
||||||
|
if (border)
|
||||||
|
{
|
||||||
|
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя листа Excel: до 31 символа, без []:*?/\, уникальное без учёта регистра.
|
||||||
|
private static string UniqueSheetName(string raw, HashSet<string> used)
|
||||||
|
{
|
||||||
|
var cleaned = new string((raw ?? string.Empty)
|
||||||
|
.Select(c => InvalidSheetNameChars.Contains(c) ? ' ' : c)
|
||||||
|
.ToArray()).Trim();
|
||||||
|
if (cleaned.Length == 0) cleaned = "Лист";
|
||||||
|
|
||||||
|
var candidate = Truncate(cleaned, MaxSheetNameLength);
|
||||||
|
for (var n = 2; !used.Add(candidate); n++)
|
||||||
|
{
|
||||||
|
var suffix = $" ({n})";
|
||||||
|
candidate = Truncate(cleaned, MaxSheetNameLength - suffix.Length) + suffix;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string value, int length) =>
|
||||||
|
value.Length <= length ? value : value[..length].TrimEnd();
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка списка «Заявка на подбор — данные». Имена колонок — как в request-portal
|
||||||
|
/// (provisioning.json формы RecruitingSelection).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record SelectionRow(
|
||||||
|
int Id,
|
||||||
|
string ListId,
|
||||||
|
string Status,
|
||||||
|
string? Position,
|
||||||
|
string? DepartmentName,
|
||||||
|
string? ObjectProject,
|
||||||
|
int VacanciesCount,
|
||||||
|
DateOnly? SubmitDate,
|
||||||
|
string? Initiator,
|
||||||
|
string? OpenReason,
|
||||||
|
string? OpenReasonDetails)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Поля, которые нужны отчёту, — перечислены явно: список данных широкий
|
||||||
|
/// (полсотни колонок, часть — Note с многострочным текстом), и тянуть его
|
||||||
|
/// целиком по всем партициям только ради шестнадцати колонок незачем.
|
||||||
|
/// </summary>
|
||||||
|
public const string Select =
|
||||||
|
"$select=Id,RequestStatus,Position,DepartmentName,ObjectProject,OpenVacanciesCount," +
|
||||||
|
"SubmitDate,Created,VacancyOpenReason,VacancyOpenReasonDetails,Responsible/Title&$expand=Responsible&$top=5000";
|
||||||
|
|
||||||
|
public static SelectionRow? TryParse(JsonElement item, string listId)
|
||||||
|
{
|
||||||
|
var id = SharePointJson.Int(item, "Id");
|
||||||
|
if (id is null) return null;
|
||||||
|
|
||||||
|
return new SelectionRow(
|
||||||
|
id.Value,
|
||||||
|
listId,
|
||||||
|
SharePointJson.String(item, "RequestStatus") ?? string.Empty,
|
||||||
|
SharePointJson.String(item, "Position"),
|
||||||
|
SharePointJson.String(item, "DepartmentName"),
|
||||||
|
SharePointJson.String(item, "ObjectProject"),
|
||||||
|
// Пустое «Количество открытых вакансий» — это одна вакансия: заявку
|
||||||
|
// подают на конкретную должность, и ноль вакансий в отчёте сломал бы
|
||||||
|
// и итог по колонке E, и признак «закрыта» (0 из 0).
|
||||||
|
SharePointJson.Int(item, "OpenVacanciesCount") is { } count and > 0 ? count : 1,
|
||||||
|
// Дата подачи — поле формы, заполняется руками и может быть пустым;
|
||||||
|
// тогда берём дату создания элемента. Тем же правилом заявки
|
||||||
|
// отбираются по интервалу (см. VacancyReportService.PeriodFilter) —
|
||||||
|
// иначе заявка попадала бы в отчёт, но с пустой колонкой H.
|
||||||
|
SharePointJson.Date(item, "SubmitDate") ?? SharePointJson.Date(item, "Created"),
|
||||||
|
SharePointJson.PersonTitle(item, "Responsible"),
|
||||||
|
SharePointJson.String(item, "VacancyOpenReason"),
|
||||||
|
SharePointJson.String(item, "VacancyOpenReasonDetails"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение полей из ответа SharePoint REST. Те же грабли, что разобраны в
|
||||||
|
/// Reminders/ApprovalRow: Number-колонка приходит дробной («1.0»), и обычный
|
||||||
|
/// TryGetInt32 на ней возвращает false, а часть полей SharePoint отдаёт строками.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SharePointJson
|
||||||
|
{
|
||||||
|
public static string? String(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? v.GetString() is { Length: > 0 } s ? s : null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public static int? Int(JsonElement e, string name)
|
||||||
|
{
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return null;
|
||||||
|
|
||||||
|
if (v.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
if (v.TryGetInt32(out var i)) return i;
|
||||||
|
return v.TryGetDouble(out var d) && d is >= int.MinValue and <= int.MaxValue ? (int)Math.Round(d) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.ValueKind != JsonValueKind.String) return null;
|
||||||
|
|
||||||
|
return double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||||
|
&& parsed is >= int.MinValue and <= int.MaxValue
|
||||||
|
? (int)Math.Round(parsed)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дата БЕЗ приведения к другой зоне: колонки отчёта — dateOnly, SharePoint
|
||||||
|
/// отдаёт их полуночью, и любой сдвиг зоны увёл бы день на соседний.
|
||||||
|
/// </summary>
|
||||||
|
public static DateOnly? Date(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v)
|
||||||
|
&& v.ValueKind == JsonValueKind.String
|
||||||
|
&& DateTimeOffset.TryParse(v.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||||
|
? DateOnly.FromDateTime(parsed.DateTime)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>Title развёрнутого персонального поля (…/Title через $expand). null — поле пустое.</summary>
|
||||||
|
public static string? PersonTitle(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var person) && person.ValueKind == JsonValueKind.Object
|
||||||
|
? String(person, "Title")
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отчёт по вакансиям за период — ровно то, что ложится на лист «Список вакансий
|
||||||
|
/// (все)» в xlsx: интервал плюс вакансии, разбитые по объектам/проектам.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList<VacancyReportGroup> Groups);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Объект/проект и его вакансии. Имя группы становится строкой-разделителем на
|
||||||
|
/// листе (как «КЭС КУРЧАТОВ» в образце отчёта); пустое имя — вакансии, у которых
|
||||||
|
/// объект в заявке не указан, они идут первым блоком без разделителя.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VacancyReportGroup(string Name, IReadOnlyList<VacancyReportRow> Rows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк
|
||||||
|
/// на листе, там они объединяются по вертикали; то, что различается от приёма к
|
||||||
|
/// приёму, лежит в Lines.
|
||||||
|
///
|
||||||
|
/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся:
|
||||||
|
/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в
|
||||||
|
/// файле поправят руками.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VacancyReportRow(
|
||||||
|
// «Вакансия» — должность из заявки на подбор.
|
||||||
|
string Position,
|
||||||
|
// «Подразделение» ответственного за подачу заявки.
|
||||||
|
string? Department,
|
||||||
|
// «Кол-во вакансий».
|
||||||
|
int VacanciesCount,
|
||||||
|
// «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
|
||||||
|
int ClosedCount,
|
||||||
|
// «Дата подачи заявки».
|
||||||
|
DateOnly? SubmitDate,
|
||||||
|
// «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
|
||||||
|
DateOnly? ClosingDate,
|
||||||
|
// «Инициатор заявки».
|
||||||
|
string? Initiator,
|
||||||
|
// «Закрыта вакансия»: да / нет / частично.
|
||||||
|
string ClosedFlag,
|
||||||
|
// «Рекрутер»: исполнитель фазы исполнения заявки на подбор.
|
||||||
|
string? Recruiter,
|
||||||
|
// Строки листа по этой вакансии — ВСЕГДА не меньше одной (см. VacancyReportLine).
|
||||||
|
IReadOnlyList<VacancyReportLine> Lines);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одна СТРОКА ЛИСТА в пределах вакансии — то, что различается между её строками.
|
||||||
|
/// Обычно это один приём: на заявку на подбор «Разнорабочий, 4 вакансии»
|
||||||
|
/// приходится четыре заявки на приём, у каждой свой кандидат, своя дата выхода и
|
||||||
|
/// свой «взамен кого».
|
||||||
|
///
|
||||||
|
/// Именно СТРОКА, а не «приём»: у вакансии, по которой приёмов ещё нет (или все
|
||||||
|
/// отклонены), строка всё равно одна — с пустыми ФИО и датой выхода, но с
|
||||||
|
/// заполненным «Вместо сотрудника» из причины открытия вакансии. Реальное число
|
||||||
|
/// приёмов лежит в VacancyReportRow.ClosedCount, так что информация не теряется.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VacancyReportLine(
|
||||||
|
// «Ф.И.О. нанимаемого сотрудника» — кандидат из заявки на приём.
|
||||||
|
string? CandidateName,
|
||||||
|
// «Дата выхода» — дата приёма этого кандидата.
|
||||||
|
DateOnly? StartDate,
|
||||||
|
// «Вместо сотрудника Ф.И.О.».
|
||||||
|
string? ReplacedEmployee);
|
||||||
|
|
||||||
|
/// <summary>Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет.</summary>
|
||||||
|
public static class VacancyClosedFlags
|
||||||
|
{
|
||||||
|
public const string Yes = "да";
|
||||||
|
public const string No = "нет";
|
||||||
|
public const string Partial = "частично";
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отчёт по вакансиям за период. Источник — две формы портала: «Заявка на подбор»
|
||||||
|
/// даёт саму вакансию (должность, количество, объект, инициатор), «Приём на
|
||||||
|
/// работу» — её закрытие (кого и когда приняли). Связь между ними — колонки
|
||||||
|
/// SelectionRequestId / SelectionRequestListId заявки на приём.
|
||||||
|
///
|
||||||
|
/// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного
|
||||||
|
/// списка «— согласование», из строк фазы «Исполнение» (см. ExecutionRow).
|
||||||
|
///
|
||||||
|
/// ПОЧЕМУ ЗДЕСЬ items REST ПО ПАРТИЦИЯМ, А НЕ SEARCH (разбирались 2026-08-28).
|
||||||
|
/// Search выглядит уместнее — один KQL сразу по всем партициям, и отставание
|
||||||
|
/// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не
|
||||||
|
/// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно
|
||||||
|
/// через REST):
|
||||||
|
/// • «Заявка на подбор»: VacancyOpenReason, VacancyOpenReasonDetails;
|
||||||
|
/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
|
||||||
|
/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
|
||||||
|
/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
|
||||||
|
/// Префикс группы колонок сайта (TansuSite…, см. tools/sp/README.md) тоже не
|
||||||
|
/// помогает — пробовали.
|
||||||
|
///
|
||||||
|
/// Причина, если понадобится чинить: одноимённые колонки в РАЗНЫХ списках — это
|
||||||
|
/// разные поля со своими GUID, а значит и со своими crawled property
|
||||||
|
/// (ows_Position, ows_Position1, …); автосозданное managed property привязано
|
||||||
|
/// только к одному из них. Поэтому, например, PositionOWSTEXT отдаёт значения в
|
||||||
|
/// «Заявка на подбор — данные» и молчит в «Прием на работу — данные». Проверять
|
||||||
|
/// надо не «работает ли свойство», а «работает ли оно для ЭТОГО списка» — этим
|
||||||
|
/// занимается `sp-write props` (репозиторий C:\work\git\tools), он же говорит,
|
||||||
|
/// нужно ли домапить crawled property или свойства нет вовсе.
|
||||||
|
///
|
||||||
|
/// Значит эти поля пришлось бы дочитывать построчно батчами по 100 — и на 1000
|
||||||
|
/// заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST.
|
||||||
|
/// В list view threshold (5000) REST при этом не упирается by design: партиции
|
||||||
|
/// режутся на 4900 элементов (ListPartitioning:ItemCountThreshold), то есть ни
|
||||||
|
/// один $filter не сканирует больше предела.
|
||||||
|
///
|
||||||
|
/// Вернуться к Search имеет смысл, только если в Search Schema заведут
|
||||||
|
/// работающие managed properties для перечисленных выше полей.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VacancyReportService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
IOptions<ReportsOptions> options,
|
||||||
|
ILogger<VacancyReportService> logger)
|
||||||
|
{
|
||||||
|
public async Task<Result<VacancyReport>> GetAsync(
|
||||||
|
DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (to < from)
|
||||||
|
return Result.Fail($"Конец периода ({to:dd.MM.yyyy}) раньше его начала ({from:dd.MM.yyyy}).");
|
||||||
|
|
||||||
|
var selectionType = await FindTypeAsync(options.Value.RecruitingSelectionPageUrl, cancellationToken);
|
||||||
|
if (selectionType is null)
|
||||||
|
return Result.Fail(
|
||||||
|
$"Тип заявки «{options.Value.RecruitingSelectionPageUrl}» не настроен (нет списка или сайта) — " +
|
||||||
|
"отчёт по вакансиям строить не из чего.");
|
||||||
|
|
||||||
|
var selections = await ReadSelectionsAsync(selectionType, from, to, cancellationToken);
|
||||||
|
|
||||||
|
// Заявки на приём читаются ВСЕ, без ограничения периодом: у вакансии,
|
||||||
|
// поданной в начале интервала, приём мог оформляться уже после его конца,
|
||||||
|
// и фильтр по дате обнулил бы ей и «Дату закрытия», и «Дату выхода».
|
||||||
|
// Дальше они отбираются по ссылке на заявку на подбор.
|
||||||
|
var hireType = await FindTypeAsync(options.Value.RecruitingHirePageUrl, cancellationToken);
|
||||||
|
if (hireType is null)
|
||||||
|
logger.LogWarning(
|
||||||
|
"Тип заявки «{PageUrl}» не настроен — отчёт по вакансиям будет без закрытий",
|
||||||
|
options.Value.RecruitingHirePageUrl);
|
||||||
|
|
||||||
|
var hires = hireType is null
|
||||||
|
? (IReadOnlyList<HireLinkRow>)[]
|
||||||
|
: await ReadHiresAsync(hireType, cancellationToken);
|
||||||
|
|
||||||
|
var hiresBySelection = hires
|
||||||
|
.GroupBy(x => (x.SelectionRequestId, ListId: NormalizeListId(x.SelectionRequestListId)))
|
||||||
|
.ToDictionary(g => g.Key, g => (IReadOnlyList<HireLinkRow>)g.ToList());
|
||||||
|
|
||||||
|
return Result.Ok(new VacancyReport(from, to, Group(selections, hiresBySelection)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RequestType?> FindTypeAsync(string pageUrl, CancellationToken cancellationToken) =>
|
||||||
|
await db.RequestTypes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.PageUrl == pageUrl && x.ListId != null && x.SiteUrl != null, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Заявки на подбор за период по ВСЕМ партициям типа — вакансия, уехавшая в
|
||||||
|
/// архивный список, из отчёта пропадать не должна: подбор по ней мог начаться
|
||||||
|
/// задолго до того, как для новых заявок завели следующую партицию.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<SelectionWithExecution>> ReadSelectionsAsync(
|
||||||
|
RequestType type, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = new List<SelectionWithExecution>();
|
||||||
|
|
||||||
|
foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result.AddRange(await ReadSelectionPartitionAsync(type, listId, from, to, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// Недоступная партиция (архивный список переименовали или удалили
|
||||||
|
// руками) не должна обнулять весь отчёт — в файл попадёт то, что
|
||||||
|
// прочиталось, а причина уйдёт в лог.
|
||||||
|
logger.LogError(
|
||||||
|
e, "Отчёт по вакансиям: партиция №{Number} ({ListId}) типа «{Type}» не прочитана",
|
||||||
|
number, listId, type.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SelectionWithExecution>> ReadSelectionPartitionAsync(
|
||||||
|
RequestType type, string listId, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var siteUrl = type.SiteUrl!;
|
||||||
|
|
||||||
|
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
siteUrl, listId, $"{SelectionRow.Select}&{PeriodFilter(from, to)}", cancellationToken);
|
||||||
|
|
||||||
|
var rows = items
|
||||||
|
.Select(item => SelectionRow.TryParse(item, listId))
|
||||||
|
.OfType<SelectionRow>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Список согласования читаем только если в партиции вообще нашлись
|
||||||
|
// заявки за период: у пустой партиции он всё равно ничего не добавит.
|
||||||
|
if (rows.Count == 0) return [];
|
||||||
|
|
||||||
|
var executions = await ReadExecutionsAsync(siteUrl, listId, cancellationToken);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.Select(row => new SelectionWithExecution(
|
||||||
|
row, executions.TryGetValue(row.Id, out var found) ? found : ExecutionInfo.Empty))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строки фазы «Исполнение» партиции, свёрнутые по заявке. Заголовок списка
|
||||||
|
/// согласования выводится из ЖИВОГО заголовка списка данных, а не из url:
|
||||||
|
/// список могли переименовать руками.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Dictionary<int, ExecutionInfo>> ReadExecutionsAsync(
|
||||||
|
string siteUrl, string listId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
|
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||||
|
if (approvalListTitle is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Отчёт по вакансиям: у списка «{List}» нет парного списка согласования — " +
|
||||||
|
"колонки «Рекрутер» и «Дата закрытия вакансии» останутся пустыми",
|
||||||
|
dataListTitle);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await sharePoint.GetItemsByListTitleAsync(
|
||||||
|
siteUrl, approvalListTitle, ExecutionRow.Select, cancellationToken);
|
||||||
|
|
||||||
|
return items
|
||||||
|
.Select(ExecutionRow.TryParse)
|
||||||
|
.OfType<ExecutionRow>()
|
||||||
|
.GroupBy(x => x.TicketId)
|
||||||
|
.ToDictionary(g => g.Key, ExecutionInfo.From);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<HireLinkRow>> ReadHiresAsync(
|
||||||
|
RequestType type, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = new List<HireLinkRow>();
|
||||||
|
|
||||||
|
foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
type.SiteUrl!, listId, HireLinkRow.Select, cancellationToken);
|
||||||
|
|
||||||
|
result.AddRange(items.Select(HireLinkRow.TryParse).OfType<HireLinkRow>());
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
e, "Отчёт по вакансиям: приёмы из партиции №{Number} ({ListId}) не прочитаны", number, listId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Все партиции типа — архив плюс текущая (она в реестр не пишется, а синтезируется).</summary>
|
||||||
|
private async Task<IReadOnlyList<(string ListId, int Number)>> PartitionsOfAsync(
|
||||||
|
RequestType type, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
|
return archived
|
||||||
|
.Select(x => (x.ListId, x.Number))
|
||||||
|
.Append((current.ListId, current.Number))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VacancyReportRow Build(
|
||||||
|
SelectionWithExecution selection,
|
||||||
|
IReadOnlyDictionary<(int, string), IReadOnlyList<HireLinkRow>> hiresBySelection)
|
||||||
|
{
|
||||||
|
var (row, execution) = selection;
|
||||||
|
|
||||||
|
var hires = hiresBySelection.TryGetValue((row.Id, NormalizeListId(row.ListId)), out var found)
|
||||||
|
? found
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой
|
||||||
|
// кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы
|
||||||
|
// ложью («вот кого приняли»).
|
||||||
|
var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList();
|
||||||
|
|
||||||
|
return new VacancyReportRow(
|
||||||
|
row.Position ?? string.Empty,
|
||||||
|
row.DepartmentName,
|
||||||
|
row.VacanciesCount,
|
||||||
|
accepted.Count,
|
||||||
|
row.SubmitDate,
|
||||||
|
execution.ClosingDate,
|
||||||
|
ShortName(row.Initiator),
|
||||||
|
ClosedFlag(row.VacanciesCount, accepted.Count),
|
||||||
|
execution.Recruiter,
|
||||||
|
Lines(row, accepted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет
|
||||||
|
/// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ
|
||||||
|
/// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней
|
||||||
|
/// заполняется из причины открытия вакансии.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<VacancyReportLine> Lines(SelectionRow row, IReadOnlyList<HireLinkRow> accepted)
|
||||||
|
{
|
||||||
|
if (accepted.Count == 0)
|
||||||
|
return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))];
|
||||||
|
|
||||||
|
return accepted
|
||||||
|
// Порядок строк внутри вакансии — по дате выхода: так в отчёте видно,
|
||||||
|
// кто вышел раньше. Приём без даты уходит в конец.
|
||||||
|
.OrderBy(x => x.HireDate ?? DateOnly.MaxValue)
|
||||||
|
.ThenBy(x => x.CandidateName, StringComparer.CurrentCulture)
|
||||||
|
.Select(x => new VacancyReportLine(x.CandidateName, x.HireDate, ReplacedEmployee(row, x)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле
|
||||||
|
/// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если
|
||||||
|
/// причина — замена уволенного: при «новой штатной единице» в том же поле
|
||||||
|
/// лежит совсем другой текст.
|
||||||
|
///
|
||||||
|
/// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии
|
||||||
|
/// он одинаковый — это нормально: причина открытия у вакансии одна.
|
||||||
|
///
|
||||||
|
/// hire = null — у вакансии приёмов нет вовсе, остаётся только откат.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ReplacedEmployee(SelectionRow row, HireLinkRow? hire)
|
||||||
|
{
|
||||||
|
if (hire is not null && !string.IsNullOrWhiteSpace(hire.ReplacedEmployee))
|
||||||
|
return hire.ReplacedEmployee;
|
||||||
|
|
||||||
|
return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ClosedFlag(int vacancies, int closed) => closed switch
|
||||||
|
{
|
||||||
|
0 => VacancyClosedFlags.No,
|
||||||
|
_ when closed >= vacancies => VacancyClosedFlags.Yes,
|
||||||
|
_ => VacancyClosedFlags.Partial,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// «Фамилия Имя Отчество» → «Фамилия И.О.» — так инициатор записан в образце
|
||||||
|
/// отчёта («Ким Н.Ю.»). Имя из одного слова остаётся как есть.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ShortName(string? fullName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fullName)) return null;
|
||||||
|
|
||||||
|
var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2) return fullName.Trim();
|
||||||
|
|
||||||
|
var initials = string.Concat(parts.Skip(1).Select(x => $"{char.ToUpperInvariant(x[0])}."));
|
||||||
|
return $"{parts[0]} {initials}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Группировка по объекту/проекту — она же порядок строк на листе: сначала
|
||||||
|
/// вакансии без объекта (в образце это верхний блок без разделителя), затем
|
||||||
|
/// объекты по алфавиту, внутри группы — по дате подачи заявки.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<VacancyReportGroup> Group(
|
||||||
|
IEnumerable<SelectionWithExecution> selections,
|
||||||
|
IReadOnlyDictionary<(int, string), IReadOnlyList<HireLinkRow>> hiresBySelection) => selections
|
||||||
|
.GroupBy(x => (x.Row.ObjectProject ?? string.Empty).Trim())
|
||||||
|
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
|
||||||
|
.ThenBy(g => g.Key, StringComparer.CurrentCulture)
|
||||||
|
.Select(g => new VacancyReportGroup(
|
||||||
|
g.Key,
|
||||||
|
g.OrderBy(x => x.Row.SubmitDate ?? DateOnly.MaxValue)
|
||||||
|
.ThenBy(x => x.Row.Id)
|
||||||
|
.Select(x => Build(x, hiresBySelection))
|
||||||
|
.ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фильтр периода по «Дате подачи заявки» с откатом на дату создания элемента:
|
||||||
|
/// SubmitDate — поле формы, заполняется руками и часто пустое, и без отката
|
||||||
|
/// такие заявки не попали бы в отчёт вообще.
|
||||||
|
///
|
||||||
|
/// Верхняя граница — по НАЧАЛУ следующего дня и строгим «lt»: даты в SharePoint
|
||||||
|
/// лежат с временем, и «le {to}» отсекло бы всё, что записано в сам день `to`
|
||||||
|
/// позже полуночи.
|
||||||
|
/// </summary>
|
||||||
|
private static string PeriodFilter(DateOnly from, DateOnly to)
|
||||||
|
{
|
||||||
|
var start = from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
var end = to.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return "$filter=" +
|
||||||
|
$"(SubmitDate ge datetime'{start}T00:00:00Z' and SubmitDate lt datetime'{end}T00:00:00Z') or " +
|
||||||
|
$"(SubmitDate eq null and Created ge datetime'{start}T00:00:00Z' " +
|
||||||
|
$"and Created lt datetime'{end}T00:00:00Z')";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
|
||||||
|
/// конвенции «<Форма> <N> — данные|согласование»: ListTitleConvention
|
||||||
|
/// в Infrastructure — internal, а здесь нужен только этот один переход.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ApprovalListTitleFor(string? dataListTitle)
|
||||||
|
{
|
||||||
|
const string dataSuffix = " — данные";
|
||||||
|
if (string.IsNullOrWhiteSpace(dataListTitle) || !dataListTitle.EndsWith(dataSuffix, StringComparison.Ordinal))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return string.Concat(dataListTitle.AsSpan(0, dataListTitle.Length - dataSuffix.Length), " — согласование");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Приведение server-relative url партиции к сравнимому виду: портал пишет
|
||||||
|
/// SelectionRequestListId не всегда в том же регистре и с тем же хвостовым слешем.
|
||||||
|
/// </summary>
|
||||||
|
private static string NormalizeListId(string? listId) =>
|
||||||
|
(listId ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Заявка на подбор вместе с тем, что о ней знает её фаза исполнения.</summary>
|
||||||
|
internal sealed record SelectionWithExecution(SelectionRow Row, ExecutionInfo Execution);
|
||||||
|
|
||||||
|
/// <summary>Статусы списка «Прием на работу — данные», которые важны отчёту.</summary>
|
||||||
|
internal static class HireRequestStatus
|
||||||
|
{
|
||||||
|
public const string Rejected = "Отклонено";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Значение колонки «Причина открытия вакансии», при котором заполнено «вместо кого».</summary>
|
||||||
|
internal static class SelectionOpenReason
|
||||||
|
{
|
||||||
|
public const string Replacement = "Замена уволенного сотрудника";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Что фаза исполнения заявки на подбор даёт отчёту. Рекрутёр — исполнитель
|
||||||
|
/// ПОСЛЕДНЕГО шага исполнения (шагов может быть несколько, вакансию довёл
|
||||||
|
/// последний); дата закрытия появляется только когда ВСЕ шаги исполнения
|
||||||
|
/// выполнены — пока хоть один в работе, вакансия не закрыта.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ExecutionInfo(string? Recruiter, DateOnly? ClosingDate)
|
||||||
|
{
|
||||||
|
public static readonly ExecutionInfo Empty = new(null, null);
|
||||||
|
|
||||||
|
public static ExecutionInfo From(IEnumerable<ExecutionRow> rows)
|
||||||
|
{
|
||||||
|
var ordered = rows.OrderBy(x => x.StepOrder).ToList();
|
||||||
|
if (ordered.Count == 0) return Empty;
|
||||||
|
|
||||||
|
var recruiter = ordered
|
||||||
|
.Select(x => x.ExecutorName)
|
||||||
|
.LastOrDefault(x => !string.IsNullOrWhiteSpace(x));
|
||||||
|
|
||||||
|
var closingDate = ordered.All(x => x.Status == ExecutionRow.Done)
|
||||||
|
? ordered.Select(x => x.Date).Where(x => x is not null).Max()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new ExecutionInfo(recruiter, closingDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Разбивка отчёта по вакансиям по рекрутёрам — второй лист xlsx («Результаты» в
|
||||||
|
/// образце ручного отчёта HR): кто сколько вакансий ведёт, сколько из них в
|
||||||
|
/// работе/закрыто, и доля каждого рекрутёра в общем объёме — то, что дальше
|
||||||
|
/// рисуется как две круговые диаграммы.
|
||||||
|
///
|
||||||
|
/// Считается по той же колонке E (VacanciesCount), что и итоги на первом листе, а
|
||||||
|
/// не по числу заявок: одна заявка на подбор с несколькими вакансиями (напр.
|
||||||
|
/// «Разнорабочий ×4») засчитывается рекрутёру всеми своими местами разом — так же,
|
||||||
|
/// как единица подсчёта работает на первом листе.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VacancyReportSummary(int GrandTotal, int GrandInProgress, IReadOnlyList<RecruiterBreakdown> Recruiters)
|
||||||
|
{
|
||||||
|
/// <summary>Без рекрутёра не бывает: 0 вакансий = 0 рекрутёров, а не пустая диаграмма с NaN-процентами.</summary>
|
||||||
|
public static readonly VacancyReportSummary Empty = new(0, 0, []);
|
||||||
|
|
||||||
|
public static VacancyReportSummary Build(VacancyReport report)
|
||||||
|
{
|
||||||
|
var rows = report.Groups.SelectMany(g => g.Rows).ToList();
|
||||||
|
if (rows.Count == 0) return Empty;
|
||||||
|
|
||||||
|
var recruiters = rows
|
||||||
|
.GroupBy(r => string.IsNullOrWhiteSpace(r.Recruiter) ? UnassignedLabel : r.Recruiter!.Trim())
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Name = g.Key,
|
||||||
|
Total = g.Sum(r => r.VacanciesCount),
|
||||||
|
Closed = g.Sum(r => r.ClosedCount),
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var grandTotal = recruiters.Sum(r => r.Total);
|
||||||
|
var grandInProgress = recruiters.Sum(r => r.Total - r.Closed);
|
||||||
|
|
||||||
|
// «Без рекрутёра» — всегда последним и независимо от объёма: это не
|
||||||
|
// реальный участник рейтинга, чья позиция в сортировке имела бы смысл.
|
||||||
|
var ordered = recruiters
|
||||||
|
.OrderByDescending(r => r.Name != UnassignedLabel)
|
||||||
|
.ThenByDescending(r => r.Total)
|
||||||
|
.Select(r => new RecruiterBreakdown(r.Name, r.Total, r.Total - r.Closed, r.Closed))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new VacancyReportSummary(grandTotal, grandInProgress, ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Строка для вакансий без рекрутёра (фаза исполнения ещё не началась или не даёт исполнителя).</summary>
|
||||||
|
public const string UnassignedLabel = "Без рекрутёра";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один рекрутёр в сводке. Проценты не хранятся — считаются в момент вывода
|
||||||
|
/// (ShareOfTotal/ShareOfInProgress/InProgressRate ниже), чтобы не разъезжались с
|
||||||
|
/// Total/InProgress при округлении.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RecruiterBreakdown(string Name, int Total, int InProgress, int Closed)
|
||||||
|
{
|
||||||
|
/// <summary>Доля этого рекрутёра в общем объёме вакансий периода (для первой диаграммы).</summary>
|
||||||
|
public double ShareOfTotal(int grandTotal) => grandTotal > 0 ? (double)Total / grandTotal : 0;
|
||||||
|
|
||||||
|
/// <summary>Доля этого рекрутёра в объёме вакансий, которые ещё в работе (для второй диаграммы).</summary>
|
||||||
|
public double ShareOfInProgress(int grandInProgress) => grandInProgress > 0 ? (double)InProgress / grandInProgress : 0;
|
||||||
|
|
||||||
|
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые ещё в работе — «в работе (в%)» в горизонтальной таблице.</summary>
|
||||||
|
public double InProgressRate => Total > 0 ? (double)InProgress / Total : 0;
|
||||||
|
|
||||||
|
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые уже закрыты — «закрыто» в горизонтальной таблице.</summary>
|
||||||
|
public double ClosedRate => Total > 0 ? (double)Closed / Total : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
using NPOI.OOXML.XSSF.UserModel;
|
||||||
|
using NPOI.SS.UserModel;
|
||||||
|
using NPOI.SS.UserModel.Charts;
|
||||||
|
using NPOI.SS.Util;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR:
|
||||||
|
/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по
|
||||||
|
/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя
|
||||||
|
/// круговыми диаграммами).
|
||||||
|
///
|
||||||
|
/// Листы собираются кодом, а не заполнением файла-шаблона: число строк
|
||||||
|
/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) —
|
||||||
|
/// шаблон с такой структурой пришлось бы править на каждое изменение состава.
|
||||||
|
///
|
||||||
|
/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов
|
||||||
|
/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный
|
||||||
|
/// лист выглядел бы просто как две таблицы без визуализации, как в образце.
|
||||||
|
///
|
||||||
|
/// Производные колонки (G «в работе», J «срок отработки») и итоги первого
|
||||||
|
/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше
|
||||||
|
/// руками, и после правки строки цифры должны пересчитаться сами.
|
||||||
|
/// </summary>
|
||||||
|
public static class VacancyReportWorkbook
|
||||||
|
{
|
||||||
|
private const string SheetName = "Список вакансий (все)";
|
||||||
|
private const string SummarySheetName = "Результаты";
|
||||||
|
|
||||||
|
private const string DateFormat = "dd.mm.yyyy";
|
||||||
|
private const string WholePercentFormat = "0%";
|
||||||
|
private const string PrecisePercentFormat = "0.00%";
|
||||||
|
|
||||||
|
/// <summary>Колонка первого листа: заголовок и ширина (ширины — из образца отчёта).</summary>
|
||||||
|
private static readonly (string Title, double Width)[] Columns =
|
||||||
|
[
|
||||||
|
(" ", 6.1),
|
||||||
|
("№", 4.9),
|
||||||
|
("Вакансия", 57.4),
|
||||||
|
("Подразделение", 17.1),
|
||||||
|
("Кол-во вакансий", 8.3),
|
||||||
|
("количество закрытых вакансий", 9.1),
|
||||||
|
("количество вакансий в работе", 11.6),
|
||||||
|
("Дата подачи заявки", 14.7),
|
||||||
|
("Дата закрытия вакансии", 14.9),
|
||||||
|
("Срок отработки", 12.3),
|
||||||
|
("Дата выхода", 13.9),
|
||||||
|
("Ф.И.О. нанимаемого сотрудника", 30.0),
|
||||||
|
("Инициатор заявки", 26.9),
|
||||||
|
("Вместо сотрудника Ф.И.О.", 26.0),
|
||||||
|
("Комментарии", 51.3),
|
||||||
|
("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
|
||||||
|
("Рекрутер", 23.9),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.).
|
||||||
|
private const int ColTotalNumber = 0;
|
||||||
|
private const int ColGroupNumber = 1;
|
||||||
|
private const int ColPosition = 2;
|
||||||
|
private const int ColDepartment = 3;
|
||||||
|
private const int ColVacancies = 4;
|
||||||
|
private const int ColClosed = 5;
|
||||||
|
private const int ColInProgress = 6;
|
||||||
|
private const int ColSubmitDate = 7;
|
||||||
|
private const int ColClosingDate = 8;
|
||||||
|
private const int ColLeadTime = 9;
|
||||||
|
private const int ColStartDate = 10;
|
||||||
|
private const int ColCandidateName = 11;
|
||||||
|
private const int ColInitiator = 12;
|
||||||
|
private const int ColReplaced = 13;
|
||||||
|
private const int ColComment = 14;
|
||||||
|
private const int ColClosedFlag = 15;
|
||||||
|
private const int ColRecruiter = 16;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные
|
||||||
|
/// общие — при нескольких приёмах по одной заявке на подбор они объединяются
|
||||||
|
/// по вертикали (см. WriteVacancy).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced];
|
||||||
|
|
||||||
|
private const int HeaderRowIdx = 0;
|
||||||
|
|
||||||
|
public static byte[] Build(VacancyReport report)
|
||||||
|
{
|
||||||
|
using var workbook = new XSSFWorkbook();
|
||||||
|
var styles = new Styles(workbook);
|
||||||
|
|
||||||
|
BuildVacancyListSheet(workbook, styles, report);
|
||||||
|
BuildSummarySheet(workbook, styles, report);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.Write(stream, leaveOpen: true);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Имя файла — с периодом, как в ручном отчёте HR.</summary>
|
||||||
|
public static string FileName(VacancyReport report) =>
|
||||||
|
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
|
||||||
|
|
||||||
|
// ==================== Лист 1: «Список вакансий (все)» ====================
|
||||||
|
|
||||||
|
private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
|
||||||
|
{
|
||||||
|
var sheet = (XSSFSheet)workbook.CreateSheet(SheetName);
|
||||||
|
|
||||||
|
WriteHeader(sheet, styles);
|
||||||
|
|
||||||
|
var rowIdx = HeaderRowIdx + 1;
|
||||||
|
var totalNumber = 0;
|
||||||
|
var firstDataRowIdx = rowIdx;
|
||||||
|
|
||||||
|
foreach (var group in report.Groups)
|
||||||
|
{
|
||||||
|
if (group.Name.Length > 0) WriteGroupSeparator(sheet, styles, rowIdx++, group.Name);
|
||||||
|
|
||||||
|
var groupNumber = 0;
|
||||||
|
foreach (var item in group.Rows)
|
||||||
|
rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastDataRowIdx = rowIdx - 1;
|
||||||
|
|
||||||
|
// Итоги — только если есть что суммировать: SUM по пустому диапазону
|
||||||
|
// (firstDataRowIdx > lastDataRowIdx) дал бы формулу с перевёрнутым диапазоном.
|
||||||
|
if (lastDataRowIdx >= firstDataRowIdx) WriteTotals(sheet, styles, rowIdx, firstDataRowIdx, lastDataRowIdx);
|
||||||
|
|
||||||
|
FinishVacancyListSheet(sheet, styles, lastDataRowIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteHeader(XSSFSheet sheet, Styles styles)
|
||||||
|
{
|
||||||
|
var row = sheet.CreateRow(HeaderRowIdx);
|
||||||
|
// Заголовки длинные и переносятся — без заданной высоты Excel показал бы
|
||||||
|
// одну строку из трёх (высота 90 — из образца отчёта).
|
||||||
|
row.HeightInPoints = 90f;
|
||||||
|
|
||||||
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
|
{
|
||||||
|
var (title, width) = Columns[col];
|
||||||
|
var cell = row.CreateCell(col);
|
||||||
|
cell.SetCellValue(title);
|
||||||
|
cell.CellStyle = styles.Header;
|
||||||
|
sheet.SetColumnWidth(col, (int)Math.Round(width * 256));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
|
||||||
|
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
|
||||||
|
/// начинается заново — так же, как в ручном отчёте.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name)
|
||||||
|
{
|
||||||
|
var row = sheet.CreateRow(rowIdx);
|
||||||
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
|
row.CreateCell(col).CellStyle = styles.GroupSeparator;
|
||||||
|
|
||||||
|
row.GetCell(ColGroupNumber).SetCellValue(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одна вакансия — столько строк, сколько у неё Lines (минимум одна).
|
||||||
|
/// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик.
|
||||||
|
///
|
||||||
|
/// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по
|
||||||
|
/// вертикали: у объединённой области Excel хранит значение в верхней левой
|
||||||
|
/// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя —
|
||||||
|
/// SUM в итогах посчитал бы одно и то же несколько раз).
|
||||||
|
/// </summary>
|
||||||
|
private static int WriteVacancy(
|
||||||
|
XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
|
||||||
|
{
|
||||||
|
var lines = item.Lines;
|
||||||
|
var lineCount = Math.Max(lines.Count, 1);
|
||||||
|
var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки.
|
||||||
|
|
||||||
|
// Все строки вакансии — со стилем данных, иначе у продолжений не будет
|
||||||
|
// границ и объединённая область выглядит рваной.
|
||||||
|
for (var offset = 0; offset < lineCount; offset++)
|
||||||
|
{
|
||||||
|
var row = sheet.CreateRow(firstRowIdx + offset);
|
||||||
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
|
row.CreateCell(col).CellStyle = styles.Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = sheet.GetRow(firstRowIdx);
|
||||||
|
|
||||||
|
first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
|
||||||
|
first.GetCell(ColGroupNumber).SetCellValue(groupNumber);
|
||||||
|
first.GetCell(ColPosition).SetCellValue(item.Position);
|
||||||
|
first.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
|
||||||
|
first.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
|
||||||
|
first.GetCell(ColClosed).SetCellValue(item.ClosedCount);
|
||||||
|
first.GetCell(ColInProgress).SetCellFormula(
|
||||||
|
$"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
|
||||||
|
|
||||||
|
SetDate(first.GetCell(ColSubmitDate), styles, item.SubmitDate);
|
||||||
|
SetDate(first.GetCell(ColClosingDate), styles, item.ClosingDate);
|
||||||
|
|
||||||
|
// «Срок отработки» — разница дат в днях, и только когда известны обе:
|
||||||
|
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
|
||||||
|
// дней (пустая ячейка — это 00.01.1900).
|
||||||
|
if (item.SubmitDate is not null && item.ClosingDate is not null)
|
||||||
|
{
|
||||||
|
var leadTimeCell = first.GetCell(ColLeadTime);
|
||||||
|
leadTimeCell.CellStyle = styles.DataLeadTime;
|
||||||
|
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
|
||||||
|
}
|
||||||
|
|
||||||
|
first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
|
||||||
|
// Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
|
||||||
|
// отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
|
||||||
|
// заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
|
||||||
|
// в выгрузке.
|
||||||
|
first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
|
||||||
|
first.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
|
||||||
|
|
||||||
|
// Построчные поля — каждое в свою строку.
|
||||||
|
for (var offset = 0; offset < lines.Count; offset++)
|
||||||
|
{
|
||||||
|
var row = sheet.GetRow(firstRowIdx + offset);
|
||||||
|
var line = lines[offset];
|
||||||
|
|
||||||
|
row.GetCell(ColCandidateName).SetCellValue(line.CandidateName ?? string.Empty);
|
||||||
|
SetDate(row.GetCell(ColStartDate), styles, line.StartDate);
|
||||||
|
row.GetCell(ColReplaced).SetCellValue(line.ReplacedEmployee ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объединяем общие колонки только когда строк действительно больше одной:
|
||||||
|
// область 1×1 Excel считает некорректной и портит файл.
|
||||||
|
if (lineCount > 1)
|
||||||
|
{
|
||||||
|
var lastRowIdx = firstRowIdx + lineCount - 1;
|
||||||
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
|
{
|
||||||
|
if (PerLineColumns.Contains(col)) continue;
|
||||||
|
sheet.AddMergedRegion(new CellRangeAddress(firstRowIdx, lastRowIdx, col, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lineCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
|
||||||
|
{
|
||||||
|
var row = sheet.CreateRow(rowIdx);
|
||||||
|
var firstExcelRow = firstDataRowIdx + 1;
|
||||||
|
var lastExcelRow = lastDataRowIdx + 1;
|
||||||
|
|
||||||
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
|
row.CreateCell(col).CellStyle = styles.Totals;
|
||||||
|
|
||||||
|
foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress])
|
||||||
|
{
|
||||||
|
var letter = ColLetter(col);
|
||||||
|
row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр
|
||||||
|
/// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида
|
||||||
|
/// вместе с отфильтрованными строками.
|
||||||
|
/// </summary>
|
||||||
|
private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx)
|
||||||
|
{
|
||||||
|
// ВАЖНО: даты НЕ переносятся сюда через SetDefaultColumnStyle — NPOI (в
|
||||||
|
// отличие от чистого OOXML, где стиль колонки должен уступать явному
|
||||||
|
// стилю ячейки) переписывает им стиль уже существующих ячеек колонки
|
||||||
|
// целиком, включая шапку: заголовки «Дата подачи заявки»/«Дата закрытия
|
||||||
|
// вакансии»/«Дата выхода» лишались синей заливки и получали формат даты
|
||||||
|
// вместо неё. Формат даты у самих дат и так стоит явно, ячейка за
|
||||||
|
// ячейкой (см. SetDate) — колонки, дописанные в файл руками ниже данных,
|
||||||
|
// формат не унаследуют, но это не тот случай, ради которого стоит опять
|
||||||
|
// ломать шапку.
|
||||||
|
//
|
||||||
|
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
|
||||||
|
// какая колонка какая.
|
||||||
|
sheet.CreateFreezePane(0, HeaderRowIdx + 1);
|
||||||
|
|
||||||
|
if (lastDataRowIdx > HeaderRowIdx)
|
||||||
|
sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetDate(ICell cell, Styles styles, DateOnly? date)
|
||||||
|
{
|
||||||
|
if (date is null) return;
|
||||||
|
|
||||||
|
cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue));
|
||||||
|
cell.CellStyle = styles.DataDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Лист 2: «Результаты» (сводка по рекрутёрам) ====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сводка по рекрутёрам: горизонтальная таблица (кто сколько вакансий ведёт,
|
||||||
|
/// какая доля в работе/закрыта) и две вертикальные таблицы с долей каждого
|
||||||
|
/// рекрутёра в общем объёме — они же источник данных для двух круговых
|
||||||
|
/// диаграмм рядом. Раскладка — по образцу листа «Статус работы отдела_АХ» в
|
||||||
|
/// ручном отчёте HR.
|
||||||
|
/// </summary>
|
||||||
|
private static void BuildSummarySheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
|
||||||
|
{
|
||||||
|
var sheet = (XSSFSheet)workbook.CreateSheet(SummarySheetName);
|
||||||
|
sheet.SetColumnWidth(0, (int)Math.Round(26.0 * 256));
|
||||||
|
|
||||||
|
var titleRow = sheet.CreateRow(0);
|
||||||
|
titleRow.CreateCell(0).SetCellValue("результаты");
|
||||||
|
titleRow.GetCell(0).CellStyle = styles.Title;
|
||||||
|
|
||||||
|
var summary = VacancyReportSummary.Build(report);
|
||||||
|
if (summary.Recruiters.Count == 0)
|
||||||
|
{
|
||||||
|
sheet.CreateRow(2).CreateCell(0).SetCellValue("Нет данных за период.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var recruiters = summary.Recruiters;
|
||||||
|
for (var i = 0; i < recruiters.Count; i++)
|
||||||
|
sheet.SetColumnWidth(1 + i, (int)Math.Round(12.0 * 256));
|
||||||
|
|
||||||
|
WriteHorizontalTable(sheet, styles, summary);
|
||||||
|
|
||||||
|
const int horizontalTableRows = 4; // заголовок + «Всего» + «в работе» + «закрыто»
|
||||||
|
var vertTable1HeaderRowIdx = HeaderRowIdx + 1 + horizontalTableRows + 1; // +1 — пустая строка-отступ
|
||||||
|
var vertTable1DataStartRowIdx = vertTable1HeaderRowIdx + 1;
|
||||||
|
WriteVerticalTable(
|
||||||
|
sheet, styles, vertTable1HeaderRowIdx, "Общий объем работ", summary.GrandTotal,
|
||||||
|
recruiters, r => r.ShareOfTotal(summary.GrandTotal));
|
||||||
|
|
||||||
|
var vertTable2HeaderRowIdx = vertTable1DataStartRowIdx + recruiters.Count + 1;
|
||||||
|
var vertTable2DataStartRowIdx = vertTable2HeaderRowIdx + 1;
|
||||||
|
WriteVerticalTable(
|
||||||
|
sheet, styles, vertTable2HeaderRowIdx, "Общий объем в работе", summary.GrandInProgress,
|
||||||
|
recruiters, r => r.ShareOfInProgress(summary.GrandInProgress));
|
||||||
|
|
||||||
|
// Якоря диаграмм считаются НЕЗАВИСИМО от строк таблиц: у таблицы может
|
||||||
|
// быть 2 строки, а у диаграммы фиксированная высота ChartHeightRows — с
|
||||||
|
// якорем "на уровне шапки своей таблицы" вторая диаграмма при коротких
|
||||||
|
// таблицах вставала бы внутрь вертикального диапазона первой (см.
|
||||||
|
// ChartHeightRows). Обе выравнены по левому краю первой (горизонтальной)
|
||||||
|
// таблицы и просто идут подряд сверху вниз с отступом.
|
||||||
|
var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}";
|
||||||
|
var chart1TopRowIdx = HeaderRowIdx + 1;
|
||||||
|
var chart2TopRowIdx = chart1TopRowIdx + ChartHeightRows + 1;
|
||||||
|
|
||||||
|
CreatePieChart(
|
||||||
|
sheet, "Объём вакансий " + period,
|
||||||
|
firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart1TopRowIdx);
|
||||||
|
CreatePieChart(
|
||||||
|
sheet, "Объём вакансий в работе " + period,
|
||||||
|
firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart2TopRowIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры
|
||||||
|
/// колонками, как в образце отчёта (там же W:Y).
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteHorizontalTable(XSSFSheet sheet, Styles styles, VacancyReportSummary summary)
|
||||||
|
{
|
||||||
|
var headerRow = sheet.CreateRow(HeaderRowIdx + 1);
|
||||||
|
var totalRow = sheet.CreateRow(HeaderRowIdx + 2);
|
||||||
|
var inProgressRow = sheet.CreateRow(HeaderRowIdx + 3);
|
||||||
|
var closedRow = sheet.CreateRow(HeaderRowIdx + 4);
|
||||||
|
|
||||||
|
SetCell(headerRow, 0, "Кол-во вакансий", styles.SummaryHeaderYellow);
|
||||||
|
SetCell(totalRow, 0, "Всего", styles.SummaryLabel);
|
||||||
|
SetCell(inProgressRow, 0, "в работе (в%)", styles.SummaryRowGreenLabel);
|
||||||
|
SetCell(closedRow, 0, "закрыто", styles.SummaryLabel);
|
||||||
|
|
||||||
|
for (var i = 0; i < summary.Recruiters.Count; i++)
|
||||||
|
{
|
||||||
|
var r = summary.Recruiters[i];
|
||||||
|
var col = 1 + i;
|
||||||
|
|
||||||
|
SetCell(headerRow, col, r.Name, styles.SummaryHeaderYellow);
|
||||||
|
SetCell(totalRow, col, r.Total, styles.SummaryValue);
|
||||||
|
SetCell(inProgressRow, col, r.InProgressRate, styles.SummaryRowGreenPercent);
|
||||||
|
SetCell(closedRow, col, r.ClosedRate, styles.SummaryPercent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одна вертикальная таблица: шапка «label | итого» (синяя заливка, итого —
|
||||||
|
/// красным полужирным, как в образце) плюс по строке на рекрутёра с его
|
||||||
|
/// долей в этом итого. Возвращаемый диапазон строк — вход для круговой
|
||||||
|
/// диаграммы (см. CreatePieChart).
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteVerticalTable(
|
||||||
|
XSSFSheet sheet, Styles styles, int headerRowIdx, string label, int total,
|
||||||
|
IReadOnlyList<RecruiterBreakdown> recruiters, Func<RecruiterBreakdown, double> share)
|
||||||
|
{
|
||||||
|
var headerRow = sheet.CreateRow(headerRowIdx);
|
||||||
|
SetCell(headerRow, 0, label, styles.SummaryBlueHeader);
|
||||||
|
SetCell(headerRow, 1, total, styles.SummaryBlueTotal);
|
||||||
|
|
||||||
|
// Заливка — на всю ширину блока (по числу рекрутёров, как в горизонтальной
|
||||||
|
// таблице выше), а не только под текстом заголовка: иначе шапка выглядит
|
||||||
|
// как две узкие цветные ячейки на фоне остального белого листа, а не как
|
||||||
|
// сплошная плашка над таблицей.
|
||||||
|
for (var col = 2; col <= recruiters.Count; col++)
|
||||||
|
SetCell(headerRow, col, string.Empty, styles.SummaryBlueHeader);
|
||||||
|
|
||||||
|
for (var i = 0; i < recruiters.Count; i++)
|
||||||
|
{
|
||||||
|
var row = sheet.CreateRow(headerRowIdx + 1 + i);
|
||||||
|
SetCell(row, 0, recruiters[i].Name, styles.SummaryGreenText);
|
||||||
|
SetCell(row, 1, share(recruiters[i]), styles.SummaryGreenPercent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Высота якоря диаграммы в строках — фиксированная и не зависит от числа
|
||||||
|
/// рекрутёров: подгонять размер круга под число категорий смысла нет, малое
|
||||||
|
/// число секторов на большом круге читается ничуть не хуже. Используется и
|
||||||
|
/// при размещении диаграмм (см. BuildSummarySheet — вторая идёт сразу под
|
||||||
|
/// первой, без наложения).
|
||||||
|
/// </summary>
|
||||||
|
private const int ChartHeightRows = 14;
|
||||||
|
|
||||||
|
/// <summary>Круговая диаграмма по одной вертикальной таблице: категории — колонка A (имена рекрутёров), значения — колонка B (доля).</summary>
|
||||||
|
private static void CreatePieChart(
|
||||||
|
XSSFSheet sheet, string title, int firstDataRowIdx, int rowCount, int anchorTopRowIdx)
|
||||||
|
{
|
||||||
|
if (rowCount == 0) return;
|
||||||
|
|
||||||
|
var drawing = (XSSFDrawing)sheet.CreateDrawingPatriarch();
|
||||||
|
var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + ChartHeightRows);
|
||||||
|
var chart = (XSSFChart)drawing.CreateChart(anchor);
|
||||||
|
chart.SetTitle(title);
|
||||||
|
|
||||||
|
var lastDataRowIdx = firstDataRowIdx + rowCount - 1;
|
||||||
|
var data = chart.ChartDataFactory.CreatePieChartData<string, double>();
|
||||||
|
var categories = DataSources.FromStringCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 0, 0));
|
||||||
|
var values = DataSources.FromNumericCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 1, 1));
|
||||||
|
data.AddSeries(categories, values);
|
||||||
|
chart.Plot(data);
|
||||||
|
chart.GetOrCreateLegend();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetCell(IRow row, int col, string value, ICellStyle style)
|
||||||
|
{
|
||||||
|
var cell = row.CreateCell(col);
|
||||||
|
cell.SetCellValue(value);
|
||||||
|
cell.CellStyle = style;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetCell(IRow row, int col, double value, ICellStyle style)
|
||||||
|
{
|
||||||
|
var cell = row.CreateCell(col);
|
||||||
|
cell.SetCellValue(value);
|
||||||
|
cell.CellStyle = style;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ColLetter(int col0Based) => CellReference.ConvertNumToColString(col0Based);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Все стили обоих листов — созданы один раз на книгу и переиспользуются:
|
||||||
|
/// ICellStyle в NPOI (в отличие от ClosedXML) назначается ячейке целиком, а
|
||||||
|
/// не собирается по кусочкам, поэтому под каждую итоговую комбинацию
|
||||||
|
/// заливка+шрифт+формат нужен отдельный объект стиля.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class Styles
|
||||||
|
{
|
||||||
|
public ICellStyle Header { get; }
|
||||||
|
public ICellStyle GroupSeparator { get; }
|
||||||
|
public ICellStyle Data { get; }
|
||||||
|
public ICellStyle DataDate { get; }
|
||||||
|
public ICellStyle DataLeadTime { get; }
|
||||||
|
public ICellStyle Totals { get; }
|
||||||
|
|
||||||
|
public ICellStyle Title { get; }
|
||||||
|
public ICellStyle SummaryHeaderYellow { get; }
|
||||||
|
public ICellStyle SummaryLabel { get; }
|
||||||
|
public ICellStyle SummaryValue { get; }
|
||||||
|
public ICellStyle SummaryPercent { get; }
|
||||||
|
public ICellStyle SummaryRowGreenLabel { get; }
|
||||||
|
public ICellStyle SummaryRowGreenPercent { get; }
|
||||||
|
public ICellStyle SummaryBlueHeader { get; }
|
||||||
|
public ICellStyle SummaryBlueTotal { get; }
|
||||||
|
public ICellStyle SummaryGreenText { get; }
|
||||||
|
public ICellStyle SummaryGreenPercent { get; }
|
||||||
|
|
||||||
|
// Цвета — из образца отчёта (заливка шапки FFE5E5FF и т.д.), чтобы
|
||||||
|
// выгрузка выглядела привычно рядом со старым файлом.
|
||||||
|
private static readonly byte[] HeaderFill = [0xE5, 0xE5, 0xFF];
|
||||||
|
private static readonly byte[] GroupFill = [0xF2, 0xF2, 0xF2];
|
||||||
|
private static readonly byte[] YellowFill = [0xFF, 0xFF, 0x00];
|
||||||
|
private static readonly byte[] GreenFill = [0xC6, 0xE0, 0xB4];
|
||||||
|
private static readonly byte[] BlueFill = [0x4A, 0x7E, 0xBB];
|
||||||
|
private static readonly byte[] RedFont = [0xC0, 0x00, 0x00];
|
||||||
|
private static readonly byte[] WhiteFont = [0xFF, 0xFF, 0xFF];
|
||||||
|
|
||||||
|
public Styles(XSSFWorkbook workbook)
|
||||||
|
{
|
||||||
|
var colorMap = new DefaultIndexedColorMap();
|
||||||
|
|
||||||
|
ICellStyle New(
|
||||||
|
byte[]? fill = null, bool bold = false, byte[]? fontColor = null, string? numberFormat = null,
|
||||||
|
bool wrap = false, bool border = false, bool topDoubleBorder = false,
|
||||||
|
HorizontalAlignment? align = null, VerticalAlignment? valign = null)
|
||||||
|
{
|
||||||
|
var style = (XSSFCellStyle)workbook.CreateCellStyle();
|
||||||
|
|
||||||
|
if (fill is not null)
|
||||||
|
{
|
||||||
|
style.SetFillForegroundColor(new XSSFColor(fill, colorMap));
|
||||||
|
style.FillPattern = FillPattern.SolidForeground;
|
||||||
|
}
|
||||||
|
if (bold || fontColor is not null)
|
||||||
|
{
|
||||||
|
var font = (XSSFFont)workbook.CreateFont();
|
||||||
|
font.IsBold = bold;
|
||||||
|
if (fontColor is not null) font.SetColor(new XSSFColor(fontColor, colorMap));
|
||||||
|
style.SetFont(font);
|
||||||
|
}
|
||||||
|
if (numberFormat is not null)
|
||||||
|
style.DataFormat = workbook.CreateDataFormat().GetFormat(numberFormat);
|
||||||
|
if (wrap) style.WrapText = true;
|
||||||
|
if (border)
|
||||||
|
{
|
||||||
|
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
|
||||||
|
}
|
||||||
|
if (topDoubleBorder) style.BorderTop = BorderStyle.Double;
|
||||||
|
if (align is not null) style.Alignment = align.Value;
|
||||||
|
if (valign is not null) style.VerticalAlignment = valign.Value;
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header = New(
|
||||||
|
fill: HeaderFill, bold: true, wrap: true, border: true,
|
||||||
|
align: HorizontalAlignment.Center, valign: VerticalAlignment.Center);
|
||||||
|
GroupSeparator = New(fill: GroupFill, bold: true);
|
||||||
|
Data = New(wrap: true, border: true);
|
||||||
|
DataDate = New(wrap: true, border: true, numberFormat: DateFormat);
|
||||||
|
DataLeadTime = New(wrap: true, border: true, numberFormat: "0");
|
||||||
|
Totals = New(bold: true, topDoubleBorder: true);
|
||||||
|
|
||||||
|
Title = New(bold: true);
|
||||||
|
SummaryHeaderYellow = New(fill: YellowFill, bold: true);
|
||||||
|
SummaryLabel = New(bold: true);
|
||||||
|
SummaryValue = New();
|
||||||
|
SummaryPercent = New(numberFormat: WholePercentFormat);
|
||||||
|
SummaryRowGreenLabel = New(fill: GreenFill, bold: true);
|
||||||
|
SummaryRowGreenPercent = New(fill: GreenFill, numberFormat: WholePercentFormat);
|
||||||
|
SummaryBlueHeader = New(fill: BlueFill, bold: true, fontColor: WhiteFont);
|
||||||
|
SummaryBlueTotal = New(fill: BlueFill, bold: true, fontColor: RedFont);
|
||||||
|
SummaryGreenText = New(fill: GreenFill);
|
||||||
|
SummaryGreenPercent = New(fill: GreenFill, numberFormat: PrecisePercentFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestManage;
|
||||||
|
|
||||||
|
// Формы 1:1 с фронтендом (frontend/src/webparts/approvalAdmin/models/requestManage.ts,
|
||||||
|
// manageByUser.ts) — эти DTO заменяют собой то, что раньше собирал PnP-клиент
|
||||||
|
// НАПРЯМУЮ в SharePoint от имени текущего пользователя (см. requestManageService.ts):
|
||||||
|
// тут это делает бэкенд от имени приложения (см. ISharePointClient), поэтому
|
||||||
|
// результат не зависит от прав конкретного пользователя на списки SharePoint.
|
||||||
|
|
||||||
|
/// <summary>Заявка (строка списка «… — данные»), как её видит комбобокс выбора заявки.</summary>
|
||||||
|
public sealed record ManagedRequestSummaryDto(
|
||||||
|
int Id,
|
||||||
|
string Title,
|
||||||
|
string Status,
|
||||||
|
/// <summary>ISO 8601 или пустая строка.</summary>
|
||||||
|
string Created,
|
||||||
|
string AuthorName,
|
||||||
|
string? UniqueCode,
|
||||||
|
/// <summary>Партиция (серверный относительный url списка данных), где физически лежит заявка.</summary>
|
||||||
|
string ListId);
|
||||||
|
|
||||||
|
public sealed record ManagedRequestPageDto(IReadOnlyList<ManagedRequestSummaryDto> Items, bool HasMore);
|
||||||
|
|
||||||
|
/// <summary>Строка списка «… — согласование».</summary>
|
||||||
|
public sealed record ManagedApprovalRowDto(
|
||||||
|
int Id,
|
||||||
|
int TicketId,
|
||||||
|
int? ApproverId,
|
||||||
|
string ApproverName,
|
||||||
|
string ApproverEmail,
|
||||||
|
string Position,
|
||||||
|
string Status,
|
||||||
|
/// <summary>ISO 8601 или пустая строка — форматирование под «дд.мм.гггг» остаётся на фронтенде.</summary>
|
||||||
|
string DateIso,
|
||||||
|
string Comment,
|
||||||
|
double Order,
|
||||||
|
bool AllowReassign,
|
||||||
|
string Phase,
|
||||||
|
int? SlaHours,
|
||||||
|
bool? AskOverdueReason);
|
||||||
|
|
||||||
|
/// <summary>Заявка + полный маршрут (обе фазы) — для экрана редактирования заявок.</summary>
|
||||||
|
public sealed record ManagedRequestMatrixDto(
|
||||||
|
ManagedRequestSummaryDto Request,
|
||||||
|
string SiteUrl,
|
||||||
|
string DataListTitle,
|
||||||
|
string DataListUrl,
|
||||||
|
string ApprovalListTitle,
|
||||||
|
IReadOnlyList<ManagedApprovalRowDto> Rows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка согласования, найденная сразу по ВСЕМ типам заявок и партициям — для
|
||||||
|
/// экрана «Заявки по согласующему» (UserApprovalRow во фронтенде — тот же набор
|
||||||
|
/// полей, что и ManagedApprovalRowDto, только развёрнутый, а не вложенный).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UserApprovalRowDto(
|
||||||
|
int Id,
|
||||||
|
int TicketId,
|
||||||
|
int? ApproverId,
|
||||||
|
string ApproverName,
|
||||||
|
string ApproverEmail,
|
||||||
|
string Position,
|
||||||
|
string Status,
|
||||||
|
string DateIso,
|
||||||
|
string Comment,
|
||||||
|
double Order,
|
||||||
|
bool AllowReassign,
|
||||||
|
string Phase,
|
||||||
|
int? SlaHours,
|
||||||
|
bool? AskOverdueReason,
|
||||||
|
int RequestTypeId,
|
||||||
|
string RequestTypeName,
|
||||||
|
string SiteUrl,
|
||||||
|
string ListId,
|
||||||
|
string ApprovalListTitle,
|
||||||
|
string RequestTitle,
|
||||||
|
string? RequestUniqueCode);
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestManage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок»
|
||||||
|
/// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не
|
||||||
|
/// текущего пользователя.
|
||||||
|
///
|
||||||
|
/// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде
|
||||||
|
/// (см. requestManageService.ts) от имени залогиненного администратора — и
|
||||||
|
/// список видимых заявок молча урезался правами САМОГО администратора на списки
|
||||||
|
/// SharePoint (item-level security), хотя вкладка задумана как «видно всё».
|
||||||
|
/// Правка строк маршрута (переназначить/удалить/переставить) остаётся на
|
||||||
|
/// фронтенде через PnP — сюда вынесено только чтение.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RequestManageService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
ILogger<RequestManageService> logger)
|
||||||
|
{
|
||||||
|
private const int DefaultPageSize = 20;
|
||||||
|
|
||||||
|
// Сколько типов заявок читаем из SharePoint одновременно. Без ограничения
|
||||||
|
// 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались
|
||||||
|
// и экран показывал пустой список.
|
||||||
|
private const int MaxParallelTypes = 3;
|
||||||
|
|
||||||
|
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
||||||
|
// requestManageService.ts (тот же самый список SharePoint, тот же провижининг).
|
||||||
|
private const string ApproverField = "TansuSiteApprover";
|
||||||
|
private const string StatusField = "ApprovalStatus";
|
||||||
|
private const string TicketIdField = "TicketId";
|
||||||
|
private const string DateField = "Date";
|
||||||
|
private const string CommentField = "Comment";
|
||||||
|
private const string OrderField = "StepOrder";
|
||||||
|
private const string AllowReassignField = "AllowReassignToOtherApprovers";
|
||||||
|
private const string PhaseField = "Phase";
|
||||||
|
private const string SlaHoursField = "SLAhours";
|
||||||
|
private const string AskOverdueReasonField = "AskOverdueReason";
|
||||||
|
|
||||||
|
private const string ApprovalRowSelect =
|
||||||
|
$"$select=Id,Title,{StatusField},{DateField},{CommentField},{OrderField},{AllowReassignField},{PhaseField}," +
|
||||||
|
$"{SlaHoursField},{AskOverdueReasonField},{TicketIdField},{ApproverField}/Id,{ApproverField}/Title,{ApproverField}/EMail" +
|
||||||
|
$"&$expand={ApproverField}";
|
||||||
|
|
||||||
|
private const string DataRowSelect = "$select=Id,Title,RequestStatus,Created,Author/Title,RequestUniqueCode&$expand=Author";
|
||||||
|
|
||||||
|
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||||
|
// (см. requestManageService.ts/DATA_LIST_SUFFIX).
|
||||||
|
private static readonly Regex DataListSuffix = new(@"\s*—\s*данные\s*$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая),
|
||||||
|
/// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<ManagedRequestPageDto>> SearchAsync(
|
||||||
|
int requestTypeId, string query, int page, int? pageSize, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestType = await db.RequestTypes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||||
|
if (requestType is null)
|
||||||
|
return Result.Fail<ManagedRequestPageDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
|
return Result.Ok(new ManagedRequestPageDto([], false));
|
||||||
|
|
||||||
|
var size = pageSize is > 0 ? pageSize.Value : DefaultPageSize;
|
||||||
|
var siteUrl = requestType.SiteUrl;
|
||||||
|
|
||||||
|
var partitionIds = await GetAllPartitionListIdsAsync(requestType, cancellationToken);
|
||||||
|
|
||||||
|
var all = new List<ManagedRequestSummaryDto>();
|
||||||
|
foreach (var listId in partitionIds)
|
||||||
|
{
|
||||||
|
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
siteUrl, listId, $"{DataRowSelect}&$top=5000", cancellationToken);
|
||||||
|
all.AddRange(items.Select(i => ToSummary(i, listId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = SplitQueryWords(query);
|
||||||
|
var filtered = all.Where(x => MatchesQuery(x, words))
|
||||||
|
// Тот же порядок, что и раньше (Search сортировал по Created по возрастанию).
|
||||||
|
.OrderBy(x => x.Created, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var pageItems = filtered.Skip((page - 1) * size).Take(size).ToList();
|
||||||
|
return Result.Ok(new ManagedRequestPageDto(pageItems, page * size < filtered.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.</summary>
|
||||||
|
public async Task<Result<ManagedRequestMatrixDto>> GetMatrixAsync(
|
||||||
|
int requestTypeId, string listId, int requestId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestType = await db.RequestTypes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||||
|
if (requestType is null)
|
||||||
|
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
|
return Result.Fail<ManagedRequestMatrixDto>(
|
||||||
|
new ValidationError($"У типа заявки {requestTypeId} не указан сайт SharePoint."));
|
||||||
|
|
||||||
|
var siteUrl = requestType.SiteUrl;
|
||||||
|
|
||||||
|
var rawRequestItems = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
siteUrl, listId, $"{DataRowSelect}&$filter=Id eq {requestId}&$top=1", cancellationToken);
|
||||||
|
var rawRequest = rawRequestItems.FirstOrDefault();
|
||||||
|
if (rawRequest.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||||
|
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Заявка", requestId));
|
||||||
|
|
||||||
|
var request = ToSummary(rawRequest, listId);
|
||||||
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken) ?? string.Empty;
|
||||||
|
var dataListUrl = ListAbsUrl(siteUrl, listId);
|
||||||
|
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||||
|
|
||||||
|
if (approvalListTitle is null)
|
||||||
|
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, string.Empty, []));
|
||||||
|
|
||||||
|
var rawRows = await sharePoint.GetItemsByListTitleAsync(
|
||||||
|
siteUrl, approvalListTitle,
|
||||||
|
$"{ApprovalRowSelect}&$filter={TicketIdField} eq {requestId}&$orderby={OrderField}", cancellationToken);
|
||||||
|
|
||||||
|
var rows = rawRows.Select(ToApprovalRow).ToList();
|
||||||
|
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Активные строки согласования данного человека сразу по всем типам заявок и
|
||||||
|
/// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему».
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<IReadOnlyList<UserApprovalRowDto>>> FindActiveApprovalRowsForApproverAsync(
|
||||||
|
string approverEmail, IReadOnlyList<string> activeStatuses, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0)
|
||||||
|
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>([]);
|
||||||
|
|
||||||
|
var requestTypes = await db.RequestTypes.AsNoTracking()
|
||||||
|
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var email = approverEmail.Trim();
|
||||||
|
|
||||||
|
// DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО,
|
||||||
|
// и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше
|
||||||
|
// GetArchivedAsync вызывался из параллельных задач — «вторая операция на
|
||||||
|
// контексте» падала, а пустой catch превращал это в пустой список.
|
||||||
|
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||||
|
foreach (var rt in requestTypes)
|
||||||
|
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
|
||||||
|
|
||||||
|
using var gate = new SemaphoreSlim(MaxParallelTypes);
|
||||||
|
|
||||||
|
// Search не поймать и не подменить обходом списков: если он недоступен, это
|
||||||
|
// видно по ошибке запроса, а не по тихо неполному результату.
|
||||||
|
var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken);
|
||||||
|
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string SearchApproverProperty = "TansuSiteApproverOWSUSER";
|
||||||
|
private const string SearchStatusProperty = "ApprovalStatusOWSCHCS";
|
||||||
|
|
||||||
|
private static readonly Regex ApprovalListSuffix = new(@"\s*—\s*согласование\s*$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static UserApprovalRowDto ToUserRow(
|
||||||
|
ManagedApprovalRowDto r, RequestType requestType, string siteUrl, string dataListId, string approvalListTitle,
|
||||||
|
ManagedRequestSummaryDto? dataRow) =>
|
||||||
|
new(
|
||||||
|
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
|
||||||
|
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
|
||||||
|
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
|
||||||
|
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН
|
||||||
|
/// запрос Search на сайт (по человеку и статусам, сразу по всем спискам
|
||||||
|
/// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования
|
||||||
|
/// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные
|
||||||
|
/// строки дочитываются по REST — только в тех списках, где они реально нашлись.
|
||||||
|
/// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<UserApprovalRowDto>> FindViaSearchAsync(
|
||||||
|
IReadOnlyList<RequestType> requestTypes,
|
||||||
|
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||||
|
IReadOnlyList<string> activeStatuses,
|
||||||
|
string approverEmail,
|
||||||
|
SemaphoreSlim gate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var email = approverEmail.Replace("\"", string.Empty);
|
||||||
|
var statusQuery = string.Join(
|
||||||
|
" OR ", activeStatuses.Select(s => $"{SearchStatusProperty}:\"{s.Replace("\"", string.Empty)}\""));
|
||||||
|
var kql = $"{SearchApproverProperty}:\"{email}\" ({statusQuery}) contentclass:STS_ListItem_GenericList";
|
||||||
|
|
||||||
|
var result = new List<UserApprovalRowDto>();
|
||||||
|
foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken);
|
||||||
|
|
||||||
|
// Путь вида https://host/sites/x/Lists/L/DispForm.aspx?ID=5 → серверный url списка.
|
||||||
|
var byList = hits
|
||||||
|
.Select(h => (
|
||||||
|
Url: ListUrlFromHitPath(h.GetValueOrDefault("Path")),
|
||||||
|
Id: int.TryParse(h.GetValueOrDefault("ListItemID"), out var id) ? id : 0))
|
||||||
|
.Where(x => x.Url is not null && x.Id > 0)
|
||||||
|
.GroupBy(x => x.Url!, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var siteTypes = requestTypes
|
||||||
|
.Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var perList = await Task.WhenAll(byList.Select(async group =>
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await ReadSearchHitListAsync(
|
||||||
|
siteUrl, group.Key, group.Select(x => x.Id).Distinct().ToList(), siteTypes, archivedByType,
|
||||||
|
activeStatuses, approverEmail, cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.AddRange(perList.SelectMany(x => x));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пара «список согласования → (его заголовок, адрес парного списка данных)»
|
||||||
|
// не меняется, пока список не переименуют, — запоминаем на время жизни процесса,
|
||||||
|
// чтобы не тратить два запроса к SharePoint на каждый вызов. Ключ — адрес списка.
|
||||||
|
private static readonly ConcurrentDictionary<string, (string ApprovalTitle, string DataListUrl)> ListPairs =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private async Task<(string ApprovalTitle, string DataListUrl)?> ResolveListPairAsync(
|
||||||
|
string siteUrl, string approvalListUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ListPairs.TryGetValue(approvalListUrl, out var cached)) return cached;
|
||||||
|
|
||||||
|
var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||||
|
if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return null;
|
||||||
|
|
||||||
|
var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные");
|
||||||
|
var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken);
|
||||||
|
if (dataList is null) return null;
|
||||||
|
|
||||||
|
var pair = (approvalList.Title, dataList.ServerRelativeUrl);
|
||||||
|
ListPairs[approvalListUrl] = pair;
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ListUrlFromHitPath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null;
|
||||||
|
|
||||||
|
var local = Uri.UnescapeDataString(uri.AbsolutePath);
|
||||||
|
var cut = local.LastIndexOf('/');
|
||||||
|
return cut > 0 ? local[..cut] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<UserApprovalRowDto>> ReadSearchHitListAsync(
|
||||||
|
string siteUrl,
|
||||||
|
string approvalListUrl,
|
||||||
|
IReadOnlyList<int> itemIds,
|
||||||
|
IReadOnlyList<RequestType> siteTypes,
|
||||||
|
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||||
|
IReadOnlyList<string> activeStatuses,
|
||||||
|
string approverEmail,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pair = await ResolveListPairAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||||
|
if (pair is null) return [];
|
||||||
|
var (approvalTitle, dataListUrl) = pair.Value;
|
||||||
|
|
||||||
|
// Какому типу заявок принадлежит эта партиция (текущая или архивная).
|
||||||
|
var requestType = siteTypes.FirstOrDefault(rt =>
|
||||||
|
string.Equals(rt.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| archivedByType[rt.Id].Any(a =>
|
||||||
|
string.Equals(a.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
if (requestType is null) return [];
|
||||||
|
|
||||||
|
var rows = new List<ManagedApprovalRowDto>();
|
||||||
|
foreach (var chunk in itemIds.Chunk(50))
|
||||||
|
{
|
||||||
|
var filter = string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
||||||
|
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
siteUrl, approvalListUrl, $"{ApprovalRowSelect}&$filter={filter}&$top={chunk.Length}",
|
||||||
|
cancellationToken);
|
||||||
|
rows.AddRange(items.Select(ToApprovalRow));
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched = rows
|
||||||
|
.Where(r => activeStatuses.Contains(r.Status)
|
||||||
|
&& string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (matched.Count == 0) return [];
|
||||||
|
|
||||||
|
var dataById = await ReadDataRowsAsync(
|
||||||
|
siteUrl, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return matched
|
||||||
|
.Select(r => ToUserRow(
|
||||||
|
r, requestType, siteUrl, dataListUrl, approvalTitle,
|
||||||
|
dataById.GetValueOrDefault(r.TicketId)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "by-approver: список «{ApprovalListUrl}» из Search пропущен", approvalListUrl);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
|
||||||
|
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ticketIds.Count == 0) return new Dictionary<int, ManagedRequestSummaryDto>();
|
||||||
|
|
||||||
|
var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}"));
|
||||||
|
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
siteUrl, listId, $"{DataRowSelect}&$filter={filter}&$top={ticketIds.Count}", cancellationToken);
|
||||||
|
|
||||||
|
return items.Select(i => ToSummary(i, listId)).ToDictionary(x => x.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Все партиции типа заявок (архив + текущая), только их listId — для поиска.</summary>
|
||||||
|
private async Task<IReadOnlyList<string>> GetAllPartitionListIdsAsync(
|
||||||
|
RequestType requestType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||||
|
return archived.Select(x => x.ListId).Append(current.ListId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ManagedRequestSummaryDto ToSummary(JsonElement item, string listId) =>
|
||||||
|
new(
|
||||||
|
GetInt(item, "Id") ?? 0,
|
||||||
|
GetString(item, "Title") is { Length: > 0 } title ? title : $"#{GetInt(item, "Id") ?? 0}",
|
||||||
|
GetString(item, "RequestStatus") ?? string.Empty,
|
||||||
|
GetString(item, "Created") ?? string.Empty,
|
||||||
|
item.TryGetProperty("Author", out var author) && author.ValueKind == JsonValueKind.Object
|
||||||
|
? GetString(author, "Title") ?? string.Empty
|
||||||
|
: string.Empty,
|
||||||
|
GetString(item, "RequestUniqueCode"),
|
||||||
|
listId);
|
||||||
|
|
||||||
|
private static ManagedApprovalRowDto ToApprovalRow(JsonElement item)
|
||||||
|
{
|
||||||
|
var approver = item.TryGetProperty(ApproverField, out var a) && a.ValueKind == JsonValueKind.Object ? a : (JsonElement?)null;
|
||||||
|
|
||||||
|
return new ManagedApprovalRowDto(
|
||||||
|
GetInt(item, "Id") ?? 0,
|
||||||
|
GetInt(item, TicketIdField) ?? 0,
|
||||||
|
approver is { } ap ? GetInt(ap, "Id") : null,
|
||||||
|
approver is { } an ? GetString(an, "Title") ?? string.Empty : string.Empty,
|
||||||
|
approver is { } ae ? GetString(ae, "EMail") ?? string.Empty : string.Empty,
|
||||||
|
GetString(item, "Title") ?? string.Empty,
|
||||||
|
GetString(item, StatusField) ?? string.Empty,
|
||||||
|
GetString(item, DateField) ?? string.Empty,
|
||||||
|
GetString(item, CommentField) ?? string.Empty,
|
||||||
|
GetDouble(item, OrderField) ?? 0,
|
||||||
|
GetBool(item, AllowReassignField) ?? false,
|
||||||
|
GetString(item, PhaseField) is { Length: > 0 } phase ? phase : "Согласование",
|
||||||
|
GetInt(item, SlaHoursField),
|
||||||
|
GetBool(item, AskOverdueReasonField));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ApprovalListTitleFor(string dataListTitle) =>
|
||||||
|
DataListSuffix.IsMatch(dataListTitle)
|
||||||
|
? $"{DataListSuffix.Replace(dataListTitle, string.Empty)} — согласование"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string SiteOrigin(string siteUrl)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(siteUrl, "^https?://[^/]+");
|
||||||
|
return match.Success ? match.Value : siteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ListAbsUrl(string siteUrl, string listId) => $"{SiteOrigin(siteUrl)}{listId}";
|
||||||
|
|
||||||
|
// KQL во фронтенде терпел только буквы/цифры/пробелы (см. sanitizeForKql в
|
||||||
|
// requestManageService.ts) — здесь та же санитизация для сравнения без учёта
|
||||||
|
// регистра по Title/UniqueCode, только вместо полнотекстового индекса Search
|
||||||
|
// сравниваем подстроки напрямую (см. MatchesQuery).
|
||||||
|
private static IReadOnlyList<string> SplitQueryWords(string query)
|
||||||
|
{
|
||||||
|
var sanitized = Regex.Replace(query, @"[^a-zA-Zа-яА-ЯёЁ0-9\s]", " ");
|
||||||
|
return sanitized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesQuery(ManagedRequestSummaryDto summary, IReadOnlyList<string> words)
|
||||||
|
{
|
||||||
|
if (words.Count == 0) return true;
|
||||||
|
|
||||||
|
var haystack = $"{summary.Title} {summary.UniqueCode}".ToLowerInvariant();
|
||||||
|
return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
private static int? GetInt(JsonElement e, string name)
|
||||||
|
{
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return null;
|
||||||
|
if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i)) return i;
|
||||||
|
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? GetDouble(JsonElement e, string name)
|
||||||
|
{
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return null;
|
||||||
|
if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d;
|
||||||
|
if (v.ValueKind == JsonValueKind.String && double.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool? GetBool(JsonElement e, string name)
|
||||||
|
{
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return null;
|
||||||
|
if (v.ValueKind is JsonValueKind.True or JsonValueKind.False) return v.GetBoolean();
|
||||||
|
if (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
/// <param name="SharePointLists">
|
||||||
|
/// Вся серия SharePoint-списков данных (архив + текущая партиция) типа заявки —
|
||||||
|
/// именно на них нужно выдать email'у право чтения, иначе поиск по порталу
|
||||||
|
/// продолжит отдавать только его собственные заявки: доступ здесь настраивает
|
||||||
|
/// видимость в UI approval-route, но не права SharePoint, которые остаются
|
||||||
|
/// отдельным ручным шагом.
|
||||||
|
/// </param>
|
||||||
|
public sealed record RequestTypeAccessDto(
|
||||||
|
int Id,
|
||||||
|
int RequestTypeId,
|
||||||
|
string Email,
|
||||||
|
IReadOnlyList<RequestListPartitionDto> SharePointLists);
|
||||||
|
|
||||||
|
public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email);
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
public sealed class RequestTypeAccessService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ListPartitionService partitions,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ILogger<RequestTypeAccessService> logger)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeAccessDto>>> ListAsync(
|
||||||
|
int? requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entities = await db.RequestTypeAccesses.AsNoTracking()
|
||||||
|
.Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId)
|
||||||
|
.OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Партиции запрашиваются РАЗ на тип заявки, а не на строку — иначе список
|
||||||
|
// из N email по одному типу заново обходил бы SharePoint N раз подряд.
|
||||||
|
var listsByType = new Dictionary<int, IReadOnlyList<RequestListPartitionDto>>();
|
||||||
|
var items = new List<RequestTypeAccessDto>(entities.Count);
|
||||||
|
foreach (var e in entities)
|
||||||
|
{
|
||||||
|
var lists = await GetListsCachedAsync(listsByType, e.RequestTypeId, cancellationToken);
|
||||||
|
items.Add(new RequestTypeAccessDto(e.Id, e.RequestTypeId, e.Email, lists));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<RequestTypeAccessDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<RequestListPartitionDto>> GetListsCachedAsync(
|
||||||
|
Dictionary<int, IReadOnlyList<RequestListPartitionDto>> cache,
|
||||||
|
int requestTypeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cache.TryGetValue(requestTypeId, out var cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var result = await partitions.ListAsync(requestTypeId, cancellationToken);
|
||||||
|
var lists = result.IsSuccess ? result.Value : Array.Empty<RequestListPartitionDto>();
|
||||||
|
cache[requestTypeId] = lists;
|
||||||
|
return lists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Список email'ов с доступом к типу заявок — дёргает Power Automate.</summary>
|
||||||
|
public async Task<Result<IReadOnlyList<string>>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == requestTypeId, cancellationToken))
|
||||||
|
return Result.Fail<IReadOnlyList<string>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
|
var emails = await db.RequestTypeAccesses.AsNoTracking()
|
||||||
|
.Where(x => x.RequestTypeId == requestTypeId)
|
||||||
|
.OrderBy(x => x.Email)
|
||||||
|
.Select(x => x.Email)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<string>>(emails);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeAccessDto>> CreateAsync(
|
||||||
|
CreateRequestTypeAccessRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken))
|
||||||
|
return Result.Fail<RequestTypeAccessDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||||
|
|
||||||
|
var email = request.Email.Trim();
|
||||||
|
var exists = await db.RequestTypeAccesses.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.RequestTypeId == request.RequestTypeId && x.Email == email, cancellationToken);
|
||||||
|
if (exists)
|
||||||
|
return Result.Fail<RequestTypeAccessDto>(
|
||||||
|
new ConflictError($"У {email} уже есть доступ к этому типу заявок."));
|
||||||
|
|
||||||
|
var entity = new RequestTypeAccess { RequestTypeId = request.RequestTypeId, Email = email };
|
||||||
|
db.RequestTypeAccesses.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}",
|
||||||
|
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||||
|
|
||||||
|
var listsResult = await partitions.ListAsync(entity.RequestTypeId, cancellationToken);
|
||||||
|
var lists = listsResult.IsSuccess ? listsResult.Value : Array.Empty<RequestListPartitionDto>();
|
||||||
|
return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email, lists));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypeAccesses.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Доступ к типу заявки", id));
|
||||||
|
|
||||||
|
db.RequestTypeAccesses.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Доступ к типу заявки {RequestTypeId} для {Email} отозван пользователем {Author}",
|
||||||
|
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
public sealed class CreateRequestTypeAccessRequestValidator : AbstractValidator<CreateRequestTypeAccessRequest>
|
||||||
|
{
|
||||||
|
public CreateRequestTypeAccessRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RequestTypeId).GreaterThan(0).WithMessage("Тип заявки обязателен.");
|
||||||
|
RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("Укажите корректный email.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
// SlaHours/AskOverdueReason добавлены в конец с значением по умолчанию null —
|
||||||
|
// оба поля необязательны, и старые клиенты, не знающие о них, продолжают
|
||||||
|
// работать без изменений. null здесь значит «не настроено», а не «ноль»:
|
||||||
|
// см. RequestType.SlaHours.
|
||||||
|
public sealed record RequestTypeDto(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string PageUrl,
|
||||||
|
string? ListId,
|
||||||
|
string? SiteUrl,
|
||||||
|
string Section,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
|
|
||||||
|
public sealed record CreateRequestTypeRequest(
|
||||||
|
string Name,
|
||||||
|
string PageUrl,
|
||||||
|
string? ListId,
|
||||||
|
string? SiteUrl,
|
||||||
|
string Section,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
|
|
||||||
|
public sealed record UpdateRequestTypeRequest(
|
||||||
|
string Name,
|
||||||
|
string PageUrl,
|
||||||
|
string? ListId,
|
||||||
|
string? SiteUrl,
|
||||||
|
string Section,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
|
|
||||||
|
public sealed record PatchRequestTypeRequest(
|
||||||
|
string? Name,
|
||||||
|
string? PageUrl,
|
||||||
|
string? ListId,
|
||||||
|
string? SiteUrl,
|
||||||
|
string? Section,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed class RequestTypeService(ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<RequestTypeService> logger)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeDto>>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await db.RequestTypes.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section, x.SlaHours, x.AskOverdueReason))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> GetAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!RequestTypeFields.IsListReferenceValid(request.ListId, request.SiteUrl))
|
||||||
|
return Result.Fail<RequestTypeDto>(new ValidationError(
|
||||||
|
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
|
var entity = new RequestType
|
||||||
|
{
|
||||||
|
Name = request.Name,
|
||||||
|
PageUrl = request.PageUrl,
|
||||||
|
ListId = request.ListId,
|
||||||
|
SiteUrl = request.SiteUrl,
|
||||||
|
Section = request.Section,
|
||||||
|
SlaHours = request.SlaHours,
|
||||||
|
AskOverdueReason = request.AskOverdueReason
|
||||||
|
};
|
||||||
|
db.RequestTypes.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Тип заявки {RequestTypeId} создан пользователем {Author}", entity.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> UpdateAsync(int id, UpdateRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
if (!RequestTypeFields.IsListReferenceValid(request.ListId, request.SiteUrl))
|
||||||
|
return Result.Fail<RequestTypeDto>(new ValidationError(
|
||||||
|
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
|
entity.Name = request.Name;
|
||||||
|
entity.PageUrl = request.PageUrl;
|
||||||
|
entity.ListId = request.ListId;
|
||||||
|
entity.SiteUrl = request.SiteUrl;
|
||||||
|
entity.Section = request.Section;
|
||||||
|
entity.SlaHours = request.SlaHours;
|
||||||
|
entity.AskOverdueReason = request.AskOverdueReason;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> PatchAsync(int id, PatchRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
var listId = request.ListId ?? entity.ListId;
|
||||||
|
var siteUrl = request.SiteUrl ?? entity.SiteUrl;
|
||||||
|
|
||||||
|
if (!RequestTypeFields.IsListReferenceValid(listId, siteUrl))
|
||||||
|
return Result.Fail<RequestTypeDto>(new ValidationError(
|
||||||
|
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
|
entity.Name = request.Name ?? entity.Name;
|
||||||
|
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
|
||||||
|
entity.ListId = listId;
|
||||||
|
entity.SiteUrl = siteUrl;
|
||||||
|
entity.Section = request.Section ?? entity.Section;
|
||||||
|
// PATCH здесь традиционно трактует null как «не менять» (как и остальные
|
||||||
|
// поля выше), поэтому сбросить SLA обратно в «наследовать» можно только
|
||||||
|
// через PUT — там значение записывается как есть.
|
||||||
|
entity.SlaHours = request.SlaHours ?? entity.SlaHours;
|
||||||
|
entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
db.RequestTypes.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Тип заявки {RequestTypeId} удалён пользователем {Author}", id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RequestTypeDto ToDto(RequestType e) =>
|
||||||
|
new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section, e.SlaHours, e.AskOverdueReason);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed class CreateRequestTypeRequestValidator : AbstractValidator<CreateRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public CreateRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
|
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||||
|
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<UpdateRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public UpdateRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
|
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||||
|
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public PatchRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Name is not null, () =>
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым."));
|
||||||
|
When(x => x.PageUrl is not null, () =>
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
|
||||||
|
When(x => x.Section is not null, () =>
|
||||||
|
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RequestTypeFields
|
||||||
|
{
|
||||||
|
public static bool IsListReferenceValid(string? listId, string? siteUrl) =>
|
||||||
|
string.IsNullOrWhiteSpace(listId) == string.IsNullOrWhiteSpace(siteUrl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
// AutoApprove — «согласовать шаг автоматически по истечении SLA». Обычный bool,
|
||||||
|
// а не три состояния: наследовать его неоткуда (см. TemplateRouteStep.AutoApprove).
|
||||||
|
//
|
||||||
|
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
|
||||||
|
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
|
||||||
|
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
||||||
|
// записей, чтобы не ломать существующие позиционные вызовы.
|
||||||
|
|
||||||
|
/// <summary>Одно условие появления шага — см. Domain.Entities.TemplateRouteStepCondition.</summary>
|
||||||
|
public sealed record RouteStepConditionDto(string FieldName, ComparisonOperator Operation, string Value);
|
||||||
|
|
||||||
|
public sealed record TemplateRouteStepDto(
|
||||||
|
int Id,
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
int RouteId,
|
||||||
|
bool AllowReassignToOtherApprovers,
|
||||||
|
IReadOnlyList<RouteStepConditionDto> Conditions,
|
||||||
|
ConditionsLogic ConditionsLogic,
|
||||||
|
string? Warning = null,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
|
public sealed record CreateTemplateRouteStepRequest(
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
bool AllowReassignToOtherApprovers,
|
||||||
|
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||||
|
ConditionsLogic ConditionsLogic,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
|
public sealed record UpdateTemplateRouteStepRequest(
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
bool AllowReassignToOtherApprovers,
|
||||||
|
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||||
|
ConditionsLogic ConditionsLogic,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
|
||||||
|
/// «не менять» от «убрать все условия» здесь нельзя (тот же приём, что и раньше
|
||||||
|
/// для FieldName) — очистка условий делается через PUT с Conditions: [].
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PatchTemplateRouteStepRequest(
|
||||||
|
int? OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus? Status,
|
||||||
|
bool? AllowReassignToOtherApprovers,
|
||||||
|
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||||
|
ConditionsLogic? ConditionsLogic = null,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null,
|
||||||
|
bool? AutoApprove = null);
|
||||||
+374
@@ -0,0 +1,374 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteStepService(
|
||||||
|
ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<TemplateRouteStepService> logger)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
.Include(x => x.Conditions)
|
||||||
|
.Where(x => x.RouteId == routeId)
|
||||||
|
.OrderBy(x => x.OrderNo)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для
|
||||||
|
/// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen):
|
||||||
|
/// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не
|
||||||
|
/// попадают — это не привязка к конкретному человеку.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListByExecutorEmailAsync(
|
||||||
|
string executorEmail, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(executorEmail))
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
|
||||||
|
|
||||||
|
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
.Include(x => x.Conditions)
|
||||||
|
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
|
||||||
|
.OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
.Include(x => x.Conditions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||||
|
|
||||||
|
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
|
||||||
|
r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
|
var entity = new TemplateRouteStep
|
||||||
|
{
|
||||||
|
RouteId = route.Id,
|
||||||
|
OrderNo = r.OrderNo,
|
||||||
|
ExecutorEmail = r.ExecutorEmail,
|
||||||
|
Position = r.Position,
|
||||||
|
ProjectRole = r.ProjectRole,
|
||||||
|
HeadType = r.HeadType,
|
||||||
|
Status = r.Status,
|
||||||
|
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
|
||||||
|
ConditionsLogic = r.ConditionsLogic,
|
||||||
|
Conditions = ToEntities(r.Conditions),
|
||||||
|
SlaHours = r.SlaHours,
|
||||||
|
AskOverdueReason = r.AskOverdueReason,
|
||||||
|
AutoApprove = r.AutoApprove
|
||||||
|
};
|
||||||
|
db.TemplateRouteSteps.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Шаг {StepId} маршрута {RouteId} создан пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
|
||||||
|
int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||||
|
|
||||||
|
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
|
||||||
|
r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
|
entity.OrderNo = r.OrderNo;
|
||||||
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
|
entity.Position = r.Position;
|
||||||
|
entity.ProjectRole = r.ProjectRole;
|
||||||
|
entity.HeadType = r.HeadType;
|
||||||
|
entity.Status = r.Status;
|
||||||
|
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
|
||||||
|
entity.ConditionsLogic = r.ConditionsLogic;
|
||||||
|
ReplaceConditions(entity, r.Conditions);
|
||||||
|
entity.SlaHours = r.SlaHours;
|
||||||
|
entity.AskOverdueReason = r.AskOverdueReason;
|
||||||
|
entity.AutoApprove = r.AutoApprove;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> PatchAsync(
|
||||||
|
int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail;
|
||||||
|
var position = r.Position ?? entity.Position;
|
||||||
|
var projectRole = r.ProjectRole ?? entity.ProjectRole;
|
||||||
|
var headType = r.HeadType ?? entity.HeadType;
|
||||||
|
var status = r.Status ?? entity.Status;
|
||||||
|
var conditions = r.Conditions ?? ToDtos(entity.Conditions);
|
||||||
|
var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic;
|
||||||
|
|
||||||
|
if (status == RouteStepStatus.None)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
|
||||||
|
|
||||||
|
if (!route.ByProject && projectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
|
||||||
|
|
||||||
|
if (!TemplateRouteStepFields.AreConditionsValid(conditions))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||||
|
|
||||||
|
if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, executorEmail, position, projectRole, headType, status,
|
||||||
|
conditions, conditionsLogic, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
|
entity.ExecutorEmail = executorEmail;
|
||||||
|
entity.Position = position;
|
||||||
|
entity.ProjectRole = projectRole;
|
||||||
|
entity.HeadType = headType;
|
||||||
|
entity.Status = status;
|
||||||
|
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
|
||||||
|
entity.ConditionsLogic = conditionsLogic;
|
||||||
|
if (r.Conditions is not null)
|
||||||
|
ReplaceConditions(entity, r.Conditions);
|
||||||
|
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
|
||||||
|
// наследованию SLA от типа заявки можно через PUT.
|
||||||
|
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
||||||
|
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
||||||
|
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
db.TemplateRouteSteps.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Шаг {StepId} маршрута {RouteId} удалён пользователем {Author}", id, routeId, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiresList(IReadOnlyList<RouteStepConditionDto>? conditions) => conditions is { Count: > 0 };
|
||||||
|
|
||||||
|
private static List<TemplateRouteStepCondition> ToEntities(IReadOnlyList<RouteStepConditionDto>? conditions) =>
|
||||||
|
(conditions ?? []).Select((c, i) => new TemplateRouteStepCondition
|
||||||
|
{
|
||||||
|
OrderNo = i,
|
||||||
|
FieldName = c.FieldName,
|
||||||
|
Operation = c.Operation,
|
||||||
|
Value = c.Value
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
private static void ReplaceConditions(TemplateRouteStep entity, IReadOnlyList<RouteStepConditionDto>? conditions)
|
||||||
|
{
|
||||||
|
entity.Conditions.Clear();
|
||||||
|
entity.Conditions.AddRange(ToEntities(conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RouteStepConditionDto> ToDtos(IEnumerable<TemplateRouteStepCondition> conditions) =>
|
||||||
|
conditions.OrderBy(c => c.OrderNo).Select(c => new RouteStepConditionDto(c.FieldName, c.Operation, c.Value)).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом
|
||||||
|
/// условии появления (набор условий + логика их объединения): шаги с разными
|
||||||
|
/// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они
|
||||||
|
/// попасть могут, а в один резолв — нет. Сравнение условий — в памяти
|
||||||
|
/// (см. <see cref="ConditionsEqual"/>): кандидатов на дубликат в пределах
|
||||||
|
/// одного маршрута всегда мало, а сравнение списка условий целиком плохо
|
||||||
|
/// транслируется в SQL.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> HasDuplicateAsync(
|
||||||
|
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
|
||||||
|
RouteStepStatus status, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
||||||
|
int? excludeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
|
||||||
|
.Where(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RouteId == routeId &&
|
||||||
|
x.ExecutorEmail == executorEmail &&
|
||||||
|
x.Position == position &&
|
||||||
|
x.ProjectRole == projectRole &&
|
||||||
|
x.HeadType == headType &&
|
||||||
|
x.Status == status)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors)
|
||||||
|
/// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости
|
||||||
|
/// инициатора, единственный departmentId запроса (для DepartmentHead, см.
|
||||||
|
/// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для
|
||||||
|
/// CompanyDirectors) не оставляют места для выбора. Для большинства значений это
|
||||||
|
/// один конкретный человек; для CompanyDirectors — один и тот же набор людей
|
||||||
|
/// (директор каждой компании инициатора), но тоже всегда один и тот же для
|
||||||
|
/// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым
|
||||||
|
/// HeadType всегда резолвились бы одинаково — например, «два разных директора
|
||||||
|
/// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync
|
||||||
|
/// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения
|
||||||
|
/// HeadType — вне зависимости от статуса или остальных полей шага. Исключение —
|
||||||
|
/// условие появления шага: при разных условиях в конкретный резолв попадёт только
|
||||||
|
/// один из шагов, так что дублирования исполнителей в маршруте не окажется.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> HasDuplicateHeadTypeAsync(
|
||||||
|
int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
||||||
|
int? excludeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (headType is null or HeadType.None)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
|
||||||
|
.Where(x => x.Id != excludeId && x.RouteId == routeId && x.HeadType == headType)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Наборы условий равны, если совпадают все условия (без учёта порядка — порядок
|
||||||
|
/// влияет только на отображение в форме, не на вычисление) и, если условий два и
|
||||||
|
/// более, логика их объединения (И/ИЛИ). При 0-1 условии логика ни на что не
|
||||||
|
/// влияет, поэтому не сравнивается.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ConditionsEqual(
|
||||||
|
IReadOnlyList<RouteStepConditionDto> a, ConditionsLogic aLogic,
|
||||||
|
IReadOnlyList<RouteStepConditionDto>? b, ConditionsLogic bLogic)
|
||||||
|
{
|
||||||
|
var bList = b ?? [];
|
||||||
|
if (a.Count != bList.Count)
|
||||||
|
return false;
|
||||||
|
if (a.Count > 1 && aLogic != bLogic)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
static IEnumerable<(string FieldName, ComparisonOperator Operation, string Value)> Normalize(
|
||||||
|
IReadOnlyList<RouteStepConditionDto> c) =>
|
||||||
|
c.Select(x => (x.FieldName, x.Operation, x.Value))
|
||||||
|
.OrderBy(x => x.FieldName, StringComparer.Ordinal)
|
||||||
|
.ThenBy(x => x.Operation)
|
||||||
|
.ThenBy(x => x.Value, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
return Normalize(a).SequenceEqual(Normalize(bList));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||||
|
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? warning = null;
|
||||||
|
if (route.ByProject)
|
||||||
|
{
|
||||||
|
var hasProjectRoleStep = await db.TemplateRouteSteps
|
||||||
|
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken);
|
||||||
|
if (!hasProjectRoleStep)
|
||||||
|
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToDto(entity) with { Warning = warning };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||||
|
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
|
||||||
|
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason, e.AutoApprove);
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<CreateTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public CreateTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
RuleFor(x => x.ConditionsLogic)
|
||||||
|
.NotEqual(ConditionsLogic.None)
|
||||||
|
.WithMessage("Логика объединения условий обязательна: And или Or.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||||
|
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<UpdateTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public UpdateTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
RuleFor(x => x.ConditionsLogic)
|
||||||
|
.NotEqual(ConditionsLogic.None)
|
||||||
|
.WithMessage("Логика объединения условий обязательна: And или Or.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||||
|
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// У PATCH-запроса шага валидатора раньше не было вовсе — здесь он ровно про одно:
|
||||||
|
/// не дать протащить невалидные значения-заглушки в поля, которые запрос ЯВНО
|
||||||
|
/// задаёт. null остаётся «не менять» и не проверяется (см. PatchTemplateRouteStepRequest).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PatchTemplateRouteStepRequestValidator : AbstractValidator<PatchTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public PatchTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Status is not null, () =>
|
||||||
|
RuleFor(x => x.Status)
|
||||||
|
.NotEqual(RouteStepStatus.None)
|
||||||
|
.WithMessage("Статус обязателен: Approval или Execution."));
|
||||||
|
When(x => x.ConditionsLogic is not null, () =>
|
||||||
|
RuleFor(x => x.ConditionsLogic)
|
||||||
|
.NotEqual(ConditionsLogic.None)
|
||||||
|
.WithMessage("Логика объединения условий обязательна: And или Or."));
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||||
|
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TemplateRouteStepFields
|
||||||
|
{
|
||||||
|
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
|
||||||
|
(string.IsNullOrWhiteSpace(executorEmail) ? 0 : 1) +
|
||||||
|
(string.IsNullOrWhiteSpace(position) ? 0 : 1) +
|
||||||
|
(projectRole is null or ProjectRole.None ? 0 : 1) +
|
||||||
|
(headType is null or HeadType.None ? 0 : 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Условие с пустым field_name/value или operation=None — половинчатое, а не
|
||||||
|
/// «выключенное» (выключается вовсе не добавляя условие в список), поэтому
|
||||||
|
/// такое отдельное условие недопустимо, даже если остальные условия шага корректны.
|
||||||
|
/// </summary>
|
||||||
|
public static bool AreConditionsValid(IReadOnlyList<RouteStepConditionDto>? conditions) =>
|
||||||
|
conditions is null || conditions.All(c =>
|
||||||
|
!string.IsNullOrWhiteSpace(c.FieldName) &&
|
||||||
|
c.Operation != ComparisonOperator.None &&
|
||||||
|
!string.IsNullOrWhiteSpace(c.Value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
/// <param name="SlaHours">
|
||||||
|
/// ДЕЙСТВУЮЩИЙ SLA шага в часах — наследование уже применено: значение шага, а
|
||||||
|
/// если на шаге не задано, то значение типа заявки. null означает, что SLA не
|
||||||
|
/// настроен нигде, то есть напоминаний по этому шагу не будет.
|
||||||
|
/// Резолв отдаёт именно итоговое значение, чтобы вызывающая сторона
|
||||||
|
/// (request-portal) просто записала его в строку списка согласования и не
|
||||||
|
/// повторяла у себя правило наследования.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AskOverdueReason">
|
||||||
|
/// Спрашивать ли причину просрочки — так же с уже применённым наследованием.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="PositionCode">
|
||||||
|
/// HeadType шага (Chief/DirectionHead/DirectorTansu/ManagingDirectorTansu/DepartmentHead),
|
||||||
|
/// если исполнитель задан типом руководителя, иначе null. В отличие от <see cref="Position"/>
|
||||||
|
/// (реальная должность резолвнутого сотрудника из справочника api.tnsu.kz — свободный
|
||||||
|
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
|
||||||
|
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AutoApprove">
|
||||||
|
/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть —
|
||||||
|
/// наследовать его неоткуда (у типа заявки такого поля нет, см.
|
||||||
|
/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто
|
||||||
|
/// переносит его в строку списка согласования: решает по нему не она, а фоновая
|
||||||
|
/// задача бэкенда (AutoApproveService).
|
||||||
|
/// </param>
|
||||||
|
public sealed record ResolvedRouteStepDto(
|
||||||
|
int Id,
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
bool AllowReassignToOtherApprovers,
|
||||||
|
string? Warning = null,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null,
|
||||||
|
HeadType? PositionCode = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
|
public sealed record ResolveTemplateRouteResultDto(
|
||||||
|
int RouteId,
|
||||||
|
bool RequiresControl,
|
||||||
|
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
||||||
|
|
||||||
|
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
|
||||||
|
public sealed record ResolveFieldValueDto(string FieldName, string Value);
|
||||||
|
|
||||||
|
/// <param name="DepartmentId">
|
||||||
|
/// Отдел для шагов с HeadType.DepartmentHead ("руководитель указанного отдела") —
|
||||||
|
/// не выводится из инициатора, поэтому должен быть передан явно. Необязателен:
|
||||||
|
/// если в маршруте нет такого шага, поле не нужно; если есть, а поле не
|
||||||
|
/// передано — шаг не резолвится и получает предупреждение (см.
|
||||||
|
/// TemplateRouteService.ResolveStepAsync), весь запрос при этом не падает.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ResolveTemplateRouteRequest(
|
||||||
|
int RequestTypeId,
|
||||||
|
string? UserEmail,
|
||||||
|
Guid? ProjectId,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null,
|
||||||
|
Guid? DepartmentId = null);
|
||||||
+3
-9
@@ -1,33 +1,27 @@
|
|||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
public sealed record TemplateRouteDto(
|
public sealed record TemplateRouteDto(
|
||||||
int Id,
|
int Id,
|
||||||
RouteType RouteType,
|
|
||||||
int RequestTypeId,
|
int RequestTypeId,
|
||||||
Guid? DepartmentId,
|
Guid? DepartmentId,
|
||||||
Guid CompanyId,
|
Guid? CompanyId,
|
||||||
bool ByProject,
|
bool ByProject,
|
||||||
bool RequiresControl);
|
bool RequiresControl);
|
||||||
|
|
||||||
public sealed record CreateTemplateRouteRequest(
|
public sealed record CreateTemplateRouteRequest(
|
||||||
RouteType RouteType,
|
|
||||||
int RequestTypeId,
|
int RequestTypeId,
|
||||||
Guid? DepartmentId,
|
Guid? DepartmentId,
|
||||||
Guid CompanyId,
|
Guid? CompanyId,
|
||||||
bool ByProject,
|
bool ByProject,
|
||||||
bool RequiresControl);
|
bool RequiresControl);
|
||||||
|
|
||||||
public sealed record UpdateTemplateRouteRequest(
|
public sealed record UpdateTemplateRouteRequest(
|
||||||
RouteType RouteType,
|
|
||||||
Guid? DepartmentId,
|
Guid? DepartmentId,
|
||||||
Guid CompanyId,
|
Guid? CompanyId,
|
||||||
bool ByProject,
|
bool ByProject,
|
||||||
bool RequiresControl);
|
bool RequiresControl);
|
||||||
|
|
||||||
public sealed record PatchTemplateRouteRequest(
|
public sealed record PatchTemplateRouteRequest(
|
||||||
RouteType? RouteType,
|
|
||||||
Guid? DepartmentId,
|
Guid? DepartmentId,
|
||||||
Guid? CompanyId,
|
Guid? CompanyId,
|
||||||
bool? ByProject,
|
bool? ByProject,
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IOptions<TnsuPortalOptions> portalOptions,
|
||||||
|
ILogger<TemplateRouteService> logger)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
|
||||||
|
if (requestTypeId is { } id)
|
||||||
|
query = query.Where(x => x.RequestTypeId == id);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.Select(x => new TemplateRouteDto(
|
||||||
|
x.Id, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> GetAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
|
||||||
|
if (!requestTypeExists)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
var entity = new TemplateRoute
|
||||||
|
{
|
||||||
|
RequestTypeId = r.RequestTypeId,
|
||||||
|
DepartmentId = r.DepartmentId,
|
||||||
|
CompanyId = r.CompanyId,
|
||||||
|
ByProject = r.ByProject,
|
||||||
|
RequiresControl = r.RequiresControl
|
||||||
|
};
|
||||||
|
db.TemplateRoutes.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Маршрут {RouteId} создан пользователем {Author}", entity.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
entity.DepartmentId = r.DepartmentId;
|
||||||
|
entity.CompanyId = r.CompanyId;
|
||||||
|
entity.ByProject = r.ByProject;
|
||||||
|
entity.RequiresControl = r.RequiresControl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
var departmentId = r.DepartmentId ?? entity.DepartmentId;
|
||||||
|
var companyId = r.CompanyId ?? entity.CompanyId;
|
||||||
|
var byProject = r.ByProject ?? entity.ByProject;
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
entity.DepartmentId = departmentId;
|
||||||
|
entity.CompanyId = companyId;
|
||||||
|
entity.ByProject = byProject;
|
||||||
|
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
db.TemplateRoutes.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Маршрут {RouteId} удалён пользователем {Author}", id, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ResolveTemplateRouteResultDto>> ResolveAsync(
|
||||||
|
ResolveTemplateRouteRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail;
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||||
|
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
|
||||||
|
|
||||||
|
// Сам тип заявки, а не только факт его существования: из него берутся
|
||||||
|
// значения SLA по умолчанию для шагов (см. ResolveStepAsync).
|
||||||
|
var requestType = await db.RequestTypes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.RequestTypeId, cancellationToken);
|
||||||
|
if (requestType is null)
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||||
|
|
||||||
|
// /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких
|
||||||
|
// компаниях одновременно. Для поиска маршрута и большинства резолвов ниже
|
||||||
|
// используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно
|
||||||
|
// одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors
|
||||||
|
// (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все.
|
||||||
|
var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||||
|
if (employees.Count == 0)
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||||
|
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
||||||
|
|
||||||
|
var primaryEmployee = employees[0];
|
||||||
|
var initiatorCompanies = employees
|
||||||
|
.Select(e => e.Company)
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var byProject = request.ProjectId is not null;
|
||||||
|
|
||||||
|
// Приоритет — маршрут с компанией инициатора; если для этой компании
|
||||||
|
// своего маршрута нет, берём запасной маршрут без указанной компании
|
||||||
|
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
|
||||||
|
var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken)
|
||||||
|
?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.DepartmentId, byProject, cancellationToken);
|
||||||
|
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
||||||
|
"Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»",
|
||||||
|
request.RequestTypeId));
|
||||||
|
|
||||||
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors = [];
|
||||||
|
if (request.ProjectId is { } projectId)
|
||||||
|
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
||||||
|
|
||||||
|
var fieldValues = (request.FieldValues ?? [])
|
||||||
|
.Where(f => !string.IsNullOrWhiteSpace(f.FieldName))
|
||||||
|
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Синтетическое поле условия — переопределяет то, что мог прислать вызывающий
|
||||||
|
// код под тем же именем. Считаем НЕ по должности инициатора из справочника
|
||||||
|
// api.tnsu.kz (employee.Position — она у самого сотрудника чаще всего не
|
||||||
|
// «руководитель»), а по полю Position, заполненному В САМОЙ АНКЕТЕ (например,
|
||||||
|
// «Должность» в Обходном листе — там указывают должность увольняемого, а не
|
||||||
|
// того, кто отправил заявку). Это свободный текст, поэтому определяем
|
||||||
|
// «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке —
|
||||||
|
// считаем «нет» (а не подставляем должность инициатора как фолбэк).
|
||||||
|
fieldValues.TryGetValue(PositionField, out var formPosition);
|
||||||
|
fieldValues[ManagerConditionField] = IsManagerPosition(formPosition) ? "true" : "false";
|
||||||
|
|
||||||
|
// Должность в заявке не передана вовсе — условие по IsManager вычислить не из
|
||||||
|
// чего. Такой шаг НЕ выбрасываем: молча потерять согласующего хуже, чем показать
|
||||||
|
// лишнего, поэтому шаг попадает в результат и несёт предупреждение (см. ниже
|
||||||
|
// ResolveStepAsync/conditionWarning). Пустая строка или должность, не похожая на
|
||||||
|
// руководящую, — это осознанный «нет», предупреждать не о чем.
|
||||||
|
var positionMissing = string.IsNullOrWhiteSpace(formPosition);
|
||||||
|
|
||||||
|
var matchingSteps = route.Steps
|
||||||
|
.Where(s => s.Status == request.Status)
|
||||||
|
.Where(s => MatchesConditions(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||||
|
.OrderBy(s => s.OrderNo)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные
|
||||||
|
// походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте
|
||||||
|
// (десяток+ шагов) последовательные await один за другим складываются в
|
||||||
|
// заметную задержку. Task.WhenAll сохраняет порядок результатов (массив
|
||||||
|
// соответствует порядку задач, а не порядку завершения), поэтому
|
||||||
|
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
||||||
|
// дубликат считается первым) продолжают работать так же, как при
|
||||||
|
// последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО
|
||||||
|
// строк результата (HeadType.CompanyDirectors — по одной на компанию
|
||||||
|
// инициатора), поэтому ResolveStepAsync возвращает список, а не одну dto,
|
||||||
|
// и результат нужно сначала сгладить (SelectMany).
|
||||||
|
var stepResults = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||||||
|
step, email, primaryEmployee.Company, initiatorCompanies, projectExecutors, request.DepartmentId, requestType,
|
||||||
|
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||||
|
cancellationToken)));
|
||||||
|
|
||||||
|
var flattenedSteps = stepResults.SelectMany(x => x).ToList();
|
||||||
|
var withoutDirectorTansuDuplicate = RemoveCompanyDirectorsDuplicatedByDirectorTansu(flattenedSteps);
|
||||||
|
var collapsedSteps = CollapseByExecutorIdentity(withoutDirectorTansuDuplicate);
|
||||||
|
|
||||||
|
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один и тот же человек в справочнике api.tnsu.kz может иметь несколько email —
|
||||||
|
/// поэтому дедуп исполнителей (и здесь, и в CollapseByExecutorIdentity) идёт по
|
||||||
|
/// Guid сотрудника (EmployeeDirectoryInfo.Id), а не по email. Email — фолбэк на
|
||||||
|
/// случай, если Id почему-то не резолвился (сотрудник не найден по email в
|
||||||
|
/// момент BuildDtoAsync). null — нет ни того, ни другого, дедуп не имеет смысла
|
||||||
|
/// (например, исполнитель шага вовсе не резолвился).
|
||||||
|
/// </summary>
|
||||||
|
private static string? IdentityKeyOf(ResolvedStepRow row) =>
|
||||||
|
row.ExecutorId is { } id ? $"id:{id}" :
|
||||||
|
!string.IsNullOrWhiteSpace(row.Dto.ExecutorEmail) ? $"email:{row.Dto.ExecutorEmail.Trim().ToLowerInvariant()}" :
|
||||||
|
null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
|
||||||
|
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
|
||||||
|
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
|
||||||
|
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
|
||||||
|
/// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity):
|
||||||
|
/// тот считает разные PositionCode у одного исполнителя конфликтом и пишет
|
||||||
|
/// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
|
||||||
|
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
|
||||||
|
/// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе
|
||||||
|
/// email), а не по email напрямую — один человек может фигурировать под
|
||||||
|
/// разными email в разных ветках резолва.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<ResolvedStepRow> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||||||
|
IReadOnlyList<ResolvedStepRow> steps)
|
||||||
|
{
|
||||||
|
var directorTansuKeys = steps
|
||||||
|
.Where(s => s.Dto.PositionCode == HeadType.DirectorTansu)
|
||||||
|
.Select(IdentityKeyOf)
|
||||||
|
.Where(k => k is not null)
|
||||||
|
.Select(k => k!)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
if (directorTansuKeys.Count == 0)
|
||||||
|
return steps;
|
||||||
|
|
||||||
|
return steps
|
||||||
|
.Where(s => s.Dto.PositionCode != HeadType.CompanyDirectors || !directorTansuKeys.Contains(IdentityKeyOf(s) ?? ""))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
|
||||||
|
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
|
||||||
|
/// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает
|
||||||
|
/// вызывающий код (см. ResolveAsync: сначала с companyId сотрудника, затем null).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<TemplateRoute?> FindRouteAsync(
|
||||||
|
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
|
||||||
|
await db.TemplateRoutes.AsNoTracking()
|
||||||
|
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||||
|
.Where(x =>
|
||||||
|
x.RequestTypeId == requestTypeId &&
|
||||||
|
x.CompanyId == companyId &&
|
||||||
|
x.ByProject == byProject &&
|
||||||
|
x.DepartmentId == departmentId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
?? await db.TemplateRoutes.AsNoTracking()
|
||||||
|
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||||
|
.Where(x =>
|
||||||
|
x.RequestTypeId == requestTypeId &&
|
||||||
|
x.CompanyId == companyId &&
|
||||||
|
x.ByProject == byProject &&
|
||||||
|
x.DepartmentId == null)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
private Task<bool> HasDuplicateAsync(
|
||||||
|
int requestTypeId, Guid? departmentId, Guid? companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
|
||||||
|
db.TemplateRoutes.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RequestTypeId == requestTypeId &&
|
||||||
|
x.DepartmentId == departmentId &&
|
||||||
|
x.CompanyId == companyId &&
|
||||||
|
x.ByProject == byProject,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <param name="conditionWarning">
|
||||||
|
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||||
|
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||||
|
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||||
|
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||||
|
/// </param>
|
||||||
|
/// <summary>
|
||||||
|
/// Название должности директора для конкретной компании — по умолчанию «Директор»,
|
||||||
|
/// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
|
||||||
|
/// поэтому для HeadType.CompanyDirectors ищем по этому названию через
|
||||||
|
/// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/
|
||||||
|
/// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же:
|
||||||
|
/// отдельного источника правды для него в системе нет.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> DirectorPositionByCompany =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["ТОО KazPromService"] = "Главный механик",
|
||||||
|
["ТОО СТ Global"] = "Генеральный директор"
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string DefaultDirectorPosition = "Директор";
|
||||||
|
|
||||||
|
private static string DirectorPositionFor(string company) =>
|
||||||
|
DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для
|
||||||
|
/// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию
|
||||||
|
/// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном
|
||||||
|
/// шаге, то есть параллельны по построению, а не потому что кто-то расставил
|
||||||
|
/// им одинаковый порядок вручную.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="initiatorCompanies">
|
||||||
|
/// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны
|
||||||
|
/// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему
|
||||||
|
/// используют одну <paramref name="initiatorCompany"/> (первую по счёту).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="conditionWarning">
|
||||||
|
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||||
|
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||||
|
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||||
|
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||||
|
/// </param>
|
||||||
|
private async Task<IReadOnlyList<ResolvedStepRow>> ResolveStepAsync(
|
||||||
|
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
|
||||||
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||||
|
string? conditionWarning,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||||||
|
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||||
|
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||||
|
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||||
|
async Task<ResolvedStepRow> BuildDtoAsync(string? resolvedEmail, string? warning)
|
||||||
|
{
|
||||||
|
string? resolvedPosition = null;
|
||||||
|
Guid? resolvedId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
{
|
||||||
|
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||||
|
var resolvedEmployee = resolvedEmployees.FirstOrDefault();
|
||||||
|
resolvedPosition = resolvedEmployee?.Position;
|
||||||
|
resolvedId = resolvedEmployee?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||||
|
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||||
|
|
||||||
|
var dto = new ResolvedRouteStepDto(
|
||||||
|
step.Id,
|
||||||
|
step.OrderNo,
|
||||||
|
resolvedEmail,
|
||||||
|
resolvedPosition,
|
||||||
|
step.AllowReassignToOtherApprovers,
|
||||||
|
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||||
|
step.SlaHours ?? requestType.SlaHours,
|
||||||
|
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||||
|
step.HeadType is null or HeadType.None ? null : step.HeadType,
|
||||||
|
step.AutoApprove);
|
||||||
|
|
||||||
|
return new ResolvedStepRow(dto, resolvedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
||||||
|
// (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно
|
||||||
|
// и до общего "одна строка на шаг" пути ниже.
|
||||||
|
if (step.HeadType == HeadType.CompanyDirectors)
|
||||||
|
{
|
||||||
|
if (initiatorCompanies.Count == 0)
|
||||||
|
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
||||||
|
|
||||||
|
var dtos = new List<ResolvedStepRow>();
|
||||||
|
foreach (var company in initiatorCompanies)
|
||||||
|
{
|
||||||
|
var directorPosition = DirectorPositionFor(company);
|
||||||
|
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(directorPosition, company, cancellationToken);
|
||||||
|
var directorEmail = employeeByPosition.Email;
|
||||||
|
string? directorWarning = null;
|
||||||
|
if (!employeeByPosition.Found)
|
||||||
|
directorWarning = $"Не найден сотрудник на должности «{directorPosition}» в компании «{company}».";
|
||||||
|
else if (string.IsNullOrWhiteSpace(directorEmail))
|
||||||
|
directorWarning = $"Сотрудник {employeeByPosition.Fio} на должности «{directorPosition}» в компании «{company}» найден, но email не указан в системе портала.";
|
||||||
|
|
||||||
|
dtos.Add(await BuildDtoAsync(directorEmail, directorWarning));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dtos;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? resolvedEmail;
|
||||||
|
string? warning = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||||
|
{
|
||||||
|
resolvedEmail = step.ExecutorEmail;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(step.Position))
|
||||||
|
{
|
||||||
|
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
|
||||||
|
resolvedEmail = employeeByPosition.Email;
|
||||||
|
if (!employeeByPosition.Found)
|
||||||
|
warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}».";
|
||||||
|
else if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
warning = $"Сотрудник {employeeByPosition.Fio} на должности «{step.Position}» найден, но email не указан в системе портала.";
|
||||||
|
}
|
||||||
|
else if (step.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
{
|
||||||
|
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
|
||||||
|
}
|
||||||
|
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||||||
|
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||||||
|
// ResolveTemplateRouteRequest.DepartmentId): отдел инициатора тут ни при
|
||||||
|
// чём, шаг про «руководителя ТАКОГО-ТО отдела», а не «моего руководителя».
|
||||||
|
// Поэтому у него отдельная ветка, а не место в switch чуть ниже.
|
||||||
|
else if (step.HeadType == HeadType.DepartmentHead)
|
||||||
|
{
|
||||||
|
if (departmentId is null)
|
||||||
|
{
|
||||||
|
resolvedEmail = null;
|
||||||
|
warning = "Шаг требует руководителя указанного отдела, но departmentId не передан в запросе.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resolvedEmail = await portalClient.GetDepartmentHeadEmailAsync(departmentId.Value, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
warning = $"Не найден руководитель отдела с id «{departmentId}».";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (step.HeadType is not (null or HeadType.None))
|
||||||
|
{
|
||||||
|
resolvedEmail = step.HeadType switch
|
||||||
|
{
|
||||||
|
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||||||
|
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||||||
|
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||||
|
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
{
|
||||||
|
var headLabel = StepLabels.InSentence(step.HeadType.Value);
|
||||||
|
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
|
||||||
|
// справочника сотрудников — там email не «не найден», а «не проставлен».
|
||||||
|
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
|
||||||
|
? $"Не проставлен {headLabel}."
|
||||||
|
: $"Не найден {headLabel}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resolvedEmail = null;
|
||||||
|
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [await BuildDtoAsync(resolvedEmail, warning)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий
|
||||||
|
/// применяется всегда. Несколько условий объединяются через <see cref="TemplateRouteStep.ConditionsLogic"/>:
|
||||||
|
/// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано
|
||||||
|
/// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы
|
||||||
|
/// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется.
|
||||||
|
/// </summary>
|
||||||
|
private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||||||
|
{
|
||||||
|
if (step.Conditions.Count == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return step.ConditionsLogic == ConditionsLogic.Or
|
||||||
|
? step.Conditions.Any(c => MatchesCondition(c, fieldValues))
|
||||||
|
: step.Conditions.All(c => MatchesCondition(c, fieldValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesCondition(TemplateRouteStepCondition condition, IReadOnlyDictionary<string, string> fieldValues)
|
||||||
|
{
|
||||||
|
if (!fieldValues.TryGetValue(condition.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var expected = condition.Value;
|
||||||
|
return condition.Operation switch
|
||||||
|
{
|
||||||
|
ComparisonOperator.Equal => IsEqual(actual, expected),
|
||||||
|
ComparisonOperator.NotEqual => !IsEqual(actual, expected),
|
||||||
|
ComparisonOperator.Contains => Contains(actual, expected),
|
||||||
|
ComparisonOperator.NotContains => !Contains(actual, expected),
|
||||||
|
ComparisonOperator.GreaterThan =>
|
||||||
|
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
|
||||||
|
ComparisonOperator.LessThan =>
|
||||||
|
TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected,
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// «Равно» для значений без типа: сначала как текст без учёта регистра, затем как числа —
|
||||||
|
/// чтобы «1000» и «1000.00» считались равными. «Не равно» — строгое отрицание этого же.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsEqual(string actual, string? expected) =>
|
||||||
|
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected);
|
||||||
|
|
||||||
|
/// <summary>«Содержит» — подстрока без учёта регистра; операторы доступны только для текстовых полей.</summary>
|
||||||
|
private static bool Contains(string actual, string? expected) =>
|
||||||
|
!string.IsNullOrEmpty(expected) &&
|
||||||
|
actual.Contains(expected.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool TryParseNumber(string? value, out double number) =>
|
||||||
|
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||||
|
|
||||||
|
/// <summary>Поле заявки, из которого вычисляется <see cref="ManagerConditionField"/>.</summary>
|
||||||
|
private const string PositionField = "Position";
|
||||||
|
|
||||||
|
/// <summary>Синтетическое поле условия шага — вычисляется, а не приходит из заявки.</summary>
|
||||||
|
private const string ManagerConditionField = "IsManager";
|
||||||
|
|
||||||
|
private const string PositionMissingWarning =
|
||||||
|
"В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " +
|
||||||
|
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность
|
||||||
|
/// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с
|
||||||
|
/// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага:
|
||||||
|
/// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
|
||||||
|
positionMissing &&
|
||||||
|
step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Должность — свободный текст без структуры (нет отдельного флага «это
|
||||||
|
/// руководитель»), поэтому определяем это по ключевым словам в её названии.
|
||||||
|
/// Список подобран по фактическим должностям в справочнике api.tnsu.kz —
|
||||||
|
/// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом.
|
||||||
|
/// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший
|
||||||
|
/// специалист, а не должность с подчинёнными.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] ManagerPositionKeywords =
|
||||||
|
["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"];
|
||||||
|
|
||||||
|
private static bool IsManagerPosition(string? position) =>
|
||||||
|
!string.IsNullOrWhiteSpace(position) &&
|
||||||
|
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один и тот же исполнитель, полученный из разных шагов, в результате должен
|
||||||
|
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||||||
|
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||||||
|
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||||||
|
/// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две
|
||||||
|
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||||||
|
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||||||
|
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||||||
|
/// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по
|
||||||
|
/// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же
|
||||||
|
/// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними
|
||||||
|
/// дедуп по email эту пару не поймал бы.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorIdentity(IReadOnlyList<ResolvedStepRow> steps)
|
||||||
|
{
|
||||||
|
var indexByKey = new Dictionary<string, int>();
|
||||||
|
var result = new List<ResolvedRouteStepDto>();
|
||||||
|
|
||||||
|
foreach (var row in steps)
|
||||||
|
{
|
||||||
|
var key = IdentityKeyOf(row);
|
||||||
|
if (key is null)
|
||||||
|
{
|
||||||
|
result.Add(row.Dto);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!indexByKey.TryGetValue(key, out var index))
|
||||||
|
{
|
||||||
|
indexByKey[key] = result.Count;
|
||||||
|
result.Add(row.Dto);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = result[index];
|
||||||
|
var step = row.Dto;
|
||||||
|
if (existing.PositionCode is null && step.PositionCode is not null)
|
||||||
|
{
|
||||||
|
result[index] = existing with { PositionCode = step.PositionCode };
|
||||||
|
}
|
||||||
|
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||||||
|
{
|
||||||
|
var conflictWarning =
|
||||||
|
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
|
||||||
|
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||||||
|
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||||
|
result[index] = existing with { Warning = combinedWarning };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||||
|
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Промежуточный результат резолва одного шага — dto плюс Guid сотрудника из
|
||||||
|
/// справочника api.tnsu.kz (если резолвился), используемый только для дедупа
|
||||||
|
/// (см. IdentityKeyOf/CollapseByExecutorIdentity). Наружу отдаётся только Dto.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record ResolvedStepRow(ResolvedRouteStepDto Dto, Guid? ExecutorId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
// Компания и отдел маршрута — оба опциональны, поэтому CreateTemplateRouteRequest/
|
||||||
|
// UpdateTemplateRouteRequest/PatchTemplateRouteRequest не нуждаются в отдельных
|
||||||
|
// валидаторах: маршрут без компании служит запасным вариантом, когда для
|
||||||
|
// конкретной компании инициатора своего маршрута нет (см. ResolveAsync).
|
||||||
|
|
||||||
|
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public ResolveTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
RuleForEach(x => x.FieldValues).ChildRules(v =>
|
||||||
|
{
|
||||||
|
v.RuleFor(f => f.FieldName).NotEmpty().WithMessage("Имя поля обязательно.");
|
||||||
|
v.RuleFor(f => f.Value).NotEmpty().WithMessage("Значение поля обязательно.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -4,9 +4,12 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- Генерация xlsx для отчётов (Reports/) — стили, формулы, autofilter, диаграммы. -->
|
||||||
|
<PackageReference Include="NPOI" />
|
||||||
<PackageReference Include="FluentResults" />
|
<PackageReference Include="FluentResults" />
|
||||||
<PackageReference Include="FluentValidation" />
|
<PackageReference Include="FluentValidation" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
|
<PackageReference Include="Hangfire.Core" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один SharePoint-список данных из нумерованной серии по типу заявок.
|
||||||
|
///
|
||||||
|
/// Списки заявок нумерованные («<Форма> <N> — данные»): когда в текущем
|
||||||
|
/// списке набирается порог элементов, рядом создаётся следующая пара (партиция), а
|
||||||
|
/// прежняя остаётся архивом только для чтения. Эта таблица — реестр всей серии: без
|
||||||
|
/// неё поиск по заявкам типа не знает, по каким спискам искать.
|
||||||
|
///
|
||||||
|
/// Текущий (тот, куда идёт запись) список продолжает лежать в
|
||||||
|
/// <see cref="RequestType.ListId"/> — реестр его дублирует строкой с наибольшим
|
||||||
|
/// <see cref="Number"/>, но источником правды для записи остаётся RequestType.
|
||||||
|
///
|
||||||
|
/// Сайта у партиции нет намеренно: вся серия живёт на одном сайте — том, что указан
|
||||||
|
/// в <see cref="RequestType.SiteUrl"/> на момент создания партиции. Хранить его
|
||||||
|
/// отдельно на каждой партиции — заводить возможность архивам разъехаться по разным
|
||||||
|
/// сайтам, а такой сценарий не поддерживается ни провижинером (см.
|
||||||
|
/// SharePointListProvisioner — всегда пишет на siteUrl запрашивающего типа заявок),
|
||||||
|
/// ни поиском. Если сайт типа заявок когда-нибудь придётся сменить, старые партиции
|
||||||
|
/// нужно будет переносить осознанно, а не молча продолжать читать их со старого адреса.
|
||||||
|
/// </summary>
|
||||||
|
public class RequestListPartition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Номер партиции в серии, с 1. Уникален в пределах типа заявок.</summary>
|
||||||
|
public int Number { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Серверный относительный url списка — та же семантика, что у
|
||||||
|
/// <see cref="RequestType.ListId"/> (несмотря на имя, это url, а не GUID).
|
||||||
|
/// </summary>
|
||||||
|
public string ListId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Title списка на момент создания — для админки, чтобы показать серию без
|
||||||
|
/// похода в SharePoint за каждым заголовком. Может разойтись с фактическим,
|
||||||
|
/// если список переименовали вручную.
|
||||||
|
/// </summary>
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
public class RequestType
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string PageUrl { get; set; } = string.Empty;
|
||||||
|
public string? ListId { get; set; }
|
||||||
|
public string? SiteUrl { get; set; }
|
||||||
|
public string Section { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SLA по умолчанию для ВСЕХ шагов заявок этого типа, в часах. Отсчёт идёт от
|
||||||
|
/// активации шага, а не от создания заявки (см. эндпоинт напоминаний).
|
||||||
|
/// null — SLA не настроен, напоминания по этому типу не строятся.
|
||||||
|
/// Значение на шаге (<see cref="TemplateRouteStep.SlaHours"/>) перекрывает это.
|
||||||
|
/// </summary>
|
||||||
|
public int? SlaHours { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Спрашивать ли у согласующего причину просрочки — значение по умолчанию для
|
||||||
|
/// всех шагов заявок этого типа. null — не задано.
|
||||||
|
/// Отдаётся в ответе эндпоинта напоминаний, чтобы вызывающая сторона решила,
|
||||||
|
/// запрашивать причину или просто напомнить.
|
||||||
|
/// </summary>
|
||||||
|
public bool? AskOverdueReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Момент, когда фоновая задача создания следующей партиции списка взяла эту
|
||||||
|
/// строку в работу. null — задача не выполняется.
|
||||||
|
///
|
||||||
|
/// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует
|
||||||
|
/// только если сумела перевести поле из null в now() одним условным UPDATE
|
||||||
|
/// (см. ListPartitioningLock) — тогда две параллельные задачи по одному типу
|
||||||
|
/// заявок невозможны.
|
||||||
|
///
|
||||||
|
/// Метка времени, а не bool, намеренно: упавший процесс не успевает снять
|
||||||
|
/// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка
|
||||||
|
/// просрочена, и её можно перехватить.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset? ListPartitioningStartedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
||||||
|
|
||||||
|
/// <summary>Нумерованная серия партиций списка этого типа заявок (архив + текущая).</summary>
|
||||||
|
public ICollection<RequestListPartition> ListPartitions { get; set; } = new List<RequestListPartition>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Право одного пользователя видеть ВСЕ заявки одного типа в портале заявок
|
||||||
|
/// (а не только свои). У одного человека может быть по строке на каждый тип
|
||||||
|
/// заявок, к которому ему открыт полный доступ.
|
||||||
|
/// </summary>
|
||||||
|
public class RequestTypeAccess
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
+1
-4
@@ -1,15 +1,12 @@
|
|||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
public class TemplateRoute
|
public class TemplateRoute
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
|
||||||
public int RequestTypeId { get; set; }
|
public int RequestTypeId { get; set; }
|
||||||
|
|
||||||
public Guid? DepartmentId { get; set; }
|
public Guid? DepartmentId { get; set; }
|
||||||
public Guid CompanyId { get; set; }
|
public Guid? CompanyId { get; set; }
|
||||||
public bool ByProject { get; set; }
|
public bool ByProject { get; set; }
|
||||||
|
|
||||||
public bool RequiresControl { get; set; }
|
public bool RequiresControl { get; set; }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
public class TemplateRouteStep
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int OrderNo { get; set; }
|
||||||
|
|
||||||
|
public string? ExecutorEmail { get; set; }
|
||||||
|
public string? Position { get; set; }
|
||||||
|
public ProjectRole? ProjectRole { get; set; }
|
||||||
|
public HeadType? HeadType { get; set; }
|
||||||
|
|
||||||
|
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
|
||||||
|
public int RouteId { get; set; }
|
||||||
|
public bool AllowReassignToOtherApprovers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SLA этого шага в часах — перекрывает <see cref="RequestType.SlaHours"/>.
|
||||||
|
/// null означает ИМЕННО «не задано, наследовать от типа заявки», а 0 —
|
||||||
|
/// осознанное «напоминать сразу», поэтому поле обязано быть nullable и не
|
||||||
|
/// должно получать значение по умолчанию.
|
||||||
|
/// </summary>
|
||||||
|
public int? SlaHours { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Спрашивать ли причину просрочки на этом шаге — перекрывает
|
||||||
|
/// <see cref="RequestType.AskOverdueReason"/>. Здесь тоже три состояния:
|
||||||
|
/// null — наследовать от типа, true/false — явный выбор.
|
||||||
|
/// </summary>
|
||||||
|
public bool? AskOverdueReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Согласовывать шаг автоматически, когда истёк его SLA (см. <see cref="SlaHours"/>).
|
||||||
|
///
|
||||||
|
/// Не nullable, в отличие от SLA-полей рядом: наследовать тут нечего — у типа
|
||||||
|
/// заявки такого «значения по умолчанию» нет и быть не должно (включить
|
||||||
|
/// автосогласование сразу всем шагам типа — не настройка, а авария), поэтому
|
||||||
|
/// три состояния не нужны, хватает обычного «включено/выключено».
|
||||||
|
///
|
||||||
|
/// Действует только вместе с SLA: без действующего SLA (ни на шаге, ни на типе
|
||||||
|
/// заявки) отсчитывать нечего, и фоновая задача такой шаг пропускает
|
||||||
|
/// (см. AutoApproveService).
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoApprove { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
|
||||||
|
public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
|
||||||
|
|
||||||
|
/// <summary>Условия появления шага. Пусто — шаг применяется всегда.</summary>
|
||||||
|
public List<TemplateRouteStepCondition> Conditions { get; set; } = [];
|
||||||
|
|
||||||
|
public TemplateRoute? Route { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одно условие появления шага (см. <see cref="TemplateRouteStep.Conditions"/>) —
|
||||||
|
/// сравнение значения поля заявки (<see cref="FieldName"/>) с <see cref="Value"/>
|
||||||
|
/// через <see cref="Operation"/>. Несколько условий одного шага объединяются
|
||||||
|
/// через <see cref="TemplateRouteStep.ConditionsLogic"/> (И/ИЛИ).
|
||||||
|
/// </summary>
|
||||||
|
public class TemplateRouteStepCondition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int StepId { get; set; }
|
||||||
|
public TemplateRouteStep? Step { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Порядок отображения в форме — на вычисление условия не влияет.</summary>
|
||||||
|
public int OrderNo { get; set; }
|
||||||
|
|
||||||
|
public string FieldName { get; set; } = string.Empty;
|
||||||
|
public ComparisonOperator Operation { get; set; }
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
public enum ComparisonOperator
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
GreaterThan,
|
||||||
|
LessThan,
|
||||||
|
Equal,
|
||||||
|
NotEqual,
|
||||||
|
Contains,
|
||||||
|
NotContains
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Как объединяются условия шага (<see cref="Entities.TemplateRouteStep.Conditions"/>)
|
||||||
|
/// между собой, если их несколько. Не действует, если условие одно или их нет вовсе.
|
||||||
|
/// </summary>
|
||||||
|
public enum ConditionsLogic
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Логика не задана — НЕвалидное значение (см. валидаторы шага), а не «как И».
|
||||||
|
/// Существует только как значение по умолчанию (default(ConditionsLogic)):
|
||||||
|
/// шаг с такой логикой не должен ни создаваться, ни сохраняться.
|
||||||
|
///
|
||||||
|
/// Именно поэтому у самой сущности и у запросов на создание/обновление
|
||||||
|
/// значение по умолчанию задано явно — <c>ConditionsLogic.And</c>
|
||||||
|
/// (см. TemplateRouteStep.ConditionsLogic и *TemplateRouteStepRequest):
|
||||||
|
/// пропущенное в запросе поле по-прежнему означает «И», как и раньше.
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary>
|
||||||
|
And = 1,
|
||||||
|
|
||||||
|
/// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary>
|
||||||
|
Or = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
public enum HeadType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Chief,
|
||||||
|
DirectionHead,
|
||||||
|
DirectorTansu,
|
||||||
|
ManagingDirectorTansu,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Руководитель отдела, указанного явно в запросе на resolve (см.
|
||||||
|
/// ResolveTemplateRouteRequest.DepartmentId), а не отдела инициатора —
|
||||||
|
/// этим отличается от Chief/DirectionHead, которые всегда идут по цепочке
|
||||||
|
/// подчинённости самого инициатора. В маршруте не может быть больше одного
|
||||||
|
/// шага с этим типом (см. TemplateRouteStepService).
|
||||||
|
/// </summary>
|
||||||
|
DepartmentHead,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Директора ВСЕХ компаний, в которых числится инициатор (сотрудник может
|
||||||
|
/// работать в нескольких компаниях — /Employee/ByEmail теперь возвращает
|
||||||
|
/// массив). В отличие от остальных значений этого enum'а, один такой шаг
|
||||||
|
/// резолвится в НЕСКОЛЬКО строк результата — по одной на каждую компанию
|
||||||
|
/// инициатора, все с одним и тем же OrderNo (то есть параллельно, см.
|
||||||
|
/// TemplateRouteService.ResolveStepAsync).
|
||||||
|
/// </summary>
|
||||||
|
CompanyDirectors
|
||||||
|
}
|
||||||
+2
-1
@@ -6,5 +6,6 @@ public enum ProjectRole
|
|||||||
ProjectManager,
|
ProjectManager,
|
||||||
Planner,
|
Planner,
|
||||||
ManagerHSE,
|
ManagerHSE,
|
||||||
Security
|
Security,
|
||||||
|
Keeper
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user