Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5804ff3fe | |||
| 27edf8d959 | |||
| 33d106430c | |||
| f3b0f0ad48 | |||
| 0e6a7064d2 | |||
| 05f73d356a | |||
| 82c2dc36a6 | |||
| b959feedbf | |||
| dcdfcdd396 | |||
| a8bfd9f456 | |||
| 1bf8d4ca11 | |||
| 2b8fe77fac | |||
| e2b6227015 | |||
| 95d460c80a | |||
| 19a46b0df8 | |||
| b86e126ceb | |||
| ba48ed1a0e | |||
| e4a6cb8167 | |||
| a1fa862ed4 | |||
| aa97657721 | |||
| 1ee1b71e3e | |||
| 82512eb1db | |||
| a2672042d7 | |||
| 36fb71da02 | |||
| 7bd4acb7a6 | |||
| 48ede1420d |
@@ -18,3 +18,5 @@ workspace.xml
|
||||
**/bin/
|
||||
**/obj/
|
||||
.DS_Store
|
||||
logs/
|
||||
appsettings.Local.json
|
||||
@@ -0,0 +1,4 @@
|
||||
# Стайл-гайд для Клода
|
||||
|
||||
- У `enum` первое значение (0) — всегда `None`.
|
||||
- Сборка не должна выдавать ни одного warning.
|
||||
@@ -1,6 +1,12 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<!--
|
||||
Транзитивные зависимости тоже подчиняются версиям ниже. Нужно из-за Hangfire:
|
||||
он тянет Newtonsoft.Json 11.0.1 с известной уязвимостью, и без пиннинга её
|
||||
не поднять, не добавляя пакет в проекты, которым он сам по себе не нужен.
|
||||
-->
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
||||
@@ -9,6 +15,9 @@
|
||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.24" />
|
||||
<PackageVersion Include="Hangfire.Core" Version="1.8.24" />
|
||||
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||
@@ -16,10 +25,27 @@
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<!--
|
||||
Только чтобы поднять транзитивную версию из Microsoft.AspNetCore.OpenApi (2.0.0
|
||||
с уязвимостью GHSA-v5pm-xwqc-g5wc, исправлена в 2.7.5) — напрямую не используется.
|
||||
Убрать, когда Microsoft.AspNetCore.OpenApi сам подтянет исправленную версию.
|
||||
-->
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="NPOI" Version="2.7.6" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||
<!--
|
||||
Только чтобы поднять транзитивную версию из NPOI (8.0.2 с известной
|
||||
уязвимостью, см. GHSA-8q5v-6pqq-x66h и соседние advisories той же пачки)
|
||||
— напрямую не используется. Убрать, когда NPOI сам подтянет исправленную.
|
||||
-->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.11" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
|
||||
@@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
public static class AuthSchemes
|
||||
{
|
||||
/// <summary>Локальная разработка и тесты — Entra не настроен (см. AuthSetup).</summary>
|
||||
public const string Dev = "Dev";
|
||||
|
||||
/// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary>
|
||||
public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ public static class AuthSetup
|
||||
var tenantId = configuration["Entra:TenantId"];
|
||||
var audience = configuration["Entra:Audience"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
|
||||
AddEntraAuth(services, configuration, tenantId, audience);
|
||||
else
|
||||
AddDevAuth(services);
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
|
||||
throw new InvalidOperationException(
|
||||
"Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
|
||||
|
||||
AddEntraAuth(services, configuration, tenantId, audience);
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -125,26 +125,4 @@ public static class AuthSetup
|
||||
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddDevAuth(IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AuthSchemes.Dev)
|
||||
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
||||
|
||||
// Те же имена политик, но поверх Dev-схемы: иначе контроллер с
|
||||
// [Authorize(Policy = ...)] падал бы локально с «политика не найдена».
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any })
|
||||
{
|
||||
options.AddPolicy(name, policy =>
|
||||
{
|
||||
policy.AuthenticationSchemes.Add(AuthSchemes.Dev);
|
||||
policy.RequireAuthenticatedUser();
|
||||
});
|
||||
}
|
||||
|
||||
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
public sealed class DevAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IConfiguration configuration)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
|
||||
?? configuration["Dev:DefaultUserEmail"]
|
||||
?? "dev@tnsu.kz";
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Hangfire.Dashboard;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Простая Basic Auth для дашборда Hangfire — единственный логин/пароль из
|
||||
/// конфигурации (Hangfire:Dashboard), а не полноценная схема аутентификации: сам
|
||||
/// дашборд не участвует в остальной авторизации API (это отдельная страница,
|
||||
/// открываемая прямо в браузере, не по JWT).
|
||||
///
|
||||
/// Пустые логин/пароль (не заданы в конфигурации) означают «дашборд закрыт для
|
||||
/// всех» — не «открыт для всех»: пустая строка в конфиге не должна случайно снять
|
||||
/// защиту.
|
||||
/// </summary>
|
||||
public sealed class HangfireDashboardBasicAuthFilter(string username, string password) : IDashboardAuthorizationFilter
|
||||
{
|
||||
public bool Authorize(DashboardContext context)
|
||||
{
|
||||
var httpContext = context.GetHttpContext();
|
||||
|
||||
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) &&
|
||||
TryGetBasicAuthCredentials(httpContext.Request.Headers.Authorization, out var user, out var pass) &&
|
||||
FixedTimeEquals(user, username) && FixedTimeEquals(pass, password))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = "Basic realm=\"Hangfire\"";
|
||||
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetBasicAuthCredentials(
|
||||
string? authorizationHeader, out string username, out string password)
|
||||
{
|
||||
username = string.Empty;
|
||||
password = string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(authorizationHeader) ||
|
||||
!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(
|
||||
Convert.FromBase64String(authorizationHeader["Basic ".Length..].Trim()));
|
||||
var separatorIndex = decoded.IndexOf(':');
|
||||
if (separatorIndex < 0) return false;
|
||||
|
||||
username = decoded[..separatorIndex];
|
||||
password = decoded[(separatorIndex + 1)..];
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Битый base64 в заголовке — не наша забота, просто не авторизуем.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Сравнение за постоянное время: длина логина/пароля в Basic Auth и так видна
|
||||
// по заголовку, но посимвольное сравнение через == утекало бы ЕЩЁ и через
|
||||
// тайминг совпадения символов — дешёво закрыть и это.
|
||||
private static bool FixedTimeEquals(string a, string b) =>
|
||||
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
|
||||
}
|
||||
@@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Просроченные шаги согласования и исполнения на текущий момент.
|
||||
/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
|
||||
/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
|
||||
/// появления ProbationEnding.
|
||||
///
|
||||
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
||||
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
||||
@@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||
(await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) =>
|
||||
(await service.GetRemindersAsync(feed, ct)).ToActionResult();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузки отчётов файлами. В отличие от остальных контроллеров отдаёт не
|
||||
/// ApiResponse, а сам файл: получатель — кнопка «скачать» в портале и Power
|
||||
/// Automate, обоим нужен готовый xlsx, а не JSON с телом в base64.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Any)]
|
||||
[Route("api/reports")]
|
||||
public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
|
||||
{
|
||||
private const string XlsxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям за период — лист «Список вакансий (все)» по данным
|
||||
/// заявок на подбор и на приём.
|
||||
///
|
||||
/// Период отбирает заявки по «Дате подачи заявки» (колонка H отчёта). Заявки
|
||||
/// на приём периодом НЕ ограничены: у вакансии, поданной в начале интервала,
|
||||
/// приём мог оформляться уже после его конца.
|
||||
/// </summary>
|
||||
[HttpGet("vacancies")]
|
||||
[Produces(XlsxContentType)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Vacancies(
|
||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct = default)
|
||||
{
|
||||
var result = await vacancies.GetAsync(from, to, ct);
|
||||
if (result.IsFailed)
|
||||
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
||||
|
||||
var report = result.Value;
|
||||
var file = VacancyReportWorkbook.Build(report);
|
||||
|
||||
// Имя файла с кириллицей и пробелами — через ContentDispositionHeaderValue:
|
||||
// он сам добавит filename* в RFC 5987, иначе браузер сохранит файл
|
||||
// с искажённым именем.
|
||||
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||
disposition.SetHttpFileName(VacancyReportWorkbook.FileName(report));
|
||||
Response.Headers.ContentDisposition = disposition.ToString();
|
||||
|
||||
return File(file, XlsxContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||
/// POST — потому что в теле список id типов (см. RouteExportService).
|
||||
/// </summary>
|
||||
[HttpPost("routes")]
|
||||
[Produces(XlsxContentType)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Routes([FromBody] RouteExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
|
||||
if (result.IsFailed)
|
||||
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
||||
|
||||
var file = result.Value;
|
||||
|
||||
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||
disposition.SetHttpFileName(file.FileName);
|
||||
Response.Headers.ContentDisposition = disposition.ToString();
|
||||
|
||||
return File(file.Content, XlsxContentType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Чтение заявок и маршрутов согласования от имени приложения (см.
|
||||
/// RequestManageService) — для вкладок «Редактирование заявок» и «Заявки по
|
||||
/// согласующему»: результат не зависит от прав вызывающего пользователя на
|
||||
/// списки SharePoint, в отличие от прежнего прямого PnP-чтения с фронтенда.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/request-manage")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RequestManageController(RequestManageService service) : ControllerBase
|
||||
{
|
||||
/// <summary>Заявки типа requestTypeId по всем партициям (архив + текущая), постранично.</summary>
|
||||
[HttpGet("search")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ManagedRequestPageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery] int requestTypeId,
|
||||
// string? (не string) — [ApiController] с Nullable enable иначе считает
|
||||
// query обязательным параметром модели, а IHttpClient.buildUrl (см.
|
||||
// httpClient.ts) вырезает из URL пустые query-параметры целиком: пустое
|
||||
// поле поиска (обычное состояние при открытии комбобокса) присылало
|
||||
// запрос вовсе БЕЗ query, и такой запрос падал 400 ещё до контроллера.
|
||||
[FromQuery] string? query,
|
||||
[FromQuery] int page,
|
||||
[FromQuery] int? pageSize,
|
||||
CancellationToken ct) =>
|
||||
(await service.SearchAsync(requestTypeId, query ?? string.Empty, page < 1 ? 1 : page, pageSize, ct)).ToActionResult();
|
||||
|
||||
/// <summary>Заявка + полный маршрут согласования (обе фазы, по StepOrder).</summary>
|
||||
[HttpGet("matrix")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ManagedRequestMatrixDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Matrix(
|
||||
[FromQuery] int requestTypeId, [FromQuery] string listId, [FromQuery] int requestId, CancellationToken ct) =>
|
||||
(await service.GetMatrixAsync(requestTypeId, listId, requestId, ct)).ToActionResult();
|
||||
|
||||
/// <summary>
|
||||
/// Активные строки согласования одного человека сразу по всем типам заявок и
|
||||
/// партициям. statuses — через запятую (см. REQUEST_STATUS во фронтенде).
|
||||
/// </summary>
|
||||
[HttpGet("by-approver")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserApprovalRowDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ByApprover(
|
||||
[FromQuery] string approverEmail, [FromQuery] string? statuses, CancellationToken ct)
|
||||
{
|
||||
var activeStatuses = (statuses ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
|
||||
return (await service.FindActiveApprovalRowsForApproverAsync(approverEmail ?? string.Empty, activeStatuses, ct))
|
||||
.ToActionResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Настройка доступа «видеть все заявки типа», а не только свои. Список
|
||||
/// email'ов по типу дёргает и портал заявок (проверить текущего пользователя),
|
||||
/// и Power Automate — поэтому политика AnyAuth, как у RemindersController.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Any)]
|
||||
[Route("api/request-type-access")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RequestTypeAccessController(RequestTypeAccessService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeAccessDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
|
||||
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||
|
||||
[HttpGet("{requestTypeId:int}/emails")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ListEmails(int requestTypeId, CancellationToken ct) =>
|
||||
(await service.ListEmailsAsync(requestTypeId, ct)).ToActionResult();
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ApiResponse<RequestTypeAccessDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create(CreateRequestTypeAccessRequest body, CancellationToken ct) =>
|
||||
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас
|
||||
/// и вся серия для админки/портала.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/request-types")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Серии партиций ВСЕХ типов заявок разом — для портала: чтобы искать по
|
||||
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
|
||||
/// запрашивать серию по каждому типу отдельно.
|
||||
/// </summary>
|
||||
/// <param name="title">
|
||||
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||
/// без учёта регистра, по вхождению подстроки.
|
||||
/// </param>
|
||||
[HttpGet("lists")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ListAll([FromQuery] string? title, CancellationToken ct) =>
|
||||
(await service.ListAllAsync(title, ct)).ToActionResult();
|
||||
|
||||
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
|
||||
[HttpGet("{requestTypeId:int}/lists")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestListPartitionDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> List(int requestTypeId, CancellationToken ct) =>
|
||||
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||
|
||||
/// <summary>
|
||||
/// Партиция, в которую нужно писать новые заявки этого типа.
|
||||
///
|
||||
/// Не чистый GET: если партиция добрала порог элементов, запрос ставит в очередь
|
||||
/// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync).
|
||||
/// </summary>
|
||||
[HttpGet("{requestTypeId:int}/lists/current")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CurrentListPartitionDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Current(int requestTypeId, CancellationToken ct) =>
|
||||
(await service.GetCurrentAsync(requestTypeId, ct)).ToActionResult();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Поиск шагов шаблонов маршрутов НЕ по конкретному маршруту, а сквозной, по всем
|
||||
/// маршрутам разом — отдельный top-level контроллер, а не вложенный под
|
||||
/// /api/template-routes/{routeId}/steps (тот же приём, что RequestTypeListsController
|
||||
/// отдельно от RequestTypesController).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/template-route-steps")]
|
||||
[Produces("application/json")]
|
||||
public sealed class TemplateRouteStepSearchController(TemplateRouteStepService service) : ControllerBase
|
||||
{
|
||||
/// <summary>Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «заменить согласующего everywhere».</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] string executorEmail, CancellationToken ct) =>
|
||||
(await service.ListByExecutorEmailAsync(executorEmail, ct)).ToActionResult();
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Extensions.Hosting.AsyncInitialization;
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Api.Filters;
|
||||
using Tnsu.ApprovalRoute.Application;
|
||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Infrastructure;
|
||||
|
||||
@@ -17,12 +24,26 @@ const string CorsPolicyName = "Frontend";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Локальные оверрайды, не попадающие в git (см. .gitignore) — на своей машине
|
||||
// можно, например, занизить ListPartitioning:ItemCountThreshold, чтобы не
|
||||
// накапливать тысячи тестовых заявок для проверки создания партиций.
|
||||
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
|
||||
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
"logs/log-.txt",
|
||||
rollingInterval: RollingInterval.Month,
|
||||
retainedFileCountLimit: 6));
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
@@ -32,11 +53,27 @@ builder.Services.AddCors(options =>
|
||||
options.AddPolicy(CorsPolicyName, policy => policy
|
||||
.WithOrigins(corsAllowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod());
|
||||
.AllowAnyMethod()
|
||||
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
|
||||
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
|
||||
.WithExposedHeaders("Content-Disposition"));
|
||||
});
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||
builder.Services.AddApprovalRouteApplication();
|
||||
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
||||
builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports"));
|
||||
builder.Services.Configure<AutoApproveOptions>(builder.Configuration.GetSection("AutoApprove"));
|
||||
|
||||
builder.Services.AddHangfire(configuration => configuration
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UsePostgreSqlStorage(postgres =>
|
||||
postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres"))));
|
||||
|
||||
builder.Services.AddHangfireServer();
|
||||
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
||||
builder.Services.AddOpenApi(options =>
|
||||
@@ -92,6 +129,38 @@ app.UseCors(CorsPolicyName);
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
Authorization =
|
||||
[
|
||||
new HangfireDashboardBasicAuthFilter(
|
||||
app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty,
|
||||
app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty)
|
||||
]
|
||||
});
|
||||
|
||||
// Рекуррентная задача регистрируется ПОСЛЕ Build (нужен собранный контейнер) и
|
||||
// на каждом старте: AddOrUpdate идемпотентен и заодно подхватывает изменившееся
|
||||
// расписание из конфигурации — иначе задача навсегда осталась бы с тем cron, с
|
||||
// которым её создали в первый раз.
|
||||
var autoApprove = app.Services.GetRequiredService<IOptions<AutoApproveOptions>>().Value;
|
||||
var recurringJobs = app.Services.GetRequiredService<IRecurringJobManager>();
|
||||
if (autoApprove.Enabled)
|
||||
{
|
||||
recurringJobs.AddOrUpdate<AutoApproveJob>(
|
||||
AutoApproveJob.RecurringJobId,
|
||||
job => job.RunAsync(CancellationToken.None),
|
||||
autoApprove.Cron,
|
||||
// Cron в настройке — в UTC, как и всё остальное время в сервисе; иначе
|
||||
// расписание поехало бы при смене часового пояса машины.
|
||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Выключили настройкой — снимаем задачу, а не оставляем её вхолостую крутиться.
|
||||
recurringJobs.RemoveIfExists(AutoApproveJob.RecurringJobId);
|
||||
}
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
await app.InitAndRunAsync();
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.*.json" DependentUpon="appsettings.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
@@ -12,5 +17,8 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"Entra": {
|
||||
"TenantId": "",
|
||||
"Audience": ""
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||
},
|
||||
"ListPartitioning": {
|
||||
"ItemCountThreshold": 99999
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Hangfire": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
@@ -13,20 +18,34 @@
|
||||
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"AllowedAppIds": []
|
||||
},
|
||||
"Dev": {
|
||||
"DefaultUserEmail": "dev@tnsu.kz"
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"https://tnsukz.sharepoint.com",
|
||||
"https://localhost:4321"
|
||||
]
|
||||
},
|
||||
"AutoApprove": {
|
||||
"Enabled": true,
|
||||
"Cron": "0 * * * *",
|
||||
"MaxRowsPerRun": 200
|
||||
},
|
||||
"ListPartitioning": {
|
||||
"ItemCountThreshold": 4900,
|
||||
"StaleLockAfter": "00:30:00",
|
||||
"CheckThresholdOnRead": true
|
||||
},
|
||||
"Hangfire": {
|
||||
"Dashboard": {
|
||||
"Username": "admin",
|
||||
"Password": "RzaT2krczbRpE36QAucdCXR"
|
||||
}
|
||||
},
|
||||
"SharePoint": {
|
||||
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"CertificateSettingName": "AzurePfx",
|
||||
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
|
||||
"CertificatePassword": "Rk.xDjccnfyjdktybz1",
|
||||
"NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint"
|
||||
},
|
||||
"TnsuPortal": {
|
||||
"BaseUrl": "https://api.tnsu.kz",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
@@ -14,6 +19,16 @@ public static class ApplicationServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<ReminderService>();
|
||||
services.AddScoped<RequestTypeService>();
|
||||
services.AddScoped<RequestTypeAccessService>();
|
||||
services.AddScoped<RequestManageService>();
|
||||
services.AddScoped<ListPartitionRegistry>();
|
||||
services.AddScoped<ListPartitionService>();
|
||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||
services.AddScoped<CreateNextListPartitionJob>();
|
||||
services.AddScoped<AutoApproveService>();
|
||||
services.AddScoped<AutoApproveJob>();
|
||||
services.AddScoped<VacancyReportService>();
|
||||
services.AddScoped<RouteExportService>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
services.AddScoped<TemplateRouteStepService>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
-9
@@ -1,45 +1,63 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
||||
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
||||
/// именно он эти списки и создаёт.
|
||||
///
|
||||
/// Общая для напоминаний (ReminderService) и автосогласования
|
||||
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
|
||||
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
|
||||
/// просроченным шаг считается в одном месте и не считается в другом.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalRow(
|
||||
int Id,
|
||||
int TicketId,
|
||||
int StepOrder,
|
||||
string Status,
|
||||
string Phase,
|
||||
DateTimeOffset? Date,
|
||||
string? ApproverEmail,
|
||||
string? ApproverName,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason)
|
||||
bool? AskOverdueReason,
|
||||
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
|
||||
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
|
||||
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
|
||||
// сценария — см. RowSelect у каждого из них.
|
||||
double Order = 0,
|
||||
bool AutoApprove = false)
|
||||
{
|
||||
public static ApprovalRow? TryParse(JsonElement item)
|
||||
{
|
||||
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
||||
|
||||
var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||
? GetString(a, "EMail")
|
||||
: null;
|
||||
var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||
? a
|
||||
: (JsonElement?)null;
|
||||
|
||||
return new ApprovalRow(
|
||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||
ticketId,
|
||||
TryGetInt(item, "StepOrder", out var order) ? order : 0,
|
||||
GetString(item, "ApprovalStatus") ?? string.Empty,
|
||||
// Пустая фаза — старые строки, заведённые до появления колонки;
|
||||
// трактуем как «Согласование» (так же поступает request-portal).
|
||||
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
||||
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,
|
||||
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) =>
|
||||
@@ -79,6 +97,18 @@ internal sealed record ApprovalRow(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Дробное число из JSON — с теми же оговорками, что и <see cref="TryGetInt"/>.</summary>
|
||||
private static bool TryGetDouble(JsonElement e, string name, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (!e.TryGetProperty(name, out var v)) return false;
|
||||
|
||||
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out value);
|
||||
|
||||
return v.ValueKind == JsonValueKind.String &&
|
||||
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
private static bool TryGetBool(JsonElement e, string name, out bool value)
|
||||
{
|
||||
value = false;
|
||||
@@ -108,8 +138,34 @@ internal static class ApprovalPhases
|
||||
public const string Execution = "Исполнение";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
|
||||
/// Один в один с REQUEST_STATUS в request-portal
|
||||
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
|
||||
/// </summary>
|
||||
internal static class ApprovalStatuses
|
||||
{
|
||||
public const string Queued = "Ждёт очереди";
|
||||
public const string OnApproval = "На согласовании";
|
||||
public const string OnExecution = "На исполнении";
|
||||
public const string Approved = "Согласовано";
|
||||
public const string Executed = "Выполнено";
|
||||
public const string Rejected = "Отклонено";
|
||||
public const string Returned = "Возвращена на доработку";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значения колонки RequestStatus — статус ЗАЯВКИ целиком (список «… — данные»).
|
||||
/// Набор ДРУГОЙ, чем у строки маршрута: «Ждёт очереди»/«На исполнении» у заявки
|
||||
/// не бывает, а «Выполнение»/«Закрыта» не бывает у строки (см. тот же
|
||||
/// statuses.ts, где оба набора сведены).
|
||||
/// </summary>
|
||||
internal static class RequestStatuses
|
||||
{
|
||||
public const string OnApproval = "На согласовании";
|
||||
public const string Approved = "Согласовано";
|
||||
public const string Rejected = "Отклонено";
|
||||
public const string Returned = "Возвращена на доработку";
|
||||
public const string Execution = "Выполнение";
|
||||
public const string Closed = "Закрыта";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
/// <summary>
|
||||
/// Рекуррентная задача автосогласования — тонкая обёртка над
|
||||
/// <see cref="AutoApproveService"/>: всё, что она добавляет сверху, — расписание,
|
||||
/// защита от наложения запусков и одна строка в логе с итогом.
|
||||
///
|
||||
/// Повторы отключены (Attempts = 0) намеренно, в отличие от
|
||||
/// CreateNextListPartitionJob: там повтор нужен, потому что пропуск задачи ломает
|
||||
/// сценарий целиком (список не создан — писать некуда). Здесь же следующий запуск
|
||||
/// придёт сам, через час, и сделает ровно ту же работу с актуальным состоянием —
|
||||
/// уже согласованные строки в выборку не попадут. Повторять упавший проход раньше
|
||||
/// этого смысла нет, а висящие в очереди повторы только прятали бы настоящую
|
||||
/// причину сбоя.
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 0)]
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 5 * 60)]
|
||||
public sealed class AutoApproveJob(AutoApproveService service, ILogger<AutoApproveJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Идентификатор рекуррентной задачи в Hangfire. Константой — по нему задача и
|
||||
/// заводится, и снимается при выключении настройки (см. Program.cs): разъехавшись,
|
||||
/// эти два имени оставили бы в дашборде задачу, которую уже некому снять.
|
||||
/// </summary>
|
||||
public const string RecurringJobId = "auto-approve";
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var approved = await service.RunAsync(cancellationToken);
|
||||
logger.LogInformation("Автосогласование: строк согласовано автоматически — {Count}", approved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
public sealed class AutoApproveOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Включено ли автосогласование вообще. Выключение снимает рекуррентную задачу
|
||||
/// из Hangfire (см. Program.cs), а не просто делает её пустой: иначе в дашборде
|
||||
/// осталась бы задача, которая регулярно запускается и ничего не делает.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Расписание запуска (cron, UTC). Раз в час: SLA задаётся в ЧАСАХ, поэтому
|
||||
/// час — предельная точность, которая вообще имеет смысл, а более частый опрос
|
||||
/// всех списков SharePoint ничего не ускоряет по существу.
|
||||
///
|
||||
/// Следствие: шаг согласуется не в сам момент истечения SLA, а на ближайшем
|
||||
/// запуске после него — то есть в пределах часа позже. Это осознанно: раньше
|
||||
/// срока не согласуется никогда, а опоздание в пределах часа для срока,
|
||||
/// измеряемого часами, роли не играет.
|
||||
/// </summary>
|
||||
public string Cron { get; set; } = "0 * * * *";
|
||||
|
||||
/// <summary>
|
||||
/// Комментарий, который задача пишет в строку согласования вместо комментария
|
||||
/// согласующего. Нужен, чтобы в матрице заявки было видно, что решение принял не
|
||||
/// человек: других признаков у автосогласованной строки нет — статус и дата у неё
|
||||
/// такие же, как у обычной.
|
||||
/// </summary>
|
||||
public string Comment { get; set; } = "Согласовано автоматически: истёк срок SLA по этому шагу.";
|
||||
|
||||
/// <summary>
|
||||
/// Сколько строк автосогласовать максимум за один запуск. Предохранитель на
|
||||
/// случай массового включения флага или сдвига дат: без него один запуск мог бы
|
||||
/// пройти по всему архиву партиций и написать в SharePoint тысячи изменений,
|
||||
/// упёршись в троттлинг. Остаток уедет в следующий запуск — задача идемпотентна
|
||||
/// (уже решённые строки в выборку активных не попадают).
|
||||
/// </summary>
|
||||
public int MaxRowsPerRun { get; set; } = 200;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
/// <summary>
|
||||
/// Автосогласование по SLA: находит строки списков согласования, у которых
|
||||
/// включён флаг «Автосогласование» (колонка AutoApprove) и истёк срок SLA, и
|
||||
/// принимает за согласующего положительное решение.
|
||||
///
|
||||
/// Это ЗЕРКАЛО клиентской логики решения из request-portal
|
||||
/// (shared/approval/approverDecision.service.ts — decide, и approvalList.service.ts —
|
||||
/// setDecision/activateNextQueued): та же последовательность «пометить строку →
|
||||
/// продвинуть очередь → пересчитать статус заявки», просто выполненная сервером
|
||||
/// от имени приложения, а не браузером от имени человека. Расхождение между ними
|
||||
/// означало бы, что автосогласованная заявка встаёт в состояние, в которое
|
||||
/// портал её сам никогда не приводит, поэтому правила здесь повторены буквально,
|
||||
/// а не «по смыслу» (см. ActivationTargets и ComputeRequestStatus).
|
||||
///
|
||||
/// В отличие от <see cref="Reminders.ReminderService"/> (срез «кто просрочил»,
|
||||
/// без записи) задача именно ПИШЕТ в SharePoint, поэтому обход здесь
|
||||
/// последовательный: параллелить запись по типам заявок — это упереться в
|
||||
/// троттлинг и потерять предсказуемость порядка, а объём работы за один запуск
|
||||
/// ограничен сверху (см. AutoApproveOptions.MaxRowsPerRun).
|
||||
/// </summary>
|
||||
public sealed class AutoApproveService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<AutoApproveOptions> options,
|
||||
ILogger<AutoApproveService> logger)
|
||||
{
|
||||
// Активные строки С ВКЛЮЧЁННЫМ автосогласованием — отбор целиком на стороне
|
||||
// SharePoint, чтобы не тащить сюда весь список ради единиц строк.
|
||||
//
|
||||
// Да/Нет-колонка в $filter сравнивается с 1/0, а не с true/false.
|
||||
//
|
||||
// У строк, заведённых ДО появления колонки AutoApprove, значение пустое, и
|
||||
// под `eq 1` они не попадают — это верно по смыслу: включить автосогласование
|
||||
// задним числом нельзя, флаг проставляется при отправке заявки (портал) или
|
||||
// вручную в админке.
|
||||
private const string CandidateFilter =
|
||||
$"$filter=(ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}')" +
|
||||
" and AutoApprove eq 1";
|
||||
|
||||
private const string RowSelect =
|
||||
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,AutoApprove,StepOrder,TicketId&$top=5000";
|
||||
|
||||
/// <summary>
|
||||
/// Один проход по всем типам заявок. Возвращает число автосогласованных строк —
|
||||
/// его пишет в лог задача (см. AutoApproveJob), и по нему же удобно понять из
|
||||
/// дашборда Hangfire, делал ли запуск хоть что-нибудь.
|
||||
/// </summary>
|
||||
public async Task<int> RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// В отличие от напоминаний, ТИПЫ БЕЗ SlaHours отсюда НЕ отсеиваются: у типа
|
||||
// SLA может быть не задан, а у конкретного шага — задан, и именно такой шаг
|
||||
// чаще всего и делают автосогласуемым («это согласование формальное, ждём
|
||||
// сутки и идём дальше»). Шаг, у которого SLA нет ни там, ни там, отсеется
|
||||
// ниже поштучно (см. DeadlineOf).
|
||||
var types = await db.RequestTypes.AsNoTracking()
|
||||
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var approved = 0;
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (approved >= options.Value.MaxRowsPerRun) break;
|
||||
|
||||
try
|
||||
{
|
||||
approved += await RunForTypeAsync(type, now, options.Value.MaxRowsPerRun - approved, cancellationToken);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Недоступный список одного типа не должен останавливать остальные —
|
||||
// ровно как в напоминаниях.
|
||||
logger.LogError(e, "Автосогласование по типу заявки «{Type}» не выполнено", type.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return approved;
|
||||
}
|
||||
|
||||
private async Task<int> RunForTypeAsync(
|
||||
RequestType type, DateTimeOffset now, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||
|
||||
// Архив обходится наравне с текущей партицией: заявка, уехавшая в архив,
|
||||
// продолжает ждать решения так же, как в текущем списке, и её шаг точно так
|
||||
// же просрочивается (см. тот же довод в ReminderService).
|
||||
var partitions = archived
|
||||
.Select(x => (x.ListId, x.Number))
|
||||
.Append((current.ListId, current.Number));
|
||||
|
||||
var approved = 0;
|
||||
foreach (var (listId, number) in partitions)
|
||||
{
|
||||
if (approved >= limit) break;
|
||||
|
||||
try
|
||||
{
|
||||
approved += await RunForPartitionAsync(type, listId, now, limit - approved, cancellationToken);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(
|
||||
e, "Автосогласование по списку {ListId} (партиция №{Number}) типа «{Type}» не выполнено",
|
||||
listId, number, type.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return approved;
|
||||
}
|
||||
|
||||
private async Task<int> RunForPartitionAsync(
|
||||
RequestType type, string listId, DateTimeOffset now, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var siteUrl = type.SiteUrl!;
|
||||
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(dataListTitle)) return 0;
|
||||
|
||||
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
|
||||
if (approvalListTitle is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти",
|
||||
dataListTitle);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var candidates = await ReadRowsAsync(
|
||||
siteUrl, approvalListTitle, $"{RowSelect}&{CandidateFilter}", cancellationToken);
|
||||
if (candidates.Count == 0) return 0;
|
||||
|
||||
var approved = 0;
|
||||
// По заявкам, а не по строкам подряд: решение по одной строке двигает
|
||||
// очередь и меняет статус всей заявки, поэтому строки одной заявки нельзя
|
||||
// обрабатывать по устаревшему снимку — внутри группы состояние
|
||||
// перечитывается (см. ApproveAsync).
|
||||
foreach (var ticket in candidates.GroupBy(r => r.TicketId))
|
||||
{
|
||||
if (approved >= limit) return approved;
|
||||
|
||||
foreach (var candidate in ticket.OrderBy(r => r.Order))
|
||||
{
|
||||
if (approved >= limit) return approved;
|
||||
|
||||
var deadline = DeadlineOf(candidate, type);
|
||||
if (deadline is null || deadline > now) continue;
|
||||
|
||||
var done = await ApproveAsync(
|
||||
type, siteUrl, approvalListTitle, listId, candidate, deadline.Value, now, cancellationToken);
|
||||
if (done) approved++;
|
||||
}
|
||||
}
|
||||
|
||||
return approved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Момент, после которого шаг считается просроченным. null — SLA не задан ни на
|
||||
/// строке, ни на типе заявки, либо не заполнена дата активации: отсчитывать не
|
||||
/// от чего, и автосогласовать такой шаг нельзя — он «висел» бы бесконечно, а
|
||||
/// сработал бы в произвольный момент после того, как SLA всё-таки настроят.
|
||||
/// </summary>
|
||||
private DateTimeOffset? DeadlineOf(ApprovalRow row, RequestType type)
|
||||
{
|
||||
if (row.Date is not { } activatedAt)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
|
||||
"но дата активации (Date) не заполнена — пропущена",
|
||||
row.Id, row.TicketId, type.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Как и в напоминаниях: SLA строки перекрывает SLA типа заявки, и ноль —
|
||||
// это ноль («сразу»), а не «не задано».
|
||||
var slaHours = row.SlaHours ?? type.SlaHours;
|
||||
if (slaHours is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
|
||||
"но SLA не задан ни на шаге, ни на типе заявки — пропущена",
|
||||
row.Id, row.TicketId, type.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return activatedAt.AddHours(slaHours.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Решение по одной строке. Возвращает false, если строку решать уже не нужно —
|
||||
/// её успел закрыть человек между выборкой кандидатов и этим моментом.
|
||||
///
|
||||
/// Проверка «строка всё ещё активна» делается по СВЕЖЕМУ чтению, а не по
|
||||
/// кандидату из выборки: между ними проходит сколько угодно времени (обход
|
||||
/// партиций, сеть), и записать «Согласовано» поверх только что поставленного
|
||||
/// человеком «Отклонено» — худшее, что эта задача может сделать. Полностью
|
||||
/// гонку это не закрывает (решение может прийти и в миллисекунду между
|
||||
/// проверкой и записью), но сводит окно к одному HTTP-запросу вместо всего
|
||||
/// прохода; блокировок на элемент списка SharePoint не предоставляет.
|
||||
/// </summary>
|
||||
private async Task<bool> ApproveAsync(
|
||||
RequestType type, string siteUrl, string approvalListTitle, string dataListUrl,
|
||||
ApprovalRow candidate, DateTimeOffset deadline, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await ReadTicketRowsAsync(siteUrl, approvalListTitle, candidate.TicketId, cancellationToken);
|
||||
|
||||
var row = rows.FirstOrDefault(r => r.Id == candidate.Id);
|
||||
if (row is null || !IsActive(row))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Строка {Row} заявки {Ticket} уже не ждёт решения — автосогласование не требуется",
|
||||
candidate.Id, candidate.TicketId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Фаза исполнения закрывается статусом «Выполнено», а не «Согласовано» —
|
||||
// это те же два значения, что ставит DecisionPanel портала для решений
|
||||
// approve и execute.
|
||||
var decision = row.Phase == ApprovalPhases.Execution
|
||||
? ApprovalStatuses.Executed
|
||||
: ApprovalStatuses.Approved;
|
||||
|
||||
await sharePoint.UpdateItemByListTitleAsync(
|
||||
siteUrl, approvalListTitle, row.Id,
|
||||
new
|
||||
{
|
||||
ApprovalStatus = decision,
|
||||
// Дата РЕШЕНИЯ перезаписывает дату активации — так же, как
|
||||
// setDecision в портале (колонка Date у строки одна).
|
||||
Date = now.UtcDateTime,
|
||||
Comment = options.Value.Comment
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Строка {Row} заявки {Ticket} типа «{Type}» автосогласована ({Decision}): срок SLA истёк {Deadline:u}",
|
||||
row.Id, row.TicketId, type.Name, decision, deadline);
|
||||
|
||||
// Дальше считаем по снимку, в котором решение УЖЕ применено: перечитывать
|
||||
// список после каждой записи незачем — мы сами знаем, что изменили, а
|
||||
// лишние чтения на каждом шаге заметно удлиняют проход.
|
||||
var updated = rows
|
||||
.Select(r => r.Id == row.Id ? r with { Status = decision } : r)
|
||||
.ToList();
|
||||
|
||||
var activated = ActivationTargets(updated);
|
||||
foreach (var target in activated)
|
||||
{
|
||||
await sharePoint.UpdateItemByListTitleAsync(
|
||||
siteUrl, approvalListTitle, target.Row.Id,
|
||||
// Активация — это статус ПЛЮС отметка времени «получил на
|
||||
// согласование»: от неё отсчитывается SLA следующего шага (см.
|
||||
// setActive в портале). Без даты шаг выпал бы и из напоминаний, и
|
||||
// из автосогласования.
|
||||
new { ApprovalStatus = target.Status, Date = now.UtcDateTime },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var target in activated)
|
||||
updated[updated.FindIndex(r => r.Id == target.Row.Id)] = target.Row with { Status = target.Status };
|
||||
|
||||
await sharePoint.UpdateItemByListUrlAsync(
|
||||
siteUrl, dataListUrl, row.TicketId,
|
||||
new { RequestStatus = ComputeRequestStatus(updated) },
|
||||
cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsActive(ApprovalRow row) =>
|
||||
row.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution;
|
||||
|
||||
/// <summary>
|
||||
/// Кого активировать после положительного решения — зеркало
|
||||
/// ApprovalListService.activateNextQueued из request-portal.
|
||||
///
|
||||
/// Среди ещё ждущих строк текущей фазы активируются те, что делят НАИМЕНЬШИЙ
|
||||
/// StepOrder: обычно это одна строка, несколько — параллельный шаг. Фаза
|
||||
/// исполнения активируется, только когда фаза согласования пройдена целиком;
|
||||
/// если согласование остановлено (отклонение/возврат), не активируется никто.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<(ApprovalRow Row, string Status)> ActivationTargets(IReadOnlyList<ApprovalRow> rows)
|
||||
{
|
||||
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
|
||||
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
|
||||
|
||||
if (approvalRows.Any(r => r.Status is ApprovalStatuses.Rejected or ApprovalStatuses.Returned))
|
||||
return [];
|
||||
|
||||
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
|
||||
|
||||
var (phaseRows, activeStatus) = approvalDone
|
||||
? (execRows, ApprovalStatuses.OnExecution)
|
||||
: (approvalRows, ApprovalStatuses.OnApproval);
|
||||
|
||||
// В фазе уже есть активная строка — очередь двигать не нужно (так бывает при
|
||||
// параллельном шаге, где решение принято ещё не всеми).
|
||||
if (phaseRows.Any(r => r.Status == activeStatus)) return [];
|
||||
|
||||
var queued = phaseRows.Where(r => r.Status == ApprovalStatuses.Queued).ToList();
|
||||
if (queued.Count == 0) return [];
|
||||
|
||||
var minOrder = queued.Min(r => r.Order);
|
||||
return queued.Where(r => r.Order == minOrder).Select(r => (r, activeStatus)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Статус ЗАЯВКИ по строкам маршрута — зеркало computeRequestStatus из
|
||||
/// request-portal (approverDecision.service.ts). Порядок проверок важен и
|
||||
/// повторён дословно: отклонение и возврат старше незавершённого согласования,
|
||||
/// а «Согласовано» — финал только для маршрута без фазы исполнения.
|
||||
/// </summary>
|
||||
private static string ComputeRequestStatus(IReadOnlyList<ApprovalRow> rows)
|
||||
{
|
||||
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
|
||||
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
|
||||
|
||||
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Rejected)) return RequestStatuses.Rejected;
|
||||
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Returned)) return RequestStatuses.Returned;
|
||||
|
||||
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
|
||||
if (!approvalDone) return RequestStatuses.OnApproval;
|
||||
|
||||
if (execRows.Count == 0) return RequestStatuses.Approved;
|
||||
if (execRows.All(r => r.Status == ApprovalStatuses.Executed)) return RequestStatuses.Closed;
|
||||
return RequestStatuses.Execution;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<ApprovalRow>> ReadTicketRowsAsync(
|
||||
string siteUrl, string approvalListTitle, int ticketId, CancellationToken cancellationToken) =>
|
||||
ReadRowsAsync(siteUrl, approvalListTitle, $"{RowSelect}&$filter=TicketId eq {ticketId}", cancellationToken);
|
||||
|
||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await sharePoint.GetItemsByListTitleAsync(siteUrl, listTitle, odataQuery, cancellationToken);
|
||||
return items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Русские названия типов руководителя и ролей в проекте — для всего, что видит
|
||||
/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя
|
||||
/// enum'а («DirectionHead») в тексте для пользователя недопустимо.
|
||||
/// </summary>
|
||||
public static class StepLabels
|
||||
{
|
||||
public static string Of(HeadType headType) => headType switch
|
||||
{
|
||||
HeadType.Chief => "Непосредственный руководитель",
|
||||
HeadType.DirectionHead => "Руководитель направления",
|
||||
HeadType.DirectorTansu => "Директор Tansu",
|
||||
HeadType.ManagingDirectorTansu => "Управляющий директор Tansu",
|
||||
HeadType.DepartmentHead => "Руководитель указанного отдела",
|
||||
HeadType.CompanyDirectors => "Директора компаний инициатора",
|
||||
_ => headType.ToString()
|
||||
};
|
||||
|
||||
/// <summary>Название с маленькой буквы — для середины фразы («Не найден руководитель направления»).</summary>
|
||||
public static string InSentence(HeadType headType) => Lower(Of(headType));
|
||||
|
||||
/// <summary>
|
||||
/// Все названия мужского рода («найден руководитель проекта»), кроме «Безопасность» —
|
||||
/// для неё нужна отдельная формулировка, иначе выйдет «найден безопасность».
|
||||
/// </summary>
|
||||
public static string InSentence(ProjectRole projectRole) =>
|
||||
projectRole == ProjectRole.Security ? "сотрудник службы безопасности" : Lower(Of(projectRole));
|
||||
|
||||
private static string Lower(string label) => char.ToLowerInvariant(label[0]) + label[1..];
|
||||
|
||||
public static string Of(ProjectRole projectRole) => projectRole switch
|
||||
{
|
||||
ProjectRole.ProjectManager => "Руководитель проекта",
|
||||
ProjectRole.Planner => "Планировщик",
|
||||
ProjectRole.ManagerHSE => "Менеджер HSE",
|
||||
ProjectRole.Security => "Безопасность",
|
||||
ProjectRole.Keeper => "Заведующий склада",
|
||||
_ => projectRole.ToString()
|
||||
};
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using System.Diagnostics;
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в
|
||||
/// реестр и переключить на неё запись.
|
||||
///
|
||||
/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной
|
||||
/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое
|
||||
/// выполнение — разные события во времени: задача может полежать в очереди, быть
|
||||
/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда.
|
||||
/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но
|
||||
/// не с точки зрения блокировки: settled только выполнение может её удержать, и
|
||||
/// проверять это надо на каждом фактическом входе в код, создающий список в
|
||||
/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на
|
||||
/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос,
|
||||
/// поймавший тот же порог до переключения списка) синхронизировались бы неверно.
|
||||
///
|
||||
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
|
||||
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
|
||||
/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если
|
||||
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
|
||||
/// а не молотить впустую.
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 3)]
|
||||
public sealed class CreateNextListPartitionJob(
|
||||
ApprovalRouteDbContext db,
|
||||
ListPartitioningLock provisioningLock,
|
||||
ListPartitionRegistry registry,
|
||||
ISharePointListProvisioner provisioner,
|
||||
TnsuPortalCallerToken callerToken,
|
||||
IOptions<ListPartitioningOptions> options,
|
||||
ILogger<CreateNextListPartitionJob> logger)
|
||||
{
|
||||
/// <param name="authorization">
|
||||
/// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см.
|
||||
/// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать
|
||||
/// новый список на уведомления от имени этого пользователя: своего HttpContext
|
||||
/// у фоновой задачи нет, а client_credentials сейчас не настроен (см.
|
||||
/// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не
|
||||
/// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть
|
||||
/// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание
|
||||
/// списка (см. ISharePointWebhookSubscriber).
|
||||
///
|
||||
/// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и
|
||||
/// виден в его дашборде.
|
||||
/// </param>
|
||||
public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken)
|
||||
{
|
||||
callerToken.Set(authorization);
|
||||
|
||||
// Единственная настоящая защита от параллельного запуска: атомарный захват
|
||||
// флага в строке request_types. Проверка перед постановкой в очередь —
|
||||
// только чтобы не плодить лишние задачи; гонку разруливает именно здесь.
|
||||
if (!await provisioningLock.TryAcquireAsync(
|
||||
requestTypeId, options.Value.StaleLockAfter, cancellationToken))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Провижининг списка для типа заявок {RequestTypeId} уже идёт — задача пропущена.",
|
||||
requestTypeId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var requestType = await db.RequestTypes
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
|
||||
if (requestType is null)
|
||||
{
|
||||
logger.LogWarning("Тип заявок {RequestTypeId} не найден — провижининг отменён.", requestTypeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"У типа заявок {RequestTypeId} не настроен список SharePoint — провижининг отменён.",
|
||||
requestTypeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас
|
||||
// перестаёт быть текущей — записываем её в архив (request_list_partitions
|
||||
// хранит только архив, см. ListPartitionRegistry). До этого момента она
|
||||
// нигде отдельной строкой не значилась.
|
||||
var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken);
|
||||
|
||||
var nextNumber = archivedCurrent.Number + 1;
|
||||
|
||||
logger.LogInformation(
|
||||
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
|
||||
"по структуре {SourceListId}.",
|
||||
nextNumber, requestTypeId, requestType.Name, requestType.ListId);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
ProvisionedList created;
|
||||
try
|
||||
{
|
||||
// Партиция всегда создаётся на том же сайте, что и текущая (см.
|
||||
// комментарий на RequestListPartition о недопустимости разъезда серии
|
||||
// по сайтам) — сайт берём из RequestType, а не откуда-либо ещё, так что
|
||||
// расхождение структурно невозможно.
|
||||
created = await provisioner.CreateNextListPairAsync(
|
||||
requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Логируем с контекстом (номер партиции, тип заявок) здесь, а не
|
||||
// полагаемся только на то, что исключение долетит до лога Hangfire:
|
||||
// там оно будет без этих подробностей, вперемешку с логами других задач.
|
||||
// Дальше даём ему пойти в Hangfire как обычно — так сработает
|
||||
// AutomaticRetry(Attempts = 3).
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Провижининг партиции №{Number} для типа заявок {RequestTypeId} не удался за {ElapsedMs} мс.",
|
||||
nextNumber, requestTypeId, stopwatch.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Новая партиция строкой в request_list_partitions пока не появляется —
|
||||
// она получит свою запись только когда САМА станет архивом, следующим
|
||||
// вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry).
|
||||
// Здесь переключаем только указатель.
|
||||
requestType.ListId = created.ListId;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
|
||||
"{RequestTypeId} переключена на неё.",
|
||||
nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
|
||||
// держала бы тип заблокированным до истечения StaleLockAfter.
|
||||
await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>Одна партиция серии — для админки/портала (вся серия) и для ответа «текущий список».</summary>
|
||||
/// <param name="IsCurrent">
|
||||
/// true — в эту партицию идёт запись новых заявок; остальные партиции серии — архив только для чтения.
|
||||
/// </param>
|
||||
public sealed record RequestListPartitionDto(
|
||||
int Number,
|
||||
string SiteUrl,
|
||||
string ListId,
|
||||
string? Title,
|
||||
bool IsCurrent,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>Вся серия партиций одного типа заявок — единица ответа bulk-эндпоинта для портала.</summary>
|
||||
public sealed record RequestTypeListSeriesDto(
|
||||
int RequestTypeId,
|
||||
IReadOnlyList<RequestListPartitionDto> Partitions);
|
||||
|
||||
/// <summary>Ответ «куда писать новую заявку».</summary>
|
||||
/// <param name="ItemCount">
|
||||
/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
|
||||
/// null, если SharePoint не ответил (тогда условие создания следующей партиции не проверялось).
|
||||
/// </param>
|
||||
/// <param name="ProvisioningQueued">
|
||||
/// true — этим запросом поставлена в очередь задача создания следующей партиции серии.
|
||||
/// </param>
|
||||
public sealed record CurrentListPartitionDto(
|
||||
int RequestTypeId,
|
||||
int Number,
|
||||
string SiteUrl,
|
||||
string ListId,
|
||||
string? Title,
|
||||
int? ItemCount = null,
|
||||
bool ProvisioningQueued = false);
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
|
||||
/// эндпоинту, и фоновой задаче.
|
||||
///
|
||||
/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали
|
||||
/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда
|
||||
/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync).
|
||||
/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId,
|
||||
/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция
|
||||
/// реально стала архивом.
|
||||
/// </summary>
|
||||
public sealed class ListPartitionRegistry(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ILogger<ListPartitionRegistry> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей.
|
||||
///
|
||||
/// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление
|
||||
/// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая
|
||||
/// партиция уже успела записаться архивом, а переключение указателя на новую ещё
|
||||
/// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы
|
||||
/// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см.
|
||||
/// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом
|
||||
/// переходном состоянии.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RequestListPartition>> GetArchivedAsync(
|
||||
RequestType requestType, CancellationToken cancellationToken) =>
|
||||
await db.RequestListPartitions.AsNoTracking()
|
||||
.Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId)
|
||||
.OrderBy(x => x.Number)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions
|
||||
/// на запись: строка появится там только когда эта партиция реально станет
|
||||
/// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным
|
||||
/// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не
|
||||
/// кешируется — кешировать было бы негде без строки в БД.
|
||||
///
|
||||
/// archived — уже загруженный список архивных партиций этого типа (см.
|
||||
/// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая
|
||||
/// сторона (которой archived обычно уже нужен для остальной серии) не делала
|
||||
/// один и тот же запрос дважды.
|
||||
/// </summary>
|
||||
public async Task<RequestListPartitionDto> BuildCurrentAsync(
|
||||
RequestType requestType, IReadOnlyList<RequestListPartition> archived, CancellationToken cancellationToken)
|
||||
{
|
||||
var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1;
|
||||
var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken);
|
||||
|
||||
return new RequestListPartitionDto(
|
||||
number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент,
|
||||
/// когда она перестаёт быть текущей (единственный вызывающий —
|
||||
/// CreateNextListPartitionJob, прямо перед тем как переключить
|
||||
/// RequestType.ListId на следующую партицию).
|
||||
///
|
||||
/// Читает свой собственный, НЕфильтрованный список существующих партиций —
|
||||
/// намеренно не переиспользует GetArchivedAsync(): та фильтрует по
|
||||
/// ListId != RequestType.ListId, и ровно между этим вызовом и переключением
|
||||
/// указателя (см. CreateNextListPartitionJob) только что записанная строка
|
||||
/// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не
|
||||
/// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на
|
||||
/// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже
|
||||
/// записанное и вернуть его.
|
||||
/// </summary>
|
||||
public async Task<RequestListPartition> ArchiveCurrentAsync(
|
||||
RequestType requestType, CancellationToken cancellationToken)
|
||||
{
|
||||
var series = await db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == requestType.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId);
|
||||
if (existing is not null) return existing;
|
||||
|
||||
var entity = new RequestListPartition
|
||||
{
|
||||
RequestTypeId = requestType.Id,
|
||||
Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1,
|
||||
ListId = requestType.ListId!,
|
||||
Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.RequestListPartitions.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.",
|
||||
entity.ListId, requestType.Id, entity.Number);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
// Заголовок нужен только для отображения в админке/портале — из-за его
|
||||
// отсутствия чтение партиции падать не должно.
|
||||
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось прочитать заголовок списка {ListId}.", listId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using FluentResults;
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда
|
||||
/// писать) и вся серия целиком (архив + текущая) для админки и портала.
|
||||
/// </summary>
|
||||
public sealed class ListPartitionService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry registry,
|
||||
IBackgroundJobClient backgroundJobs,
|
||||
TnsuPortalCallerToken callerToken,
|
||||
IOptions<ListPartitioningOptions> options,
|
||||
ILogger<ListPartitionService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Партиция, в которую сейчас нужно писать новые заявки.
|
||||
///
|
||||
/// Побочный эффект: если партиция набрала порог элементов, ставит в очередь
|
||||
/// задачу создания следующей. Побочный эффект в GET — сознательный компромисс
|
||||
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
|
||||
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
|
||||
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
|
||||
/// </summary>
|
||||
public async Task<Result<CurrentListPartitionDto>> GetCurrentAsync(
|
||||
int requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestType = await db.RequestTypes
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
|
||||
if (requestType is null)
|
||||
return Result.Fail<CurrentListPartitionDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
|
||||
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
||||
|
||||
// Синтезируется на лету, не пишется в request_list_partitions — см.
|
||||
// ListPartitionRegistry.BuildCurrentAsync: строка появится там только когда
|
||||
// партиция реально станет архивом.
|
||||
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
|
||||
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||
|
||||
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
|
||||
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
|
||||
|
||||
return Result.Ok(new CurrentListPartitionDto(
|
||||
requestTypeId, current.Number, requestType.SiteUrl, current.ListId, current.Title, itemCount, queued));
|
||||
}
|
||||
|
||||
/// <summary>Вся серия партиций типа заявок, от первой к последней — для админки.</summary>
|
||||
public async Task<Result<IReadOnlyList<RequestListPartitionDto>>> ListAsync(
|
||||
int requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestType = await db.RequestTypes
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
|
||||
if (requestType is null)
|
||||
return Result.Fail<IReadOnlyList<RequestListPartitionDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
return Result.Ok(await BuildSeriesAsync(requestType, cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во
|
||||
/// всех партициях (архив + текущая) любого типа, порталу нужен весь список
|
||||
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
|
||||
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
|
||||
/// </summary>
|
||||
/// <param name="title">
|
||||
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||
/// без учёта регистра, по вхождению подстроки. null — фильтр не применяется.
|
||||
/// </param>
|
||||
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(
|
||||
string? title, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestTypes = await db.RequestTypes
|
||||
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new List<RequestTypeListSeriesDto>(requestTypes.Count);
|
||||
foreach (var requestType in requestTypes)
|
||||
{
|
||||
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
partitions = partitions
|
||||
.Where(x => x.Title is not null && x.Title.Contains(title, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (partitions.Count == 0) continue;
|
||||
|
||||
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
|
||||
}
|
||||
|
||||
return Result.Ok<IReadOnlyList<RequestTypeListSeriesDto>>(result);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<RequestListPartitionDto>> BuildSeriesAsync(
|
||||
RequestType requestType, CancellationToken cancellationToken)
|
||||
{
|
||||
// Тип без настроенного списка — пустая серия, а не ошибка: в админке такой
|
||||
// тип заявок вполне может существовать до привязки к SharePoint.
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return [];
|
||||
|
||||
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
|
||||
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||
|
||||
var result = new List<RequestListPartitionDto>(archived.Count + 1);
|
||||
result.AddRange(archived.Select(x =>
|
||||
new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt)));
|
||||
result.Add(current);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать
|
||||
/// адрес списка даже когда SharePoint недоступен, иначе портал не сможет
|
||||
/// принять заявку из-за сбоя необязательной проверки порога.
|
||||
/// </summary>
|
||||
private async Task<int?> TryGetItemCountAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Value.CheckThresholdOnRead) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await sharePoint.GetItemCountAsync(siteUrl, listId, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось получить число элементов списка {ListId} — порог не проверен.", listId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает
|
||||
/// true, только если задача действительно поставлена.
|
||||
/// </summary>
|
||||
private async Task<bool> TryEnqueueProvisioningAsync(
|
||||
RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false;
|
||||
|
||||
// Следующая партиция уже создана, а запись на неё ещё не переключили —
|
||||
// задача не нужна (и переключать здесь нельзя: этим владеет провижининг).
|
||||
var hasNext = await db.RequestListPartitions
|
||||
.AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
|
||||
if (hasNext) return false;
|
||||
|
||||
// Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
|
||||
// постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
|
||||
// самой задачи (см. CreateNextListPartitionJob).
|
||||
if (requestType.ListPartitioningStartedAt is not null) return false;
|
||||
|
||||
// Токен ЭТОГО запроса уезжает в задачу: подписать новый список на
|
||||
// уведомления нужно от имени пользователя, а своего токена у фоновой задачи
|
||||
// нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и
|
||||
// срок жизни).
|
||||
backgroundJobs.Enqueue<CreateNextListPartitionJob>(
|
||||
job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None));
|
||||
|
||||
logger.LogInformation(
|
||||
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
|
||||
"поставлена задача создания партиции №{NextNumber}.",
|
||||
requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации).</summary>
|
||||
public sealed class ListPartitioningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Порог элементов в списке данных, по достижении которого ставится задача на
|
||||
/// создание следующей партиции (пары списков).
|
||||
///
|
||||
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
|
||||
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
|
||||
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
|
||||
/// ниже, чтобы задача успела создать следующую партицию до того, как текущая
|
||||
/// начнёт упираться.
|
||||
/// </summary>
|
||||
public int ItemCountThreshold { get; set; } = 4500;
|
||||
|
||||
/// <summary>
|
||||
/// Через сколько блокировка провижининга считается брошенной и может быть
|
||||
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt.
|
||||
/// </summary>
|
||||
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
|
||||
/// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не
|
||||
/// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно
|
||||
/// приостановить автосоздание партиций, не трогая остальную логику.
|
||||
/// </summary>
|
||||
public bool CheckThresholdOnRead { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <param name="Email">
|
||||
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
|
||||
/// именем ради обратной совместимости с потребителями, читавшими это поле до
|
||||
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
|
||||
/// </param>
|
||||
/// <param name="HoursOverdue">
|
||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||
/// Выходные не учитываются — считаются календарные часы.
|
||||
/// </param>
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||
/// наследованием (шаг перекрывает тип заявки).
|
||||
/// </param>
|
||||
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
|
||||
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
|
||||
/// <param name="RequestId">Id заявки (TicketId строки списка согласования).</param>
|
||||
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
||||
/// <param name="RequestUniqueCode">
|
||||
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
||||
/// заявки его нет или строка данных не найдена.
|
||||
/// </param>
|
||||
public sealed record ApprovalOverdueReminderDto(
|
||||
string Email,
|
||||
int HoursOverdue,
|
||||
bool AskOverdueReason,
|
||||
int RequestTypeId,
|
||||
string RequestTypeName,
|
||||
int RequestId,
|
||||
string Status,
|
||||
string? RequestUniqueCode,
|
||||
string Url,
|
||||
string? ReceiverName = null)
|
||||
: ReminderDto(Email, ReceiverName, Url);
|
||||
@@ -0,0 +1,95 @@
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
internal enum EmploymentStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Статус не определяли. EmploymentOf такого не возвращает — значение существует
|
||||
/// только как значение по умолчанию (default(EmploymentStatus)); трактуется
|
||||
/// наравне с <see cref="NotFound"/>, то есть «трудоустройство не подтверждено».
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
|
||||
NotFound = 1,
|
||||
|
||||
/// <summary>Работает хотя бы в одной компании.</summary>
|
||||
Employed = 2,
|
||||
|
||||
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
|
||||
Dismissed = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Оба справочника api.tnsu.kz разом, разобранные по ФИО: дата увольнения есть
|
||||
/// только в /Employee/FullBase, а email — только в /Employee/All, поэтому нужны
|
||||
/// оба. Ищем ПО ФИО, потому что точечного поиска по нему у api.tnsu.kz нет
|
||||
/// (эндпоинта /Employee/ByFio не существует), а в заявке на приём кандидат
|
||||
/// записан именно ФИО (колонка CandidateName) — временное решение, см.
|
||||
/// <see cref="ITnsuPortalClient.GetAllEmployeesAsync"/>.
|
||||
///
|
||||
/// ФИО не уникально: один человек может числиться в нескольких компаниях (тогда
|
||||
/// это несколько записей одного и того же человека), но с тем же успехом могут
|
||||
/// совпасть ФИО двух разных людей — различить эти два случая по одному ФИО нельзя,
|
||||
/// поэтому все совпадения трактуются как записи одного человека.
|
||||
/// </summary>
|
||||
internal sealed class EmployeeDirectorySnapshot
|
||||
{
|
||||
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
|
||||
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
||||
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
|
||||
|
||||
public EmployeeDirectorySnapshot(
|
||||
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
||||
{
|
||||
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
||||
_contactsByFio = contacts.ToLookup(e => Normalize(e.Fio));
|
||||
|
||||
// Обратный индекс email → ФИО — нужен, чтобы подписать напоминание именем
|
||||
// получателя, когда известен только его email (руководитель кандидата,
|
||||
// см. GetManagerAsync). При дублях email в справочнике побеждает первая
|
||||
// запись — этого достаточно для отображения имени, не для идентификации.
|
||||
_fioByEmail = contacts
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Email))
|
||||
.GroupBy(e => e.Email!.Trim().ToLowerInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.First().Fio);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Уволенным считаем только того, у кого дата увольнения стоит во ВСЕХ его
|
||||
/// записях: сотрудник, уволенный из одной компании холдинга, но оставшийся в
|
||||
/// другой, продолжает работать — и напоминание ему по-прежнему нужно.
|
||||
/// </summary>
|
||||
public EmploymentStatus EmploymentOf(string fio)
|
||||
{
|
||||
var entries = _fullBaseByFio[Normalize(fio)].ToList();
|
||||
if (entries.Count == 0) return EmploymentStatus.NotFound;
|
||||
|
||||
return entries.All(e => e.DismissalDate is not null)
|
||||
? EmploymentStatus.Dismissed
|
||||
: EmploymentStatus.Employed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Первый непустой email среди записей этого ФИО. У одного человека записей
|
||||
/// несколько (по компании на каждую), а email у него один и тот же — но
|
||||
/// заполнен он может быть не в каждой записи.
|
||||
/// </summary>
|
||||
public string? EmailOf(string fio) =>
|
||||
_contactsByFio[Normalize(fio)]
|
||||
.Select(e => e.Email)
|
||||
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
||||
|
||||
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
|
||||
public string? NameByEmail(string email) =>
|
||||
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
|
||||
|
||||
/// <summary>
|
||||
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
||||
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
||||
/// двойные пробелы, и неразрывный пробел — см. поле department в /Employee/All).
|
||||
/// </summary>
|
||||
private static string Normalize(string fio) =>
|
||||
string.Join(' ', fio.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «Прием на работу — данные», разобранная из ответа SharePoint.
|
||||
/// Имена колонок — как в request-portal (provisioning.json формы RecruitingHire).
|
||||
/// </summary>
|
||||
internal sealed record HireRow(
|
||||
int Id,
|
||||
string Status,
|
||||
string? CandidateName,
|
||||
DateTimeOffset? HireDate,
|
||||
string? ProbationPeriod,
|
||||
string? RequestUniqueCode)
|
||||
{
|
||||
public static HireRow? TryParse(JsonElement item)
|
||||
{
|
||||
if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) return null;
|
||||
|
||||
return new HireRow(
|
||||
id,
|
||||
GetString(item, "RequestStatus") ?? string.Empty,
|
||||
GetString(item, "CandidateName"),
|
||||
TryGetDate(item, "HireDate"),
|
||||
GetString(item, "ProbationPeriod"),
|
||||
GetString(item, "RequestUniqueCode"));
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static DateTimeOffset? TryGetDate(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) &&
|
||||
v.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(v.GetString(), out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Статусы списка данных («… — данные», колонка RequestStatus) — считаем приём завершённым по ним.</summary>
|
||||
internal static class HireRequestStatuses
|
||||
{
|
||||
public const string Approved = "Согласовано";
|
||||
public const string Closed = "Закрыта";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <param name="ReceiverEmail">
|
||||
/// Email руководителя принимаемого сотрудника — кому адресовано напоминание.
|
||||
/// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение
|
||||
/// под вторым именем.
|
||||
/// </param>
|
||||
/// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
|
||||
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
|
||||
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
|
||||
/// <param name="HireDate">Дата приёма (колонка HireDate).</param>
|
||||
/// <param name="ProbationEndsAt">Дата окончания испытательного срока (HireDate + срок).</param>
|
||||
/// <param name="DaysUntilProbationEnds">Сколько дней осталось до конца испытательного срока.</param>
|
||||
public sealed record ProbationEndingReminderDto(
|
||||
string ReceiverEmail,
|
||||
string CandidateEmail,
|
||||
string CandidateName,
|
||||
string? ProbationPeriod,
|
||||
DateTimeOffset HireDate,
|
||||
DateTimeOffset ProbationEndsAt,
|
||||
int DaysUntilProbationEnds,
|
||||
string Url,
|
||||
string? ReceiverName = null)
|
||||
: ReminderDto(ReceiverEmail, ReceiverName, Url);
|
||||
@@ -1,20 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
|
||||
/// <param name="HoursOverdue">
|
||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||
/// Выходные не учитываются — считаются календарные часы.
|
||||
/// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
|
||||
/// <param name="ReceiverName">
|
||||
/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
|
||||
/// в старой строке списка без заполненного Title персонального поля) — тогда null.
|
||||
/// </param>
|
||||
/// <param name="Url">Ссылка на заявку, требующую решения.</param>
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||
/// наследованием (шаг перекрывает тип заявки).
|
||||
/// <param name="Url">
|
||||
/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
|
||||
/// потому что не константна: requestId и партиция списка (server-relative url,
|
||||
/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
|
||||
/// дублировать эту логику снаружи незачем.
|
||||
/// </param>
|
||||
public sealed record ReminderDto(
|
||||
string Email,
|
||||
int HoursOverdue,
|
||||
string Url,
|
||||
bool AskOverdueReason,
|
||||
int RequestTypeId,
|
||||
string RequestTypeName,
|
||||
int RequestId);
|
||||
/// <remarks>
|
||||
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
|
||||
/// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
|
||||
/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
|
||||
/// (Power Automate ссылается на неё, работая со списком согласования конкретного
|
||||
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
|
||||
///
|
||||
/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
|
||||
/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
|
||||
/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
|
||||
/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
|
||||
/// со значением "ApprovalOverdue"/"ProbationEnding".
|
||||
/// </remarks>
|
||||
[JsonPolymorphic]
|
||||
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
|
||||
[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
|
||||
public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <summary>
|
||||
/// Какой вид напоминаний запросить у <see cref="ReminderService.GetRemindersAsync(ReminderFeed, CancellationToken)"/>.
|
||||
///
|
||||
/// <see cref="None"/> («вид не указан») трактуется как <see cref="ApprovalOverdue"/>:
|
||||
/// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и
|
||||
/// вызов без явного выбора обязан работать как раньше.
|
||||
/// </summary>
|
||||
public enum ReminderFeed
|
||||
{
|
||||
None = 0,
|
||||
ApprovalOverdue = 1,
|
||||
ProbationEnding = 2,
|
||||
}
|
||||
@@ -3,9 +3,12 @@ using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
@@ -20,18 +23,43 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
public sealed class ReminderService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
ITnsuPortalClient portalClient,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<RemindersOptions> options,
|
||||
ILogger<ReminderService> logger)
|
||||
{
|
||||
// Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе
|
||||
// на большом списке получится неподъёмной длины URL.
|
||||
private const int IdChunkSize = 50;
|
||||
|
||||
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
|
||||
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
|
||||
private const string ActiveStatusFilter =
|
||||
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
||||
|
||||
/// <summary>
|
||||
/// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
|
||||
/// это был единственный вид напоминаний, и без явного выбора им и остаётся.
|
||||
/// </summary>
|
||||
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(CancellationToken cancellationToken) =>
|
||||
GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей
|
||||
/// стороны. Момент «сейчас» берётся здесь же, из <see cref="TimeProvider"/>, а не
|
||||
/// пробрасывается вызывающей стороной — во всех проде это <c>TimeProvider.System</c>
|
||||
/// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/
|
||||
/// GetProbationEndingAsync по-прежнему принимают <c>now</c> явным параметром: так
|
||||
/// их сценарии остаются детерминированными без своего TimeProvider на каждый тест.
|
||||
/// </summary>
|
||||
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
|
||||
ReminderFeed feed, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return feed switch
|
||||
{
|
||||
ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
|
||||
_ => GetOverdueAsync(now, cancellationToken),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
||||
DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -42,37 +70,101 @@ public sealed class ReminderService(
|
||||
.Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var reminders = new List<ReminderDto>();
|
||||
|
||||
// Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не
|
||||
// потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких
|
||||
// веток Task.WhenAll ниже валит его исключением ("A second operation
|
||||
// was started on this context instance before a previous operation
|
||||
// completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо
|
||||
// проглатывает — часть напоминаний молча пропадала бы без всякой
|
||||
// видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность
|
||||
// здесь не стоит ощутимого времени.
|
||||
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||
foreach (var type in types)
|
||||
archivedByType[type.Id] = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||
|
||||
// Дальше типы независимы (разные сайты/списки в SharePoint, БД уже не
|
||||
// трогают) — обход распараллелен, иначе время растёт линейно с числом
|
||||
// типов: каждый тип сам по себе делает несколько последовательных
|
||||
// HTTP-запросов.
|
||||
var results = await Task.WhenAll(
|
||||
types.Select(type => GetOverdueForTypeSafeAsync(type, archivedByType[type.Id], now, cancellationToken)));
|
||||
|
||||
return Result.Ok<IReadOnlyList<ReminderDto>>(results.SelectMany(r => r).ToList());
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeSafeAsync(
|
||||
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await GetOverdueForTypeAsync(type, archived, now, cancellationToken);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Недоступный список одного типа не должен обнулять напоминания по
|
||||
// всем остальным: Power Automate получит то, что удалось собрать.
|
||||
logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в
|
||||
/// архив, продолжает ждать чьего-то решения точно так же, как в текущем
|
||||
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
||||
/// новых заявок завели следующий список.
|
||||
///
|
||||
/// archived — уже прочитан вызывающей стороной (GetOverdueAsync,
|
||||
/// последовательно для всех типов разом) — здесь БД больше не трогаем,
|
||||
/// только HTTP к SharePoint, поэтому вызов можно параллелить по типам.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||||
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||
|
||||
var partitions = archived
|
||||
.Select(x => (x.ListId, x.Number))
|
||||
.Append((current.ListId, current.Number));
|
||||
|
||||
var reminders = new List<ReminderDto>();
|
||||
foreach (var (listId, number) in partitions)
|
||||
{
|
||||
try
|
||||
{
|
||||
reminders.AddRange(await GetOverdueForTypeAsync(type, now, cancellationToken));
|
||||
reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Недоступный список одного типа не должен обнулять напоминания по
|
||||
// всем остальным: Power Automate получит то, что удалось собрать.
|
||||
logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name);
|
||||
// Недоступная партиция (например, архивный список переименовали
|
||||
// или удалили вручную) не должна обнулять напоминания по
|
||||
// остальным партициям того же типа.
|
||||
logger.LogError(
|
||||
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
|
||||
listId, number, type.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.Ok<IReadOnlyList<ReminderDto>>(reminders);
|
||||
return reminders;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||||
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
|
||||
RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var siteUrl = type.SiteUrl!;
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken);
|
||||
|
||||
// Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url
|
||||
// список найдётся всегда.
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(dataListTitle))
|
||||
{
|
||||
logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.ListId);
|
||||
logger.LogWarning(
|
||||
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
|
||||
number, type.Name, listId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
|
||||
if (approvalListTitle is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
@@ -81,7 +173,7 @@ public sealed class ReminderService(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus.
|
||||
// Активные строки — прямым $filter по полю ApprovalStatus.
|
||||
//
|
||||
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
|
||||
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
|
||||
@@ -91,124 +183,85 @@ public sealed class ReminderService(
|
||||
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
|
||||
// он не отстаёт от реального состояния списка (не зависит от индексации
|
||||
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
|
||||
//
|
||||
// Дата активации строки — прямо в ней самой (колонка Date): request-portal
|
||||
// проставляет её в момент перехода строки в «На согласовании»/«На
|
||||
// исполнении» (см. approvalList.service.ts/setActive) и никак иначе,
|
||||
// поэтому больше не нужно ни читать весь маршрут заявки, ни дату
|
||||
// создания заявки, ни выводить активацию из решений соседних шагов.
|
||||
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
|
||||
if (active.Count == 0) return [];
|
||||
|
||||
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
|
||||
// дата активации шага выводится из дат решений предыдущего шага.
|
||||
var ticketIds = active.Select(r => r.TicketId).Distinct().ToList();
|
||||
var allRows = await ReadRowsByTicketsAsync(siteUrl, approvalListTitle, ticketIds, cancellationToken);
|
||||
var rowsByTicket = allRows.GroupBy(r => r.TicketId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var createdByTicket = await ReadCreatedAsync(siteUrl, type.ListId!, ticketIds, cancellationToken);
|
||||
var uniqueCodes = await ReadUniqueCodesAsync(
|
||||
siteUrl, type.ListId!, active.Select(r => r.TicketId).Distinct(), cancellationToken);
|
||||
|
||||
var reminders = new List<ReminderDto>();
|
||||
foreach (var row in active)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
|
||||
if (!rowsByTicket.TryGetValue(row.TicketId, out var ticketRows)) continue;
|
||||
|
||||
createdByTicket.TryGetValue(row.TicketId, out var created);
|
||||
var activatedAt = ActivatedAt(row, ticketRows, created);
|
||||
if (activatedAt is null)
|
||||
if (row.Date is not { } activatedAt)
|
||||
{
|
||||
// Строка активна, но Date пуста — так бывает у строк, заведённых
|
||||
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
||||
// почему-то не проставил её при активации. Без даты старта SLA не
|
||||
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
||||
//
|
||||
// RequestTypeId — всегда, чтобы искать TicketId в правильном типе
|
||||
// заявок. RequestUniqueCode — то, по чему заявку реально ищут в
|
||||
// портале, добавляется, только если он уже есть (строка данных
|
||||
// могла ещё не появиться или заявка старше появления колонки).
|
||||
var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code
|
||||
? $"requestTypeId={type.Id}, code={code}"
|
||||
: $"requestTypeId={type.Id}";
|
||||
logger.LogWarning(
|
||||
"Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена",
|
||||
row.Id, row.TicketId);
|
||||
"Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена",
|
||||
row.Id, row.TicketId, identifier);
|
||||
continue;
|
||||
}
|
||||
|
||||
// SLA строки перекрывает типовой. У строк, созданных до появления
|
||||
// колонки, значения нет — тогда работает SLA типа заявки.
|
||||
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
|
||||
var deadline = activatedAt.Value.AddHours(slaHours);
|
||||
var deadline = activatedAt.AddHours(slaHours);
|
||||
if (deadline >= now) continue;
|
||||
|
||||
reminders.Add(new ReminderDto(
|
||||
reminders.Add(new ApprovalOverdueReminderDto(
|
||||
row.ApproverEmail!,
|
||||
(int)Math.Floor((now - deadline).TotalHours),
|
||||
BuildUrl(type, row),
|
||||
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
|
||||
type.Id,
|
||||
type.Name,
|
||||
row.TicketId));
|
||||
row.TicketId,
|
||||
row.Status,
|
||||
uniqueCodes.GetValueOrDefault(row.TicketId),
|
||||
BuildUrl(type, row, listId),
|
||||
row.ApproverName));
|
||||
}
|
||||
|
||||
return reminders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Момент, с которого шаг считается активным, — начало отсчёта SLA.
|
||||
///
|
||||
/// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это
|
||||
/// точечное обновление статуса, дату оно не пишет), поэтому момент выводится
|
||||
/// из соседних строк:
|
||||
/// • первый шаг согласования — дата создания заявки;
|
||||
/// • первый шаг исполнения — последнее решение по фазе согласования
|
||||
/// (фаза исполнения активируется только когда согласование пройдено целиком);
|
||||
/// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы.
|
||||
///
|
||||
/// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить
|
||||
/// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг
|
||||
/// активируется только после решения последнего из них.
|
||||
///
|
||||
/// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не
|
||||
/// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по
|
||||
/// условию появления).
|
||||
/// </summary>
|
||||
private static DateTimeOffset? ActivatedAt(
|
||||
ApprovalRow row, IReadOnlyList<ApprovalRow> ticketRows, DateTimeOffset? requestCreated)
|
||||
{
|
||||
var samePhase = ticketRows.Where(r => r.Phase == row.Phase).ToList();
|
||||
var isFirstInPhase = samePhase.All(r => r.StepOrder >= row.StepOrder);
|
||||
|
||||
if (isFirstInPhase)
|
||||
{
|
||||
return row.Phase == ApprovalPhases.Execution
|
||||
? LastDecisionOf(ticketRows.Where(r => r.Phase == ApprovalPhases.Approval))
|
||||
: requestCreated;
|
||||
}
|
||||
|
||||
var previousOrder = samePhase
|
||||
.Where(r => r.StepOrder < row.StepOrder)
|
||||
.Max(r => r.StepOrder);
|
||||
|
||||
return LastDecisionOf(samePhase.Where(r => r.StepOrder == previousOrder));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? LastDecisionOf(IEnumerable<ApprovalRow> rows)
|
||||
{
|
||||
var dates = rows.Select(r => r.Date).Where(d => d is not null).Select(d => d!.Value).ToList();
|
||||
return dates.Count == 0 ? null : dates.Max();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
|
||||
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
|
||||
/// откроется панелью поверх раздела (rpmode=approve).
|
||||
///
|
||||
/// listId — server-relative url партиции (см. shared/routing.ts в
|
||||
/// request-portal, параметр rplist): без него архивная заявка открылась бы
|
||||
/// так, будто лежит в ТЕКУЩЕЙ партиции типа, и портал не нашёл бы её.
|
||||
/// </summary>
|
||||
private string BuildUrl(RequestType type, ApprovalRow row) =>
|
||||
private string BuildUrl(RequestType type, ApprovalRow row, string listId) =>
|
||||
options.Value.RequestUrlTemplate
|
||||
.Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/'))
|
||||
.Replace("{pageUrl}", type.PageUrl)
|
||||
.Replace("{requestId}", row.TicketId.ToString())
|
||||
.Replace("{listId}", Uri.EscapeDataString(listId))
|
||||
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
|
||||
|
||||
/// <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 =
|
||||
"$select=Id,ApprovalStatus,StepOrder,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
|
||||
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||
"TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
|
||||
|
||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
||||
@@ -219,43 +272,218 @@ public sealed class ReminderService(
|
||||
return Parse(items);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
|
||||
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
||||
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// RequestUniqueCode заявок из списка данных — только по заявкам, у которых
|
||||
/// сейчас есть просроченный шаг (а не по всему списку: он может быть большим).
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyDictionary<int, string?>> ReadUniqueCodesAsync(
|
||||
string siteUrl, string dataListUrl, IEnumerable<int> ticketIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = new List<ApprovalRow>();
|
||||
foreach (var chunk in ticketIds.Chunk(IdChunkSize))
|
||||
var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}"));
|
||||
if (filter.Length == 0) return new Dictionary<int, string?>();
|
||||
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, dataListUrl, $"$select=Id,RequestUniqueCode&$filter={filter}", cancellationToken);
|
||||
|
||||
var result = new Dictionary<int, string?>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"TicketId eq {id}"));
|
||||
rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken));
|
||||
if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) continue;
|
||||
|
||||
result[id] = item.TryGetProperty("RequestUniqueCode", out var code) && code.ValueKind == JsonValueKind.String
|
||||
? code.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
return rows;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Даты создания заявок — начало отсчёта для первого шага согласования.</summary>
|
||||
private async Task<Dictionary<int, DateTimeOffset>> ReadCreatedAsync(
|
||||
string siteUrl, string listUrl, IReadOnlyList<int> ids, CancellationToken cancellationToken)
|
||||
{
|
||||
var created = new Dictionary<int, DateTimeOffset>();
|
||||
/// <summary>
|
||||
/// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока.
|
||||
///
|
||||
/// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую
|
||||
/// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам,
|
||||
/// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в
|
||||
/// портале, и напоминание уходило бы на день раньше срока.
|
||||
///
|
||||
/// Константой, а не настройкой: смещение фиксировано для всей организации и не
|
||||
/// меняется от окружения (перехода на летнее время нет).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6);
|
||||
|
||||
foreach (var chunk in ids.Chunk(IdChunkSize))
|
||||
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
|
||||
new Dictionary<string, int>
|
||||
{
|
||||
var query = "$select=Id,Created&$top=5000&$filter=" +
|
||||
string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
||||
["1 месяц"] = 1,
|
||||
["2 месяца"] = 2,
|
||||
["3 месяца"] = 3,
|
||||
["6 месяцев"] = 6,
|
||||
};
|
||||
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listUrl, query, cancellationToken);
|
||||
foreach (var item in items)
|
||||
/// <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
|
||||
{
|
||||
if (!item.TryGetProperty("Id", out var idValue) || !idValue.TryGetInt32(out var id)) continue;
|
||||
if (!item.TryGetProperty("Created", out var createdValue)) continue;
|
||||
if (createdValue.ValueKind != JsonValueKind.String) continue;
|
||||
if (DateTimeOffset.TryParse(createdValue.GetString(), out var parsed)) created[id] = parsed;
|
||||
reminders.AddRange(
|
||||
await GetProbationEndingForPartitionAsync(type, listId, directory, now, cancellationToken));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(
|
||||
e, "Напоминания об испытательном сроке по списку {ListId} (партиция №{Number}) не собраны",
|
||||
listId, number);
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
return Result.Ok<IReadOnlyList<ReminderDto>>(reminders);
|
||||
}
|
||||
|
||||
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
||||
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,56 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
public sealed class RemindersOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Шаблон ссылки на заявку. Подстановки: {siteUrl}, {pageUrl}, {requestId}, {nav}.
|
||||
/// Шаблон ссылки на заявку. Подстановки: {siteUrl}, {pageUrl}, {requestId},
|
||||
/// {listId}, {nav}.
|
||||
///
|
||||
/// Портал — одностраничный и живёт в КОРНЕ сайта, отдельного пути к странице
|
||||
/// нет; всё состояние передаётся параметрами запроса (см. shared/routing.ts
|
||||
/// в request-portal). Пример живой ссылки:
|
||||
/// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11
|
||||
/// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11&rplist=%2Fsites%2Frequests%2FLists%2F...
|
||||
///
|
||||
/// {pageUrl} — это PageUrl типа заявки, то есть КЛЮЧ ФОРМЫ (it-1c-ticket), а
|
||||
/// не путь к странице: в портале он и подставляется в rptype.
|
||||
///
|
||||
/// {listId} — server-relative url КОНКРЕТНОЙ партиции, где лежит строка
|
||||
/// согласования (параметр rplist). Без него портал открывал бы заявку так,
|
||||
/// будто она лежит в ТЕКУЩЕЙ партиции типа, и архивная заявка не находилась
|
||||
/// бы вовсе (см. IRoute.listId в request-portal/shared/routing.ts).
|
||||
///
|
||||
/// Вынесено в конфигурацию, чтобы смена разметки ссылок в портале правилась
|
||||
/// настройкой, а не пересборкой сервиса.
|
||||
/// </summary>
|
||||
public string RequestUrlTemplate { get; set; } =
|
||||
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}";
|
||||
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}";
|
||||
|
||||
/// <summary>
|
||||
/// PageUrl типа заявки «Заявка на приём» (см. request-portal, forms/RecruitingHire/model.ts,
|
||||
/// FORM_ID) — по нему ищется RequestType для напоминаний об окончании испытательного срока.
|
||||
/// Не завязано на SlaHours: этот вид напоминаний не про просрочку шага согласования.
|
||||
/// </summary>
|
||||
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
||||
|
||||
/// <summary>
|
||||
/// За сколько дней до конца испытательного срока присылать напоминание — неделя,
|
||||
/// чтобы у руководителя было время подготовить решение по итогам срока.
|
||||
///
|
||||
/// Дни КАЛЕНДАРНЫЕ, не рабочие: напоминание строится ровно в тот день, когда до
|
||||
/// конца срока остаётся столько дней, и попади он на выходной — уйдёт в выходной.
|
||||
/// </summary>
|
||||
public int ProbationReminderDaysBeforeEnd { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Куда ведёт ссылка в напоминании об окончании испытательного срока — на
|
||||
/// раздел «Подать заявку» портала, а НЕ на саму заявку на приём: от
|
||||
/// руководителя по этому напоминанию требуется завести НОВУЮ заявку (решение
|
||||
/// по итогам испытательного срока), а не открыть уже закрытую старую.
|
||||
///
|
||||
/// Поэтому ссылка константная, без подстановок: адрес конкретной формы в
|
||||
/// портал вообще не передать — параметр rptype без rpid/rpsource
|
||||
/// игнорируется, и маршрут сваливается в раздел «Подать заявку» (см.
|
||||
/// parseRoute в request-portal/shared/routing.ts). Отдельная настройка, а не
|
||||
/// хардкод, — чтобы адрес правился конфигурацией.
|
||||
/// </summary>
|
||||
public string ProbationReminderUrl { get; set; } =
|
||||
"https://tnsukz.sharepoint.com/sites/requests?rpnav=new";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Строка ФАЗЫ ИСПОЛНЕНИЯ из списка «Заявка на подбор — согласование». Отчёту она
|
||||
/// даёт две колонки, которых нет в самой заявке: рекрутёр (исполнитель шага) и
|
||||
/// дата закрытия вакансии.
|
||||
///
|
||||
/// Дата закрытия — это колонка Date выполненной строки: request-portal
|
||||
/// перезаписывает её в момент решения по шагу (см. setDecision в
|
||||
/// shared/approval/approvalList.service.ts), так что у строки со статусом
|
||||
/// «Выполнено» там именно дата закрытия, а не дата получения шага в работу.
|
||||
/// </summary>
|
||||
internal sealed record ExecutionRow(
|
||||
int TicketId,
|
||||
int StepOrder,
|
||||
string Status,
|
||||
string? ExecutorName,
|
||||
DateOnly? Date)
|
||||
{
|
||||
public const string Select =
|
||||
$"$select=Id,TicketId,ApprovalStatus,StepOrder,Date,TansuSiteApprover/Title" +
|
||||
$"&$expand=TansuSiteApprover&$filter=Phase eq '{Phase}'&$top=5000";
|
||||
|
||||
private const string Phase = "Исполнение";
|
||||
|
||||
/// <summary>Статус выполненной строки исполнения — по нему считаем вакансию закрытой.</summary>
|
||||
public const string Done = "Выполнено";
|
||||
|
||||
public static ExecutionRow? TryParse(JsonElement item)
|
||||
{
|
||||
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||
var ticketId = SharePointJson.Int(item, "TicketId");
|
||||
if (ticketId is null) return null;
|
||||
|
||||
return new ExecutionRow(
|
||||
ticketId.Value,
|
||||
SharePointJson.Int(item, "StepOrder") ?? 0,
|
||||
SharePointJson.String(item, "ApprovalStatus") ?? string.Empty,
|
||||
SharePointJson.PersonTitle(item, "TansuSiteApprover"),
|
||||
SharePointJson.Date(item, "Date"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «Прием на работу — данные» в том объёме, в каком она нужна отчёту:
|
||||
/// на какую заявку на подбор ссылается, кого и когда приняли.
|
||||
/// </summary>
|
||||
internal sealed record HireLinkRow(
|
||||
int SelectionRequestId,
|
||||
string? SelectionRequestListId,
|
||||
string Status,
|
||||
string? CandidateName,
|
||||
string? ReplacedEmployee,
|
||||
DateOnly? HireDate)
|
||||
{
|
||||
public const string Select =
|
||||
"$select=Id,RequestStatus,SelectionRequestId,SelectionRequestListId," +
|
||||
"CandidateName,ReplacedEmployee,HireDate&$top=5000";
|
||||
|
||||
/// <summary>Заявка на приём без ссылки на подбор отчёту бесполезна — её отбрасываем.</summary>
|
||||
public static HireLinkRow? TryParse(JsonElement item)
|
||||
{
|
||||
var selectionId = SharePointJson.Int(item, "SelectionRequestId");
|
||||
if (selectionId is null or <= 0) return null;
|
||||
|
||||
return new HireLinkRow(
|
||||
selectionId.Value,
|
||||
SharePointJson.String(item, "SelectionRequestListId"),
|
||||
SharePointJson.String(item, "RequestStatus") ?? string.Empty,
|
||||
SharePointJson.String(item, "CandidateName"),
|
||||
SharePointJson.String(item, "ReplacedEmployee"),
|
||||
SharePointJson.Date(item, "HireDate"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>Настройки отчётов (секция Reports в конфигурации).</summary>
|
||||
public sealed class ReportsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// PageUrl типа заявки «Заявка на подбор» (см. request-portal,
|
||||
/// forms/RecruitingSelection/model.ts, FORM_ID) — по нему ищется RequestType,
|
||||
/// из партиций которого берутся сами вакансии.
|
||||
/// </summary>
|
||||
public string RecruitingSelectionPageUrl { get; set; } = "recruiting-selection";
|
||||
|
||||
/// <summary>
|
||||
/// PageUrl типа заявки «Приём на работу» — из его партиций берутся закрытия
|
||||
/// вакансий: заявка на приём ссылается на заявку на подбор колонками
|
||||
/// SelectionRequestId / SelectionRequestListId.
|
||||
/// </summary>
|
||||
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.Common;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>Готовый файл выгрузки и его имя.</summary>
|
||||
public sealed record RouteExportFile(byte[] Content, string FileName);
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов
|
||||
/// заявок, превращает их в понятные человеку строки (роль, исполнитель,
|
||||
/// организация, условие, комментарий) и отдаёт на вёрстку в
|
||||
/// <see cref="RouteExportWorkbook"/>.
|
||||
///
|
||||
/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага,
|
||||
/// <see cref="RouteStepStatus"/>), у каждой своя нумерация.
|
||||
/// </summary>
|
||||
|
||||
public sealed class RouteExportService(
|
||||
ApprovalRouteDbContext db,
|
||||
ITnsuPortalClient portalClient,
|
||||
IOptions<TnsuPortalOptions> portalOptions)
|
||||
{
|
||||
private const int MaxTypesInFileName = 7;
|
||||
private const int MaxTypeNameLength = 20;
|
||||
|
||||
private const string Empty = "—";
|
||||
private const string Always = "Всегда";
|
||||
private const string AnyCompany = "любая компания";
|
||||
private const string AnyDepartment = "любой отдел";
|
||||
private const string ByRequest = "Определяется по заявке";
|
||||
|
||||
/// <summary>
|
||||
/// Технические имена колонок списка, которые в таблице показываем по-человечески
|
||||
/// (CompaniesJson — организации заявки).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> ConditionFieldAliases = new(StringComparer.Ordinal)
|
||||
{
|
||||
["CompaniesJson"] = "организация"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
|
||||
{
|
||||
[ComparisonOperator.GreaterThan] = ">",
|
||||
[ComparisonOperator.LessThan] = "<",
|
||||
[ComparisonOperator.Equal] = "=",
|
||||
[ComparisonOperator.NotEqual] = "≠",
|
||||
[ComparisonOperator.Contains] = "содержит",
|
||||
[ComparisonOperator.NotContains] = "не содержит"
|
||||
};
|
||||
|
||||
public async Task<Result<RouteExportFile>> BuildAsync(IReadOnlyList<int>? requestTypeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestTypeIds is not { Count: > 0 })
|
||||
return Result.Fail("Не выбрано ни одного типа заявки.");
|
||||
|
||||
var types = await db.RequestTypes.AsNoTracking()
|
||||
.Where(x => requestTypeIds.Contains(x.Id))
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (types.Count == 0)
|
||||
return Result.Fail("Выбранные типы заявок не найдены.");
|
||||
|
||||
var routes = await db.TemplateRoutes.AsNoTracking()
|
||||
.Where(x => requestTypeIds.Contains(x.RequestTypeId))
|
||||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||
// Две вложенные коллекции (шаги и их условия) одним запросом дали бы декартово
|
||||
// произведение строк — режем на отдельные запросы.
|
||||
.AsSplitQuery()
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var directory = await LoadDirectoryAsync(cancellationToken);
|
||||
|
||||
var sheets = types
|
||||
.Select(type => BuildSheet(type, routes.Where(r => r.RequestTypeId == type.Id).ToList(), directory))
|
||||
.ToList();
|
||||
|
||||
return Result.Ok(new RouteExportFile(
|
||||
RouteExportWorkbook.Build(sheets),
|
||||
FileName(types.Select(t => t.Name).ToList(), DateOnly.FromDateTime(DateTime.Now))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Маршруты согласования (подбор, прием на работу) 13.10.2026.xlsx» — в скобках
|
||||
/// сокращённые названия типов (не больше <see cref="MaxTypesInFileName"/>, остальные
|
||||
/// сворачиваются в «и др.»), в конце — дата выгрузки.
|
||||
/// </summary>
|
||||
public static string FileName(IReadOnlyList<string> typeNames, DateOnly date)
|
||||
{
|
||||
var shown = typeNames.Take(MaxTypesInFileName).Select(Abbreviate).Where(n => n.Length > 0).ToList();
|
||||
var list = string.Join(", ", shown) + (typeNames.Count > MaxTypesInFileName ? " и др." : string.Empty);
|
||||
|
||||
var name = $"Маршруты согласования ({list}) {date:dd.MM.yyyy}.xlsx";
|
||||
return string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? ' ' : c));
|
||||
}
|
||||
|
||||
// «Заявка на подбор персонала» → «подбор персонала»: служебное начало названия
|
||||
// ничего не говорит, а длинное название раздуло бы имя файла.
|
||||
private static string Abbreviate(string typeName)
|
||||
{
|
||||
var name = typeName.Trim();
|
||||
foreach (var prefix in new[] { "Заявка на ", "Заявка о ", "Заявка " })
|
||||
{
|
||||
if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name[prefix.Length..];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (name.Length <= MaxTypeNameLength)
|
||||
return name;
|
||||
|
||||
var cut = name[..MaxTypeNameLength];
|
||||
var lastSpace = cut.LastIndexOf(' ');
|
||||
return (lastSpace > MaxTypeNameLength / 2 ? cut[..lastSpace] : cut).TrimEnd() + "…";
|
||||
}
|
||||
|
||||
private async Task<PortalDirectory> LoadDirectoryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Справочники кешируются клиентом портала (см. CachingTnsuPortalClient).
|
||||
var employees = await portalClient.GetAllEmployeesAsync(cancellationToken);
|
||||
var companies = await portalClient.GetCompaniesAsync(cancellationToken);
|
||||
var departments = await portalClient.GetDepartmentsAsync(cancellationToken);
|
||||
|
||||
return new PortalDirectory(
|
||||
employees,
|
||||
companies.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First().Name),
|
||||
departments.GroupBy(d => d.Id).ToDictionary(g => g.Key, g => g.First().Name));
|
||||
}
|
||||
|
||||
private RouteExportSheet BuildSheet(RequestType type, IReadOnlyList<TemplateRoute> routes, PortalDirectory directory)
|
||||
{
|
||||
var sections = routes
|
||||
.Select(route =>
|
||||
{
|
||||
return new RouteExportSection(
|
||||
routes.Count < 2 ? null : RouteTitle(route, directory),
|
||||
BuildGroups(route.Steps, directory));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new RouteExportSheet(type.Name, sections);
|
||||
}
|
||||
|
||||
private static string RouteTitle(TemplateRoute route, PortalDirectory directory)
|
||||
{
|
||||
var company = route.CompanyId is { } c ? directory.CompanyName(c) : AnyCompany;
|
||||
var department = route.DepartmentId is { } d ? directory.DepartmentName(d) : AnyDepartment;
|
||||
return $"Маршрут: {company} · {department}{(route.ByProject ? " · по проекту" : string.Empty)}";
|
||||
}
|
||||
|
||||
private List<RouteExportGroup> BuildGroups(IEnumerable<TemplateRouteStep> steps, PortalDirectory directory)
|
||||
{
|
||||
var ordered = steps.OrderBy(s => s.OrderNo).ToList();
|
||||
var approvers = ordered.Where(s => s.Status != RouteStepStatus.Execution).ToList();
|
||||
var executors = ordered.Where(s => s.Status == RouteStepStatus.Execution).ToList();
|
||||
|
||||
return new[]
|
||||
{
|
||||
new RouteExportGroup("Согласующие", BuildRows(approvers, directory)),
|
||||
new RouteExportGroup("Исполнители", BuildRows(executors, directory))
|
||||
}
|
||||
.Where(g => g.Rows.Count > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Параллельные шаги (равный OrderNo) получают один и тот же номер.
|
||||
private List<RouteExportRow> BuildRows(IReadOnlyList<TemplateRouteStep> steps, PortalDirectory directory)
|
||||
{
|
||||
var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList();
|
||||
|
||||
return steps
|
||||
.Select(step =>
|
||||
{
|
||||
var (role, executor, organization) = DescribeRoleAndExecutor(step, directory);
|
||||
return new RouteExportRow(
|
||||
orderNos.IndexOf(step.OrderNo) + 1,
|
||||
role,
|
||||
executor,
|
||||
organization,
|
||||
DescribeCondition(step),
|
||||
DescribeComment(step));
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// «Роль» — должность или тип руководителя, «исполнитель» — конкретный человек,
|
||||
// если он известен уже при настройке маршрута. Директор Tansu и Управляющий
|
||||
// директор Tansu — тоже конкретные люди (их email лежит в настройках).
|
||||
private (string Role, string Executor, string Organization) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||
{
|
||||
var employee = directory.FindByEmail(step.ExecutorEmail);
|
||||
return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail, directory.CompaniesOf(step.ExecutorEmail));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(step.Position))
|
||||
{
|
||||
var holder = directory.FindByPosition(step.Position);
|
||||
return (step.Position, holder?.Fio ?? Empty, directory.CompaniesOf(holder?.Email));
|
||||
}
|
||||
|
||||
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
|
||||
return (StepLabels.Of(projectRole), ByRequest, Empty);
|
||||
|
||||
if (step.HeadType is { } headType && headType != HeadType.None)
|
||||
{
|
||||
var fixedEmail = headType switch
|
||||
{
|
||||
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||||
_ => null
|
||||
};
|
||||
if (fixedEmail is not null)
|
||||
// Нет в справочнике — лучше показать email, чем пустую ячейку.
|
||||
return (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
|
||||
|
||||
return (StepLabels.Of(headType), ByRequest, Empty);
|
||||
}
|
||||
|
||||
return (Empty, Empty, Empty);
|
||||
}
|
||||
|
||||
private static string DescribeCondition(TemplateRouteStep step)
|
||||
{
|
||||
if (step.Conditions.Count == 0)
|
||||
return Always;
|
||||
|
||||
var joiner = step.ConditionsLogic == ConditionsLogic.Or ? " ИЛИ " : " И ";
|
||||
return string.Join(joiner, step.Conditions.OrderBy(c => c.OrderNo).Select(c =>
|
||||
{
|
||||
var field = ConditionFieldAliases.GetValueOrDefault(c.FieldName, c.FieldName);
|
||||
var symbol = OperatorSymbols.GetValueOrDefault(c.Operation, string.Empty);
|
||||
return $"{field} {symbol} {c.Value}";
|
||||
}));
|
||||
}
|
||||
|
||||
// Галочка «можно переназначить» — шаг может передать другому: согласующий
|
||||
// назначает согласующего, исполнитель — исполнителя.
|
||||
private static string DescribeComment(TemplateRouteStep step) =>
|
||||
!step.AllowReassignToOtherApprovers
|
||||
? string.Empty
|
||||
: step.Status == RouteStepStatus.Execution ? "может назначить исполнителя" : "может назначить согласующего";
|
||||
|
||||
private static string EmptyIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? Empty : value;
|
||||
|
||||
private sealed class PortalDirectory(
|
||||
IReadOnlyList<EmployeeContactInfo> employees,
|
||||
IReadOnlyDictionary<Guid, string> companies,
|
||||
IReadOnlyDictionary<Guid, string> departments)
|
||||
{
|
||||
// Справочник даёт строку на каждую компанию сотрудника — берём первую с
|
||||
// заполненной должностью, остальные для ФИО/должности ничего не добавляют.
|
||||
public EmployeeContactInfo? FindByEmail(string email) =>
|
||||
employees.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(e => !string.IsNullOrWhiteSpace(e.Position))
|
||||
.FirstOrDefault();
|
||||
|
||||
public EmployeeContactInfo? FindByPosition(string position) =>
|
||||
employees.FirstOrDefault(e => string.Equals(e.Position?.Trim(), position.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Организация — компания СОТРУДНИКА, а не маршрута. Справочник даёт строку на
|
||||
// каждую компанию, где он числится (совместители), поэтому выводим все.
|
||||
public string CompaniesOf(string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return Empty;
|
||||
|
||||
var names = employees
|
||||
.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(e.Company))
|
||||
.Select(e => e.Company)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return names.Count == 0 ? Empty : string.Join(", ", names);
|
||||
}
|
||||
|
||||
// Пока справочник не отдал имя (или запись удалена) — усечённый Guid, как в админке.
|
||||
public string CompanyName(Guid id) => companies.GetValueOrDefault(id) ?? ShortGuid(id);
|
||||
|
||||
public string DepartmentName(Guid id) => departments.GetValueOrDefault(id) ?? ShortGuid(id);
|
||||
|
||||
private static string ShortGuid(Guid id) => id.ToString()[..8];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using NPOI.SS.UserModel;
|
||||
using NPOI.SS.Util;
|
||||
using NPOI.XSSF.UserModel;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
|
||||
public sealed record RouteExportRow(
|
||||
int Number,
|
||||
string Role,
|
||||
string Executor,
|
||||
string Organization,
|
||||
string Condition,
|
||||
string Comment);
|
||||
|
||||
/// <summary>Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация).</summary>
|
||||
public sealed record RouteExportGroup(string Caption, IReadOnlyList<RouteExportRow> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
|
||||
/// (по компании/отделу) и их нужно различать на одном листе; у единственного
|
||||
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
|
||||
/// </summary>
|
||||
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
|
||||
|
||||
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
||||
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
||||
|
||||
/// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
|
||||
public sealed record RouteExportRequest(IReadOnlyList<int>? RequestTypeIds);
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
||||
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
||||
///
|
||||
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
|
||||
/// <see cref="RouteExportService"/>.
|
||||
/// </summary>
|
||||
public static class RouteExportWorkbook
|
||||
{
|
||||
private const int MaxSheetNameLength = 31;
|
||||
private const string InvalidSheetNameChars = "[]:*?/\\";
|
||||
|
||||
private static readonly (string Title, double Width)[] Columns =
|
||||
[
|
||||
(" ", 6.0),
|
||||
("Роль ", 41.1),
|
||||
("Исполнитель ", 42.2),
|
||||
("Организация ", 28.0),
|
||||
("Условие ", 66.2),
|
||||
("Комментарий ", 36.0),
|
||||
];
|
||||
|
||||
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
|
||||
{
|
||||
var workbook = new XSSFWorkbook();
|
||||
|
||||
var headerStyle = CreateStyle(workbook, bold: true);
|
||||
var cellStyle = CreateStyle(workbook, bold: false);
|
||||
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
||||
|
||||
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var sheetData in sheets)
|
||||
{
|
||||
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
sheet.SetColumnWidth(i, (int)(Columns[i].Width * 256));
|
||||
|
||||
var rowIndex = 0;
|
||||
foreach (var section in sheetData.Sections)
|
||||
{
|
||||
if (rowIndex > 0) rowIndex++; // пустая строка только между маршрутами
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||
{
|
||||
var titleRow = sheet.CreateRow(rowIndex);
|
||||
var titleCell = titleRow.CreateCell(0);
|
||||
titleCell.SetCellValue(section.Title);
|
||||
titleCell.CellStyle = titleStyle;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
// Шапка колонок — один раз на маршрут: исполнители идут сразу под
|
||||
// согласующими, их отделяет только строка-подпись.
|
||||
var firstGroup = true;
|
||||
foreach (var group in section.Groups)
|
||||
{
|
||||
var captionRow = sheet.CreateRow(rowIndex);
|
||||
var captionCell = captionRow.CreateCell(0);
|
||||
captionCell.SetCellValue(group.Caption);
|
||||
captionCell.CellStyle = titleStyle;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
||||
rowIndex++;
|
||||
|
||||
if (firstGroup)
|
||||
{
|
||||
var header = sheet.CreateRow(rowIndex++);
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
{
|
||||
var cell = header.CreateCell(i);
|
||||
cell.SetCellValue(Columns[i].Title);
|
||||
cell.CellStyle = headerStyle;
|
||||
}
|
||||
}
|
||||
|
||||
firstGroup = false;
|
||||
|
||||
foreach (var row in group.Rows)
|
||||
{
|
||||
var excelRow = sheet.CreateRow(rowIndex++);
|
||||
var numberCell = excelRow.CreateCell(0);
|
||||
numberCell.SetCellValue(row.Number);
|
||||
numberCell.CellStyle = cellStyle;
|
||||
WriteText(excelRow, 1, row.Role, cellStyle);
|
||||
WriteText(excelRow, 2, row.Executor, cellStyle);
|
||||
WriteText(excelRow, 3, row.Organization, cellStyle);
|
||||
WriteText(excelRow, 4, row.Condition, cellStyle);
|
||||
WriteText(excelRow, 5, row.Comment, cellStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.Write(stream, leaveOpen: true);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteText(IRow row, int column, string value, ICellStyle style)
|
||||
{
|
||||
var cell = row.CreateCell(column);
|
||||
cell.SetCellValue(value);
|
||||
cell.CellStyle = style;
|
||||
}
|
||||
|
||||
private static ICellStyle CreateStyle(IWorkbook workbook, bool bold, bool border = true)
|
||||
{
|
||||
var font = workbook.CreateFont();
|
||||
font.IsBold = bold;
|
||||
|
||||
var style = workbook.CreateCellStyle();
|
||||
style.SetFont(font);
|
||||
style.WrapText = true;
|
||||
style.VerticalAlignment = VerticalAlignment.Top;
|
||||
if (border)
|
||||
{
|
||||
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
// Имя листа Excel: до 31 символа, без []:*?/\, уникальное без учёта регистра.
|
||||
private static string UniqueSheetName(string raw, HashSet<string> used)
|
||||
{
|
||||
var cleaned = new string((raw ?? string.Empty)
|
||||
.Select(c => InvalidSheetNameChars.Contains(c) ? ' ' : c)
|
||||
.ToArray()).Trim();
|
||||
if (cleaned.Length == 0) cleaned = "Лист";
|
||||
|
||||
var candidate = Truncate(cleaned, MaxSheetNameLength);
|
||||
for (var n = 2; !used.Add(candidate); n++)
|
||||
{
|
||||
var suffix = $" ({n})";
|
||||
candidate = Truncate(cleaned, MaxSheetNameLength - suffix.Length) + suffix;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int length) =>
|
||||
value.Length <= length ? value : value[..length].TrimEnd();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «Заявка на подбор — данные». Имена колонок — как в request-portal
|
||||
/// (provisioning.json формы RecruitingSelection).
|
||||
/// </summary>
|
||||
internal sealed record SelectionRow(
|
||||
int Id,
|
||||
string ListId,
|
||||
string Status,
|
||||
string? Position,
|
||||
string? DepartmentName,
|
||||
string? ObjectProject,
|
||||
int VacanciesCount,
|
||||
DateOnly? SubmitDate,
|
||||
string? Initiator,
|
||||
string? OpenReason,
|
||||
string? OpenReasonDetails)
|
||||
{
|
||||
/// <summary>
|
||||
/// Поля, которые нужны отчёту, — перечислены явно: список данных широкий
|
||||
/// (полсотни колонок, часть — Note с многострочным текстом), и тянуть его
|
||||
/// целиком по всем партициям только ради шестнадцати колонок незачем.
|
||||
/// </summary>
|
||||
public const string Select =
|
||||
"$select=Id,RequestStatus,Position,DepartmentName,ObjectProject,OpenVacanciesCount," +
|
||||
"SubmitDate,Created,VacancyOpenReason,VacancyOpenReasonDetails,Responsible/Title&$expand=Responsible&$top=5000";
|
||||
|
||||
public static SelectionRow? TryParse(JsonElement item, string listId)
|
||||
{
|
||||
var id = SharePointJson.Int(item, "Id");
|
||||
if (id is null) return null;
|
||||
|
||||
return new SelectionRow(
|
||||
id.Value,
|
||||
listId,
|
||||
SharePointJson.String(item, "RequestStatus") ?? string.Empty,
|
||||
SharePointJson.String(item, "Position"),
|
||||
SharePointJson.String(item, "DepartmentName"),
|
||||
SharePointJson.String(item, "ObjectProject"),
|
||||
// Пустое «Количество открытых вакансий» — это одна вакансия: заявку
|
||||
// подают на конкретную должность, и ноль вакансий в отчёте сломал бы
|
||||
// и итог по колонке E, и признак «закрыта» (0 из 0).
|
||||
SharePointJson.Int(item, "OpenVacanciesCount") is { } count and > 0 ? count : 1,
|
||||
// Дата подачи — поле формы, заполняется руками и может быть пустым;
|
||||
// тогда берём дату создания элемента. Тем же правилом заявки
|
||||
// отбираются по интервалу (см. VacancyReportService.PeriodFilter) —
|
||||
// иначе заявка попадала бы в отчёт, но с пустой колонкой H.
|
||||
SharePointJson.Date(item, "SubmitDate") ?? SharePointJson.Date(item, "Created"),
|
||||
SharePointJson.PersonTitle(item, "Responsible"),
|
||||
SharePointJson.String(item, "VacancyOpenReason"),
|
||||
SharePointJson.String(item, "VacancyOpenReasonDetails"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Чтение полей из ответа SharePoint REST. Те же грабли, что разобраны в
|
||||
/// Reminders/ApprovalRow: Number-колонка приходит дробной («1.0»), и обычный
|
||||
/// TryGetInt32 на ней возвращает false, а часть полей SharePoint отдаёт строками.
|
||||
/// </summary>
|
||||
internal static class SharePointJson
|
||||
{
|
||||
public static string? String(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() is { Length: > 0 } s ? s : null
|
||||
: null;
|
||||
|
||||
public static int? Int(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
|
||||
if (v.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
if (v.TryGetInt32(out var i)) return i;
|
||||
return v.TryGetDouble(out var d) && d is >= int.MinValue and <= int.MaxValue ? (int)Math.Round(d) : null;
|
||||
}
|
||||
|
||||
if (v.ValueKind != JsonValueKind.String) return null;
|
||||
|
||||
return double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||
&& parsed is >= int.MinValue and <= int.MaxValue
|
||||
? (int)Math.Round(parsed)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Дата БЕЗ приведения к другой зоне: колонки отчёта — dateOnly, SharePoint
|
||||
/// отдаёт их полуночью, и любой сдвиг зоны увёл бы день на соседний.
|
||||
/// </summary>
|
||||
public static DateOnly? Date(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
&& DateTimeOffset.TryParse(v.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
? DateOnly.FromDateTime(parsed.DateTime)
|
||||
: null;
|
||||
|
||||
/// <summary>Title развёрнутого персонального поля (…/Title через $expand). null — поле пустое.</summary>
|
||||
public static string? PersonTitle(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var person) && person.ValueKind == JsonValueKind.Object
|
||||
? String(person, "Title")
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям за период — ровно то, что ложится на лист «Список вакансий
|
||||
/// (все)» в xlsx: интервал плюс вакансии, разбитые по объектам/проектам.
|
||||
/// </summary>
|
||||
public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList<VacancyReportGroup> Groups);
|
||||
|
||||
/// <summary>
|
||||
/// Объект/проект и его вакансии. Имя группы становится строкой-разделителем на
|
||||
/// листе (как «КЭС КУРЧАТОВ» в образце отчёта); пустое имя — вакансии, у которых
|
||||
/// объект в заявке не указан, они идут первым блоком без разделителя.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportGroup(string Name, IReadOnlyList<VacancyReportRow> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк
|
||||
/// на листе, там они объединяются по вертикали; то, что различается от приёма к
|
||||
/// приёму, лежит в Lines.
|
||||
///
|
||||
/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся:
|
||||
/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в
|
||||
/// файле поправят руками.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportRow(
|
||||
// «Вакансия» — должность из заявки на подбор.
|
||||
string Position,
|
||||
// «Подразделение» ответственного за подачу заявки.
|
||||
string? Department,
|
||||
// «Кол-во вакансий».
|
||||
int VacanciesCount,
|
||||
// «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
|
||||
int ClosedCount,
|
||||
// «Дата подачи заявки».
|
||||
DateOnly? SubmitDate,
|
||||
// «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
|
||||
DateOnly? ClosingDate,
|
||||
// «Инициатор заявки».
|
||||
string? Initiator,
|
||||
// «Закрыта вакансия»: да / нет / частично.
|
||||
string ClosedFlag,
|
||||
// «Рекрутер»: исполнитель фазы исполнения заявки на подбор.
|
||||
string? Recruiter,
|
||||
// Строки листа по этой вакансии — ВСЕГДА не меньше одной (см. VacancyReportLine).
|
||||
IReadOnlyList<VacancyReportLine> Lines);
|
||||
|
||||
/// <summary>
|
||||
/// Одна СТРОКА ЛИСТА в пределах вакансии — то, что различается между её строками.
|
||||
/// Обычно это один приём: на заявку на подбор «Разнорабочий, 4 вакансии»
|
||||
/// приходится четыре заявки на приём, у каждой свой кандидат, своя дата выхода и
|
||||
/// свой «взамен кого».
|
||||
///
|
||||
/// Именно СТРОКА, а не «приём»: у вакансии, по которой приёмов ещё нет (или все
|
||||
/// отклонены), строка всё равно одна — с пустыми ФИО и датой выхода, но с
|
||||
/// заполненным «Вместо сотрудника» из причины открытия вакансии. Реальное число
|
||||
/// приёмов лежит в VacancyReportRow.ClosedCount, так что информация не теряется.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportLine(
|
||||
// «Ф.И.О. нанимаемого сотрудника» — кандидат из заявки на приём.
|
||||
string? CandidateName,
|
||||
// «Дата выхода» — дата приёма этого кандидата.
|
||||
DateOnly? StartDate,
|
||||
// «Вместо сотрудника Ф.И.О.».
|
||||
string? ReplacedEmployee);
|
||||
|
||||
/// <summary>Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет.</summary>
|
||||
public static class VacancyClosedFlags
|
||||
{
|
||||
public const string Yes = "да";
|
||||
public const string No = "нет";
|
||||
public const string Partial = "частично";
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using System.Globalization;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям за период. Источник — две формы портала: «Заявка на подбор»
|
||||
/// даёт саму вакансию (должность, количество, объект, инициатор), «Приём на
|
||||
/// работу» — её закрытие (кого и когда приняли). Связь между ними — колонки
|
||||
/// SelectionRequestId / SelectionRequestListId заявки на приём.
|
||||
///
|
||||
/// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного
|
||||
/// списка «— согласование», из строк фазы «Исполнение» (см. ExecutionRow).
|
||||
///
|
||||
/// ПОЧЕМУ ЗДЕСЬ items REST ПО ПАРТИЦИЯМ, А НЕ SEARCH (разбирались 2026-08-28).
|
||||
/// Search выглядит уместнее — один KQL сразу по всем партициям, и отставание
|
||||
/// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не
|
||||
/// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно
|
||||
/// через REST):
|
||||
/// • «Заявка на подбор»: VacancyOpenReason, VacancyOpenReasonDetails;
|
||||
/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
|
||||
/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
|
||||
/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
|
||||
/// Префикс группы колонок сайта (TansuSite…, см. tools/sp/README.md) тоже не
|
||||
/// помогает — пробовали.
|
||||
///
|
||||
/// Причина, если понадобится чинить: одноимённые колонки в РАЗНЫХ списках — это
|
||||
/// разные поля со своими GUID, а значит и со своими crawled property
|
||||
/// (ows_Position, ows_Position1, …); автосозданное managed property привязано
|
||||
/// только к одному из них. Поэтому, например, PositionOWSTEXT отдаёт значения в
|
||||
/// «Заявка на подбор — данные» и молчит в «Прием на работу — данные». Проверять
|
||||
/// надо не «работает ли свойство», а «работает ли оно для ЭТОГО списка» — этим
|
||||
/// занимается `sp-write props` (репозиторий C:\work\git\tools), он же говорит,
|
||||
/// нужно ли домапить crawled property или свойства нет вовсе.
|
||||
///
|
||||
/// Значит эти поля пришлось бы дочитывать построчно батчами по 100 — и на 1000
|
||||
/// заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST.
|
||||
/// В list view threshold (5000) REST при этом не упирается by design: партиции
|
||||
/// режутся на 4900 элементов (ListPartitioning:ItemCountThreshold), то есть ни
|
||||
/// один $filter не сканирует больше предела.
|
||||
///
|
||||
/// Вернуться к Search имеет смысл, только если в Search Schema заведут
|
||||
/// работающие managed properties для перечисленных выше полей.
|
||||
/// </summary>
|
||||
public sealed class VacancyReportService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
IOptions<ReportsOptions> options,
|
||||
ILogger<VacancyReportService> logger)
|
||||
{
|
||||
public async Task<Result<VacancyReport>> GetAsync(
|
||||
DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
{
|
||||
if (to < from)
|
||||
return Result.Fail($"Конец периода ({to:dd.MM.yyyy}) раньше его начала ({from:dd.MM.yyyy}).");
|
||||
|
||||
var selectionType = await FindTypeAsync(options.Value.RecruitingSelectionPageUrl, cancellationToken);
|
||||
if (selectionType is null)
|
||||
return Result.Fail(
|
||||
$"Тип заявки «{options.Value.RecruitingSelectionPageUrl}» не настроен (нет списка или сайта) — " +
|
||||
"отчёт по вакансиям строить не из чего.");
|
||||
|
||||
var selections = await ReadSelectionsAsync(selectionType, from, to, cancellationToken);
|
||||
|
||||
// Заявки на приём читаются ВСЕ, без ограничения периодом: у вакансии,
|
||||
// поданной в начале интервала, приём мог оформляться уже после его конца,
|
||||
// и фильтр по дате обнулил бы ей и «Дату закрытия», и «Дату выхода».
|
||||
// Дальше они отбираются по ссылке на заявку на подбор.
|
||||
var hireType = await FindTypeAsync(options.Value.RecruitingHirePageUrl, cancellationToken);
|
||||
if (hireType is null)
|
||||
logger.LogWarning(
|
||||
"Тип заявки «{PageUrl}» не настроен — отчёт по вакансиям будет без закрытий",
|
||||
options.Value.RecruitingHirePageUrl);
|
||||
|
||||
var hires = hireType is null
|
||||
? (IReadOnlyList<HireLinkRow>)[]
|
||||
: await ReadHiresAsync(hireType, cancellationToken);
|
||||
|
||||
var hiresBySelection = hires
|
||||
.GroupBy(x => (x.SelectionRequestId, ListId: NormalizeListId(x.SelectionRequestListId)))
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<HireLinkRow>)g.ToList());
|
||||
|
||||
return Result.Ok(new VacancyReport(from, to, Group(selections, hiresBySelection)));
|
||||
}
|
||||
|
||||
private async Task<RequestType?> FindTypeAsync(string pageUrl, CancellationToken cancellationToken) =>
|
||||
await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.PageUrl == pageUrl && x.ListId != null && x.SiteUrl != null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Заявки на подбор за период по ВСЕМ партициям типа — вакансия, уехавшая в
|
||||
/// архивный список, из отчёта пропадать не должна: подбор по ней мог начаться
|
||||
/// задолго до того, как для новых заявок завели следующую партицию.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<SelectionWithExecution>> ReadSelectionsAsync(
|
||||
RequestType type, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<SelectionWithExecution>();
|
||||
|
||||
foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
result.AddRange(await ReadSelectionPartitionAsync(type, listId, from, to, cancellationToken));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Недоступная партиция (архивный список переименовали или удалили
|
||||
// руками) не должна обнулять весь отчёт — в файл попадёт то, что
|
||||
// прочиталось, а причина уйдёт в лог.
|
||||
logger.LogError(
|
||||
e, "Отчёт по вакансиям: партиция №{Number} ({ListId}) типа «{Type}» не прочитана",
|
||||
number, listId, type.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SelectionWithExecution>> ReadSelectionPartitionAsync(
|
||||
RequestType type, string listId, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
{
|
||||
var siteUrl = type.SiteUrl!;
|
||||
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, listId, $"{SelectionRow.Select}&{PeriodFilter(from, to)}", cancellationToken);
|
||||
|
||||
var rows = items
|
||||
.Select(item => SelectionRow.TryParse(item, listId))
|
||||
.OfType<SelectionRow>()
|
||||
.ToList();
|
||||
|
||||
// Список согласования читаем только если в партиции вообще нашлись
|
||||
// заявки за период: у пустой партиции он всё равно ничего не добавит.
|
||||
if (rows.Count == 0) return [];
|
||||
|
||||
var executions = await ReadExecutionsAsync(siteUrl, listId, cancellationToken);
|
||||
|
||||
return rows
|
||||
.Select(row => new SelectionWithExecution(
|
||||
row, executions.TryGetValue(row.Id, out var found) ? found : ExecutionInfo.Empty))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строки фазы «Исполнение» партиции, свёрнутые по заявке. Заголовок списка
|
||||
/// согласования выводится из ЖИВОГО заголовка списка данных, а не из url:
|
||||
/// список могли переименовать руками.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<int, ExecutionInfo>> ReadExecutionsAsync(
|
||||
string siteUrl, string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||
if (approvalListTitle is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Отчёт по вакансиям: у списка «{List}» нет парного списка согласования — " +
|
||||
"колонки «Рекрутер» и «Дата закрытия вакансии» останутся пустыми",
|
||||
dataListTitle);
|
||||
return [];
|
||||
}
|
||||
|
||||
var items = await sharePoint.GetItemsByListTitleAsync(
|
||||
siteUrl, approvalListTitle, ExecutionRow.Select, cancellationToken);
|
||||
|
||||
return items
|
||||
.Select(ExecutionRow.TryParse)
|
||||
.OfType<ExecutionRow>()
|
||||
.GroupBy(x => x.TicketId)
|
||||
.ToDictionary(g => g.Key, ExecutionInfo.From);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<HireLinkRow>> ReadHiresAsync(
|
||||
RequestType type, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<HireLinkRow>();
|
||||
|
||||
foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
type.SiteUrl!, listId, HireLinkRow.Select, cancellationToken);
|
||||
|
||||
result.AddRange(items.Select(HireLinkRow.TryParse).OfType<HireLinkRow>());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(
|
||||
e, "Отчёт по вакансиям: приёмы из партиции №{Number} ({ListId}) не прочитаны", number, listId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Все партиции типа — архив плюс текущая (она в реестр не пишется, а синтезируется).</summary>
|
||||
private async Task<IReadOnlyList<(string ListId, int Number)>> PartitionsOfAsync(
|
||||
RequestType type, CancellationToken cancellationToken)
|
||||
{
|
||||
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||
|
||||
return archived
|
||||
.Select(x => (x.ListId, x.Number))
|
||||
.Append((current.ListId, current.Number))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static VacancyReportRow Build(
|
||||
SelectionWithExecution selection,
|
||||
IReadOnlyDictionary<(int, string), IReadOnlyList<HireLinkRow>> hiresBySelection)
|
||||
{
|
||||
var (row, execution) = selection;
|
||||
|
||||
var hires = hiresBySelection.TryGetValue((row.Id, NormalizeListId(row.ListId)), out var found)
|
||||
? found
|
||||
: [];
|
||||
|
||||
// Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой
|
||||
// кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы
|
||||
// ложью («вот кого приняли»).
|
||||
var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList();
|
||||
|
||||
return new VacancyReportRow(
|
||||
row.Position ?? string.Empty,
|
||||
row.DepartmentName,
|
||||
row.VacanciesCount,
|
||||
accepted.Count,
|
||||
row.SubmitDate,
|
||||
execution.ClosingDate,
|
||||
ShortName(row.Initiator),
|
||||
ClosedFlag(row.VacanciesCount, accepted.Count),
|
||||
execution.Recruiter,
|
||||
Lines(row, accepted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет
|
||||
/// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ
|
||||
/// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней
|
||||
/// заполняется из причины открытия вакансии.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<VacancyReportLine> Lines(SelectionRow row, IReadOnlyList<HireLinkRow> accepted)
|
||||
{
|
||||
if (accepted.Count == 0)
|
||||
return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))];
|
||||
|
||||
return accepted
|
||||
// Порядок строк внутри вакансии — по дате выхода: так в отчёте видно,
|
||||
// кто вышел раньше. Приём без даты уходит в конец.
|
||||
.OrderBy(x => x.HireDate ?? DateOnly.MaxValue)
|
||||
.ThenBy(x => x.CandidateName, StringComparer.CurrentCulture)
|
||||
.Select(x => new VacancyReportLine(x.CandidateName, x.HireDate, ReplacedEmployee(row, x)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле
|
||||
/// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если
|
||||
/// причина — замена уволенного: при «новой штатной единице» в том же поле
|
||||
/// лежит совсем другой текст.
|
||||
///
|
||||
/// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии
|
||||
/// он одинаковый — это нормально: причина открытия у вакансии одна.
|
||||
///
|
||||
/// hire = null — у вакансии приёмов нет вовсе, остаётся только откат.
|
||||
/// </summary>
|
||||
private static string? ReplacedEmployee(SelectionRow row, HireLinkRow? hire)
|
||||
{
|
||||
if (hire is not null && !string.IsNullOrWhiteSpace(hire.ReplacedEmployee))
|
||||
return hire.ReplacedEmployee;
|
||||
|
||||
return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null;
|
||||
}
|
||||
|
||||
private static string ClosedFlag(int vacancies, int closed) => closed switch
|
||||
{
|
||||
0 => VacancyClosedFlags.No,
|
||||
_ when closed >= vacancies => VacancyClosedFlags.Yes,
|
||||
_ => VacancyClosedFlags.Partial,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// «Фамилия Имя Отчество» → «Фамилия И.О.» — так инициатор записан в образце
|
||||
/// отчёта («Ким Н.Ю.»). Имя из одного слова остаётся как есть.
|
||||
/// </summary>
|
||||
private static string? ShortName(string? fullName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullName)) return null;
|
||||
|
||||
var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2) return fullName.Trim();
|
||||
|
||||
var initials = string.Concat(parts.Skip(1).Select(x => $"{char.ToUpperInvariant(x[0])}."));
|
||||
return $"{parts[0]} {initials}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Группировка по объекту/проекту — она же порядок строк на листе: сначала
|
||||
/// вакансии без объекта (в образце это верхний блок без разделителя), затем
|
||||
/// объекты по алфавиту, внутри группы — по дате подачи заявки.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<VacancyReportGroup> Group(
|
||||
IEnumerable<SelectionWithExecution> selections,
|
||||
IReadOnlyDictionary<(int, string), IReadOnlyList<HireLinkRow>> hiresBySelection) => selections
|
||||
.GroupBy(x => (x.Row.ObjectProject ?? string.Empty).Trim())
|
||||
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
|
||||
.ThenBy(g => g.Key, StringComparer.CurrentCulture)
|
||||
.Select(g => new VacancyReportGroup(
|
||||
g.Key,
|
||||
g.OrderBy(x => x.Row.SubmitDate ?? DateOnly.MaxValue)
|
||||
.ThenBy(x => x.Row.Id)
|
||||
.Select(x => Build(x, hiresBySelection))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр периода по «Дате подачи заявки» с откатом на дату создания элемента:
|
||||
/// SubmitDate — поле формы, заполняется руками и часто пустое, и без отката
|
||||
/// такие заявки не попали бы в отчёт вообще.
|
||||
///
|
||||
/// Верхняя граница — по НАЧАЛУ следующего дня и строгим «lt»: даты в SharePoint
|
||||
/// лежат с временем, и «le {to}» отсекло бы всё, что записано в сам день `to`
|
||||
/// позже полуночи.
|
||||
/// </summary>
|
||||
private static string PeriodFilter(DateOnly from, DateOnly to)
|
||||
{
|
||||
var start = from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
var end = to.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
|
||||
return "$filter=" +
|
||||
$"(SubmitDate ge datetime'{start}T00:00:00Z' and SubmitDate lt datetime'{end}T00:00:00Z') or " +
|
||||
$"(SubmitDate eq null and Created ge datetime'{start}T00:00:00Z' " +
|
||||
$"and Created lt datetime'{end}T00:00:00Z')";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
|
||||
/// конвенции «<Форма> <N> — данные|согласование»: ListTitleConvention
|
||||
/// в Infrastructure — internal, а здесь нужен только этот один переход.
|
||||
/// </summary>
|
||||
private static string? ApprovalListTitleFor(string? dataListTitle)
|
||||
{
|
||||
const string dataSuffix = " — данные";
|
||||
if (string.IsNullOrWhiteSpace(dataListTitle) || !dataListTitle.EndsWith(dataSuffix, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
return string.Concat(dataListTitle.AsSpan(0, dataListTitle.Length - dataSuffix.Length), " — согласование");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Приведение server-relative url партиции к сравнимому виду: портал пишет
|
||||
/// SelectionRequestListId не всегда в том же регистре и с тем же хвостовым слешем.
|
||||
/// </summary>
|
||||
private static string NormalizeListId(string? listId) =>
|
||||
(listId ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Заявка на подбор вместе с тем, что о ней знает её фаза исполнения.</summary>
|
||||
internal sealed record SelectionWithExecution(SelectionRow Row, ExecutionInfo Execution);
|
||||
|
||||
/// <summary>Статусы списка «Прием на работу — данные», которые важны отчёту.</summary>
|
||||
internal static class HireRequestStatus
|
||||
{
|
||||
public const string Rejected = "Отклонено";
|
||||
}
|
||||
|
||||
/// <summary>Значение колонки «Причина открытия вакансии», при котором заполнено «вместо кого».</summary>
|
||||
internal static class SelectionOpenReason
|
||||
{
|
||||
public const string Replacement = "Замена уволенного сотрудника";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Что фаза исполнения заявки на подбор даёт отчёту. Рекрутёр — исполнитель
|
||||
/// ПОСЛЕДНЕГО шага исполнения (шагов может быть несколько, вакансию довёл
|
||||
/// последний); дата закрытия появляется только когда ВСЕ шаги исполнения
|
||||
/// выполнены — пока хоть один в работе, вакансия не закрыта.
|
||||
/// </summary>
|
||||
internal sealed record ExecutionInfo(string? Recruiter, DateOnly? ClosingDate)
|
||||
{
|
||||
public static readonly ExecutionInfo Empty = new(null, null);
|
||||
|
||||
public static ExecutionInfo From(IEnumerable<ExecutionRow> rows)
|
||||
{
|
||||
var ordered = rows.OrderBy(x => x.StepOrder).ToList();
|
||||
if (ordered.Count == 0) return Empty;
|
||||
|
||||
var recruiter = ordered
|
||||
.Select(x => x.ExecutorName)
|
||||
.LastOrDefault(x => !string.IsNullOrWhiteSpace(x));
|
||||
|
||||
var closingDate = ordered.All(x => x.Status == ExecutionRow.Done)
|
||||
? ordered.Select(x => x.Date).Where(x => x is not null).Max()
|
||||
: null;
|
||||
|
||||
return new ExecutionInfo(recruiter, closingDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Разбивка отчёта по вакансиям по рекрутёрам — второй лист xlsx («Результаты» в
|
||||
/// образце ручного отчёта HR): кто сколько вакансий ведёт, сколько из них в
|
||||
/// работе/закрыто, и доля каждого рекрутёра в общем объёме — то, что дальше
|
||||
/// рисуется как две круговые диаграммы.
|
||||
///
|
||||
/// Считается по той же колонке E (VacanciesCount), что и итоги на первом листе, а
|
||||
/// не по числу заявок: одна заявка на подбор с несколькими вакансиями (напр.
|
||||
/// «Разнорабочий ×4») засчитывается рекрутёру всеми своими местами разом — так же,
|
||||
/// как единица подсчёта работает на первом листе.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportSummary(int GrandTotal, int GrandInProgress, IReadOnlyList<RecruiterBreakdown> Recruiters)
|
||||
{
|
||||
/// <summary>Без рекрутёра не бывает: 0 вакансий = 0 рекрутёров, а не пустая диаграмма с NaN-процентами.</summary>
|
||||
public static readonly VacancyReportSummary Empty = new(0, 0, []);
|
||||
|
||||
public static VacancyReportSummary Build(VacancyReport report)
|
||||
{
|
||||
var rows = report.Groups.SelectMany(g => g.Rows).ToList();
|
||||
if (rows.Count == 0) return Empty;
|
||||
|
||||
var recruiters = rows
|
||||
.GroupBy(r => string.IsNullOrWhiteSpace(r.Recruiter) ? UnassignedLabel : r.Recruiter!.Trim())
|
||||
.Select(g => new
|
||||
{
|
||||
Name = g.Key,
|
||||
Total = g.Sum(r => r.VacanciesCount),
|
||||
Closed = g.Sum(r => r.ClosedCount),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var grandTotal = recruiters.Sum(r => r.Total);
|
||||
var grandInProgress = recruiters.Sum(r => r.Total - r.Closed);
|
||||
|
||||
// «Без рекрутёра» — всегда последним и независимо от объёма: это не
|
||||
// реальный участник рейтинга, чья позиция в сортировке имела бы смысл.
|
||||
var ordered = recruiters
|
||||
.OrderByDescending(r => r.Name != UnassignedLabel)
|
||||
.ThenByDescending(r => r.Total)
|
||||
.Select(r => new RecruiterBreakdown(r.Name, r.Total, r.Total - r.Closed, r.Closed))
|
||||
.ToList();
|
||||
|
||||
return new VacancyReportSummary(grandTotal, grandInProgress, ordered);
|
||||
}
|
||||
|
||||
/// <summary>Строка для вакансий без рекрутёра (фаза исполнения ещё не началась или не даёт исполнителя).</summary>
|
||||
public const string UnassignedLabel = "Без рекрутёра";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один рекрутёр в сводке. Проценты не хранятся — считаются в момент вывода
|
||||
/// (ShareOfTotal/ShareOfInProgress/InProgressRate ниже), чтобы не разъезжались с
|
||||
/// Total/InProgress при округлении.
|
||||
/// </summary>
|
||||
public sealed record RecruiterBreakdown(string Name, int Total, int InProgress, int Closed)
|
||||
{
|
||||
/// <summary>Доля этого рекрутёра в общем объёме вакансий периода (для первой диаграммы).</summary>
|
||||
public double ShareOfTotal(int grandTotal) => grandTotal > 0 ? (double)Total / grandTotal : 0;
|
||||
|
||||
/// <summary>Доля этого рекрутёра в объёме вакансий, которые ещё в работе (для второй диаграммы).</summary>
|
||||
public double ShareOfInProgress(int grandInProgress) => grandInProgress > 0 ? (double)InProgress / grandInProgress : 0;
|
||||
|
||||
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые ещё в работе — «в работе (в%)» в горизонтальной таблице.</summary>
|
||||
public double InProgressRate => Total > 0 ? (double)InProgress / Total : 0;
|
||||
|
||||
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые уже закрыты — «закрыто» в горизонтальной таблице.</summary>
|
||||
public double ClosedRate => Total > 0 ? (double)Closed / Total : 0;
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
using NPOI.OOXML.XSSF.UserModel;
|
||||
using NPOI.SS.UserModel;
|
||||
using NPOI.SS.UserModel.Charts;
|
||||
using NPOI.SS.Util;
|
||||
using NPOI.XSSF.UserModel;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR:
|
||||
/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по
|
||||
/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя
|
||||
/// круговыми диаграммами).
|
||||
///
|
||||
/// Листы собираются кодом, а не заполнением файла-шаблона: число строк
|
||||
/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) —
|
||||
/// шаблон с такой структурой пришлось бы править на каждое изменение состава.
|
||||
///
|
||||
/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов
|
||||
/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный
|
||||
/// лист выглядел бы просто как две таблицы без визуализации, как в образце.
|
||||
///
|
||||
/// Производные колонки (G «в работе», J «срок отработки») и итоги первого
|
||||
/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше
|
||||
/// руками, и после правки строки цифры должны пересчитаться сами.
|
||||
/// </summary>
|
||||
public static class VacancyReportWorkbook
|
||||
{
|
||||
private const string SheetName = "Список вакансий (все)";
|
||||
private const string SummarySheetName = "Результаты";
|
||||
|
||||
private const string DateFormat = "dd.mm.yyyy";
|
||||
private const string WholePercentFormat = "0%";
|
||||
private const string PrecisePercentFormat = "0.00%";
|
||||
|
||||
/// <summary>Колонка первого листа: заголовок и ширина (ширины — из образца отчёта).</summary>
|
||||
private static readonly (string Title, double Width)[] Columns =
|
||||
[
|
||||
(" ", 6.1),
|
||||
("№", 4.9),
|
||||
("Вакансия", 57.4),
|
||||
("Подразделение", 17.1),
|
||||
("Кол-во вакансий", 8.3),
|
||||
("количество закрытых вакансий", 9.1),
|
||||
("количество вакансий в работе", 11.6),
|
||||
("Дата подачи заявки", 14.7),
|
||||
("Дата закрытия вакансии", 14.9),
|
||||
("Срок отработки", 12.3),
|
||||
("Дата выхода", 13.9),
|
||||
("Ф.И.О. нанимаемого сотрудника", 30.0),
|
||||
("Инициатор заявки", 26.9),
|
||||
("Вместо сотрудника Ф.И.О.", 26.0),
|
||||
("Комментарии", 51.3),
|
||||
("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
|
||||
("Рекрутер", 23.9),
|
||||
];
|
||||
|
||||
// Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.).
|
||||
private const int ColTotalNumber = 0;
|
||||
private const int ColGroupNumber = 1;
|
||||
private const int ColPosition = 2;
|
||||
private const int ColDepartment = 3;
|
||||
private const int ColVacancies = 4;
|
||||
private const int ColClosed = 5;
|
||||
private const int ColInProgress = 6;
|
||||
private const int ColSubmitDate = 7;
|
||||
private const int ColClosingDate = 8;
|
||||
private const int ColLeadTime = 9;
|
||||
private const int ColStartDate = 10;
|
||||
private const int ColCandidateName = 11;
|
||||
private const int ColInitiator = 12;
|
||||
private const int ColReplaced = 13;
|
||||
private const int ColComment = 14;
|
||||
private const int ColClosedFlag = 15;
|
||||
private const int ColRecruiter = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные
|
||||
/// общие — при нескольких приёмах по одной заявке на подбор они объединяются
|
||||
/// по вертикали (см. WriteVacancy).
|
||||
/// </summary>
|
||||
private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced];
|
||||
|
||||
private const int HeaderRowIdx = 0;
|
||||
|
||||
public static byte[] Build(VacancyReport report)
|
||||
{
|
||||
using var workbook = new XSSFWorkbook();
|
||||
var styles = new Styles(workbook);
|
||||
|
||||
BuildVacancyListSheet(workbook, styles, report);
|
||||
BuildSummarySheet(workbook, styles, report);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.Write(stream, leaveOpen: true);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Имя файла — с периодом, как в ручном отчёте HR.</summary>
|
||||
public static string FileName(VacancyReport report) =>
|
||||
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
|
||||
|
||||
// ==================== Лист 1: «Список вакансий (все)» ====================
|
||||
|
||||
private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
|
||||
{
|
||||
var sheet = (XSSFSheet)workbook.CreateSheet(SheetName);
|
||||
|
||||
WriteHeader(sheet, styles);
|
||||
|
||||
var rowIdx = HeaderRowIdx + 1;
|
||||
var totalNumber = 0;
|
||||
var firstDataRowIdx = rowIdx;
|
||||
|
||||
foreach (var group in report.Groups)
|
||||
{
|
||||
if (group.Name.Length > 0) WriteGroupSeparator(sheet, styles, rowIdx++, group.Name);
|
||||
|
||||
var groupNumber = 0;
|
||||
foreach (var item in group.Rows)
|
||||
rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item);
|
||||
}
|
||||
|
||||
var lastDataRowIdx = rowIdx - 1;
|
||||
|
||||
// Итоги — только если есть что суммировать: SUM по пустому диапазону
|
||||
// (firstDataRowIdx > lastDataRowIdx) дал бы формулу с перевёрнутым диапазоном.
|
||||
if (lastDataRowIdx >= firstDataRowIdx) WriteTotals(sheet, styles, rowIdx, firstDataRowIdx, lastDataRowIdx);
|
||||
|
||||
FinishVacancyListSheet(sheet, styles, lastDataRowIdx);
|
||||
}
|
||||
|
||||
private static void WriteHeader(XSSFSheet sheet, Styles styles)
|
||||
{
|
||||
var row = sheet.CreateRow(HeaderRowIdx);
|
||||
// Заголовки длинные и переносятся — без заданной высоты Excel показал бы
|
||||
// одну строку из трёх (высота 90 — из образца отчёта).
|
||||
row.HeightInPoints = 90f;
|
||||
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
{
|
||||
var (title, width) = Columns[col];
|
||||
var cell = row.CreateCell(col);
|
||||
cell.SetCellValue(title);
|
||||
cell.CellStyle = styles.Header;
|
||||
sheet.SetColumnWidth(col, (int)Math.Round(width * 256));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
|
||||
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
|
||||
/// начинается заново — так же, как в ручном отчёте.
|
||||
/// </summary>
|
||||
private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name)
|
||||
{
|
||||
var row = sheet.CreateRow(rowIdx);
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
row.CreateCell(col).CellStyle = styles.GroupSeparator;
|
||||
|
||||
row.GetCell(ColGroupNumber).SetCellValue(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна вакансия — столько строк, сколько у неё Lines (минимум одна).
|
||||
/// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик.
|
||||
///
|
||||
/// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по
|
||||
/// вертикали: у объединённой области Excel хранит значение в верхней левой
|
||||
/// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя —
|
||||
/// SUM в итогах посчитал бы одно и то же несколько раз).
|
||||
/// </summary>
|
||||
private static int WriteVacancy(
|
||||
XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
|
||||
{
|
||||
var lines = item.Lines;
|
||||
var lineCount = Math.Max(lines.Count, 1);
|
||||
var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки.
|
||||
|
||||
// Все строки вакансии — со стилем данных, иначе у продолжений не будет
|
||||
// границ и объединённая область выглядит рваной.
|
||||
for (var offset = 0; offset < lineCount; offset++)
|
||||
{
|
||||
var row = sheet.CreateRow(firstRowIdx + offset);
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
row.CreateCell(col).CellStyle = styles.Data;
|
||||
}
|
||||
|
||||
var first = sheet.GetRow(firstRowIdx);
|
||||
|
||||
first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
|
||||
first.GetCell(ColGroupNumber).SetCellValue(groupNumber);
|
||||
first.GetCell(ColPosition).SetCellValue(item.Position);
|
||||
first.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
|
||||
first.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
|
||||
first.GetCell(ColClosed).SetCellValue(item.ClosedCount);
|
||||
first.GetCell(ColInProgress).SetCellFormula(
|
||||
$"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
|
||||
|
||||
SetDate(first.GetCell(ColSubmitDate), styles, item.SubmitDate);
|
||||
SetDate(first.GetCell(ColClosingDate), styles, item.ClosingDate);
|
||||
|
||||
// «Срок отработки» — разница дат в днях, и только когда известны обе:
|
||||
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
|
||||
// дней (пустая ячейка — это 00.01.1900).
|
||||
if (item.SubmitDate is not null && item.ClosingDate is not null)
|
||||
{
|
||||
var leadTimeCell = first.GetCell(ColLeadTime);
|
||||
leadTimeCell.CellStyle = styles.DataLeadTime;
|
||||
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
|
||||
}
|
||||
|
||||
first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
|
||||
// Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
|
||||
// отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
|
||||
// заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
|
||||
// в выгрузке.
|
||||
first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
|
||||
first.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
|
||||
|
||||
// Построчные поля — каждое в свою строку.
|
||||
for (var offset = 0; offset < lines.Count; offset++)
|
||||
{
|
||||
var row = sheet.GetRow(firstRowIdx + offset);
|
||||
var line = lines[offset];
|
||||
|
||||
row.GetCell(ColCandidateName).SetCellValue(line.CandidateName ?? string.Empty);
|
||||
SetDate(row.GetCell(ColStartDate), styles, line.StartDate);
|
||||
row.GetCell(ColReplaced).SetCellValue(line.ReplacedEmployee ?? string.Empty);
|
||||
}
|
||||
|
||||
// Объединяем общие колонки только когда строк действительно больше одной:
|
||||
// область 1×1 Excel считает некорректной и портит файл.
|
||||
if (lineCount > 1)
|
||||
{
|
||||
var lastRowIdx = firstRowIdx + lineCount - 1;
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
{
|
||||
if (PerLineColumns.Contains(col)) continue;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(firstRowIdx, lastRowIdx, col, col));
|
||||
}
|
||||
}
|
||||
|
||||
return lineCount;
|
||||
}
|
||||
|
||||
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
|
||||
{
|
||||
var row = sheet.CreateRow(rowIdx);
|
||||
var firstExcelRow = firstDataRowIdx + 1;
|
||||
var lastExcelRow = lastDataRowIdx + 1;
|
||||
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
row.CreateCell(col).CellStyle = styles.Totals;
|
||||
|
||||
foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress])
|
||||
{
|
||||
var letter = ColLetter(col);
|
||||
row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр
|
||||
/// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида
|
||||
/// вместе с отфильтрованными строками.
|
||||
/// </summary>
|
||||
private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx)
|
||||
{
|
||||
// ВАЖНО: даты НЕ переносятся сюда через SetDefaultColumnStyle — NPOI (в
|
||||
// отличие от чистого OOXML, где стиль колонки должен уступать явному
|
||||
// стилю ячейки) переписывает им стиль уже существующих ячеек колонки
|
||||
// целиком, включая шапку: заголовки «Дата подачи заявки»/«Дата закрытия
|
||||
// вакансии»/«Дата выхода» лишались синей заливки и получали формат даты
|
||||
// вместо неё. Формат даты у самих дат и так стоит явно, ячейка за
|
||||
// ячейкой (см. SetDate) — колонки, дописанные в файл руками ниже данных,
|
||||
// формат не унаследуют, но это не тот случай, ради которого стоит опять
|
||||
// ломать шапку.
|
||||
//
|
||||
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
|
||||
// какая колонка какая.
|
||||
sheet.CreateFreezePane(0, HeaderRowIdx + 1);
|
||||
|
||||
if (lastDataRowIdx > HeaderRowIdx)
|
||||
sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1));
|
||||
}
|
||||
|
||||
private static void SetDate(ICell cell, Styles styles, DateOnly? date)
|
||||
{
|
||||
if (date is null) return;
|
||||
|
||||
cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue));
|
||||
cell.CellStyle = styles.DataDate;
|
||||
}
|
||||
|
||||
// ==================== Лист 2: «Результаты» (сводка по рекрутёрам) ====================
|
||||
|
||||
/// <summary>
|
||||
/// Сводка по рекрутёрам: горизонтальная таблица (кто сколько вакансий ведёт,
|
||||
/// какая доля в работе/закрыта) и две вертикальные таблицы с долей каждого
|
||||
/// рекрутёра в общем объёме — они же источник данных для двух круговых
|
||||
/// диаграмм рядом. Раскладка — по образцу листа «Статус работы отдела_АХ» в
|
||||
/// ручном отчёте HR.
|
||||
/// </summary>
|
||||
private static void BuildSummarySheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
|
||||
{
|
||||
var sheet = (XSSFSheet)workbook.CreateSheet(SummarySheetName);
|
||||
sheet.SetColumnWidth(0, (int)Math.Round(26.0 * 256));
|
||||
|
||||
var titleRow = sheet.CreateRow(0);
|
||||
titleRow.CreateCell(0).SetCellValue("результаты");
|
||||
titleRow.GetCell(0).CellStyle = styles.Title;
|
||||
|
||||
var summary = VacancyReportSummary.Build(report);
|
||||
if (summary.Recruiters.Count == 0)
|
||||
{
|
||||
sheet.CreateRow(2).CreateCell(0).SetCellValue("Нет данных за период.");
|
||||
return;
|
||||
}
|
||||
|
||||
var recruiters = summary.Recruiters;
|
||||
for (var i = 0; i < recruiters.Count; i++)
|
||||
sheet.SetColumnWidth(1 + i, (int)Math.Round(12.0 * 256));
|
||||
|
||||
WriteHorizontalTable(sheet, styles, summary);
|
||||
|
||||
const int horizontalTableRows = 4; // заголовок + «Всего» + «в работе» + «закрыто»
|
||||
var vertTable1HeaderRowIdx = HeaderRowIdx + 1 + horizontalTableRows + 1; // +1 — пустая строка-отступ
|
||||
var vertTable1DataStartRowIdx = vertTable1HeaderRowIdx + 1;
|
||||
WriteVerticalTable(
|
||||
sheet, styles, vertTable1HeaderRowIdx, "Общий объем работ", summary.GrandTotal,
|
||||
recruiters, r => r.ShareOfTotal(summary.GrandTotal));
|
||||
|
||||
var vertTable2HeaderRowIdx = vertTable1DataStartRowIdx + recruiters.Count + 1;
|
||||
var vertTable2DataStartRowIdx = vertTable2HeaderRowIdx + 1;
|
||||
WriteVerticalTable(
|
||||
sheet, styles, vertTable2HeaderRowIdx, "Общий объем в работе", summary.GrandInProgress,
|
||||
recruiters, r => r.ShareOfInProgress(summary.GrandInProgress));
|
||||
|
||||
// Якоря диаграмм считаются НЕЗАВИСИМО от строк таблиц: у таблицы может
|
||||
// быть 2 строки, а у диаграммы фиксированная высота ChartHeightRows — с
|
||||
// якорем "на уровне шапки своей таблицы" вторая диаграмма при коротких
|
||||
// таблицах вставала бы внутрь вертикального диапазона первой (см.
|
||||
// ChartHeightRows). Обе выравнены по левому краю первой (горизонтальной)
|
||||
// таблицы и просто идут подряд сверху вниз с отступом.
|
||||
var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}";
|
||||
var chart1TopRowIdx = HeaderRowIdx + 1;
|
||||
var chart2TopRowIdx = chart1TopRowIdx + ChartHeightRows + 1;
|
||||
|
||||
CreatePieChart(
|
||||
sheet, "Объём вакансий " + period,
|
||||
firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart1TopRowIdx);
|
||||
CreatePieChart(
|
||||
sheet, "Объём вакансий в работе " + period,
|
||||
firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart2TopRowIdx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры
|
||||
/// колонками, как в образце отчёта (там же W:Y).
|
||||
/// </summary>
|
||||
private static void WriteHorizontalTable(XSSFSheet sheet, Styles styles, VacancyReportSummary summary)
|
||||
{
|
||||
var headerRow = sheet.CreateRow(HeaderRowIdx + 1);
|
||||
var totalRow = sheet.CreateRow(HeaderRowIdx + 2);
|
||||
var inProgressRow = sheet.CreateRow(HeaderRowIdx + 3);
|
||||
var closedRow = sheet.CreateRow(HeaderRowIdx + 4);
|
||||
|
||||
SetCell(headerRow, 0, "Кол-во вакансий", styles.SummaryHeaderYellow);
|
||||
SetCell(totalRow, 0, "Всего", styles.SummaryLabel);
|
||||
SetCell(inProgressRow, 0, "в работе (в%)", styles.SummaryRowGreenLabel);
|
||||
SetCell(closedRow, 0, "закрыто", styles.SummaryLabel);
|
||||
|
||||
for (var i = 0; i < summary.Recruiters.Count; i++)
|
||||
{
|
||||
var r = summary.Recruiters[i];
|
||||
var col = 1 + i;
|
||||
|
||||
SetCell(headerRow, col, r.Name, styles.SummaryHeaderYellow);
|
||||
SetCell(totalRow, col, r.Total, styles.SummaryValue);
|
||||
SetCell(inProgressRow, col, r.InProgressRate, styles.SummaryRowGreenPercent);
|
||||
SetCell(closedRow, col, r.ClosedRate, styles.SummaryPercent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна вертикальная таблица: шапка «label | итого» (синяя заливка, итого —
|
||||
/// красным полужирным, как в образце) плюс по строке на рекрутёра с его
|
||||
/// долей в этом итого. Возвращаемый диапазон строк — вход для круговой
|
||||
/// диаграммы (см. CreatePieChart).
|
||||
/// </summary>
|
||||
private static void WriteVerticalTable(
|
||||
XSSFSheet sheet, Styles styles, int headerRowIdx, string label, int total,
|
||||
IReadOnlyList<RecruiterBreakdown> recruiters, Func<RecruiterBreakdown, double> share)
|
||||
{
|
||||
var headerRow = sheet.CreateRow(headerRowIdx);
|
||||
SetCell(headerRow, 0, label, styles.SummaryBlueHeader);
|
||||
SetCell(headerRow, 1, total, styles.SummaryBlueTotal);
|
||||
|
||||
// Заливка — на всю ширину блока (по числу рекрутёров, как в горизонтальной
|
||||
// таблице выше), а не только под текстом заголовка: иначе шапка выглядит
|
||||
// как две узкие цветные ячейки на фоне остального белого листа, а не как
|
||||
// сплошная плашка над таблицей.
|
||||
for (var col = 2; col <= recruiters.Count; col++)
|
||||
SetCell(headerRow, col, string.Empty, styles.SummaryBlueHeader);
|
||||
|
||||
for (var i = 0; i < recruiters.Count; i++)
|
||||
{
|
||||
var row = sheet.CreateRow(headerRowIdx + 1 + i);
|
||||
SetCell(row, 0, recruiters[i].Name, styles.SummaryGreenText);
|
||||
SetCell(row, 1, share(recruiters[i]), styles.SummaryGreenPercent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Высота якоря диаграммы в строках — фиксированная и не зависит от числа
|
||||
/// рекрутёров: подгонять размер круга под число категорий смысла нет, малое
|
||||
/// число секторов на большом круге читается ничуть не хуже. Используется и
|
||||
/// при размещении диаграмм (см. BuildSummarySheet — вторая идёт сразу под
|
||||
/// первой, без наложения).
|
||||
/// </summary>
|
||||
private const int ChartHeightRows = 14;
|
||||
|
||||
/// <summary>Круговая диаграмма по одной вертикальной таблице: категории — колонка A (имена рекрутёров), значения — колонка B (доля).</summary>
|
||||
private static void CreatePieChart(
|
||||
XSSFSheet sheet, string title, int firstDataRowIdx, int rowCount, int anchorTopRowIdx)
|
||||
{
|
||||
if (rowCount == 0) return;
|
||||
|
||||
var drawing = (XSSFDrawing)sheet.CreateDrawingPatriarch();
|
||||
var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + ChartHeightRows);
|
||||
var chart = (XSSFChart)drawing.CreateChart(anchor);
|
||||
chart.SetTitle(title);
|
||||
|
||||
var lastDataRowIdx = firstDataRowIdx + rowCount - 1;
|
||||
var data = chart.ChartDataFactory.CreatePieChartData<string, double>();
|
||||
var categories = DataSources.FromStringCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 0, 0));
|
||||
var values = DataSources.FromNumericCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 1, 1));
|
||||
data.AddSeries(categories, values);
|
||||
chart.Plot(data);
|
||||
chart.GetOrCreateLegend();
|
||||
}
|
||||
|
||||
private static void SetCell(IRow row, int col, string value, ICellStyle style)
|
||||
{
|
||||
var cell = row.CreateCell(col);
|
||||
cell.SetCellValue(value);
|
||||
cell.CellStyle = style;
|
||||
}
|
||||
|
||||
private static void SetCell(IRow row, int col, double value, ICellStyle style)
|
||||
{
|
||||
var cell = row.CreateCell(col);
|
||||
cell.SetCellValue(value);
|
||||
cell.CellStyle = style;
|
||||
}
|
||||
|
||||
private static string ColLetter(int col0Based) => CellReference.ConvertNumToColString(col0Based);
|
||||
|
||||
/// <summary>
|
||||
/// Все стили обоих листов — созданы один раз на книгу и переиспользуются:
|
||||
/// ICellStyle в NPOI (в отличие от ClosedXML) назначается ячейке целиком, а
|
||||
/// не собирается по кусочкам, поэтому под каждую итоговую комбинацию
|
||||
/// заливка+шрифт+формат нужен отдельный объект стиля.
|
||||
/// </summary>
|
||||
private sealed class Styles
|
||||
{
|
||||
public ICellStyle Header { get; }
|
||||
public ICellStyle GroupSeparator { get; }
|
||||
public ICellStyle Data { get; }
|
||||
public ICellStyle DataDate { get; }
|
||||
public ICellStyle DataLeadTime { get; }
|
||||
public ICellStyle Totals { get; }
|
||||
|
||||
public ICellStyle Title { get; }
|
||||
public ICellStyle SummaryHeaderYellow { get; }
|
||||
public ICellStyle SummaryLabel { get; }
|
||||
public ICellStyle SummaryValue { get; }
|
||||
public ICellStyle SummaryPercent { get; }
|
||||
public ICellStyle SummaryRowGreenLabel { get; }
|
||||
public ICellStyle SummaryRowGreenPercent { get; }
|
||||
public ICellStyle SummaryBlueHeader { get; }
|
||||
public ICellStyle SummaryBlueTotal { get; }
|
||||
public ICellStyle SummaryGreenText { get; }
|
||||
public ICellStyle SummaryGreenPercent { get; }
|
||||
|
||||
// Цвета — из образца отчёта (заливка шапки FFE5E5FF и т.д.), чтобы
|
||||
// выгрузка выглядела привычно рядом со старым файлом.
|
||||
private static readonly byte[] HeaderFill = [0xE5, 0xE5, 0xFF];
|
||||
private static readonly byte[] GroupFill = [0xF2, 0xF2, 0xF2];
|
||||
private static readonly byte[] YellowFill = [0xFF, 0xFF, 0x00];
|
||||
private static readonly byte[] GreenFill = [0xC6, 0xE0, 0xB4];
|
||||
private static readonly byte[] BlueFill = [0x4A, 0x7E, 0xBB];
|
||||
private static readonly byte[] RedFont = [0xC0, 0x00, 0x00];
|
||||
private static readonly byte[] WhiteFont = [0xFF, 0xFF, 0xFF];
|
||||
|
||||
public Styles(XSSFWorkbook workbook)
|
||||
{
|
||||
var colorMap = new DefaultIndexedColorMap();
|
||||
|
||||
ICellStyle New(
|
||||
byte[]? fill = null, bool bold = false, byte[]? fontColor = null, string? numberFormat = null,
|
||||
bool wrap = false, bool border = false, bool topDoubleBorder = false,
|
||||
HorizontalAlignment? align = null, VerticalAlignment? valign = null)
|
||||
{
|
||||
var style = (XSSFCellStyle)workbook.CreateCellStyle();
|
||||
|
||||
if (fill is not null)
|
||||
{
|
||||
style.SetFillForegroundColor(new XSSFColor(fill, colorMap));
|
||||
style.FillPattern = FillPattern.SolidForeground;
|
||||
}
|
||||
if (bold || fontColor is not null)
|
||||
{
|
||||
var font = (XSSFFont)workbook.CreateFont();
|
||||
font.IsBold = bold;
|
||||
if (fontColor is not null) font.SetColor(new XSSFColor(fontColor, colorMap));
|
||||
style.SetFont(font);
|
||||
}
|
||||
if (numberFormat is not null)
|
||||
style.DataFormat = workbook.CreateDataFormat().GetFormat(numberFormat);
|
||||
if (wrap) style.WrapText = true;
|
||||
if (border)
|
||||
{
|
||||
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
|
||||
}
|
||||
if (topDoubleBorder) style.BorderTop = BorderStyle.Double;
|
||||
if (align is not null) style.Alignment = align.Value;
|
||||
if (valign is not null) style.VerticalAlignment = valign.Value;
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
Header = New(
|
||||
fill: HeaderFill, bold: true, wrap: true, border: true,
|
||||
align: HorizontalAlignment.Center, valign: VerticalAlignment.Center);
|
||||
GroupSeparator = New(fill: GroupFill, bold: true);
|
||||
Data = New(wrap: true, border: true);
|
||||
DataDate = New(wrap: true, border: true, numberFormat: DateFormat);
|
||||
DataLeadTime = New(wrap: true, border: true, numberFormat: "0");
|
||||
Totals = New(bold: true, topDoubleBorder: true);
|
||||
|
||||
Title = New(bold: true);
|
||||
SummaryHeaderYellow = New(fill: YellowFill, bold: true);
|
||||
SummaryLabel = New(bold: true);
|
||||
SummaryValue = New();
|
||||
SummaryPercent = New(numberFormat: WholePercentFormat);
|
||||
SummaryRowGreenLabel = New(fill: GreenFill, bold: true);
|
||||
SummaryRowGreenPercent = New(fill: GreenFill, numberFormat: WholePercentFormat);
|
||||
SummaryBlueHeader = New(fill: BlueFill, bold: true, fontColor: WhiteFont);
|
||||
SummaryBlueTotal = New(fill: BlueFill, bold: true, fontColor: RedFont);
|
||||
SummaryGreenText = New(fill: GreenFill);
|
||||
SummaryGreenPercent = New(fill: GreenFill, numberFormat: PrecisePercentFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
|
||||
// Формы 1:1 с фронтендом (frontend/src/webparts/approvalAdmin/models/requestManage.ts,
|
||||
// manageByUser.ts) — эти DTO заменяют собой то, что раньше собирал PnP-клиент
|
||||
// НАПРЯМУЮ в SharePoint от имени текущего пользователя (см. requestManageService.ts):
|
||||
// тут это делает бэкенд от имени приложения (см. ISharePointClient), поэтому
|
||||
// результат не зависит от прав конкретного пользователя на списки SharePoint.
|
||||
|
||||
/// <summary>Заявка (строка списка «… — данные»), как её видит комбобокс выбора заявки.</summary>
|
||||
public sealed record ManagedRequestSummaryDto(
|
||||
int Id,
|
||||
string Title,
|
||||
string Status,
|
||||
/// <summary>ISO 8601 или пустая строка.</summary>
|
||||
string Created,
|
||||
string AuthorName,
|
||||
string? UniqueCode,
|
||||
/// <summary>Партиция (серверный относительный url списка данных), где физически лежит заявка.</summary>
|
||||
string ListId);
|
||||
|
||||
public sealed record ManagedRequestPageDto(IReadOnlyList<ManagedRequestSummaryDto> Items, bool HasMore);
|
||||
|
||||
/// <summary>Строка списка «… — согласование».</summary>
|
||||
public sealed record ManagedApprovalRowDto(
|
||||
int Id,
|
||||
int TicketId,
|
||||
int? ApproverId,
|
||||
string ApproverName,
|
||||
string ApproverEmail,
|
||||
string Position,
|
||||
string Status,
|
||||
/// <summary>ISO 8601 или пустая строка — форматирование под «дд.мм.гггг» остаётся на фронтенде.</summary>
|
||||
string DateIso,
|
||||
string Comment,
|
||||
double Order,
|
||||
bool AllowReassign,
|
||||
string Phase,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason);
|
||||
|
||||
/// <summary>Заявка + полный маршрут (обе фазы) — для экрана редактирования заявок.</summary>
|
||||
public sealed record ManagedRequestMatrixDto(
|
||||
ManagedRequestSummaryDto Request,
|
||||
string SiteUrl,
|
||||
string DataListTitle,
|
||||
string DataListUrl,
|
||||
string ApprovalListTitle,
|
||||
IReadOnlyList<ManagedApprovalRowDto> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Строка согласования, найденная сразу по ВСЕМ типам заявок и партициям — для
|
||||
/// экрана «Заявки по согласующему» (UserApprovalRow во фронтенде — тот же набор
|
||||
/// полей, что и ManagedApprovalRowDto, только развёрнутый, а не вложенный).
|
||||
/// </summary>
|
||||
public sealed record UserApprovalRowDto(
|
||||
int Id,
|
||||
int TicketId,
|
||||
int? ApproverId,
|
||||
string ApproverName,
|
||||
string ApproverEmail,
|
||||
string Position,
|
||||
string Status,
|
||||
string DateIso,
|
||||
string Comment,
|
||||
double Order,
|
||||
bool AllowReassign,
|
||||
string Phase,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason,
|
||||
int RequestTypeId,
|
||||
string RequestTypeName,
|
||||
string SiteUrl,
|
||||
string ListId,
|
||||
string ApprovalListTitle,
|
||||
string RequestTitle,
|
||||
string? RequestUniqueCode);
|
||||
@@ -0,0 +1,444 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
|
||||
/// <summary>
|
||||
/// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок»
|
||||
/// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не
|
||||
/// текущего пользователя.
|
||||
///
|
||||
/// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде
|
||||
/// (см. requestManageService.ts) от имени залогиненного администратора — и
|
||||
/// список видимых заявок молча урезался правами САМОГО администратора на списки
|
||||
/// SharePoint (item-level security), хотя вкладка задумана как «видно всё».
|
||||
/// Правка строк маршрута (переназначить/удалить/переставить) остаётся на
|
||||
/// фронтенде через PnP — сюда вынесено только чтение.
|
||||
/// </summary>
|
||||
public sealed class RequestManageService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
ILogger<RequestManageService> logger)
|
||||
{
|
||||
private const int DefaultPageSize = 20;
|
||||
|
||||
// Сколько типов заявок читаем из SharePoint одновременно. Без ограничения
|
||||
// 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались
|
||||
// и экран показывал пустой список.
|
||||
private const int MaxParallelTypes = 3;
|
||||
|
||||
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
||||
// requestManageService.ts (тот же самый список SharePoint, тот же провижининг).
|
||||
private const string ApproverField = "TansuSiteApprover";
|
||||
private const string StatusField = "ApprovalStatus";
|
||||
private const string TicketIdField = "TicketId";
|
||||
private const string DateField = "Date";
|
||||
private const string CommentField = "Comment";
|
||||
private const string OrderField = "StepOrder";
|
||||
private const string AllowReassignField = "AllowReassignToOtherApprovers";
|
||||
private const string PhaseField = "Phase";
|
||||
private const string SlaHoursField = "SLAhours";
|
||||
private const string AskOverdueReasonField = "AskOverdueReason";
|
||||
|
||||
private const string ApprovalRowSelect =
|
||||
$"$select=Id,Title,{StatusField},{DateField},{CommentField},{OrderField},{AllowReassignField},{PhaseField}," +
|
||||
$"{SlaHoursField},{AskOverdueReasonField},{TicketIdField},{ApproverField}/Id,{ApproverField}/Title,{ApproverField}/EMail" +
|
||||
$"&$expand={ApproverField}";
|
||||
|
||||
private const string DataRowSelect = "$select=Id,Title,RequestStatus,Created,Author/Title,RequestUniqueCode&$expand=Author";
|
||||
|
||||
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||
// (см. requestManageService.ts/DATA_LIST_SUFFIX).
|
||||
private static readonly Regex DataListSuffix = new(@"\s*—\s*данные\s*$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая),
|
||||
/// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen).
|
||||
/// </summary>
|
||||
public async Task<Result<ManagedRequestPageDto>> SearchAsync(
|
||||
int requestTypeId, string query, int page, int? pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestType = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
if (requestType is null)
|
||||
return Result.Fail<ManagedRequestPageDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Ok(new ManagedRequestPageDto([], false));
|
||||
|
||||
var size = pageSize is > 0 ? pageSize.Value : DefaultPageSize;
|
||||
var siteUrl = requestType.SiteUrl;
|
||||
|
||||
var partitionIds = await GetAllPartitionListIdsAsync(requestType, cancellationToken);
|
||||
|
||||
var all = new List<ManagedRequestSummaryDto>();
|
||||
foreach (var listId in partitionIds)
|
||||
{
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, listId, $"{DataRowSelect}&$top=5000", cancellationToken);
|
||||
all.AddRange(items.Select(i => ToSummary(i, listId)));
|
||||
}
|
||||
|
||||
var words = SplitQueryWords(query);
|
||||
var filtered = all.Where(x => MatchesQuery(x, words))
|
||||
// Тот же порядок, что и раньше (Search сортировал по Created по возрастанию).
|
||||
.OrderBy(x => x.Created, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var pageItems = filtered.Skip((page - 1) * size).Take(size).ToList();
|
||||
return Result.Ok(new ManagedRequestPageDto(pageItems, page * size < filtered.Count));
|
||||
}
|
||||
|
||||
/// <summary>Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.</summary>
|
||||
public async Task<Result<ManagedRequestMatrixDto>> GetMatrixAsync(
|
||||
int requestTypeId, string listId, int requestId, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestType = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
if (requestType is null)
|
||||
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Fail<ManagedRequestMatrixDto>(
|
||||
new ValidationError($"У типа заявки {requestTypeId} не указан сайт SharePoint."));
|
||||
|
||||
var siteUrl = requestType.SiteUrl;
|
||||
|
||||
var rawRequestItems = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, listId, $"{DataRowSelect}&$filter=Id eq {requestId}&$top=1", cancellationToken);
|
||||
var rawRequest = rawRequestItems.FirstOrDefault();
|
||||
if (rawRequest.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Заявка", requestId));
|
||||
|
||||
var request = ToSummary(rawRequest, listId);
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken) ?? string.Empty;
|
||||
var dataListUrl = ListAbsUrl(siteUrl, listId);
|
||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||
|
||||
if (approvalListTitle is null)
|
||||
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, string.Empty, []));
|
||||
|
||||
var rawRows = await sharePoint.GetItemsByListTitleAsync(
|
||||
siteUrl, approvalListTitle,
|
||||
$"{ApprovalRowSelect}&$filter={TicketIdField} eq {requestId}&$orderby={OrderField}", cancellationToken);
|
||||
|
||||
var rows = rawRows.Select(ToApprovalRow).ToList();
|
||||
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Активные строки согласования данного человека сразу по всем типам заявок и
|
||||
/// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему».
|
||||
/// </summary>
|
||||
public async Task<Result<IReadOnlyList<UserApprovalRowDto>>> FindActiveApprovalRowsForApproverAsync(
|
||||
string approverEmail, IReadOnlyList<string> activeStatuses, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0)
|
||||
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>([]);
|
||||
|
||||
var requestTypes = await db.RequestTypes.AsNoTracking()
|
||||
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var email = approverEmail.Trim();
|
||||
|
||||
// DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО,
|
||||
// и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше
|
||||
// GetArchivedAsync вызывался из параллельных задач — «вторая операция на
|
||||
// контексте» падала, а пустой catch превращал это в пустой список.
|
||||
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||
foreach (var rt in requestTypes)
|
||||
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
|
||||
|
||||
using var gate = new SemaphoreSlim(MaxParallelTypes);
|
||||
|
||||
// Search не поймать и не подменить обходом списков: если он недоступен, это
|
||||
// видно по ошибке запроса, а не по тихо неполному результату.
|
||||
var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken);
|
||||
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(rows);
|
||||
}
|
||||
|
||||
private const string SearchApproverProperty = "TansuSiteApproverOWSUSER";
|
||||
private const string SearchStatusProperty = "ApprovalStatusOWSCHCS";
|
||||
|
||||
private static readonly Regex ApprovalListSuffix = new(@"\s*—\s*согласование\s*$", RegexOptions.Compiled);
|
||||
|
||||
private static UserApprovalRowDto ToUserRow(
|
||||
ManagedApprovalRowDto r, RequestType requestType, string siteUrl, string dataListId, string approvalListTitle,
|
||||
ManagedRequestSummaryDto? dataRow) =>
|
||||
new(
|
||||
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
|
||||
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
|
||||
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
|
||||
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
|
||||
|
||||
/// <summary>
|
||||
/// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН
|
||||
/// запрос Search на сайт (по человеку и статусам, сразу по всем спискам
|
||||
/// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования
|
||||
/// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные
|
||||
/// строки дочитываются по REST — только в тех списках, где они реально нашлись.
|
||||
/// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<UserApprovalRowDto>> FindViaSearchAsync(
|
||||
IReadOnlyList<RequestType> requestTypes,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||
IReadOnlyList<string> activeStatuses,
|
||||
string approverEmail,
|
||||
SemaphoreSlim gate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var email = approverEmail.Replace("\"", string.Empty);
|
||||
var statusQuery = string.Join(
|
||||
" OR ", activeStatuses.Select(s => $"{SearchStatusProperty}:\"{s.Replace("\"", string.Empty)}\""));
|
||||
var kql = $"{SearchApproverProperty}:\"{email}\" ({statusQuery}) contentclass:STS_ListItem_GenericList";
|
||||
|
||||
var result = new List<UserApprovalRowDto>();
|
||||
foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken);
|
||||
|
||||
// Путь вида https://host/sites/x/Lists/L/DispForm.aspx?ID=5 → серверный url списка.
|
||||
var byList = hits
|
||||
.Select(h => (
|
||||
Url: ListUrlFromHitPath(h.GetValueOrDefault("Path")),
|
||||
Id: int.TryParse(h.GetValueOrDefault("ListItemID"), out var id) ? id : 0))
|
||||
.Where(x => x.Url is not null && x.Id > 0)
|
||||
.GroupBy(x => x.Url!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var siteTypes = requestTypes
|
||||
.Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var perList = await Task.WhenAll(byList.Select(async group =>
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return await ReadSearchHitListAsync(
|
||||
siteUrl, group.Key, group.Select(x => x.Id).Distinct().ToList(), siteTypes, archivedByType,
|
||||
activeStatuses, approverEmail, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}));
|
||||
|
||||
result.AddRange(perList.SelectMany(x => x));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Пара «список согласования → (его заголовок, адрес парного списка данных)»
|
||||
// не меняется, пока список не переименуют, — запоминаем на время жизни процесса,
|
||||
// чтобы не тратить два запроса к SharePoint на каждый вызов. Ключ — адрес списка.
|
||||
private static readonly ConcurrentDictionary<string, (string ApprovalTitle, string DataListUrl)> ListPairs =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<(string ApprovalTitle, string DataListUrl)?> ResolveListPairAsync(
|
||||
string siteUrl, string approvalListUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ListPairs.TryGetValue(approvalListUrl, out var cached)) return cached;
|
||||
|
||||
var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||
if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return null;
|
||||
|
||||
var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные");
|
||||
var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken);
|
||||
if (dataList is null) return null;
|
||||
|
||||
var pair = (approvalList.Title, dataList.ServerRelativeUrl);
|
||||
ListPairs[approvalListUrl] = pair;
|
||||
return pair;
|
||||
}
|
||||
|
||||
private static string? ListUrlFromHitPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null;
|
||||
|
||||
var local = Uri.UnescapeDataString(uri.AbsolutePath);
|
||||
var cut = local.LastIndexOf('/');
|
||||
return cut > 0 ? local[..cut] : null;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UserApprovalRowDto>> ReadSearchHitListAsync(
|
||||
string siteUrl,
|
||||
string approvalListUrl,
|
||||
IReadOnlyList<int> itemIds,
|
||||
IReadOnlyList<RequestType> siteTypes,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||
IReadOnlyList<string> activeStatuses,
|
||||
string approverEmail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pair = await ResolveListPairAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||
if (pair is null) return [];
|
||||
var (approvalTitle, dataListUrl) = pair.Value;
|
||||
|
||||
// Какому типу заявок принадлежит эта партиция (текущая или архивная).
|
||||
var requestType = siteTypes.FirstOrDefault(rt =>
|
||||
string.Equals(rt.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)
|
||||
|| archivedByType[rt.Id].Any(a =>
|
||||
string.Equals(a.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)));
|
||||
if (requestType is null) return [];
|
||||
|
||||
var rows = new List<ManagedApprovalRowDto>();
|
||||
foreach (var chunk in itemIds.Chunk(50))
|
||||
{
|
||||
var filter = string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, approvalListUrl, $"{ApprovalRowSelect}&$filter={filter}&$top={chunk.Length}",
|
||||
cancellationToken);
|
||||
rows.AddRange(items.Select(ToApprovalRow));
|
||||
}
|
||||
|
||||
var matched = rows
|
||||
.Where(r => activeStatuses.Contains(r.Status)
|
||||
&& string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (matched.Count == 0) return [];
|
||||
|
||||
var dataById = await ReadDataRowsAsync(
|
||||
siteUrl, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(),
|
||||
cancellationToken);
|
||||
|
||||
return matched
|
||||
.Select(r => ToUserRow(
|
||||
r, requestType, siteUrl, dataListUrl, approvalTitle,
|
||||
dataById.GetValueOrDefault(r.TicketId)))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "by-approver: список «{ApprovalListUrl}» из Search пропущен", approvalListUrl);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
|
||||
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticketIds.Count == 0) return new Dictionary<int, ManagedRequestSummaryDto>();
|
||||
|
||||
var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}"));
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, listId, $"{DataRowSelect}&$filter={filter}&$top={ticketIds.Count}", cancellationToken);
|
||||
|
||||
return items.Select(i => ToSummary(i, listId)).ToDictionary(x => x.Id);
|
||||
}
|
||||
|
||||
/// <summary>Все партиции типа заявок (архив + текущая), только их listId — для поиска.</summary>
|
||||
private async Task<IReadOnlyList<string>> GetAllPartitionListIdsAsync(
|
||||
RequestType requestType, CancellationToken cancellationToken)
|
||||
{
|
||||
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||
return archived.Select(x => x.ListId).Append(current.ListId).ToList();
|
||||
}
|
||||
|
||||
private static ManagedRequestSummaryDto ToSummary(JsonElement item, string listId) =>
|
||||
new(
|
||||
GetInt(item, "Id") ?? 0,
|
||||
GetString(item, "Title") is { Length: > 0 } title ? title : $"#{GetInt(item, "Id") ?? 0}",
|
||||
GetString(item, "RequestStatus") ?? string.Empty,
|
||||
GetString(item, "Created") ?? string.Empty,
|
||||
item.TryGetProperty("Author", out var author) && author.ValueKind == JsonValueKind.Object
|
||||
? GetString(author, "Title") ?? string.Empty
|
||||
: string.Empty,
|
||||
GetString(item, "RequestUniqueCode"),
|
||||
listId);
|
||||
|
||||
private static ManagedApprovalRowDto ToApprovalRow(JsonElement item)
|
||||
{
|
||||
var approver = item.TryGetProperty(ApproverField, out var a) && a.ValueKind == JsonValueKind.Object ? a : (JsonElement?)null;
|
||||
|
||||
return new ManagedApprovalRowDto(
|
||||
GetInt(item, "Id") ?? 0,
|
||||
GetInt(item, TicketIdField) ?? 0,
|
||||
approver is { } ap ? GetInt(ap, "Id") : null,
|
||||
approver is { } an ? GetString(an, "Title") ?? string.Empty : string.Empty,
|
||||
approver is { } ae ? GetString(ae, "EMail") ?? string.Empty : string.Empty,
|
||||
GetString(item, "Title") ?? string.Empty,
|
||||
GetString(item, StatusField) ?? string.Empty,
|
||||
GetString(item, DateField) ?? string.Empty,
|
||||
GetString(item, CommentField) ?? string.Empty,
|
||||
GetDouble(item, OrderField) ?? 0,
|
||||
GetBool(item, AllowReassignField) ?? false,
|
||||
GetString(item, PhaseField) is { Length: > 0 } phase ? phase : "Согласование",
|
||||
GetInt(item, SlaHoursField),
|
||||
GetBool(item, AskOverdueReasonField));
|
||||
}
|
||||
|
||||
private static string? ApprovalListTitleFor(string dataListTitle) =>
|
||||
DataListSuffix.IsMatch(dataListTitle)
|
||||
? $"{DataListSuffix.Replace(dataListTitle, string.Empty)} — согласование"
|
||||
: null;
|
||||
|
||||
private static string SiteOrigin(string siteUrl)
|
||||
{
|
||||
var match = Regex.Match(siteUrl, "^https?://[^/]+");
|
||||
return match.Success ? match.Value : siteUrl;
|
||||
}
|
||||
|
||||
private static string ListAbsUrl(string siteUrl, string listId) => $"{SiteOrigin(siteUrl)}{listId}";
|
||||
|
||||
// KQL во фронтенде терпел только буквы/цифры/пробелы (см. sanitizeForKql в
|
||||
// requestManageService.ts) — здесь та же санитизация для сравнения без учёта
|
||||
// регистра по Title/UniqueCode, только вместо полнотекстового индекса Search
|
||||
// сравниваем подстроки напрямую (см. MatchesQuery).
|
||||
private static IReadOnlyList<string> SplitQueryWords(string query)
|
||||
{
|
||||
var sanitized = Regex.Replace(query, @"[^a-zA-Zа-яА-ЯёЁ0-9\s]", " ");
|
||||
return sanitized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private static bool MatchesQuery(ManagedRequestSummaryDto summary, IReadOnlyList<string> words)
|
||||
{
|
||||
if (words.Count == 0) return true;
|
||||
|
||||
var haystack = $"{summary.Title} {summary.UniqueCode}".ToLowerInvariant();
|
||||
return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static int? GetInt(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i)) return i;
|
||||
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? GetDouble(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d;
|
||||
if (v.ValueKind == JsonValueKind.String && double.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetBool(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind is JsonValueKind.True or JsonValueKind.False) return v.GetBoolean();
|
||||
if (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
/// <param name="SharePointLists">
|
||||
/// Вся серия SharePoint-списков данных (архив + текущая партиция) типа заявки —
|
||||
/// именно на них нужно выдать email'у право чтения, иначе поиск по порталу
|
||||
/// продолжит отдавать только его собственные заявки: доступ здесь настраивает
|
||||
/// видимость в UI approval-route, но не права SharePoint, которые остаются
|
||||
/// отдельным ручным шагом.
|
||||
/// </param>
|
||||
public sealed record RequestTypeAccessDto(
|
||||
int Id,
|
||||
int RequestTypeId,
|
||||
string Email,
|
||||
IReadOnlyList<RequestListPartitionDto> SharePointLists);
|
||||
|
||||
public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email);
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
public sealed class RequestTypeAccessService(
|
||||
ApprovalRouteDbContext db,
|
||||
ListPartitionService partitions,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<RequestTypeAccessService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<RequestTypeAccessDto>>> ListAsync(
|
||||
int? requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var entities = await db.RequestTypeAccesses.AsNoTracking()
|
||||
.Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId)
|
||||
.OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Партиции запрашиваются РАЗ на тип заявки, а не на строку — иначе список
|
||||
// из N email по одному типу заново обходил бы SharePoint N раз подряд.
|
||||
var listsByType = new Dictionary<int, IReadOnlyList<RequestListPartitionDto>>();
|
||||
var items = new List<RequestTypeAccessDto>(entities.Count);
|
||||
foreach (var e in entities)
|
||||
{
|
||||
var lists = await GetListsCachedAsync(listsByType, e.RequestTypeId, cancellationToken);
|
||||
items.Add(new RequestTypeAccessDto(e.Id, e.RequestTypeId, e.Email, lists));
|
||||
}
|
||||
|
||||
return Result.Ok<IReadOnlyList<RequestTypeAccessDto>>(items);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<RequestListPartitionDto>> GetListsCachedAsync(
|
||||
Dictionary<int, IReadOnlyList<RequestListPartitionDto>> cache,
|
||||
int requestTypeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (cache.TryGetValue(requestTypeId, out var cached))
|
||||
return cached;
|
||||
|
||||
var result = await partitions.ListAsync(requestTypeId, cancellationToken);
|
||||
var lists = result.IsSuccess ? result.Value : Array.Empty<RequestListPartitionDto>();
|
||||
cache[requestTypeId] = lists;
|
||||
return lists;
|
||||
}
|
||||
|
||||
/// <summary>Список email'ов с доступом к типу заявок — дёргает Power Automate.</summary>
|
||||
public async Task<Result<IReadOnlyList<string>>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == requestTypeId, cancellationToken))
|
||||
return Result.Fail<IReadOnlyList<string>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
var emails = await db.RequestTypeAccesses.AsNoTracking()
|
||||
.Where(x => x.RequestTypeId == requestTypeId)
|
||||
.OrderBy(x => x.Email)
|
||||
.Select(x => x.Email)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<string>>(emails);
|
||||
}
|
||||
|
||||
public async Task<Result<RequestTypeAccessDto>> CreateAsync(
|
||||
CreateRequestTypeAccessRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken))
|
||||
return Result.Fail<RequestTypeAccessDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||
|
||||
var email = request.Email.Trim();
|
||||
var exists = await db.RequestTypeAccesses.AsNoTracking()
|
||||
.AnyAsync(x => x.RequestTypeId == request.RequestTypeId && x.Email == email, cancellationToken);
|
||||
if (exists)
|
||||
return Result.Fail<RequestTypeAccessDto>(
|
||||
new ConflictError($"У {email} уже есть доступ к этому типу заявок."));
|
||||
|
||||
var entity = new RequestTypeAccess { RequestTypeId = request.RequestTypeId, Email = email };
|
||||
db.RequestTypeAccesses.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}",
|
||||
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||
|
||||
var listsResult = await partitions.ListAsync(entity.RequestTypeId, cancellationToken);
|
||||
var lists = listsResult.IsSuccess ? listsResult.Value : Array.Empty<RequestListPartitionDto>();
|
||||
return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email, lists));
|
||||
}
|
||||
|
||||
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.RequestTypeAccesses.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail(new NotFoundError("Доступ к типу заявки", id));
|
||||
|
||||
db.RequestTypeAccesses.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Доступ к типу заявки {RequestTypeId} для {Email} отозван пользователем {Author}",
|
||||
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
public sealed class CreateRequestTypeAccessRequestValidator : AbstractValidator<CreateRequestTypeAccessRequest>
|
||||
{
|
||||
public CreateRequestTypeAccessRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RequestTypeId).GreaterThan(0).WithMessage("Тип заявки обязателен.");
|
||||
RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("Укажите корректный email.");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
|
||||
public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
public sealed class RequestTypeService(ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<RequestTypeService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<RequestTypeDto>>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -45,6 +47,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
};
|
||||
db.RequestTypes.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Тип заявки {RequestTypeId} создан пользователем {Author}", entity.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
}
|
||||
@@ -67,6 +70,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
entity.SlaHours = request.SlaHours;
|
||||
entity.AskOverdueReason = request.AskOverdueReason;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
}
|
||||
@@ -95,6 +99,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
entity.SlaHours = request.SlaHours ?? entity.SlaHours;
|
||||
entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
}
|
||||
@@ -107,6 +112,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
|
||||
db.RequestTypes.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Тип заявки {RequestTypeId} удалён пользователем {Author}", id, currentUser.Email);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
// AutoApprove — «согласовать шаг автоматически по истечении SLA». Обычный bool,
|
||||
// а не три состояния: наследовать его неоткуда (см. TemplateRouteStep.AutoApprove).
|
||||
//
|
||||
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
|
||||
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
|
||||
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
||||
// записей, чтобы не ломать существующие позиционные вызовы.
|
||||
|
||||
/// <summary>Одно условие появления шага — см. Domain.Entities.TemplateRouteStepCondition.</summary>
|
||||
public sealed record RouteStepConditionDto(string FieldName, ComparisonOperator Operation, string Value);
|
||||
|
||||
public sealed record TemplateRouteStepDto(
|
||||
int Id,
|
||||
int OrderNo,
|
||||
@@ -17,12 +23,12 @@ public sealed record TemplateRouteStepDto(
|
||||
RouteStepStatus Status,
|
||||
int RouteId,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto> Conditions,
|
||||
ConditionsLogic ConditionsLogic,
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
public sealed record CreateTemplateRouteStepRequest(
|
||||
int OrderNo,
|
||||
@@ -32,11 +38,11 @@ public sealed record CreateTemplateRouteStepRequest(
|
||||
HeadType? HeadType,
|
||||
RouteStepStatus Status,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic ConditionsLogic,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
public sealed record UpdateTemplateRouteStepRequest(
|
||||
int OrderNo,
|
||||
@@ -46,12 +52,17 @@ public sealed record UpdateTemplateRouteStepRequest(
|
||||
HeadType? HeadType,
|
||||
RouteStepStatus Status,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic ConditionsLogic,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
/// <summary>
|
||||
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
|
||||
/// «не менять» от «убрать все условия» здесь нельзя (тот же приём, что и раньше
|
||||
/// для FieldName) — очистка условий делается через PUT с Conditions: [].
|
||||
/// </summary>
|
||||
public sealed record PatchTemplateRouteStepRequest(
|
||||
int? OrderNo,
|
||||
string? ExecutorEmail,
|
||||
@@ -60,8 +71,8 @@ public sealed record PatchTemplateRouteStepRequest(
|
||||
HeadType? HeadType,
|
||||
RouteStepStatus? Status,
|
||||
bool? AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic? ConditionsLogic = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool? AutoApprove = null);
|
||||
|
||||
+166
-85
@@ -1,30 +1,54 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Application.Common;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
public sealed class TemplateRouteStepService(
|
||||
ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<TemplateRouteStepService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Include(x => x.Conditions)
|
||||
.Where(x => x.RouteId == routeId)
|
||||
.OrderBy(x => x.OrderNo)
|
||||
.Select(x => new TemplateRouteStepDto(
|
||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
|
||||
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для
|
||||
/// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen):
|
||||
/// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не
|
||||
/// попадают — это не привязка к конкретному человеку.
|
||||
/// </summary>
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListByExecutorEmailAsync(
|
||||
string executorEmail, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(executorEmail))
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
|
||||
|
||||
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Include(x => x.Conditions)
|
||||
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
|
||||
.OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Include(x => x.Conditions)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
|
||||
return entity is null
|
||||
@@ -42,24 +66,24 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||
|
||||
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
|
||||
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||
|
||||
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
|
||||
var duplicate = await HasDuplicateAsync(
|
||||
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
|
||||
r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken);
|
||||
r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken))
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
var entity = new TemplateRouteStep
|
||||
{
|
||||
@@ -71,14 +95,16 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
HeadType = r.HeadType,
|
||||
Status = r.Status,
|
||||
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
|
||||
FieldName = r.FieldName,
|
||||
Operation = r.Operation,
|
||||
Value = r.Value,
|
||||
ConditionsLogic = r.ConditionsLogic,
|
||||
Conditions = ToEntities(r.Conditions),
|
||||
SlaHours = r.SlaHours,
|
||||
AskOverdueReason = r.AskOverdueReason
|
||||
AskOverdueReason = r.AskOverdueReason,
|
||||
AutoApprove = r.AutoApprove
|
||||
};
|
||||
db.TemplateRouteSteps.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Шаг {StepId} маршрута {RouteId} создан пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||
}
|
||||
@@ -90,7 +116,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
if (route is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||
|
||||
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||
|
||||
@@ -98,24 +125,24 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||
|
||||
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
|
||||
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||
|
||||
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
|
||||
var duplicate = await HasDuplicateAsync(
|
||||
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
|
||||
r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken);
|
||||
r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, 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(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
entity.OrderNo = r.OrderNo;
|
||||
entity.ExecutorEmail = r.ExecutorEmail;
|
||||
@@ -124,12 +151,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
entity.HeadType = r.HeadType;
|
||||
entity.Status = r.Status;
|
||||
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
|
||||
entity.FieldName = r.FieldName;
|
||||
entity.Operation = r.Operation;
|
||||
entity.Value = r.Value;
|
||||
entity.ConditionsLogic = r.ConditionsLogic;
|
||||
ReplaceConditions(entity, r.Conditions);
|
||||
entity.SlaHours = r.SlaHours;
|
||||
entity.AskOverdueReason = r.AskOverdueReason;
|
||||
entity.AutoApprove = r.AutoApprove;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||
}
|
||||
@@ -141,7 +170,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
if (route is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||
|
||||
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||
|
||||
@@ -150,9 +180,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
var projectRole = r.ProjectRole ?? entity.ProjectRole;
|
||||
var headType = r.HeadType ?? entity.HeadType;
|
||||
var status = r.Status ?? entity.Status;
|
||||
var fieldName = r.FieldName ?? entity.FieldName;
|
||||
var operation = r.Operation ?? entity.Operation;
|
||||
var value = r.Value ?? entity.Value;
|
||||
var conditions = r.Conditions ?? ToDtos(entity.Conditions);
|
||||
var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic;
|
||||
|
||||
if (status == RouteStepStatus.None)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
|
||||
@@ -165,24 +194,24 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
|
||||
|
||||
if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value))
|
||||
if (!TemplateRouteStepFields.AreConditionsValid(conditions))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||
|
||||
if (RequiresList(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
|
||||
var duplicate = await HasDuplicateAsync(
|
||||
route.Id, executorEmail, position, projectRole, headType, status,
|
||||
fieldName, operation, value, excludeId: entity.Id, cancellationToken);
|
||||
conditions, conditionsLogic, excludeId: entity.Id, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, fieldName, operation, value, excludeId: entity.Id, cancellationToken))
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||
entity.ExecutorEmail = executorEmail;
|
||||
@@ -191,14 +220,17 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
entity.HeadType = headType;
|
||||
entity.Status = status;
|
||||
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
|
||||
entity.FieldName = fieldName;
|
||||
entity.Operation = operation;
|
||||
entity.Value = value;
|
||||
entity.ConditionsLogic = conditionsLogic;
|
||||
if (r.Conditions is not null)
|
||||
ReplaceConditions(entity, r.Conditions);
|
||||
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
|
||||
// наследованию SLA от типа заявки можно через PUT.
|
||||
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
||||
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
||||
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||
}
|
||||
@@ -211,65 +243,114 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
|
||||
db.TemplateRouteSteps.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Шаг {StepId} маршрута {RouteId} удалён пользователем {Author}", id, routeId, currentUser.Email);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) =>
|
||||
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
|
||||
private static bool RequiresList(IReadOnlyList<RouteStepConditionDto>? conditions) => conditions is { Count: > 0 };
|
||||
|
||||
private static List<TemplateRouteStepCondition> ToEntities(IReadOnlyList<RouteStepConditionDto>? conditions) =>
|
||||
(conditions ?? []).Select((c, i) => new TemplateRouteStepCondition
|
||||
{
|
||||
OrderNo = i,
|
||||
FieldName = c.FieldName,
|
||||
Operation = c.Operation,
|
||||
Value = c.Value
|
||||
}).ToList();
|
||||
|
||||
private static void ReplaceConditions(TemplateRouteStep entity, IReadOnlyList<RouteStepConditionDto>? conditions)
|
||||
{
|
||||
entity.Conditions.Clear();
|
||||
entity.Conditions.AddRange(ToEntities(conditions));
|
||||
}
|
||||
|
||||
private static List<RouteStepConditionDto> ToDtos(IEnumerable<TemplateRouteStepCondition> conditions) =>
|
||||
conditions.OrderBy(c => c.OrderNo).Select(c => new RouteStepConditionDto(c.FieldName, c.Operation, c.Value)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом
|
||||
/// условии появления (field_name/operation/value): шаги с разными условиями
|
||||
/// взаимоисключающи по данным заявки, поэтому в один маршрут они попасть могут,
|
||||
/// а в один резолв — нет.
|
||||
/// условии появления (набор условий + логика их объединения): шаги с разными
|
||||
/// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они
|
||||
/// попасть могут, а в один резолв — нет. Сравнение условий — в памяти
|
||||
/// (см. <see cref="ConditionsEqual"/>): кандидатов на дубликат в пределах
|
||||
/// одного маршрута всегда мало, а сравнение списка условий целиком плохо
|
||||
/// транслируется в SQL.
|
||||
/// </summary>
|
||||
private Task<bool> HasDuplicateAsync(
|
||||
private async Task<bool> HasDuplicateAsync(
|
||||
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
|
||||
RouteStepStatus status, string? fieldName, ComparisonOperator? operation, string? value,
|
||||
int? excludeId, CancellationToken cancellationToken) =>
|
||||
db.TemplateRouteSteps.AnyAsync(x =>
|
||||
x.Id != excludeId &&
|
||||
x.RouteId == routeId &&
|
||||
x.ExecutorEmail == executorEmail &&
|
||||
x.Position == position &&
|
||||
x.ProjectRole == projectRole &&
|
||||
x. HeadType == headType &&
|
||||
x.Status == status &&
|
||||
x.FieldName == fieldName &&
|
||||
x.Operation == operation &&
|
||||
x.Value == value,
|
||||
cancellationToken);
|
||||
RouteStepStatus status, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
||||
int? excludeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
|
||||
.Where(x =>
|
||||
x.Id != excludeId &&
|
||||
x.RouteId == routeId &&
|
||||
x.ExecutorEmail == executorEmail &&
|
||||
x.Position == position &&
|
||||
x.ProjectRole == projectRole &&
|
||||
x.HeadType == headType &&
|
||||
x.Status == status)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
|
||||
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для
|
||||
/// DepartmentHead, единственный departmentId запроса (см.
|
||||
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора.
|
||||
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы
|
||||
/// в одного и того же (или неотличимо какого) руководителя — например, «два
|
||||
/// разных директора одной и той же компании» просто не существуют. В отличие
|
||||
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
|
||||
/// достаточно совпадения HeadType — вне зависимости от статуса или остальных
|
||||
/// полей шага. Исключение — условие появления шага: при разных условиях
|
||||
/// в конкретный резолв попадёт только один из шагов, так что одного и того же
|
||||
/// руководителя дважды в маршруте не окажется.
|
||||
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors)
|
||||
/// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости
|
||||
/// инициатора, единственный departmentId запроса (для DepartmentHead, см.
|
||||
/// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для
|
||||
/// CompanyDirectors) не оставляют места для выбора. Для большинства значений это
|
||||
/// один конкретный человек; для CompanyDirectors — один и тот же набор людей
|
||||
/// (директор каждой компании инициатора), но тоже всегда один и тот же для
|
||||
/// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым
|
||||
/// HeadType всегда резолвились бы одинаково — например, «два разных директора
|
||||
/// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync
|
||||
/// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения
|
||||
/// HeadType — вне зависимости от статуса или остальных полей шага. Исключение —
|
||||
/// условие появления шага: при разных условиях в конкретный резолв попадёт только
|
||||
/// один из шагов, так что дублирования исполнителей в маршруте не окажется.
|
||||
/// </summary>
|
||||
private Task<bool> HasDuplicateHeadTypeAsync(
|
||||
int routeId, HeadType? headType, string? fieldName, ComparisonOperator? operation, string? value,
|
||||
private async Task<bool> HasDuplicateHeadTypeAsync(
|
||||
int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
||||
int? excludeId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (headType is null or HeadType.None)
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
|
||||
return db.TemplateRouteSteps.AnyAsync(x =>
|
||||
x.Id != excludeId &&
|
||||
x.RouteId == routeId &&
|
||||
x.HeadType == headType &&
|
||||
x.FieldName == fieldName &&
|
||||
x.Operation == operation &&
|
||||
x.Value == value,
|
||||
cancellationToken);
|
||||
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
|
||||
.Where(x => x.Id != excludeId && x.RouteId == routeId && x.HeadType == headType)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Наборы условий равны, если совпадают все условия (без учёта порядка — порядок
|
||||
/// влияет только на отображение в форме, не на вычисление) и, если условий два и
|
||||
/// более, логика их объединения (И/ИЛИ). При 0-1 условии логика ни на что не
|
||||
/// влияет, поэтому не сравнивается.
|
||||
/// </summary>
|
||||
private static bool ConditionsEqual(
|
||||
IReadOnlyList<RouteStepConditionDto> a, ConditionsLogic aLogic,
|
||||
IReadOnlyList<RouteStepConditionDto>? b, ConditionsLogic bLogic)
|
||||
{
|
||||
var bList = b ?? [];
|
||||
if (a.Count != bList.Count)
|
||||
return false;
|
||||
if (a.Count > 1 && aLogic != bLogic)
|
||||
return false;
|
||||
|
||||
static IEnumerable<(string FieldName, ComparisonOperator Operation, string Value)> Normalize(
|
||||
IReadOnlyList<RouteStepConditionDto> c) =>
|
||||
c.Select(x => (x.FieldName, x.Operation, x.Value))
|
||||
.OrderBy(x => x.FieldName, StringComparer.Ordinal)
|
||||
.ThenBy(x => x.Operation)
|
||||
.ThenBy(x => x.Value, StringComparer.Ordinal);
|
||||
|
||||
return Normalize(a).SequenceEqual(Normalize(bList));
|
||||
}
|
||||
|
||||
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||
@@ -289,5 +370,5 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
|
||||
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.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason);
|
||||
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason, e.AutoApprove);
|
||||
}
|
||||
|
||||
+49
-14
@@ -8,12 +8,15 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
|
||||
public CreateTemplateRouteStepRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||
RuleFor(x => x.ConditionsLogic)
|
||||
.NotEqual(ConditionsLogic.None)
|
||||
.WithMessage("Логика объединения условий обязательна: And или Or.");
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
||||
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
@@ -28,12 +31,44 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
|
||||
public UpdateTemplateRouteStepRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||
RuleFor(x => x.ConditionsLogic)
|
||||
.NotEqual(ConditionsLogic.None)
|
||||
.WithMessage("Логика объединения условий обязательна: And или Or.");
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
||||
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.SlaHours is not null)
|
||||
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// У PATCH-запроса шага валидатора раньше не было вовсе — здесь он ровно про одно:
|
||||
/// не дать протащить невалидные значения-заглушки в поля, которые запрос ЯВНО
|
||||
/// задаёт. null остаётся «не менять» и не проверяется (см. PatchTemplateRouteStepRequest).
|
||||
/// </summary>
|
||||
public sealed class PatchTemplateRouteStepRequestValidator : AbstractValidator<PatchTemplateRouteStepRequest>
|
||||
{
|
||||
public PatchTemplateRouteStepRequestValidator()
|
||||
{
|
||||
When(x => x.Status is not null, () =>
|
||||
RuleFor(x => x.Status)
|
||||
.NotEqual(RouteStepStatus.None)
|
||||
.WithMessage("Статус обязателен: Approval или Execution."));
|
||||
When(x => x.ConditionsLogic is not null, () =>
|
||||
RuleFor(x => x.ConditionsLogic)
|
||||
.NotEqual(ConditionsLogic.None)
|
||||
.WithMessage("Логика объединения условий обязательна: And или Or."));
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
@@ -51,14 +86,14 @@ internal static class TemplateRouteStepFields
|
||||
(projectRole is null or ProjectRole.None ? 0 : 1) +
|
||||
(headType is null or HeadType.None ? 0 : 1);
|
||||
|
||||
public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) =>
|
||||
(string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) +
|
||||
(operation is null or ComparisonOperator.None ? 0 : 1) +
|
||||
(string.IsNullOrWhiteSpace(value) ? 0 : 1);
|
||||
|
||||
public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value)
|
||||
{
|
||||
var count = CountConditionFilled(fieldName, operation, value);
|
||||
return count == 0 || count == 3;
|
||||
}
|
||||
/// <summary>
|
||||
/// Условие с пустым field_name/value или operation=None — половинчатое, а не
|
||||
/// «выключенное» (выключается вовсе не добавляя условие в список), поэтому
|
||||
/// такое отдельное условие недопустимо, даже если остальные условия шага корректны.
|
||||
/// </summary>
|
||||
public static bool AreConditionsValid(IReadOnlyList<RouteStepConditionDto>? conditions) =>
|
||||
conditions is null || conditions.All(c =>
|
||||
!string.IsNullOrWhiteSpace(c.FieldName) &&
|
||||
c.Operation != ComparisonOperator.None &&
|
||||
!string.IsNullOrWhiteSpace(c.Value));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,20 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли причину просрочки — так же с уже применённым наследованием.
|
||||
/// </param>
|
||||
/// <param name="PositionCode">
|
||||
/// HeadType шага (Chief/DirectionHead/DirectorTansu/ManagingDirectorTansu/DepartmentHead),
|
||||
/// если исполнитель задан типом руководителя, иначе null. В отличие от <see cref="Position"/>
|
||||
/// (реальная должность резолвнутого сотрудника из справочника api.tnsu.kz — свободный
|
||||
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
|
||||
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
|
||||
/// </param>
|
||||
/// <param name="AutoApprove">
|
||||
/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть —
|
||||
/// наследовать его неоткуда (у типа заявки такого поля нет, см.
|
||||
/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто
|
||||
/// переносит его в строку списка согласования: решает по нему не она, а фоновая
|
||||
/// задача бэкенда (AutoApproveService).
|
||||
/// </param>
|
||||
public sealed record ResolvedRouteStepDto(
|
||||
int Id,
|
||||
int OrderNo,
|
||||
@@ -21,7 +35,9 @@ public sealed record ResolvedRouteStepDto(
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
HeadType? PositionCode = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
public sealed record ResolveTemplateRouteResultDto(
|
||||
int RouteId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Globalization;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.Common;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
@@ -15,7 +17,8 @@ public sealed class TemplateRouteService(
|
||||
ApprovalRouteDbContext db,
|
||||
ITnsuPortalClient portalClient,
|
||||
ICurrentUser currentUser,
|
||||
IOptions<TnsuPortalOptions> portalOptions)
|
||||
IOptions<TnsuPortalOptions> portalOptions,
|
||||
ILogger<TemplateRouteService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -61,6 +64,7 @@ public sealed class TemplateRouteService(
|
||||
};
|
||||
db.TemplateRoutes.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Маршрут {RouteId} создан пользователем {Author}", entity.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
}
|
||||
@@ -81,6 +85,7 @@ public sealed class TemplateRouteService(
|
||||
entity.ByProject = r.ByProject;
|
||||
entity.RequiresControl = r.RequiresControl;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
}
|
||||
@@ -105,6 +110,7 @@ public sealed class TemplateRouteService(
|
||||
entity.ByProject = byProject;
|
||||
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
}
|
||||
@@ -117,6 +123,7 @@ public sealed class TemplateRouteService(
|
||||
|
||||
db.TemplateRoutes.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Маршрут {RouteId} удалён пользователем {Author}", id, currentUser.Email);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
@@ -136,18 +143,30 @@ public sealed class TemplateRouteService(
|
||||
if (requestType is null)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||
|
||||
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||
if (employee is null)
|
||||
// /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких
|
||||
// компаниях одновременно. Для поиска маршрута и большинства резолвов ниже
|
||||
// используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно
|
||||
// одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors
|
||||
// (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все.
|
||||
var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||
if (employees.Count == 0)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
||||
|
||||
var primaryEmployee = employees[0];
|
||||
var initiatorCompanies = employees
|
||||
.Select(e => e.Company)
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var byProject = request.ProjectId is not null;
|
||||
|
||||
// Приоритет — маршрут с компанией инициатора; если для этой компании
|
||||
// своего маршрута нет, берём запасной маршрут без указанной компании
|
||||
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
|
||||
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
|
||||
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
|
||||
var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken)
|
||||
?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.DepartmentId, byProject, cancellationToken);
|
||||
|
||||
if (route is null)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
||||
@@ -183,22 +202,77 @@ public sealed class TemplateRouteService(
|
||||
|
||||
var matchingSteps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||
.Where(s => MatchesConditions(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||
.OrderBy(s => s.OrderNo)
|
||||
.ToList();
|
||||
|
||||
var steps = new List<ResolvedRouteStepDto>();
|
||||
foreach (var step in matchingSteps)
|
||||
steps.Add(await ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
cancellationToken));
|
||||
// Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные
|
||||
// походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте
|
||||
// (десяток+ шагов) последовательные await один за другим складываются в
|
||||
// заметную задержку. Task.WhenAll сохраняет порядок результатов (массив
|
||||
// соответствует порядку задач, а не порядку завершения), поэтому
|
||||
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
||||
// дубликат считается первым) продолжают работать так же, как при
|
||||
// последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО
|
||||
// строк результата (HeadType.CompanyDirectors — по одной на компанию
|
||||
// инициатора), поэтому ResolveStepAsync возвращает список, а не одну dto,
|
||||
// и результат нужно сначала сгладить (SelectMany).
|
||||
var stepResults = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||||
step, email, primaryEmployee.Company, initiatorCompanies, projectExecutors, request.DepartmentId, requestType,
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
cancellationToken)));
|
||||
|
||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||
var flattenedSteps = stepResults.SelectMany(x => x).ToList();
|
||||
var withoutDirectorTansuDuplicate = RemoveCompanyDirectorsDuplicatedByDirectorTansu(flattenedSteps);
|
||||
var collapsedSteps = CollapseByExecutorIdentity(withoutDirectorTansuDuplicate);
|
||||
|
||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один и тот же человек в справочнике api.tnsu.kz может иметь несколько email —
|
||||
/// поэтому дедуп исполнителей (и здесь, и в CollapseByExecutorIdentity) идёт по
|
||||
/// Guid сотрудника (EmployeeDirectoryInfo.Id), а не по email. Email — фолбэк на
|
||||
/// случай, если Id почему-то не резолвился (сотрудник не найден по email в
|
||||
/// момент BuildDtoAsync). null — нет ни того, ни другого, дедуп не имеет смысла
|
||||
/// (например, исполнитель шага вовсе не резолвился).
|
||||
/// </summary>
|
||||
private static string? IdentityKeyOf(ResolvedStepRow row) =>
|
||||
row.ExecutorId is { } id ? $"id:{id}" :
|
||||
!string.IsNullOrWhiteSpace(row.Dto.ExecutorEmail) ? $"email:{row.Dto.ExecutorEmail.Trim().ToLowerInvariant()}" :
|
||||
null;
|
||||
|
||||
/// <summary>
|
||||
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
|
||||
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
|
||||
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
|
||||
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
|
||||
/// DirectorTansu через обычное схлопывание (см. CollapseByExecutorIdentity):
|
||||
/// тот считает разные PositionCode у одного исполнителя конфликтом и пишет
|
||||
/// предупреждение, а здесь это не конфликт, а один и тот же человек — оставляем
|
||||
/// только шаг DirectorTansu, а дубликат из CompanyDirectors удаляем. Совпадение
|
||||
/// определяется по identity-ключу (Id сотрудника, если он резолвился, иначе
|
||||
/// email), а не по email напрямую — один человек может фигурировать под
|
||||
/// разными email в разных ветках резолва.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ResolvedStepRow> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||||
IReadOnlyList<ResolvedStepRow> steps)
|
||||
{
|
||||
var directorTansuKeys = steps
|
||||
.Where(s => s.Dto.PositionCode == HeadType.DirectorTansu)
|
||||
.Select(IdentityKeyOf)
|
||||
.Where(k => k is not null)
|
||||
.Select(k => k!)
|
||||
.ToHashSet();
|
||||
|
||||
if (directorTansuKeys.Count == 0)
|
||||
return steps;
|
||||
|
||||
return steps
|
||||
.Where(s => s.Dto.PositionCode != HeadType.CompanyDirectors || !directorTansuKeys.Contains(IdentityKeyOf(s) ?? ""))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
|
||||
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
|
||||
@@ -208,7 +282,7 @@ public sealed class TemplateRouteService(
|
||||
private async Task<TemplateRoute?> FindRouteAsync(
|
||||
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
|
||||
await db.TemplateRoutes.AsNoTracking()
|
||||
.Include(x => x.Steps)
|
||||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||
.Where(x =>
|
||||
x.RequestTypeId == requestTypeId &&
|
||||
x.CompanyId == companyId &&
|
||||
@@ -216,7 +290,7 @@ public sealed class TemplateRouteService(
|
||||
x.DepartmentId == departmentId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? await db.TemplateRoutes.AsNoTracking()
|
||||
.Include(x => x.Steps)
|
||||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||
.Where(x =>
|
||||
x.RequestTypeId == requestTypeId &&
|
||||
x.CompanyId == companyId &&
|
||||
@@ -240,12 +314,110 @@ public sealed class TemplateRouteService(
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||
/// <summary>
|
||||
/// Название должности директора для конкретной компании — по умолчанию «Директор»,
|
||||
/// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
|
||||
/// поэтому для HeadType.CompanyDirectors ищем по этому названию через
|
||||
/// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/
|
||||
/// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же:
|
||||
/// отдельного источника правды для него в системе нет.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> DirectorPositionByCompany =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ТОО KazPromService"] = "Главный механик",
|
||||
["ТОО СТ Global"] = "Генеральный директор"
|
||||
};
|
||||
|
||||
private const string DefaultDirectorPosition = "Директор";
|
||||
|
||||
private static string DirectorPositionFor(string company) =>
|
||||
DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition;
|
||||
|
||||
/// <summary>
|
||||
/// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для
|
||||
/// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию
|
||||
/// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном
|
||||
/// шаге, то есть параллельны по построению, а не потому что кто-то расставил
|
||||
/// им одинаковый порядок вручную.
|
||||
/// </summary>
|
||||
/// <param name="initiatorCompanies">
|
||||
/// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны
|
||||
/// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему
|
||||
/// используют одну <paramref name="initiatorCompany"/> (первую по счёту).
|
||||
/// </param>
|
||||
/// <param name="conditionWarning">
|
||||
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<IReadOnlyList<ResolvedStepRow>> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||
string? conditionWarning,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
async Task<ResolvedStepRow> BuildDtoAsync(string? resolvedEmail, string? warning)
|
||||
{
|
||||
string? resolvedPosition = null;
|
||||
Guid? resolvedId = null;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
{
|
||||
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||
var resolvedEmployee = resolvedEmployees.FirstOrDefault();
|
||||
resolvedPosition = resolvedEmployee?.Position;
|
||||
resolvedId = resolvedEmployee?.Id;
|
||||
}
|
||||
|
||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
|
||||
var dto = new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
resolvedPosition,
|
||||
step.AllowReassignToOtherApprovers,
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||
step.HeadType is null or HeadType.None ? null : step.HeadType,
|
||||
step.AutoApprove);
|
||||
|
||||
return new ResolvedStepRow(dto, resolvedId);
|
||||
}
|
||||
|
||||
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
||||
// (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно
|
||||
// и до общего "одна строка на шаг" пути ниже.
|
||||
if (step.HeadType == HeadType.CompanyDirectors)
|
||||
{
|
||||
if (initiatorCompanies.Count == 0)
|
||||
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
||||
|
||||
var dtos = new List<ResolvedStepRow>();
|
||||
foreach (var company in initiatorCompanies)
|
||||
{
|
||||
var directorPosition = DirectorPositionFor(company);
|
||||
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(directorPosition, company, cancellationToken);
|
||||
var directorEmail = employeeByPosition.Email;
|
||||
string? directorWarning = null;
|
||||
if (!employeeByPosition.Found)
|
||||
directorWarning = $"Не найден сотрудник на должности «{directorPosition}» в компании «{company}».";
|
||||
else if (string.IsNullOrWhiteSpace(directorEmail))
|
||||
directorWarning = $"Сотрудник {employeeByPosition.Fio} на должности «{directorPosition}» в компании «{company}» найден, но email не указан в системе портала.";
|
||||
|
||||
dtos.Add(await BuildDtoAsync(directorEmail, directorWarning));
|
||||
}
|
||||
|
||||
return dtos;
|
||||
}
|
||||
|
||||
string? resolvedEmail;
|
||||
string? warning = null;
|
||||
|
||||
@@ -255,15 +427,18 @@ public sealed class TemplateRouteService(
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(step.Position))
|
||||
{
|
||||
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
|
||||
resolvedEmail = employeeByPosition.Email;
|
||||
if (!employeeByPosition.Found)
|
||||
warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}».";
|
||||
else if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
warning = $"Сотрудник {employeeByPosition.Fio} на должности «{step.Position}» найден, но email не указан в системе портала.";
|
||||
}
|
||||
else if (step.ProjectRole is not (null or ProjectRole.None))
|
||||
{
|
||||
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
||||
warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
|
||||
}
|
||||
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||||
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||||
@@ -295,7 +470,14 @@ public sealed class TemplateRouteService(
|
||||
_ => null
|
||||
};
|
||||
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
|
||||
{
|
||||
@@ -303,51 +485,38 @@ public sealed class TemplateRouteService(
|
||||
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
|
||||
}
|
||||
|
||||
string? resolvedPosition = null;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
{
|
||||
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||
resolvedPosition = resolvedEmployee?.Position;
|
||||
}
|
||||
|
||||
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
resolvedPosition,
|
||||
step.AllowReassignToOtherApprovers,
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason);
|
||||
return [await BuildDtoAsync(resolvedEmail, warning)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе.
|
||||
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля
|
||||
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный
|
||||
/// сценарий, и отсутствие значения означает, что условие не выполняется.
|
||||
/// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий
|
||||
/// применяется всегда. Несколько условий объединяются через <see cref="TemplateRouteStep.ConditionsLogic"/>:
|
||||
/// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано
|
||||
/// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы
|
||||
/// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется.
|
||||
/// </summary>
|
||||
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||||
private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None)
|
||||
if (step.Conditions.Count == 0)
|
||||
return true;
|
||||
|
||||
if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||
return step.ConditionsLogic == ConditionsLogic.Or
|
||||
? step.Conditions.Any(c => MatchesCondition(c, fieldValues))
|
||||
: step.Conditions.All(c => MatchesCondition(c, fieldValues));
|
||||
}
|
||||
|
||||
private static bool MatchesCondition(TemplateRouteStepCondition condition, IReadOnlyDictionary<string, string> fieldValues)
|
||||
{
|
||||
if (!fieldValues.TryGetValue(condition.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||
return false;
|
||||
|
||||
var expected = step.Value;
|
||||
return step.Operation switch
|
||||
var expected = condition.Value;
|
||||
return condition.Operation switch
|
||||
{
|
||||
ComparisonOperator.Equal =>
|
||||
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
|
||||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected),
|
||||
ComparisonOperator.Equal => IsEqual(actual, expected),
|
||||
ComparisonOperator.NotEqual => !IsEqual(actual, expected),
|
||||
ComparisonOperator.Contains => Contains(actual, expected),
|
||||
ComparisonOperator.NotContains => !Contains(actual, expected),
|
||||
ComparisonOperator.GreaterThan =>
|
||||
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
|
||||
ComparisonOperator.LessThan =>
|
||||
@@ -356,6 +525,19 @@ public sealed class TemplateRouteService(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Равно» для значений без типа: сначала как текст без учёта регистра, затем как числа —
|
||||
/// чтобы «1000» и «1000.00» считались равными. «Не равно» — строгое отрицание этого же.
|
||||
/// </summary>
|
||||
private static bool IsEqual(string actual, string? expected) =>
|
||||
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
|
||||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected);
|
||||
|
||||
/// <summary>«Содержит» — подстрока без учёта регистра; операторы доступны только для текстовых полей.</summary>
|
||||
private static bool Contains(string actual, string? expected) =>
|
||||
!string.IsNullOrEmpty(expected) &&
|
||||
actual.Contains(expected.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryParseNumber(string? value, out double number) =>
|
||||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||
|
||||
@@ -370,14 +552,14 @@ public sealed class TemplateRouteService(
|
||||
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
|
||||
|
||||
/// <summary>
|
||||
/// Условие шага завязано на IsManager, но вычислить его не из чего: должность
|
||||
/// в заявке не передана. Такой шаг попадает в результат вопреки условию и с
|
||||
/// предупреждением — см. ResolveAsync.
|
||||
/// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность
|
||||
/// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с
|
||||
/// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага:
|
||||
/// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен.
|
||||
/// </summary>
|
||||
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
|
||||
positionMissing &&
|
||||
string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) &&
|
||||
step.Operation is not (null or ComparisonOperator.None);
|
||||
step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Должность — свободный текст без структуры (нет отдельного флага «это
|
||||
@@ -394,17 +576,55 @@ public sealed class TemplateRouteService(
|
||||
!string.IsNullOrWhiteSpace(position) &&
|
||||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
/// <summary>
|
||||
/// Один и тот же исполнитель, полученный из разных шагов, в результате должен
|
||||
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||||
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||||
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||||
/// задан у обеих строк и они РАЗНЫЕ (один и тот же человек получен через две
|
||||
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||||
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||||
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||||
/// хуже, чем показать чуть неточный PositionCode с пояснением. Дедуп — по
|
||||
/// identity-ключу (см. IdentityKeyOf), а не по email напрямую: один и тот же
|
||||
/// человек в справочнике api.tnsu.kz может иметь несколько email, и с ними
|
||||
/// дедуп по email эту пару не поймал бы.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorIdentity(IReadOnlyList<ResolvedStepRow> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var indexByKey = new Dictionary<string, int>();
|
||||
var result = new List<ResolvedRouteStepDto>();
|
||||
|
||||
foreach (var step in steps)
|
||||
foreach (var row in steps)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
||||
var key = IdentityKeyOf(row);
|
||||
if (key is null)
|
||||
{
|
||||
result.Add(row.Dto);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(step);
|
||||
if (!indexByKey.TryGetValue(key, out var index))
|
||||
{
|
||||
indexByKey[key] = result.Count;
|
||||
result.Add(row.Dto);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = result[index];
|
||||
var step = row.Dto;
|
||||
if (existing.PositionCode is null && step.PositionCode is not null)
|
||||
{
|
||||
result[index] = existing with { PositionCode = step.PositionCode };
|
||||
}
|
||||
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||||
{
|
||||
var conflictWarning =
|
||||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
|
||||
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
result[index] = existing with { Warning = combinedWarning };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -412,4 +632,11 @@ public sealed class TemplateRouteService(
|
||||
|
||||
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||||
|
||||
/// <summary>
|
||||
/// Промежуточный результат резолва одного шага — dto плюс Guid сотрудника из
|
||||
/// справочника api.tnsu.kz (если резолвился), используемый только для дедупа
|
||||
/// (см. IdentityKeyOf/CollapseByExecutorIdentity). Наружу отдаётся только Dto.
|
||||
/// </summary>
|
||||
private sealed record ResolvedStepRow(ResolvedRouteStepDto Dto, Guid? ExecutorId);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Генерация xlsx для отчётов (Reports/) — стили, формулы, autofilter, диаграммы. -->
|
||||
<PackageReference Include="NPOI" />
|
||||
<PackageReference Include="FluentResults" />
|
||||
<PackageReference Include="FluentValidation" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||
<PackageReference Include="Hangfire.Core" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Один SharePoint-список данных из нумерованной серии по типу заявок.
|
||||
///
|
||||
/// Списки заявок нумерованные («<Форма> <N> — данные»): когда в текущем
|
||||
/// списке набирается порог элементов, рядом создаётся следующая пара (партиция), а
|
||||
/// прежняя остаётся архивом только для чтения. Эта таблица — реестр всей серии: без
|
||||
/// неё поиск по заявкам типа не знает, по каким спискам искать.
|
||||
///
|
||||
/// Текущий (тот, куда идёт запись) список продолжает лежать в
|
||||
/// <see cref="RequestType.ListId"/> — реестр его дублирует строкой с наибольшим
|
||||
/// <see cref="Number"/>, но источником правды для записи остаётся RequestType.
|
||||
///
|
||||
/// Сайта у партиции нет намеренно: вся серия живёт на одном сайте — том, что указан
|
||||
/// в <see cref="RequestType.SiteUrl"/> на момент создания партиции. Хранить его
|
||||
/// отдельно на каждой партиции — заводить возможность архивам разъехаться по разным
|
||||
/// сайтам, а такой сценарий не поддерживается ни провижинером (см.
|
||||
/// SharePointListProvisioner — всегда пишет на siteUrl запрашивающего типа заявок),
|
||||
/// ни поиском. Если сайт типа заявок когда-нибудь придётся сменить, старые партиции
|
||||
/// нужно будет переносить осознанно, а не молча продолжать читать их со старого адреса.
|
||||
/// </summary>
|
||||
public class RequestListPartition
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int RequestTypeId { get; set; }
|
||||
public RequestType? RequestType { get; set; }
|
||||
|
||||
/// <summary>Номер партиции в серии, с 1. Уникален в пределах типа заявок.</summary>
|
||||
public int Number { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Серверный относительный url списка — та же семантика, что у
|
||||
/// <see cref="RequestType.ListId"/> (несмотря на имя, это url, а не GUID).
|
||||
/// </summary>
|
||||
public string ListId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Title списка на момент создания — для админки, чтобы показать серию без
|
||||
/// похода в SharePoint за каждым заголовком. Может разойтись с фактическим,
|
||||
/// если список переименовали вручную.
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -25,5 +25,23 @@ public class RequestType
|
||||
/// </summary>
|
||||
public bool? AskOverdueReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент, когда фоновая задача создания следующей партиции списка взяла эту
|
||||
/// строку в работу. null — задача не выполняется.
|
||||
///
|
||||
/// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует
|
||||
/// только если сумела перевести поле из null в now() одним условным UPDATE
|
||||
/// (см. ListPartitioningLock) — тогда две параллельные задачи по одному типу
|
||||
/// заявок невозможны.
|
||||
///
|
||||
/// Метка времени, а не bool, намеренно: упавший процесс не успевает снять
|
||||
/// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка
|
||||
/// просрочена, и её можно перехватить.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ListPartitioningStartedAt { get; set; }
|
||||
|
||||
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
||||
|
||||
/// <summary>Нумерованная серия партиций списка этого типа заявок (архив + текущая).</summary>
|
||||
public ICollection<RequestListPartition> ListPartitions { get; set; } = new List<RequestListPartition>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Право одного пользователя видеть ВСЕ заявки одного типа в портале заявок
|
||||
/// (а не только свои). У одного человека может быть по строке на каждый тип
|
||||
/// заявок, к которому ему открыт полный доступ.
|
||||
/// </summary>
|
||||
public class RequestTypeAccess
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int RequestTypeId { get; set; }
|
||||
public RequestType? RequestType { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -31,9 +31,25 @@ public class TemplateRouteStep
|
||||
/// </summary>
|
||||
public bool? AskOverdueReason { get; set; }
|
||||
|
||||
public string? FieldName { get; set; }
|
||||
public ComparisonOperator? Operation { get; set; }
|
||||
public string? Value { get; set; }
|
||||
/// <summary>
|
||||
/// Согласовывать шаг автоматически, когда истёк его SLA (см. <see cref="SlaHours"/>).
|
||||
///
|
||||
/// Не nullable, в отличие от SLA-полей рядом: наследовать тут нечего — у типа
|
||||
/// заявки такого «значения по умолчанию» нет и быть не должно (включить
|
||||
/// автосогласование сразу всем шагам типа — не настройка, а авария), поэтому
|
||||
/// три состояния не нужны, хватает обычного «включено/выключено».
|
||||
///
|
||||
/// Действует только вместе с SLA: без действующего SLA (ни на шаге, ни на типе
|
||||
/// заявки) отсчитывать нечего, и фоновая задача такой шаг пропускает
|
||||
/// (см. AutoApproveService).
|
||||
/// </summary>
|
||||
public bool AutoApprove { get; set; }
|
||||
|
||||
/// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
|
||||
public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
|
||||
|
||||
/// <summary>Условия появления шага. Пусто — шаг применяется всегда.</summary>
|
||||
public List<TemplateRouteStepCondition> Conditions { get; set; } = [];
|
||||
|
||||
public TemplateRoute? Route { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Одно условие появления шага (см. <see cref="TemplateRouteStep.Conditions"/>) —
|
||||
/// сравнение значения поля заявки (<see cref="FieldName"/>) с <see cref="Value"/>
|
||||
/// через <see cref="Operation"/>. Несколько условий одного шага объединяются
|
||||
/// через <see cref="TemplateRouteStep.ConditionsLogic"/> (И/ИЛИ).
|
||||
/// </summary>
|
||||
public class TemplateRouteStepCondition
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int StepId { get; set; }
|
||||
public TemplateRouteStep? Step { get; set; }
|
||||
|
||||
/// <summary>Порядок отображения в форме — на вычисление условия не влияет.</summary>
|
||||
public int OrderNo { get; set; }
|
||||
|
||||
public string FieldName { get; set; } = string.Empty;
|
||||
public ComparisonOperator Operation { get; set; }
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -5,5 +5,8 @@ public enum ComparisonOperator
|
||||
None,
|
||||
GreaterThan,
|
||||
LessThan,
|
||||
Equal
|
||||
Equal,
|
||||
NotEqual,
|
||||
Contains,
|
||||
NotContains
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Как объединяются условия шага (<see cref="Entities.TemplateRouteStep.Conditions"/>)
|
||||
/// между собой, если их несколько. Не действует, если условие одно или их нет вовсе.
|
||||
/// </summary>
|
||||
public enum ConditionsLogic
|
||||
{
|
||||
/// <summary>
|
||||
/// Логика не задана — НЕвалидное значение (см. валидаторы шага), а не «как И».
|
||||
/// Существует только как значение по умолчанию (default(ConditionsLogic)):
|
||||
/// шаг с такой логикой не должен ни создаваться, ни сохраняться.
|
||||
///
|
||||
/// Именно поэтому у самой сущности и у запросов на создание/обновление
|
||||
/// значение по умолчанию задано явно — <c>ConditionsLogic.And</c>
|
||||
/// (см. TemplateRouteStep.ConditionsLogic и *TemplateRouteStepRequest):
|
||||
/// пропущенное в запросе поле по-прежнему означает «И», как и раньше.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary>
|
||||
And = 1,
|
||||
|
||||
/// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary>
|
||||
Or = 2
|
||||
}
|
||||
@@ -15,5 +15,15 @@ public enum HeadType
|
||||
/// подчинённости самого инициатора. В маршруте не может быть больше одного
|
||||
/// шага с этим типом (см. TemplateRouteStepService).
|
||||
/// </summary>
|
||||
DepartmentHead
|
||||
DepartmentHead,
|
||||
|
||||
/// <summary>
|
||||
/// Директора ВСЕХ компаний, в которых числится инициатор (сотрудник может
|
||||
/// работать в нескольких компаниях — /Employee/ByEmail теперь возвращает
|
||||
/// массив). В отличие от остальных значений этого enum'а, один такой шаг
|
||||
/// резолвится в НЕСКОЛЬКО строк результата — по одной на каждую компанию
|
||||
/// инициатора, все с одним и тем же OrderNo (то есть параллельно, см.
|
||||
/// TemplateRouteService.ResolveStepAsync).
|
||||
/// </summary>
|
||||
CompanyDirectors
|
||||
}
|
||||
|
||||
+24
-1
@@ -20,20 +20,43 @@ public static class InfrastructureServiceCollectionExtensions
|
||||
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
||||
.UseSnakeCaseNamingConvention());
|
||||
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
||||
services.AddScoped<ListPartitioningLock>();
|
||||
|
||||
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
|
||||
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
|
||||
// время жизни процесса (см. DbCertificateProvider).
|
||||
services.AddSingleton<ICertificateProvider, DbCertificateProvider>();
|
||||
services.AddHttpClient<ISharePointClient, SharePointClient>();
|
||||
services.AddScoped<ISharePointListProvisioner, SharePointListProvisioner>();
|
||||
|
||||
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
||||
services.AddHttpClient<TnsuPortalTokenProvider>();
|
||||
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
|
||||
// Токен вызывающего и общая авторизация запросов к порталу — одни на всех
|
||||
// его клиентов (см. TnsuPortalCallerToken/TnsuPortalAuthorizer). Именно
|
||||
// Scoped: Hangfire даёт задаче свою область DI, и положенный в задаче токен
|
||||
// не должен протекать в соседние.
|
||||
services.AddScoped<TnsuPortalCallerToken>();
|
||||
services.AddScoped<TnsuPortalAuthorizer>();
|
||||
|
||||
// Подписка новых списков на уведомления SharePoint. Клиент отдельный от
|
||||
// ITnsuPortalClient: там кешируемые чтения справочника, здесь команда.
|
||||
// Своих опций нет — адрес приёмника лежит в SharePoint:NotificationUrl.
|
||||
services.AddHttpClient<ISharePointWebhookSubscriber, HttpSharePointWebhookSubscriber>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
// ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут,
|
||||
// см. её комментарий) — сам HttpTnsuPortalClient регистрируем по
|
||||
// конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить
|
||||
// как обычную зависимость.
|
||||
services.AddHttpClient<HttpTnsuPortalClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITnsuPortalClient, CachingTnsuPortalClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||
public DbSet<RequestTypeAccess> RequestTypeAccesses => Set<RequestTypeAccess>();
|
||||
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
|
||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||
public DbSet<TemplateRouteStepCondition> TemplateRouteStepConditions => Set<TemplateRouteStepCondition>();
|
||||
public DbSet<AppSetting> Settings => Set<AppSetting>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
@@ -33,6 +36,27 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
||||
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 =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.ListId).HasMaxLength(512).IsRequired();
|
||||
e.Property(x => x.Title).HasMaxLength(512);
|
||||
e.HasOne(x => x.RequestType).WithMany(x => x.ListPartitions).HasForeignKey(x => x.RequestTypeId);
|
||||
// Номер уникален в пределах типа заявок — вторая попытка записать тот же
|
||||
// номер (например, при двойном запуске задачи) упадёт на этом индексе,
|
||||
// а не создаст в реестре дубль.
|
||||
e.HasIndex(x => new { x.RequestTypeId, x.Number }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TemplateRoute>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
@@ -48,11 +72,19 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
||||
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
e.Property(x => x.FieldName).HasMaxLength(128);
|
||||
e.Property(x => x.Operation).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.Value).HasMaxLength(256);
|
||||
e.Property(x => x.ConditionsLogic).HasConversion<string>().HasMaxLength(16).IsRequired();
|
||||
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
|
||||
e.HasIndex(x => new { x.RouteId, x.OrderNo });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TemplateRouteStepCondition>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.FieldName).HasMaxLength(128).IsRequired();
|
||||
e.Property(x => x.Operation).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
e.Property(x => x.Value).HasMaxLength(256).IsRequired();
|
||||
e.HasOne(x => x.Step).WithMany(x => x.Conditions).HasForeignKey(x => x.StepId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasIndex(x => new { x.StepId, x.OrderNo });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Блокировка «по типу заявок идёт создание следующей партиции списка» на строке
|
||||
/// request_types.
|
||||
///
|
||||
/// Почему сырой SQL, а не обычный EF: через change tracker это было бы
|
||||
/// read-modify-write (прочитали флаг → проверили → записали), и две задачи,
|
||||
/// прочитавшие null одновременно, обе решили бы, что блокировка свободна.
|
||||
/// Здесь проверка и захват — ОДИН условный UPDATE, атомарный на уровне строки:
|
||||
/// Postgres берёт row lock на время оператора, поэтому ровно одна из
|
||||
/// конкурирующих задач увидит affected rows = 1, остальные — 0.
|
||||
///
|
||||
/// Никакого pg_advisory_lock: он живёт в рамках сессии, а нам нужно, чтобы
|
||||
/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы
|
||||
/// она переживала возврат соединения в пул между шагами задачи.
|
||||
/// </summary>
|
||||
public sealed class ListPartitioningLock(ApprovalRouteDbContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Попытаться захватить блокировку. true — захватили (и только тогда можно
|
||||
/// провижинить), false — держит кто-то другой либо типа заявок нет.
|
||||
///
|
||||
/// Блокировка старше staleAfter считается брошенной (процесс упал, не сняв
|
||||
/// флаг) и перехватывается.
|
||||
/// </summary>
|
||||
public async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan staleAfter, CancellationToken cancellationToken)
|
||||
{
|
||||
// Имена таблицы/колонки — snake_case из UseSnakeCaseNamingConvention
|
||||
// (см. InfrastructureServiceCollectionExtensions).
|
||||
var affected = await db.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"""
|
||||
UPDATE request_types
|
||||
SET list_partitioning_started_at = now()
|
||||
WHERE id = {requestTypeId}
|
||||
AND (list_partitioning_started_at IS NULL
|
||||
OR list_partitioning_started_at < now() - CAST({staleAfter} AS interval))
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
return affected == 1;
|
||||
}
|
||||
|
||||
/// <summary>Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки.</summary>
|
||||
public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) =>
|
||||
db.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"UPDATE request_types SET list_partitioning_started_at = NULL WHERE id = {requestTypeId}",
|
||||
cancellationToken);
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
// <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("20260817102423_AddListPartitioning")]
|
||||
partial class AddListPartitioning
|
||||
{
|
||||
/// <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<string>("ExecutorEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("executor_email");
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("field_name");
|
||||
|
||||
b.Property<string>("HeadType")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("head_type");
|
||||
|
||||
b.Property<string>("Operation")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("operation");
|
||||
|
||||
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.Property<string>("Value")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("value");
|
||||
|
||||
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.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.RequestType", b =>
|
||||
{
|
||||
b.Navigation("ListPartitions");
|
||||
|
||||
b.Navigation("Routes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddListPartitioning : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "list_partitioning_started_at",
|
||||
table: "request_types",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "request_list_partitions",
|
||||
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),
|
||||
number = table.Column<int>(type: "integer", nullable: false),
|
||||
list_id = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
title = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_request_list_partitions", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_request_list_partitions_request_types_request_type_id",
|
||||
column: x => x.request_type_id,
|
||||
principalTable: "request_types",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_request_list_partitions_request_type_id_number",
|
||||
table: "request_list_partitions",
|
||||
columns: new[] { "request_type_id", "number" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "request_list_partitions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "list_partitioning_started_at",
|
||||
table: "request_types");
|
||||
}
|
||||
}
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
// <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("20260825044822_AddMultipleStepConditions")]
|
||||
partial class AddMultipleStepConditions
|
||||
{
|
||||
/// <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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultipleStepConditions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "conditions_logic",
|
||||
table: "template_route_steps",
|
||||
type: "character varying(16)",
|
||||
maxLength: 16,
|
||||
nullable: false,
|
||||
defaultValue: "And");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "template_route_step_conditions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
step_id = table.Column<int>(type: "integer", nullable: false),
|
||||
order_no = table.Column<int>(type: "integer", nullable: false),
|
||||
field_name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
operation = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
value = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_template_route_step_conditions", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_template_route_step_conditions_template_route_steps_step_id",
|
||||
column: x => x.step_id,
|
||||
principalTable: "template_route_steps",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_template_route_step_conditions_step_id_order_no",
|
||||
table: "template_route_step_conditions",
|
||||
columns: new[] { "step_id", "order_no" });
|
||||
|
||||
// Существующее одиночное условие шага (field_name/operation/value) переносим
|
||||
// как первое (и единственное) условие в новую таблицу — до удаления старых
|
||||
// колонок, иначе уже настроенные в деве/проде условия шагов молча пропадут.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO template_route_step_conditions (step_id, order_no, field_name, operation, value)
|
||||
SELECT id, 0, field_name, operation, value
|
||||
FROM template_route_steps
|
||||
WHERE field_name IS NOT NULL AND operation IS NOT NULL AND value IS NOT NULL;
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "field_name",
|
||||
table: "template_route_steps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "operation",
|
||||
table: "template_route_steps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "value",
|
||||
table: "template_route_steps");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "field_name",
|
||||
table: "template_route_steps",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "operation",
|
||||
table: "template_route_steps",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "value",
|
||||
table: "template_route_steps",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
// Откат теряет условия сверх первого (по order_no) — старая схема хранила
|
||||
// только одно условие на шаг, второе и далее девать некуда.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE template_route_steps s
|
||||
SET field_name = c.field_name, operation = c.operation, value = c.value
|
||||
FROM (
|
||||
SELECT DISTINCT ON (step_id) step_id, field_name, operation, value
|
||||
FROM template_route_step_conditions
|
||||
ORDER BY step_id, order_no
|
||||
) c
|
||||
WHERE c.step_id = s.id;
|
||||
""");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "template_route_step_conditions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "conditions_logic",
|
||||
table: "template_route_steps");
|
||||
}
|
||||
}
|
||||
}
|
||||
+370
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+411
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
-15
@@ -22,6 +22,48 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
|
||||
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")
|
||||
@@ -40,6 +82,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.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)
|
||||
@@ -73,6 +119,35 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
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")
|
||||
@@ -129,26 +204,26 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.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>("FieldName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("field_name");
|
||||
|
||||
b.Property<string>("HeadType")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("head_type");
|
||||
|
||||
b.Property<string>("Operation")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<int>("OrderNo")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("order_no");
|
||||
@@ -177,11 +252,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_template_route_steps");
|
||||
|
||||
@@ -191,6 +261,50 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
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")
|
||||
@@ -212,6 +326,30 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
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")
|
||||
@@ -236,8 +374,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -245,6 +397,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||
{
|
||||
b.Navigation("Conditions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>Созданный список: серверный относительный url (он же ListId) и Title.</summary>
|
||||
public sealed record ProvisionedList(string ListId, string Title);
|
||||
|
||||
/// <summary>
|
||||
/// Создание следующей пары списков серии в SharePoint («<Форма> <N> — данные»
|
||||
/// + «<Форма> <N> — согласование») дублированием текущей пары.
|
||||
///
|
||||
/// Интерфейс живёт в Infrastructure, а не в Application, потому что Application уже
|
||||
/// ссылается на Infrastructure (так же устроен <see cref="ISharePointClient"/>) —
|
||||
/// обратная ссылка замкнула бы проекты в кольцо.
|
||||
/// </summary>
|
||||
public interface ISharePointListProvisioner
|
||||
{
|
||||
/// <summary>
|
||||
/// Создать пару списков с номером <paramref name="number"/>, скопировав структуру
|
||||
/// со списка <paramref name="sourceDataListId"/>. Возвращает созданный список ДАННЫХ
|
||||
/// (список согласования создаётся тем же вызовом, но наружу не отдаётся — его
|
||||
/// находят по конвенции имени).
|
||||
/// </summary>
|
||||
Task<ProvisionedList> CreateNextListPairAsync(
|
||||
string siteUrl,
|
||||
string sourceDataListId,
|
||||
int number,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>
|
||||
/// Конвенция имён списков заявок: «<Форма> <N> — данные» и парный ему
|
||||
/// «<Форма> <N> — согласование».
|
||||
///
|
||||
/// Третья копия одного и того же соглашения — оно же в провижинере request-portal
|
||||
/// (provisioning/Provision-Lists.ps1, Get-NumberedListTitle) и в самом портале
|
||||
/// (shared/listTitles.ts). Держать в одном месте не получается: три разных runtime.
|
||||
/// Меняя здесь, менять и там.
|
||||
///
|
||||
/// Номер стоит ПЕРЕД суффиксом, потому что имя списка согласования и портал, и
|
||||
/// админка выводят из имени списка данных регексом, привязанным к концу строки.
|
||||
/// </summary>
|
||||
internal static class ListTitleConvention
|
||||
{
|
||||
private const string DataSuffix = "данные";
|
||||
private const string ApprovalSuffix = "согласование";
|
||||
|
||||
private static readonly Regex TitlePattern = new(
|
||||
@"^(?<base>.+?)\s*—\s*(?<suffix>данные|согласование)\s*$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex TrailingNumber = new(@"\s+\d+$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Имя того же списка с номером <paramref name="number"/>. Уже имеющийся номер
|
||||
/// заменяется, а не дописывается вторым.
|
||||
/// </summary>
|
||||
public static string WithNumber(string title, int number)
|
||||
{
|
||||
var match = TitlePattern.Match(title);
|
||||
if (!match.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"Имя списка «{title}» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда.");
|
||||
|
||||
var baseName = TrailingNumber.Replace(match.Groups["base"].Value, string.Empty);
|
||||
return $"{baseName} {number} — {match.Groups["suffix"].Value}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Имя парного списка согласования по имени списка данных (номер сохраняется).
|
||||
/// null — имя не по конвенции либо это не список данных.
|
||||
/// </summary>
|
||||
public static string? ToApprovalTitle(string dataListTitle)
|
||||
{
|
||||
var match = TitlePattern.Match(dataListTitle);
|
||||
if (!match.Success || match.Groups["suffix"].Value != DataSuffix) return null;
|
||||
|
||||
return $"{match.Groups["base"].Value} — {ApprovalSuffix}";
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,45 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список.</summary>
|
||||
public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount);
|
||||
|
||||
/// <summary>Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке.</summary>
|
||||
public sealed record SharePointFieldSchema(string InternalName, string SchemaXml);
|
||||
|
||||
public interface ISharePointClient
|
||||
{
|
||||
/// <summary>Список по серверному относительному url. null — списка нет.</summary>
|
||||
Task<SharePointListInfo?> GetListByUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Список по заголовку. null — списка нет.</summary>
|
||||
Task<SharePointListInfo?> GetListByTitleAsync(
|
||||
string siteUrl, string listTitle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные
|
||||
/// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы:
|
||||
/// они появляются в новом списке сами.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Создать пустой список (GenericList с версионированием) и вернуть его.</summary>
|
||||
Task<SharePointListInfo> CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Создать колонку в списке из её SchemaXml.</summary>
|
||||
Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
||||
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) —
|
||||
/// по нему решается, не пора ли заводить следующий список серии.
|
||||
/// </summary>
|
||||
Task<int?> GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
||||
@@ -19,6 +53,27 @@ public interface ISharePointClient
|
||||
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||
string siteUrl, string listTitle, string odataQuery, 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>
|
||||
@@ -50,6 +105,116 @@ public sealed class SharePointClient(
|
||||
return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
|
||||
}
|
||||
|
||||
public Task<SharePointListInfo?> GetListByUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) =>
|
||||
GetListInfoAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')",
|
||||
cancellationToken);
|
||||
|
||||
public Task<SharePointListInfo?> GetListByTitleAsync(
|
||||
string siteUrl, string listTitle, CancellationToken cancellationToken) =>
|
||||
GetListInfoAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')",
|
||||
cancellationToken);
|
||||
|
||||
private async Task<SharePointListInfo?> GetListInfoAsync(
|
||||
string siteUrl, string listUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder";
|
||||
var json = await TryGetAsync(siteUrl, url, cancellationToken);
|
||||
if (json is null) return null;
|
||||
|
||||
var value = json.Value;
|
||||
var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder)
|
||||
&& rootFolder.TryGetProperty("ServerRelativeUrl", out var relative)
|
||||
? relative.GetString()
|
||||
: null;
|
||||
|
||||
if (serverRelativeUrl is null) return null;
|
||||
|
||||
return new SharePointListInfo(
|
||||
value.GetProperty("Id").GetGuid(),
|
||||
value.GetProperty("Title").GetString() ?? string.Empty,
|
||||
serverRelativeUrl,
|
||||
value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
// Ни FromBaseType, ни CanBeDeleted не годятся как признак «колонку явно
|
||||
// добавили в список»: у переиспользуемых стандартных site-колонок SharePoint
|
||||
// (например, «Department» — встроенная текстовая колонка из схемы v3,
|
||||
// подключаемая через DelayActivateTemplateBinding) ОБА флага стоят как у
|
||||
// настоящего системного поля, хотя колонка реально добавлена в список и в
|
||||
// неё пишут данные. Из-за этого такие колонки тихо выпадали из копирования —
|
||||
// без ошибки, просто без предупреждения.
|
||||
//
|
||||
// Поэтому здесь фильтруем только Hidden/ReadOnlyField (их создать всё равно
|
||||
// нельзя), а отделять настоящие базовые поля (Title, ID, ContentType …) от
|
||||
// явно добавленных — задача вызывающего кода: он сравнивает с полями
|
||||
// ТОЛЬКО ЧТО СОЗДАННОГО пустого списка того же шаблона (см. CloneAsync,
|
||||
// alreadyThere) — то, что есть на обоих, действительно базовое; то, что
|
||||
// есть только на источнике, добавлено явно, независимо от флагов SharePoint.
|
||||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" +
|
||||
"?$select=InternalName,SchemaXml,Hidden,ReadOnlyField" +
|
||||
"&$filter=Hidden eq false and ReadOnlyField eq false";
|
||||
|
||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||
var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? value.EnumerateArray()
|
||||
: json.EnumerateArray();
|
||||
|
||||
var fields = new List<SharePointFieldSchema>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null;
|
||||
var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null;
|
||||
if (internalName is null || schemaXml is null) continue;
|
||||
|
||||
fields.Add(new SharePointFieldSchema(internalName, schemaXml));
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
public async Task<SharePointListInfo> CreateListAsync(
|
||||
string siteUrl, string title, CancellationToken cancellationToken)
|
||||
{
|
||||
// BaseTemplate 100 — GenericList, как -Template GenericList у провижинера.
|
||||
await PostAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists",
|
||||
JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }),
|
||||
cancellationToken);
|
||||
|
||||
// Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url —
|
||||
// дочитываем список отдельным запросом.
|
||||
return await GetListByTitleAsync(siteUrl, title, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно.");
|
||||
}
|
||||
|
||||
public Task AddFieldFromXmlAsync(
|
||||
string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) =>
|
||||
PostAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml",
|
||||
// Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить
|
||||
// InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается
|
||||
// код портала именно по внутреннему имени, так что это не косметика.
|
||||
JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }),
|
||||
cancellationToken);
|
||||
|
||||
public async Task<int?> GetItemCountAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount";
|
||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||
return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : null;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
||||
GetItemsAsync(
|
||||
@@ -64,6 +229,89 @@ public sealed class SharePointClient(
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
||||
cancellationToken);
|
||||
|
||||
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(
|
||||
string siteUrl, string url, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -79,9 +327,33 @@ public sealed class SharePointClient(
|
||||
: [];
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetAsync(string siteUrl, string url, CancellationToken cancellationToken)
|
||||
private async Task<JsonElement> GetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
|
||||
await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken)
|
||||
?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}.");
|
||||
|
||||
/// <summary>GET, для которого «нет такого списка» — не ошибка, а null.</summary>
|
||||
private Task<JsonElement?> TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
|
||||
SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken);
|
||||
|
||||
private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) =>
|
||||
SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken);
|
||||
|
||||
private async Task<JsonElement?> SendAsync(
|
||||
HttpMethod method,
|
||||
string siteUrl,
|
||||
string url,
|
||||
string? body,
|
||||
bool allowNotFound,
|
||||
CancellationToken cancellationToken,
|
||||
bool merge = false)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, 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(
|
||||
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
|
||||
@@ -89,14 +361,30 @@ public sealed class SharePointClient(
|
||||
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
|
||||
});
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new StringContent(body);
|
||||
// odata=nometadata и на запись: тогда в теле не нужен блок __metadata с
|
||||
// типом сущности — SharePoint выводит тип из самого эндпоинта.
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json")
|
||||
{
|
||||
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
|
||||
};
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException(
|
||||
$"SharePoint ответил {(int)response.StatusCode} на {url}: {body}");
|
||||
$"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}");
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
if (string.IsNullOrWhiteSpace(responseBody)) return null;
|
||||
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого
|
||||
/// списка (его пользовательские колонки) и повторяет её в новом списке с
|
||||
/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются —
|
||||
/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что
|
||||
/// реально в списке.
|
||||
///
|
||||
/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки
|
||||
/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а
|
||||
/// не создаёт второй список.
|
||||
/// </summary>
|
||||
public sealed class SharePointListProvisioner(
|
||||
ISharePointClient sharePoint,
|
||||
ISharePointWebhookSubscriber webhookSubscriber,
|
||||
ILogger<SharePointListProvisioner> logger) : ISharePointListProvisioner
|
||||
{
|
||||
public async Task<ProvisionedList> CreateNextListPairAsync(
|
||||
string siteUrl,
|
||||
string sourceDataListId,
|
||||
int number,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Дублирую пару списков №{Number} на {SiteUrl} по образцу {SourceListId}.",
|
||||
number, siteUrl, sourceDataListId);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего.");
|
||||
|
||||
var newData = await CloneAsync(
|
||||
siteUrl,
|
||||
sourceData,
|
||||
ListTitleConvention.WithNumber(sourceData.Title, number),
|
||||
role: "данные",
|
||||
retarget: null,
|
||||
cancellationToken);
|
||||
|
||||
await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).",
|
||||
number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl);
|
||||
|
||||
// Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы:
|
||||
// • подписывать нечего, пока списка нет;
|
||||
// • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку
|
||||
// согласования подписка не нужна;
|
||||
// • в самом конце, а не сразу после создания списка данных: сорвись
|
||||
// дублирование пары на списке согласования, подписка была бы уже
|
||||
// заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же
|
||||
// подписку (идемпотентна ли ручка портала — неизвестно).
|
||||
// Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber).
|
||||
await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, cancellationToken);
|
||||
|
||||
return new ProvisionedList(newData.ServerRelativeUrl, newData.Title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Дублирует парный список согласования. Его не на что сослаться, кроме имени:
|
||||
/// в БД хранится ссылка только на список данных, а согласование ищется по
|
||||
/// конвенции — так же, как это делают портал и админка.
|
||||
/// </summary>
|
||||
private async Task CloneApprovalPairAsync(
|
||||
string siteUrl,
|
||||
SharePointListInfo sourceData,
|
||||
SharePointListInfo newData,
|
||||
int number,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceApprovalTitle = ListTitleConvention.ToApprovalTitle(sourceData.Title);
|
||||
if (sourceApprovalTitle is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Список «{Title}» не по конвенции «… — данные» — парный список согласования не дублируется.",
|
||||
sourceData.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceApproval = await sharePoint.GetListByTitleAsync(siteUrl, sourceApprovalTitle, cancellationToken);
|
||||
if (sourceApproval is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Список согласования «{Title}» не найден — дублируется только список данных.",
|
||||
sourceApprovalTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
await CloneAsync(
|
||||
siteUrl,
|
||||
sourceApproval,
|
||||
ListTitleConvention.WithNumber(sourceApproval.Title, number),
|
||||
role: "согласование",
|
||||
// Ticket списка согласования смотрит на список данных — в дубле он должен
|
||||
// смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок
|
||||
// будут ссылаться в старый список.
|
||||
retarget: (sourceData.Id, newData.Id),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// role — только для логов («данные»/«согласование»): по нему в логе видно, какую
|
||||
/// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов.
|
||||
/// </summary>
|
||||
private async Task<SharePointListInfo> CloneAsync(
|
||||
string siteUrl,
|
||||
SharePointListInfo source,
|
||||
string newTitle,
|
||||
string role,
|
||||
(Guid From, Guid To)? retarget,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await sharePoint.GetListByTitleAsync(siteUrl, newTitle, cancellationToken);
|
||||
var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
existing is null
|
||||
? "Список {Role} «{Title}» создан."
|
||||
: "Список {Role} «{Title}» уже существует — дозаполняю недостающие колонки.",
|
||||
role, newTitle);
|
||||
|
||||
var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken);
|
||||
var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken);
|
||||
var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var copied = 0;
|
||||
foreach (var field in sourceFields)
|
||||
{
|
||||
if (alreadyThere.Contains(field.InternalName)) continue;
|
||||
|
||||
var schemaXml = retarget is null
|
||||
? field.SchemaXml
|
||||
: RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To);
|
||||
|
||||
await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken);
|
||||
copied++;
|
||||
|
||||
// По каждой колонке — только на Debug: при штатной первой партии их
|
||||
// десятки на список, и на Information это был бы шум. Итог всё равно
|
||||
// виден в сводке ниже.
|
||||
logger.LogDebug(
|
||||
"Список {Role} «{Title}»: скопирована колонка {InternalName}.", role, newTitle, field.InternalName);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Список {Role} «{Title}»: скопировано колонок {Copied}, уже было {AlreadyThere}.",
|
||||
role, newTitle, copied, sourceFields.Count - copied);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перенацеливает Lookup на новый список данных.
|
||||
///
|
||||
/// Целевой список Lookup хранит в SchemaXml GUID'ом (атрибут List), поэтому
|
||||
/// подменяем именно GUID. Подменяем ТОЛЬКО ссылки на исходный список данных:
|
||||
/// кросс-формные lookup'ы (например, «организация рабочего места» ссылается на
|
||||
/// список «приема на работу») должны и дальше смотреть на список ЧУЖОЙ формы.
|
||||
/// </summary>
|
||||
private static string RetargetLookup(string schemaXml, Guid from, Guid to)
|
||||
{
|
||||
var fromText = from.ToString("D");
|
||||
return schemaXml.Contains(fromText, StringComparison.OrdinalIgnoreCase)
|
||||
? schemaXml.Replace(fromText, to.ToString("D"), StringComparison.OrdinalIgnoreCase)
|
||||
: schemaXml;
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,15 @@ public sealed class SharePointOptions
|
||||
/// это здесь не стоит — пароль от сертификата в исходниках не место.
|
||||
/// </summary>
|
||||
public string CertificatePassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Куда SharePoint будет присылать уведомления об изменениях списка — параметр
|
||||
/// notificationUrl ручки POST /Webhook/Subscribe, которой новый список данных
|
||||
/// подписывается на уведомления (см. HttpSharePointWebhookSubscriber).
|
||||
/// Это приёмник на СТОРОНЕ ПОРТАЛА, а не адрес нашего сервиса.
|
||||
///
|
||||
/// Пусто — подписывать не будем вовсе: без адреса приёмника подписка
|
||||
/// бессмысленна, и это же единственный способ её выключить.
|
||||
/// </summary>
|
||||
public string NotificationUrl { get; set; } = "https://api.tnsu.kz/Webhook/SharePoint";
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
/// <summary>
|
||||
/// Кеширующая обёртка над <see cref="HttpTnsuPortalClient"/>. Резолв одного
|
||||
/// маршрута (TemplateRouteService.ResolveAsync) может дёргать справочник
|
||||
/// api.tnsu.kz по многу раз за один запрос — один и тот же согласующий,
|
||||
/// должность или руководитель нередко повторяется в разных шагах длинного
|
||||
/// маршрута, — и без кеша распараллеливание самих шагов резолва не спасает от
|
||||
/// нагрузки на api.tnsu.kz: он просто получает то же число запросов
|
||||
/// одновременно вместо по очереди. Кеш процессный (<see cref="IMemoryCache"/>,
|
||||
/// не распределённый — реплик API несколько, поэтому в них он не общий), TTL
|
||||
/// фиксирован на <see cref="CacheDuration"/>.
|
||||
/// </summary>
|
||||
public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryCache cache) : ITnsuPortalClient
|
||||
{
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"emp:email:{email.ToLowerInvariant()}",
|
||||
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
|
||||
|
||||
public Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
|
||||
() => 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) =>
|
||||
GetOrAddAsync(
|
||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||
() => 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) =>
|
||||
GetOrAddAsync(
|
||||
$"projexec:{projectId}",
|
||||
() => inner.GetProjectExecutorsAsync(projectId, cancellationToken));
|
||||
|
||||
public Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"depthead:{departmentId}",
|
||||
() => inner.GetDepartmentHeadEmailAsync(departmentId, cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// Кеширует и «не нашли» (null / пустой список) — повторять запрос к
|
||||
/// api.tnsu.kz за тем же результатом на каждый одинаковый шаг маршрута
|
||||
/// незачем. Ключ по одному и тому же значению может задеть гонку: два
|
||||
/// конкурентных шага (см. Task.WhenAll в ResolveAsync) с одинаковым ключом
|
||||
/// оба не найдут его в кеше и оба сходят в API — не страшно, это лишний
|
||||
/// один поход, а не порча данных; блокировка per-key усложнила бы код ради
|
||||
/// выигрыша, который при TTL в минуты и так почти всегда есть без неё.
|
||||
/// </summary>
|
||||
private async Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory)
|
||||
{
|
||||
if (cache.TryGetValue(key, out T? cached))
|
||||
return cached!;
|
||||
|
||||
var value = await factory();
|
||||
cache.Set(key, value, CacheDuration);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
/// <summary>
|
||||
/// POST {BaseUrl}/Webhook/Subscribe?listName=…&url=…&notificationUrl=… — просит
|
||||
/// портал завести подписку SharePoint на только что созданный список данных.
|
||||
///
|
||||
/// Параметры уходят строкой запроса, тела у запроса нет — так описана ручка.
|
||||
///
|
||||
/// Отдельный клиент, а не метод в ITnsuPortalClient: тот про справочник сотрудников
|
||||
/// и обёрнут кешем (CachingTnsuPortalClient), а здесь команда, которую кешировать
|
||||
/// нельзя ни в каком виде. Авторизация общая с ним — см. TnsuPortalAuthorizer.
|
||||
///
|
||||
/// Адрес приёмника уведомлений живёт в SharePoint:NotificationUrl — в одном блоке с
|
||||
/// остальными настройками SharePoint, а не в своём: подписка относится к спискам,
|
||||
/// а не к порталу как таковому.
|
||||
///
|
||||
/// Ошибки НЕ пробрасываются наружу. Список к этому моменту уже создан, и уронить
|
||||
/// провижининг из-за недоступности портала значило бы оставить партиционирование
|
||||
/// нерабочим целиком (задача Hangfire ушла бы в повторы, а порог по элементам
|
||||
/// продолжал бы срабатывать). Поэтому сбой — Error в лог с полным контекстом
|
||||
/// (имя списка и сайт видны, подписку можно завести руками), а провижининг идёт
|
||||
/// дальше. Отдельно предупреждаем про 401/403: в фоновой задаче нет входящего
|
||||
/// токена, а client_credentials требует заполненных TnsuPortal:ClientSecret/Scope
|
||||
/// (в конфигурации они пустые — см. TnsuPortalAuthorizer).
|
||||
/// </summary>
|
||||
public sealed class HttpSharePointWebhookSubscriber(
|
||||
HttpClient httpClient,
|
||||
TnsuPortalAuthorizer authorizer,
|
||||
IOptions<SharePointOptions> options,
|
||||
ILogger<HttpSharePointWebhookSubscriber> logger) : ISharePointWebhookSubscriber
|
||||
{
|
||||
public async Task<bool> TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken)
|
||||
{
|
||||
var notificationUrl = options.Value.NotificationUrl;
|
||||
if (string.IsNullOrWhiteSpace(notificationUrl))
|
||||
{
|
||||
// Без адреса приёмника подписка бессмысленна — он же служит выключателем
|
||||
// (см. SharePointOptions.NotificationUrl).
|
||||
logger.LogInformation(
|
||||
"SharePoint:NotificationUrl не задан — список «{ListTitle}» на уведомления не подписан.",
|
||||
listTitle);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(listTitle))
|
||||
{
|
||||
// Без имени списка ручке нечего подписывать: у неё нет другого способа
|
||||
// его найти (адрес списка она не принимает).
|
||||
logger.LogError("Заголовок нового списка пуст — подписка на уведомления невозможна.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Полный адрес сайта целиком («https://tnsukz.sharepoint.com/sites/requests»),
|
||||
// а не короткое имя — именно это ждёт ручка.
|
||||
var site = siteUrl.TrimEnd('/');
|
||||
var url =
|
||||
$"/Webhook/Subscribe?listName={Uri.EscapeDataString(listTitle)}" +
|
||||
$"&url={Uri.EscapeDataString(site)}" +
|
||||
$"¬ificationUrl={Uri.EscapeDataString(notificationUrl)}";
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
await authorizer.AuthorizeAsync(request, cancellationToken);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Тело читаем именно на неуспехе — в нём причина отказа портала.
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
logger.LogError(
|
||||
"Портал отказал в подписке списка «{ListTitle}» (сайт {Site}): {StatusCode}. Ответ: {Body}",
|
||||
listTitle, site, (int)response.StatusCode, body);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Список «{ListTitle}» (сайт {Site}) подписан на уведомления SharePoint.", listTitle, site);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Не удалось подписать список «{ListTitle}» (сайт {Site}) на уведомления — подпишите вручную.",
|
||||
listTitle, site);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,67 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
public sealed class HttpTnsuPortalClient(
|
||||
HttpClient httpClient,
|
||||
TnsuPortalTokenProvider tokenProvider,
|
||||
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
|
||||
TnsuPortalAuthorizer authorizer) : ITnsuPortalClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var employee = await GetAsync<EmployeeResponse>(
|
||||
var employees = await GetAsync<List<EmployeeResponse>>(
|
||||
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
|
||||
if (employee is null)
|
||||
return null;
|
||||
if (employees is null)
|
||||
return [];
|
||||
|
||||
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
|
||||
return employees
|
||||
.Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position, e.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
|
||||
public async Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
|
||||
{
|
||||
var employee = await GetAsync<EmployeeResponse>(
|
||||
$"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken);
|
||||
|
||||
return employee?.Email;
|
||||
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)
|
||||
@@ -41,6 +72,18 @@ public sealed class HttpTnsuPortalClient(
|
||||
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)
|
||||
{
|
||||
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
||||
@@ -73,7 +116,7 @@ public sealed class HttpTnsuPortalClient(
|
||||
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeAsync(request, cancellationToken);
|
||||
await authorizer.AuthorizeAsync(request, cancellationToken);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
@@ -89,29 +132,28 @@ public sealed class HttpTnsuPortalClient(
|
||||
executors.Add(new ProjectExecutorInfo(role, email));
|
||||
}
|
||||
|
||||
private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(incoming))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", incoming);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
private sealed record EmployeeResponse(
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("company")] string? Company,
|
||||
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
||||
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
|
||||
[property: JsonPropertyName("position")] string? Position);
|
||||
[property: JsonPropertyName("position")] string? Position,
|
||||
[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(
|
||||
[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(
|
||||
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
||||
[property: JsonPropertyName("planner")] string? Planner,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
/// <summary>
|
||||
/// Подписывает список данных SharePoint на уведомления об изменениях через портал.
|
||||
/// </summary>
|
||||
public interface ISharePointWebhookSubscriber
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписать список. НЕ бросает: подписка — следствие создания списка, а не его
|
||||
/// условие (см. реализацию), поэтому сбой портала не должен рушить провижининг
|
||||
/// партиции. Возвращает true, только если портал подтвердил подписку.
|
||||
/// </summary>
|
||||
/// <param name="siteUrl">Абсолютный адрес сайта, где создан список.</param>
|
||||
/// <param name="listTitle">Заголовок списка данных — русское имя, как в SharePoint.</param>
|
||||
Task<bool> TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -2,12 +2,39 @@ namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
public interface ITnsuPortalClient
|
||||
{
|
||||
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Все записи сотрудника по email — /Employee/ByEmail отдаёт массив: сотрудник может
|
||||
/// числиться в нескольких компаниях одновременно (один Id/Fio/Email, разные Company/
|
||||
/// CompanyId/Section/DepartmentId/Position на каждую). Пусто, если не найден вовсе.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
Task<string?> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Весь справочник сотрудников — /Employee/FullBase. Единственный источник, где
|
||||
/// есть дата увольнения (DismissalDate): нужен, чтобы не напоминать про
|
||||
/// испытательный срок сотрудника, который уже уволен.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
/// <summary>
|
||||
/// Авторизация запросов к api.tnsu.kz. Вынесена из HttpTnsuPortalClient, потому что
|
||||
/// к порталу ходит не только справочник сотрудников (см.
|
||||
/// HttpSharePointWebhookSubscriber), а правило выбора токена должно быть одно.
|
||||
///
|
||||
/// Токен берётся в двух режимах, и порядок принципиален:
|
||||
/// 1) Токен вызывающего пользователя (см. TnsuPortalCallerToken) — из текущего
|
||||
/// HTTP-запроса либо переданный аргументом в фоновую задачу. Портал тогда
|
||||
/// видит исходного пользователя, а не сервисное приложение.
|
||||
/// 2) Его нет вовсе — client_credentials самого сервиса (см.
|
||||
/// TnsuPortalTokenProvider).
|
||||
///
|
||||
/// ВАЖНО про второй путь: TnsuPortal:ClientSecret/Scope в конфигурации пусты
|
||||
/// (см. appsettings.json), поэтому он сейчас нерабочий — это именно фолбэк, а не
|
||||
/// штатный режим. Вызывающая сторона обязана это учитывать: см.
|
||||
/// HttpSharePointWebhookSubscriber, где сбой подписки намеренно не рушит
|
||||
/// провижининг списка.
|
||||
/// </summary>
|
||||
public sealed class TnsuPortalAuthorizer(
|
||||
TnsuPortalCallerToken callerToken,
|
||||
TnsuPortalTokenProvider tokenProvider)
|
||||
{
|
||||
public async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var caller = callerToken.Current;
|
||||
if (!string.IsNullOrWhiteSpace(caller))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", caller);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization, с которым идти в api.tnsu.kz в пределах текущей области DI.
|
||||
///
|
||||
/// Два источника, и второй нужен именно фоновым задачам:
|
||||
/// • обрабатывается HTTP-запрос — заголовок берётся прямо из него;
|
||||
/// • выполняется задача Hangfire — своего HttpContext'а у неё нет, поэтому
|
||||
/// токен инициировавшего пользователя ей передают аргументом и кладут сюда
|
||||
/// через <see cref="Set"/> (см. CreateNextListPartitionJob).
|
||||
///
|
||||
/// Scoped: Hangfire создаёт отдельную область DI на каждое выполнение задачи,
|
||||
/// поэтому положенный здесь токен не протекает между задачами.
|
||||
/// </summary>
|
||||
public sealed class TnsuPortalCallerToken(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
private string? _explicit;
|
||||
|
||||
/// <summary>
|
||||
/// Заголовок целиком, вида «Bearer …». null — вызывающего токена нет
|
||||
/// (тогда авторизация уходит на client_credentials, см. TnsuPortalAuthorizer).
|
||||
/// </summary>
|
||||
public string? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
|
||||
return string.IsNullOrWhiteSpace(incoming) ? _explicit : incoming;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Положить токен явно — для кода без HttpContext (фоновые задачи).</summary>
|
||||
public void Set(string? authorization) =>
|
||||
_explicit = string.IsNullOrWhiteSpace(authorization) ? null : authorization;
|
||||
}
|
||||
@@ -2,6 +2,38 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary>
|
||||
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);
|
||||
|
||||
+353
@@ -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("Отклонено");
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// request_list_partitions хранит ТОЛЬКО архив — партиция, которая ни разу не
|
||||
/// архивировалась, не должна получать строку просто от того, что её прочитали
|
||||
/// (см. ListPartitionRegistry). Раньше самозаполняющийся реестр писал строку
|
||||
/// уже на первое чтение, из-за чего один и тот же список оказывался записан
|
||||
/// дважды: и в RequestType.ListId, и строкой в этой таблице.
|
||||
/// </summary>
|
||||
public sealed class ListPartitionRegistryTests(PostgresDatabaseFixture fixture) : IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private readonly ApprovalRouteDbContext _db =
|
||||
TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService<ApprovalRouteDbContext>();
|
||||
|
||||
private ListPartitionRegistry BuildRegistry() =>
|
||||
new(_db, Mock.Of<ISharePointClient>(), NullLogger<ListPartitionRegistry>.Instance);
|
||||
|
||||
private async Task<RequestType> CreateTypeAsync(string listId)
|
||||
{
|
||||
var type = new RequestType
|
||||
{
|
||||
Name = $"Тест {Guid.NewGuid():N}",
|
||||
PageUrl = $"/test-{Guid.NewGuid():N}",
|
||||
ListId = listId,
|
||||
SiteUrl = "https://tnsukz.sharepoint.com/sites/requests",
|
||||
Section = "General"
|
||||
};
|
||||
_db.RequestTypes.Add(type);
|
||||
await _db.SaveChangesAsync();
|
||||
return type;
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Партиция, которую ни разу не архивировали, не пишется в request_list_partitions")]
|
||||
public async Task BuildCurrentAsync_NeverArchived_DoesNotWriteRow()
|
||||
{
|
||||
// Arrange
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
|
||||
// Action
|
||||
var archived = await registry.GetArchivedAsync(type, CancellationToken.None);
|
||||
var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
archived.ShouldBeEmpty();
|
||||
current.Number.ShouldBe(1);
|
||||
current.ListId.ShouldBe(type.ListId);
|
||||
current.IsCurrent.ShouldBeTrue();
|
||||
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.CountAsync();
|
||||
rowsInDb.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ArchiveCurrentAsync пишет ровно одну строку архива под номером 1")]
|
||||
public async Task ArchiveCurrentAsync_WritesExactlyOneRow()
|
||||
{
|
||||
// Arrange
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
|
||||
// Action
|
||||
var archived = await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
archived.Number.ShouldBe(1);
|
||||
archived.ListId.ShouldBe("/sites/requests/Lists/1");
|
||||
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.ToListAsync();
|
||||
rowsInDb.Count.ShouldBe(1);
|
||||
rowsInDb[0].ListId.ShouldBe("/sites/requests/Lists/1");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ArchiveCurrentAsync идемпотентна — повтор не создаёт вторую строку")]
|
||||
public async Task ArchiveCurrentAsync_CalledTwice_DoesNotDuplicate()
|
||||
{
|
||||
// Arrange
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
|
||||
// Action — как повторная попытка Hangfire после частичного сбоя: пока указатель
|
||||
// ещё не переключён, GetArchivedAsync только что записанную строку не увидит
|
||||
// (фильтрует по ListId != RequestType.ListId) — именно поэтому идемпотентность
|
||||
// ArchiveCurrentAsync не должна зависеть от него (см. её doc-комментарий).
|
||||
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.CountAsync();
|
||||
rowsInDb.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "После архивации и переключения текущая партиция получает следующий номер")]
|
||||
public async Task BuildCurrentAsync_AfterArchiving_ReturnsNextNumber()
|
||||
{
|
||||
// Arrange: тип с одной партицией, которую сейчас архивируем (как
|
||||
// CreateNextListPartitionJob перед переключением на следующую).
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
|
||||
// Action: переключаем указатель на новую партицию (сама она архивной
|
||||
// строки ещё не получает — см. doc-комментарий ListPartitionRegistry).
|
||||
type.ListId = "/sites/requests/Lists/2";
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var archived = await registry.GetArchivedAsync(type, CancellationToken.None);
|
||||
var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
archived.Count.ShouldBe(1);
|
||||
archived[0].ListId.ShouldBe("/sites/requests/Lists/1");
|
||||
current.Number.ShouldBe(2);
|
||||
current.ListId.ShouldBe("/sites/requests/Lists/2");
|
||||
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.CountAsync();
|
||||
rowsInDb.ShouldBe(1); // новая партиция строки ещё не получила
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать
|
||||
/// по партиции каждая, поэтому проверяется именно на живом Postgres: смысл здесь
|
||||
/// несёт атомарность условного UPDATE, а не код на C#.
|
||||
/// </summary>
|
||||
public sealed class ListPartitioningLockTests(PostgresDatabaseFixture fixture)
|
||||
: IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly ServiceProvider _provider = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||
|
||||
[Fact(DisplayName = "Повторный захват занятой блокировки не удаётся")]
|
||||
public async Task TryAcquire_WhenAlreadyHeld_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
|
||||
// Action
|
||||
var first = await TryAcquireAsync(requestTypeId);
|
||||
var second = await TryAcquireAsync(requestTypeId);
|
||||
|
||||
// Assert
|
||||
first.ShouldBeTrue();
|
||||
second.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "При одновременном захвате блокировку получает ровно одна задача")]
|
||||
public async Task TryAcquire_Concurrently_OnlyOneWins()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
|
||||
// Action — каждый захват из своего scope, то есть своим соединением к БД:
|
||||
// именно так это и происходит у параллельных задач Hangfire.
|
||||
var results = await Task.WhenAll(
|
||||
Enumerable.Range(0, 8).Select(_ => TryAcquireAsync(requestTypeId)));
|
||||
|
||||
// Assert
|
||||
results.Count(x => x).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "После снятия блокировку можно захватить снова")]
|
||||
public async Task TryAcquire_AfterRelease_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||
|
||||
// Action
|
||||
using (var scope = _provider.CreateScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<ListPartitioningLock>()
|
||||
.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Просроченная блокировка перехватывается, свежая — нет")]
|
||||
public async Task TryAcquire_StaleLock_IsTakenOver()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||
|
||||
// Свежую блокировку с тем же порогом перехватить нельзя.
|
||||
(await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeFalse();
|
||||
|
||||
// Action — отодвигаем метку в прошлое: так выглядит блокировка, которую
|
||||
// оставил упавший, не снявший её процесс.
|
||||
using (var scope = _provider.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
||||
await db.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"UPDATE request_types SET list_partitioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}");
|
||||
}
|
||||
|
||||
// Assert
|
||||
(await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Захват блокировки для несуществующего типа заявки не удаётся")]
|
||||
public async Task TryAcquire_UnknownRequestType_ReturnsFalse() =>
|
||||
(await TryAcquireAsync(999999)).ShouldBeFalse();
|
||||
|
||||
private async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null)
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ListPartitioningLock>()
|
||||
.TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task<int> CreateRequestTypeAsync()
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
||||
|
||||
var entity = new RequestType
|
||||
{
|
||||
Name = $"Тип {Guid.NewGuid():N}",
|
||||
PageUrl = "/test",
|
||||
Section = "General"
|
||||
};
|
||||
db.RequestTypes.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+423
-94
@@ -1,21 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA:
|
||||
/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент
|
||||
/// выводится из соседних строк — это самая хрупкая часть эндпоинта.
|
||||
/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки
|
||||
/// (колонка Date — request-portal проставляет её в момент, когда строка
|
||||
/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше
|
||||
/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты
|
||||
/// и проверяют вместо прежнего вывода даты из соседних строк.
|
||||
///
|
||||
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||||
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
|
||||
@@ -26,8 +31,28 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
private const string DataListTitle = "Заявка тест — данные";
|
||||
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);
|
||||
|
||||
// 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 RequestTypeService _requestTypeService;
|
||||
|
||||
@@ -50,16 +75,32 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
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 object Row(
|
||||
int id, int ticketId, int stepOrder, string status, string phase,
|
||||
string? date = null, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
||||
int id, int ticketId, string status, string phase,
|
||||
string? date, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
||||
new
|
||||
{
|
||||
Id = id,
|
||||
TicketId = ticketId,
|
||||
StepOrder = stepOrder,
|
||||
ApprovalStatus = status,
|
||||
Phase = phase,
|
||||
Date = date,
|
||||
@@ -68,8 +109,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
TansuSiteApprover = new { EMail = email }
|
||||
};
|
||||
|
||||
private ReminderService BuildService(
|
||||
string listUrl, IReadOnlyList<object> allRows, DateTimeOffset requestCreated)
|
||||
// Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) —
|
||||
// мок отвечает так же: по значению статуса, а не по признаку решённости.
|
||||
// Никаких запросов ни по TicketId, ни к списку данных за Created сервис
|
||||
// больше не делает — если он вдруг снова начнёт, немонастроенный мок
|
||||
// (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой,
|
||||
// а не тихо проигнорирует лишний запрос.
|
||||
private ReminderService BuildService(string listUrl, IReadOnlyList<object> rows)
|
||||
{
|
||||
var sharePoint = new Mock<ISharePointClient>();
|
||||
|
||||
@@ -77,9 +123,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DataListTitle);
|
||||
|
||||
// Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
|
||||
// отвечает так же: по значению статуса, а не по признаку решённости.
|
||||
var activeRows = allRows
|
||||
var activeRows = rows
|
||||
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
||||
"На согласовании" or "На исполнении")
|
||||
.ToList();
|
||||
@@ -88,109 +132,108 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(activeRows.Select(Json).ToList());
|
||||
|
||||
// RequestUniqueCode запрашивается по списку данных сразу для всех активных
|
||||
// заявок — по умолчанию тесты его не проверяют, поэтому мок отдаёт пусто.
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListTitleAsync(
|
||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(allRows.Select(Json).ToList());
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]);
|
||||
.Setup(x => x.GetItemsByListUrlAsync(
|
||||
SiteUrl, listUrl, It.Is<string>(q => q.Contains("RequestUniqueCode")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
return new ReminderService(
|
||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||
sharePoint.Object,
|
||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||
Mock.Of<ITnsuPortalClient>(),
|
||||
Clock,
|
||||
Options.Create(new RemindersOptions()),
|
||||
NullLogger<ReminderService>.Instance);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")]
|
||||
public async Task FirstApprovalStep_CountsFromRequestCreated()
|
||||
[Fact(DisplayName = "SLA отсчитывается от Date активной строки")]
|
||||
public async Task ActiveRow_CountsFromOwnDate()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24);
|
||||
// Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24);
|
||||
// Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-48));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>();
|
||||
reminder.Email.ShouldBe("approver@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(24);
|
||||
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 = "Срок ещё не вышел — напоминания нет")]
|
||||
public async Task WithinSla_ReturnsNothing()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-2));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-2).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Второй шаг: SLA отсчитывается от решения предыдущего шага, а не от создания заявки")]
|
||||
public async Task SecondStep_CountsFromPreviousStepDecision()
|
||||
[Fact(DisplayName = "Другие строки того же маршрута сервису не нужны — читается только активная")]
|
||||
public async Task OtherRoutRows_AreIrrelevant()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla second step", slaHours: 10);
|
||||
// Заявка создана давно, но предыдущий шаг решён 12 часов назад:
|
||||
// при отсчёте от создания просрочка была бы огромной, верная — 2 часа.
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla ignores history", slaHours: 10);
|
||||
// Решённые строки маршрута заведомо не попадут в ответ на $filter по
|
||||
// ApprovalStatus (мок это тоже соблюдает) — но даже будь они прочитаны,
|
||||
// сервис их дату уже не использует: отсчёт идёт только от Date
|
||||
// активной строки.
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
||||
Row(11, 1, 2, "На согласовании", "Согласование", email: "second@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
Row(10, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
||||
Row(11, 1, "На согласовании", "Согласование", date: Now.AddHours(-12).ToString("O"))
|
||||
]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
reminder.Email.ShouldBe("second@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(2);
|
||||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Параллельный предыдущий шаг: отсчёт от ПОСЛЕДНЕГО решения, а не от первого")]
|
||||
public async Task ParallelPreviousStep_CountsFromLastDecision()
|
||||
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
||||
public async Task ActiveRow_WithoutDate_IsSkipped()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla parallel", slaHours: 10);
|
||||
// Двое делят StepOrder = 1. Следующий шаг активируется только после
|
||||
// решения второго (-12 ч), а не первого (-40 ч).
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla missing date", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
||||
Row(11, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
||||
Row(12, 1, 2, "На согласовании", "Согласование", email: "next@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: null)]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Первый шаг исполнения: отсчёт от последнего решения фазы согласования")]
|
||||
public async Task FirstExecutionStep_CountsFromLastApprovalDecision()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla execution", slaHours: 5);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-30).ToString("O")),
|
||||
Row(11, 1, 2, "Согласовано", "Согласование", date: Now.AddHours(-8).ToString("O")),
|
||||
Row(12, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
reminder.Email.ShouldBe("executor@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(3);
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
|
||||
@@ -199,12 +242,11 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla row override", slaHours: 100);
|
||||
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1)],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1)]);
|
||||
|
||||
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»), и на этом
|
||||
@@ -220,12 +262,11 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
|
||||
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
|
||||
// воспроизводит.
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1.0m)],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1.0m)]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
|
||||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
|
||||
@@ -233,30 +274,29 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().AskOverdueReason.ShouldBeTrue();
|
||||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().AskOverdueReason.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
|
||||
// живым примером:
|
||||
// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11
|
||||
// живым примером (см. docs/routing.md в request-portal):
|
||||
// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11&rplist=%2Fsites%2Frequests%2FLists%2Fit-1c-ticket
|
||||
[Fact(DisplayName = "Ссылка на заявку собирается в том же виде, что и в портале")]
|
||||
public async Task Url_MatchesPortalFormat()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 11, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 11, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().Url.ShouldBe(
|
||||
"https://tnsukz.sharepoint.com/sites/requests" +
|
||||
"?rpnav=approvals&rpmode=approve&rptype=/it-1c-ticket&rpid=11");
|
||||
"?rpnav=approvals&rpmode=approve&rptype=/it-1c-ticket&rpid=11" +
|
||||
"&rplist=%2Fsites%2Frequests%2FLists%2Fit-1c-ticket");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Просроченный шаг исполнения ведёт в раздел «На исполнении», а не в согласование")]
|
||||
@@ -264,11 +304,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla exec nav", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
||||
Row(11, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
[Row(10, 1, "На исполнении", "Исполнение", date: Now.AddHours(-6).ToString("O"), email: "executor@tnsu.kz")]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
@@ -281,13 +317,306 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla queued", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "На согласовании", "Согласование"),
|
||||
Row(11, 1, 2, "Ждёт очереди", "Согласование", email: "waiting@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
||||
Row(11, 1, "Ждёт очереди", "Согласование", date: null, email: "waiting@tnsu.kz")
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe).
|
||||
///
|
||||
/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка
|
||||
/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит
|
||||
/// провижининг — подписка следствие создания списка, а не его условие.
|
||||
/// </summary>
|
||||
public sealed class SharePointWebhookSubscriberTests
|
||||
{
|
||||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||
private const string ListTitle = "Обходной лист 3 — данные";
|
||||
|
||||
/// <summary>Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ.</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? Captured { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Captured = request;
|
||||
CallCount++;
|
||||
return Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
// Токен вызывающего есть — тогда авторизатор пробрасывает его и не идёт за
|
||||
// client_credentials (у которого в тестах нет ни секрета, ни сети).
|
||||
// fromHttpContext=false — путь фоновой задачи: токен положен явно, HttpContext'а
|
||||
// нет вовсе (см. TnsuPortalCallerToken.Set / CreateNextListPartitionJob).
|
||||
private static TnsuPortalAuthorizer BuildAuthorizer(bool fromHttpContext = true)
|
||||
{
|
||||
var accessor = new HttpContextAccessor();
|
||||
var callerToken = new TnsuPortalCallerToken(accessor);
|
||||
|
||||
if (fromHttpContext)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = "Bearer test-token";
|
||||
accessor.HttpContext = context;
|
||||
}
|
||||
else
|
||||
{
|
||||
callerToken.Set("Bearer test-token");
|
||||
}
|
||||
|
||||
return new TnsuPortalAuthorizer(
|
||||
callerToken,
|
||||
new TnsuPortalTokenProvider(new HttpClient(), Options.Create(new TnsuPortalOptions())));
|
||||
}
|
||||
|
||||
private static (HttpSharePointWebhookSubscriber Subscriber, StubHandler Handler) Build(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> respond,
|
||||
SharePointOptions? options = null,
|
||||
bool tokenFromHttpContext = true)
|
||||
{
|
||||
var handler = new StubHandler(respond);
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.tnsu.kz") };
|
||||
|
||||
var subscriber = new HttpSharePointWebhookSubscriber(
|
||||
httpClient,
|
||||
BuildAuthorizer(tokenFromHttpContext),
|
||||
Options.Create(options ?? new SharePointOptions()),
|
||||
NullLogger<HttpSharePointWebhookSubscriber>.Instance);
|
||||
|
||||
return (subscriber, handler);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Ok() => new(System.Net.HttpStatusCode.OK);
|
||||
|
||||
[Fact(DisplayName = "Подписка уходит POST'ом со всеми тремя параметрами в строке запроса")]
|
||||
public async Task TrySubscribeAsync_SendsPostWithQueryParameters()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, handler) = Build(_ => Ok());
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeTrue();
|
||||
handler.Captured!.Method.ShouldBe(HttpMethod.Post);
|
||||
handler.Captured.Content.ShouldBeNull(); // тела у ручки нет
|
||||
|
||||
var query = Uri.UnescapeDataString(handler.Captured.RequestUri!.Query);
|
||||
handler.Captured.RequestUri.AbsolutePath.ShouldBe("/Webhook/Subscribe");
|
||||
query.ShouldContain($"listName={ListTitle}");
|
||||
query.ShouldContain("notificationUrl=https://api.tnsu.kz/Webhook/SharePoint");
|
||||
// Токен из входящего запроса пробрасывается как есть (см. TnsuPortalAuthorizer).
|
||||
handler.Captured.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "В url уходит ПОЛНЫЙ адрес сайта, а не короткое имя")]
|
||||
public async Task TrySubscribeAsync_SendsFullSiteUrl()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, handler) = Build(_ => Ok());
|
||||
|
||||
// Action
|
||||
await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var query = Uri.UnescapeDataString(handler.Captured!.RequestUri!.Query);
|
||||
query.ShouldContain($"url={SiteUrl}");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Токен фоновой задачи (без HttpContext) тоже уходит в заголовок")]
|
||||
public async Task TrySubscribeAsync_TokenSetExplicitly_IsSentAsAuthorization()
|
||||
{
|
||||
// Arrange: так работает провижининг — HttpContext'а нет, токен передан
|
||||
// аргументом задачи (см. CreateNextListPartitionJob).
|
||||
var (subscriber, handler) = Build(_ => Ok(), tokenFromHttpContext: false);
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeTrue();
|
||||
handler.Captured!.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Отказ портала не бросает исключение — провижининг списка не рушится")]
|
||||
public async Task TrySubscribeAsync_PortalRejects_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, _) = Build(_ => new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Content = new StringContent("no token")
|
||||
});
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Недоступный портал (исключение в сети) тоже не бросает наружу")]
|
||||
public async Task TrySubscribeAsync_NetworkFailure_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, _) = Build(_ => throw new HttpRequestException("портал недоступен"));
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "NotificationUrl не задан — запрос не отправляется вовсе")]
|
||||
public async Task TrySubscribeAsync_NoNotificationUrl_DoesNotCallPortal()
|
||||
{
|
||||
// Arrange: пустой адрес приёмника — единственный выключатель подписки
|
||||
// (отдельного флага Enabled нет, см. SharePointOptions.NotificationUrl).
|
||||
var (subscriber, handler) = Build(_ => Ok(), new SharePointOptions { NotificationUrl = "" });
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
handler.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Пустой заголовок списка — подписывать нечего, запроса нет")]
|
||||
public async Task TrySubscribeAsync_EmptyListTitle_DoesNotCallPortal()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, handler) = Build(_ => Ok());
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, " ", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
handler.CallCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user