Compare commits
13 Commits
874e41990c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ffc9b0e422 | |||
| 33d106430c | |||
| f3b0f0ad48 | |||
| 0e6a7064d2 | |||
| 05f73d356a | |||
| 82c2dc36a6 | |||
| b959feedbf | |||
| dcdfcdd396 | |||
| a8bfd9f456 | |||
| 1bf8d4ca11 | |||
| 2b8fe77fac | |||
| e2b6227015 | |||
| 95d460c80a |
@@ -0,0 +1,4 @@
|
|||||||
|
# Стайл-гайд для Клода
|
||||||
|
|
||||||
|
- У `enum` первое значение (0) — всегда `None`.
|
||||||
|
- Сборка не должна выдавать ни одного warning.
|
||||||
@@ -34,8 +34,15 @@
|
|||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageVersion Include="NPOI" Version="2.7.6" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
|
<!--
|
||||||
|
Только чтобы поднять транзитивную версию из NPOI (8.0.2 с известной
|
||||||
|
уязвимостью, см. GHSA-8q5v-6pqq-x66h и соседние advisories той же пачки)
|
||||||
|
— напрямую не используется. Убрать, когда NPOI сам подтянет исправленную.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.11" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders;
|
|||||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Просроченные шаги согласования и исполнения на текущий момент.
|
/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
|
||||||
|
/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
|
||||||
|
/// появления ProbationEnding.
|
||||||
///
|
///
|
||||||
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
||||||
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
||||||
@@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
|
|||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> List(CancellationToken ct) =>
|
public async Task<IActionResult> List(
|
||||||
(await service.GetRemindersAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
|
[FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) =>
|
||||||
|
(await service.GetRemindersAsync(feed, ct)).ToActionResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузки отчётов файлами. В отличие от остальных контроллеров отдаёт не
|
||||||
|
/// ApiResponse, а сам файл: получатель — кнопка «скачать» в портале и Power
|
||||||
|
/// Automate, обоим нужен готовый xlsx, а не JSON с телом в base64.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
|
[Route("api/reports")]
|
||||||
|
public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string XlsxContentType =
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отчёт по вакансиям за период — лист «Список вакансий (все)» по данным
|
||||||
|
/// заявок на подбор и на приём.
|
||||||
|
///
|
||||||
|
/// Период отбирает заявки по «Дате подачи заявки» (колонка H отчёта). Заявки
|
||||||
|
/// на приём периодом НЕ ограничены: у вакансии, поданной в начале интервала,
|
||||||
|
/// приём мог оформляться уже после его конца.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("vacancies")]
|
||||||
|
[Produces(XlsxContentType)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Vacancies(
|
||||||
|
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await vacancies.GetAsync(from, to, ct);
|
||||||
|
if (result.IsFailed)
|
||||||
|
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
||||||
|
|
||||||
|
var report = result.Value;
|
||||||
|
var file = VacancyReportWorkbook.Build(report);
|
||||||
|
|
||||||
|
// Имя файла с кириллицей и пробелами — через ContentDispositionHeaderValue:
|
||||||
|
// он сам добавит filename* в RFC 5987, иначе браузер сохранит файл
|
||||||
|
// с искажённым именем.
|
||||||
|
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||||
|
disposition.SetHttpFileName(VacancyReportWorkbook.FileName(report));
|
||||||
|
Response.Headers.ContentDisposition = disposition.ToString();
|
||||||
|
|
||||||
|
return File(file, XlsxContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||||
|
/// POST — потому что в теле список id типов (см. RouteExportService).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("routes")]
|
||||||
|
[Produces(XlsxContentType)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Routes([FromBody] RouteExportRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
|
||||||
|
if (result.IsFailed)
|
||||||
|
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
||||||
|
|
||||||
|
var file = result.Value;
|
||||||
|
|
||||||
|
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||||
|
disposition.SetHttpFileName(file.FileName);
|
||||||
|
Response.Headers.ContentDisposition = disposition.ToString();
|
||||||
|
|
||||||
|
return File(file.Content, XlsxContentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение заявок и маршрутов согласования от имени приложения (см.
|
||||||
|
/// RequestManageService) — для вкладок «Редактирование заявок» и «Заявки по
|
||||||
|
/// согласующему»: результат не зависит от прав вызывающего пользователя на
|
||||||
|
/// списки SharePoint, в отличие от прежнего прямого PnP-чтения с фронтенда.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/request-manage")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestManageController(RequestManageService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Заявки типа requestTypeId по всем партициям (архив + текущая), постранично.</summary>
|
||||||
|
[HttpGet("search")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ManagedRequestPageDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Search(
|
||||||
|
[FromQuery] int requestTypeId,
|
||||||
|
// string? (не string) — [ApiController] с Nullable enable иначе считает
|
||||||
|
// query обязательным параметром модели, а IHttpClient.buildUrl (см.
|
||||||
|
// httpClient.ts) вырезает из URL пустые query-параметры целиком: пустое
|
||||||
|
// поле поиска (обычное состояние при открытии комбобокса) присылало
|
||||||
|
// запрос вовсе БЕЗ query, и такой запрос падал 400 ещё до контроллера.
|
||||||
|
[FromQuery] string? query,
|
||||||
|
[FromQuery] int page,
|
||||||
|
[FromQuery] int? pageSize,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
(await service.SearchAsync(requestTypeId, query ?? string.Empty, page < 1 ? 1 : page, pageSize, ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>Заявка + полный маршрут согласования (обе фазы, по StepOrder).</summary>
|
||||||
|
[HttpGet("matrix")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ManagedRequestMatrixDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Matrix(
|
||||||
|
[FromQuery] int requestTypeId, [FromQuery] string listId, [FromQuery] int requestId, CancellationToken ct) =>
|
||||||
|
(await service.GetMatrixAsync(requestTypeId, listId, requestId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Активные строки согласования одного человека сразу по всем типам заявок и
|
||||||
|
/// партициям. statuses — через запятую (см. REQUEST_STATUS во фронтенде).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("by-approver")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserApprovalRowDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ByApprover(
|
||||||
|
[FromQuery] string approverEmail, [FromQuery] string? statuses, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var activeStatuses = (statuses ?? string.Empty)
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return (await service.FindActiveApprovalRowsForApproverAsync(approverEmail ?? string.Empty, activeStatuses, ct))
|
||||||
|
.ToActionResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройка доступа «видеть все заявки типа», а не только свои. Список
|
||||||
|
/// email'ов по типу дёргает и портал заявок (проверить текущего пользователя),
|
||||||
|
/// и Power Automate — поэтому политика AnyAuth, как у RemindersController.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
|
[Route("api/request-type-access")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestTypeAccessController(RequestTypeAccessService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeAccessDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{requestTypeId:int}/emails")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ListEmails(int requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListEmailsAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeAccessDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Create(CreateRequestTypeAccessRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -20,10 +20,14 @@ public sealed class RequestTypeListsController(ListPartitionService service) : C
|
|||||||
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
|
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
|
||||||
/// запрашивать серию по каждому типу отдельно.
|
/// запрашивать серию по каждому типу отдельно.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="title">
|
||||||
|
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||||
|
/// без учёта регистра, по вхождению подстроки.
|
||||||
|
/// </param>
|
||||||
[HttpGet("lists")]
|
[HttpGet("lists")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> ListAll(CancellationToken ct) =>
|
public async Task<IActionResult> ListAll([FromQuery] string? title, CancellationToken ct) =>
|
||||||
(await service.ListAllAsync(ct)).ToActionResult();
|
(await service.ListAllAsync(title, ct)).ToActionResult();
|
||||||
|
|
||||||
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
|
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
|
||||||
[HttpGet("{requestTypeId:int}/lists")]
|
[HttpGet("{requestTypeId:int}/lists")]
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Tnsu.ApprovalRoute.Api.Auth;
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
using Tnsu.ApprovalRoute.Api.Filters;
|
using Tnsu.ApprovalRoute.Api.Filters;
|
||||||
using Tnsu.ApprovalRoute.Application;
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure;
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
@@ -50,12 +53,18 @@ builder.Services.AddCors(options =>
|
|||||||
options.AddPolicy(CorsPolicyName, policy => policy
|
options.AddPolicy(CorsPolicyName, policy => policy
|
||||||
.WithOrigins(corsAllowedOrigins)
|
.WithOrigins(corsAllowedOrigins)
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod());
|
.AllowAnyMethod()
|
||||||
|
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
|
||||||
|
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
|
||||||
|
.WithExposedHeaders("Content-Disposition"));
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||||
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
||||||
|
builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports"));
|
||||||
|
builder.Services.Configure<AutoApproveOptions>(builder.Configuration.GetSection("AutoApprove"));
|
||||||
|
|
||||||
builder.Services.AddHangfire(configuration => configuration
|
builder.Services.AddHangfire(configuration => configuration
|
||||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||||
@@ -130,6 +139,28 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Рекуррентная задача регистрируется ПОСЛЕ Build (нужен собранный контейнер) и
|
||||||
|
// на каждом старте: AddOrUpdate идемпотентен и заодно подхватывает изменившееся
|
||||||
|
// расписание из конфигурации — иначе задача навсегда осталась бы с тем cron, с
|
||||||
|
// которым её создали в первый раз.
|
||||||
|
var autoApprove = app.Services.GetRequiredService<IOptions<AutoApproveOptions>>().Value;
|
||||||
|
var recurringJobs = app.Services.GetRequiredService<IRecurringJobManager>();
|
||||||
|
if (autoApprove.Enabled)
|
||||||
|
{
|
||||||
|
recurringJobs.AddOrUpdate<AutoApproveJob>(
|
||||||
|
AutoApproveJob.RecurringJobId,
|
||||||
|
job => job.RunAsync(CancellationToken.None),
|
||||||
|
autoApprove.Cron,
|
||||||
|
// Cron в настройке — в UTC, как и всё остальное время в сервисе; иначе
|
||||||
|
// расписание поехало бы при смене часового пояса машины.
|
||||||
|
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Выключили настройкой — снимаем задачу, а не оставляем её вхолостую крутиться.
|
||||||
|
recurringJobs.RemoveIfExists(AutoApproveJob.RecurringJobId);
|
||||||
|
}
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
await app.InitAndRunAsync();
|
await app.InitAndRunAsync();
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="appsettings.*.json" DependentUpon="appsettings.json" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" />
|
<PackageReference Include="Hangfire.AspNetCore" />
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
"https://localhost:4321"
|
"https://localhost:4321"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"AutoApprove": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Cron": "0 * * * *",
|
||||||
|
"MaxRowsPerRun": 200
|
||||||
|
},
|
||||||
"ListPartitioning": {
|
"ListPartitioning": {
|
||||||
"ItemCountThreshold": 4900,
|
"ItemCountThreshold": 4900,
|
||||||
"StaleLockAfter": "00:30:00",
|
"StaleLockAfter": "00:30:00",
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
@@ -15,10 +19,16 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.AddScoped<ReminderService>();
|
services.AddScoped<ReminderService>();
|
||||||
services.AddScoped<RequestTypeService>();
|
services.AddScoped<RequestTypeService>();
|
||||||
|
services.AddScoped<RequestTypeAccessService>();
|
||||||
|
services.AddScoped<RequestManageService>();
|
||||||
services.AddScoped<ListPartitionRegistry>();
|
services.AddScoped<ListPartitionRegistry>();
|
||||||
services.AddScoped<ListPartitionService>();
|
services.AddScoped<ListPartitionService>();
|
||||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||||
services.AddScoped<CreateNextListPartitionJob>();
|
services.AddScoped<CreateNextListPartitionJob>();
|
||||||
|
services.AddScoped<AutoApproveService>();
|
||||||
|
services.AddScoped<AutoApproveJob>();
|
||||||
|
services.AddScoped<VacancyReportService>();
|
||||||
|
services.AddScoped<RouteExportService>();
|
||||||
services.AddScoped<TemplateRouteService>();
|
services.AddScoped<TemplateRouteService>();
|
||||||
services.AddScoped<TemplateRouteStepService>();
|
services.AddScoped<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конвенция имён списков портала заявок: у каждой пары списков имя данных и имя
|
||||||
|
/// согласования отличаются только суффиксом после длинного тире (см.
|
||||||
|
/// Get-NumberedListTitle в request-portal/provisioning/Provision-Lists.ps1 —
|
||||||
|
/// именно провижинер эти имена и ставит).
|
||||||
|
///
|
||||||
|
/// Отдельным классом, а не приватным методом сервиса: правило нужно и
|
||||||
|
/// напоминаниям (ReminderService), и автосогласованию (AutoApproveService), и
|
||||||
|
/// разъехаться между ними оно не должно — переименование одного списка из пары
|
||||||
|
/// молча оставило бы один из сценариев без строк.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ApprovalListNaming
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// «Заявка на приём 1 — данные» → «Заявка на приём 1 — согласование».
|
||||||
|
/// null — имя не по конвенции, список согласования по нему не найти.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ApprovalListTitleFor(string dataListTitle)
|
||||||
|
{
|
||||||
|
var index = dataListTitle.LastIndexOf('—');
|
||||||
|
if (index < 0) return null;
|
||||||
|
|
||||||
|
var suffix = dataListTitle[(index + 1)..].Trim();
|
||||||
|
return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"{dataListTitle[..index].TrimEnd()} — согласование"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-7
@@ -1,12 +1,17 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
||||||
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
||||||
/// именно он эти списки и создаёт.
|
/// именно он эти списки и создаёт.
|
||||||
|
///
|
||||||
|
/// Общая для напоминаний (ReminderService) и автосогласования
|
||||||
|
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
|
||||||
|
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
|
||||||
|
/// просроченным шаг считается в одном месте и не считается в другом.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record ApprovalRow(
|
internal sealed record ApprovalRow(
|
||||||
int Id,
|
int Id,
|
||||||
@@ -15,17 +20,24 @@ internal sealed record ApprovalRow(
|
|||||||
string Phase,
|
string Phase,
|
||||||
DateTimeOffset? Date,
|
DateTimeOffset? Date,
|
||||||
string? ApproverEmail,
|
string? ApproverEmail,
|
||||||
|
string? ApproverName,
|
||||||
int? SlaHours,
|
int? SlaHours,
|
||||||
bool? AskOverdueReason)
|
bool? AskOverdueReason,
|
||||||
|
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
|
||||||
|
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
|
||||||
|
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
|
||||||
|
// сценария — см. RowSelect у каждого из них.
|
||||||
|
double Order = 0,
|
||||||
|
bool AutoApprove = false)
|
||||||
{
|
{
|
||||||
public static ApprovalRow? TryParse(JsonElement item)
|
public static ApprovalRow? TryParse(JsonElement item)
|
||||||
{
|
{
|
||||||
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||||
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
||||||
|
|
||||||
var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||||
? GetString(a, "EMail")
|
? a
|
||||||
: null;
|
: (JsonElement?)null;
|
||||||
|
|
||||||
return new ApprovalRow(
|
return new ApprovalRow(
|
||||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||||
@@ -35,9 +47,17 @@ internal sealed record ApprovalRow(
|
|||||||
// трактуем как «Согласование» (так же поступает request-portal).
|
// трактуем как «Согласование» (так же поступает request-portal).
|
||||||
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
||||||
TryGetDate(item, "Date"),
|
TryGetDate(item, "Date"),
|
||||||
approver,
|
approverObject is { } ao ? GetString(ao, "EMail") : null,
|
||||||
|
// Title у персонального поля — не всегда заполнен (например, у
|
||||||
|
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
|
||||||
|
approverObject is { } an ? GetString(an, "Title") : null,
|
||||||
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
||||||
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
|
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null,
|
||||||
|
// StepOrder — Number, и он бывает ДРОБНЫМ: делегат вклинивается между
|
||||||
|
// шагами долей (см. ApproverService.delegate в request-portal),
|
||||||
|
// поэтому здесь double, а не int.
|
||||||
|
TryGetDouble(item, "StepOrder", out var order) ? order : 0,
|
||||||
|
TryGetBool(item, "AutoApprove", out var auto) && auto);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetString(JsonElement e, string name) =>
|
private static string? GetString(JsonElement e, string name) =>
|
||||||
@@ -77,6 +97,18 @@ internal sealed record ApprovalRow(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Дробное число из JSON — с теми же оговорками, что и <see cref="TryGetInt"/>.</summary>
|
||||||
|
private static bool TryGetDouble(JsonElement e, string name, out double value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (!e.TryGetProperty(name, out var v)) return false;
|
||||||
|
|
||||||
|
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out value);
|
||||||
|
|
||||||
|
return v.ValueKind == JsonValueKind.String &&
|
||||||
|
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryGetBool(JsonElement e, string name, out bool value)
|
private static bool TryGetBool(JsonElement e, string name, out bool value)
|
||||||
{
|
{
|
||||||
value = false;
|
value = false;
|
||||||
@@ -106,8 +138,34 @@ internal static class ApprovalPhases
|
|||||||
public const string Execution = "Исполнение";
|
public const string Execution = "Исполнение";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
|
||||||
|
/// Один в один с REQUEST_STATUS в request-portal
|
||||||
|
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
|
||||||
|
/// </summary>
|
||||||
internal static class ApprovalStatuses
|
internal static class ApprovalStatuses
|
||||||
{
|
{
|
||||||
|
public const string Queued = "Ждёт очереди";
|
||||||
public const string OnApproval = "На согласовании";
|
public const string OnApproval = "На согласовании";
|
||||||
public const string OnExecution = "На исполнении";
|
public const string OnExecution = "На исполнении";
|
||||||
|
public const string Approved = "Согласовано";
|
||||||
|
public const string Executed = "Выполнено";
|
||||||
|
public const string Rejected = "Отклонено";
|
||||||
|
public const string Returned = "Возвращена на доработку";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значения колонки RequestStatus — статус ЗАЯВКИ целиком (список «… — данные»).
|
||||||
|
/// Набор ДРУГОЙ, чем у строки маршрута: «Ждёт очереди»/«На исполнении» у заявки
|
||||||
|
/// не бывает, а «Выполнение»/«Закрыта» не бывает у строки (см. тот же
|
||||||
|
/// statuses.ts, где оба набора сведены).
|
||||||
|
/// </summary>
|
||||||
|
internal static class RequestStatuses
|
||||||
|
{
|
||||||
|
public const string OnApproval = "На согласовании";
|
||||||
|
public const string Approved = "Согласовано";
|
||||||
|
public const string Rejected = "Отклонено";
|
||||||
|
public const string Returned = "Возвращена на доработку";
|
||||||
|
public const string Execution = "Выполнение";
|
||||||
|
public const string Closed = "Закрыта";
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рекуррентная задача автосогласования — тонкая обёртка над
|
||||||
|
/// <see cref="AutoApproveService"/>: всё, что она добавляет сверху, — расписание,
|
||||||
|
/// защита от наложения запусков и одна строка в логе с итогом.
|
||||||
|
///
|
||||||
|
/// Повторы отключены (Attempts = 0) намеренно, в отличие от
|
||||||
|
/// CreateNextListPartitionJob: там повтор нужен, потому что пропуск задачи ломает
|
||||||
|
/// сценарий целиком (список не создан — писать некуда). Здесь же следующий запуск
|
||||||
|
/// придёт сам, через час, и сделает ровно ту же работу с актуальным состоянием —
|
||||||
|
/// уже согласованные строки в выборку не попадут. Повторять упавший проход раньше
|
||||||
|
/// этого смысла нет, а висящие в очереди повторы только прятали бы настоящую
|
||||||
|
/// причину сбоя.
|
||||||
|
/// </summary>
|
||||||
|
[AutomaticRetry(Attempts = 0)]
|
||||||
|
[DisableConcurrentExecution(timeoutInSeconds: 5 * 60)]
|
||||||
|
public sealed class AutoApproveJob(AutoApproveService service, ILogger<AutoApproveJob> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор рекуррентной задачи в Hangfire. Константой — по нему задача и
|
||||||
|
/// заводится, и снимается при выключении настройки (см. Program.cs): разъехавшись,
|
||||||
|
/// эти два имени оставили бы в дашборде задачу, которую уже некому снять.
|
||||||
|
/// </summary>
|
||||||
|
public const string RecurringJobId = "auto-approve";
|
||||||
|
|
||||||
|
public async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var approved = await service.RunAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Автосогласование: строк согласовано автоматически — {Count}", approved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
public sealed class AutoApproveOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Включено ли автосогласование вообще. Выключение снимает рекуррентную задачу
|
||||||
|
/// из Hangfire (см. Program.cs), а не просто делает её пустой: иначе в дашборде
|
||||||
|
/// осталась бы задача, которая регулярно запускается и ничего не делает.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Расписание запуска (cron, UTC). Раз в час: SLA задаётся в ЧАСАХ, поэтому
|
||||||
|
/// час — предельная точность, которая вообще имеет смысл, а более частый опрос
|
||||||
|
/// всех списков SharePoint ничего не ускоряет по существу.
|
||||||
|
///
|
||||||
|
/// Следствие: шаг согласуется не в сам момент истечения SLA, а на ближайшем
|
||||||
|
/// запуске после него — то есть в пределах часа позже. Это осознанно: раньше
|
||||||
|
/// срока не согласуется никогда, а опоздание в пределах часа для срока,
|
||||||
|
/// измеряемого часами, роли не играет.
|
||||||
|
/// </summary>
|
||||||
|
public string Cron { get; set; } = "0 * * * *";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Комментарий, который задача пишет в строку согласования вместо комментария
|
||||||
|
/// согласующего. Нужен, чтобы в матрице заявки было видно, что решение принял не
|
||||||
|
/// человек: других признаков у автосогласованной строки нет — статус и дата у неё
|
||||||
|
/// такие же, как у обычной.
|
||||||
|
/// </summary>
|
||||||
|
public string Comment { get; set; } = "Согласовано автоматически: истёк срок SLA по этому шагу.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сколько строк автосогласовать максимум за один запуск. Предохранитель на
|
||||||
|
/// случай массового включения флага или сдвига дат: без него один запуск мог бы
|
||||||
|
/// пройти по всему архиву партиций и написать в SharePoint тысячи изменений,
|
||||||
|
/// упёршись в троттлинг. Остаток уедет в следующий запуск — задача идемпотентна
|
||||||
|
/// (уже решённые строки в выборку активных не попадают).
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRowsPerRun { get; set; } = 200;
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Автосогласование по SLA: находит строки списков согласования, у которых
|
||||||
|
/// включён флаг «Автосогласование» (колонка AutoApprove) и истёк срок SLA, и
|
||||||
|
/// принимает за согласующего положительное решение.
|
||||||
|
///
|
||||||
|
/// Это ЗЕРКАЛО клиентской логики решения из request-portal
|
||||||
|
/// (shared/approval/approverDecision.service.ts — decide, и approvalList.service.ts —
|
||||||
|
/// setDecision/activateNextQueued): та же последовательность «пометить строку →
|
||||||
|
/// продвинуть очередь → пересчитать статус заявки», просто выполненная сервером
|
||||||
|
/// от имени приложения, а не браузером от имени человека. Расхождение между ними
|
||||||
|
/// означало бы, что автосогласованная заявка встаёт в состояние, в которое
|
||||||
|
/// портал её сам никогда не приводит, поэтому правила здесь повторены буквально,
|
||||||
|
/// а не «по смыслу» (см. ActivationTargets и ComputeRequestStatus).
|
||||||
|
///
|
||||||
|
/// В отличие от <see cref="Reminders.ReminderService"/> (срез «кто просрочил»,
|
||||||
|
/// без записи) задача именно ПИШЕТ в SharePoint, поэтому обход здесь
|
||||||
|
/// последовательный: параллелить запись по типам заявок — это упереться в
|
||||||
|
/// троттлинг и потерять предсказуемость порядка, а объём работы за один запуск
|
||||||
|
/// ограничен сверху (см. AutoApproveOptions.MaxRowsPerRun).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoApproveService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IOptions<AutoApproveOptions> options,
|
||||||
|
ILogger<AutoApproveService> logger)
|
||||||
|
{
|
||||||
|
// Активные строки С ВКЛЮЧЁННЫМ автосогласованием — отбор целиком на стороне
|
||||||
|
// SharePoint, чтобы не тащить сюда весь список ради единиц строк.
|
||||||
|
//
|
||||||
|
// Да/Нет-колонка в $filter сравнивается с 1/0, а не с true/false.
|
||||||
|
//
|
||||||
|
// У строк, заведённых ДО появления колонки AutoApprove, значение пустое, и
|
||||||
|
// под `eq 1` они не попадают — это верно по смыслу: включить автосогласование
|
||||||
|
// задним числом нельзя, флаг проставляется при отправке заявки (портал) или
|
||||||
|
// вручную в админке.
|
||||||
|
private const string CandidateFilter =
|
||||||
|
$"$filter=(ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}')" +
|
||||||
|
" and AutoApprove eq 1";
|
||||||
|
|
||||||
|
private const string RowSelect =
|
||||||
|
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,AutoApprove,StepOrder,TicketId&$top=5000";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один проход по всем типам заявок. Возвращает число автосогласованных строк —
|
||||||
|
/// его пишет в лог задача (см. AutoApproveJob), и по нему же удобно понять из
|
||||||
|
/// дашборда Hangfire, делал ли запуск хоть что-нибудь.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
// В отличие от напоминаний, ТИПЫ БЕЗ SlaHours отсюда НЕ отсеиваются: у типа
|
||||||
|
// SLA может быть не задан, а у конкретного шага — задан, и именно такой шаг
|
||||||
|
// чаще всего и делают автосогласуемым («это согласование формальное, ждём
|
||||||
|
// сутки и идём дальше»). Шаг, у которого SLA нет ни там, ни там, отсеется
|
||||||
|
// ниже поштучно (см. DeadlineOf).
|
||||||
|
var types = await db.RequestTypes.AsNoTracking()
|
||||||
|
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var approved = 0;
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
if (approved >= options.Value.MaxRowsPerRun) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
approved += await RunForTypeAsync(type, now, options.Value.MaxRowsPerRun - approved, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// Недоступный список одного типа не должен останавливать остальные —
|
||||||
|
// ровно как в напоминаниях.
|
||||||
|
logger.LogError(e, "Автосогласование по типу заявки «{Type}» не выполнено", type.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> RunForTypeAsync(
|
||||||
|
RequestType type, DateTimeOffset now, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
|
// Архив обходится наравне с текущей партицией: заявка, уехавшая в архив,
|
||||||
|
// продолжает ждать решения так же, как в текущем списке, и её шаг точно так
|
||||||
|
// же просрочивается (см. тот же довод в ReminderService).
|
||||||
|
var partitions = archived
|
||||||
|
.Select(x => (x.ListId, x.Number))
|
||||||
|
.Append((current.ListId, current.Number));
|
||||||
|
|
||||||
|
var approved = 0;
|
||||||
|
foreach (var (listId, number) in partitions)
|
||||||
|
{
|
||||||
|
if (approved >= limit) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
approved += await RunForPartitionAsync(type, listId, now, limit - approved, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
e, "Автосогласование по списку {ListId} (партиция №{Number}) типа «{Type}» не выполнено",
|
||||||
|
listId, number, type.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> RunForPartitionAsync(
|
||||||
|
RequestType type, string listId, DateTimeOffset now, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var siteUrl = type.SiteUrl!;
|
||||||
|
|
||||||
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(dataListTitle)) return 0;
|
||||||
|
|
||||||
|
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
|
||||||
|
if (approvalListTitle is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти",
|
||||||
|
dataListTitle);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = await ReadRowsAsync(
|
||||||
|
siteUrl, approvalListTitle, $"{RowSelect}&{CandidateFilter}", cancellationToken);
|
||||||
|
if (candidates.Count == 0) return 0;
|
||||||
|
|
||||||
|
var approved = 0;
|
||||||
|
// По заявкам, а не по строкам подряд: решение по одной строке двигает
|
||||||
|
// очередь и меняет статус всей заявки, поэтому строки одной заявки нельзя
|
||||||
|
// обрабатывать по устаревшему снимку — внутри группы состояние
|
||||||
|
// перечитывается (см. ApproveAsync).
|
||||||
|
foreach (var ticket in candidates.GroupBy(r => r.TicketId))
|
||||||
|
{
|
||||||
|
if (approved >= limit) return approved;
|
||||||
|
|
||||||
|
foreach (var candidate in ticket.OrderBy(r => r.Order))
|
||||||
|
{
|
||||||
|
if (approved >= limit) return approved;
|
||||||
|
|
||||||
|
var deadline = DeadlineOf(candidate, type);
|
||||||
|
if (deadline is null || deadline > now) continue;
|
||||||
|
|
||||||
|
var done = await ApproveAsync(
|
||||||
|
type, siteUrl, approvalListTitle, listId, candidate, deadline.Value, now, cancellationToken);
|
||||||
|
if (done) approved++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Момент, после которого шаг считается просроченным. null — SLA не задан ни на
|
||||||
|
/// строке, ни на типе заявки, либо не заполнена дата активации: отсчитывать не
|
||||||
|
/// от чего, и автосогласовать такой шаг нельзя — он «висел» бы бесконечно, а
|
||||||
|
/// сработал бы в произвольный момент после того, как SLA всё-таки настроят.
|
||||||
|
/// </summary>
|
||||||
|
private DateTimeOffset? DeadlineOf(ApprovalRow row, RequestType type)
|
||||||
|
{
|
||||||
|
if (row.Date is not { } activatedAt)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
|
||||||
|
"но дата активации (Date) не заполнена — пропущена",
|
||||||
|
row.Id, row.TicketId, type.Id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Как и в напоминаниях: SLA строки перекрывает SLA типа заявки, и ноль —
|
||||||
|
// это ноль («сразу»), а не «не задано».
|
||||||
|
var slaHours = row.SlaHours ?? type.SlaHours;
|
||||||
|
if (slaHours is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
|
||||||
|
"но SLA не задан ни на шаге, ни на типе заявки — пропущена",
|
||||||
|
row.Id, row.TicketId, type.Id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activatedAt.AddHours(slaHours.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Решение по одной строке. Возвращает false, если строку решать уже не нужно —
|
||||||
|
/// её успел закрыть человек между выборкой кандидатов и этим моментом.
|
||||||
|
///
|
||||||
|
/// Проверка «строка всё ещё активна» делается по СВЕЖЕМУ чтению, а не по
|
||||||
|
/// кандидату из выборки: между ними проходит сколько угодно времени (обход
|
||||||
|
/// партиций, сеть), и записать «Согласовано» поверх только что поставленного
|
||||||
|
/// человеком «Отклонено» — худшее, что эта задача может сделать. Полностью
|
||||||
|
/// гонку это не закрывает (решение может прийти и в миллисекунду между
|
||||||
|
/// проверкой и записью), но сводит окно к одному HTTP-запросу вместо всего
|
||||||
|
/// прохода; блокировок на элемент списка SharePoint не предоставляет.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> ApproveAsync(
|
||||||
|
RequestType type, string siteUrl, string approvalListTitle, string dataListUrl,
|
||||||
|
ApprovalRow candidate, DateTimeOffset deadline, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = await ReadTicketRowsAsync(siteUrl, approvalListTitle, candidate.TicketId, cancellationToken);
|
||||||
|
|
||||||
|
var row = rows.FirstOrDefault(r => r.Id == candidate.Id);
|
||||||
|
if (row is null || !IsActive(row))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Строка {Row} заявки {Ticket} уже не ждёт решения — автосогласование не требуется",
|
||||||
|
candidate.Id, candidate.TicketId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фаза исполнения закрывается статусом «Выполнено», а не «Согласовано» —
|
||||||
|
// это те же два значения, что ставит DecisionPanel портала для решений
|
||||||
|
// approve и execute.
|
||||||
|
var decision = row.Phase == ApprovalPhases.Execution
|
||||||
|
? ApprovalStatuses.Executed
|
||||||
|
: ApprovalStatuses.Approved;
|
||||||
|
|
||||||
|
await sharePoint.UpdateItemByListTitleAsync(
|
||||||
|
siteUrl, approvalListTitle, row.Id,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ApprovalStatus = decision,
|
||||||
|
// Дата РЕШЕНИЯ перезаписывает дату активации — так же, как
|
||||||
|
// setDecision в портале (колонка Date у строки одна).
|
||||||
|
Date = now.UtcDateTime,
|
||||||
|
Comment = options.Value.Comment
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Строка {Row} заявки {Ticket} типа «{Type}» автосогласована ({Decision}): срок SLA истёк {Deadline:u}",
|
||||||
|
row.Id, row.TicketId, type.Name, decision, deadline);
|
||||||
|
|
||||||
|
// Дальше считаем по снимку, в котором решение УЖЕ применено: перечитывать
|
||||||
|
// список после каждой записи незачем — мы сами знаем, что изменили, а
|
||||||
|
// лишние чтения на каждом шаге заметно удлиняют проход.
|
||||||
|
var updated = rows
|
||||||
|
.Select(r => r.Id == row.Id ? r with { Status = decision } : r)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var activated = ActivationTargets(updated);
|
||||||
|
foreach (var target in activated)
|
||||||
|
{
|
||||||
|
await sharePoint.UpdateItemByListTitleAsync(
|
||||||
|
siteUrl, approvalListTitle, target.Row.Id,
|
||||||
|
// Активация — это статус ПЛЮС отметка времени «получил на
|
||||||
|
// согласование»: от неё отсчитывается SLA следующего шага (см.
|
||||||
|
// setActive в портале). Без даты шаг выпал бы и из напоминаний, и
|
||||||
|
// из автосогласования.
|
||||||
|
new { ApprovalStatus = target.Status, Date = now.UtcDateTime },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var target in activated)
|
||||||
|
updated[updated.FindIndex(r => r.Id == target.Row.Id)] = target.Row with { Status = target.Status };
|
||||||
|
|
||||||
|
await sharePoint.UpdateItemByListUrlAsync(
|
||||||
|
siteUrl, dataListUrl, row.TicketId,
|
||||||
|
new { RequestStatus = ComputeRequestStatus(updated) },
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsActive(ApprovalRow row) =>
|
||||||
|
row.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кого активировать после положительного решения — зеркало
|
||||||
|
/// ApprovalListService.activateNextQueued из request-portal.
|
||||||
|
///
|
||||||
|
/// Среди ещё ждущих строк текущей фазы активируются те, что делят НАИМЕНЬШИЙ
|
||||||
|
/// StepOrder: обычно это одна строка, несколько — параллельный шаг. Фаза
|
||||||
|
/// исполнения активируется, только когда фаза согласования пройдена целиком;
|
||||||
|
/// если согласование остановлено (отклонение/возврат), не активируется никто.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<(ApprovalRow Row, string Status)> ActivationTargets(IReadOnlyList<ApprovalRow> rows)
|
||||||
|
{
|
||||||
|
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
|
||||||
|
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
|
||||||
|
|
||||||
|
if (approvalRows.Any(r => r.Status is ApprovalStatuses.Rejected or ApprovalStatuses.Returned))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
|
||||||
|
|
||||||
|
var (phaseRows, activeStatus) = approvalDone
|
||||||
|
? (execRows, ApprovalStatuses.OnExecution)
|
||||||
|
: (approvalRows, ApprovalStatuses.OnApproval);
|
||||||
|
|
||||||
|
// В фазе уже есть активная строка — очередь двигать не нужно (так бывает при
|
||||||
|
// параллельном шаге, где решение принято ещё не всеми).
|
||||||
|
if (phaseRows.Any(r => r.Status == activeStatus)) return [];
|
||||||
|
|
||||||
|
var queued = phaseRows.Where(r => r.Status == ApprovalStatuses.Queued).ToList();
|
||||||
|
if (queued.Count == 0) return [];
|
||||||
|
|
||||||
|
var minOrder = queued.Min(r => r.Order);
|
||||||
|
return queued.Where(r => r.Order == minOrder).Select(r => (r, activeStatus)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Статус ЗАЯВКИ по строкам маршрута — зеркало computeRequestStatus из
|
||||||
|
/// request-portal (approverDecision.service.ts). Порядок проверок важен и
|
||||||
|
/// повторён дословно: отклонение и возврат старше незавершённого согласования,
|
||||||
|
/// а «Согласовано» — финал только для маршрута без фазы исполнения.
|
||||||
|
/// </summary>
|
||||||
|
private static string ComputeRequestStatus(IReadOnlyList<ApprovalRow> rows)
|
||||||
|
{
|
||||||
|
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
|
||||||
|
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
|
||||||
|
|
||||||
|
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Rejected)) return RequestStatuses.Rejected;
|
||||||
|
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Returned)) return RequestStatuses.Returned;
|
||||||
|
|
||||||
|
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
|
||||||
|
if (!approvalDone) return RequestStatuses.OnApproval;
|
||||||
|
|
||||||
|
if (execRows.Count == 0) return RequestStatuses.Approved;
|
||||||
|
if (execRows.All(r => r.Status == ApprovalStatuses.Executed)) return RequestStatuses.Closed;
|
||||||
|
return RequestStatuses.Execution;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<IReadOnlyList<ApprovalRow>> ReadTicketRowsAsync(
|
||||||
|
string siteUrl, string approvalListTitle, int ticketId, CancellationToken cancellationToken) =>
|
||||||
|
ReadRowsAsync(siteUrl, approvalListTitle, $"{RowSelect}&$filter=TicketId eq {ticketId}", cancellationToken);
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||||
|
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await sharePoint.GetItemsByListTitleAsync(siteUrl, listTitle, odataQuery, cancellationToken);
|
||||||
|
return items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Русские названия типов руководителя и ролей в проекте — для всего, что видит
|
||||||
|
/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя
|
||||||
|
/// enum'а («DirectionHead») в тексте для пользователя недопустимо.
|
||||||
|
/// </summary>
|
||||||
|
public static class StepLabels
|
||||||
|
{
|
||||||
|
public static string Of(HeadType headType) => headType switch
|
||||||
|
{
|
||||||
|
HeadType.Chief => "Непосредственный руководитель",
|
||||||
|
HeadType.DirectionHead => "Руководитель направления",
|
||||||
|
HeadType.DirectorTansu => "Директор Tansu",
|
||||||
|
HeadType.ManagingDirectorTansu => "Управляющий директор Tansu",
|
||||||
|
HeadType.DepartmentHead => "Руководитель указанного отдела",
|
||||||
|
HeadType.CompanyDirectors => "Директора компаний инициатора",
|
||||||
|
_ => headType.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Название с маленькой буквы — для середины фразы («Не найден руководитель направления»).</summary>
|
||||||
|
public static string InSentence(HeadType headType) => Lower(Of(headType));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Все названия мужского рода («найден руководитель проекта»), кроме «Безопасность» —
|
||||||
|
/// для неё нужна отдельная формулировка, иначе выйдет «найден безопасность».
|
||||||
|
/// </summary>
|
||||||
|
public static string InSentence(ProjectRole projectRole) =>
|
||||||
|
projectRole == ProjectRole.Security ? "сотрудник службы безопасности" : Lower(Of(projectRole));
|
||||||
|
|
||||||
|
private static string Lower(string label) => char.ToLowerInvariant(label[0]) + label[1..];
|
||||||
|
|
||||||
|
public static string Of(ProjectRole projectRole) => projectRole switch
|
||||||
|
{
|
||||||
|
ProjectRole.ProjectManager => "Руководитель проекта",
|
||||||
|
ProjectRole.Planner => "Планировщик",
|
||||||
|
ProjectRole.ManagerHSE => "Менеджер HSE",
|
||||||
|
ProjectRole.Security => "Безопасность",
|
||||||
|
ProjectRole.Keeper => "Заведующий склада",
|
||||||
|
_ => projectRole.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -78,7 +78,12 @@ public sealed class ListPartitionService(
|
|||||||
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
|
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
|
||||||
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
|
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(CancellationToken cancellationToken)
|
/// <param name="title">
|
||||||
|
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||||
|
/// без учёта регистра, по вхождению подстроки. null — фильтр не применяется.
|
||||||
|
/// </param>
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(
|
||||||
|
string? title, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var requestTypes = await db.RequestTypes
|
var requestTypes = await db.RequestTypes
|
||||||
.Where(x => x.ListId != null && x.SiteUrl != null)
|
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||||
@@ -88,6 +93,14 @@ public sealed class ListPartitionService(
|
|||||||
foreach (var requestType in requestTypes)
|
foreach (var requestType in requestTypes)
|
||||||
{
|
{
|
||||||
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
|
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(title))
|
||||||
|
partitions = partitions
|
||||||
|
.Where(x => x.Title is not null && x.Title.Contains(title, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (partitions.Count == 0) continue;
|
||||||
|
|
||||||
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
|
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <param name="Email">
|
||||||
|
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
|
||||||
|
/// именем ради обратной совместимости с потребителями, читавшими это поле до
|
||||||
|
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="HoursOverdue">
|
||||||
|
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||||
|
/// Выходные не учитываются — считаются календарные часы.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AskOverdueReason">
|
||||||
|
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||||
|
/// наследованием (шаг перекрывает тип заявки).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
|
||||||
|
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
|
||||||
|
/// <param name="RequestId">Id заявки (TicketId строки списка согласования).</param>
|
||||||
|
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
||||||
|
/// <param name="RequestUniqueCode">
|
||||||
|
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
||||||
|
/// заявки его нет или строка данных не найдена.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ApprovalOverdueReminderDto(
|
||||||
|
string Email,
|
||||||
|
int HoursOverdue,
|
||||||
|
bool AskOverdueReason,
|
||||||
|
int RequestTypeId,
|
||||||
|
string RequestTypeName,
|
||||||
|
int RequestId,
|
||||||
|
string Status,
|
||||||
|
string? RequestUniqueCode,
|
||||||
|
string Url,
|
||||||
|
string? ReceiverName = null)
|
||||||
|
: ReminderDto(Email, ReceiverName, Url);
|
||||||
@@ -4,14 +4,21 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
|
|||||||
|
|
||||||
internal enum EmploymentStatus
|
internal enum EmploymentStatus
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Статус не определяли. EmploymentOf такого не возвращает — значение существует
|
||||||
|
/// только как значение по умолчанию (default(EmploymentStatus)); трактуется
|
||||||
|
/// наравне с <see cref="NotFound"/>, то есть «трудоустройство не подтверждено».
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
|
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
|
||||||
NotFound,
|
NotFound = 1,
|
||||||
|
|
||||||
/// <summary>Работает хотя бы в одной компании.</summary>
|
/// <summary>Работает хотя бы в одной компании.</summary>
|
||||||
Employed,
|
Employed = 2,
|
||||||
|
|
||||||
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
|
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
|
||||||
Dismissed,
|
Dismissed = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -31,12 +38,22 @@ internal sealed class EmployeeDirectorySnapshot
|
|||||||
{
|
{
|
||||||
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
|
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
|
||||||
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
|
||||||
|
|
||||||
public EmployeeDirectorySnapshot(
|
public EmployeeDirectorySnapshot(
|
||||||
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
||||||
{
|
{
|
||||||
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
||||||
_contactsByFio = contacts.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>
|
||||||
@@ -64,6 +81,10 @@ internal sealed class EmployeeDirectorySnapshot
|
|||||||
.Select(e => e.Email)
|
.Select(e => e.Email)
|
||||||
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
||||||
|
|
||||||
|
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
|
||||||
|
public string? NameByEmail(string email) =>
|
||||||
|
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
||||||
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
||||||
|
|||||||
@@ -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,23 +0,0 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Категория уведомления в <see cref="ReminderDto"/>. Добавлена позже самого DTO,
|
|
||||||
/// поэтому в нём параметр необязательный со значением <see cref="None"/> —
|
|
||||||
/// «категория не указана»: ровно то, что было до появления этого поля, когда
|
|
||||||
/// эндпоинт отдавал единственный вид напоминаний, и потребители (Power Automate)
|
|
||||||
/// ни о каких категориях не знали.
|
|
||||||
///
|
|
||||||
/// На провод уходит строкой, а не числом (JsonStringEnumConverter, см. Program.cs),
|
|
||||||
/// так что числовые значения — деталь реализации, менять их порядок безопасно.
|
|
||||||
/// </summary>
|
|
||||||
public enum ReminderCategory
|
|
||||||
{
|
|
||||||
/// <summary>Категория не указана — значение по умолчанию для обратной совместимости.</summary>
|
|
||||||
None = 0,
|
|
||||||
|
|
||||||
/// <summary>Просроченный шаг согласования/исполнения (см. ReminderService.GetOverdueAsync).</summary>
|
|
||||||
ApprovalOverdue = 1,
|
|
||||||
|
|
||||||
/// <summary>Скоро заканчивается испытательный срок (см. ReminderService.GetProbationEndingAsync).</summary>
|
|
||||||
ProbationEnding = 2,
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,32 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
|
/// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
|
||||||
/// <param name="HoursOverdue">
|
/// <param name="ReceiverName">
|
||||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
|
||||||
/// Выходные не учитываются — считаются календарные часы.
|
/// в старой строке списка без заполненного Title персонального поля) — тогда null.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Url">Ссылка на заявку, требующую решения.</param>
|
/// <param name="Url">
|
||||||
/// <param name="AskOverdueReason">
|
/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
|
||||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
/// потому что не константна: requestId и партиция списка (server-relative url,
|
||||||
/// наследованием (шаг перекрывает тип заявки).
|
/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
|
||||||
|
/// дублировать эту логику снаружи незачем.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
/// <remarks>
|
||||||
/// <param name="RequestUniqueCode">
|
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
|
||||||
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
/// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
|
||||||
/// заявки его нет или строка данных не найдена.
|
/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
|
||||||
/// </param>
|
/// (Power Automate ссылается на неё, работая со списком согласования конкретного
|
||||||
/// <param name="Category">
|
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
|
||||||
/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по
|
///
|
||||||
/// умолчанию <see cref="ReminderCategory.None"/> («не указана»): старые вызовы
|
/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
|
||||||
/// конструктора и потребители, ничего не знающие о категориях, продолжают работать
|
/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
|
||||||
/// как раньше.
|
/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
|
||||||
/// </param>
|
/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
|
||||||
/// <param name="ManagerEmail">
|
/// со значением "ApprovalOverdue"/"ProbationEnding".
|
||||||
/// Только для <see cref="ReminderCategory.ProbationEnding"/> — email руководителя
|
/// </remarks>
|
||||||
/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для
|
[JsonPolymorphic]
|
||||||
/// остальных категорий — null.
|
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
|
||||||
/// </param>
|
[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
|
||||||
/// <param name="CandidateEmail">Только для ProbationEnding — email принимаемого сотрудника.</param>
|
public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
|
||||||
/// <param name="CandidateName">Только для ProbationEnding — ФИО принимаемого сотрудника (колонка CandidateName).</param>
|
|
||||||
/// <param name="ProbationPeriod">Только для ProbationEnding — текст испытательного срока (колонка ProbationPeriod).</param>
|
|
||||||
/// <param name="HireDate">Только для ProbationEnding — дата приёма (колонка HireDate).</param>
|
|
||||||
/// <param name="ProbationEndsAt">Только для ProbationEnding — дата окончания испытательного срока (HireDate + срок).</param>
|
|
||||||
/// <param name="DaysUntilProbationEnds">Только для ProbationEnding — сколько дней осталось до конца испытательного срока.</param>
|
|
||||||
public sealed record ReminderDto(
|
|
||||||
string Email,
|
|
||||||
int HoursOverdue,
|
|
||||||
string Url,
|
|
||||||
bool AskOverdueReason,
|
|
||||||
int RequestTypeId,
|
|
||||||
string RequestTypeName,
|
|
||||||
int RequestId,
|
|
||||||
string Status,
|
|
||||||
string? RequestUniqueCode,
|
|
||||||
ReminderCategory Category = ReminderCategory.None,
|
|
||||||
string? ManagerEmail = null,
|
|
||||||
string? CandidateEmail = null,
|
|
||||||
string? CandidateName = null,
|
|
||||||
string? ProbationPeriod = null,
|
|
||||||
DateTimeOffset? HireDate = null,
|
|
||||||
DateTimeOffset? ProbationEndsAt = null,
|
|
||||||
int? DaysUntilProbationEnds = null);
|
|
||||||
|
|||||||
@@ -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,6 +3,7 @@ using FluentResults;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
@@ -24,6 +25,7 @@ public sealed class ReminderService(
|
|||||||
ISharePointClient sharePoint,
|
ISharePointClient sharePoint,
|
||||||
ListPartitionRegistry partitionRegistry,
|
ListPartitionRegistry partitionRegistry,
|
||||||
ITnsuPortalClient portalClient,
|
ITnsuPortalClient portalClient,
|
||||||
|
TimeProvider timeProvider,
|
||||||
IOptions<RemindersOptions> options,
|
IOptions<RemindersOptions> options,
|
||||||
ILogger<ReminderService> logger)
|
ILogger<ReminderService> logger)
|
||||||
{
|
{
|
||||||
@@ -32,17 +34,30 @@ public sealed class ReminderService(
|
|||||||
private const string ActiveStatusFilter =
|
private const string ActiveStatusFilter =
|
||||||
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
||||||
|
|
||||||
/// <summary>Оба вида напоминаний разом — то, что реально отдаёт контроллер.</summary>
|
/// <summary>
|
||||||
public async Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
|
/// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
|
||||||
DateTimeOffset now, CancellationToken cancellationToken)
|
/// это был единственный вид напоминаний, и без явного выбора им и остаётся.
|
||||||
|
/// </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 overdue = await GetOverdueAsync(now, cancellationToken);
|
var now = timeProvider.GetUtcNow();
|
||||||
if (overdue.IsFailed) return overdue;
|
return feed switch
|
||||||
|
{
|
||||||
var probation = await GetProbationEndingAsync(now, cancellationToken);
|
ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
|
||||||
if (probation.IsFailed) return probation;
|
_ => GetOverdueAsync(now, cancellationToken),
|
||||||
|
};
|
||||||
return Result.Ok<IReadOnlyList<ReminderDto>>(overdue.Value.Concat(probation.Value).ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
||||||
@@ -149,7 +164,7 @@ public sealed class ReminderService(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
|
||||||
if (approvalListTitle is null)
|
if (approvalListTitle is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
@@ -191,9 +206,17 @@ public sealed class ReminderService(
|
|||||||
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
||||||
// почему-то не проставил её при активации. Без даты старта SLA не
|
// почему-то не проставил её при активации. Без даты старта SLA не
|
||||||
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
||||||
|
//
|
||||||
|
// RequestTypeId — всегда, чтобы искать TicketId в правильном типе
|
||||||
|
// заявок. RequestUniqueCode — то, по чему заявку реально ищут в
|
||||||
|
// портале, добавляется, только если он уже есть (строка данных
|
||||||
|
// могла ещё не появиться или заявка старше появления колонки).
|
||||||
|
var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code
|
||||||
|
? $"requestTypeId={type.Id}, code={code}"
|
||||||
|
: $"requestTypeId={type.Id}";
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена",
|
"Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена",
|
||||||
row.Id, row.TicketId);
|
row.Id, row.TicketId, identifier);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,17 +226,17 @@ public sealed class ReminderService(
|
|||||||
var deadline = activatedAt.AddHours(slaHours);
|
var deadline = activatedAt.AddHours(slaHours);
|
||||||
if (deadline >= now) continue;
|
if (deadline >= now) continue;
|
||||||
|
|
||||||
reminders.Add(new ReminderDto(
|
reminders.Add(new ApprovalOverdueReminderDto(
|
||||||
row.ApproverEmail!,
|
row.ApproverEmail!,
|
||||||
(int)Math.Floor((now - deadline).TotalHours),
|
(int)Math.Floor((now - deadline).TotalHours),
|
||||||
BuildUrl(type, row, listId),
|
|
||||||
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
|
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
|
||||||
type.Id,
|
type.Id,
|
||||||
type.Name,
|
type.Name,
|
||||||
row.TicketId,
|
row.TicketId,
|
||||||
row.Status,
|
row.Status,
|
||||||
uniqueCodes.GetValueOrDefault(row.TicketId),
|
uniqueCodes.GetValueOrDefault(row.TicketId),
|
||||||
ReminderCategory.ApprovalOverdue));
|
BuildUrl(type, row, listId),
|
||||||
|
row.ApproverName));
|
||||||
}
|
}
|
||||||
|
|
||||||
return reminders;
|
return reminders;
|
||||||
@@ -236,21 +259,9 @@ public sealed class ReminderService(
|
|||||||
.Replace("{listId}", Uri.EscapeDataString(listId))
|
.Replace("{listId}", Uri.EscapeDataString(listId))
|
||||||
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
|
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
|
||||||
|
|
||||||
/// <summary>«Заявка на приём — данные» → «Заявка на приём — согласование».</summary>
|
|
||||||
private static string? ApprovalListTitleFor(string dataListTitle)
|
|
||||||
{
|
|
||||||
var index = dataListTitle.LastIndexOf('—');
|
|
||||||
if (index < 0) return null;
|
|
||||||
|
|
||||||
var suffix = dataListTitle[(index + 1)..].Trim();
|
|
||||||
return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? $"{dataListTitle[..index].TrimEnd()} — согласование"
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private const string RowSelect =
|
private const string RowSelect =
|
||||||
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||||
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
|
"TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||||
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
||||||
@@ -290,6 +301,19 @@ public sealed class ReminderService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока.
|
||||||
|
///
|
||||||
|
/// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую
|
||||||
|
/// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам,
|
||||||
|
/// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в
|
||||||
|
/// портале, и напоминание уходило бы на день раньше срока.
|
||||||
|
///
|
||||||
|
/// Константой, а не настройкой: смещение фиксировано для всей организации и не
|
||||||
|
/// меняется от окружения (перехода на летнее время нет).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6);
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
|
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
|
||||||
new Dictionary<string, int>
|
new Dictionary<string, int>
|
||||||
{
|
{
|
||||||
@@ -317,7 +341,16 @@ public sealed class ReminderService(
|
|||||||
|
|
||||||
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
var partitions = archived.Select(x => (x.ListId, x.Number)).Append((current.ListId, current.Number));
|
|
||||||
|
// Испытательный срок — не больше 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 нет, а нужны
|
// на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны
|
||||||
@@ -357,8 +390,14 @@ public sealed class ReminderService(
|
|||||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
|
if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
|
||||||
|
|
||||||
|
// HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две
|
||||||
|
// последних партиции (см. GetProbationEndingAsync): более старый приём уже
|
||||||
|
// не может дать напоминание, читать его строки незачем даже в пределах
|
||||||
|
// партиции, где они могут лежать вперемешку со свежими.
|
||||||
|
var since = now.AddMonths(-6);
|
||||||
var filter =
|
var filter =
|
||||||
$"$filter=RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}'";
|
$"$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(
|
var items = await sharePoint.GetItemsByListTitleAsync(
|
||||||
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
|
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
|
||||||
var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
@@ -366,15 +405,20 @@ public sealed class ReminderService(
|
|||||||
var reminders = new List<ReminderDto>();
|
var reminders = new List<ReminderDto>();
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
var reminder = await TryBuildProbationReminderAsync(type, row, listId, directory, now, cancellationToken);
|
var reminder = await TryBuildProbationReminderAsync(row, directory, now, cancellationToken);
|
||||||
if (reminder is not null) reminders.Add(reminder);
|
if (reminder is not null) reminders.Add(reminder);
|
||||||
}
|
}
|
||||||
|
|
||||||
return reminders;
|
return reminders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ни type, ни listId здесь больше не нужны: ссылка в этом виде напоминаний —
|
||||||
|
/// константа из настроек (см. ProbationReminderUrl), а поля самой заявки
|
||||||
|
/// (RequestTypeId и т.п.) в ProbationEndingReminderDto не входят.
|
||||||
|
/// </summary>
|
||||||
private async Task<ReminderDto?> TryBuildProbationReminderAsync(
|
private async Task<ReminderDto?> TryBuildProbationReminderAsync(
|
||||||
RequestType type, HireRow row, string listId, EmployeeDirectorySnapshot directory,
|
HireRow row, EmployeeDirectorySnapshot directory,
|
||||||
DateTimeOffset now, CancellationToken cancellationToken)
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null;
|
if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null;
|
||||||
@@ -382,17 +426,26 @@ public sealed class ReminderService(
|
|||||||
if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months))
|
if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months))
|
||||||
return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
|
return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
|
||||||
|
|
||||||
var probationEndsAt = hireDate.AddMonths(months);
|
// Обе даты — в местном поясе (см. LocalOffset): и дату приёма, и «сегодня»
|
||||||
var daysLeft = (probationEndsAt.Date - now.Date).Days;
|
// надо брать по тому же календарю, который видит человек в портале.
|
||||||
|
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;
|
if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null;
|
||||||
|
|
||||||
var candidateName = row.CandidateName.Trim();
|
var candidateName = row.CandidateName.Trim();
|
||||||
|
|
||||||
|
// Перечислены оба «продолжающих» исхода явно, а «пропустить» — это default:
|
||||||
|
// так любое НЕ подтверждённое трудоустройство (в том числе EmploymentStatus.None,
|
||||||
|
// если он когда-нибудь сюда доедет) приводит к пропуску с предупреждением, а не
|
||||||
|
// молча считается «работает».
|
||||||
switch (directory.EmploymentOf(candidateName))
|
switch (directory.EmploymentOf(candidateName))
|
||||||
{
|
{
|
||||||
|
case EmploymentStatus.Employed:
|
||||||
|
break;
|
||||||
case EmploymentStatus.Dismissed:
|
case EmploymentStatus.Dismissed:
|
||||||
return null; // уже уволен — напоминать не о чем.
|
return null; // уже уволен — напоминать не о чем.
|
||||||
case EmploymentStatus.NotFound:
|
default:
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено",
|
"Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено",
|
||||||
row.Id, candidateName);
|
row.Id, candidateName);
|
||||||
@@ -417,35 +470,20 @@ public sealed class ReminderService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ReminderDto(
|
return new ProbationEndingReminderDto(
|
||||||
Email: managerEmail,
|
ReceiverEmail: managerEmail,
|
||||||
HoursOverdue: 0,
|
|
||||||
Url: BuildProbationUrl(type, row, listId),
|
|
||||||
AskOverdueReason: false,
|
|
||||||
RequestTypeId: type.Id,
|
|
||||||
RequestTypeName: type.Name,
|
|
||||||
RequestId: row.Id,
|
|
||||||
Status: row.Status,
|
|
||||||
RequestUniqueCode: row.RequestUniqueCode,
|
|
||||||
Category: ReminderCategory.ProbationEnding,
|
|
||||||
ManagerEmail: managerEmail,
|
|
||||||
CandidateEmail: candidateEmail,
|
CandidateEmail: candidateEmail,
|
||||||
CandidateName: candidateName,
|
CandidateName: candidateName,
|
||||||
ProbationPeriod: row.ProbationPeriod,
|
ProbationPeriod: row.ProbationPeriod,
|
||||||
HireDate: row.HireDate,
|
// Тоже в местном поясе — иначе в письме дата приёма была бы на сутки
|
||||||
|
// раньше той, от которой посчитан ProbationEndsAt рядом.
|
||||||
|
HireDate: hireDateLocal,
|
||||||
ProbationEndsAt: probationEndsAt,
|
ProbationEndsAt: probationEndsAt,
|
||||||
DaysUntilProbationEnds: daysLeft);
|
DaysUntilProbationEnds: daysLeft,
|
||||||
|
// Не ссылка на саму заявку на приём (она уже закрыта, решать по ней
|
||||||
|
// нечего) — константный адрес раздела, где руководитель заводит новую
|
||||||
|
// заявку по итогам испытательного срока. См. ProbationReminderUrl.
|
||||||
|
Url: options.Value.ProbationReminderUrl,
|
||||||
|
ReceiverName: directory.NameByEmail(managerEmail));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ссылка на саму заявку (не на шаг согласования — этого вида напоминаний
|
|
||||||
/// понятие "фаза" не касается), в том же формате, что и BuildUrl.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildProbationUrl(RequestType type, HireRow row, string listId) =>
|
|
||||||
options.Value.RequestUrlTemplate
|
|
||||||
.Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/'))
|
|
||||||
.Replace("{pageUrl}", type.PageUrl)
|
|
||||||
.Replace("{requestId}", row.Id.ToString())
|
|
||||||
.Replace("{listId}", Uri.EscapeDataString(listId))
|
|
||||||
.Replace("{nav}", "approvals");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ public sealed class RemindersOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
||||||
|
|
||||||
/// <summary>За сколько дней до конца испытательного срока присылать напоминание.</summary>
|
/// <summary>
|
||||||
public int ProbationReminderDaysBeforeEnd { get; set; } = 3;
|
/// За сколько дней до конца испытательного срока присылать напоминание — неделя,
|
||||||
|
/// чтобы у руководителя было время подготовить решение по итогам срока.
|
||||||
|
///
|
||||||
|
/// Дни КАЛЕНДАРНЫЕ, не рабочие: напоминание строится ровно в тот день, когда до
|
||||||
|
/// конца срока остаётся столько дней, и попади он на выходной — уйдёт в выходной.
|
||||||
|
/// </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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
// AutoApprove — «согласовать шаг автоматически по истечении SLA». Обычный bool,
|
||||||
|
// а не три состояния: наследовать его неоткуда (см. TemplateRouteStep.AutoApprove).
|
||||||
|
//
|
||||||
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
|
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
|
||||||
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
|
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
|
||||||
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
||||||
@@ -24,7 +27,8 @@ public sealed record TemplateRouteStepDto(
|
|||||||
ConditionsLogic ConditionsLogic,
|
ConditionsLogic ConditionsLogic,
|
||||||
string? Warning = null,
|
string? Warning = null,
|
||||||
int? SlaHours = null,
|
int? SlaHours = null,
|
||||||
bool? AskOverdueReason = null);
|
bool? AskOverdueReason = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
public sealed record CreateTemplateRouteStepRequest(
|
public sealed record CreateTemplateRouteStepRequest(
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
@@ -35,9 +39,10 @@ public sealed record CreateTemplateRouteStepRequest(
|
|||||||
RouteStepStatus Status,
|
RouteStepStatus Status,
|
||||||
bool AllowReassignToOtherApprovers,
|
bool AllowReassignToOtherApprovers,
|
||||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||||
ConditionsLogic ConditionsLogic = ConditionsLogic.And,
|
ConditionsLogic ConditionsLogic,
|
||||||
int? SlaHours = null,
|
int? SlaHours = null,
|
||||||
bool? AskOverdueReason = null);
|
bool? AskOverdueReason = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
public sealed record UpdateTemplateRouteStepRequest(
|
public sealed record UpdateTemplateRouteStepRequest(
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
@@ -48,9 +53,10 @@ public sealed record UpdateTemplateRouteStepRequest(
|
|||||||
RouteStepStatus Status,
|
RouteStepStatus Status,
|
||||||
bool AllowReassignToOtherApprovers,
|
bool AllowReassignToOtherApprovers,
|
||||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||||
ConditionsLogic ConditionsLogic = ConditionsLogic.And,
|
ConditionsLogic ConditionsLogic,
|
||||||
int? SlaHours = null,
|
int? SlaHours = null,
|
||||||
bool? AskOverdueReason = null);
|
bool? AskOverdueReason = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
|
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
|
||||||
@@ -68,4 +74,5 @@ public sealed record PatchTemplateRouteStepRequest(
|
|||||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||||
ConditionsLogic? ConditionsLogic = null,
|
ConditionsLogic? ConditionsLogic = null,
|
||||||
int? SlaHours = null,
|
int? SlaHours = null,
|
||||||
bool? AskOverdueReason = null);
|
bool? AskOverdueReason = null,
|
||||||
|
bool? AutoApprove = null);
|
||||||
|
|||||||
+9
-5
@@ -1,6 +1,7 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
var entity = new TemplateRouteStep
|
var entity = new TemplateRouteStep
|
||||||
{
|
{
|
||||||
@@ -97,7 +98,8 @@ public sealed class TemplateRouteStepService(
|
|||||||
ConditionsLogic = r.ConditionsLogic,
|
ConditionsLogic = r.ConditionsLogic,
|
||||||
Conditions = ToEntities(r.Conditions),
|
Conditions = ToEntities(r.Conditions),
|
||||||
SlaHours = r.SlaHours,
|
SlaHours = r.SlaHours,
|
||||||
AskOverdueReason = r.AskOverdueReason
|
AskOverdueReason = r.AskOverdueReason,
|
||||||
|
AutoApprove = r.AutoApprove
|
||||||
};
|
};
|
||||||
db.TemplateRouteSteps.Add(entity);
|
db.TemplateRouteSteps.Add(entity);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -140,7 +142,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo;
|
entity.OrderNo = r.OrderNo;
|
||||||
entity.ExecutorEmail = r.ExecutorEmail;
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
@@ -153,6 +155,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
ReplaceConditions(entity, r.Conditions);
|
ReplaceConditions(entity, r.Conditions);
|
||||||
entity.SlaHours = r.SlaHours;
|
entity.SlaHours = r.SlaHours;
|
||||||
entity.AskOverdueReason = r.AskOverdueReason;
|
entity.AskOverdueReason = r.AskOverdueReason;
|
||||||
|
entity.AutoApprove = r.AutoApprove;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||||
@@ -208,7 +211,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
entity.ExecutorEmail = executorEmail;
|
entity.ExecutorEmail = executorEmail;
|
||||||
@@ -224,6 +227,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
// наследованию SLA от типа заявки можно через PUT.
|
// наследованию SLA от типа заявки можно через PUT.
|
||||||
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
||||||
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
||||||
|
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||||
@@ -366,5 +370,5 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||||
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
|
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
|
||||||
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason);
|
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason, e.AutoApprove);
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -8,6 +8,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
|
|||||||
public CreateTemplateRouteStepRequestValidator()
|
public CreateTemplateRouteStepRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
RuleFor(x => x.ConditionsLogic)
|
||||||
|
.NotEqual(ConditionsLogic.None)
|
||||||
|
.WithMessage("Логика объединения условий обязательна: And или Or.");
|
||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
@@ -28,6 +31,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
|
|||||||
public UpdateTemplateRouteStepRequestValidator()
|
public UpdateTemplateRouteStepRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
RuleFor(x => x.ConditionsLogic)
|
||||||
|
.NotEqual(ConditionsLogic.None)
|
||||||
|
.WithMessage("Логика объединения условий обязательна: And или Or.");
|
||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
@@ -43,6 +49,35 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// У PATCH-запроса шага валидатора раньше не было вовсе — здесь он ровно про одно:
|
||||||
|
/// не дать протащить невалидные значения-заглушки в поля, которые запрос ЯВНО
|
||||||
|
/// задаёт. null остаётся «не менять» и не проверяется (см. PatchTemplateRouteStepRequest).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PatchTemplateRouteStepRequestValidator : AbstractValidator<PatchTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public PatchTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Status is not null, () =>
|
||||||
|
RuleFor(x => x.Status)
|
||||||
|
.NotEqual(RouteStepStatus.None)
|
||||||
|
.WithMessage("Статус обязателен: Approval или Execution."));
|
||||||
|
When(x => x.ConditionsLogic is not null, () =>
|
||||||
|
RuleFor(x => x.ConditionsLogic)
|
||||||
|
.NotEqual(ConditionsLogic.None)
|
||||||
|
.WithMessage("Логика объединения условий обязательна: And или Or."));
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||||
|
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static class TemplateRouteStepFields
|
internal static class TemplateRouteStepFields
|
||||||
{
|
{
|
||||||
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
|
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
|||||||
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
|
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
|
||||||
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
|
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="AutoApprove">
|
||||||
|
/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть —
|
||||||
|
/// наследовать его неоткуда (у типа заявки такого поля нет, см.
|
||||||
|
/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто
|
||||||
|
/// переносит его в строку списка согласования: решает по нему не она, а фоновая
|
||||||
|
/// задача бэкенда (AutoApproveService).
|
||||||
|
/// </param>
|
||||||
public sealed record ResolvedRouteStepDto(
|
public sealed record ResolvedRouteStepDto(
|
||||||
int Id,
|
int Id,
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
@@ -29,7 +36,8 @@ public sealed record ResolvedRouteStepDto(
|
|||||||
string? Warning = null,
|
string? Warning = null,
|
||||||
int? SlaHours = null,
|
int? SlaHours = null,
|
||||||
bool? AskOverdueReason = null,
|
bool? AskOverdueReason = null,
|
||||||
HeadType? PositionCode = null);
|
HeadType? PositionCode = null,
|
||||||
|
bool AutoApprove = false);
|
||||||
|
|
||||||
public sealed record ResolveTemplateRouteResultDto(
|
public sealed record ResolveTemplateRouteResultDto(
|
||||||
int RouteId,
|
int RouteId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using FluentResults;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -385,7 +386,8 @@ public sealed class TemplateRouteService(
|
|||||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||||
step.SlaHours ?? requestType.SlaHours,
|
step.SlaHours ?? requestType.SlaHours,
|
||||||
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||||
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
step.HeadType is null or HeadType.None ? null : step.HeadType,
|
||||||
|
step.AutoApprove);
|
||||||
|
|
||||||
return new ResolvedStepRow(dto, resolvedId);
|
return new ResolvedStepRow(dto, resolvedId);
|
||||||
}
|
}
|
||||||
@@ -436,7 +438,7 @@ public sealed class TemplateRouteService(
|
|||||||
{
|
{
|
||||||
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
|
||||||
}
|
}
|
||||||
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||||||
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||||||
@@ -468,7 +470,14 @@ public sealed class TemplateRouteService(
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
warning = $"Не найден руководитель типа «{step.HeadType}».";
|
{
|
||||||
|
var headLabel = StepLabels.InSentence(step.HeadType.Value);
|
||||||
|
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
|
||||||
|
// справочника сотрудников — там email не «не найден», а «не проставлен».
|
||||||
|
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
|
||||||
|
? $"Не проставлен {headLabel}."
|
||||||
|
: $"Не найден {headLabel}.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -611,7 +620,7 @@ public sealed class TemplateRouteService(
|
|||||||
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||||||
{
|
{
|
||||||
var conflictWarning =
|
var conflictWarning =
|
||||||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
|
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
|
||||||
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||||
result[index] = existing with { Warning = combinedWarning };
|
result[index] = existing with { Warning = combinedWarning };
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- Генерация xlsx для отчётов (Reports/) — стили, формулы, autofilter, диаграммы. -->
|
||||||
|
<PackageReference Include="NPOI" />
|
||||||
<PackageReference Include="FluentResults" />
|
<PackageReference Include="FluentResults" />
|
||||||
<PackageReference Include="FluentValidation" />
|
<PackageReference Include="FluentValidation" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Право одного пользователя видеть ВСЕ заявки одного типа в портале заявок
|
||||||
|
/// (а не только свои). У одного человека может быть по строке на каждый тип
|
||||||
|
/// заявок, к которому ему открыт полный доступ.
|
||||||
|
/// </summary>
|
||||||
|
public class RequestTypeAccess
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -31,6 +31,20 @@ public class TemplateRouteStep
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool? AskOverdueReason { get; set; }
|
public bool? AskOverdueReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Согласовывать шаг автоматически, когда истёк его SLA (см. <see cref="SlaHours"/>).
|
||||||
|
///
|
||||||
|
/// Не nullable, в отличие от SLA-полей рядом: наследовать тут нечего — у типа
|
||||||
|
/// заявки такого «значения по умолчанию» нет и быть не должно (включить
|
||||||
|
/// автосогласование сразу всем шагам типа — не настройка, а авария), поэтому
|
||||||
|
/// три состояния не нужны, хватает обычного «включено/выключено».
|
||||||
|
///
|
||||||
|
/// Действует только вместе с SLA: без действующего SLA (ни на шаге, ни на типе
|
||||||
|
/// заявки) отсчитывать нечего, и фоновая задача такой шаг пропускает
|
||||||
|
/// (см. AutoApproveService).
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoApprove { get; set; }
|
||||||
|
|
||||||
/// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
|
/// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
|
||||||
public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
|
public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,21 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public enum ConditionsLogic
|
public enum ConditionsLogic
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Логика не задана — НЕвалидное значение (см. валидаторы шага), а не «как И».
|
||||||
|
/// Существует только как значение по умолчанию (default(ConditionsLogic)):
|
||||||
|
/// шаг с такой логикой не должен ни создаваться, ни сохраняться.
|
||||||
|
///
|
||||||
|
/// Именно поэтому у самой сущности и у запросов на создание/обновление
|
||||||
|
/// значение по умолчанию задано явно — <c>ConditionsLogic.And</c>
|
||||||
|
/// (см. TemplateRouteStep.ConditionsLogic и *TemplateRouteStepRequest):
|
||||||
|
/// пропущенное в запросе поле по-прежнему означает «И», как и раньше.
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
/// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary>
|
/// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary>
|
||||||
And,
|
And = 1,
|
||||||
|
|
||||||
/// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary>
|
/// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary>
|
||||||
Or
|
Or = 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||||
|
public DbSet<RequestTypeAccess> RequestTypeAccesses => Set<RequestTypeAccess>();
|
||||||
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
|
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
|
||||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||||
@@ -35,6 +36,15 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RequestTypeAccess>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||||
|
e.HasOne(x => x.RequestType).WithMany().HasForeignKey(x => x.RequestTypeId);
|
||||||
|
// Одна и та же пара тип+email не должна выдаваться дважды.
|
||||||
|
e.HasIndex(x => new { x.RequestTypeId, x.Email }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RequestListPartition>(e =>
|
modelBuilder.Entity<RequestListPartition>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -119,6 +119,35 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_type_accesses");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Email")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
|
||||||
|
|
||||||
|
b.ToTable("request_type_accesses", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -175,6 +204,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("ask_overdue_reason");
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
|
b.Property<bool>("AutoApprove")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("auto_approve");
|
||||||
|
|
||||||
b.Property<string>("ConditionsLogic")
|
b.Property<string>("ConditionsLogic")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
@@ -305,6 +338,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
|||||||
@@ -53,6 +53,27 @@ public interface ISharePointClient
|
|||||||
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
|
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
|
||||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
||||||
|
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
||||||
|
/// как «имя managed property → значение» (RowLimit — не больше 500).
|
||||||
|
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||||
|
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
|
||||||
|
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
|
||||||
|
/// перезаписываются, остальные остаются как были.
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateItemByListTitleAsync(
|
||||||
|
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>То же по серверному относительному url списка (см. RequestType.ListId).</summary>
|
||||||
|
Task UpdateItemByListUrlAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -208,6 +229,89 @@ public sealed class SharePointClient(
|
|||||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||||
|
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var body = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
request = new
|
||||||
|
{
|
||||||
|
Querytext = queryText,
|
||||||
|
RowLimit = 500,
|
||||||
|
SelectProperties = selectProperties,
|
||||||
|
TrimDuplicates = false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var json = await SendAsync(
|
||||||
|
HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body,
|
||||||
|
allowNotFound: false, cancellationToken);
|
||||||
|
|
||||||
|
var result = new List<IReadOnlyDictionary<string, string?>>();
|
||||||
|
if (json is null
|
||||||
|
|| !json.Value.TryGetProperty("PrimaryQueryResult", out var primary)
|
||||||
|
|| !primary.TryGetProperty("RelevantResults", out var relevant)
|
||||||
|
|| !relevant.TryGetProperty("Table", out var table)
|
||||||
|
|| !table.TryGetProperty("Rows", out var rows))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба.
|
||||||
|
if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped))
|
||||||
|
rows = wrapped;
|
||||||
|
if (rows.ValueKind != JsonValueKind.Array) return result;
|
||||||
|
|
||||||
|
foreach (var row in rows.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!row.TryGetProperty("Cells", out var cells)) continue;
|
||||||
|
if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells))
|
||||||
|
cells = wrappedCells;
|
||||||
|
|
||||||
|
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var cell in cells.EnumerateArray())
|
||||||
|
{
|
||||||
|
var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null;
|
||||||
|
if (key is null) continue;
|
||||||
|
dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateItemByListTitleAsync(
|
||||||
|
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
|
||||||
|
MergeAsync(
|
||||||
|
siteUrl,
|
||||||
|
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items({itemId})",
|
||||||
|
fields,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task UpdateItemByListUrlAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken) =>
|
||||||
|
MergeAsync(
|
||||||
|
siteUrl,
|
||||||
|
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})",
|
||||||
|
fields,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Частичное обновление элемента: у SharePoint REST это POST с заголовком
|
||||||
|
/// X-HTTP-Method: MERGE (PATCH он не понимает), и обязательным If-Match.
|
||||||
|
///
|
||||||
|
/// If-Match: * — намеренно «перезаписать, чей бы ни был последний етаг», а не
|
||||||
|
/// оптимистичная блокировка по конкретной версии. Пишем мы точечно, по
|
||||||
|
/// отдельным колонкам, а единственный конкурент — человек, принявший решение
|
||||||
|
/// по тому же шагу вручную; его защищает не етаг, а проверка статуса строки
|
||||||
|
/// перед записью (см. AutoApproveService). Реальный етаг пришлось бы читать
|
||||||
|
/// отдельным запросом, и он всё равно устаревал бы к моменту записи.
|
||||||
|
/// </summary>
|
||||||
|
private Task MergeAsync(string siteUrl, string url, object fields, CancellationToken cancellationToken) =>
|
||||||
|
SendAsync(
|
||||||
|
HttpMethod.Post, siteUrl, url, JsonSerializer.Serialize(fields),
|
||||||
|
allowNotFound: false, cancellationToken, merge: true);
|
||||||
|
|
||||||
private async Task<IReadOnlyList<JsonElement>> GetItemsAsync(
|
private async Task<IReadOnlyList<JsonElement>> GetItemsAsync(
|
||||||
string siteUrl, string url, CancellationToken cancellationToken)
|
string siteUrl, string url, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -240,9 +344,16 @@ public sealed class SharePointClient(
|
|||||||
string url,
|
string url,
|
||||||
string? body,
|
string? body,
|
||||||
bool allowNotFound,
|
bool allowNotFound,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
bool merge = false)
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(method, url);
|
using var request = new HttpRequestMessage(method, url);
|
||||||
|
if (merge)
|
||||||
|
{
|
||||||
|
request.Headers.Add("X-HTTP-Method", "MERGE");
|
||||||
|
request.Headers.Add("If-Match", "*");
|
||||||
|
}
|
||||||
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
|
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
|
|||||||
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
|
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken));
|
||||||
|
|
||||||
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync(
|
GetOrAddAsync(
|
||||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||||
|
|||||||
@@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
|
|
||||||
return employees
|
return employees
|
||||||
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
|
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
|
||||||
.Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty))
|
.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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
[property: JsonPropertyName("fio")] string? Fio,
|
[property: JsonPropertyName("fio")] string? Fio,
|
||||||
[property: JsonPropertyName("id")] Guid? Id = null);
|
[property: JsonPropertyName("id")] Guid? Id = null);
|
||||||
|
|
||||||
|
private sealed record DictionaryItemResponse(
|
||||||
|
[property: JsonPropertyName("id")] Guid? Id,
|
||||||
|
[property: JsonPropertyName("name")] string? Name);
|
||||||
|
|
||||||
private sealed record ManagerResponse(
|
private sealed record ManagerResponse(
|
||||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ public interface ITnsuPortalClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Справочник компаний — /Dictionary/Companies.</summary>
|
||||||
|
Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Справочник отделов — /Dictionary/Departments.</summary>
|
||||||
|
Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company,
|
|||||||
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
||||||
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company);
|
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("Отклонено");
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
-15
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -30,8 +31,28 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
private const string DataListTitle = "Заявка тест — данные";
|
private const string DataListTitle = "Заявка тест — данные";
|
||||||
private const string ApprovalListTitle = "Заявка тест — согласование";
|
private const string ApprovalListTitle = "Заявка тест — согласование";
|
||||||
|
|
||||||
|
// Своё значение (не то, что по умолчанию в RemindersOptions) — так тест
|
||||||
|
// проверяет, что ссылка действительно берётся из настроек, а не совпадает
|
||||||
|
// с ожидаемой случайно.
|
||||||
|
private const string ProbationReminderUrl = "https://portal.test/новая-заявка";
|
||||||
|
|
||||||
|
// Порог фиксируем в тестах СВОИМ значением, не полагаясь на дефолт из
|
||||||
|
// RemindersOptions: изменение продового порога (3 → 7 дней и далее) не должно
|
||||||
|
// ломать тесты, которые проверяют вовсе не его.
|
||||||
|
private const int ProbationDaysBeforeEnd = 3;
|
||||||
|
|
||||||
private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
// GetOverdueAsync/GetProbationEndingAsync по-прежнему берут now параметром — их
|
||||||
|
// тесты ниже дают его напрямую. TimeProvider нужен только там, где ReminderService
|
||||||
|
// сам решает, «сейчас» сколько — то есть в GetRemindersAsync (см. её doc-комментарий).
|
||||||
|
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly TimeProvider Clock = new FixedTimeProvider(Now);
|
||||||
|
|
||||||
private readonly ServiceProvider _services;
|
private readonly ServiceProvider _services;
|
||||||
private readonly RequestTypeService _requestTypeService;
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
@@ -123,6 +144,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
sharePoint.Object,
|
sharePoint.Object,
|
||||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
Mock.Of<ITnsuPortalClient>(),
|
Mock.Of<ITnsuPortalClient>(),
|
||||||
|
Clock,
|
||||||
Options.Create(new RemindersOptions()),
|
Options.Create(new RemindersOptions()),
|
||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
}
|
}
|
||||||
@@ -138,11 +160,36 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.IsSuccess.ShouldBeTrue();
|
result.IsSuccess.ShouldBeTrue();
|
||||||
var reminder = result.Value.ShouldHaveSingleItem();
|
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>();
|
||||||
reminder.Email.ShouldBe("approver@tnsu.kz");
|
reminder.Email.ShouldBe("approver@tnsu.kz");
|
||||||
reminder.HoursOverdue.ShouldBe(24);
|
reminder.HoursOverdue.ShouldBe(24);
|
||||||
reminder.RequestId.ShouldBe(1);
|
reminder.RequestId.ShouldBe(1);
|
||||||
reminder.Category.ShouldBe(ReminderCategory.ApprovalOverdue);
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "GetRemindersAsync без feed — обратная совместимость, как ApprovalOverdue")]
|
||||||
|
public async Task GetRemindersAsync_WithoutFeed_BehavesLikeApprovalOverdue()
|
||||||
|
{
|
||||||
|
var listUrl = await CreateTypeWithSlaAsync("Sla feed default", slaHours: 24);
|
||||||
|
var service = BuildService(listUrl,
|
||||||
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
|
||||||
|
|
||||||
|
var result = await service.GetRemindersAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(24);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "GetRemindersAsync(feed: ProbationEnding) — просроченные шаги согласования игнорируются")]
|
||||||
|
public async Task GetRemindersAsync_ProbationEndingFeed_IgnoresApprovalOverdue()
|
||||||
|
{
|
||||||
|
var listUrl = await CreateTypeWithSlaAsync("Sla feed probation", slaHours: 24);
|
||||||
|
var service = BuildService(listUrl,
|
||||||
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
|
||||||
|
|
||||||
|
var result = await service.GetRemindersAsync(ReminderFeed.ProbationEnding, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
|
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
|
||||||
@@ -173,7 +220,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
||||||
@@ -199,7 +246,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
|
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
|
||||||
@@ -219,7 +266,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
|
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
|
||||||
@@ -231,7 +278,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().AskOverdueReason.ShouldBeTrue();
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().AskOverdueReason.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
|
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
|
||||||
@@ -276,7 +323,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz");
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().Email.ShouldBe("approver@tnsu.kz");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object HireDataRow(
|
private static object HireDataRow(
|
||||||
@@ -293,7 +340,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
private ReminderService BuildProbationService(
|
private ReminderService BuildProbationService(
|
||||||
string pageUrl, string listUrl, IReadOnlyList<object> rows, Mock<ITnsuPortalClient> portalClient,
|
string pageUrl, string listUrl, IReadOnlyList<object> rows, Mock<ITnsuPortalClient> portalClient,
|
||||||
int daysBeforeEnd = 3)
|
int daysBeforeEnd = ProbationDaysBeforeEnd)
|
||||||
{
|
{
|
||||||
const string hireDataListTitle = "Прием на работу тест — данные";
|
const string hireDataListTitle = "Прием на работу тест — данные";
|
||||||
|
|
||||||
@@ -317,7 +364,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
sharePoint.Object,
|
sharePoint.Object,
|
||||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
portalClient.Object,
|
portalClient.Object,
|
||||||
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }),
|
Clock,
|
||||||
|
Options.Create(new RemindersOptions
|
||||||
|
{
|
||||||
|
RecruitingHirePageUrl = pageUrl,
|
||||||
|
ProbationReminderDaysBeforeEnd = daysBeforeEnd,
|
||||||
|
ProbationReminderUrl = ProbationReminderUrl
|
||||||
|
}),
|
||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,13 +408,14 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.IsSuccess.ShouldBeTrue();
|
result.IsSuccess.ShouldBeTrue();
|
||||||
var reminder = result.Value.ShouldHaveSingleItem();
|
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
|
||||||
reminder.Category.ShouldBe(ReminderCategory.ProbationEnding);
|
reminder.ReceiverEmail.ShouldBe("manager@tnsu.kz");
|
||||||
reminder.ManagerEmail.ShouldBe("manager@tnsu.kz");
|
|
||||||
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
|
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
|
||||||
reminder.Email.ShouldBe("manager@tnsu.kz");
|
|
||||||
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
|
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
|
||||||
reminder.DaysUntilProbationEnds.ShouldBe(3);
|
reminder.DaysUntilProbationEnds.ShouldBe(3);
|
||||||
|
// Ссылка ведёт не на саму заявку на приём, а на раздел, где заводят новую
|
||||||
|
// (константа из настроек, см. RemindersOptions.ProbationReminderUrl).
|
||||||
|
reminder.Url.ShouldBe(ProbationReminderUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")]
|
[Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")]
|
||||||
@@ -407,7 +461,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().CandidateEmail.ShouldBe("romanov@tnsu.kz");
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("romanov@tnsu.kz");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны
|
// ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны
|
||||||
@@ -423,7 +477,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().CandidateEmail.ShouldBe("volkov@tnsu.kz");
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("volkov@tnsu.kz");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]
|
[Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]
|
||||||
@@ -472,4 +526,97 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
result.Value.ShouldBeEmpty();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-32
@@ -169,7 +169,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -206,7 +206,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(
|
new CreateTemplateRouteStepRequest(
|
||||||
1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null,
|
1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And,
|
||||||
SlaHours: stepSla, AskOverdueReason: stepAsk),
|
SlaHours: stepSla, AskOverdueReason: stepAsk),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -285,15 +285,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
|
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -342,11 +342,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
|
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -361,8 +361,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
|
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldContain("Chief");
|
warning.ShouldContain("Непосредственный руководитель");
|
||||||
warning.ShouldContain("DirectionHead");
|
warning.ShouldContain("Руководитель направления");
|
||||||
|
warning.ShouldNotContain("DirectionHead");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
||||||
@@ -377,7 +378,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action — departmentId в запросе не передан
|
// Action — departmentId в запросе не передан
|
||||||
@@ -419,7 +420,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -459,7 +460,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -488,7 +489,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -515,11 +516,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -546,7 +547,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action — fieldValues не переданы вовсе
|
// Action — fieldValues не переданы вовсе
|
||||||
@@ -571,7 +572,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }),
|
new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -605,7 +606,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }),
|
new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -672,7 +673,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -710,7 +711,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -749,7 +750,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -785,7 +786,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -832,7 +833,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -883,7 +884,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -949,7 +950,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -998,11 +999,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail,
|
// Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail,
|
||||||
@@ -1036,7 +1037,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -1050,7 +1051,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldContain("руководитель");
|
warning.ShouldBe("Не найден непосредственный руководитель.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
||||||
@@ -1065,7 +1066,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -1094,7 +1095,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -1108,7 +1109,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldContain("роль");
|
warning.ShouldBe("В проекте не найден сотрудник службы безопасности.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||||
|
|||||||
+42
-36
@@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var result = await _service.CreateAsync(
|
var result = await _service.CreateAsync(
|
||||||
999999,
|
999999,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -59,7 +59,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var result = await _service.CreateAsync(
|
var result = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -71,14 +71,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
|
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
|
||||||
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null);
|
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And);
|
||||||
var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
|
var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
|
||||||
first.IsSuccess.ShouldBeTrue();
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
var duplicate = await _service.CreateAsync(
|
var duplicate = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null),
|
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -153,14 +153,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
|
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
|
||||||
var first = await _service.CreateAsync(
|
var first = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
first.IsSuccess.ShouldBeTrue();
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
|
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
|
||||||
var second = await _service.CreateAsync(
|
var second = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null),
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -174,14 +174,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
|
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
|
||||||
var first = await _service.CreateAsync(
|
var first = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
first.IsSuccess.ShouldBeTrue();
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
var second = await _service.CreateAsync(
|
var second = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null),
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -197,11 +197,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var chief = await _service.CreateAsync(
|
var chief = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
var directorTansu = await _service.CreateAsync(
|
var directorTansu = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -219,11 +219,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var small = await _service.CreateAsync(
|
var small = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
var big = await _service.CreateAsync(
|
var big = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -240,11 +240,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var small = await _service.CreateAsync(
|
var small = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
var big = await _service.CreateAsync(
|
var big = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -259,14 +259,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
|
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
|
||||||
var first = await _service.CreateAsync(
|
var first = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
first.IsSuccess.ShouldBeTrue();
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
// Action — то же условие, другой статус
|
// Action — то же условие, другой статус
|
||||||
var second = await _service.CreateAsync(
|
var second = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -280,14 +280,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
|
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
|
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
|
||||||
var updated = await _service.UpdateAsync(
|
var updated = await _service.UpdateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
created.Value.Id,
|
created.Value.Id,
|
||||||
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null),
|
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -303,7 +303,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -321,7 +321,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -341,7 +341,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -359,7 +359,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -374,14 +374,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
var updated = await _service.UpdateAsync(
|
var updated = await _service.UpdateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
created.Value.Id,
|
created.Value.Id,
|
||||||
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null),
|
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -398,7 +398,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
|
var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -422,7 +422,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
|
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -443,7 +443,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
|
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -457,6 +457,12 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
patched.IsFailed.ShouldBeTrue();
|
patched.IsFailed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConditionsLogic здесь всюду передаётся явно, потому что он обязателен: у
|
||||||
|
// Create/Update-запроса значения по умолчанию нет вовсе, а пропущенное в JSON
|
||||||
|
// поле превращается в ConditionsLogic.None и отвергается валидатором на слое
|
||||||
|
// API (TemplateRouteStepValidators + ValidationActionFilter). Отдельным тестом
|
||||||
|
// это не покрыто — сервис валидаторы не вызывает.
|
||||||
|
|
||||||
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
|
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
|
||||||
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
|
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
|
||||||
{
|
{
|
||||||
@@ -464,7 +470,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
|
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -489,7 +495,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var result = await _service.CreateAsync(
|
var result = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false,
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false,
|
||||||
new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }),
|
new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -505,7 +511,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var result = await _service.CreateAsync(
|
var result = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -521,7 +527,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -539,7 +545,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
|
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -560,7 +566,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -581,16 +587,16 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
|
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
|
||||||
await _service.CreateAsync(
|
await _service.CreateAsync(
|
||||||
routeAId,
|
routeAId,
|
||||||
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
await _service.CreateAsync(
|
await _service.CreateAsync(
|
||||||
routeBId,
|
routeBId,
|
||||||
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null),
|
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
// Другой исполнитель — не должен попасть в выдачу.
|
// Другой исполнитель — не должен попасть в выдачу.
|
||||||
await _service.CreateAsync(
|
await _service.CreateAsync(
|
||||||
routeAId,
|
routeAId,
|
||||||
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
|
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
|
|||||||
+431
@@ -0,0 +1,431 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сборка отчёта по вакансиям из трёх источников: заявка на подбор, её фаза
|
||||||
|
/// исполнения (рекрутёр и дата закрытия) и связанные заявки на приём (кого и
|
||||||
|
/// когда приняли).
|
||||||
|
///
|
||||||
|
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||||||
|
/// возвращает REST, — так тесты заодно покрывают и разбор ответа (в частности
|
||||||
|
/// дробные «1.0» у Number-колонок).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||||
|
private const string DataListTitle = "Заявка на подбор — данные";
|
||||||
|
private const string ApprovalListTitle = "Заявка на подбор — согласование";
|
||||||
|
|
||||||
|
private static readonly DateOnly From = new(2026, 7, 1);
|
||||||
|
private static readonly DateOnly To = new(2026, 8, 31);
|
||||||
|
|
||||||
|
private readonly ServiceProvider _services;
|
||||||
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
|
public VacancyReportServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||||
|
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// База у тестов класса общая, а сервис ищет тип заявки по PageUrl — поэтому
|
||||||
|
// у каждого теста свои списки и PageUrl (оба выводятся из имени теста), и
|
||||||
|
// тесты не видят записи друг друга.
|
||||||
|
private static string SelectionListUrl(string name) => $"/sites/requests/Lists/Подбор-{name}";
|
||||||
|
private static string HireListUrl(string name) => $"/sites/requests/Lists/Приём-{name}";
|
||||||
|
|
||||||
|
private async Task CreateTypeAsync(string name, string listUrl, string pageUrl) =>
|
||||||
|
await _requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||||
|
|
||||||
|
/// <summary>Элемент списка «Заявка на подбор — данные» в том виде, в каком его отдаёт REST.</summary>
|
||||||
|
private static object Selection(
|
||||||
|
int id,
|
||||||
|
string position,
|
||||||
|
string? objectProject = null,
|
||||||
|
double vacancies = 1,
|
||||||
|
string? submitDate = "2026-07-08T00:00:00Z",
|
||||||
|
string? created = "2026-07-08T00:00:00Z",
|
||||||
|
string? responsible = "Ким Наталья Юрьевна",
|
||||||
|
string? openReason = null,
|
||||||
|
string? openReasonDetails = null) =>
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
RequestStatus = "Выполнение",
|
||||||
|
Position = position,
|
||||||
|
DepartmentName = "офис",
|
||||||
|
ObjectProject = objectProject,
|
||||||
|
// Number-колонка приходит дробной («1.0») — тест держит её такой же.
|
||||||
|
OpenVacanciesCount = vacancies,
|
||||||
|
SubmitDate = submitDate,
|
||||||
|
Created = created,
|
||||||
|
VacancyOpenReason = openReason,
|
||||||
|
VacancyOpenReasonDetails = openReasonDetails,
|
||||||
|
Responsible = new { Title = responsible }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Строка фазы «Исполнение» из списка «Заявка на подбор — согласование».</summary>
|
||||||
|
private static object Execution(
|
||||||
|
int ticketId,
|
||||||
|
string status = "Выполнено",
|
||||||
|
string? date = "2026-07-22T10:00:00Z",
|
||||||
|
double stepOrder = 1,
|
||||||
|
string? executor = "Сергеева Надежда Ивановна") =>
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = ticketId * 100 + (int)stepOrder,
|
||||||
|
TicketId = ticketId,
|
||||||
|
ApprovalStatus = status,
|
||||||
|
StepOrder = stepOrder,
|
||||||
|
Date = date,
|
||||||
|
TansuSiteApprover = new { Title = executor }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Элемент списка «Прием на работу — данные». selectionListUrl — партиция, в
|
||||||
|
/// которой лежит заявка на подбор: связь идёт по паре (Id, партиция), а не по
|
||||||
|
/// одному Id — в разных партициях Id повторяются.
|
||||||
|
/// </summary>
|
||||||
|
private static object Hire(
|
||||||
|
int selectionId,
|
||||||
|
string selectionListUrl,
|
||||||
|
string status = "Закрыта",
|
||||||
|
string? hireDate = "2026-08-17T00:00:00Z",
|
||||||
|
string? replaced = null,
|
||||||
|
string? candidate = "Шакенов Анель") =>
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = selectionId * 10,
|
||||||
|
RequestStatus = status,
|
||||||
|
SelectionRequestId = (double)selectionId,
|
||||||
|
SelectionRequestListId = selectionListUrl,
|
||||||
|
CandidateName = candidate,
|
||||||
|
ReplacedEmployee = replaced,
|
||||||
|
HireDate = hireDate
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<(VacancyReportService Service, Mock<ISharePointClient> SharePoint)> BuildAsync(
|
||||||
|
string name,
|
||||||
|
IReadOnlyList<object> selections,
|
||||||
|
IReadOnlyList<object>? executions = null,
|
||||||
|
IReadOnlyList<object>? hires = null)
|
||||||
|
{
|
||||||
|
var selectionList = SelectionListUrl(name);
|
||||||
|
var hireList = HireListUrl(name);
|
||||||
|
var selectionPage = $"/подбор-{name}";
|
||||||
|
var hirePage = $"/приём-{name}";
|
||||||
|
|
||||||
|
await CreateTypeAsync($"Подбор {name}", selectionList, selectionPage);
|
||||||
|
await CreateTypeAsync($"Приём {name}", hireList, hirePage);
|
||||||
|
|
||||||
|
var sharePoint = new Mock<ISharePointClient>();
|
||||||
|
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListUrlAsync(
|
||||||
|
SiteUrl, selectionList, It.Is<string>(q => q.Contains("OpenVacanciesCount")),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(selections.Select(Json).ToList());
|
||||||
|
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetListTitleAsync(SiteUrl, selectionList, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(DataListTitle);
|
||||||
|
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(
|
||||||
|
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("Исполнение")),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((executions ?? []).Select(Json).ToList());
|
||||||
|
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListUrlAsync(
|
||||||
|
SiteUrl, hireList, It.Is<string>(q => q.Contains("SelectionRequestId")),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((hires ?? []).Select(Json).ToList());
|
||||||
|
|
||||||
|
var service = new VacancyReportService(
|
||||||
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||||
|
sharePoint.Object,
|
||||||
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
|
Options.Create(new ReportsOptions
|
||||||
|
{
|
||||||
|
RecruitingSelectionPageUrl = selectionPage,
|
||||||
|
RecruitingHirePageUrl = hirePage
|
||||||
|
}),
|
||||||
|
NullLogger<VacancyReportService>.Instance);
|
||||||
|
|
||||||
|
return (service, sharePoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VacancyReportRow SingleRow(VacancyReport report) =>
|
||||||
|
report.Groups.ShouldHaveSingleItem().Rows.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Закрытая вакансия: приём даёт число закрытых, «да» и дату выхода")]
|
||||||
|
public async Task ClosedVacancy_FilledFromHire()
|
||||||
|
{
|
||||||
|
const string name = "closed";
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Зам нач.юр.отдела")],
|
||||||
|
[Execution(1, date: "2026-07-08T10:00:00Z")],
|
||||||
|
[Hire(1, SelectionListUrl(name))]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.Position.ShouldBe("Зам нач.юр.отдела");
|
||||||
|
row.Department.ShouldBe("офис");
|
||||||
|
row.VacanciesCount.ShouldBe(1);
|
||||||
|
row.ClosedCount.ShouldBe(1);
|
||||||
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
|
||||||
|
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
|
||||||
|
row.ClosingDate.ShouldBe(new DateOnly(2026, 7, 8));
|
||||||
|
row.Initiator.ShouldBe("Ким Н.Ю.");
|
||||||
|
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
|
||||||
|
var line = row.Lines.ShouldHaveSingleItem();
|
||||||
|
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
|
||||||
|
line.CandidateName.ShouldBe("Шакенов Анель");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
|
||||||
|
public async Task Hire_FromOtherPartition_NotMatched()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"otherPartition",
|
||||||
|
[Selection(1, "Зам нач.юр.отдела")],
|
||||||
|
[Execution(1)],
|
||||||
|
[Hire(1, "/sites/requests/Lists/СовсемДругаяПартиция")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosedCount.ShouldBe(0);
|
||||||
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||||
|
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
|
||||||
|
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
|
||||||
|
public async Task PartiallyClosed()
|
||||||
|
{
|
||||||
|
const string name = "partial";
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Логист", vacancies: 2)],
|
||||||
|
[Execution(1, status: "На исполнении", date: null)],
|
||||||
|
[Hire(1, SelectionListUrl(name))]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.VacanciesCount.ShouldBe(2);
|
||||||
|
row.ClosedCount.ShouldBe(1);
|
||||||
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Незавершённая фаза исполнения — даты закрытия нет, рекрутёр есть")]
|
||||||
|
public async Task ExecutionInProgress_NoClosingDate()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"inprogress",
|
||||||
|
[Selection(1, "Начальник ОИД")],
|
||||||
|
[
|
||||||
|
Execution(1, status: "Выполнено", date: "2026-07-10T00:00:00Z", stepOrder: 1),
|
||||||
|
Execution(1, status: "На исполнении", date: null, stepOrder: 2, executor: "Ким Дания Сериковна")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosingDate.ShouldBeNull();
|
||||||
|
// Рекрутёр — исполнитель ПОСЛЕДНЕГО шага: вакансию доводит он.
|
||||||
|
row.Recruiter.ShouldBe("Ким Дания Сериковна");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Отклонённая заявка на приём вакансию не закрывает")]
|
||||||
|
public async Task RejectedHire_DoesNotClose()
|
||||||
|
{
|
||||||
|
const string name = "rejected";
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Юрист")],
|
||||||
|
[Execution(1)],
|
||||||
|
[Hire(1, SelectionListUrl(name), status: "Отклонено")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosedCount.ShouldBe(0);
|
||||||
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
|
||||||
|
public async Task SeveralHires_OneLineEach()
|
||||||
|
{
|
||||||
|
const string name = "several";
|
||||||
|
var list = SelectionListUrl(name);
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||||||
|
[Execution(1)],
|
||||||
|
[
|
||||||
|
// Намеренно НЕ в порядке дат — проверяем сортировку строк.
|
||||||
|
Hire(1, list, hireDate: "2026-08-25T00:00:00Z", candidate: "Бутиев Азамат"),
|
||||||
|
Hire(1, list, hireDate: "2026-08-05T00:00:00Z", candidate: "Дулин Евгений")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosedCount.ShouldBe(2);
|
||||||
|
// Вакансия одна, но строк на листе две — по приёму на каждую.
|
||||||
|
row.Lines.Count.ShouldBe(2);
|
||||||
|
row.Lines.Select(l => l.StartDate).ShouldBe([new DateOnly(2026, 8, 5), new DateOnly(2026, 8, 25)]);
|
||||||
|
row.Lines.Select(l => l.CandidateName).ShouldBe(["Дулин Евгений", "Бутиев Азамат"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Отклонённый приём не даёт строки — кандидата в отчёте быть не должно")]
|
||||||
|
public async Task RejectedHire_ProducesNoLine()
|
||||||
|
{
|
||||||
|
const string name = "rejectedLine";
|
||||||
|
var list = SelectionListUrl(name);
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||||||
|
[Execution(1)],
|
||||||
|
[
|
||||||
|
Hire(1, list, candidate: "Принятый Кандидат"),
|
||||||
|
Hire(1, list, status: "Отклонено", candidate: "Отклонённый Кандидат")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosedCount.ShouldBe(1);
|
||||||
|
row.Lines.ShouldHaveSingleItem().CandidateName.ShouldBe("Принятый Кандидат");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
|
||||||
|
public async Task SubmitDateEmpty_FallsBackToCreated()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"created",
|
||||||
|
[Selection(1, "Прораб", submitDate: null, created: "2026-07-30T09:00:00Z")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
SingleRow(result.Value).SubmitDate.ShouldBe(new DateOnly(2026, 7, 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Пустое «Количество открытых вакансий» — это одна вакансия")]
|
||||||
|
public async Task VacanciesCountEmpty_IsOne()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync("noCount", [Selection(1, "Фельдшер", vacancies: 0)]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
SingleRow(result.Value).VacanciesCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Группировка: сначала вакансии без объекта, потом объекты по алфавиту")]
|
||||||
|
public async Task Groups_UnnamedFirstThenAlphabetical()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"groups",
|
||||||
|
[
|
||||||
|
Selection(1, "Оператор катка", objectProject: "КЭС КУРЧАТОВ"),
|
||||||
|
Selection(2, "Логист"),
|
||||||
|
Selection(3, "Начальник участка", objectProject: "Авиа-ангар г.Атырау")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.Groups.Select(g => g.Name).ShouldBe(["", "Авиа-ангар г.Атырау", "КЭС КУРЧАТОВ"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«Вместо сотрудника» — из причины открытия, только при замене уволенного")]
|
||||||
|
public async Task ReplacedEmployee_OnlyForReplacementReason()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"replaced",
|
||||||
|
[
|
||||||
|
Selection(1, "Начальник геодезии",
|
||||||
|
openReason: "Замена уволенного сотрудника", openReasonDetails: "Серик"),
|
||||||
|
Selection(2, "Инженер ВиВ",
|
||||||
|
openReason: "Новая штатная единица", openReasonDetails: "по расширению штата")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
|
||||||
|
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
|
||||||
|
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
|
||||||
|
rows.Single(r => r.Position == "Начальник геодезии")
|
||||||
|
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Серик");
|
||||||
|
rows.Single(r => r.Position == "Инженер ВиВ")
|
||||||
|
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«Взамен кого» из заявки на приём важнее причины открытия вакансии")]
|
||||||
|
public async Task ReplacedEmployee_HireWins()
|
||||||
|
{
|
||||||
|
const string name = "replacedHire";
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Юрист", openReason: "Замена уволенного сотрудника", openReasonDetails: "Из заявки")],
|
||||||
|
[Execution(1)],
|
||||||
|
[Hire(1, SelectionListUrl(name), replaced: "Из приёма")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
SingleRow(result.Value).Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Из приёма");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
||||||
|
public async Task InvertedPeriod_Fails()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync("inverted", [Selection(1, "Логист")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(To, From, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Фильтр периода уходит в SharePoint с откатом на Created")]
|
||||||
|
public async Task PeriodFilter_PassedToSharePoint()
|
||||||
|
{
|
||||||
|
const string name = "filter";
|
||||||
|
var (service, sharePoint) = await BuildAsync(name, [Selection(1, "Логист")]);
|
||||||
|
|
||||||
|
await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
sharePoint.Verify(
|
||||||
|
x => x.GetItemsByListUrlAsync(
|
||||||
|
SiteUrl,
|
||||||
|
SelectionListUrl(name),
|
||||||
|
It.Is<string>(q =>
|
||||||
|
q.Contains("SubmitDate ge datetime'2026-07-01T00:00:00Z'")
|
||||||
|
// Верхняя граница — начало следующего дня и строгое lt: иначе
|
||||||
|
// заявки за сам последний день периода отсеклись бы.
|
||||||
|
&& q.Contains("SubmitDate lt datetime'2026-09-01T00:00:00Z'")
|
||||||
|
&& q.Contains("SubmitDate eq null and Created ge")),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
+386
@@ -0,0 +1,386 @@
|
|||||||
|
using NPOI.SS.UserModel;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
|
||||||
|
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
|
||||||
|
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
|
||||||
|
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
|
||||||
|
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VacancyReportWorkbookTests
|
||||||
|
{
|
||||||
|
private static VacancyReportRow Row(
|
||||||
|
string position,
|
||||||
|
int vacancies = 1,
|
||||||
|
int closed = 0,
|
||||||
|
DateOnly? submitDate = null,
|
||||||
|
DateOnly? closingDate = null,
|
||||||
|
DateOnly? startDate = null,
|
||||||
|
string? recruiter = "Сергеева Надежда Ивановна",
|
||||||
|
IReadOnlyList<VacancyReportLine>? lines = null) =>
|
||||||
|
new(
|
||||||
|
position,
|
||||||
|
"офис",
|
||||||
|
vacancies,
|
||||||
|
closed,
|
||||||
|
submitDate ?? new DateOnly(2026, 7, 8),
|
||||||
|
closingDate,
|
||||||
|
"Ким Н.Ю.",
|
||||||
|
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
||||||
|
recruiter,
|
||||||
|
// По умолчанию одна строка — вакансия без приёмов либо с одним;
|
||||||
|
// тесты многострочности передают lines явно.
|
||||||
|
lines ?? [new VacancyReportLine(null, startDate, null)]);
|
||||||
|
|
||||||
|
/// <summary>Строка листа в пределах вакансии — то, что различается между приёмами.</summary>
|
||||||
|
private static VacancyReportLine Line(string? candidate, DateOnly? startDate = null, string? replaced = null) =>
|
||||||
|
new(candidate, startDate, replaced);
|
||||||
|
|
||||||
|
private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups)
|
||||||
|
{
|
||||||
|
var report = new VacancyReport(from, to, groups);
|
||||||
|
var bytes = VacancyReportWorkbook.Build(report);
|
||||||
|
return new XSSFWorkbook(new MemoryStream(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ISheet BuildListSheet(params VacancyReportGroup[] groups) =>
|
||||||
|
BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups)
|
||||||
|
.GetSheet("Список вакансий (все)");
|
||||||
|
|
||||||
|
/// <summary>Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E».</summary>
|
||||||
|
private static ICell? CellAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
sheet.GetRow(excelRow - 1)?.GetCell(excelCol - 1);
|
||||||
|
|
||||||
|
private static string StringAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
CellAt(sheet, excelRow, excelCol)?.StringCellValue ?? "";
|
||||||
|
|
||||||
|
private static int IntAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
(int)(CellAt(sheet, excelRow, excelCol)?.NumericCellValue ?? throw new InvalidOperationException("Ячейка пуста"));
|
||||||
|
|
||||||
|
private static DateTime DateAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
CellAt(sheet, excelRow, excelCol)!.DateCellValue!.Value;
|
||||||
|
|
||||||
|
private static bool IsEmptyAt(ISheet sheet, int excelRow, int excelCol)
|
||||||
|
{
|
||||||
|
var cell = CellAt(sheet, excelRow, excelCol);
|
||||||
|
return cell is null || cell.CellType == CellType.Blank;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе).</summary>
|
||||||
|
private static string FormulaAt(ISheet sheet, int excelRow, int excelCol)
|
||||||
|
{
|
||||||
|
var cell = CellAt(sheet, excelRow, excelCol);
|
||||||
|
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")]
|
||||||
|
public void Header_MatchesSample()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
|
StringAt(sheet, 1, 2).ShouldBe("№");
|
||||||
|
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
|
||||||
|
StringAt(sheet, 1, 4).ShouldBe("Подразделение");
|
||||||
|
StringAt(sheet, 1, 5).ShouldBe("Кол-во вакансий");
|
||||||
|
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
|
||||||
|
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
|
||||||
|
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
|
||||||
|
StringAt(sheet, 1, 12).ShouldBe("Ф.И.О. нанимаемого сотрудника");
|
||||||
|
StringAt(sheet, 1, 13).ShouldBe("Инициатор заявки");
|
||||||
|
StringAt(sheet, 1, 15).ShouldBe("Комментарии");
|
||||||
|
StringAt(sheet, 1, 17).ShouldBe("Рекрутер");
|
||||||
|
IsEmptyAt(sheet, 1, 18).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")]
|
||||||
|
public void Header_DateColumns_SameStyleAsRestOfHeader()
|
||||||
|
{
|
||||||
|
// Регрессия: даты когда-то получали формат через SetDefaultColumnStyle,
|
||||||
|
// а он в NPOI переписывает стиль УЖЕ существующих ячеек колонки —
|
||||||
|
// включая шапку, которая от этого лишалась заливки (H1/I1/K1 получали
|
||||||
|
// DataDate вместо Header). Формат даты теперь ставится только на сами
|
||||||
|
// ячейки с датами (см. SetDate), шапки колонок с датами он не касается.
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
|
// NPOI отдаёт на каждый вызов CellStyle новую обёртку — сравнивать
|
||||||
|
// нужно индекс стиля в книге, а не ссылки на объекты.
|
||||||
|
var headerStyleIndex = CellAt(sheet, 1, 3)!.CellStyle.Index; // «Вакансия» — заведомо обычная шапка.
|
||||||
|
foreach (var col in (int[])[8, 9, 11]) // H «Дата подачи заявки», I «Дата закрытия», K «Дата выхода»
|
||||||
|
CellAt(sheet, 1, col)!.CellStyle.Index.ShouldBe(headerStyleIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
|
||||||
|
public void DataRow_Layout()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
|
Row("Зам нач.юр.отдела", vacancies: 1, closed: 1,
|
||||||
|
submitDate: new DateOnly(2026, 3, 30),
|
||||||
|
closingDate: new DateOnly(2026, 7, 8),
|
||||||
|
startDate: new DateOnly(2026, 8, 17))
|
||||||
|
]));
|
||||||
|
|
||||||
|
IntAt(sheet, 2, 1).ShouldBe(1);
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe("Зам нач.юр.отдела");
|
||||||
|
IntAt(sheet, 2, 5).ShouldBe(1);
|
||||||
|
IntAt(sheet, 2, 6).ShouldBe(1);
|
||||||
|
DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30));
|
||||||
|
DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8));
|
||||||
|
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17));
|
||||||
|
StringAt(sheet, 2, 13).ShouldBe("Ким Н.Ю.");
|
||||||
|
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
||||||
|
IsEmptyAt(sheet, 2, 15).ShouldBeTrue();
|
||||||
|
StringAt(sheet, 2, 16).ShouldBe("да");
|
||||||
|
StringAt(sheet, 2, 17).ShouldBe("Сергеева Надежда Ивановна");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Несколько приёмов: своя строка на каждого, общие поля объединены по вертикали")]
|
||||||
|
public void SeveralHires_OneRowEachWithMergedCommonCells()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
|
Row("Разнорабочий", vacancies: 4, closed: 4,
|
||||||
|
submitDate: new DateOnly(2026, 7, 10),
|
||||||
|
lines: [
|
||||||
|
Line("Дулин Евгений Петрович", new DateOnly(2026, 8, 19)),
|
||||||
|
Line("Телятников Игорь Сергеевич", new DateOnly(2026, 8, 21)),
|
||||||
|
Line("Жабаев Нурлан Ахметович", new DateOnly(2026, 8, 25), replaced: "Оспанов К.")
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Построчные колонки — своё значение в каждой строке.
|
||||||
|
StringAt(sheet, 2, 12).ShouldBe("Дулин Евгений Петрович");
|
||||||
|
StringAt(sheet, 3, 12).ShouldBe("Телятников Игорь Сергеевич");
|
||||||
|
StringAt(sheet, 4, 12).ShouldBe("Жабаев Нурлан Ахметович");
|
||||||
|
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 19));
|
||||||
|
DateAt(sheet, 3, 11).ShouldBe(new DateTime(2026, 8, 21));
|
||||||
|
DateAt(sheet, 4, 11).ShouldBe(new DateTime(2026, 8, 25));
|
||||||
|
StringAt(sheet, 4, 14).ShouldBe("Оспанов К.");
|
||||||
|
|
||||||
|
// Общие — значение ТОЛЬКО в первой строке: у объединённой области Excel
|
||||||
|
// хранит его в верхней левой ячейке, и дубли сломали бы SUM в итогах.
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe("Разнорабочий");
|
||||||
|
IsEmptyAt(sheet, 3, 3).ShouldBeTrue();
|
||||||
|
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||||||
|
IntAt(sheet, 2, 5).ShouldBe(4);
|
||||||
|
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||||||
|
|
||||||
|
// И сами объединения заведены — иначе продолжения выглядели бы пустыми.
|
||||||
|
var merged = Enumerable.Range(0, sheet.NumMergedRegions).Select(sheet.GetMergedRegion).ToList();
|
||||||
|
merged.ShouldContain(r => r.FirstRow == 1 && r.LastRow == 3 && r.FirstColumn == 2 && r.LastColumn == 2);
|
||||||
|
// Построчные колонки объединять нельзя — иначе ФИО второго и третьего пропали бы.
|
||||||
|
merged.ShouldNotContain(r => r.FirstColumn == 11); // 0-based: ColCandidateName
|
||||||
|
merged.ShouldNotContain(r => r.FirstColumn == 10); // ColStartDate
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Одна строка у вакансии — объединений не создаётся (область 1×1 портит файл)")]
|
||||||
|
public void SingleLine_NoMergedRegions()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
|
sheet.NumMergedRegions.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Итоги суммируют вакансии по одному разу, а не по числу приёмов")]
|
||||||
|
public void Totals_CountVacancyOnce_NotPerHire()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
|
Row("Разнорабочий", vacancies: 4, closed: 4, lines: [
|
||||||
|
Line("Первый", new DateOnly(2026, 8, 19)),
|
||||||
|
Line("Второй", new DateOnly(2026, 8, 21))
|
||||||
|
]),
|
||||||
|
Row("Логист", vacancies: 1, closed: 0)
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Строки: 2-3 «Разнорабочий» (две), 4 «Логист» → итоги в 5-й.
|
||||||
|
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
|
||||||
|
// Значение «Кол-во вакансий» лежит только в E2 и E4 — E3 пустая (объединение),
|
||||||
|
// поэтому SUM даёт 4+1=5, а не 4+4+1.
|
||||||
|
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||||||
|
IntAt(sheet, 4, 5).ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
||||||
|
public void DerivedColumns_AreFormulas()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
|
Row("Логист", vacancies: 3, closed: 1,
|
||||||
|
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
|
||||||
|
]));
|
||||||
|
|
||||||
|
FormulaAt(sheet, 2, 7).ShouldBe("E2-F2");
|
||||||
|
FormulaAt(sheet, 2, 10).ShouldBe("I2-H2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
|
||||||
|
public void LeadTime_EmptyWithoutClosingDate()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
|
FormulaAt(sheet, 2, 10).ShouldBeEmpty();
|
||||||
|
IsEmptyAt(sheet, 2, 10).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
|
||||||
|
public void GroupSeparator_RestartsNumbering()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(
|
||||||
|
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
|
||||||
|
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
|
||||||
|
|
||||||
|
// Верхний блок без объекта — сразу после шапки, без разделителя.
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe("Логист");
|
||||||
|
StringAt(sheet, 3, 3).ShouldBe("Юрист");
|
||||||
|
|
||||||
|
StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ");
|
||||||
|
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
|
||||||
|
IntAt(sheet, 5, 1).ShouldBe(3);
|
||||||
|
IntAt(sheet, 5, 2).ShouldBe(1);
|
||||||
|
IntAt(sheet, 6, 1).ShouldBe(4);
|
||||||
|
IntAt(sheet, 6, 2).ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
|
||||||
|
public void Totals_SumWholeDataRange()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(
|
||||||
|
new VacancyReportGroup("", [Row("Логист")]),
|
||||||
|
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
|
||||||
|
|
||||||
|
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
|
||||||
|
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
|
||||||
|
FormulaAt(sheet, 5, 6).ShouldBe("SUM(F2:F4)");
|
||||||
|
FormulaAt(sheet, 5, 7).ShouldBe("SUM(G2:G4)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
|
||||||
|
public void EmptyReport_HeaderOnly()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet();
|
||||||
|
|
||||||
|
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
|
||||||
|
IsEmptyAt(sheet, 2, 5).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
|
||||||
|
public void FileName_CarriesPeriod()
|
||||||
|
{
|
||||||
|
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), []);
|
||||||
|
|
||||||
|
VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Лист «Результаты» ====================
|
||||||
|
|
||||||
|
private static VacancyReportRow RowFor(string recruiter, int vacancies, int closed) =>
|
||||||
|
Row("Позиция", vacancies: vacancies, closed: closed, recruiter: recruiter);
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: горизонтальная таблица — рекрутёры по убыванию объёма, с процентами")]
|
||||||
|
public void SummarySheet_HorizontalTable()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Надежда", vacancies: 10, closed: 4), // в работе 60%, закрыто 40%
|
||||||
|
RowFor("Алтынай", vacancies: 5, closed: 5), // закрыто 100%
|
||||||
|
]));
|
||||||
|
var sheet = workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
StringAt(sheet, 1, 1).ShouldBe("результаты");
|
||||||
|
|
||||||
|
// Заголовок: «Кол-во вакансий» + рекрутёры по убыванию объёма (Надежда 10 > Алтынай 5).
|
||||||
|
StringAt(sheet, 2, 1).ShouldBe("Кол-во вакансий");
|
||||||
|
StringAt(sheet, 2, 2).ShouldBe("Надежда");
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe("Алтынай");
|
||||||
|
|
||||||
|
StringAt(sheet, 3, 1).ShouldBe("Всего");
|
||||||
|
IntAt(sheet, 3, 2).ShouldBe(10);
|
||||||
|
IntAt(sheet, 3, 3).ShouldBe(5);
|
||||||
|
|
||||||
|
StringAt(sheet, 4, 1).ShouldBe("в работе (в%)");
|
||||||
|
CellAt(sheet, 4, 2)!.NumericCellValue.ShouldBe(0.6, 0.0001);
|
||||||
|
CellAt(sheet, 4, 3)!.NumericCellValue.ShouldBe(0.0, 0.0001);
|
||||||
|
|
||||||
|
StringAt(sheet, 5, 1).ShouldBe("закрыто");
|
||||||
|
CellAt(sheet, 5, 2)!.NumericCellValue.ShouldBe(0.4, 0.0001);
|
||||||
|
CellAt(sheet, 5, 3)!.NumericCellValue.ShouldBe(1.0, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: вакансии без рекрутёра сведены в «Без рекрутёра» и идут последними")]
|
||||||
|
public void SummarySheet_UnassignedRecruiterLast()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Алтынай", vacancies: 1, closed: 0),
|
||||||
|
Row("Без исполнителя", vacancies: 100, closed: 0, recruiter: null),
|
||||||
|
]));
|
||||||
|
var sheet = workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
// «Без рекрутёра» — последняя колонка горизонтальной таблицы, несмотря на
|
||||||
|
// то, что её объём (100) больше, чем у Алтынай (1): это не участник
|
||||||
|
// рейтинга, а технический хвост.
|
||||||
|
StringAt(sheet, 2, 2).ShouldBe("Алтынай");
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe(VacancyReportSummary.UnassignedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: вертикальные таблицы — доля каждого рекрутёра в общем объёме и в работе")]
|
||||||
|
public void SummarySheet_VerticalTables()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Надежда", vacancies: 3, closed: 0), // весь объём в работе
|
||||||
|
RowFor("Алтынай", vacancies: 1, closed: 1), // весь объём закрыт
|
||||||
|
]));
|
||||||
|
var sheet = workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
// Таблица 1 — «Общий объем работ»: строка 7 (после шапки+4 строк
|
||||||
|
// горизонтальной таблицы+пустой строки), итого 4 (3+1), доля Надежды 75%.
|
||||||
|
StringAt(sheet, 7, 1).ShouldBe("Общий объем работ");
|
||||||
|
IntAt(sheet, 7, 2).ShouldBe(4);
|
||||||
|
StringAt(sheet, 8, 1).ShouldBe("Надежда");
|
||||||
|
CellAt(sheet, 8, 2)!.NumericCellValue.ShouldBe(0.75, 0.0001);
|
||||||
|
StringAt(sheet, 9, 1).ShouldBe("Алтынай");
|
||||||
|
CellAt(sheet, 9, 2)!.NumericCellValue.ShouldBe(0.25, 0.0001);
|
||||||
|
|
||||||
|
// Таблица 2 — «Общий объем в работе»: строка 11, итого 3 (только Надежда
|
||||||
|
// не закрыта), её доля — 100%; у Алтынай в работе 0 → доля 0%.
|
||||||
|
StringAt(sheet, 11, 1).ShouldBe("Общий объем в работе");
|
||||||
|
IntAt(sheet, 11, 2).ShouldBe(3);
|
||||||
|
StringAt(sheet, 12, 1).ShouldBe("Надежда");
|
||||||
|
CellAt(sheet, 12, 2)!.NumericCellValue.ShouldBe(1.0, 0.0001);
|
||||||
|
StringAt(sheet, 13, 1).ShouldBe("Алтынай");
|
||||||
|
CellAt(sheet, 13, 2)!.NumericCellValue.ShouldBe(0.0, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: под каждой вертикальной таблицей — своя круговая диаграмма")]
|
||||||
|
public void SummarySheet_HasTwoPieCharts()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Надежда", vacancies: 3, closed: 1),
|
||||||
|
RowFor("Алтынай", vacancies: 2, closed: 0),
|
||||||
|
]));
|
||||||
|
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
var charts = sheet.GetDrawingPatriarch()?.GetCharts();
|
||||||
|
charts.ShouldNotBeNull();
|
||||||
|
charts!.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: нет вакансий за период — без таблиц и диаграмм, без падения")]
|
||||||
|
public void SummarySheet_EmptyReport_NoTablesOrCharts()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31));
|
||||||
|
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
sheet.GetDrawingPatriarch().ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
|
|||||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@tnsu/ui-kit": "^0.4.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
@@ -6283,9 +6283,9 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.4.0",
|
"version": "0.14.0",
|
||||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz",
|
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
||||||
"integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==",
|
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@tnsu/ui-kit": "^0.4.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import { texts } from '../../texts';
|
|||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
|
import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen';
|
||||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||||
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||||
|
import { RoutesExportScreen } from '../screens/RoutesExportScreen';
|
||||||
|
|
||||||
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
||||||
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||||
@@ -29,8 +31,10 @@ const NAV_ITEMS: ISidebarItem[] = [
|
|||||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||||
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||||
|
{ id: 'routesExport', title: texts.nav.routesExport, icon: 'document' },
|
||||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||||
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }
|
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' },
|
||||||
|
{ id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function sidebarActiveId(tab: ScreenKey): string {
|
function sidebarActiveId(tab: ScreenKey): string {
|
||||||
@@ -147,6 +151,14 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||||
|
{route.tab === 'requestTypeAccess' && (
|
||||||
|
<RequestTypeAccessScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
initialFilterRequestTypeId={route.accessFilterRequestTypeId}
|
||||||
|
initialFilterEmail={route.accessFilterEmail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{route.tab === 'routes' && !route.routeId && (
|
{route.tab === 'routes' && !route.routeId && (
|
||||||
<RoutesScreen
|
<RoutesScreen
|
||||||
api={api}
|
api={api}
|
||||||
@@ -174,6 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
route={route.resolve}
|
route={route.resolve}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
|
||||||
{route.tab === 'manageRequests' && (
|
{route.tab === 'manageRequests' && (
|
||||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser';
|
export type ScreenKey =
|
||||||
|
| 'dashboard'
|
||||||
|
| 'requestTypes'
|
||||||
|
| 'requestTypeAccess'
|
||||||
|
| 'routes'
|
||||||
|
| 'routeSteps'
|
||||||
|
| 'resolve'
|
||||||
|
| 'routesExport'
|
||||||
|
| 'manageRequests'
|
||||||
|
| 'manageByUser';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ApprovalRouteApi } from '../../services/api';
|
|||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { RequestManageService } from '../../services/requestManageService';
|
import { RequestManageService } from '../../services/requestManageService';
|
||||||
import { ApiClientError } from '../../services/httpClient';
|
import { ApiClientError } from '../../services/httpClient';
|
||||||
import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models';
|
import { REQUEST_STATUS, UserApprovalRow, UserTemplateStepRow } from '../../models';
|
||||||
import { routeStepStatusLabels, texts } from '../../texts';
|
import { routeStepStatusLabels, texts } from '../../texts';
|
||||||
import { shortGuid } from '../../utils/guid';
|
import { shortGuid } from '../../utils/guid';
|
||||||
import { describeStepCondition } from '../../utils/stepExecutor';
|
import { describeStepCondition } from '../../utils/stepExecutor';
|
||||||
@@ -15,7 +15,6 @@ import { mapWithLimit } from '../../utils/parallel';
|
|||||||
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
|
|
||||||
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
|
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
|
||||||
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
|
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
@@ -95,7 +94,6 @@ interface IReplaceScope {
|
|||||||
|
|
||||||
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
const listSeries = useRequestTypeListsAll(api);
|
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
const dictionaries = usePortalDictionaries(dictionary);
|
const dictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
@@ -125,27 +123,21 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
});
|
});
|
||||||
}, [employeeEmail, targetEmail]);
|
}, [employeeEmail, targetEmail]);
|
||||||
|
|
||||||
const partitionsByType = React.useMemo(() => {
|
|
||||||
const map = new Map<number, RequestListPartition[]>();
|
|
||||||
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
|
|
||||||
return map;
|
|
||||||
}, [listSeries.items]);
|
|
||||||
|
|
||||||
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
|
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
|
||||||
const [liveLoading, setLiveLoading] = React.useState(false);
|
const [liveLoading, setLiveLoading] = React.useState(false);
|
||||||
const [liveError, setLiveError] = React.useState<string | undefined>();
|
const [liveError, setLiveError] = React.useState<string | undefined>();
|
||||||
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
|
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const reloadLiveRows = React.useCallback(() => {
|
const reloadLiveRows = React.useCallback(() => {
|
||||||
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return;
|
if (!employeeEmail || requestTypes.isLoading) return;
|
||||||
setLiveLoading(true);
|
setLiveLoading(true);
|
||||||
setLiveError(undefined);
|
setLiveError(undefined);
|
||||||
requestManage
|
api.requestManage
|
||||||
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES)
|
.byApprover(employeeEmail, ACTIVE_STATUSES)
|
||||||
.then(setLiveRows)
|
.then(setLiveRows)
|
||||||
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover')))
|
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover')))
|
||||||
.finally(() => setLiveLoading(false));
|
.finally(() => setLiveLoading(false));
|
||||||
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]);
|
}, [api, requestTypes.isLoading, employeeEmail]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setLiveRows([]);
|
setLiveRows([]);
|
||||||
@@ -273,7 +265,11 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
|
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
|
||||||
const replaced = await mapWithLimit(
|
const replaced = await mapWithLimit(
|
||||||
clean,
|
clean,
|
||||||
row => requestManage.reassignApprover(row, row.id, email, position),
|
row =>
|
||||||
|
requestManage.reassignApprover(row, row.id, email, position, {
|
||||||
|
ticketId: row.ticketId,
|
||||||
|
dataListId: row.listId
|
||||||
|
}),
|
||||||
{ onSettled: advanceProgress }
|
{ onSettled: advanceProgress }
|
||||||
);
|
);
|
||||||
replaced.forEach(result => {
|
replaced.forEach(result => {
|
||||||
|
|||||||
+6
-1
@@ -74,6 +74,11 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
||||||
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
||||||
|
|
||||||
|
const isDirty =
|
||||||
|
email !== initialEmail ||
|
||||||
|
slaHours !== slaHoursToInput(initialSlaHours) ||
|
||||||
|
askOverdueReason !== boolToTriState(initialAskOverdueReason);
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
const trimmedEmail = email.trim();
|
const trimmedEmail = email.trim();
|
||||||
if (!trimmedEmail) {
|
if (!trimmedEmail) {
|
||||||
@@ -95,7 +100,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}>
|
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose} isDirty={isDirty}>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.manageRequests.approverLabel} required full>
|
<Field label={texts.manageRequests.approverLabel} required full>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
|
|||||||
+13
-3
@@ -214,13 +214,23 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
const isBlockStart = block.start === i;
|
const isBlockStart = block.start === i;
|
||||||
const movable = blockIndex > lastLockedBlockIndex;
|
const movable = blockIndex > lastLockedBlockIndex;
|
||||||
const rowEditable = isEditableRowStatus(r.status);
|
const rowEditable = isEditableRowStatus(r.status);
|
||||||
// Уведомлять можно только активный шаг (флоу шлёт письмо тому,
|
// Уведомлять можно активный шаг (флоу шлёт письмо тому, чей
|
||||||
// чей RowID мы передали, независимо от статуса — сам он не
|
// RowID мы передали, независимо от статуса — сам он не
|
||||||
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
|
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
|
||||||
// слать по нему нечего: согласующий/исполнитель пока не
|
// слать по нему нечего: согласующий/исполнитель пока не
|
||||||
// должен действовать (см. request-portal/notify.service.ts,
|
// должен действовать (см. request-portal/notify.service.ts,
|
||||||
// где по той же причине уведомляется только активный шаг).
|
// где по той же причине уведомляется только активный шаг).
|
||||||
const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution;
|
//
|
||||||
|
// «Возвращена на доработку» — тоже уведомляемый статус, хотя
|
||||||
|
// и не активный: для него тот же поток шлёт письмо не
|
||||||
|
// согласующему строки, а АВТОРУ заявки (см.
|
||||||
|
// RequestManageService.notifyRow) — так интерфейс
|
||||||
|
// редактирования заявок получает кнопку «сообщить автору о
|
||||||
|
// доработке».
|
||||||
|
const notifiable =
|
||||||
|
r.status === REQUEST_STATUS.onApproval ||
|
||||||
|
r.status === REQUEST_STATUS.onExecution ||
|
||||||
|
r.status === REQUEST_STATUS.returned;
|
||||||
// Переключение параллельности — тоже перестановка (меняет order
|
// Переключение параллельности — тоже перестановка (меняет order
|
||||||
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
|
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
|
||||||
// а не на rowEditable: у «Возвращена на доработку» кнопка
|
// а не на rowEditable: у «Возвращена на доработку» кнопка
|
||||||
|
|||||||
@@ -235,12 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
|
|
||||||
const searchRequests = React.useCallback(
|
const searchRequests = React.useCallback(
|
||||||
(query: string, page: number) => {
|
(query: string, page: number) => {
|
||||||
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
|
if (!requestType || !requestType.siteUrl) {
|
||||||
return Promise.resolve({ items: [], hasMore: false });
|
return Promise.resolve({ items: [], hasMore: false });
|
||||||
}
|
}
|
||||||
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
|
return api.requestManage.search(requestType.id, query, page);
|
||||||
},
|
},
|
||||||
[requestManage, requestType, partitions.items]
|
[api, requestType]
|
||||||
);
|
);
|
||||||
|
|
||||||
// silent=true — фоновое обновление (после своего же действия, автообновление
|
// silent=true — фоновое обновление (после своего же действия, автообновление
|
||||||
@@ -270,8 +270,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
}
|
}
|
||||||
if (!silent) setMatrixLoading(true);
|
if (!silent) setMatrixLoading(true);
|
||||||
setMatrixError(undefined);
|
setMatrixError(undefined);
|
||||||
requestManage
|
api.requestManage
|
||||||
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
|
.matrix(requestType.id, listId, selectedRequest.id)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
setMatrix(m);
|
setMatrix(m);
|
||||||
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
|
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
|
||||||
@@ -296,12 +296,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
return same ? prev : { ...prev, selectedRequest: m.request };
|
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
|
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: matrix')))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!silent) setMatrixLoading(false);
|
if (!silent) setMatrixLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
|
[api, requestType, selectedRequest, partitions.items, partitions.isLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -433,7 +433,10 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
// SharePoint, и параллельные update по одному элементу списка могут
|
// SharePoint, и параллельные update по одному элементу списка могут
|
||||||
// затереть друг друга (последний выигрывает).
|
// затереть друг друга (последний выигрывает).
|
||||||
return requestManage
|
return requestManage
|
||||||
.reassignApprover(matrix, rowId, email, position)
|
.reassignApprover(matrix, rowId, email, position, {
|
||||||
|
ticketId: matrix.request.id,
|
||||||
|
dataListId: matrix.request.listId
|
||||||
|
})
|
||||||
.then(() => requestManage.updateRowSla(matrix, rowId, slaHours, askOverdueReason))
|
.then(() => requestManage.updateRowSla(matrix, rowId, slaHours, askOverdueReason))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const found = findEmployeeByEmail(employees.items, email);
|
const found = findEmployeeByEmail(employees.items, email);
|
||||||
@@ -507,7 +510,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
|
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
|
||||||
|
|
||||||
requestManage
|
requestManage
|
||||||
.addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false)
|
.addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false, matrix.request.listId)
|
||||||
.then(() => reloadMatrix(true))
|
.then(() => reloadMatrix(true))
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
setMatrix(previous);
|
setMatrix(previous);
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterField {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosenItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 8px 4px 12px;
|
||||||
|
border: 1px solid #e1e4ea;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosenEmpty {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './RequestTypeAccessScreen.module.scss';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
|
import { useRequestTypeAccess } from '../../hooks/useRequestTypeAccess';
|
||||||
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
|
import { replaceRoute } from '../../routing';
|
||||||
|
import { RequestTypeAccess } from '../../models';
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
Alert,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
FormGrid,
|
||||||
|
FormModal,
|
||||||
|
IconButton,
|
||||||
|
PageHeader,
|
||||||
|
PrimaryCell,
|
||||||
|
SearchableSelect,
|
||||||
|
Select
|
||||||
|
} from '../ui';
|
||||||
|
|
||||||
|
export interface IRequestTypeAccessScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
|
/** Фильтры из ссылки (aaacctype/aaaccemail) — перекрывают сохранённые, если заданы. */
|
||||||
|
initialFilterRequestTypeId?: string;
|
||||||
|
initialFilterEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтры переживают перезагрузку — тот же приём, что и ROUTES_FILTER_STORAGE_KEY в RoutesScreen.
|
||||||
|
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
||||||
|
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
||||||
|
|
||||||
|
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
|
||||||
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess';
|
||||||
|
|
||||||
|
interface IFormState {
|
||||||
|
requestTypeId: string;
|
||||||
|
emails: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: IFormState = { requestTypeId: '', emails: [] };
|
||||||
|
|
||||||
|
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
|
||||||
|
api,
|
||||||
|
dictionary,
|
||||||
|
initialFilterRequestTypeId,
|
||||||
|
initialFilterEmail
|
||||||
|
}) => {
|
||||||
|
const requestTypes = useRequestTypes(api);
|
||||||
|
const access = useRequestTypeAccess(api);
|
||||||
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
|
||||||
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
|
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
||||||
|
const { requestTypeId, emails } = draft.value;
|
||||||
|
const setEmails = (update: (current: string[]) => string[]): void =>
|
||||||
|
draft.setValue(f => ({ ...f, emails: update(f.emails) }));
|
||||||
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
|
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
||||||
|
const [filterEmail, setFilterEmail] = useLocalStorageState(ACCESS_FILTER_EMAIL_STORAGE_KEY, '');
|
||||||
|
|
||||||
|
// Ссылка с фильтром сильнее сохранённого значения — только на монтировании
|
||||||
|
// (см. тот же приём в RoutesScreen).
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialFilterEmail) setFilterEmail(initialFilterEmail);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Держим адрес синхронным с фильтрами — отфильтрованную таблицу можно
|
||||||
|
// передать ссылкой. replaceRoute, а не navigate: смена фильтра не должна
|
||||||
|
// плодить записи в истории браузера.
|
||||||
|
React.useEffect(() => {
|
||||||
|
replaceRoute({ tab: 'requestTypeAccess', accessFilterRequestTypeId: filterRequestTypeId, accessFilterEmail: filterEmail });
|
||||||
|
}, [filterRequestTypeId, filterEmail]);
|
||||||
|
|
||||||
|
const deleteTarget = useDeleteTarget<RequestTypeAccess>(item => api.requestTypeAccess.remove(item.id), access.reload);
|
||||||
|
|
||||||
|
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||||
|
|
||||||
|
const filteredItems = access.items.filter(item => {
|
||||||
|
if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false;
|
||||||
|
if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
|
||||||
|
// здесь настраивает только видимость в UI approval-route, но не в самом
|
||||||
|
// SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
|
||||||
|
const formatSharePointLists = (item: RequestTypeAccess): string =>
|
||||||
|
item.sharePointLists.length === 0
|
||||||
|
? texts.common.empty
|
||||||
|
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
createOpen.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = (): void => {
|
||||||
|
if (!requestTypeId) {
|
||||||
|
submit.fail(texts.requestTypeAccess.validationRequestType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emails.length === 0) {
|
||||||
|
submit.fail(texts.requestTypeAccess.validationEmail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на
|
||||||
|
// одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы
|
||||||
|
// можно было поправить и нажать ещё раз.
|
||||||
|
submit.run(async () => {
|
||||||
|
const failures: string[] = [];
|
||||||
|
const failedEmails: string[] = [];
|
||||||
|
for (const email of emails) {
|
||||||
|
try {
|
||||||
|
await api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email });
|
||||||
|
} catch (e) {
|
||||||
|
failedEmails.push(email);
|
||||||
|
failures.push(`${email}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedEmails.length < emails.length) access.reload();
|
||||||
|
if (failures.length === 0) {
|
||||||
|
draft.reset();
|
||||||
|
createOpen.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmails(() => failedEmails);
|
||||||
|
throw new Error(texts.requestTypeAccess.createFailed(failures));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Тех, у кого доступ к этому типу уже есть (и кого уже добавили в список), показываем
|
||||||
|
// в поиске с пометкой, но выбрать нельзя — так видно, что человек не потерялся.
|
||||||
|
const grantedEmails = new Set(
|
||||||
|
access.items.filter(a => String(a.requestTypeId) === requestTypeId).map(a => a.email.toLowerCase())
|
||||||
|
);
|
||||||
|
const chosenEmails = new Set(emails.map(e => e.toLowerCase()));
|
||||||
|
const employeeOptions = employees.items.map(employee => {
|
||||||
|
const option = toEmployeeOption(employee);
|
||||||
|
const key = employee.email.toLowerCase();
|
||||||
|
if (grantedEmails.has(key)) {
|
||||||
|
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyGranted}` };
|
||||||
|
}
|
||||||
|
if (chosenEmails.has(key)) {
|
||||||
|
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyChosen}` };
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
});
|
||||||
|
const employeeLabel = (email: string): string => {
|
||||||
|
const employee = employees.items.find(e => e.email.toLowerCase() === email.toLowerCase());
|
||||||
|
return employee ? `${employee.fio} · ${employee.email}` : email;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypeAccess.breadcrumb }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="people"
|
||||||
|
subtitle={texts.requestTypeAccess.subtitle}
|
||||||
|
title={texts.requestTypeAccess.title}
|
||||||
|
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypeAccess.add}</Button>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<div className={styles.filterField}>
|
||||||
|
<Field label={texts.requestTypeAccess.filterRequestTypeLabel}>
|
||||||
|
<Select
|
||||||
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder={texts.requestTypeAccess.filterRequestTypeAll}
|
||||||
|
value={filterRequestTypeId}
|
||||||
|
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className={styles.filterField}>
|
||||||
|
<Field label={texts.requestTypeAccess.filterEmailLabel}>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.items.map(toEmployeeOption)}
|
||||||
|
value={filterEmail}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.requestTypeAccess.filterEmailAll}
|
||||||
|
placeholder={texts.requestTypeAccess.filterEmailPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={setFilterEmail}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{access.error && <Alert kind="err">{access.error}</Alert>}
|
||||||
|
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> },
|
||||||
|
{ key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email },
|
||||||
|
{ key: 'sharePointLists', header: texts.requestTypeAccess.columnSharePointLists, render: formatSharePointLists },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
width: '100px',
|
||||||
|
align: 'right',
|
||||||
|
sticky: true,
|
||||||
|
render: r => (
|
||||||
|
<ActionsCell>
|
||||||
|
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||||
|
</ActionsCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
rows={filteredItems}
|
||||||
|
keyOf={r => r.id}
|
||||||
|
emptyMessage={
|
||||||
|
access.isLoading
|
||||||
|
? texts.common.loading
|
||||||
|
: access.items.length > 0 && filteredItems.length === 0
|
||||||
|
? texts.requestTypeAccess.emptyFiltered
|
||||||
|
: texts.requestTypeAccess.emptyList
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{createOpen.isOpen && (
|
||||||
|
<FormModal
|
||||||
|
title={texts.requestTypeAccess.createTitle}
|
||||||
|
error={submit.error}
|
||||||
|
isSaving={submit.isBusy}
|
||||||
|
onSubmit={submitForm}
|
||||||
|
onClose={createOpen.close}
|
||||||
|
onReset={draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
|
>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
||||||
|
<Select
|
||||||
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
||||||
|
value={requestTypeId}
|
||||||
|
onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||||
|
<div>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employeeOptions}
|
||||||
|
value=""
|
||||||
|
loading={employees.isLoading}
|
||||||
|
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={value => value && setEmails(current => [...current, value])}
|
||||||
|
/>
|
||||||
|
<div className={styles.chosen}>
|
||||||
|
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
|
||||||
|
{emails.map(email => (
|
||||||
|
<div key={email} className={styles.chosenItem}>
|
||||||
|
<span>{employeeLabel(email)}</span>
|
||||||
|
<IconButton
|
||||||
|
icon="close"
|
||||||
|
label={texts.requestTypeAccess.removeEmail}
|
||||||
|
onClick={() => setEmails(current => current.filter(x => x !== email))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</FormModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteTarget.target && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={texts.requestTypeAccess.deleteTitle}
|
||||||
|
text={texts.requestTypeAccess.deleteText(deleteTarget.target.email, requestTypeName(deleteTarget.target.requestTypeId))}
|
||||||
|
confirmLabel={texts.common.remove}
|
||||||
|
busy={deleteTarget.isBusy}
|
||||||
|
onCancel={deleteTarget.cancel}
|
||||||
|
onConfirm={deleteTarget.confirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,6 +19,8 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
|||||||
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -57,6 +59,22 @@ const EMPTY_FORM: IFormState = {
|
|||||||
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
|
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
||||||
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType';
|
||||||
|
|
||||||
|
function toFormState(item: RequestType): IFormState {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
pageUrl: item.pageUrl,
|
||||||
|
section: item.section,
|
||||||
|
siteUrl: item.siteUrl ?? '',
|
||||||
|
listId: item.listId ?? '',
|
||||||
|
slaHours: slaHoursToInput(item.slaHours),
|
||||||
|
askOverdueReason: boolToTriState(item.askOverdueReason)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
||||||
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||||
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
||||||
@@ -78,8 +96,18 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
const list = useRequestTypes(api);
|
const list = useRequestTypes(api);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
|
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
||||||
|
const [editForm, setEditForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||||
|
const form = editing ? editForm : draft.value;
|
||||||
|
const isEditDirty = Boolean(editing) && JSON.stringify(editForm) !== JSON.stringify(toFormState(editing as RequestType));
|
||||||
|
const setForm = editing ? setEditForm : draft.setValue;
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||||
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
||||||
@@ -92,21 +120,14 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setForm(EMPTY_FORM);
|
setShowForm(false);
|
||||||
setShowForm(true);
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: RequestType): void => {
|
const openEdit = (item: RequestType): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setForm({
|
setEditForm(toFormState(item));
|
||||||
name: item.name,
|
createOpen.close();
|
||||||
pageUrl: item.pageUrl,
|
|
||||||
section: item.section,
|
|
||||||
siteUrl: item.siteUrl ?? '',
|
|
||||||
listId: item.listId ?? '',
|
|
||||||
slaHours: slaHoursToInput(item.slaHours),
|
|
||||||
askOverdueReason: boolToTriState(item.askOverdueReason)
|
|
||||||
});
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +164,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(() => {
|
request.then(() => {
|
||||||
setShowForm(false);
|
if (!editing) draft.reset();
|
||||||
|
closeForm();
|
||||||
list.reload();
|
list.reload();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -192,13 +214,16 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<FormModal
|
<FormModal
|
||||||
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||||
error={submit.error}
|
error={submit.error}
|
||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
|
onReset={editing ? undefined : draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
|
isDirty={isEditDirty}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.requestTypes.nameLabel} required full>
|
<Field label={texts.requestTypes.nameLabel} required full>
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
|
|||||||
conditions: step.conditions,
|
conditions: step.conditions,
|
||||||
conditionsLogic: step.conditionsLogic,
|
conditionsLogic: step.conditionsLogic,
|
||||||
slaHours: step.slaHours,
|
slaHours: step.slaHours,
|
||||||
askOverdueReason: step.askOverdueReason
|
askOverdueReason: step.askOverdueReason,
|
||||||
|
autoApprove: step.autoApprove
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ interface IFormState {
|
|||||||
requiresControl: boolean;
|
requiresControl: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
||||||
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route';
|
||||||
|
|
||||||
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return {
|
return {
|
||||||
@@ -58,7 +62,10 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
|
// Редактирование не персистим — форма всегда открывается с данными маршрута с сервера.
|
||||||
|
const draft = useFormDraft<IFormState>(editing ? undefined : CREATE_DRAFT_STORAGE_KEY, toFormState(editing, initialRequestTypeId));
|
||||||
|
const form = draft.value;
|
||||||
|
const setForm = draft.setValue;
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||||
@@ -93,6 +100,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(() => {
|
request.then(() => {
|
||||||
|
draft.reset();
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
@@ -106,6 +114,9 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onReset={editing ? undefined : draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
|
isDirty={Boolean(editing) && draft.isDirty}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.routes.requestTypeLabel} required full>
|
<Field label={texts.routes.requestTypeLabel} required full>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||||
@@ -77,6 +78,7 @@ interface IFormState {
|
|||||||
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
|
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
|
||||||
slaHours: string;
|
slaHours: string;
|
||||||
askOverdueReason: TriState;
|
askOverdueReason: TriState;
|
||||||
|
autoApprove: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' };
|
const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' };
|
||||||
@@ -118,6 +120,9 @@ const IS_MANAGER_VALUE_OPTIONS = [
|
|||||||
{ value: 'false', label: texts.common.no }
|
{ value: 'false', label: texts.common.no }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */
|
||||||
|
const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`;
|
||||||
|
|
||||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return {
|
return {
|
||||||
@@ -133,7 +138,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
|||||||
conditions: [EMPTY_CONDITION],
|
conditions: [EMPTY_CONDITION],
|
||||||
conditionsLogic: ConditionsLogic.And,
|
conditionsLogic: ConditionsLogic.And,
|
||||||
slaHours: '',
|
slaHours: '',
|
||||||
askOverdueReason: ''
|
askOverdueReason: '',
|
||||||
|
autoApprove: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -149,7 +155,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
|||||||
conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
|
conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
|
||||||
conditionsLogic: editing.conditionsLogic,
|
conditionsLogic: editing.conditionsLogic,
|
||||||
slaHours: slaHoursToInput(editing.slaHours),
|
slaHours: slaHoursToInput(editing.slaHours),
|
||||||
askOverdueReason: boolToTriState(editing.askOverdueReason)
|
askOverdueReason: boolToTriState(editing.askOverdueReason),
|
||||||
|
autoApprove: editing.autoApprove
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +174,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
// Редактирование не персистим — форма всегда открывается с данными шага с сервера.
|
||||||
|
const draft = useFormDraft<IFormState>(editing ? undefined : createDraftStorageKey(route.id), toFormState(editing, nextOrderNo));
|
||||||
|
const form = draft.value;
|
||||||
|
const setForm = draft.setValue;
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const hasListId = Boolean(requestType?.listId);
|
const hasListId = Boolean(requestType?.listId);
|
||||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||||
@@ -365,6 +375,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
submit.fail(texts.routeSteps.validationSlaHours);
|
submit.fail(texts.routeSteps.validationSlaHours);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Автосогласование отсчитывается от SLA — без него фоновая задача шаг просто
|
||||||
|
// пропустит (см. AutoApproveService.DeadlineOf), и настройка тихо не сработала
|
||||||
|
// бы. Пустое поле здесь не значит «нет SLA»: оно значит «взять у типа заявки»,
|
||||||
|
// поэтому ошибка только когда SLA нет и там.
|
||||||
|
if (form.autoApprove && parseSlaHours(form.slaHours) === null && requestType?.slaHours === null) {
|
||||||
|
submit.fail(texts.routeSteps.validationAutoApproveWithoutSla);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
orderNo,
|
orderNo,
|
||||||
@@ -379,7 +397,8 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
: null,
|
: null,
|
||||||
conditionsLogic: form.conditionsLogic,
|
conditionsLogic: form.conditionsLogic,
|
||||||
slaHours: parseSlaHours(form.slaHours),
|
slaHours: parseSlaHours(form.slaHours),
|
||||||
askOverdueReason: triStateToBool(form.askOverdueReason)
|
askOverdueReason: triStateToBool(form.askOverdueReason),
|
||||||
|
autoApprove: form.autoApprove
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = editing
|
const request = editing
|
||||||
@@ -388,6 +407,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(step => {
|
request.then(step => {
|
||||||
|
draft.reset();
|
||||||
onSaved(step);
|
onSaved(step);
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
@@ -401,6 +421,9 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onReset={editing ? undefined : draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
|
isDirty={Boolean(editing) && draft.isDirty}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.routeSteps.orderNoLabel} required>
|
<Field label={texts.routeSteps.orderNoLabel} required>
|
||||||
@@ -505,6 +528,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label={texts.routeSteps.autoApproveLabel} full hint={texts.routeSteps.autoApproveHint}>
|
||||||
|
<Checkbox
|
||||||
|
label={texts.routeSteps.autoApproveCheckbox}
|
||||||
|
checked={form.autoApprove}
|
||||||
|
onChange={v => setForm({ ...form, autoApprove: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={texts.routeSteps.conditionCheckbox}
|
label={texts.routeSteps.conditionCheckbox}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u
|
|||||||
import { reportError } from '../../utils/errors';
|
import { reportError } from '../../utils/errors';
|
||||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen),
|
||||||
|
// ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const createOpen = usePersistedOpen(`approvalAdmin.draftOpen.routeStep.${route.id}`);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
||||||
item => api.templateRouteSteps.remove(route.id, item.id),
|
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||||
@@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(false);
|
||||||
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRouteStep): void => {
|
const openEdit = (item: TemplateRouteStep): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
|
createOpen.close();
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<RouteStepFormModal
|
<RouteStepFormModal
|
||||||
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
||||||
// применяется к любой компании инициатора) — тогда подсказка
|
// применяется к любой компании инициатора) — тогда подсказка
|
||||||
@@ -293,7 +303,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
employees={employees.items}
|
employees={employees.items}
|
||||||
employeesLoading={employees.isLoading}
|
employeesLoading={employees.isLoading}
|
||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
|
import { reportError } from '../../utils/errors';
|
||||||
|
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
|
||||||
|
|
||||||
|
export interface IRoutesExportScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
|
||||||
|
// делают по одному и тому же набору типов.
|
||||||
|
const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected';
|
||||||
|
|
||||||
|
function download(blob: Blob, fileName: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
|
||||||
|
const requestTypes = useRequestTypes(api);
|
||||||
|
|
||||||
|
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
|
||||||
|
const [isBusy, setIsBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const allIds = requestTypes.items.map(rt => rt.id);
|
||||||
|
// Удалённые с тех пор типы из сохранённого выбора не считаем.
|
||||||
|
const selected = storedSelected.filter(id => allIds.indexOf(id) !== -1);
|
||||||
|
const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1);
|
||||||
|
|
||||||
|
const toggle = (id: number, checked: boolean): void =>
|
||||||
|
setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id)));
|
||||||
|
|
||||||
|
const runExport = (): void => {
|
||||||
|
setError(undefined);
|
||||||
|
setIsBusy(true);
|
||||||
|
api.reports.routes(selected)
|
||||||
|
.then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName))
|
||||||
|
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
|
||||||
|
.then(() => setIsBusy(false), () => setIsBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="clipboard"
|
||||||
|
subtitle={texts.routesExport.subtitle}
|
||||||
|
title={texts.routesExport.title}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
icon="check"
|
||||||
|
disabled={selected.length === 0 || isBusy}
|
||||||
|
onClick={runExport}
|
||||||
|
>
|
||||||
|
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||||
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<p>{texts.routesExport.hint}</p>
|
||||||
|
{requestTypes.isLoading && <p>{texts.common.loading}</p>}
|
||||||
|
{!requestTypes.isLoading && requestTypes.items.length === 0 && <p>{texts.routesExport.emptyTypes}</p>}
|
||||||
|
{requestTypes.items.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
label={texts.routesExport.selectAll}
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={checked => setSelected(checked ? allIds : [])}
|
||||||
|
/>
|
||||||
|
{requestTypes.items.map(rt => (
|
||||||
|
<Checkbox
|
||||||
|
key={rt.id}
|
||||||
|
label={rt.name}
|
||||||
|
checked={selected.indexOf(rt.id) !== -1}
|
||||||
|
onChange={checked => toggle(rt.id, checked)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<p>{texts.routesExport.selected(selected.length)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
|||||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { replaceRoute } from '../../routing';
|
import { replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,8 @@ export interface IRoutesScreenProps {
|
|||||||
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
||||||
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
||||||
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
||||||
|
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
|
||||||
|
|
||||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||||
api,
|
api,
|
||||||
@@ -69,7 +72,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||||
@@ -83,11 +92,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(false);
|
||||||
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRoute): void => {
|
const openEdit = (item: TemplateRoute): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
|
createOpen.close();
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +170,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
api={api}
|
api={api}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -169,7 +180,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
departments={portalDictionaries.departments}
|
departments={portalDictionaries.departments}
|
||||||
dictionaryLoading={portalDictionaries.isLoading}
|
dictionaryLoading={portalDictionaries.isLoading}
|
||||||
onSaved={routes.reload}
|
onSaved={routes.reload}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
box-shadow: $sh-1;
|
box-shadow: $sh-1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@include card-hover-gold;
|
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
// Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls.
|
||||||
|
@include card-hover-gold;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
import { ModalOverlay } from './ModalOverlay';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IConfirmDialogProps {
|
export interface IConfirmDialogProps {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
|
/** Подпись кнопки отказа — по умолчанию «Отмена». */
|
||||||
|
cancelLabel?: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
@@ -16,18 +19,19 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
|
|||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
|
cancelLabel = texts.common.cancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy
|
busy
|
||||||
}) => (
|
}) => (
|
||||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
<ModalOverlay onBackdropClick={onCancel}>
|
||||||
<div className={`${styles.box} ${styles.narrow}`}>
|
<div className={`${styles.box} ${styles.narrow}`}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{title}</div>
|
||||||
<p className={styles.text}>{text}</p>
|
<p className={styles.text}>{text}</p>
|
||||||
<div className={styles.narrowFooter}>
|
<div className={styles.narrowFooter}>
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button>
|
<Button variant="secondary" onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||||
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert } from './Alert';
|
import { Alert } from './Alert';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
|
import styles from './Modal.module.scss';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IFormModalProps {
|
export interface IFormModalProps {
|
||||||
@@ -15,6 +17,18 @@ export interface IFormModalProps {
|
|||||||
savingLabel?: string;
|
savingLabel?: string;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
|
||||||
|
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
|
||||||
|
*/
|
||||||
|
onReset?: () => void;
|
||||||
|
canReset?: boolean;
|
||||||
|
/**
|
||||||
|
* В форме есть несохранённые правки существующей записи: выход (крестик,
|
||||||
|
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
|
||||||
|
* не нужен — их черновик сохраняется сам (см. useFormDraft).
|
||||||
|
*/
|
||||||
|
isDirty?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,14 +44,32 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
|||||||
savingLabel = texts.common.saving,
|
savingLabel = texts.common.saving,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
|
onReset,
|
||||||
|
canReset,
|
||||||
|
isDirty,
|
||||||
children
|
children
|
||||||
}) => (
|
}) => {
|
||||||
|
const [confirmingClose, setConfirmingClose] = React.useState(false);
|
||||||
|
// Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл.
|
||||||
|
const requestClose = (): void => {
|
||||||
|
if (isSaving) return;
|
||||||
|
if (isDirty) setConfirmingClose(true);
|
||||||
|
else onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title={title}
|
title={title}
|
||||||
onClose={onClose}
|
onClose={requestClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
{onReset && canReset && (
|
||||||
|
<span className={styles.footerStart}>
|
||||||
|
<Button variant="ghost" icon="refresh" onClick={onReset} disabled={isSaving}>{texts.common.resetDraft}</Button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={requestClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
||||||
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
||||||
{isSaving ? savingLabel : submitLabel}
|
{isSaving ? savingLabel : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -47,4 +79,16 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
|||||||
{error && <Alert kind="err">{error}</Alert>}
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
{children}
|
{children}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
{confirmingClose && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={texts.common.discardTitle}
|
||||||
|
text={texts.common.discardText}
|
||||||
|
confirmLabel={texts.common.discardConfirm}
|
||||||
|
cancelLabel={texts.common.discardKeepEditing}
|
||||||
|
onConfirm={onClose}
|
||||||
|
onCancel={() => setConfirmingClose(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,9 +7,17 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 300;
|
// Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится
|
||||||
|
// порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую.
|
||||||
|
z-index: 100000;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
// Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss)
|
||||||
|
// сюда не наследуются, задаём заново.
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
line-height: 1.55;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
@@ -63,4 +71,7 @@
|
|||||||
padding: 16px 26px 22px;
|
padding: 16px 26px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */
|
||||||
|
.footerStart { margin-right: auto; }
|
||||||
|
|
||||||
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
|
import { ModalOverlay } from './ModalOverlay';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IModalProps {
|
export interface IModalProps {
|
||||||
@@ -11,8 +12,7 @@ export interface IModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
||||||
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
|
<ModalOverlay onBackdropClick={onClose}>
|
||||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{title}</div>
|
||||||
@@ -21,5 +21,5 @@ export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children
|
|||||||
<div className={styles.body}>{children}</div>
|
<div className={styles.body}>{children}</div>
|
||||||
{footer && <div className={styles.footer}>{footer}</div>}
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import styles from './Modal.module.scss';
|
||||||
|
|
||||||
|
export interface IModalOverlayProps {
|
||||||
|
/** Клик по подложке (не по содержимому окна). */
|
||||||
|
onBackdropClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Затемнение под модалкой — порталом в document.body, а не внутри веб-части.
|
||||||
|
* На живой странице SharePoint канва веб-части лежит в своём контексте наложения
|
||||||
|
* ниже шапки (suite bar), и position: fixed-оверлей изнутри неё оказывался ПОД
|
||||||
|
* шапкой: верх окна, центрированного по высоте экрана, уезжал за неё. В workbench
|
||||||
|
* шапки нет — там было незаметно.
|
||||||
|
*/
|
||||||
|
export const ModalOverlay: React.FC<IModalOverlayProps> = ({ onBackdropClick, children }) =>
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
|
||||||
|
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onBackdropClick(); }}>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
|
// Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может
|
||||||
// открыть сама длительная операция (например, разрешение конфликта), должны
|
// открыть сама длительная операция (например, разрешение конфликта), должны
|
||||||
// быть ПОВЕРХ этой подложки, а не под ней.
|
// быть ПОВЕРХ этой подложки, а не под ней.
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
box-shadow: $sh-modal;
|
box-shadow: $sh-modal;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
z-index: 400;
|
z-index: 100001; // над оверлеем модалки (Modal.module.scss)
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -116,6 +116,7 @@
|
|||||||
|
|
||||||
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||||
&.selected { font-weight: 600; color: $navy-900; }
|
&.selected { font-weight: 600; color: $navy-900; }
|
||||||
|
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemSub {
|
.itemSub {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface ISearchableOption {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
|
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchableSelectProps {
|
export interface ISearchableSelectProps {
|
||||||
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||||
|
disabled={opt.disabled}
|
||||||
onClick={() => pick(opt.value)}
|
onClick={() => pick(opt.value)}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from './PrimaryCell';
|
|||||||
export * from './ActionsCell';
|
export * from './ActionsCell';
|
||||||
export * from './CellNote';
|
export * from './CellNote';
|
||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
|
export * from './ModalOverlay';
|
||||||
export * from './ConfirmDialog';
|
export * from './ConfirmDialog';
|
||||||
export * from './FormModal';
|
export * from './FormModal';
|
||||||
export * from './FormGrid';
|
export * from './FormGrid';
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { readLocalStorage } from './useLocalStorageState';
|
||||||
|
|
||||||
|
export interface IFormDraft<T> {
|
||||||
|
value: T;
|
||||||
|
setValue: React.Dispatch<React.SetStateAction<T>>;
|
||||||
|
/** Форма отличается от initial — есть что сбрасывать. */
|
||||||
|
isDirty: boolean;
|
||||||
|
/** Вернуть initial и забыть черновик (кнопка «Очистить», успешное сохранение). */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDraft(key: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// приватный режим — черновика и не было
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние формы создания, которое переживает закрытие модалки (случайный клик
|
||||||
|
* по подложке, «Отмена», F5): черновик лежит в localStorage под key, пока его
|
||||||
|
* не сбросят явно — reset() после успешного сохранения или по кнопке «Очистить».
|
||||||
|
*
|
||||||
|
* key === undefined — обычный useState без персиста: так форма редактирования
|
||||||
|
* существующей записи всегда открывается с актуальными данными с сервера, а не
|
||||||
|
* с устаревшей правкой.
|
||||||
|
*/
|
||||||
|
export function useFormDraft<T>(key: string | undefined, initial: T): IFormDraft<T> {
|
||||||
|
const [value, setValue] = React.useState<T>(() => (key ? readLocalStorage(key, initial) : initial));
|
||||||
|
|
||||||
|
// initial у вызывающих — литерал, пересоздаётся каждый рендер; сравниваем по содержимому.
|
||||||
|
const initialJson = JSON.stringify(initial);
|
||||||
|
const isDirty = JSON.stringify(value) !== initialJson;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!key) return;
|
||||||
|
if (!isDirty) {
|
||||||
|
removeDraft(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// приватный режим / переполненная квота — не критично, просто не персистим
|
||||||
|
}
|
||||||
|
}, [key, value, isDirty]);
|
||||||
|
|
||||||
|
// Ключ удаляем синхронно, а не через эффект: после успешного сохранения модалка
|
||||||
|
// тут же размонтируется, и эффект на новое значение уже не отработает.
|
||||||
|
const reset = (): void => {
|
||||||
|
if (key) removeDraft(key);
|
||||||
|
setValue(JSON.parse(initialJson) as T);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { value, setValue, isDirty, reset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { coerceStored } from './useLocalStorageState';
|
||||||
|
|
||||||
|
describe('coerceStored', () => {
|
||||||
|
const initial = { name: '', count: 0, flag: false, tags: [] as string[], nested: { a: '' } };
|
||||||
|
|
||||||
|
it('подставляет дефолт для полей, добавленных после сохранения', () => {
|
||||||
|
expect(coerceStored(initial, { name: 'x' })).toEqual({ ...initial, name: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отбрасывает ключи, которых в форме больше нет', () => {
|
||||||
|
expect(coerceStored(initial, { name: 'x', removed: 1 })).not.toHaveProperty('removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заменяет значение другого типа дефолтом', () => {
|
||||||
|
expect(coerceStored(initial, { name: 5, tags: { a: 1 }, flag: 'yes', nested: 'oops' }))
|
||||||
|
.toEqual(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('принимает массив нужного типа и мёржит вложенный объект', () => {
|
||||||
|
expect(coerceStored(initial, { tags: ['a'], nested: {} }))
|
||||||
|
.toEqual({ ...initial, tags: ['a'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не падает на null и примитиве вместо объекта', () => {
|
||||||
|
expect(coerceStored(initial, null)).toEqual(initial);
|
||||||
|
expect(coerceStored(initial, 'x')).toEqual(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для примитивного initial проверяет тип', () => {
|
||||||
|
expect(coerceStored('', 5)).toBe('');
|
||||||
|
expect(coerceStored('', 'abc')).toBe('abc');
|
||||||
|
expect(coerceStored<number[]>([], [1, 2])).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для boolean-флага отбрасывает значение другого типа', () => {
|
||||||
|
expect(coerceStored(false, true)).toBe(true);
|
||||||
|
expect(coerceStored(false, 'true')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при initial === undefined принимает сохранённое как есть', () => {
|
||||||
|
expect(coerceStored<string | undefined>(undefined, 'a')).toBe('a');
|
||||||
|
expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,27 +5,55 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
* Сохранённое значение, приведённое к форме initial. Запись могла быть сделана
|
||||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
* старой версией формы, поэтому ей нельзя доверять слепо:
|
||||||
* квота) не ломает форму — просто не персистит.
|
* - объект мёржится с initial — добавленные с тех пор поля получают дефолт;
|
||||||
*
|
* - ключи, которых в initial нет (поле удалили), отбрасываются;
|
||||||
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
* - значение, чей тип не совпал с initial (было число — стала строка, было
|
||||||
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
* [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер;
|
||||||
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
* - null/undefined в initial типа не задают — там принимаем любое сохранённое.
|
||||||
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
|
||||||
*/
|
*/
|
||||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
export function coerceStored<T>(initial: T, stored: unknown): T {
|
||||||
const [value, setValue] = React.useState<T>(() => {
|
if (initial === null || initial === undefined) return stored as T;
|
||||||
|
if (Array.isArray(initial)) return (Array.isArray(stored) ? stored : initial) as unknown as T;
|
||||||
|
if (isPlainObject(initial)) {
|
||||||
|
if (!isPlainObject(stored)) return initial;
|
||||||
|
const result: Record<string, unknown> = { ...initial };
|
||||||
|
Object.keys(initial).forEach(k => {
|
||||||
|
if (k in stored) result[k] = coerceStored(initial[k], stored[k]);
|
||||||
|
});
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
return (typeof stored === typeof initial ? stored : initial) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значение из localStorage под key, приведённое к форме initial (см. coerceStored),
|
||||||
|
* или initial, если записи нет / она битая / localStorage недоступен.
|
||||||
|
*/
|
||||||
|
export function readLocalStorage<T>(key: string, initial: T): T {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(key);
|
const raw = window.localStorage.getItem(key);
|
||||||
if (!raw) return initial;
|
if (!raw) return initial;
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as T;
|
return coerceStored(initial, JSON.parse(raw));
|
||||||
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
|
|
||||||
} catch {
|
} catch {
|
||||||
return initial;
|
return initial;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
||||||
|
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||||
|
* квота) не ломает форму — просто не персистит.
|
||||||
|
*
|
||||||
|
* Сохранённое значение приводится к форме initial (см. coerceStored): новое поле
|
||||||
|
* получает дефолт, удалённое отбрасывается, значение не того типа заменяется
|
||||||
|
* дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же
|
||||||
|
* .map/.trim по этому полю.
|
||||||
|
*/
|
||||||
|
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||||
|
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user