14 Commits

Author SHA1 Message Date
gorbenko.pavel a46782920b feat: эндпоинт заявок по основанию (СТ/потребность) с проверкой прав на открытие
Tests / backend-test (pull_request) Successful in 2m10s
Tests / frontend-test (pull_request) Successful in 3m53s
GET /api/basis-requests?requestTypeId=&basisKey= читает заявки от имени приложения
по всем партициям, а по каждой проверяет, может ли вызывающий её открыть.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:58:42 +05:00
gorbenko.pavel f3b0f0ad48 Персист модалок: черновики, восстановление после F5, предупреждение при выходе (#30)
Tests / frontend-test (push) Successful in 1m39s
Tests / backend-test (push) Successful in 9m27s
Модалки по жёлтым кнопкам теряли введённое при случайном клике мимо окна.

**Формы создания** (типы заявок, доступ к заявкам, маршруты, шаги маршрута)
- Черновик полей хранится в localStorage (useFormDraft) и переживает закрытие окна, «Отмену» и F5.
- Окно, открытое в момент F5, открывается снова (usePersistedOpen); закрытие сбрасывает только признак, черновик остаётся.
- Черновик стирается после успешного сохранения или кнопкой «Очистить» в подвале.

**Формы редактирования**
- Не персистятся — открываются с данными сервера.
- При выходе с несохранёнными правками (крестик, «Отмена», клик мимо) — диалог «Закрыть без сохранения?» (isDirty в FormModal).

**Устойчивость к старым данным в localStorage**
- coerceStored приводит сохранённое значение к форме текущего дефолта по полям: новое поле — дефолт, удалённое отбрасывается, значение другого типа и null заменяются дефолтом. Покрыто тестами.

**Прочее**
- Modal и ConfirmDialog рендерятся порталом в body поверх шапки SharePoint — на живой странице верх высокого окна уезжал под шапку.
- Убраны Sass-предупреждения mixed-decls в Card.module.scss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/30
2026-10-19 04:46:16 +00:00
gorbenko.pavel 0e6a7064d2 Feature/routes export (#29)
Tests / backend-test (push) Successful in 6m7s
Tests / frontend-test (push) Successful in 1m29s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/29
2026-10-14 05:06:58 +00:00
gorbenko.pavel 05f73d356a fix: смена согласующего в админке «трогает» заявку, чтобы новый человек получил права (#28)
Tests / frontend-test (push) Successful in 1m32s
Tests / backend-test (push) Successful in 1m40s
Права на заявку выдаёт бэкенд по вебхуку на изменение элемента списка данных.
Массовая и одиночная замена писали только в список согласования, вебхук не
приходил, и новый человек видел «Элемент не найден». Теперь после смены
(и добавления шага) в заявку пишется её текущий RequestStatus — появляется
новая версия элемента.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/28
2026-10-07 10:19:52 +00:00
gorbenko.pavel 82c2dc36a6 Исправить имена колонок причины открытия вакансии в отчёте по вакансиям (#26)
Tests / backend-test (push) Successful in 1m50s
Tests / frontend-test (push) Successful in 1m25s
В списке «Заявка на подбор — данные» колонки называются VacancyOpenReason и
VacancyOpenReasonDetails; запрос с несуществующими OpenReason* падал, ошибка
партиции глушилась в лог, и отчёт возвращался пустым.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/26
2026-10-06 10:05:29 +00:00
gorbenko.pavel b959feedbf Fix/by approver silent failures (#25)
Tests / frontend-test (push) Successful in 1m34s
Tests / backend-test (push) Successful in 1m45s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/25
2026-10-05 04:38:37 +00:00
gorbenko.pavel dcdfcdd396 Добавить фильтр по title в GET /api/request-types/lists (#24)
Tests / frontend-test (push) Successful in 49s
Tests / backend-test (push) Successful in 1m25s
Позволяет искать партицию по заголовку списка SharePoint без вхождения
на каждый тип заявки отдельно.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/24
2026-09-28 06:37:48 +00:00
gorbenko.pavel a8bfd9f456 Feature/request type access (#23)
Tests / frontend-test (push) Successful in 49s
Tests / backend-test (push) Successful in 1m38s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/23
2026-09-25 11:48:02 +00:00
gorbenko.pavel 1bf8d4ca11 Feature/request manage server load (#22)
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 1m43s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/22
2026-09-25 06:30:59 +00:00
gorbenko.pavel 2b8fe77fac Auto approve (#21)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 3m39s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
2026-09-14 09:01:56 +00:00
gorbenko.pavel e2b6227015 Отчет по вакансиям (#20)
Tests / frontend-test (push) Successful in 47s
Tests / backend-test (push) Successful in 4m55s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/20
2026-08-31 11:00:09 +00:00
gorbenko.pavel 95d460c80a Расширение reminders (#19)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 2m22s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/19
2026-08-27 11:43:45 +00:00
gorbenko.pavel 19a46b0df8 Правки (#18)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 5m28s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/18
2026-08-26 07:08:16 +00:00
gorbenko.pavel b86e126ceb Веб-часть админки на всю ширину (edge-to-edge, как в request-portal) (#17)
Tests / frontend-test (push) Successful in 48s
Tests / backend-test (push) Successful in 2m0s
Канву SharePoint мы и раньше разжимали (тот же :global хак + supportsFullBleed
в манифесте), но содержимое упиралось в max-width: 1080px у .content и
центрировалось — визуально это читалось как «не открывается на всю ширину».

Переношу раскладку портала (RequestPortal.module.scss):
- корень .approvalAdmin — width: 100%, padding: 0;
- .shell больше не карточка: без рамки, тени, скруглений и overflow: hidden
  (последний ломает sticky), align-items: flex-start, min-height: 78vh;
- .side — заливка $navy-800 на колонке (align-self: stretch), сам Sidebar
  внутри sticky на 100vh: градиент не плывёт от высоты соседа, и под рейлом
  на длинных таблицах нет белого провала, если sticky не включился;
- .content — сняты max-width и margin: auto, остались только отступы;
- мобильный брейкпоинт обновлён под новую схему: десктопные правила рейла
  снимаются явно, иначе sticky+100vh спорил бы с position: fixed, который
  ui-kit ставит drawer'у, при равной специфичности.

Разметка AdminApp.tsx не менялась — структура уже совпадала с портальной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/17
2026-08-25 08:46:08 +00:00
117 changed files with 8437 additions and 758 deletions
+4
View File
@@ -0,0 +1,4 @@
# Стайл-гайд для Клода
- У `enum` первое значение (0) — всегда `None`.
- Сборка не должна выдавать ни одного warning.
+7
View File
@@ -34,8 +34,15 @@
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. --> <!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" /> <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.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.BasisRequests;
namespace Tnsu.ApprovalRoute.Api.Controllers;
/// <summary>
/// Заявки, поданные по одному основанию (СТ, потребность). Видны все, независимо
/// от прав вызывающего на элементы; CanOpen говорит, может ли он открыть заявку.
/// Только пользовательский токен: без email права проверять не для кого.
/// </summary>
[ApiController]
[Authorize(Policy = AuthPolicies.Delegated)]
[Route("api/basis-requests")]
[Produces("application/json")]
public sealed class BasisRequestsController(BasisRequestsService service) : ControllerBase
{
/// <summary>Заявки типа requestTypeId с BasisKey = basisKey по всем партициям.</summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<BasisRequestDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Get(
[FromQuery] int requestTypeId,
// string? — пустой параметр фронтенд вырезает из URL целиком (см. RequestManageController.Search).
[FromQuery] string? basisKey,
CancellationToken ct) =>
(await service.GetByBasisAsync(requestTypeId, basisKey ?? string.Empty, ct)).ToActionResult();
}
@@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders;
namespace Tnsu.ApprovalRoute.Api.Controllers; namespace Tnsu.ApprovalRoute.Api.Controllers;
/// <summary> /// <summary>
/// Просроченные шаги согласования и исполнения на текущий момент. /// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
/// появления ProbationEnding.
/// ///
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает /// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
/// Power Automate по расписанию (токен приложения), но и из админки его удобно /// Power Automate по расписанию (токен приложения), но и из админки его удобно
@@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(CancellationToken ct) => public async Task<IActionResult> List(
(await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult(); [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();
}
@@ -20,10 +20,14 @@ public sealed class RequestTypeListsController(ListPartitionService service) : C
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно /// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
/// запрашивать серию по каждому типу отдельно. /// запрашивать серию по каждому типу отдельно.
/// </summary> /// </summary>
/// <param name="title">
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
/// без учёта регистра, по вхождению подстроки.
/// </param>
[HttpGet("lists")] [HttpGet("lists")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> ListAll(CancellationToken ct) => public async Task<IActionResult> ListAll([FromQuery] string? title, CancellationToken ct) =>
(await service.ListAllAsync(ct)).ToActionResult(); (await service.ListAllAsync(title, ct)).ToActionResult();
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary> /// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
[HttpGet("{requestTypeId:int}/lists")] [HttpGet("{requestTypeId:int}/lists")]
+32 -1
View File
@@ -6,14 +6,17 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.Reports;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure; using Tnsu.ApprovalRoute.Infrastructure;
@@ -50,12 +53,18 @@ builder.Services.AddCors(options =>
options.AddPolicy(CorsPolicyName, policy => policy options.AddPolicy(CorsPolicyName, policy => policy
.WithOrigins(corsAllowedOrigins) .WithOrigins(corsAllowedOrigins)
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod()); .AllowAnyMethod()
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
.WithExposedHeaders("Content-Disposition"));
}); });
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication(); builder.Services.AddApprovalRouteApplication();
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders")); builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning")); 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 builder.Services.AddHangfire(configuration => configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
@@ -130,6 +139,28 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions
] ]
}); });
// Рекуррентная задача регистрируется ПОСЛЕ 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(); app.MapControllers();
await app.InitAndRunAsync(); await app.InitAndRunAsync();
@@ -3,6 +3,9 @@
<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.AspNetCore" />
@@ -24,6 +24,11 @@
"https://localhost:4321" "https://localhost:4321"
] ]
}, },
"AutoApprove": {
"Enabled": true,
"Cron": "0 * * * *",
"MaxRowsPerRun": 200
},
"ListPartitioning": { "ListPartitioning": {
"ItemCountThreshold": 4900, "ItemCountThreshold": 4900,
"StaleLockAfter": "00:30:00", "StaleLockAfter": "00:30:00",
@@ -1,8 +1,13 @@
using System.Reflection; using System.Reflection;
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.BasisRequests;
using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning; 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.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -15,10 +20,17 @@ public static class ApplicationServiceCollectionExtensions
{ {
services.AddScoped<ReminderService>(); services.AddScoped<ReminderService>();
services.AddScoped<RequestTypeService>(); services.AddScoped<RequestTypeService>();
services.AddScoped<RequestTypeAccessService>();
services.AddScoped<RequestManageService>();
services.AddScoped<BasisRequestsService>();
services.AddScoped<ListPartitionRegistry>(); services.AddScoped<ListPartitionRegistry>();
services.AddScoped<ListPartitionService>(); services.AddScoped<ListPartitionService>();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение. // Джоба резолвится Hangfire'ом из scope на каждое выполнение.
services.AddScoped<CreateNextListPartitionJob>(); services.AddScoped<CreateNextListPartitionJob>();
services.AddScoped<AutoApproveService>();
services.AddScoped<AutoApproveJob>();
services.AddScoped<VacancyReportService>();
services.AddScoped<RouteExportService>();
services.AddScoped<TemplateRouteService>(); services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>(); services.AddScoped<TemplateRouteStepService>();
@@ -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;
}
}
@@ -1,12 +1,17 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Approvals;
/// <summary> /// <summary>
/// Строка списка «… — согласование», разобранная из ответа SharePoint. /// Строка списка «… — согласование», разобранная из ответа SharePoint.
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts): /// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
/// именно он эти списки и создаёт. /// именно он эти списки и создаёт.
///
/// Общая для напоминаний (ReminderService) и автосогласования
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
/// просроченным шаг считается в одном месте и не считается в другом.
/// </summary> /// </summary>
internal sealed record ApprovalRow( internal sealed record ApprovalRow(
int Id, int Id,
@@ -15,17 +20,24 @@ internal sealed record ApprovalRow(
string Phase, string Phase,
DateTimeOffset? Date, DateTimeOffset? Date,
string? ApproverEmail, string? ApproverEmail,
string? ApproverName,
int? SlaHours, int? SlaHours,
bool? AskOverdueReason) bool? AskOverdueReason,
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
// сценария — см. RowSelect у каждого из них.
double Order = 0,
bool AutoApprove = false)
{ {
public static ApprovalRow? TryParse(JsonElement item) public static ApprovalRow? TryParse(JsonElement item)
{ {
// TicketId — Lookup на заявку; без него строку не с чем связать. // TicketId — Lookup на заявку; без него строку не с чем связать.
if (!TryGetInt(item, "TicketId", out var ticketId)) return null; if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
? GetString(a, "EMail") ? a
: null; : (JsonElement?)null;
return new ApprovalRow( return new ApprovalRow(
TryGetInt(item, "Id", out var id) ? id : 0, TryGetInt(item, "Id", out var id) ? id : 0,
@@ -35,9 +47,17 @@ internal sealed record ApprovalRow(
// трактуем как «Согласование» (так же поступает request-portal). // трактуем как «Согласование» (так же поступает request-portal).
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval, GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
TryGetDate(item, "Date"), TryGetDate(item, "Date"),
approver, 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, TryGetInt(item, "SLAhours", out var sla) ? sla : null,
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : 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) => private static string? GetString(JsonElement e, string name) =>
@@ -77,6 +97,18 @@ internal sealed record ApprovalRow(
return true; 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) private static bool TryGetBool(JsonElement e, string name, out bool value)
{ {
value = false; value = false;
@@ -106,8 +138,34 @@ internal static class ApprovalPhases
public const string Execution = "Исполнение"; public const string Execution = "Исполнение";
} }
/// <summary>
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
/// Один в один с REQUEST_STATUS в request-portal
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
/// </summary>
internal static class ApprovalStatuses internal static class ApprovalStatuses
{ {
public const string Queued = "Ждёт очереди";
public const string OnApproval = "На согласовании"; public const string OnApproval = "На согласовании";
public const string OnExecution = "На исполнении"; 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,20 @@
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
/// <summary>
/// Заявка, поданная по тому же основанию (СТ, потребность). Номер, статус и сумму
/// видят все: от них зависит проверка «сумма оплат не больше основания». Ссылку
/// на саму заявку фронтенд показывает только при CanOpen.
/// </summary>
/// <param name="Id">Id элемента списка данных.</param>
/// <param name="ListId">Серверный относительный URL списка партиции, где лежит заявка.</param>
/// <param name="UniqueCode">RequestUniqueCode; null — номер ещё не присвоен.</param>
/// <param name="Status">RequestStatus заявки.</param>
/// <param name="Amount">Сумма заявки (PayAmount).</param>
/// <param name="CanOpen">У вызывающего есть право просмотра элемента.</param>
public sealed record BasisRequestDto(
int Id,
string ListId,
string? UniqueCode,
string Status,
double Amount,
bool CanOpen);
@@ -0,0 +1,116 @@
using System.Globalization;
using System.Text.Json;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
/// <summary>
/// Заявки, поданные по одному основанию (колонка BasisKey: «ct:&lt;Id&gt;»,
/// «need:&lt;oid&gt;») — для таблицы прежних оплат в «Заявке на оплату».
///
/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и
/// под своей учёткой заявитель видел бы только собственные заявки — сумма
/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы
/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё
/// есть права: CanOpen проверяется по каждому элементу для вызывающего.
/// </summary>
public sealed class BasisRequestsService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
ICurrentUser currentUser)
{
private const string BasisKeyField = "BasisKey";
private const string AmountField = "PayAmount";
// Больше по одному основанию не бывает: СТ оплачивают частями, но не сотнями.
private const int MaxItemsPerPartition = 500;
// Проверка прав — отдельный запрос на элемент; ограничиваем параллельность,
// чтобы не упереться в троттлинг SharePoint (429).
private const int MaxParallelPermissionChecks = 4;
public async Task<Result<IReadOnlyList<BasisRequestDto>>> GetByBasisAsync(
int requestTypeId, string basisKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(basisKey))
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new ValidationError("Не указано основание (basisKey)."));
var requestType = await db.RequestTypes.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new NotFoundError("Тип заявки", requestTypeId));
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
return Result.Ok<IReadOnlyList<BasisRequestDto>>([]);
var siteUrl = requestType.SiteUrl;
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
var listIds = archived.Select(x => x.ListId).Append(current.ListId).ToList();
// Кавычка в OData-литерале удваивается; сам литерал кодируем — в ключе бывают «:» и прочее.
var literal = Uri.EscapeDataString(basisKey.Trim().Replace("'", "''"));
var query = $"$select=Id,RequestUniqueCode,RequestStatus,{AmountField}" +
$"&$filter={BasisKeyField} eq '{literal}'&$orderby=Id&$top={MaxItemsPerPartition}";
var found = new List<(JsonElement Item, string ListId)>();
foreach (var listId in listIds)
{
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listId, query, cancellationToken);
found.AddRange(items.Select(i => (i, listId)));
}
var email = currentUser.Email;
using var gate = new SemaphoreSlim(MaxParallelPermissionChecks);
var result = await Task.WhenAll(found.Select(async x =>
{
var id = GetInt(x.Item, "Id") ?? 0;
var canOpen = false;
if (!string.IsNullOrWhiteSpace(email) && id > 0)
{
await gate.WaitAsync(cancellationToken);
try
{
canOpen = await sharePoint.CanUserViewItemAsync(siteUrl, x.ListId, id, email, cancellationToken);
}
finally
{
gate.Release();
}
}
return new BasisRequestDto(
id,
x.ListId,
GetString(x.Item, "RequestUniqueCode"),
GetString(x.Item, "RequestStatus") ?? string.Empty,
GetDouble(x.Item, AmountField) ?? 0,
canOpen);
}));
return Result.Ok<IReadOnlyList<BasisRequestDto>>(result);
}
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) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : 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(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
return parsed;
return null;
}
}
@@ -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()
};
}
@@ -78,7 +78,12 @@ public sealed class ListPartitionService(
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного /// адресов один раз, а не по одному запросу на тип. Типы без настроенного
/// списка SharePoint в ответ не попадают — искать там всё равно нечего. /// списка SharePoint в ответ не попадают — искать там всё равно нечего.
/// </summary> /// </summary>
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(CancellationToken cancellationToken) /// <param name="title">
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
/// без учёта регистра, по вхождению подстроки. null — фильтр не применяется.
/// </param>
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(
string? title, CancellationToken cancellationToken)
{ {
var requestTypes = await db.RequestTypes var requestTypes = await db.RequestTypes
.Where(x => x.ListId != null && x.SiteUrl != null) .Where(x => x.ListId != null && x.SiteUrl != null)
@@ -88,6 +93,14 @@ public sealed class ListPartitionService(
foreach (var requestType in requestTypes) foreach (var requestType in requestTypes)
{ {
var partitions = await BuildSeriesAsync(requestType, cancellationToken); 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)); result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
} }
@@ -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);
@@ -1,27 +1,32 @@
using System.Text.Json.Serialization;
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param> /// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
/// <param name="HoursOverdue"> /// <param name="ReceiverName">
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). /// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
/// Выходные не учитываются — считаются календарные часы. /// в старой строке списка без заполненного Title персонального поля) — тогда null.
/// </param> /// </param>
/// <param name="Url">Ссылка на заявку, требующую решения.</param> /// <param name="Url">
/// <param name="AskOverdueReason"> /// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым /// потому что не константна: requestId и партиция списка (server-relative url,
/// наследованием (шаг перекрывает тип заявки). /// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
/// дублировать эту логику снаружи незачем.
/// </param> /// </param>
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param> /// <remarks>
/// <param name="RequestUniqueCode"> /// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у /// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
/// заявки его нет или строка данных не найдена. /// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
/// </param> /// (Power Automate ссылается на неё, работая со списком согласования конкретного
public sealed record ReminderDto( /// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
string Email, ///
int HoursOverdue, /// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
string Url, /// список именно базового типа (IReadOnlyList&lt;ReminderDto&gt;) — без этих
bool AskOverdueReason, /// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
int RequestTypeId, /// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
string RequestTypeName, /// со значением "ApprovalOverdue"/"ProbationEnding".
int RequestId, /// </remarks>
string Status, [JsonPolymorphic]
string? RequestUniqueCode); [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,
}
@@ -3,10 +3,12 @@ using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint; using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Reminders;
@@ -22,6 +24,8 @@ public sealed class ReminderService(
ApprovalRouteDbContext db, ApprovalRouteDbContext db,
ISharePointClient sharePoint, ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry, ListPartitionRegistry partitionRegistry,
ITnsuPortalClient portalClient,
TimeProvider timeProvider,
IOptions<RemindersOptions> options, IOptions<RemindersOptions> options,
ILogger<ReminderService> logger) ILogger<ReminderService> logger)
{ {
@@ -30,6 +34,32 @@ public sealed class ReminderService(
private const string ActiveStatusFilter = private const string ActiveStatusFilter =
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; $"$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( public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
DateTimeOffset now, CancellationToken cancellationToken) DateTimeOffset now, CancellationToken cancellationToken)
{ {
@@ -134,7 +164,7 @@ public sealed class ReminderService(
return []; return [];
} }
var approvalListTitle = ApprovalListTitleFor(dataListTitle); var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null) if (approvalListTitle is null)
{ {
logger.LogWarning( logger.LogWarning(
@@ -176,9 +206,17 @@ public sealed class ReminderService(
// ДО появления колонки (см. ApprovalRow) или если request-portal // ДО появления колонки (см. ApprovalRow) или если request-portal
// почему-то не проставил её при активации. Без даты старта SLA не // почему-то не проставил её при активации. Без даты старта SLA не
// от чего отсчитывать — пропускаем, а не подставляем сейчас. // от чего отсчитывать — пропускаем, а не подставляем сейчас.
//
// RequestTypeId — всегда, чтобы искать TicketId в правильном типе
// заявок. RequestUniqueCode — то, по чему заявку реально ищут в
// портале, добавляется, только если он уже есть (строка данных
// могла ещё не появиться или заявка старше появления колонки).
var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code
? $"requestTypeId={type.Id}, code={code}"
: $"requestTypeId={type.Id}";
logger.LogWarning( logger.LogWarning(
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена", "Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена",
row.Id, row.TicketId); row.Id, row.TicketId, identifier);
continue; continue;
} }
@@ -188,16 +226,17 @@ public sealed class ReminderService(
var deadline = activatedAt.AddHours(slaHours); var deadline = activatedAt.AddHours(slaHours);
if (deadline >= now) continue; if (deadline >= now) continue;
reminders.Add(new ReminderDto( reminders.Add(new ApprovalOverdueReminderDto(
row.ApproverEmail!, row.ApproverEmail!,
(int)Math.Floor((now - deadline).TotalHours), (int)Math.Floor((now - deadline).TotalHours),
BuildUrl(type, row, listId),
row.AskOverdueReason ?? type.AskOverdueReason ?? false, row.AskOverdueReason ?? type.AskOverdueReason ?? false,
type.Id, type.Id,
type.Name, type.Name,
row.TicketId, row.TicketId,
row.Status, row.Status,
uniqueCodes.GetValueOrDefault(row.TicketId))); uniqueCodes.GetValueOrDefault(row.TicketId),
BuildUrl(type, row, listId),
row.ApproverName));
} }
return reminders; return reminders;
@@ -220,21 +259,9 @@ public sealed class ReminderService(
.Replace("{listId}", Uri.EscapeDataString(listId)) .Replace("{listId}", Uri.EscapeDataString(listId))
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals"); .Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
/// <summary>«Заявка на приём — данные» → «Заявка на приём — согласование».</summary>
private 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;
}
private const string RowSelect = private const string RowSelect =
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," + "$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000"; "TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync( private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken) string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
@@ -273,4 +300,190 @@ public sealed class ReminderService(
return result; 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));
}
} }
@@ -24,4 +24,35 @@ public sealed class RemindersOptions
/// </summary> /// </summary>
public string RequestUrlTemplate { get; set; } = public string RequestUrlTemplate { get; set; } =
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}"; "{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>
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
/// конвенции «&lt;Форма&gt; &lt;N&gt; — данные|согласование»: 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);
@@ -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();
}
}
@@ -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.");
}
}
@@ -2,6 +2,9 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
// AutoApprove — «согласовать шаг автоматически по истечении SLA». Обычный bool,
// а не три состояния: наследовать его неоткуда (см. TemplateRouteStep.AutoApprove).
//
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки. // SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны // null — «не задано, наследовать», поэтому оба поля nullable и необязательны
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец // (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
@@ -24,7 +27,8 @@ public sealed record TemplateRouteStepDto(
ConditionsLogic ConditionsLogic, ConditionsLogic ConditionsLogic,
string? Warning = null, string? Warning = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool AutoApprove = false);
public sealed record CreateTemplateRouteStepRequest( public sealed record CreateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
@@ -35,9 +39,10 @@ public sealed record CreateTemplateRouteStepRequest(
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic = ConditionsLogic.And, ConditionsLogic ConditionsLogic,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool AutoApprove = false);
public sealed record UpdateTemplateRouteStepRequest( public sealed record UpdateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
@@ -48,9 +53,10 @@ public sealed record UpdateTemplateRouteStepRequest(
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic = ConditionsLogic.And, ConditionsLogic ConditionsLogic,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool AutoApprove = false);
/// <summary> /// <summary>
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить /// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
@@ -68,4 +74,5 @@ public sealed record PatchTemplateRouteStepRequest(
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic? ConditionsLogic = null, ConditionsLogic? ConditionsLogic = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool? AutoApprove = null);
@@ -1,6 +1,7 @@
using FluentResults; using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
@@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
var entity = new TemplateRouteStep var entity = new TemplateRouteStep
{ {
@@ -97,7 +98,8 @@ public sealed class TemplateRouteStepService(
ConditionsLogic = r.ConditionsLogic, ConditionsLogic = r.ConditionsLogic,
Conditions = ToEntities(r.Conditions), Conditions = ToEntities(r.Conditions),
SlaHours = r.SlaHours, SlaHours = r.SlaHours,
AskOverdueReason = r.AskOverdueReason AskOverdueReason = r.AskOverdueReason,
AutoApprove = r.AutoApprove
}; };
db.TemplateRouteSteps.Add(entity); db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -140,7 +142,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo; entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail; entity.ExecutorEmail = r.ExecutorEmail;
@@ -153,6 +155,7 @@ public sealed class TemplateRouteStepService(
ReplaceConditions(entity, r.Conditions); ReplaceConditions(entity, r.Conditions);
entity.SlaHours = r.SlaHours; entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason; entity.AskOverdueReason = r.AskOverdueReason;
entity.AutoApprove = r.AutoApprove;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
logger.LogInformation( logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
@@ -208,7 +211,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo; entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail; entity.ExecutorEmail = executorEmail;
@@ -224,6 +227,7 @@ public sealed class TemplateRouteStepService(
// наследованию SLA от типа заявки можно через PUT. // наследованию SLA от типа заявки можно через PUT.
entity.SlaHours = r.SlaHours ?? entity.SlaHours; entity.SlaHours = r.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason; entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
logger.LogInformation( logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
@@ -366,5 +370,5 @@ public sealed class TemplateRouteStepService(
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( 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, 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); ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason, e.AutoApprove);
} }
@@ -8,6 +8,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
public CreateTemplateRouteStepRequestValidator() public CreateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x.ConditionsLogic)
.NotEqual(ConditionsLogic.None)
.WithMessage("Логика объединения условий обязательна: And или Or.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -28,6 +31,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
public UpdateTemplateRouteStepRequestValidator() public UpdateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x.ConditionsLogic)
.NotEqual(ConditionsLogic.None)
.WithMessage("Логика объединения условий обязательна: And или Or.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -43,6 +49,35 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
} }
} }
/// <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 internal static class TemplateRouteStepFields
{ {
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) => public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
@@ -20,6 +20,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить, /// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом. /// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
/// </param> /// </param>
/// <param name="AutoApprove">
/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть —
/// наследовать его неоткуда (у типа заявки такого поля нет, см.
/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто
/// переносит его в строку списка согласования: решает по нему не она, а фоновая
/// задача бэкенда (AutoApproveService).
/// </param>
public sealed record ResolvedRouteStepDto( public sealed record ResolvedRouteStepDto(
int Id, int Id,
int OrderNo, int OrderNo,
@@ -29,7 +36,8 @@ public sealed record ResolvedRouteStepDto(
string? Warning = null, string? Warning = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null, bool? AskOverdueReason = null,
HeadType? PositionCode = null); HeadType? PositionCode = null,
bool AutoApprove = false);
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
int RouteId, int RouteId,
@@ -3,6 +3,7 @@ using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
@@ -221,39 +222,54 @@ public sealed class TemplateRouteService(
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
cancellationToken))); cancellationToken)));
var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList()); var flattenedSteps = stepResults.SelectMany(x => x).ToList();
var collapsedSteps = CollapseByExecutorEmail(flattenedSteps); var withoutDirectorTansuDuplicate = RemoveCompanyDirectorsDuplicatedByDirectorTansu(flattenedSteps);
var collapsedSteps = CollapseByExecutorIdentity(withoutDirectorTansuDuplicate);
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); 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> /// <summary>
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из /// Construction. Оба шага в маршруте законны сами по себе, но если так, из
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом /// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail): /// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity):
/// тот считает разные PositionCode у одного email конфликтом и пишет /// тот считает разные PositionCode у одного исполнителя конфликтом и пишет
/// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто /// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
/// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним /// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
/// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors /// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе
/// оставляет его на своём месте — в конце. /// email), а не по email напрямую — один человек может фигурировать под
/// разными email в разных ветках резолва.
/// </summary> /// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu( private static IReadOnlyList<ResolvedStepRow> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
IReadOnlyList<ResolvedRouteStepDto> steps) IReadOnlyList<ResolvedStepRow> steps)
{ {
var directorTansuEmails = steps var directorTansuKeys = steps
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail)) .Where(s => s.Dto.PositionCode == HeadType.DirectorTansu)
.Select(s => s.ExecutorEmail!) .Select(IdentityKeyOf)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .Where(k => k is not null)
.Select(k => k!)
.ToHashSet();
if (directorTansuEmails.Count == 0) if (directorTansuKeys.Count == 0)
return steps; return steps;
return steps return steps
.Where(s => s.PositionCode != HeadType.CompanyDirectors .Where(s => s.Dto.PositionCode != HeadType.CompanyDirectors || !directorTansuKeys.Contains(IdentityKeyOf(s) ?? ""))
|| string.IsNullOrWhiteSpace(s.ExecutorEmail)
|| !directorTansuEmails.Contains(s.ExecutorEmail))
.ToList(); .ToList();
} }
@@ -336,7 +352,7 @@ public sealed class TemplateRouteService(
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param> /// </param>
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync( private async Task<IReadOnlyList<ResolvedStepRow>> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies, TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType, IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
string? conditionWarning, string? conditionWarning,
@@ -346,19 +362,22 @@ public sealed class TemplateRouteService(
// работает именно так, как задумано — значение шага перекрывает типовое, // работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым // и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours). // (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning) async Task<ResolvedStepRow> BuildDtoAsync(string? resolvedEmail, string? warning)
{ {
string? resolvedPosition = null; string? resolvedPosition = null;
Guid? resolvedId = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail)) if (!string.IsNullOrWhiteSpace(resolvedEmail))
{ {
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken); var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position; var resolvedEmployee = resolvedEmployees.FirstOrDefault();
resolvedPosition = resolvedEmployee?.Position;
resolvedId = resolvedEmployee?.Id;
} }
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning } var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w))); .Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto( var dto = new ResolvedRouteStepDto(
step.Id, step.Id,
step.OrderNo, step.OrderNo,
resolvedEmail, resolvedEmail,
@@ -367,7 +386,10 @@ public sealed class TemplateRouteService(
combinedWarning.Length == 0 ? null : combinedWarning, combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours, step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason, step.AskOverdueReason ?? requestType.AskOverdueReason,
step.HeadType is null or HeadType.None ? null : step.HeadType); step.HeadType is null or HeadType.None ? null : step.HeadType,
step.AutoApprove);
return new ResolvedStepRow(dto, resolvedId);
} }
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк // CompanyDirectors — единственная ветка, которая резолвится в несколько строк
@@ -378,7 +400,7 @@ public sealed class TemplateRouteService(
if (initiatorCompanies.Count == 0) if (initiatorCompanies.Count == 0)
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")]; return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
var dtos = new List<ResolvedRouteStepDto>(); var dtos = new List<ResolvedStepRow>();
foreach (var company in initiatorCompanies) foreach (var company in initiatorCompanies)
{ {
var directorPosition = DirectorPositionFor(company); var directorPosition = DirectorPositionFor(company);
@@ -416,7 +438,7 @@ public sealed class TemplateRouteService(
{ {
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
if (string.IsNullOrWhiteSpace(resolvedEmail)) if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}»."; warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
} }
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке // DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
// подчинённости инициатора, а по отделу, переданному явно в запросе (см. // подчинённости инициатора, а по отделу, переданному явно в запросе (см.
@@ -448,7 +470,14 @@ public sealed class TemplateRouteService(
_ => null _ => null
}; };
if (string.IsNullOrWhiteSpace(resolvedEmail)) if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден руководитель типа «{step.HeadType}»."; {
var headLabel = StepLabels.InSentence(step.HeadType.Value);
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
// справочника сотрудников — там email не «не найден», а «не проставлен».
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
? $"Не проставлен {headLabel}."
: $"Не найден {headLabel}.";
}
} }
else else
{ {
@@ -552,33 +581,38 @@ public sealed class TemplateRouteService(
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode /// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки /// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он /// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две /// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) — /// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение /// это не должно молча выбрать одну из них без следа: добавляем предупреждение
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности /// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
/// хуже, чем показать чуть неточный PositionCode с пояснением. /// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по
/// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же
/// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними
/// дедуп по email эту пару не поймал бы.
/// </summary> /// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps) private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorIdentity(IReadOnlyList<ResolvedStepRow> steps)
{ {
var indexByEmail = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var indexByKey = new Dictionary<string, int>();
var result = new List<ResolvedRouteStepDto>(); var result = new List<ResolvedRouteStepDto>();
foreach (var step in steps) foreach (var row in steps)
{ {
if (string.IsNullOrWhiteSpace(step.ExecutorEmail)) var key = IdentityKeyOf(row);
if (key is null)
{ {
result.Add(step); result.Add(row.Dto);
continue; continue;
} }
if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index)) if (!indexByKey.TryGetValue(key, out var index))
{ {
indexByEmail[step.ExecutorEmail] = result.Count; indexByKey[key] = result.Count;
result.Add(step); result.Add(row.Dto);
continue; continue;
} }
var existing = result[index]; var existing = result[index];
var step = row.Dto;
if (existing.PositionCode is null && step.PositionCode is not null) if (existing.PositionCode is null && step.PositionCode is not null)
{ {
result[index] = existing with { PositionCode = step.PositionCode }; result[index] = existing with { PositionCode = step.PositionCode };
@@ -586,7 +620,7 @@ public sealed class TemplateRouteService(
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode) else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
{ {
var conflictWarning = var conflictWarning =
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}."; $"Исполнитель «{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 } var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
.Where(w => !string.IsNullOrWhiteSpace(w))); .Where(w => !string.IsNullOrWhiteSpace(w)));
result[index] = existing with { Warning = combinedWarning }; result[index] = existing with { Warning = combinedWarning };
@@ -598,4 +632,11 @@ public sealed class TemplateRouteService(
private static TemplateRouteDto ToDto(TemplateRoute e) => new( private static TemplateRouteDto ToDto(TemplateRoute e) => new(
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); 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);
} }
@@ -4,6 +4,8 @@
<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" />
@@ -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;
}
@@ -31,6 +31,20 @@ public class TemplateRouteStep
/// </summary> /// </summary>
public bool? AskOverdueReason { get; set; } 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> /// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And; public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
@@ -6,9 +6,21 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
/// </summary> /// </summary>
public enum ConditionsLogic public enum ConditionsLogic
{ {
/// <summary>
/// Логика не задана — НЕвалидное значение (см. валидаторы шага), а не «как И».
/// Существует только как значение по умолчанию (default(ConditionsLogic)):
/// шаг с такой логикой не должен ни создаваться, ни сохраняться.
///
/// Именно поэтому у самой сущности и у запросов на создание/обновление
/// значение по умолчанию задано явно — <c>ConditionsLogic.And</c>
/// (см. TemplateRouteStep.ConditionsLogic и *TemplateRouteStepRequest):
/// пропущенное в запросе поле по-прежнему означает «И», как и раньше.
/// </summary>
None = 0,
/// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary> /// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary>
And, And = 1,
/// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary> /// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary>
Or Or = 2
} }
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options) public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
{ {
public DbSet<RequestType> RequestTypes => Set<RequestType>(); public DbSet<RequestType> RequestTypes => Set<RequestType>();
public DbSet<RequestTypeAccess> RequestTypeAccesses => Set<RequestTypeAccess>();
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>(); public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>(); public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>(); public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
@@ -35,6 +36,15 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.Section).HasMaxLength(256).IsRequired(); e.Property(x => x.Section).HasMaxLength(256).IsRequired();
}); });
modelBuilder.Entity<RequestTypeAccess>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Email).HasMaxLength(256).IsRequired();
e.HasOne(x => x.RequestType).WithMany().HasForeignKey(x => x.RequestTypeId);
// Одна и та же пара тип+email не должна выдаваться дважды.
e.HasIndex(x => new { x.RequestTypeId, x.Email }).IsUnique();
});
modelBuilder.Entity<RequestListPartition>(e => modelBuilder.Entity<RequestListPartition>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -0,0 +1,370 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260911130101_AddStepAutoApprove")]
partial class AddStepAutoApprove
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ListId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<int>("Number")
.HasColumnType("integer")
.HasColumnName("number");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ListId")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.HasKey("Id")
.HasName("pk_request_types");
b.ToTable("request_types", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_step_conditions");
b.HasIndex("StepId", "OrderNo")
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
b.ToTable("template_route_step_conditions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{
b.Property<string>("OptionName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("option_name");
b.Property<byte[]>("FileData")
.HasColumnType("bytea")
.HasColumnName("file_data");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("OptionName")
.HasName("pk_settings");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
.WithMany("Conditions")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
b.Navigation("Step");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("ListPartitions");
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStepAutoApprove : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "auto_approve",
table: "template_route_steps",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "auto_approve",
table: "template_route_steps");
}
}
}
@@ -0,0 +1,411 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260925092352_AddRequestTypeAccess")]
partial class AddRequestTypeAccess
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ListId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<int>("Number")
.HasColumnType("integer")
.HasColumnName("number");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ListId")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.HasKey("Id")
.HasName("pk_request_types");
b.ToTable("request_types", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("email");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.HasKey("Id")
.HasName("pk_request_type_accesses");
b.HasIndex("RequestTypeId", "Email")
.IsUnique()
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
b.ToTable("request_type_accesses", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_step_conditions");
b.HasIndex("StepId", "OrderNo")
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
b.ToTable("template_route_step_conditions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{
b.Property<string>("OptionName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("option_name");
b.Property<byte[]>("FileData")
.HasColumnType("bytea")
.HasColumnName("file_data");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("OptionName")
.HasName("pk_settings");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany()
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
.WithMany("Conditions")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
b.Navigation("Step");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("ListPartitions");
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRequestTypeAccess : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "request_type_accesses",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
request_type_id = table.Column<int>(type: "integer", nullable: false),
email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_request_type_accesses", x => x.id);
table.ForeignKey(
name: "fk_request_type_accesses_request_types_request_type_id",
column: x => x.request_type_id,
principalTable: "request_types",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_request_type_accesses_request_type_id_email",
table: "request_type_accesses",
columns: new[] { "request_type_id", "email" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "request_type_accesses");
}
}
}
@@ -119,6 +119,35 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("request_types", (string)null); b.ToTable("request_types", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("email");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.HasKey("Id")
.HasName("pk_request_type_accesses");
b.HasIndex("RequestTypeId", "Email")
.IsUnique()
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
b.ToTable("request_type_accesses", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -175,6 +204,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("ask_overdue_reason"); .HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic") b.Property<string>("ConditionsLogic")
.IsRequired() .IsRequired()
.HasMaxLength(16) .HasMaxLength(16)
@@ -305,6 +338,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.Navigation("RequestType"); b.Navigation("RequestType");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany()
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
@@ -53,6 +53,35 @@ public interface ISharePointClient
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary> /// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync( Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
/// <summary>
/// Может ли пользователь (по email) открыть элемент списка: эффективные права
/// человека на элемент с учётом разорванного наследования, флаг ViewListItems.
/// Неизвестный SharePoint логин — не ошибка, а «прав нет».
/// </summary>
Task<bool> CanUserViewItemAsync(
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken);
/// <summary>
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
/// элементы, а не только доступные пользователю. Возвращает строки результата
/// как «имя managed property → значение» (RowLimit — не больше 500).
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
/// </summary>
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken);
/// <summary>
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
/// перезаписываются, остальные остаются как были.
/// </summary>
Task UpdateItemByListTitleAsync(
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken);
/// <summary>То же по серверному относительному url списка (см. RequestType.ListId).</summary>
Task UpdateItemByListUrlAsync(
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken);
} }
/// <summary> /// <summary>
@@ -208,6 +237,105 @@ public sealed class SharePointClient(
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
cancellationToken); cancellationToken);
public async Task<bool> CanUserViewItemAsync(
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken)
{
// Claims-логин Entra ID в SharePoint Online. Передаём через параметр-псевдоним
// @u: в пути функции «|» и «#» SharePoint не разбирает.
var login = Uri.EscapeDataString($"i:0#.f|membership|{userEmail.Trim().ToLowerInvariant()}");
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})" +
$"/getUserEffectivePermissions(@u)?@u='{login}'";
var json = await GetAsync(siteUrl, url, cancellationToken);
// BasePermissions приходит как {High, Low} — числа строками. ViewListItems — бит 0x1 в Low.
return json.TryGetProperty("Low", out var low)
&& ulong.TryParse(low.ValueKind == JsonValueKind.String ? low.GetString() : low.GetRawText(), out var bits)
&& (bits & ViewListItemsMask) != 0;
}
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
{
var body = JsonSerializer.Serialize(new
{
request = new
{
Querytext = queryText,
RowLimit = 500,
SelectProperties = selectProperties,
TrimDuplicates = false
}
});
var json = await SendAsync(
HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body,
allowNotFound: false, cancellationToken);
var result = new List<IReadOnlyDictionary<string, string?>>();
if (json is null
|| !json.Value.TryGetProperty("PrimaryQueryResult", out var primary)
|| !primary.TryGetProperty("RelevantResults", out var relevant)
|| !relevant.TryGetProperty("Table", out var table)
|| !table.TryGetProperty("Rows", out var rows))
return result;
// nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба.
if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped))
rows = wrapped;
if (rows.ValueKind != JsonValueKind.Array) return result;
foreach (var row in rows.EnumerateArray())
{
if (!row.TryGetProperty("Cells", out var cells)) continue;
if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells))
cells = wrappedCells;
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var cell in cells.EnumerateArray())
{
var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null;
if (key is null) continue;
dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}
result.Add(dict);
}
return result;
}
public Task UpdateItemByListTitleAsync(
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
MergeAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items({itemId})",
fields,
cancellationToken);
public Task UpdateItemByListUrlAsync(
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken) =>
MergeAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})",
fields,
cancellationToken);
/// <summary>
/// Частичное обновление элемента: у SharePoint REST это POST с заголовком
/// X-HTTP-Method: MERGE (PATCH он не понимает), и обязательным If-Match.
///
/// If-Match: * — намеренно «перезаписать, чей бы ни был последний етаг», а не
/// оптимистичная блокировка по конкретной версии. Пишем мы точечно, по
/// отдельным колонкам, а единственный конкурент — человек, принявший решение
/// по тому же шагу вручную; его защищает не етаг, а проверка статуса строки
/// перед записью (см. AutoApproveService). Реальный етаг пришлось бы читать
/// отдельным запросом, и он всё равно устаревал бы к моменту записи.
/// </summary>
private Task MergeAsync(string siteUrl, string url, object fields, CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post, siteUrl, url, JsonSerializer.Serialize(fields),
allowNotFound: false, cancellationToken, merge: true);
private async Task<IReadOnlyList<JsonElement>> GetItemsAsync( private async Task<IReadOnlyList<JsonElement>> GetItemsAsync(
string siteUrl, string url, CancellationToken cancellationToken) string siteUrl, string url, CancellationToken cancellationToken)
{ {
@@ -240,9 +368,16 @@ public sealed class SharePointClient(
string url, string url,
string? body, string? body,
bool allowNotFound, bool allowNotFound,
CancellationToken cancellationToken) CancellationToken cancellationToken,
bool merge = false)
{ {
using var request = new HttpRequestMessage(method, url); using var request = new HttpRequestMessage(method, url);
if (merge)
{
request.Headers.Add("X-HTTP-Method", "MERGE");
request.Headers.Add("If-Match", "*");
}
request.Headers.Authorization = new AuthenticationHeaderValue( request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", await GetTokenAsync(siteUrl, cancellationToken)); "Bearer", await GetTokenAsync(siteUrl, cancellationToken));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
@@ -315,6 +450,8 @@ public sealed class SharePointClient(
} }
} }
private const ulong ViewListItemsMask = 0x1;
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки // Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
// списков содержат тире и кириллицу, но кавычка теоретически возможна — // списков содержат тире и кириллицу, но кавычка теоретически возможна —
// без экранирования она сломала бы запрос. // без экранирования она сломала бы запрос.
@@ -27,11 +27,27 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}", $"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken)); () => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
public Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken));
public Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken));
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
GetOrAddAsync( GetOrAddAsync(
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
() => inner.GetManagerAsync(email, managerType, cancellationToken)); () => inner.GetManagerAsync(email, managerType, cancellationToken));
// Базы целиком (/Employee/All выше и /Employee/FullBase ниже) кешируем по
// фиксированному ключу — это не запросы «по чему-то», а единственные на весь
// процесс снимки справочника; TTL тот же (5 минут), что и у остальных
// обращений к api.tnsu.kz.
public Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("emp:fullbase", () => inner.GetFullBaseAsync(cancellationToken));
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) => public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
GetOrAddAsync( GetOrAddAsync(
$"projexec:{projectId}", $"projexec:{projectId}",
@@ -20,7 +20,7 @@ public sealed class HttpTnsuPortalClient(
return []; return [];
return employees return employees
.Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position)) .Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position, e.Id))
.ToList(); .ToList();
} }
@@ -32,6 +32,38 @@ public sealed class HttpTnsuPortalClient(
return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio); return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio);
} }
public async Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken)
{
var employees = await GetAsync<List<EmployeeResponse>>("/Employee/All", cancellationToken);
if (employees is null)
return [];
return employees
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
.Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty, e.Position))
.ToList();
}
// /Dictionary/* отдаёт «голый» массив с ключами в PascalCase (Id/Name) —
// PropertyNameCaseInsensitive в JsonOptions это покрывает.
public async Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken)
{
var companies = await GetAsync<List<DictionaryItemResponse>>("/Dictionary/Companies", cancellationToken);
return (companies ?? [])
.Where(c => c.Id is not null && !string.IsNullOrWhiteSpace(c.Name))
.Select(c => new CompanyInfo(c.Id!.Value, c.Name!))
.ToList();
}
public async Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken)
{
var departments = await GetAsync<List<DictionaryItemResponse>>("/Dictionary/Departments", cancellationToken);
return (departments ?? [])
.Where(d => d.Id is not null && !string.IsNullOrWhiteSpace(d.Name))
.Select(d => new DepartmentInfo(d.Id!.Value, d.Name!))
.ToList();
}
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
{ {
var manager = await GetAsync<ManagerResponse>( var manager = await GetAsync<ManagerResponse>(
@@ -40,6 +72,18 @@ public sealed class HttpTnsuPortalClient(
return manager?.ManagerEmail; return manager?.ManagerEmail;
} }
public async Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken)
{
var employees = await GetAsync<List<FullBaseResponse>>("/Employee/FullBase", cancellationToken);
if (employees is null)
return [];
return employees
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
.Select(e => new EmployeeFullBaseInfo(e.Id, e.Fio!, e.Company ?? string.Empty, e.DismissalDate))
.ToList();
}
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
{ {
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken); var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
@@ -94,11 +138,22 @@ public sealed class HttpTnsuPortalClient(
[property: JsonPropertyName("companyId")] Guid? CompanyId, [property: JsonPropertyName("companyId")] Guid? CompanyId,
[property: JsonPropertyName("departmentId")] Guid? DepartmentId, [property: JsonPropertyName("departmentId")] Guid? DepartmentId,
[property: JsonPropertyName("position")] string? Position, [property: JsonPropertyName("position")] string? Position,
[property: JsonPropertyName("fio")] string? Fio); [property: JsonPropertyName("fio")] string? Fio,
[property: JsonPropertyName("id")] Guid? Id = null);
private sealed record DictionaryItemResponse(
[property: JsonPropertyName("id")] Guid? Id,
[property: JsonPropertyName("name")] string? Name);
private sealed record ManagerResponse( private sealed record ManagerResponse(
[property: JsonPropertyName("managerEmail")] string? ManagerEmail); [property: JsonPropertyName("managerEmail")] string? ManagerEmail);
private sealed record FullBaseResponse(
[property: JsonPropertyName("id")] Guid? Id,
[property: JsonPropertyName("fio")] string? Fio,
[property: JsonPropertyName("company")] string? Company,
[property: JsonPropertyName("dismissalDate")] DateTimeOffset? DismissalDate);
private sealed record ProjectResponse( private sealed record ProjectResponse(
[property: JsonPropertyName("projectManager")] string? ProjectManager, [property: JsonPropertyName("projectManager")] string? ProjectManager,
[property: JsonPropertyName("planner")] string? Planner, [property: JsonPropertyName("planner")] string? Planner,
@@ -11,8 +11,30 @@ public interface ITnsuPortalClient
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
/// <summary>
/// Весь справочник сотрудников с email — /Employee/All. Нужен там, где известно
/// только ФИО, а email ещё нет: точечного поиска по ФИО в api.tnsu.kz нет вовсе
/// (эндпоинта /Employee/ByFio не существует), поэтому приходится тянуть базу
/// целиком и искать в ней локально. ВРЕМЕННОЕ решение — как только на стороне
/// api.tnsu.kz появится поиск по ФИО, этот вызов заменится точечным.
/// </summary>
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
/// <summary>Справочник компаний — /Dictionary/Companies.</summary>
Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken);
/// <summary>Справочник отделов — /Dictionary/Departments.</summary>
Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken);
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
/// <summary>
/// Весь справочник сотрудников — /Employee/FullBase. Единственный источник, где
/// есть дата увольнения (DismissalDate): нужен, чтобы не напоминать про
/// испытательный срок сотрудника, который уже уволен.
/// </summary>
Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken); Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary> /// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
@@ -2,9 +2,38 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position); /// <param name="Id">
/// Guid сотрудника в справочнике api.tnsu.kz — один и тот же человек может иметь
/// несколько записей (см. массив в /Employee/ByEmail) и даже несколько разных
/// email, но Id у него один. Используется как надёжный ключ дедупликации
/// исполнителей при резолве маршрута (см. TemplateRouteService) — email для
/// этого не годится: один человек может фигурировать под разными адресами.
/// Nullable, потому что старые вызовы могли не запросить это поле явно (сейчас
/// всегда приходит) — на этот случай дедуп по Id тихо откатывается на email.
/// </param>
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position, Guid? Id = null);
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
/// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary> /// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary>
public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio); public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio);
/// <summary>
/// Запись из /Employee/FullBase — весь справочник сотрудников, но БЕЗ email (в
/// отличие от /Employee/All). Нужна только ради DismissalDate: единственное место,
/// где отдаётся дата увольнения.
/// </summary>
public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, DateTimeOffset? DismissalDate);
/// <summary>
/// Запись из /Employee/All — весь справочник сотрудников с email, но БЕЗ даты
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
/// </summary>
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company, string? Position = null);
/// <summary>Компания из справочника /Dictionary/Companies.</summary>
public sealed record CompanyInfo(Guid Id, string Name);
/// <summary>Отдел из справочника /Dictionary/Departments.</summary>
public sealed record DepartmentInfo(Guid Id, string Name);
@@ -0,0 +1,353 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Автосогласование по SLA. SharePoint подменён моком, отдающим сырой JSON в том
/// же виде, что и REST, — как в ReminderServiceTests; записи (MERGE) проверяются
/// через Verify на моке, потому что именно записи здесь и есть результат работы
/// сервиса, а возвращает он только счётчик.
///
/// Все проверки идут через <see cref="Rows"/> — маленький «список в памяти»: мок
/// отдаёт из него и выборку кандидатов, и строки заявки, и применяет к нему
/// записи. Без этого тесты, где решение по одной строке двигает очередь, пришлось
/// бы писать на жёстко заданных последовательностях ответов, и они проверяли бы
/// порядок вызовов, а не поведение.
/// </summary>
public sealed class AutoApproveServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string DataListTitle = "Заявка авто — данные";
private const string ApprovalListTitle = "Заявка авто — согласование";
private static readonly DateTimeOffset Now = new(2026, 9, 11, 12, 0, 0, TimeSpan.Zero);
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService;
public AutoApproveServiceTests(PostgresDatabaseFixture fixture)
{
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
}
/// <summary>
/// База у тестов класса общая, а сервис обходит ВСЕ типы заявок — поэтому у
/// каждого теста свой список: типы соседних тестов на мок не настроены,
/// заголовок списка для них не найдётся, и в выборку они не попадут.
/// </summary>
private async Task<string> CreateTypeAsync(string name, int? slaHours = null)
{
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(name, $"/{name}", listUrl, SiteUrl, "General", slaHours),
CancellationToken.None);
return listUrl;
}
/// <summary>Строка списка согласования в том виде, в каком её отдаёт REST.</summary>
private sealed record Row(
int Id,
int TicketId,
string Status,
double Order,
string Phase = "Согласование",
string? Date = null,
int? SlaHours = null,
bool AutoApprove = false)
{
public JsonElement ToJson() => JsonSerializer.SerializeToElement(new
{
Id,
TicketId,
ApprovalStatus = Status,
Phase,
Date,
SLAhours = SlaHours,
AutoApprove,
StepOrder = Order,
TansuSiteApprover = new { EMail = "approver@tnsu.kz" }
});
}
/// <summary>Записанные сервисом поля: (id элемента, имя колонки) → значение.</summary>
private sealed class Writes
{
public List<(string List, int Id, Dictionary<string, object?> Fields)> Items { get; } = [];
public Dictionary<string, object?>? For(string list, int id) =>
Items.LastOrDefault(x => x.List == list && x.Id == id).Fields;
}
private (AutoApproveService Service, Writes Writes) BuildService(
string listUrl, List<Row> rows, DateTimeOffset? now = null)
{
var sharePoint = new Mock<ISharePointClient>();
var writes = new Writes();
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
// Кандидаты: активные строки с включённым автосогласованием — тот же отбор,
// что делает $filter на стороне SharePoint.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("AutoApprove eq 1")),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => rows
.Where(r => r.AutoApprove && r.Status is "На согласовании" or "На исполнении")
.Select(r => r.ToJson()).ToList());
// Строки одной заявки — $filter=TicketId eq N.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")),
It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string _, string query, CancellationToken _) =>
{
var ticketId = int.Parse(query.Split("TicketId eq ")[1].Split('&')[0].Trim());
return rows.Where(r => r.TicketId == ticketId).Select(r => r.ToJson()).ToList();
});
// Запись в строку маршрута применяется к самому списку rows — чтобы
// следующее чтение видело уже изменённое состояние, как в SharePoint.
sharePoint
.Setup(x => x.UpdateItemByListTitleAsync(
SiteUrl, ApprovalListTitle, It.IsAny<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Callback((string _, string list, int id, object fields, CancellationToken _) =>
{
var map = ToMap(fields);
writes.Items.Add((list, id, map));
var index = rows.FindIndex(r => r.Id == id);
if (index >= 0 && map.TryGetValue("ApprovalStatus", out var status))
rows[index] = rows[index] with { Status = (string)status! };
})
.Returns(Task.CompletedTask);
sharePoint
.Setup(x => x.UpdateItemByListUrlAsync(
SiteUrl, listUrl, It.IsAny<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Callback((string _, string list, int id, object fields, CancellationToken _) =>
writes.Items.Add((list, id, ToMap(fields))))
.Returns(Task.CompletedTask);
var service = new AutoApproveService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
new FixedTimeProvider(now ?? Now),
Options.Create(new AutoApproveOptions()),
NullLogger<AutoApproveService>.Instance);
return (service, writes);
}
// Анонимный объект, который сервис отдаёт клиенту SharePoint, — в словарь:
// сравнивать поля по именам колонок нагляднее, чем городить ожидаемый
// анонимный тип той же формы.
private static Dictionary<string, object?> ToMap(object fields) =>
fields.GetType().GetProperties().ToDictionary(p => p.Name, p => p.GetValue(fields));
[Fact(DisplayName = "SLA истёк — шаг согласуется, очередь двигается, статус заявки пересчитан")]
public async Task Overdue_ApprovesAndAdvancesQueue()
{
var listUrl = await CreateTypeAsync("Auto basic", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(11, 1, "Ждёт очереди", 2)
};
var (service, writes) = BuildService(listUrl, rows);
var approved = await service.RunAsync(CancellationToken.None);
approved.ShouldBe(1);
var decided = writes.For(ApprovalListTitle, 10).ShouldNotBeNull();
decided["ApprovalStatus"].ShouldBe("Согласовано");
decided["Date"].ShouldBe(Now.UtcDateTime);
decided["Comment"].ShouldBe(new AutoApproveOptions().Comment);
// Следующий шаг активирован — со своей датой активации, иначе от него не
// отсчитать ни напоминание, ни собственное автосогласование.
var activated = writes.For(ApprovalListTitle, 11).ShouldNotBeNull();
activated["ApprovalStatus"].ShouldBe("На согласовании");
activated["Date"].ShouldBe(Now.UtcDateTime);
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании");
}
[Fact(DisplayName = "Срок ещё не вышел — строка не трогается")]
public async Task WithinSla_DoesNothing()
{
var listUrl = await CreateTypeAsync("Auto not due", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Флаг выключен — просроченный шаг остаётся ждать человека")]
public async Task WithoutFlag_DoesNothing()
{
var listUrl = await CreateTypeAsync("Auto flag off", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"))
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "SLA шага перекрывает SLA типа заявки")]
public async Task RowSla_OverridesTypeSla()
{
var listUrl = await CreateTypeAsync("Auto row sla", slaHours: 100);
var rows = new List<Row>
{
// По типу (100 ч) срок ещё не вышел, по шагу (1 ч) — вышел.
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-5).ToString("O"), SlaHours: 1, AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 10).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Согласовано");
}
[Fact(DisplayName = "SLA не задан нигде — шаг пропущен, а не согласован немедленно")]
public async Task WithoutSla_Skipped()
{
var listUrl = await CreateTypeAsync("Auto no sla");
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Дата активации не заполнена — отсчитывать не от чего, шаг пропущен")]
public async Task WithoutDate_Skipped()
{
var listUrl = await CreateTypeAsync("Auto no date", slaHours: 24);
var rows = new List<Row> { new(10, 1, "На согласовании", 1, AutoApprove: true) };
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Последний шаг согласования без исполнителей — заявка «Согласовано»")]
public async Task LastApprovalStep_ClosesRequestAsApproved()
{
var listUrl = await CreateTypeAsync("Auto last step", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Согласовано", 1),
new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Согласовано");
}
[Fact(DisplayName = "Согласование пройдено — активируется фаза исполнения, заявка «Выполнение»")]
public async Task LastApprovalStep_ActivatesExecutionPhase()
{
var listUrl = await CreateTypeAsync("Auto to execution", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(11, 1, "Ждёт очереди", 2, Phase: "Исполнение")
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("На исполнении");
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Выполнение");
}
[Fact(DisplayName = "Шаг исполнения закрывается как «Выполнено», заявка — «Закрыта»")]
public async Task ExecutionStep_MarkedExecuted()
{
var listUrl = await CreateTypeAsync("Auto execution", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Согласовано", 1),
new(11, 1, "На исполнении", 2, Phase: "Исполнение",
Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Выполнено");
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Закрыта");
}
[Fact(DisplayName = "Параллельный шаг: решён только один — очередь не двигается")]
public async Task ParallelStep_DoesNotAdvanceWhileSiblingPending()
{
var listUrl = await CreateTypeAsync("Auto parallel", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
// Второй согласующий того же шага (общий StepOrder) решения ещё не принял.
new(11, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O")),
new(12, 1, "Ждёт очереди", 2)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 12).ShouldBeNull();
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании");
}
[Fact(DisplayName = "Шаг отклонён человеком — очередь не двигается, статус заявки «Отклонено»")]
public async Task RejectedSibling_StopsQueue()
{
var listUrl = await CreateTypeAsync("Auto rejected", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Отклонено", 1),
new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(12, 1, "Ждёт очереди", 3)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 12).ShouldBeNull();
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Отклонено");
}
}
@@ -0,0 +1,91 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.BasisRequests;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Заявки по одному основанию: видны все (чтение от имени приложения), а
/// CanOpen — только там, где у вызывающего есть права на элемент.
/// </summary>
public sealed class BasisRequestsServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string CallerEmail = "initiator@tnsu.kz";
private const string BasisKey = "need:8028c7ac-ac23-8dd8-11f0-946facc42464";
private readonly ServiceProvider _services;
public BasisRequestsServiceTests(PostgresDatabaseFixture fixture)
{
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, CallerEmail);
}
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
private async Task<(BasisRequestsService Service, Mock<ISharePointClient> SharePoint, int TypeId)> BuildAsync(
string name, string listUrl, IReadOnlyList<object> items, IReadOnlySet<int> openableIds)
{
var type = await _services.GetRequiredService<RequestTypeService>().CreateAsync(
new CreateRequestTypeRequest($"Оплата {name}", $"/оплата-{name}", listUrl, SiteUrl, "General"),
CancellationToken.None);
var sharePoint = new Mock<ISharePointClient>();
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(items.Select(Json).ToList());
sharePoint
.Setup(x => x.CanUserViewItemAsync(SiteUrl, listUrl, It.IsAny<int>(), CallerEmail, It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string _, int id, string _, CancellationToken _) => openableIds.Contains(id));
var service = new BasisRequestsService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
new TestCurrentUser(CallerEmail));
return (service, sharePoint, type.Value.Id);
}
[Fact]
public async Task Returns_all_requests_and_marks_only_permitted_as_openable()
{
const string list = "/sites/requests/Lists/Оплата-all";
var (service, sharePoint, typeId) = await BuildAsync("all", list,
[
// Number-колонка приходит дробной («50000.0») — тест держит её такой же.
new { Id = 1, RequestUniqueCode = "24-2610-1", RequestStatus = "Согласовано", PayAmount = 50000.0 },
new { Id = 2, RequestUniqueCode = "24-2610-2", RequestStatus = "На согласовании", PayAmount = 35000.5 }
], new HashSet<int> { 2 });
var result = await service.GetByBasisAsync(typeId, BasisKey, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(2);
result.Value.Single(x => x.Id == 1).ShouldBe(new BasisRequestDto(1, list, "24-2610-1", "Согласовано", 50000, false));
result.Value.Single(x => x.Id == 2).ShouldBe(new BasisRequestDto(2, list, "24-2610-2", "На согласовании", 35000.5, true));
// Фильтр по основанию уходит в SharePoint, а не применяется в памяти.
sharePoint.Verify(x => x.GetItemsByListUrlAsync(
SiteUrl, list,
It.Is<string>(q => q.Contains("BasisKey eq 'need%3A8028c7ac-ac23-8dd8-11f0-946facc42464'")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Empty_basis_key_is_a_validation_error()
{
var (service, _, typeId) = await BuildAsync("empty", "/sites/requests/Lists/Оплата-empty", [], new HashSet<int>());
var result = await service.GetByBasisAsync(typeId, " ", CancellationToken.None);
result.IsFailed.ShouldBeTrue();
}
}
@@ -0,0 +1,32 @@
using Shouldly;
using Tnsu.ApprovalRoute.Application.Reminders;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// ApprovalOverdueReminderDto должен отдавать email получателя под двумя именами
/// сразу: старым Email (для потребителей, ещё не перешедших на новый контракт) и
/// унаследованным ReceiverEmail (общим для обоих видов напоминаний, см.
/// ReminderDto) — так переход на новый контракт можно делать постепенно, не
/// ломая существующие Power Automate-флоу разом.
/// </summary>
public sealed class ReminderDtoTests
{
[Fact(DisplayName = "ApprovalOverdueReminderDto: Email и унаследованный ReceiverEmail — одно и то же значение")]
public void ApprovalOverdue_EmailAndReceiverEmail_AreTheSameValue()
{
var dto = new ApprovalOverdueReminderDto(
Email: "approver@tnsu.kz",
HoursOverdue: 5,
AskOverdueReason: false,
RequestTypeId: 1,
RequestTypeName: "Тип",
RequestId: 10,
Status: "На согласовании",
RequestUniqueCode: "code",
Url: "https://tnsukz.sharepoint.com/sites/requests");
dto.Email.ShouldBe("approver@tnsu.kz");
dto.ReceiverEmail.ShouldBe(dto.Email);
}
}
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -9,6 +10,7 @@ using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint; using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
@@ -29,8 +31,28 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
private const string DataListTitle = "Заявка тест — данные"; private const string DataListTitle = "Заявка тест — данные";
private const string ApprovalListTitle = "Заявка тест — согласование"; private const string ApprovalListTitle = "Заявка тест — согласование";
// Своё значение (не то, что по умолчанию в RemindersOptions) — так тест
// проверяет, что ссылка действительно берётся из настроек, а не совпадает
// с ожидаемой случайно.
private const string ProbationReminderUrl = "https://portal.test/новая-заявка";
// Порог фиксируем в тестах СВОИМ значением, не полагаясь на дефолт из
// RemindersOptions: изменение продового порога (3 → 7 дней и далее) не должно
// ломать тесты, которые проверяют вовсе не его.
private const int ProbationDaysBeforeEnd = 3;
private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero);
// GetOverdueAsync/GetProbationEndingAsync по-прежнему берут now параметром — их
// тесты ниже дают его напрямую. TimeProvider нужен только там, где ReminderService
// сам решает, «сейчас» сколько — то есть в GetRemindersAsync (см. её doc-комментарий).
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private static readonly TimeProvider Clock = new FixedTimeProvider(Now);
private readonly ServiceProvider _services; private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService; private readonly RequestTypeService _requestTypeService;
@@ -53,6 +75,23 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
return listUrl; return listUrl;
} }
// pageUrl отдельным параметром (а не /{name}, как в CreateTypeWithSlaAsync) —
// GetProbationEndingAsync ищет тип НЕ по SLA, а именно по PageUrl (см.
// RemindersOptions.RecruitingHirePageUrl), и у каждого теста здесь свой
// уникальный PageUrl, передаваемый в опции сервиса той же строкой: так тесты
// не видят чужие «recruiting-hire»-записи, случайно оставшиеся от других
// тестов в общей БД фикстуры.
private async Task<(string ListUrl, string PageUrl)> CreateHireTypeAsync(string name)
{
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
var pageUrl = $"/{name.Replace(" ", string.Empty)}";
await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
CancellationToken.None);
return (listUrl, pageUrl);
}
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
private static object Row( private static object Row(
@@ -104,6 +143,8 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
_services.GetRequiredService<ApprovalRouteDbContext>(), _services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object, sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(), _services.GetRequiredService<ListPartitionRegistry>(),
Mock.Of<ITnsuPortalClient>(),
Clock,
Options.Create(new RemindersOptions()), Options.Create(new RemindersOptions()),
NullLogger<ReminderService>.Instance); NullLogger<ReminderService>.Instance);
} }
@@ -119,12 +160,38 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None); var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.IsSuccess.ShouldBeTrue(); result.IsSuccess.ShouldBeTrue();
var reminder = result.Value.ShouldHaveSingleItem(); var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>();
reminder.Email.ShouldBe("approver@tnsu.kz"); reminder.Email.ShouldBe("approver@tnsu.kz");
reminder.HoursOverdue.ShouldBe(24); reminder.HoursOverdue.ShouldBe(24);
reminder.RequestId.ShouldBe(1); reminder.RequestId.ShouldBe(1);
} }
[Fact(DisplayName = "GetRemindersAsync без feed — обратная совместимость, как ApprovalOverdue")]
public async Task GetRemindersAsync_WithoutFeed_BehavesLikeApprovalOverdue()
{
var listUrl = await CreateTypeWithSlaAsync("Sla feed default", slaHours: 24);
var service = BuildService(listUrl,
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
var result = await service.GetRemindersAsync(CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(24);
}
[Fact(DisplayName = "GetRemindersAsync(feed: ProbationEnding) — просроченные шаги согласования игнорируются")]
public async Task GetRemindersAsync_ProbationEndingFeed_IgnoresApprovalOverdue()
{
var listUrl = await CreateTypeWithSlaAsync("Sla feed probation", slaHours: 24);
var service = BuildService(listUrl,
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
var result = await service.GetRemindersAsync(ReminderFeed.ProbationEnding, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")] [Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
public async Task WithinSla_ReturnsNothing() public async Task WithinSla_ReturnsNothing()
{ {
@@ -153,7 +220,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None); var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(2);
} }
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")] [Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
@@ -179,7 +246,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None); var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
} }
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом // SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
@@ -199,7 +266,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None); var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
} }
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")] [Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
@@ -211,7 +278,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None); var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().AskOverdueReason.ShouldBeTrue(); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().AskOverdueReason.ShouldBeTrue();
} }
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён // Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
@@ -256,6 +323,300 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None); var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz"); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().Email.ShouldBe("approver@tnsu.kz");
}
private static object HireDataRow(
int id, string status, string? candidateName, string? hireDate, string? probationPeriod, string? code = null) =>
new
{
Id = id,
RequestStatus = status,
CandidateName = candidateName,
HireDate = hireDate,
ProbationPeriod = probationPeriod,
RequestUniqueCode = code
};
private ReminderService BuildProbationService(
string pageUrl, string listUrl, IReadOnlyList<object> rows, Mock<ITnsuPortalClient> portalClient,
int daysBeforeEnd = ProbationDaysBeforeEnd)
{
const string hireDataListTitle = "Прием на работу тест — данные";
var sharePoint = new Mock<ISharePointClient>();
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(hireDataListTitle);
// GetProbationEndingAsync читает только «Согласовано»/«Закрыта» — SharePoint
// фильтрует это сам ($filter в запросе), мок повторяет ту же фильтрацию.
var closedRows = rows
.Where(r => (string)r.GetType().GetProperty("RequestStatus")!.GetValue(r)! is "Согласовано" or "Закрыта")
.ToList();
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, hireDataListTitle, It.Is<string>(q => q.Contains("RequestStatus eq")), It.IsAny<CancellationToken>()))
.ReturnsAsync(closedRows.Select(Json).ToList());
return new ReminderService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
portalClient.Object,
Clock,
Options.Create(new RemindersOptions
{
RecruitingHirePageUrl = pageUrl,
ProbationReminderDaysBeforeEnd = daysBeforeEnd,
ProbationReminderUrl = ProbationReminderUrl
}),
NullLogger<ReminderService>.Instance);
}
private static Mock<ITnsuPortalClient> BuildPortalClientMock(
string candidateFio, DateTimeOffset? dismissalDate, string? candidateEmail, string? managerEmail)
{
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([new EmployeeFullBaseInfo(Guid.NewGuid(), candidateFio, "ТОО TANSU", dismissalDate)]);
portalClient
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), candidateFio, candidateEmail, "ТОО TANSU")]);
if (candidateEmail is not null)
portalClient
.Setup(x => x.GetManagerAsync(candidateEmail, "chief", It.IsAny<CancellationToken>()))
.ReturnsAsync(managerEmail);
return portalClient;
}
[Fact(DisplayName = "До конца испытательного срока 3 дня — напоминание с email кандидата и его руководителя")]
public async Task ProbationEndingIn3Days_BuildsReminder()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation due");
// probationEndsAt = Now.Date + 3 дня = 2026-08-15; "2 месяца" от 2026-06-15.
var rows = new[] { HireDataRow(1, "Закрыта", "Иванов Иван Иванович", "2026-06-15T00:00:00Z", "2 месяца") };
var portalClient = BuildPortalClientMock(
"Иванов Иван Иванович", dismissalDate: null, candidateEmail: "candidate@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
reminder.ReceiverEmail.ShouldBe("manager@tnsu.kz");
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
reminder.DaysUntilProbationEnds.ShouldBe(3);
// Ссылка ведёт не на саму заявку на приём, а на раздел, где заводят новую
// (константа из настроек, см. RemindersOptions.ProbationReminderUrl).
reminder.Url.ShouldBe(ProbationReminderUrl);
}
[Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")]
public async Task DismissedCandidate_IsSkipped()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation dismissed");
var rows = new[] { HireDataRow(1, "Закрыта", "Петров Пётр Петрович", "2026-06-15T00:00:00Z", "2 месяца") };
var portalClient = BuildPortalClientMock(
"Петров Пётр Петрович", dismissalDate: Now.AddDays(-1), candidateEmail: "petrov@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldBeEmpty();
}
// Сотрудник холдинга может числиться в нескольких компаниях сразу (см.
// /Employee/FullBase — по записи на компанию). Увольнение из одной из них не
// означает, что человек ушёл: пока хоть где-то дата увольнения не стоит, он
// работает, и напоминание ему по-прежнему нужно.
[Fact(DisplayName = "Уволен из одной компании, но работает в другой — напоминание есть")]
public async Task DismissedFromOneCompanyOnly_StillBuildsReminder()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation partly dismissed");
const string fio = "Романов Роман Романович";
var rows = new[] { HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца") };
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(
[
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО АСТЕЛ-К", Now.AddDays(-10)),
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО TANSU Construction", null)
]);
portalClient
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), fio, "romanov@tnsu.kz", "ТОО TANSU Construction")]);
portalClient
.Setup(x => x.GetManagerAsync("romanov@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
.ReturnsAsync("manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("romanov@tnsu.kz");
}
// ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны
// ломать сопоставление со справочником (см. EmployeeDirectorySnapshot.Normalize).
[Fact(DisplayName = "ФИО сопоставляется без учёта регистра и лишних пробелов")]
public async Task CandidateName_IsMatchedLoosely()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation fio normalize");
var rows = new[] { HireDataRow(1, "Закрыта", " волков ВОЛК волкович ", "2026-06-15T00:00:00Z", "2 месяца") };
var portalClient = BuildPortalClientMock(
"Волков Волк Волкович", dismissalDate: null, candidateEmail: "volkov@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("volkov@tnsu.kz");
}
[Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]
public async Task CandidateNotInFullBase_IsSkipped()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation unknown");
var rows = new[] { HireDataRow(1, "Закрыта", "Сидоров Сидор Сидорович", "2026-06-15T00:00:00Z", "2 месяца") };
var portalClient = new Mock<ITnsuPortalClient>();
portalClient.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
portalClient.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "До конца испытательного срока не ровно 3 дня — напоминания нет")]
public async Task NotExactlyThreeDaysLeft_IsSkipped()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation not due");
// "1 месяц" от 2026-06-15 → срок кончился 2026-07-15, задолго до Now.
var rows = new[] { HireDataRow(1, "Закрыта", "Кузнецов Кузьма Кузьмич", "2026-06-15T00:00:00Z", "1 месяц") };
var portalClient = BuildPortalClientMock(
"Кузнецов Кузьма Кузьмич", dismissalDate: null, candidateEmail: "kuznecov@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "«Без испытательного срока» — напоминания нет")]
public async Task NoProbationPeriod_IsSkipped()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation none");
var rows = new[]
{
HireDataRow(1, "Закрыта", "Смирнов Смирн Смирнович", "2026-06-15T00:00:00Z", "Без испытательного срока")
};
var portalClient = BuildPortalClientMock(
"Смирнов Смирн Смирнович", dismissalDate: null, candidateEmail: "smirnov@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldBeEmpty();
}
// Портал пишет дату приёма как МЕСТНУЮ полночь, переведённую в UTC, поэтому в
// списке она лежит как 19:00Z предыдущего дня. Здесь 2026-06-14T19:00Z — это
// 15 июня по местному календарю (см. LocalOffset в ReminderService): срок
// истекает 15 августа, до него от Now (12 августа) ровно 3 дня.
// Если считать по UTC, дата приёма превратилась бы в 14 июня, срок — в 14
// августа, и осталось бы 2 дня: напоминание не построилось бы вовсе.
[Fact(DisplayName = "Дата приёма считается по местному календарю, а не по UTC")]
public async Task HireDate_IsReadInLocalTimeZone()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation timezone");
var rows = new[] { HireDataRow(1, "Закрыта", "Тимуров Тимур Тимурович", "2026-06-14T19:00:00Z", "2 месяца") };
var portalClient = BuildPortalClientMock(
"Тимуров Тимур Тимурович", dismissalDate: null, candidateEmail: "timurov@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
reminder.DaysUntilProbationEnds.ShouldBe(3);
// И дата приёма, и конец срока отдаются в том же местном календаре.
reminder.HireDate.Date.ShouldBe(new DateTime(2026, 6, 15));
reminder.ProbationEndsAt.Date.ShouldBe(new DateTime(2026, 8, 15));
}
// Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption
// в ReminderService), поэтому заявка, способная дать напоминание сегодня,
// физически не может лежать раньше предпоследней партиции. Три партиции:
// самая старая (№1) содержит строку-ловушку — если сервис её прочитает, тест
// это увидит по лишнему напоминанию; предпоследняя (№2) и текущая (№3)
// читаются как обычно.
[Fact(DisplayName = "Читаются только текущая и последняя архивная партиция — более старые игнорируются")]
public async Task OnlyLastTwoPartitions_AreRead()
{
var (listUrl1, pageUrl) = await CreateHireTypeAsync("Probation partitions");
var db = _services.GetRequiredService<ApprovalRouteDbContext>();
var partitionRegistry = _services.GetRequiredService<ListPartitionRegistry>();
var type = await db.RequestTypes.FirstAsync(x => x.PageUrl == pageUrl);
// Партиция №1 — уедет в архив первой, её строки читаться не должны.
await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None);
var listUrl2 = listUrl1 + "-2";
type.ListId = listUrl2;
await db.SaveChangesAsync();
// Партиция №2 — последняя архивная, должна читаться наравне с текущей.
await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None);
var listUrl3 = listUrl1 + "-3";
type.ListId = listUrl3;
await db.SaveChangesAsync();
const string fio = "Партиция Тест Тестович";
var trapRow = HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца");
var realRow = HireDataRow(2, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца");
var sharePoint = new Mock<ISharePointClient>();
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl1, It.IsAny<CancellationToken>())).ReturnsAsync("list1 — данные");
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl2, It.IsAny<CancellationToken>())).ReturnsAsync("list2 — данные");
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl3, It.IsAny<CancellationToken>())).ReturnsAsync("list3 — данные");
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([Json(trapRow)]);
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list2 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([Json(realRow)]);
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list3 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var portalClient = BuildPortalClientMock(
fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = new ReminderService(
db, sharePoint.Object, partitionRegistry, portalClient.Object, Clock,
// Порог задан явно (как и в BuildProbationService): тест про партиции, а
// не про то, сколько дней до конца срока настроено в проде.
Options.Create(new RemindersOptions
{
RecruitingHirePageUrl = pageUrl,
ProbationReminderUrl = ProbationReminderUrl,
ProbationReminderDaysBeforeEnd = ProbationDaysBeforeEnd
}),
NullLogger<ReminderService>.Instance);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>()
.CandidateEmail.ShouldBe("partition@tnsu.kz");
sharePoint.Verify(
x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
} }
} }
@@ -169,7 +169,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -206,7 +206,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest( new CreateTemplateRouteStepRequest(
1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, 1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And,
SlaHours: stepSla, AskOverdueReason: stepAsk), SlaHours: stepSla, AskOverdueReason: stepAsk),
CancellationToken.None); CancellationToken.None);
@@ -285,15 +285,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -342,11 +342,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -361,8 +361,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief); resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
var warning = resolved.Value.Steps[0].Warning; var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull(); warning.ShouldNotBeNull();
warning.ShouldContain("Chief"); warning.ShouldContain("Непосредственный руководитель");
warning.ShouldContain("DirectionHead"); warning.ShouldContain("Руководитель направления");
warning.ShouldNotContain("DirectionHead");
} }
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")] [Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
@@ -377,7 +378,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — departmentId в запросе не передан // Action — departmentId в запросе не передан
@@ -419,7 +420,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -459,7 +460,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -488,7 +489,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -515,11 +516,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -546,7 +547,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — fieldValues не переданы вовсе // Action — fieldValues не переданы вовсе
@@ -571,7 +572,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }), new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -605,7 +606,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }), new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -672,7 +673,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -710,7 +711,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -749,7 +750,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -785,7 +786,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -832,7 +833,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -849,12 +850,127 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz"); resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
} }
[Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")] [Fact(DisplayName = "Резолв: один и тот же директор двух разных компаний инициатора в CompanyDirectors схлопывается в одну строку")]
public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDirectorOfTwoCompanies_CollapsesToOneRow()
{
// Arrange — инициатор числится в двух компаниях, и директор у них ОДИН и тот же
// человек (например, холдинговая структура) — оба GetEmployeeByPositionAsync
// возвращают один и тот же email.
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EmployeeDirectoryInfo>
{
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Начальник отдела службы безопасности")
});
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор"));
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор"));
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
var requestTypeService = services.GetRequiredService<RequestTypeService>();
var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("CompanyDirectors same person request", "/company-directors-same-person", null, null, "General"),
CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert — один человек не должен согласовывать одну и ту же заявку дважды
// только из-за того, что он директор двух компаний инициатора одновременно.
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("same.director@tnsu.kz");
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors);
resolved.Value.Steps[0].Warning.ShouldBeNull();
}
[Fact(DisplayName = "Резолв: один и тот же человек под РАЗНЫМИ email (общий Id в справочнике) в CompanyDirectors схлопывается в одну строку")]
public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDifferentEmailsSameId_CollapsesToOneRow()
{
// Arrange — в справочнике api.tnsu.kz это один человек (один Guid Id), но у
// него разные email в разных компаниях. Дедуп по email это бы не поймал —
// отсюда и был реальный баг (одна и та же строка дважды на одном шаге).
var personId = Guid.NewGuid();
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync("someone@tnsu.kz", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EmployeeDirectoryInfo>
{
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Начальник отдела службы безопасности")
});
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "director.tansu.email@tnsu.kz", "Общий директор"));
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "director.stglobal.email@tnsu.kz", "Общий директор"));
// Оба email в справочнике резолвятся в ОДНОГО и того же человека (общий Id) —
// как в реальном порталe, где у сотрудника может быть несколько адресов.
portalClient
.Setup(x => x.GetEmployeeByEmailAsync("director.tansu.email@tnsu.kz", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EmployeeDirectoryInfo>
{
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Директор", personId)
});
portalClient
.Setup(x => x.GetEmployeeByEmailAsync("director.stglobal.email@tnsu.kz", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EmployeeDirectoryInfo>
{
new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Генеральный директор", personId)
});
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
var requestTypeService = services.GetRequiredService<RequestTypeService>();
var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("CompanyDirectors same person different emails request", "/company-directors-same-person-different-emails", null, null, "General"),
CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert — дедуп по Id сотрудника, а не по email: одна строка, а не две.
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors);
resolved.Value.Steps[0].Warning.ShouldBeNull();
}
[Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors")]
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors()
{ {
// Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5)
// и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — // и Директор Tansu (из шага DirectorTansu, OrderNo 10) — один и тот же человек
// один и тот же человек с одним и тем же email. // с одним и тем же email.
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
@@ -883,11 +999,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail, // Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail,
@@ -921,7 +1037,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -935,7 +1051,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull(); resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
var warning = resolved.Value.Steps[0].Warning; var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull(); warning.ShouldNotBeNull();
warning.ShouldContain("руководитель"); warning.ShouldBe("Не найден непосредственный руководитель.");
} }
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")] [Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
@@ -950,7 +1066,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -979,7 +1095,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -993,7 +1109,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull(); resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
var warning = resolved.Value.Steps[0].Warning; var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull(); warning.ShouldNotBeNull();
warning.ShouldContain("роль"); warning.ShouldBe("В проекте не найден сотрудник службы безопасности.");
} }
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
@@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
999999, 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -59,7 +59,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -71,14 +71,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null); var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And);
var first = await _service.CreateAsync(routeId, request, CancellationToken.None); var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var duplicate = await _service.CreateAsync( var duplicate = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null), new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -153,14 +153,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief // Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -174,14 +174,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -197,11 +197,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var chief = await _service.CreateAsync( var chief = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
var directorTansu = await _service.CreateAsync( var directorTansu = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -219,11 +219,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -240,11 +240,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -259,14 +259,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — то же условие, другой статус // Action — то же условие, другой статус
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -280,14 +280,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false); var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId) // Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null), new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -303,7 +303,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -321,7 +321,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -341,7 +341,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -359,7 +359,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -374,14 +374,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update step", byProject: false); var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null), new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -398,7 +398,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step", byProject: false); var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -422,7 +422,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false); var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -443,7 +443,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false); var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -457,6 +457,12 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
patched.IsFailed.ShouldBeTrue(); patched.IsFailed.ShouldBeTrue();
} }
// ConditionsLogic здесь всюду передаётся явно, потому что он обязателен: у
// Create/Update-запроса значения по умолчанию нет вовсе, а пропущенное в JSON
// поле превращается в ConditionsLogic.None и отвергается валидатором на слое
// API (TemplateRouteStepValidators + ValidationActionFilter). Отдельным тестом
// это не покрыто — сервис валидаторы не вызывает.
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")] [Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled() public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
{ {
@@ -464,7 +470,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false); var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -489,7 +495,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false,
new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }), new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -505,7 +511,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -521,7 +527,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -539,7 +545,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -560,7 +566,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -581,16 +587,16 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false); var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await _service.CreateAsync( await _service.CreateAsync(
routeBId, routeBId,
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null), new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Другой исполнитель — не должен попасть в выдачу. // Другой исполнитель — не должен попасть в выдачу.
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -0,0 +1,431 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reports;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Сборка отчёта по вакансиям из трёх источников: заявка на подбор, её фаза
/// исполнения (рекрутёр и дата закрытия) и связанные заявки на приём (кого и
/// когда приняли).
///
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
/// возвращает REST, — так тесты заодно покрывают и разбор ответа (в частности
/// дробные «1.0» у Number-колонок).
/// </summary>
public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string DataListTitle = "Заявка на подбор — данные";
private const string ApprovalListTitle = "Заявка на подбор — согласование";
private static readonly DateOnly From = new(2026, 7, 1);
private static readonly DateOnly To = new(2026, 8, 31);
private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService;
public VacancyReportServiceTests(PostgresDatabaseFixture fixture)
{
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
}
// База у тестов класса общая, а сервис ищет тип заявки по PageUrl — поэтому
// у каждого теста свои списки и PageUrl (оба выводятся из имени теста), и
// тесты не видят записи друг друга.
private static string SelectionListUrl(string name) => $"/sites/requests/Lists/Подбор-{name}";
private static string HireListUrl(string name) => $"/sites/requests/Lists/Приём-{name}";
private async Task CreateTypeAsync(string name, string listUrl, string pageUrl) =>
await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
CancellationToken.None);
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
/// <summary>Элемент списка «Заявка на подбор — данные» в том виде, в каком его отдаёт REST.</summary>
private static object Selection(
int id,
string position,
string? objectProject = null,
double vacancies = 1,
string? submitDate = "2026-07-08T00:00:00Z",
string? created = "2026-07-08T00:00:00Z",
string? responsible = "Ким Наталья Юрьевна",
string? openReason = null,
string? openReasonDetails = null) =>
new
{
Id = id,
RequestStatus = "Выполнение",
Position = position,
DepartmentName = "офис",
ObjectProject = objectProject,
// Number-колонка приходит дробной («1.0») — тест держит её такой же.
OpenVacanciesCount = vacancies,
SubmitDate = submitDate,
Created = created,
VacancyOpenReason = openReason,
VacancyOpenReasonDetails = openReasonDetails,
Responsible = new { Title = responsible }
};
/// <summary>Строка фазы «Исполнение» из списка «Заявка на подбор — согласование».</summary>
private static object Execution(
int ticketId,
string status = "Выполнено",
string? date = "2026-07-22T10:00:00Z",
double stepOrder = 1,
string? executor = "Сергеева Надежда Ивановна") =>
new
{
Id = ticketId * 100 + (int)stepOrder,
TicketId = ticketId,
ApprovalStatus = status,
StepOrder = stepOrder,
Date = date,
TansuSiteApprover = new { Title = executor }
};
/// <summary>
/// Элемент списка «Прием на работу — данные». selectionListUrl — партиция, в
/// которой лежит заявка на подбор: связь идёт по паре (Id, партиция), а не по
/// одному Id — в разных партициях Id повторяются.
/// </summary>
private static object Hire(
int selectionId,
string selectionListUrl,
string status = "Закрыта",
string? hireDate = "2026-08-17T00:00:00Z",
string? replaced = null,
string? candidate = "Шакенов Анель") =>
new
{
Id = selectionId * 10,
RequestStatus = status,
SelectionRequestId = (double)selectionId,
SelectionRequestListId = selectionListUrl,
CandidateName = candidate,
ReplacedEmployee = replaced,
HireDate = hireDate
};
private async Task<(VacancyReportService Service, Mock<ISharePointClient> SharePoint)> BuildAsync(
string name,
IReadOnlyList<object> selections,
IReadOnlyList<object>? executions = null,
IReadOnlyList<object>? hires = null)
{
var selectionList = SelectionListUrl(name);
var hireList = HireListUrl(name);
var selectionPage = $"/подбор-{name}";
var hirePage = $"/приём-{name}";
await CreateTypeAsync($"Подбор {name}", selectionList, selectionPage);
await CreateTypeAsync($"Приём {name}", hireList, hirePage);
var sharePoint = new Mock<ISharePointClient>();
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(
SiteUrl, selectionList, It.Is<string>(q => q.Contains("OpenVacanciesCount")),
It.IsAny<CancellationToken>()))
.ReturnsAsync(selections.Select(Json).ToList());
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, selectionList, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("Исполнение")),
It.IsAny<CancellationToken>()))
.ReturnsAsync((executions ?? []).Select(Json).ToList());
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(
SiteUrl, hireList, It.Is<string>(q => q.Contains("SelectionRequestId")),
It.IsAny<CancellationToken>()))
.ReturnsAsync((hires ?? []).Select(Json).ToList());
var service = new VacancyReportService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
Options.Create(new ReportsOptions
{
RecruitingSelectionPageUrl = selectionPage,
RecruitingHirePageUrl = hirePage
}),
NullLogger<VacancyReportService>.Instance);
return (service, sharePoint);
}
private static VacancyReportRow SingleRow(VacancyReport report) =>
report.Groups.ShouldHaveSingleItem().Rows.ShouldHaveSingleItem();
[Fact(DisplayName = "Закрытая вакансия: приём даёт число закрытых, «да» и дату выхода")]
public async Task ClosedVacancy_FilledFromHire()
{
const string name = "closed";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Зам нач.юр.отдела")],
[Execution(1, date: "2026-07-08T10:00:00Z")],
[Hire(1, SelectionListUrl(name))]);
var result = await service.GetAsync(From, To, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var row = SingleRow(result.Value);
row.Position.ShouldBe("Зам нач.юр.отдела");
row.Department.ShouldBe("офис");
row.VacanciesCount.ShouldBe(1);
row.ClosedCount.ShouldBe(1);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
row.ClosingDate.ShouldBe(new DateOnly(2026, 7, 8));
row.Initiator.ShouldBe("Ким Н.Ю.");
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
var line = row.Lines.ShouldHaveSingleItem();
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
line.CandidateName.ShouldBe("Шакенов Анель");
}
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
public async Task Hire_FromOtherPartition_NotMatched()
{
var (service, _) = await BuildAsync(
"otherPartition",
[Selection(1, "Зам нач.юр.отдела")],
[Execution(1)],
[Hire(1, "/sites/requests/Lists/СовсемДругаяПартиция")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(0);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
}
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
public async Task PartiallyClosed()
{
const string name = "partial";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Логист", vacancies: 2)],
[Execution(1, status: "На исполнении", date: null)],
[Hire(1, SelectionListUrl(name))]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.VacanciesCount.ShouldBe(2);
row.ClosedCount.ShouldBe(1);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Partial);
}
[Fact(DisplayName = "Незавершённая фаза исполнения — даты закрытия нет, рекрутёр есть")]
public async Task ExecutionInProgress_NoClosingDate()
{
var (service, _) = await BuildAsync(
"inprogress",
[Selection(1, "Начальник ОИД")],
[
Execution(1, status: "Выполнено", date: "2026-07-10T00:00:00Z", stepOrder: 1),
Execution(1, status: "На исполнении", date: null, stepOrder: 2, executor: "Ким Дания Сериковна")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosingDate.ShouldBeNull();
// Рекрутёр — исполнитель ПОСЛЕДНЕГО шага: вакансию доводит он.
row.Recruiter.ShouldBe("Ким Дания Сериковна");
}
[Fact(DisplayName = "Отклонённая заявка на приём вакансию не закрывает")]
public async Task RejectedHire_DoesNotClose()
{
const string name = "rejected";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Юрист")],
[Execution(1)],
[Hire(1, SelectionListUrl(name), status: "Отклонено")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(0);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
}
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
public async Task SeveralHires_OneLineEach()
{
const string name = "several";
var list = SelectionListUrl(name);
var (service, _) = await BuildAsync(
name,
[Selection(1, "Разнорабочий", vacancies: 2)],
[Execution(1)],
[
// Намеренно НЕ в порядке дат — проверяем сортировку строк.
Hire(1, list, hireDate: "2026-08-25T00:00:00Z", candidate: "Бутиев Азамат"),
Hire(1, list, hireDate: "2026-08-05T00:00:00Z", candidate: "Дулин Евгений")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(2);
// Вакансия одна, но строк на листе две — по приёму на каждую.
row.Lines.Count.ShouldBe(2);
row.Lines.Select(l => l.StartDate).ShouldBe([new DateOnly(2026, 8, 5), new DateOnly(2026, 8, 25)]);
row.Lines.Select(l => l.CandidateName).ShouldBe(["Дулин Евгений", "Бутиев Азамат"]);
}
[Fact(DisplayName = "Отклонённый приём не даёт строки — кандидата в отчёте быть не должно")]
public async Task RejectedHire_ProducesNoLine()
{
const string name = "rejectedLine";
var list = SelectionListUrl(name);
var (service, _) = await BuildAsync(
name,
[Selection(1, "Разнорабочий", vacancies: 2)],
[Execution(1)],
[
Hire(1, list, candidate: "Принятый Кандидат"),
Hire(1, list, status: "Отклонено", candidate: "Отклонённый Кандидат")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(1);
row.Lines.ShouldHaveSingleItem().CandidateName.ShouldBe("Принятый Кандидат");
}
[Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
public async Task SubmitDateEmpty_FallsBackToCreated()
{
var (service, _) = await BuildAsync(
"created",
[Selection(1, "Прораб", submitDate: null, created: "2026-07-30T09:00:00Z")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
SingleRow(result.Value).SubmitDate.ShouldBe(new DateOnly(2026, 7, 30));
}
[Fact(DisplayName = "Пустое «Количество открытых вакансий» — это одна вакансия")]
public async Task VacanciesCountEmpty_IsOne()
{
var (service, _) = await BuildAsync("noCount", [Selection(1, "Фельдшер", vacancies: 0)]);
var result = await service.GetAsync(From, To, CancellationToken.None);
SingleRow(result.Value).VacanciesCount.ShouldBe(1);
}
[Fact(DisplayName = "Группировка: сначала вакансии без объекта, потом объекты по алфавиту")]
public async Task Groups_UnnamedFirstThenAlphabetical()
{
var (service, _) = await BuildAsync(
"groups",
[
Selection(1, "Оператор катка", objectProject: "КЭС КУРЧАТОВ"),
Selection(2, "Логист"),
Selection(3, "Начальник участка", objectProject: "Авиа-ангар г.Атырау")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
result.Value.Groups.Select(g => g.Name).ShouldBe(["", "Авиа-ангар г.Атырау", "КЭС КУРЧАТОВ"]);
}
[Fact(DisplayName = "«Вместо сотрудника» — из причины открытия, только при замене уволенного")]
public async Task ReplacedEmployee_OnlyForReplacementReason()
{
var (service, _) = await BuildAsync(
"replaced",
[
Selection(1, "Начальник геодезии",
openReason: "Замена уволенного сотрудника", openReasonDetails: "Серик"),
Selection(2, "Инженер ВиВ",
openReason: "Новая штатная единица", openReasonDetails: "по расширению штата")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
rows.Single(r => r.Position == "Начальник геодезии")
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Серик");
rows.Single(r => r.Position == "Инженер ВиВ")
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBeNull();
}
[Fact(DisplayName = "«Взамен кого» из заявки на приём важнее причины открытия вакансии")]
public async Task ReplacedEmployee_HireWins()
{
const string name = "replacedHire";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Юрист", openReason: "Замена уволенного сотрудника", openReasonDetails: "Из заявки")],
[Execution(1)],
[Hire(1, SelectionListUrl(name), replaced: "Из приёма")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
SingleRow(result.Value).Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Из приёма");
}
[Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")]
public async Task InvertedPeriod_Fails()
{
var (service, _) = await BuildAsync("inverted", [Selection(1, "Логист")]);
var result = await service.GetAsync(To, From, CancellationToken.None);
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Фильтр периода уходит в SharePoint с откатом на Created")]
public async Task PeriodFilter_PassedToSharePoint()
{
const string name = "filter";
var (service, sharePoint) = await BuildAsync(name, [Selection(1, "Логист")]);
await service.GetAsync(From, To, CancellationToken.None);
sharePoint.Verify(
x => x.GetItemsByListUrlAsync(
SiteUrl,
SelectionListUrl(name),
It.Is<string>(q =>
q.Contains("SubmitDate ge datetime'2026-07-01T00:00:00Z'")
// Верхняя граница — начало следующего дня и строгое lt: иначе
// заявки за сам последний день периода отсеклись бы.
&& q.Contains("SubmitDate lt datetime'2026-09-01T00:00:00Z'")
&& q.Contains("SubmitDate eq null and Created ge")),
It.IsAny<CancellationToken>()),
Times.Once);
}
}
@@ -0,0 +1,386 @@
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
using Shouldly;
using Tnsu.ApprovalRoute.Application.Reports;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
/// </summary>
public sealed class VacancyReportWorkbookTests
{
private static VacancyReportRow Row(
string position,
int vacancies = 1,
int closed = 0,
DateOnly? submitDate = null,
DateOnly? closingDate = null,
DateOnly? startDate = null,
string? recruiter = "Сергеева Надежда Ивановна",
IReadOnlyList<VacancyReportLine>? lines = null) =>
new(
position,
"офис",
vacancies,
closed,
submitDate ?? new DateOnly(2026, 7, 8),
closingDate,
"Ким Н.Ю.",
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
recruiter,
// По умолчанию одна строка — вакансия без приёмов либо с одним;
// тесты многострочности передают lines явно.
lines ?? [new VacancyReportLine(null, startDate, null)]);
/// <summary>Строка листа в пределах вакансии — то, что различается между приёмами.</summary>
private static VacancyReportLine Line(string? candidate, DateOnly? startDate = null, string? replaced = null) =>
new(candidate, startDate, replaced);
private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups)
{
var report = new VacancyReport(from, to, groups);
var bytes = VacancyReportWorkbook.Build(report);
return new XSSFWorkbook(new MemoryStream(bytes));
}
private static ISheet BuildListSheet(params VacancyReportGroup[] groups) =>
BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups)
.GetSheet("Список вакансий (все)");
/// <summary>Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E».</summary>
private static ICell? CellAt(ISheet sheet, int excelRow, int excelCol) =>
sheet.GetRow(excelRow - 1)?.GetCell(excelCol - 1);
private static string StringAt(ISheet sheet, int excelRow, int excelCol) =>
CellAt(sheet, excelRow, excelCol)?.StringCellValue ?? "";
private static int IntAt(ISheet sheet, int excelRow, int excelCol) =>
(int)(CellAt(sheet, excelRow, excelCol)?.NumericCellValue ?? throw new InvalidOperationException("Ячейка пуста"));
private static DateTime DateAt(ISheet sheet, int excelRow, int excelCol) =>
CellAt(sheet, excelRow, excelCol)!.DateCellValue!.Value;
private static bool IsEmptyAt(ISheet sheet, int excelRow, int excelCol)
{
var cell = CellAt(sheet, excelRow, excelCol);
return cell is null || cell.CellType == CellType.Blank;
}
/// <summary>Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе).</summary>
private static string FormulaAt(ISheet sheet, int excelRow, int excelCol)
{
var cell = CellAt(sheet, excelRow, excelCol);
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
}
[Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")]
public void Header_MatchesSample()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
StringAt(sheet, 1, 2).ShouldBe("№");
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
StringAt(sheet, 1, 4).ShouldBe("Подразделение");
StringAt(sheet, 1, 5).ShouldBe("Кол-во вакансий");
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
StringAt(sheet, 1, 12).ShouldBe("Ф.И.О. нанимаемого сотрудника");
StringAt(sheet, 1, 13).ShouldBe("Инициатор заявки");
StringAt(sheet, 1, 15).ShouldBe("Комментарии");
StringAt(sheet, 1, 17).ShouldBe("Рекрутер");
IsEmptyAt(sheet, 1, 18).ShouldBeTrue();
}
[Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")]
public void Header_DateColumns_SameStyleAsRestOfHeader()
{
// Регрессия: даты когда-то получали формат через SetDefaultColumnStyle,
// а он в NPOI переписывает стиль УЖЕ существующих ячеек колонки —
// включая шапку, которая от этого лишалась заливки (H1/I1/K1 получали
// DataDate вместо Header). Формат даты теперь ставится только на сами
// ячейки с датами (см. SetDate), шапки колонок с датами он не касается.
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
// NPOI отдаёт на каждый вызов CellStyle новую обёртку — сравнивать
// нужно индекс стиля в книге, а не ссылки на объекты.
var headerStyleIndex = CellAt(sheet, 1, 3)!.CellStyle.Index; // «Вакансия» — заведомо обычная шапка.
foreach (var col in (int[])[8, 9, 11]) // H «Дата подачи заявки», I «Дата закрытия», K «Дата выхода»
CellAt(sheet, 1, col)!.CellStyle.Index.ShouldBe(headerStyleIndex);
}
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
public void DataRow_Layout()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [
Row("Зам нач.юр.отдела", vacancies: 1, closed: 1,
submitDate: new DateOnly(2026, 3, 30),
closingDate: new DateOnly(2026, 7, 8),
startDate: new DateOnly(2026, 8, 17))
]));
IntAt(sheet, 2, 1).ShouldBe(1);
StringAt(sheet, 2, 3).ShouldBe("Зам нач.юр.отдела");
IntAt(sheet, 2, 5).ShouldBe(1);
IntAt(sheet, 2, 6).ShouldBe(1);
DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30));
DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8));
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17));
StringAt(sheet, 2, 13).ShouldBe("Ким Н.Ю.");
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
IsEmptyAt(sheet, 2, 15).ShouldBeTrue();
StringAt(sheet, 2, 16).ShouldBe("да");
StringAt(sheet, 2, 17).ShouldBe("Сергеева Надежда Ивановна");
}
[Fact(DisplayName = "Несколько приёмов: своя строка на каждого, общие поля объединены по вертикали")]
public void SeveralHires_OneRowEachWithMergedCommonCells()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [
Row("Разнорабочий", vacancies: 4, closed: 4,
submitDate: new DateOnly(2026, 7, 10),
lines: [
Line("Дулин Евгений Петрович", new DateOnly(2026, 8, 19)),
Line("Телятников Игорь Сергеевич", new DateOnly(2026, 8, 21)),
Line("Жабаев Нурлан Ахметович", new DateOnly(2026, 8, 25), replaced: "Оспанов К.")
])
]));
// Построчные колонки — своё значение в каждой строке.
StringAt(sheet, 2, 12).ShouldBe("Дулин Евгений Петрович");
StringAt(sheet, 3, 12).ShouldBe("Телятников Игорь Сергеевич");
StringAt(sheet, 4, 12).ShouldBe("Жабаев Нурлан Ахметович");
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 19));
DateAt(sheet, 3, 11).ShouldBe(new DateTime(2026, 8, 21));
DateAt(sheet, 4, 11).ShouldBe(new DateTime(2026, 8, 25));
StringAt(sheet, 4, 14).ShouldBe("Оспанов К.");
// Общие — значение ТОЛЬКО в первой строке: у объединённой области Excel
// хранит его в верхней левой ячейке, и дубли сломали бы SUM в итогах.
StringAt(sheet, 2, 3).ShouldBe("Разнорабочий");
IsEmptyAt(sheet, 3, 3).ShouldBeTrue();
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
IntAt(sheet, 2, 5).ShouldBe(4);
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
// И сами объединения заведены — иначе продолжения выглядели бы пустыми.
var merged = Enumerable.Range(0, sheet.NumMergedRegions).Select(sheet.GetMergedRegion).ToList();
merged.ShouldContain(r => r.FirstRow == 1 && r.LastRow == 3 && r.FirstColumn == 2 && r.LastColumn == 2);
// Построчные колонки объединять нельзя — иначе ФИО второго и третьего пропали бы.
merged.ShouldNotContain(r => r.FirstColumn == 11); // 0-based: ColCandidateName
merged.ShouldNotContain(r => r.FirstColumn == 10); // ColStartDate
}
[Fact(DisplayName = "Одна строка у вакансии — объединений не создаётся (область 1×1 портит файл)")]
public void SingleLine_NoMergedRegions()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
sheet.NumMergedRegions.ShouldBe(0);
}
[Fact(DisplayName = "Итоги суммируют вакансии по одному разу, а не по числу приёмов")]
public void Totals_CountVacancyOnce_NotPerHire()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [
Row("Разнорабочий", vacancies: 4, closed: 4, lines: [
Line("Первый", new DateOnly(2026, 8, 19)),
Line("Второй", new DateOnly(2026, 8, 21))
]),
Row("Логист", vacancies: 1, closed: 0)
]));
// Строки: 2-3 «Разнорабочий» (две), 4 «Логист» → итоги в 5-й.
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
// Значение «Кол-во вакансий» лежит только в E2 и E4 — E3 пустая (объединение),
// поэтому SUM даёт 4+1=5, а не 4+4+1.
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
IntAt(sheet, 4, 5).ShouldBe(1);
}
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
public void DerivedColumns_AreFormulas()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [
Row("Логист", vacancies: 3, closed: 1,
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
]));
FormulaAt(sheet, 2, 7).ShouldBe("E2-F2");
FormulaAt(sheet, 2, 10).ShouldBe("I2-H2");
}
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
public void LeadTime_EmptyWithoutClosingDate()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
FormulaAt(sheet, 2, 10).ShouldBeEmpty();
IsEmptyAt(sheet, 2, 10).ShouldBeTrue();
}
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
public void GroupSeparator_RestartsNumbering()
{
var sheet = BuildListSheet(
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
// Верхний блок без объекта — сразу после шапки, без разделителя.
StringAt(sheet, 2, 3).ShouldBe("Логист");
StringAt(sheet, 3, 3).ShouldBe("Юрист");
StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ");
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
IntAt(sheet, 5, 1).ShouldBe(3);
IntAt(sheet, 5, 2).ShouldBe(1);
IntAt(sheet, 6, 1).ShouldBe(4);
IntAt(sheet, 6, 2).ShouldBe(2);
}
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
public void Totals_SumWholeDataRange()
{
var sheet = BuildListSheet(
new VacancyReportGroup("", [Row("Логист")]),
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
FormulaAt(sheet, 5, 6).ShouldBe("SUM(F2:F4)");
FormulaAt(sheet, 5, 7).ShouldBe("SUM(G2:G4)");
}
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
public void EmptyReport_HeaderOnly()
{
var sheet = BuildListSheet();
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
IsEmptyAt(sheet, 2, 5).ShouldBeTrue();
}
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
public void FileName_CarriesPeriod()
{
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), []);
VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx");
}
// ==================== Лист «Результаты» ====================
private static VacancyReportRow RowFor(string recruiter, int vacancies, int closed) =>
Row("Позиция", vacancies: vacancies, closed: closed, recruiter: recruiter);
[Fact(DisplayName = "Результаты: горизонтальная таблица — рекрутёры по убыванию объёма, с процентами")]
public void SummarySheet_HorizontalTable()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Надежда", vacancies: 10, closed: 4), // в работе 60%, закрыто 40%
RowFor("Алтынай", vacancies: 5, closed: 5), // закрыто 100%
]));
var sheet = workbook.GetSheet("Результаты");
StringAt(sheet, 1, 1).ShouldBe("результаты");
// Заголовок: «Кол-во вакансий» + рекрутёры по убыванию объёма (Надежда 10 > Алтынай 5).
StringAt(sheet, 2, 1).ShouldBe("Кол-во вакансий");
StringAt(sheet, 2, 2).ShouldBe("Надежда");
StringAt(sheet, 2, 3).ShouldBe("Алтынай");
StringAt(sheet, 3, 1).ShouldBe("Всего");
IntAt(sheet, 3, 2).ShouldBe(10);
IntAt(sheet, 3, 3).ShouldBe(5);
StringAt(sheet, 4, 1).ShouldBe("в работе (в%)");
CellAt(sheet, 4, 2)!.NumericCellValue.ShouldBe(0.6, 0.0001);
CellAt(sheet, 4, 3)!.NumericCellValue.ShouldBe(0.0, 0.0001);
StringAt(sheet, 5, 1).ShouldBe("закрыто");
CellAt(sheet, 5, 2)!.NumericCellValue.ShouldBe(0.4, 0.0001);
CellAt(sheet, 5, 3)!.NumericCellValue.ShouldBe(1.0, 0.0001);
}
[Fact(DisplayName = "Результаты: вакансии без рекрутёра сведены в «Без рекрутёра» и идут последними")]
public void SummarySheet_UnassignedRecruiterLast()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Алтынай", vacancies: 1, closed: 0),
Row("Без исполнителя", vacancies: 100, closed: 0, recruiter: null),
]));
var sheet = workbook.GetSheet("Результаты");
// «Без рекрутёра» — последняя колонка горизонтальной таблицы, несмотря на
// то, что её объём (100) больше, чем у Алтынай (1): это не участник
// рейтинга, а технический хвост.
StringAt(sheet, 2, 2).ShouldBe("Алтынай");
StringAt(sheet, 2, 3).ShouldBe(VacancyReportSummary.UnassignedLabel);
}
[Fact(DisplayName = "Результаты: вертикальные таблицы — доля каждого рекрутёра в общем объёме и в работе")]
public void SummarySheet_VerticalTables()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Надежда", vacancies: 3, closed: 0), // весь объём в работе
RowFor("Алтынай", vacancies: 1, closed: 1), // весь объём закрыт
]));
var sheet = workbook.GetSheet("Результаты");
// Таблица 1 — «Общий объем работ»: строка 7 (после шапки+4 строк
// горизонтальной таблицы+пустой строки), итого 4 (3+1), доля Надежды 75%.
StringAt(sheet, 7, 1).ShouldBe("Общий объем работ");
IntAt(sheet, 7, 2).ShouldBe(4);
StringAt(sheet, 8, 1).ShouldBe("Надежда");
CellAt(sheet, 8, 2)!.NumericCellValue.ShouldBe(0.75, 0.0001);
StringAt(sheet, 9, 1).ShouldBe("Алтынай");
CellAt(sheet, 9, 2)!.NumericCellValue.ShouldBe(0.25, 0.0001);
// Таблица 2 — «Общий объем в работе»: строка 11, итого 3 (только Надежда
// не закрыта), её доля — 100%; у Алтынай в работе 0 → доля 0%.
StringAt(sheet, 11, 1).ShouldBe("Общий объем в работе");
IntAt(sheet, 11, 2).ShouldBe(3);
StringAt(sheet, 12, 1).ShouldBe("Надежда");
CellAt(sheet, 12, 2)!.NumericCellValue.ShouldBe(1.0, 0.0001);
StringAt(sheet, 13, 1).ShouldBe("Алтынай");
CellAt(sheet, 13, 2)!.NumericCellValue.ShouldBe(0.0, 0.0001);
}
[Fact(DisplayName = "Результаты: под каждой вертикальной таблицей — своя круговая диаграмма")]
public void SummarySheet_HasTwoPieCharts()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Надежда", vacancies: 3, closed: 1),
RowFor("Алтынай", vacancies: 2, closed: 0),
]));
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
var charts = sheet.GetDrawingPatriarch()?.GetCharts();
charts.ShouldNotBeNull();
charts!.Count.ShouldBe(2);
}
[Fact(DisplayName = "Результаты: нет вакансий за период — без таблиц и диаграмм, без падения")]
public void SummarySheet_EmptyReport_NoTablesOrCharts()
{
var workbook = BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31));
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
sheet.GetDrawingPatriarch().ShouldBeNull();
}
}
@@ -14,6 +14,16 @@
.approvalAdmin { .approvalAdmin {
font-family: $font-sans; font-family: $font-sans;
box-sizing: border-box;
// Полная ширина обеспечивается через "supportsFullBleed": true в манифесте
// + снятым max-width канвы выше. Корень занимает 100% контейнера, без
// CSS-трюков с 100vw.
width: 100%;
// App-shell «edge-to-edge» (как в request-portal): рейл примыкает к левому
// краю, отступы — внутри main.
padding: 0;
} }
.stateBox { .stateBox {
@@ -2,39 +2,54 @@
// Раскладка — тот же боковой рейл + основная область, что и в request-portal // Раскладка — тот же боковой рейл + основная область, что и в request-portal
// (@tnsu/ui-kit Sidebar, см. RequestPortal.module.scss): единый стиль // (@tnsu/ui-kit Sidebar, см. RequestPortal.module.scss): единый стиль
// навигации во всех веб-частях Tnsu. В отличие от request-portal (рейл во всю // навигации во всех веб-частях Tnsu. Как и там, это не карточка на странице
// высоту окна, edge-to-edge), здесь виджет — ограниченная карточка на странице // SharePoint, а edge-to-edge app-shell: без рамки/тени/скруглений и без
// SharePoint, поэтому без sticky/100vh-трюков: рейл просто растягивается по // ограничения ширины контента — рейл примыкает к левому краю канвы.
// высоте своего соседа (.main) через align-items: stretch.
.shell { .shell {
display: flex; display: flex;
align-items: stretch; align-items: flex-start; // рейл тянется не по контенту, а по своей колонке — см. .side
border-radius: $r-lg; width: 100%;
border: 1px solid $gray-200; min-height: 78vh; // «присутствие» рейла даже когда контента мало
overflow: hidden;
box-shadow: $sh-1;
font-family: $font-sans; font-family: $font-sans;
font-size: $fs-body; font-size: $fs-body;
line-height: 1.55; line-height: 1.55;
} }
// Рейл-колонка. Ровно та же схема, что в request-portal: КОЛОНКА тянется во всю
// высоту раскладки и закрашена $navy-800 (цветом, которым градиент рейла
// заканчивается), а сам РЕЙЛ внутри — sticky на 100vh поверх этой заливки.
// Так градиент не меняет оттенок от высоты соседней колонки, а под рейлом на
// длинных таблицах нет белого провала, даже если sticky в SharePoint не
// включился (прокручивается внутренний контейнер страницы, а не окно).
// Из-за sticky у .shell не должно быть overflow: hidden.
.side { .side {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
align-self: stretch;
background: $navy-800; background: $navy-800;
// Прямой потомок — корень Sidebar из @tnsu/ui-kit (своего класса снаружи не
// видно, поэтому селектор по структуре, а не по имени).
> * {
position: sticky;
top: 0;
height: 100vh;
}
} }
.main { .main {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; // чтобы контент не распирал колонку min-width: 0; // чтобы контент не распирал колонку
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%); background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
color: $gray-900; color: $gray-900;
} }
// Отступы контента — здесь, а не на .main: без max-width/margin:auto контент
// занимает всю доступную ширину (edge-to-edge, как в портале).
.content { .content {
padding: 28px 30px 36px; padding: 28px 30px 36px;
max-width: 1080px;
margin: 0 auto;
} }
// Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта. // Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта.
@@ -48,13 +63,26 @@
// media-query, без участия JS) — НЕ display:none на .side: рейл вёрстке всё // media-query, без участия JS) — НЕ display:none на .side: рейл вёрстке всё
// равно нужен в потоке, чтобы при mobileOpen выехать поверх контента. // равно нужен в потоке, чтобы при mobileOpen выехать поверх контента.
@media (max-width: 768px) { @media (max-width: 768px) {
.shell {
min-height: 0;
}
.mobileNav { .mobileNav {
display: block; display: block;
} }
// Снимаем десктопные правила рейла явно. Здесь ui-kit сам делает Sidebar
// position:fixed (top/bottom/left), и наш sticky+100vh спорил бы с ним при
// равной специфичности — исход решался бы порядком подключения стилей.
// Заливка колонки тоже не нужна: колонка схлопнута, drawer красит себя сам.
.side { .side {
align-self: flex-start; align-self: flex-start;
background: none; background: none;
> * {
position: static;
height: auto;
}
} }
} }
@@ -14,11 +14,13 @@ import { texts } from '../../texts';
import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { DashboardScreen } from '../screens/DashboardScreen'; import { DashboardScreen } from '../screens/DashboardScreen';
import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen';
import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen';
import { RoutesScreen } from '../screens/RoutesScreen'; import { RoutesScreen } from '../screens/RoutesScreen';
import { RouteStepsScreen } from '../screens/RouteStepsScreen'; import { RouteStepsScreen } from '../screens/RouteStepsScreen';
import { ResolveScreen } from '../screens/ResolveScreen'; import { ResolveScreen } from '../screens/ResolveScreen';
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen'; import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
import { ManageByUserScreen } from '../screens/ManageByUserScreen'; import { ManageByUserScreen } from '../screens/ManageByUserScreen';
import { RoutesExportScreen } from '../screens/RoutesExportScreen';
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый // Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не // стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
@@ -29,8 +31,10 @@ const NAV_ITEMS: ISidebarItem[] = [
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' }, { id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
{ id: 'routes', title: texts.nav.routes, icon: 'send' }, { id: 'routes', title: texts.nav.routes, icon: 'send' },
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' }, { id: 'resolve', title: texts.nav.resolve, icon: 'search' },
{ id: 'routesExport', title: texts.nav.routesExport, icon: 'document' },
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }, { id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' } { id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' },
{ id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' }
]; ];
function sidebarActiveId(tab: ScreenKey): string { function sidebarActiveId(tab: ScreenKey): string {
@@ -147,6 +151,14 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
<div className={styles.content}> <div className={styles.content}>
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />} {route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />} {route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
{route.tab === 'requestTypeAccess' && (
<RequestTypeAccessScreen
api={api}
dictionary={dictionary}
initialFilterRequestTypeId={route.accessFilterRequestTypeId}
initialFilterEmail={route.accessFilterEmail}
/>
)}
{route.tab === 'routes' && !route.routeId && ( {route.tab === 'routes' && !route.routeId && (
<RoutesScreen <RoutesScreen
api={api} api={api}
@@ -174,6 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
route={route.resolve} route={route.resolve}
/> />
)} )}
{route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
{route.tab === 'manageRequests' && ( {route.tab === 'manageRequests' && (
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} /> <ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
)} )}
@@ -1 +1,10 @@
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser'; export type ScreenKey =
| 'dashboard'
| 'requestTypes'
| 'requestTypeAccess'
| 'routes'
| 'routeSteps'
| 'resolve'
| 'routesExport'
| 'manageRequests'
| 'manageByUser';
@@ -5,7 +5,7 @@ import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService'; import { RequestManageService } from '../../services/requestManageService';
import { ApiClientError } from '../../services/httpClient'; import { ApiClientError } from '../../services/httpClient';
import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models'; import { REQUEST_STATUS, UserApprovalRow, UserTemplateStepRow } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts'; import { routeStepStatusLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid'; import { shortGuid } from '../../utils/guid';
import { describeStepCondition } from '../../utils/stepExecutor'; import { describeStepCondition } from '../../utils/stepExecutor';
@@ -15,7 +15,6 @@ import { mapWithLimit } from '../../utils/parallel';
import { IManageByUserRouteParams, replaceRoute } from '../../routing'; import { IManageByUserRouteParams, replaceRoute } from '../../routing';
import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor'; import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup'; import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -95,7 +94,6 @@ interface IReplaceScope {
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => { export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
const requestTypes = useRequestTypes(api); const requestTypes = useRequestTypes(api);
const listSeries = useRequestTypeListsAll(api);
const employees = usePortalEmployees(dictionary); const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary); const dictionaries = usePortalDictionaries(dictionary);
@@ -125,27 +123,21 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
}); });
}, [employeeEmail, targetEmail]); }, [employeeEmail, targetEmail]);
const partitionsByType = React.useMemo(() => {
const map = new Map<number, RequestListPartition[]>();
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
return map;
}, [listSeries.items]);
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]); const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
const [liveLoading, setLiveLoading] = React.useState(false); const [liveLoading, setLiveLoading] = React.useState(false);
const [liveError, setLiveError] = React.useState<string | undefined>(); const [liveError, setLiveError] = React.useState<string | undefined>();
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set()); const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
const reloadLiveRows = React.useCallback(() => { const reloadLiveRows = React.useCallback(() => {
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return; if (!employeeEmail || requestTypes.isLoading) return;
setLiveLoading(true); setLiveLoading(true);
setLiveError(undefined); setLiveError(undefined);
requestManage api.requestManage
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES) .byApprover(employeeEmail, ACTIVE_STATUSES)
.then(setLiveRows) .then(setLiveRows)
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover'))) .catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover')))
.finally(() => setLiveLoading(false)); .finally(() => setLiveLoading(false));
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]); }, [api, requestTypes.isLoading, employeeEmail]);
React.useEffect(() => { React.useEffect(() => {
setLiveRows([]); setLiveRows([]);
@@ -273,7 +265,11 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят. // Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
const replaced = await mapWithLimit( const replaced = await mapWithLimit(
clean, clean,
row => requestManage.reassignApprover(row, row.id, email, position), row =>
requestManage.reassignApprover(row, row.id, email, position, {
ticketId: row.ticketId,
dataListId: row.listId
}),
{ onSettled: advanceProgress } { onSettled: advanceProgress }
); );
replaced.forEach(result => { replaced.forEach(result => {
@@ -74,6 +74,11 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase())); const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase())); const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
const isDirty =
email !== initialEmail ||
slaHours !== slaHoursToInput(initialSlaHours) ||
askOverdueReason !== boolToTriState(initialAskOverdueReason);
const handleSubmit = (): void => { const handleSubmit = (): void => {
const trimmedEmail = email.trim(); const trimmedEmail = email.trim();
if (!trimmedEmail) { if (!trimmedEmail) {
@@ -95,7 +100,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
}; };
return ( return (
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}> <FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose} isDirty={isDirty}>
<FormGrid> <FormGrid>
<Field label={texts.manageRequests.approverLabel} required full> <Field label={texts.manageRequests.approverLabel} required full>
<SearchableSelect <SearchableSelect
@@ -214,13 +214,23 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
const isBlockStart = block.start === i; const isBlockStart = block.start === i;
const movable = blockIndex > lastLockedBlockIndex; const movable = blockIndex > lastLockedBlockIndex;
const rowEditable = isEditableRowStatus(r.status); const rowEditable = isEditableRowStatus(r.status);
// Уведомлять можно только активный шаг (флоу шлёт письмо тому, // Уведомлять можно активный шаг (флоу шлёт письмо тому, чей
// чей RowID мы передали, независимо от статуса — сам он не // RowID мы передали, независимо от статуса — сам он не
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил, // проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
// слать по нему нечего: согласующий/исполнитель пока не // слать по нему нечего: согласующий/исполнитель пока не
// должен действовать (см. request-portal/notify.service.ts, // должен действовать (см. request-portal/notify.service.ts,
// где по той же причине уведомляется только активный шаг). // где по той же причине уведомляется только активный шаг).
const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution; //
// «Возвращена на доработку» — тоже уведомляемый статус, хотя
// и не активный: для него тот же поток шлёт письмо не
// согласующему строки, а АВТОРУ заявки (см.
// RequestManageService.notifyRow) — так интерфейс
// редактирования заявок получает кнопку «сообщить автору о
// доработке».
const notifiable =
r.status === REQUEST_STATUS.onApproval ||
r.status === REQUEST_STATUS.onExecution ||
r.status === REQUEST_STATUS.returned;
// Переключение параллельности — тоже перестановка (меняет order // Переключение параллельности — тоже перестановка (меняет order
// относительно соседней строки), поэтому смотрим на isMovableRowStatus, // относительно соседней строки), поэтому смотрим на isMovableRowStatus,
// а не на rowEditable: у «Возвращена на доработку» кнопка // а не на rowEditable: у «Возвращена на доработку» кнопка
@@ -235,12 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback( const searchRequests = React.useCallback(
(query: string, page: number) => { (query: string, page: number) => {
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) { if (!requestType || !requestType.siteUrl) {
return Promise.resolve({ items: [], hasMore: false }); return Promise.resolve({ items: [], hasMore: false });
} }
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page); return api.requestManage.search(requestType.id, query, page);
}, },
[requestManage, requestType, partitions.items] [api, requestType]
); );
// silent=true — фоновое обновление (после своего же действия, автообновление // silent=true — фоновое обновление (после своего же действия, автообновление
@@ -270,8 +270,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
} }
if (!silent) setMatrixLoading(true); if (!silent) setMatrixLoading(true);
setMatrixError(undefined); setMatrixError(undefined);
requestManage api.requestManage
.getMatrix(requestType.siteUrl, listId, selectedRequest.id) .matrix(requestType.id, listId, selectedRequest.id)
.then(m => { .then(m => {
setMatrix(m); setMatrix(m);
// title/status/listId в комбобоксе (а значит, и в localStorage) держим // title/status/listId в комбобоксе (а значит, и в localStorage) держим
@@ -296,12 +296,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
return same ? prev : { ...prev, selectedRequest: m.request }; return same ? prev : { ...prev, selectedRequest: m.request };
}); });
}) })
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix'))) .catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: matrix')))
.finally(() => { .finally(() => {
if (!silent) setMatrixLoading(false); if (!silent) setMatrixLoading(false);
}); });
}, },
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading] [api, requestType, selectedRequest, partitions.items, partitions.isLoading]
); );
React.useEffect(() => { React.useEffect(() => {
@@ -433,7 +433,10 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// SharePoint, и параллельные update по одному элементу списка могут // SharePoint, и параллельные update по одному элементу списка могут
// затереть друг друга (последний выигрывает). // затереть друг друга (последний выигрывает).
return requestManage return requestManage
.reassignApprover(matrix, rowId, email, position) .reassignApprover(matrix, rowId, email, position, {
ticketId: matrix.request.id,
dataListId: matrix.request.listId
})
.then(() => requestManage.updateRowSla(matrix, rowId, slaHours, askOverdueReason)) .then(() => requestManage.updateRowSla(matrix, rowId, slaHours, askOverdueReason))
.then(() => { .then(() => {
const found = findEmployeeByEmail(employees.items, email); const found = findEmployeeByEmail(employees.items, email);
@@ -507,7 +510,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] }); setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
requestManage requestManage
.addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false) .addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false, matrix.request.listId)
.then(() => reloadMatrix(true)) .then(() => reloadMatrix(true))
.catch((e: Error) => { .catch((e: Error) => {
setMatrix(previous); setMatrix(previous);
@@ -0,0 +1,33 @@
.filters {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 18px;
}
.filterField {
min-width: 220px;
max-width: 320px;
flex: 1;
}
.chosen {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.chosenItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 8px 4px 12px;
border: 1px solid #e1e4ea;
border-radius: 6px;
}
.chosenEmpty {
opacity: 0.6;
}
@@ -0,0 +1,306 @@
import * as React from 'react';
import styles from './RequestTypeAccessScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeAccess } from '../../hooks/useRequestTypeAccess';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useFormDraft } from '../../hooks/useFormDraft';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { toEmployeeOption } from '../../utils/employee';
import { replaceRoute } from '../../routing';
import { RequestTypeAccess } from '../../models';
import {
ActionsCell,
Alert,
Breadcrumbs,
Button,
ConfirmDialog,
DataTable,
Field,
FormGrid,
FormModal,
IconButton,
PageHeader,
PrimaryCell,
SearchableSelect,
Select
} from '../ui';
export interface IRequestTypeAccessScreenProps {
api: ApprovalRouteApi;
dictionary?: PortalDictionaryClient;
/** Фильтры из ссылки (aaacctype/aaaccemail) — перекрывают сохранённые, если заданы. */
initialFilterRequestTypeId?: string;
initialFilterEmail?: string;
}
// Фильтры переживают перезагрузку — тот же приём, что и ROUTES_FILTER_STORAGE_KEY в RoutesScreen.
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess';
interface IFormState {
requestTypeId: string;
emails: string[];
}
const EMPTY_FORM: IFormState = { requestTypeId: '', emails: [] };
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
api,
dictionary,
initialFilterRequestTypeId,
initialFilterEmail
}) => {
const requestTypes = useRequestTypes(api);
const access = useRequestTypeAccess(api);
const employees = usePortalEmployees(dictionary);
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
const { requestTypeId, emails } = draft.value;
const setEmails = (update: (current: string[]) => string[]): void =>
draft.setValue(f => ({ ...f, emails: update(f.emails) }));
const submit = useAsyncSubmit();
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
const [filterEmail, setFilterEmail] = useLocalStorageState(ACCESS_FILTER_EMAIL_STORAGE_KEY, '');
// Ссылка с фильтром сильнее сохранённого значения — только на монтировании
// (см. тот же приём в RoutesScreen).
React.useEffect(() => {
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (initialFilterEmail) setFilterEmail(initialFilterEmail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Держим адрес синхронным с фильтрами — отфильтрованную таблицу можно
// передать ссылкой. replaceRoute, а не navigate: смена фильтра не должна
// плодить записи в истории браузера.
React.useEffect(() => {
replaceRoute({ tab: 'requestTypeAccess', accessFilterRequestTypeId: filterRequestTypeId, accessFilterEmail: filterEmail });
}, [filterRequestTypeId, filterEmail]);
const deleteTarget = useDeleteTarget<RequestTypeAccess>(item => api.requestTypeAccess.remove(item.id), access.reload);
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
const filteredItems = access.items.filter(item => {
if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false;
if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false;
return true;
});
// Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
// здесь настраивает только видимость в UI approval-route, но не в самом
// SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
const formatSharePointLists = (item: RequestTypeAccess): string =>
item.sharePointLists.length === 0
? texts.common.empty
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
const openCreate = (): void => {
createOpen.open();
};
const submitForm = (): void => {
if (!requestTypeId) {
submit.fail(texts.requestTypeAccess.validationRequestType);
return;
}
if (emails.length === 0) {
submit.fail(texts.requestTypeAccess.validationEmail);
return;
}
// По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на
// одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы
// можно было поправить и нажать ещё раз.
submit.run(async () => {
const failures: string[] = [];
const failedEmails: string[] = [];
for (const email of emails) {
try {
await api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email });
} catch (e) {
failedEmails.push(email);
failures.push(`${email}: ${(e as Error).message}`);
}
}
if (failedEmails.length < emails.length) access.reload();
if (failures.length === 0) {
draft.reset();
createOpen.close();
return;
}
setEmails(() => failedEmails);
throw new Error(texts.requestTypeAccess.createFailed(failures));
});
};
// Тех, у кого доступ к этому типу уже есть (и кого уже добавили в список), показываем
// в поиске с пометкой, но выбрать нельзя — так видно, что человек не потерялся.
const grantedEmails = new Set(
access.items.filter(a => String(a.requestTypeId) === requestTypeId).map(a => a.email.toLowerCase())
);
const chosenEmails = new Set(emails.map(e => e.toLowerCase()));
const employeeOptions = employees.items.map(employee => {
const option = toEmployeeOption(employee);
const key = employee.email.toLowerCase();
if (grantedEmails.has(key)) {
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyGranted}` };
}
if (chosenEmails.has(key)) {
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyChosen}` };
}
return option;
});
const employeeLabel = (email: string): string => {
const employee = employees.items.find(e => e.email.toLowerCase() === email.toLowerCase());
return employee ? `${employee.fio} · ${employee.email}` : email;
};
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypeAccess.breadcrumb }]} />
<PageHeader
icon="people"
subtitle={texts.requestTypeAccess.subtitle}
title={texts.requestTypeAccess.title}
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypeAccess.add}</Button>}
/>
<div className={styles.filters}>
<div className={styles.filterField}>
<Field label={texts.requestTypeAccess.filterRequestTypeLabel}>
<Select
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.requestTypeAccess.filterRequestTypeAll}
value={filterRequestTypeId}
onChange={e => setFilterRequestTypeId(e.target.value)}
/>
</Field>
</div>
<div className={styles.filterField}>
<Field label={texts.requestTypeAccess.filterEmailLabel}>
<SearchableSelect
options={employees.items.map(toEmployeeOption)}
value={filterEmail}
loading={employees.isLoading}
allowClear
clearLabel={texts.requestTypeAccess.filterEmailAll}
placeholder={texts.requestTypeAccess.filterEmailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={setFilterEmail}
/>
</Field>
</div>
</div>
{access.error && <Alert kind="err">{access.error}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> },
{ key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email },
{ key: 'sharePointLists', header: texts.requestTypeAccess.columnSharePointLists, render: formatSharePointLists },
{
key: 'actions',
header: '',
width: '100px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={filteredItems}
keyOf={r => r.id}
emptyMessage={
access.isLoading
? texts.common.loading
: access.items.length > 0 && filteredItems.length === 0
? texts.requestTypeAccess.emptyFiltered
: texts.requestTypeAccess.emptyList
}
/>
{createOpen.isOpen && (
<FormModal
title={texts.requestTypeAccess.createTitle}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={createOpen.close}
onReset={draft.reset}
canReset={draft.isDirty}
>
<FormGrid>
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
<Select
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
value={requestTypeId}
onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))}
/>
</Field>
<Field label={texts.requestTypeAccess.emailLabel} required full>
<div>
<SearchableSelect
options={employeeOptions}
value=""
loading={employees.isLoading}
placeholder={texts.requestTypeAccess.emailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={value => value && setEmails(current => [...current, value])}
/>
<div className={styles.chosen}>
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
{emails.map(email => (
<div key={email} className={styles.chosenItem}>
<span>{employeeLabel(email)}</span>
<IconButton
icon="close"
label={texts.requestTypeAccess.removeEmail}
onClick={() => setEmails(current => current.filter(x => x !== email))}
/>
</div>
))}
</div>
</div>
</Field>
</FormGrid>
</FormModal>
)}
{deleteTarget.target && (
<ConfirmDialog
title={texts.requestTypeAccess.deleteTitle}
text={texts.requestTypeAccess.deleteText(deleteTarget.target.email, requestTypeName(deleteTarget.target.requestTypeId))}
confirmLabel={texts.common.remove}
busy={deleteTarget.isBusy}
onCancel={deleteTarget.cancel}
onConfirm={deleteTarget.confirm}
/>
)}
</div>
);
};
@@ -19,6 +19,8 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle'; import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useFormDraft } from '../../hooks/useFormDraft';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { import {
ActionsCell, ActionsCell,
Alert, Alert,
@@ -57,6 +59,22 @@ const EMPTY_FORM: IFormState = {
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: '' name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
}; };
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType';
function toFormState(item: RequestType): IFormState {
return {
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listId: item.listId ?? '',
slaHours: slaHoursToInput(item.slaHours),
askOverdueReason: boolToTriState(item.askOverdueReason)
};
}
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда // siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined, // (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде. // внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
@@ -78,8 +96,18 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const list = useRequestTypes(api); const list = useRequestTypes(api);
const [editing, setEditing] = React.useState<RequestType | undefined>(); const [editing, setEditing] = React.useState<RequestType | undefined>();
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM); const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
const closeForm = (): void => {
setShowForm(false);
createOpen.close();
};
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
const [editForm, setEditForm] = React.useState<IFormState>(EMPTY_FORM);
const form = editing ? editForm : draft.value;
const isEditDirty = Boolean(editing) && JSON.stringify(editForm) !== JSON.stringify(toFormState(editing as RequestType));
const setForm = editing ? setEditForm : draft.setValue;
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl); const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
@@ -92,21 +120,14 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const openCreate = (): void => { const openCreate = (): void => {
setEditing(undefined); setEditing(undefined);
setForm(EMPTY_FORM); setShowForm(false);
setShowForm(true); createOpen.open();
}; };
const openEdit = (item: RequestType): void => { const openEdit = (item: RequestType): void => {
setEditing(item); setEditing(item);
setForm({ setEditForm(toFormState(item));
name: item.name, createOpen.close();
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listId: item.listId ?? '',
slaHours: slaHoursToInput(item.slaHours),
askOverdueReason: boolToTriState(item.askOverdueReason)
});
setShowForm(true); setShowForm(true);
}; };
@@ -143,7 +164,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
submit.run(() => submit.run(() =>
request.then(() => { request.then(() => {
setShowForm(false); if (!editing) draft.reset();
closeForm();
list.reload(); list.reload();
}) })
); );
@@ -192,13 +214,16 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList} emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
/> />
{showForm && ( {(showForm || createOpen.isOpen) && (
<FormModal <FormModal
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle} title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
error={submit.error} error={submit.error}
isSaving={submit.isBusy} isSaving={submit.isBusy}
onSubmit={submitForm} onSubmit={submitForm}
onClose={() => setShowForm(false)} onClose={closeForm}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
isDirty={isEditDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.requestTypes.nameLabel} required full> <Field label={texts.requestTypes.nameLabel} required full>
@@ -47,7 +47,8 @@ function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
conditions: step.conditions, conditions: step.conditions,
conditionsLogic: step.conditionsLogic, conditionsLogic: step.conditionsLogic,
slaHours: step.slaHours, slaHours: step.slaHours,
askOverdueReason: step.askOverdueReason askOverdueReason: step.askOverdueReason,
autoApprove: step.autoApprove
}; };
} }
@@ -2,6 +2,7 @@ import * as React from 'react';
import { Company, Department, RequestType, TemplateRoute } from '../../models'; import { Company, Department, RequestType, TemplateRoute } from '../../models';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useFormDraft } from '../../hooks/useFormDraft';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui'; import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
@@ -27,6 +28,9 @@ interface IFormState {
requiresControl: boolean; requiresControl: boolean;
} }
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route';
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState { function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) { if (!editing) {
return { return {
@@ -58,7 +62,10 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onSaved, onSaved,
onClose onClose
}) => { }) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId)); // Редактирование не персистим — форма всегда открывается с данными маршрута с сервера.
const draft = useFormDraft<IFormState>(editing ? undefined : CREATE_DRAFT_STORAGE_KEY, toFormState(editing, initialRequestTypeId));
const form = draft.value;
const setForm = draft.setValue;
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
// Отдел принадлежит компании, поэтому список сужаем под выбранную: // Отдел принадлежит компании, поэтому список сужаем под выбранную:
@@ -93,6 +100,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.run(() => submit.run(() =>
request.then(() => { request.then(() => {
draft.reset();
onSaved(); onSaved();
onClose(); onClose();
}) })
@@ -106,6 +114,9 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
isSaving={submit.isBusy} isSaving={submit.isBusy}
onSubmit={submitForm} onSubmit={submitForm}
onClose={onClose} onClose={onClose}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
isDirty={Boolean(editing) && draft.isDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.routes.requestTypeLabel} required full> <Field label={texts.routes.requestTypeLabel} required full>
@@ -24,6 +24,7 @@ import {
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee'; import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useFormDraft } from '../../hooks/useFormDraft';
import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists'; import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
@@ -77,6 +78,7 @@ interface IFormState {
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */ /** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
slaHours: string; slaHours: string;
askOverdueReason: TriState; askOverdueReason: TriState;
autoApprove: boolean;
} }
const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' }; const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' };
@@ -118,6 +120,9 @@ const IS_MANAGER_VALUE_OPTIONS = [
{ value: 'false', label: texts.common.no } { value: 'false', label: texts.common.no }
]; ];
/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */
const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`;
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState { function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) { if (!editing) {
return { return {
@@ -133,7 +138,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
conditions: [EMPTY_CONDITION], conditions: [EMPTY_CONDITION],
conditionsLogic: ConditionsLogic.And, conditionsLogic: ConditionsLogic.And,
slaHours: '', slaHours: '',
askOverdueReason: '' askOverdueReason: '',
autoApprove: false
}; };
} }
return { return {
@@ -149,7 +155,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION], conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
conditionsLogic: editing.conditionsLogic, conditionsLogic: editing.conditionsLogic,
slaHours: slaHoursToInput(editing.slaHours), slaHours: slaHoursToInput(editing.slaHours),
askOverdueReason: boolToTriState(editing.askOverdueReason) askOverdueReason: boolToTriState(editing.askOverdueReason),
autoApprove: editing.autoApprove
}; };
} }
@@ -167,7 +174,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onSaved, onSaved,
onClose onClose
}) => { }) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo)); // Редактирование не персистим — форма всегда открывается с данными шага с сервера.
const draft = useFormDraft<IFormState>(editing ? undefined : createDraftStorageKey(route.id), toFormState(editing, nextOrderNo));
const form = draft.value;
const setForm = draft.setValue;
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const hasListId = Boolean(requestType?.listId); const hasListId = Boolean(requestType?.listId);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
@@ -365,6 +375,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationSlaHours); submit.fail(texts.routeSteps.validationSlaHours);
return; return;
} }
// Автосогласование отсчитывается от SLA — без него фоновая задача шаг просто
// пропустит (см. AutoApproveService.DeadlineOf), и настройка тихо не сработала
// бы. Пустое поле здесь не значит «нет SLA»: оно значит «взять у типа заявки»,
// поэтому ошибка только когда SLA нет и там.
if (form.autoApprove && parseSlaHours(form.slaHours) === null && requestType?.slaHours === null) {
submit.fail(texts.routeSteps.validationAutoApproveWithoutSla);
return;
}
const payload = { const payload = {
orderNo, orderNo,
@@ -379,7 +397,8 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
: null, : null,
conditionsLogic: form.conditionsLogic, conditionsLogic: form.conditionsLogic,
slaHours: parseSlaHours(form.slaHours), slaHours: parseSlaHours(form.slaHours),
askOverdueReason: triStateToBool(form.askOverdueReason) askOverdueReason: triStateToBool(form.askOverdueReason),
autoApprove: form.autoApprove
}; };
const request = editing const request = editing
@@ -388,6 +407,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.run(() => submit.run(() =>
request.then(step => { request.then(step => {
draft.reset();
onSaved(step); onSaved(step);
onClose(); onClose();
}) })
@@ -401,6 +421,9 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
isSaving={submit.isBusy} isSaving={submit.isBusy}
onSubmit={submitForm} onSubmit={submitForm}
onClose={onClose} onClose={onClose}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
isDirty={Boolean(editing) && draft.isDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.routeSteps.orderNoLabel} required> <Field label={texts.routeSteps.orderNoLabel} required>
@@ -505,6 +528,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/> />
</Field> </Field>
<Field label={texts.routeSteps.autoApproveLabel} full hint={texts.routeSteps.autoApproveHint}>
<Checkbox
label={texts.routeSteps.autoApproveCheckbox}
checked={form.autoApprove}
onChange={v => setForm({ ...form, autoApprove: v })}
/>
</Field>
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}> <Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
<Checkbox <Checkbox
label={texts.routeSteps.conditionCheckbox} label={texts.routeSteps.conditionCheckbox}
@@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u
import { reportError } from '../../utils/errors'; import { reportError } from '../../utils/errors';
import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { import {
@@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`; const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>(); const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen),
// ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal).
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const createOpen = usePersistedOpen(`approvalAdmin.draftOpen.routeStep.${route.id}`);
const closeForm = (): void => {
setShowForm(false);
createOpen.close();
};
const deleteTarget = useDeleteTarget<TemplateRouteStep>( const deleteTarget = useDeleteTarget<TemplateRouteStep>(
item => api.templateRouteSteps.remove(route.id, item.id), item => api.templateRouteSteps.remove(route.id, item.id),
@@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
const openCreate = (): void => { const openCreate = (): void => {
setEditing(undefined); setEditing(undefined);
setShowForm(true); setShowForm(false);
createOpen.open();
}; };
const openEdit = (item: TemplateRouteStep): void => { const openEdit = (item: TemplateRouteStep): void => {
setEditing(item); setEditing(item);
createOpen.close();
setShowForm(true); setShowForm(true);
}; };
@@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
</div> </div>
)} )}
{showForm && ( {(showForm || createOpen.isOpen) && (
<RouteStepFormModal <RouteStepFormModal
// Undefined, если у маршрута компания не задана (запасной маршрут, // Undefined, если у маршрута компания не задана (запасной маршрут,
// применяется к любой компании инициатора) — тогда подсказка // применяется к любой компании инициатора) — тогда подсказка
@@ -293,7 +303,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
employees={employees.items} employees={employees.items}
employeesLoading={employees.isLoading} employeesLoading={employees.isLoading}
onSaved={handleSaved} onSaved={handleSaved}
onClose={() => setShowForm(false)} onClose={closeForm}
/> />
)} )}
@@ -0,0 +1,99 @@
import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { reportError } from '../../utils/errors';
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
export interface IRoutesExportScreenProps {
api: ApprovalRouteApi;
}
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
// делают по одному и тому же набору типов.
const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected';
function download(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
const requestTypes = useRequestTypes(api);
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
const [isBusy, setIsBusy] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
const allIds = requestTypes.items.map(rt => rt.id);
// Удалённые с тех пор типы из сохранённого выбора не считаем.
const selected = storedSelected.filter(id => allIds.indexOf(id) !== -1);
const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1);
const toggle = (id: number, checked: boolean): void =>
setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id)));
const runExport = (): void => {
setError(undefined);
setIsBusy(true);
api.reports.routes(selected)
.then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName))
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
.then(() => setIsBusy(false), () => setIsBusy(false));
};
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
<PageHeader
icon="clipboard"
subtitle={texts.routesExport.subtitle}
title={texts.routesExport.title}
actions={
<Button
variant="accent"
icon="check"
disabled={selected.length === 0 || isBusy}
onClick={runExport}
>
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
</Button>
}
/>
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
{error && <Alert kind="err">{error}</Alert>}
<Panel>
<p>{texts.routesExport.hint}</p>
{requestTypes.isLoading && <p>{texts.common.loading}</p>}
{!requestTypes.isLoading && requestTypes.items.length === 0 && <p>{texts.routesExport.emptyTypes}</p>}
{requestTypes.items.length > 0 && (
<div>
<Checkbox
label={texts.routesExport.selectAll}
checked={allSelected}
onChange={checked => setSelected(checked ? allIds : [])}
/>
{requestTypes.items.map(rt => (
<Checkbox
key={rt.id}
label={rt.name}
checked={selected.indexOf(rt.id) !== -1}
onChange={checked => toggle(rt.id, checked)}
/>
))}
<p>{texts.routesExport.selected(selected.length)}</p>
</div>
)}
</Panel>
</div>
);
};
@@ -11,6 +11,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes'; import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { replaceRoute } from '../../routing'; import { replaceRoute } from '../../routing';
import { import {
@@ -39,6 +40,8 @@ export interface IRoutesScreenProps {
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает // Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно. // с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter'; const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
api, api,
@@ -69,7 +72,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined); const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
const [editing, setEditing] = React.useState<TemplateRoute | undefined>(); const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
const closeForm = (): void => {
setShowForm(false);
createOpen.close();
};
const [copying, setCopying] = React.useState<TemplateRoute | undefined>(); const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload); const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
@@ -83,11 +92,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
const openCreate = (): void => { const openCreate = (): void => {
setEditing(undefined); setEditing(undefined);
setShowForm(true); setShowForm(false);
createOpen.open();
}; };
const openEdit = (item: TemplateRoute): void => { const openEdit = (item: TemplateRoute): void => {
setEditing(item); setEditing(item);
createOpen.close();
setShowForm(true); setShowForm(true);
}; };
@@ -159,7 +170,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList} emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
/> />
{showForm && ( {(showForm || createOpen.isOpen) && (
<RouteFormModal <RouteFormModal
api={api} api={api}
editing={editing} editing={editing}
@@ -169,7 +180,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
departments={portalDictionaries.departments} departments={portalDictionaries.departments}
dictionaryLoading={portalDictionaries.isLoading} dictionaryLoading={portalDictionaries.isLoading}
onSaved={routes.reload} onSaved={routes.reload}
onClose={() => setShowForm(false)} onClose={closeForm}
/> />
)} )}
@@ -16,12 +16,13 @@
gap: 16px; gap: 16px;
box-shadow: $sh-1; box-shadow: $sh-1;
cursor: pointer; cursor: pointer;
@include card-hover-gold;
border: 1px solid $gray-200; border: 1px solid $gray-200;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
// Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls.
@include card-hover-gold;
&::before { &::before {
content: ''; content: '';
@@ -1,12 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import styles from './Modal.module.scss'; import styles from './Modal.module.scss';
import { Button } from './Button'; import { Button } from './Button';
import { ModalOverlay } from './ModalOverlay';
import { texts } from '../../texts'; import { texts } from '../../texts';
export interface IConfirmDialogProps { export interface IConfirmDialogProps {
title: string; title: string;
text: string; text: string;
confirmLabel: string; confirmLabel: string;
/** Подпись кнопки отказа — по умолчанию «Отмена». */
cancelLabel?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
busy?: boolean; busy?: boolean;
@@ -16,18 +19,19 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title, title,
text, text,
confirmLabel, confirmLabel,
cancelLabel = texts.common.cancel,
onConfirm, onConfirm,
onCancel, onCancel,
busy busy
}) => ( }) => (
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}> <ModalOverlay onBackdropClick={onCancel}>
<div className={`${styles.box} ${styles.narrow}`}> <div className={`${styles.box} ${styles.narrow}`}>
<div className={styles.title}>{title}</div> <div className={styles.title}>{title}</div>
<p className={styles.text}>{text}</p> <p className={styles.text}>{text}</p>
<div className={styles.narrowFooter}> <div className={styles.narrowFooter}>
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button> <Button variant="secondary" onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button> <Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
</div> </div>
</div> </div>
</div> </ModalOverlay>
); );
@@ -1,7 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { Alert } from './Alert'; import { Alert } from './Alert';
import { Button } from './Button'; import { Button } from './Button';
import { ConfirmDialog } from './ConfirmDialog';
import { Modal } from './Modal'; import { Modal } from './Modal';
import styles from './Modal.module.scss';
import { texts } from '../../texts'; import { texts } from '../../texts';
export interface IFormModalProps { export interface IFormModalProps {
@@ -15,6 +17,18 @@ export interface IFormModalProps {
savingLabel?: string; savingLabel?: string;
onSubmit: () => void; onSubmit: () => void;
onClose: () => void; onClose: () => void;
/**
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
*/
onReset?: () => void;
canReset?: boolean;
/**
* В форме есть несохранённые правки существующей записи: выход (крестик,
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
* не нужен — их черновик сохраняется сам (см. useFormDraft).
*/
isDirty?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -30,21 +44,51 @@ export const FormModal: React.FC<IFormModalProps> = ({
savingLabel = texts.common.saving, savingLabel = texts.common.saving,
onSubmit, onSubmit,
onClose, onClose,
onReset,
canReset,
isDirty,
children children
}) => ( }) => {
<Modal const [confirmingClose, setConfirmingClose] = React.useState(false);
title={title} // Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл.
onClose={onClose} const requestClose = (): void => {
footer={ if (isSaving) return;
<> if (isDirty) setConfirmingClose(true);
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button> else onClose();
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}> };
{isSaving ? savingLabel : submitLabel}
</Button> return (
</> <>
} <Modal
> title={title}
{error && <Alert kind="err">{error}</Alert>} onClose={requestClose}
{children} footer={
</Modal> <>
); {onReset && canReset && (
<span className={styles.footerStart}>
<Button variant="ghost" icon="refresh" onClick={onReset} disabled={isSaving}>{texts.common.resetDraft}</Button>
</span>
)}
<Button variant="secondary" onClick={requestClose} disabled={isSaving}>{texts.common.cancel}</Button>
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
{isSaving ? savingLabel : submitLabel}
</Button>
</>
}
>
{error && <Alert kind="err">{error}</Alert>}
{children}
</Modal>
{confirmingClose && (
<ConfirmDialog
title={texts.common.discardTitle}
text={texts.common.discardText}
confirmLabel={texts.common.discardConfirm}
cancelLabel={texts.common.discardKeepEditing}
onConfirm={onClose}
onCancel={() => setConfirmingClose(false)}
/>
)}
</>
);
};
@@ -7,9 +7,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 300; // Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится
// порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую.
z-index: 100000;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
padding: 20px; padding: 20px;
// Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss)
// сюда не наследуются, задаём заново.
font-family: $font-sans;
font-size: $fs-body;
line-height: 1.55;
box-sizing: border-box;
} }
.box { .box {
@@ -63,4 +71,7 @@
padding: 16px 26px 22px; padding: 16px 26px 22px;
} }
/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */
.footerStart { margin-right: auto; }
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; } .narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import styles from './Modal.module.scss'; import styles from './Modal.module.scss';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { ModalOverlay } from './ModalOverlay';
import { texts } from '../../texts'; import { texts } from '../../texts';
export interface IModalProps { export interface IModalProps {
@@ -11,8 +12,7 @@ export interface IModalProps {
} }
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => ( export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget). <ModalOverlay onBackdropClick={onClose}>
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.box}> <div className={styles.box}>
<div className={styles.header}> <div className={styles.header}>
<div className={styles.title}>{title}</div> <div className={styles.title}>{title}</div>
@@ -21,5 +21,5 @@ export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children
<div className={styles.body}>{children}</div> <div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>} {footer && <div className={styles.footer}>{footer}</div>}
</div> </div>
</div> </ModalOverlay>
); );
@@ -0,0 +1,25 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import styles from './Modal.module.scss';
export interface IModalOverlayProps {
/** Клик по подложке (не по содержимому окна). */
onBackdropClick: () => void;
children: React.ReactNode;
}
/**
* Затемнение под модалкой — порталом в document.body, а не внутри веб-части.
* На живой странице SharePoint канва веб-части лежит в своём контексте наложения
* ниже шапки (suite bar), и position: fixed-оверлей изнутри неё оказывался ПОД
* шапкой: верх окна, центрированного по высоте экрана, уезжал за неё. В workbench
* шапки нет — там было незаметно.
*/
export const ModalOverlay: React.FC<IModalOverlayProps> = ({ onBackdropClick, children }) =>
ReactDOM.createPortal(
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onBackdropClick(); }}>
{children}
</div>,
document.body
);
@@ -1,6 +1,6 @@
@import '../../../../styles/design-tokens'; @import '../../../../styles/design-tokens';
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может // Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может
// открыть сама длительная операция (например, разрешение конфликта), должны // открыть сама длительная операция (например, разрешение конфликта), должны
// быть ПОВЕРХ этой подложки, а не под ней. // быть ПОВЕРХ этой подложки, а не под ней.
.overlay { .overlay {
@@ -73,7 +73,7 @@
box-shadow: $sh-modal; box-shadow: $sh-modal;
border: 1px solid $gray-200; border: 1px solid $gray-200;
padding: 6px; padding: 6px;
z-index: 400; z-index: 100001; // над оверлеем модалки (Modal.module.scss)
max-height: 320px; max-height: 320px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -116,6 +116,7 @@
&:hover, &.active { background: $gold-50; color: $navy-900; } &:hover, &.active { background: $gold-50; color: $navy-900; }
&.selected { font-weight: 600; color: $navy-900; } &.selected { font-weight: 600; color: $navy-900; }
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
} }
.itemSub { .itemSub {
@@ -9,6 +9,8 @@ export interface ISearchableOption {
label: string; label: string;
/** Второстепенная строка под названием — например, компания у отдела. */ /** Второстепенная строка под названием — например, компания у отдела. */
sublabel?: string; sublabel?: string;
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
disabled?: boolean;
} }
export interface ISearchableSelectProps { export interface ISearchableSelectProps {
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
type="button" type="button"
key={opt.value} key={opt.value}
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`} className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
disabled={opt.disabled}
onClick={() => pick(opt.value)} onClick={() => pick(opt.value)}
> >
{opt.label} {opt.label}
@@ -17,6 +17,7 @@ export * from './PrimaryCell';
export * from './ActionsCell'; export * from './ActionsCell';
export * from './CellNote'; export * from './CellNote';
export * from './Modal'; export * from './Modal';
export * from './ModalOverlay';
export * from './ConfirmDialog'; export * from './ConfirmDialog';
export * from './FormModal'; export * from './FormModal';
export * from './FormGrid'; export * from './FormGrid';

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