10 Commits

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

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

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

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

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

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

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

---------

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

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

---------

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

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

---------

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

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

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/24
2026-09-28 06:37:48 +00:00
gorbenko.pavel a8bfd9f456 Feature/request type access (#23)
Tests / frontend-test (push) Successful in 49s
Tests / backend-test (push) Successful in 1m38s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/23
2026-09-25 11:48:02 +00:00
gorbenko.pavel 1bf8d4ca11 Feature/request manage server load (#22)
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 1m43s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/22
2026-09-25 06:30:59 +00:00
gorbenko.pavel 2b8fe77fac Auto approve (#21)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 3m39s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
2026-09-14 09:01:56 +00:00
91 changed files with 4892 additions and 609 deletions
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.BasisRequests;
namespace Tnsu.ApprovalRoute.Api.Controllers;
/// <summary>
/// Заявки, поданные по одному основанию (СТ, потребность). Видны все, независимо
/// от прав вызывающего на элементы; CanOpen говорит, может ли он открыть заявку.
/// Только пользовательский токен: без email права проверять не для кого.
/// </summary>
[ApiController]
[Authorize(Policy = AuthPolicies.Delegated)]
[Route("api/basis-requests")]
[Produces("application/json")]
public sealed class BasisRequestsController(BasisRequestsService service) : ControllerBase
{
/// <summary>Заявки типа requestTypeId с BasisKey = basisKey по всем партициям.</summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<BasisRequestDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Get(
[FromQuery] int requestTypeId,
// string? — пустой параметр фронтенд вырезает из URL целиком (см. RequestManageController.Search).
[FromQuery] string? basisKey,
CancellationToken ct) =>
(await service.GetByBasisAsync(requestTypeId, basisKey ?? string.Empty, ct)).ToActionResult();
}
@@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController]
[Authorize(Policy = AuthPolicies.Any)]
[Route("api/reports")]
public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase
public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
{
private const string XlsxContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
@@ -51,4 +51,27 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll
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>
/// <param name="title">
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
/// без учёта регистра, по вхождению подстроки.
/// </param>
[HttpGet("lists")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> ListAll(CancellationToken ct) =>
(await service.ListAllAsync(ct)).ToActionResult();
public async Task<IActionResult> ListAll([FromQuery] string? title, CancellationToken ct) =>
(await service.ListAllAsync(title, ct)).ToActionResult();
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
[HttpGet("{requestTypeId:int}/lists")]
+29 -1
View File
@@ -6,12 +6,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Scalar.AspNetCore;
using Serilog;
using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.Reports;
@@ -51,7 +53,10 @@ builder.Services.AddCors(options =>
options.AddPolicy(CorsPolicyName, policy => policy
.WithOrigins(corsAllowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod());
.AllowAnyMethod()
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
.WithExposedHeaders("Content-Disposition"));
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
@@ -59,6 +64,7 @@ builder.Services.AddApprovalRouteApplication();
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports"));
builder.Services.Configure<AutoApproveOptions>(builder.Configuration.GetSection("AutoApprove"));
builder.Services.AddHangfire(configuration => configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
@@ -133,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();
await app.InitAndRunAsync();
@@ -3,6 +3,9 @@
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="appsettings.*.json" DependentUpon="appsettings.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
<PackageReference Include="Hangfire.AspNetCore" />
@@ -24,6 +24,11 @@
"https://localhost:4321"
]
},
"AutoApprove": {
"Enabled": true,
"Cron": "0 * * * *",
"MaxRowsPerRun": 200
},
"ListPartitioning": {
"ItemCountThreshold": 4900,
"StaleLockAfter": "00:30:00",
@@ -1,9 +1,13 @@
using System.Reflection;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.BasisRequests;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reports;
using Tnsu.ApprovalRoute.Application.RequestManage;
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -16,11 +20,17 @@ public static class ApplicationServiceCollectionExtensions
{
services.AddScoped<ReminderService>();
services.AddScoped<RequestTypeService>();
services.AddScoped<RequestTypeAccessService>();
services.AddScoped<RequestManageService>();
services.AddScoped<BasisRequestsService>();
services.AddScoped<ListPartitionRegistry>();
services.AddScoped<ListPartitionService>();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
services.AddScoped<CreateNextListPartitionJob>();
services.AddScoped<AutoApproveService>();
services.AddScoped<AutoApproveJob>();
services.AddScoped<VacancyReportService>();
services.AddScoped<RouteExportService>();
services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>();
@@ -0,0 +1,30 @@
namespace Tnsu.ApprovalRoute.Application.Approvals;
/// <summary>
/// Конвенция имён списков портала заявок: у каждой пары списков имя данных и имя
/// согласования отличаются только суффиксом после длинного тире (см.
/// Get-NumberedListTitle в request-portal/provisioning/Provision-Lists.ps1 —
/// именно провижинер эти имена и ставит).
///
/// Отдельным классом, а не приватным методом сервиса: правило нужно и
/// напоминаниям (ReminderService), и автосогласованию (AutoApproveService), и
/// разъехаться между ними оно не должно — переименование одного списка из пары
/// молча оставило бы один из сценариев без строк.
/// </summary>
internal static class ApprovalListNaming
{
/// <summary>
/// «Заявка на приём 1 — данные» → «Заявка на приём 1 — согласование».
/// null — имя не по конвенции, список согласования по нему не найти.
/// </summary>
public static string? ApprovalListTitleFor(string dataListTitle)
{
var index = dataListTitle.LastIndexOf('—');
if (index < 0) return null;
var suffix = dataListTitle[(index + 1)..].Trim();
return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase)
? $"{dataListTitle[..index].TrimEnd()} — согласование"
: null;
}
}
@@ -1,12 +1,17 @@
using System.Globalization;
using System.Text.Json;
namespace Tnsu.ApprovalRoute.Application.Reminders;
namespace Tnsu.ApprovalRoute.Application.Approvals;
/// <summary>
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
/// именно он эти списки и создаёт.
///
/// Общая для напоминаний (ReminderService) и автосогласования
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
/// просроченным шаг считается в одном месте и не считается в другом.
/// </summary>
internal sealed record ApprovalRow(
int Id,
@@ -17,7 +22,13 @@ internal sealed record ApprovalRow(
string? ApproverEmail,
string? ApproverName,
int? SlaHours,
bool? AskOverdueReason)
bool? AskOverdueReason,
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
// сценария — см. RowSelect у каждого из них.
double Order = 0,
bool AutoApprove = false)
{
public static ApprovalRow? TryParse(JsonElement item)
{
@@ -41,7 +52,12 @@ internal sealed record ApprovalRow(
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
approverObject is { } an ? GetString(an, "Title") : null,
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null,
// StepOrder — Number, и он бывает ДРОБНЫМ: делегат вклинивается между
// шагами долей (см. ApproverService.delegate в request-portal),
// поэтому здесь double, а не int.
TryGetDouble(item, "StepOrder", out var order) ? order : 0,
TryGetBool(item, "AutoApprove", out var auto) && auto);
}
private static string? GetString(JsonElement e, string name) =>
@@ -81,6 +97,18 @@ internal sealed record ApprovalRow(
return true;
}
/// <summary>Дробное число из JSON — с теми же оговорками, что и <see cref="TryGetInt"/>.</summary>
private static bool TryGetDouble(JsonElement e, string name, out double value)
{
value = 0;
if (!e.TryGetProperty(name, out var v)) return false;
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out value);
return v.ValueKind == JsonValueKind.String &&
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
private static bool TryGetBool(JsonElement e, string name, out bool value)
{
value = false;
@@ -110,8 +138,34 @@ internal static class ApprovalPhases
public const string Execution = "Исполнение";
}
/// <summary>
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
/// Один в один с REQUEST_STATUS в request-portal
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
/// </summary>
internal static class ApprovalStatuses
{
public const string Queued = "Ждёт очереди";
public const string OnApproval = "На согласовании";
public const string OnExecution = "На исполнении";
public const string Approved = "Согласовано";
public const string Executed = "Выполнено";
public const string Rejected = "Отклонено";
public const string Returned = "Возвращена на доработку";
}
/// <summary>
/// Значения колонки RequestStatus — статус ЗАЯВКИ целиком (список «… — данные»).
/// Набор ДРУГОЙ, чем у строки маршрута: «Ждёт очереди»/«На исполнении» у заявки
/// не бывает, а «Выполнение»/«Закрыта» не бывает у строки (см. тот же
/// statuses.ts, где оба набора сведены).
/// </summary>
internal static class RequestStatuses
{
public const string OnApproval = "На согласовании";
public const string Approved = "Согласовано";
public const string Rejected = "Отклонено";
public const string Returned = "Возвращена на доработку";
public const string Execution = "Выполнение";
public const string Closed = "Закрыта";
}
@@ -0,0 +1,35 @@
using Hangfire;
using Microsoft.Extensions.Logging;
namespace Tnsu.ApprovalRoute.Application.Approvals;
/// <summary>
/// Рекуррентная задача автосогласования — тонкая обёртка над
/// <see cref="AutoApproveService"/>: всё, что она добавляет сверху, — расписание,
/// защита от наложения запусков и одна строка в логе с итогом.
///
/// Повторы отключены (Attempts = 0) намеренно, в отличие от
/// CreateNextListPartitionJob: там повтор нужен, потому что пропуск задачи ломает
/// сценарий целиком (список не создан — писать некуда). Здесь же следующий запуск
/// придёт сам, через час, и сделает ровно ту же работу с актуальным состоянием —
/// уже согласованные строки в выборку не попадут. Повторять упавший проход раньше
/// этого смысла нет, а висящие в очереди повторы только прятали бы настоящую
/// причину сбоя.
/// </summary>
[AutomaticRetry(Attempts = 0)]
[DisableConcurrentExecution(timeoutInSeconds: 5 * 60)]
public sealed class AutoApproveJob(AutoApproveService service, ILogger<AutoApproveJob> logger)
{
/// <summary>
/// Идентификатор рекуррентной задачи в Hangfire. Константой — по нему задача и
/// заводится, и снимается при выключении настройки (см. Program.cs): разъехавшись,
/// эти два имени оставили бы в дашборде задачу, которую уже некому снять.
/// </summary>
public const string RecurringJobId = "auto-approve";
public async Task RunAsync(CancellationToken cancellationToken)
{
var approved = await service.RunAsync(cancellationToken);
logger.LogInformation("Автосогласование: строк согласовано автоматически — {Count}", approved);
}
}
@@ -0,0 +1,40 @@
namespace Tnsu.ApprovalRoute.Application.Approvals;
public sealed class AutoApproveOptions
{
/// <summary>
/// Включено ли автосогласование вообще. Выключение снимает рекуррентную задачу
/// из Hangfire (см. Program.cs), а не просто делает её пустой: иначе в дашборде
/// осталась бы задача, которая регулярно запускается и ничего не делает.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Расписание запуска (cron, UTC). Раз в час: SLA задаётся в ЧАСАХ, поэтому
/// час — предельная точность, которая вообще имеет смысл, а более частый опрос
/// всех списков SharePoint ничего не ускоряет по существу.
///
/// Следствие: шаг согласуется не в сам момент истечения SLA, а на ближайшем
/// запуске после него — то есть в пределах часа позже. Это осознанно: раньше
/// срока не согласуется никогда, а опоздание в пределах часа для срока,
/// измеряемого часами, роли не играет.
/// </summary>
public string Cron { get; set; } = "0 * * * *";
/// <summary>
/// Комментарий, который задача пишет в строку согласования вместо комментария
/// согласующего. Нужен, чтобы в матрице заявки было видно, что решение принял не
/// человек: других признаков у автосогласованной строки нет — статус и дата у неё
/// такие же, как у обычной.
/// </summary>
public string Comment { get; set; } = "Согласовано автоматически: истёк срок SLA по этому шагу.";
/// <summary>
/// Сколько строк автосогласовать максимум за один запуск. Предохранитель на
/// случай массового включения флага или сдвига дат: без него один запуск мог бы
/// пройти по всему архиву партиций и написать в SharePoint тысячи изменений,
/// упёршись в троттлинг. Остаток уедет в следующий запуск — задача идемпотентна
/// (уже решённые строки в выборку активных не попадают).
/// </summary>
public int MaxRowsPerRun { get; set; } = 200;
}
@@ -0,0 +1,354 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.Approvals;
/// <summary>
/// Автосогласование по SLA: находит строки списков согласования, у которых
/// включён флаг «Автосогласование» (колонка AutoApprove) и истёк срок SLA, и
/// принимает за согласующего положительное решение.
///
/// Это ЗЕРКАЛО клиентской логики решения из request-portal
/// (shared/approval/approverDecision.service.ts — decide, и approvalList.service.ts —
/// setDecision/activateNextQueued): та же последовательность «пометить строку →
/// продвинуть очередь → пересчитать статус заявки», просто выполненная сервером
/// от имени приложения, а не браузером от имени человека. Расхождение между ними
/// означало бы, что автосогласованная заявка встаёт в состояние, в которое
/// портал её сам никогда не приводит, поэтому правила здесь повторены буквально,
/// а не «по смыслу» (см. ActivationTargets и ComputeRequestStatus).
///
/// В отличие от <see cref="Reminders.ReminderService"/> (срез «кто просрочил»,
/// без записи) задача именно ПИШЕТ в SharePoint, поэтому обход здесь
/// последовательный: параллелить запись по типам заявок — это упереться в
/// троттлинг и потерять предсказуемость порядка, а объём работы за один запуск
/// ограничен сверху (см. AutoApproveOptions.MaxRowsPerRun).
/// </summary>
public sealed class AutoApproveService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
TimeProvider timeProvider,
IOptions<AutoApproveOptions> options,
ILogger<AutoApproveService> logger)
{
// Активные строки С ВКЛЮЧЁННЫМ автосогласованием — отбор целиком на стороне
// SharePoint, чтобы не тащить сюда весь список ради единиц строк.
//
// Да/Нет-колонка в $filter сравнивается с 1/0, а не с true/false.
//
// У строк, заведённых ДО появления колонки AutoApprove, значение пустое, и
// под `eq 1` они не попадают — это верно по смыслу: включить автосогласование
// задним числом нельзя, флаг проставляется при отправке заявки (портал) или
// вручную в админке.
private const string CandidateFilter =
$"$filter=(ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}')" +
" and AutoApprove eq 1";
private const string RowSelect =
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,AutoApprove,StepOrder,TicketId&$top=5000";
/// <summary>
/// Один проход по всем типам заявок. Возвращает число автосогласованных строк —
/// его пишет в лог задача (см. AutoApproveJob), и по нему же удобно понять из
/// дашборда Hangfire, делал ли запуск хоть что-нибудь.
/// </summary>
public async Task<int> RunAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
// В отличие от напоминаний, ТИПЫ БЕЗ SlaHours отсюда НЕ отсеиваются: у типа
// SLA может быть не задан, а у конкретного шага — задан, и именно такой шаг
// чаще всего и делают автосогласуемым («это согласование формальное, ждём
// сутки и идём дальше»). Шаг, у которого SLA нет ни там, ни там, отсеется
// ниже поштучно (см. DeadlineOf).
var types = await db.RequestTypes.AsNoTracking()
.Where(x => x.ListId != null && x.SiteUrl != null)
.ToListAsync(cancellationToken);
var approved = 0;
foreach (var type in types)
{
if (approved >= options.Value.MaxRowsPerRun) break;
try
{
approved += await RunForTypeAsync(type, now, options.Value.MaxRowsPerRun - approved, cancellationToken);
}
catch (Exception e)
{
// Недоступный список одного типа не должен останавливать остальные —
// ровно как в напоминаниях.
logger.LogError(e, "Автосогласование по типу заявки «{Type}» не выполнено", type.Name);
}
}
return approved;
}
private async Task<int> RunForTypeAsync(
RequestType type, DateTimeOffset now, int limit, CancellationToken cancellationToken)
{
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
// Архив обходится наравне с текущей партицией: заявка, уехавшая в архив,
// продолжает ждать решения так же, как в текущем списке, и её шаг точно так
// же просрочивается (см. тот же довод в ReminderService).
var partitions = archived
.Select(x => (x.ListId, x.Number))
.Append((current.ListId, current.Number));
var approved = 0;
foreach (var (listId, number) in partitions)
{
if (approved >= limit) break;
try
{
approved += await RunForPartitionAsync(type, listId, now, limit - approved, cancellationToken);
}
catch (Exception e)
{
logger.LogError(
e, "Автосогласование по списку {ListId} (партиция №{Number}) типа «{Type}» не выполнено",
listId, number, type.Name);
}
}
return approved;
}
private async Task<int> RunForPartitionAsync(
RequestType type, string listId, DateTimeOffset now, int limit, CancellationToken cancellationToken)
{
var siteUrl = type.SiteUrl!;
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
if (string.IsNullOrWhiteSpace(dataListTitle)) return 0;
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null)
{
logger.LogWarning(
"Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти",
dataListTitle);
return 0;
}
var candidates = await ReadRowsAsync(
siteUrl, approvalListTitle, $"{RowSelect}&{CandidateFilter}", cancellationToken);
if (candidates.Count == 0) return 0;
var approved = 0;
// По заявкам, а не по строкам подряд: решение по одной строке двигает
// очередь и меняет статус всей заявки, поэтому строки одной заявки нельзя
// обрабатывать по устаревшему снимку — внутри группы состояние
// перечитывается (см. ApproveAsync).
foreach (var ticket in candidates.GroupBy(r => r.TicketId))
{
if (approved >= limit) return approved;
foreach (var candidate in ticket.OrderBy(r => r.Order))
{
if (approved >= limit) return approved;
var deadline = DeadlineOf(candidate, type);
if (deadline is null || deadline > now) continue;
var done = await ApproveAsync(
type, siteUrl, approvalListTitle, listId, candidate, deadline.Value, now, cancellationToken);
if (done) approved++;
}
}
return approved;
}
/// <summary>
/// Момент, после которого шаг считается просроченным. null — SLA не задан ни на
/// строке, ни на типе заявки, либо не заполнена дата активации: отсчитывать не
/// от чего, и автосогласовать такой шаг нельзя — он «висел» бы бесконечно, а
/// сработал бы в произвольный момент после того, как SLA всё-таки настроят.
/// </summary>
private DateTimeOffset? DeadlineOf(ApprovalRow row, RequestType type)
{
if (row.Date is not { } activatedAt)
{
logger.LogWarning(
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
"но дата активации (Date) не заполнена — пропущена",
row.Id, row.TicketId, type.Id);
return null;
}
// Как и в напоминаниях: SLA строки перекрывает SLA типа заявки, и ноль —
// это ноль («сразу»), а не «не задано».
var slaHours = row.SlaHours ?? type.SlaHours;
if (slaHours is null)
{
logger.LogWarning(
"Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " +
"но SLA не задан ни на шаге, ни на типе заявки — пропущена",
row.Id, row.TicketId, type.Id);
return null;
}
return activatedAt.AddHours(slaHours.Value);
}
/// <summary>
/// Решение по одной строке. Возвращает false, если строку решать уже не нужно —
/// её успел закрыть человек между выборкой кандидатов и этим моментом.
///
/// Проверка «строка всё ещё активна» делается по СВЕЖЕМУ чтению, а не по
/// кандидату из выборки: между ними проходит сколько угодно времени (обход
/// партиций, сеть), и записать «Согласовано» поверх только что поставленного
/// человеком «Отклонено» — худшее, что эта задача может сделать. Полностью
/// гонку это не закрывает (решение может прийти и в миллисекунду между
/// проверкой и записью), но сводит окно к одному HTTP-запросу вместо всего
/// прохода; блокировок на элемент списка SharePoint не предоставляет.
/// </summary>
private async Task<bool> ApproveAsync(
RequestType type, string siteUrl, string approvalListTitle, string dataListUrl,
ApprovalRow candidate, DateTimeOffset deadline, DateTimeOffset now, CancellationToken cancellationToken)
{
var rows = await ReadTicketRowsAsync(siteUrl, approvalListTitle, candidate.TicketId, cancellationToken);
var row = rows.FirstOrDefault(r => r.Id == candidate.Id);
if (row is null || !IsActive(row))
{
logger.LogInformation(
"Строка {Row} заявки {Ticket} уже не ждёт решения — автосогласование не требуется",
candidate.Id, candidate.TicketId);
return false;
}
// Фаза исполнения закрывается статусом «Выполнено», а не «Согласовано» —
// это те же два значения, что ставит DecisionPanel портала для решений
// approve и execute.
var decision = row.Phase == ApprovalPhases.Execution
? ApprovalStatuses.Executed
: ApprovalStatuses.Approved;
await sharePoint.UpdateItemByListTitleAsync(
siteUrl, approvalListTitle, row.Id,
new
{
ApprovalStatus = decision,
// Дата РЕШЕНИЯ перезаписывает дату активации — так же, как
// setDecision в портале (колонка Date у строки одна).
Date = now.UtcDateTime,
Comment = options.Value.Comment
},
cancellationToken);
logger.LogInformation(
"Строка {Row} заявки {Ticket} типа «{Type}» автосогласована ({Decision}): срок SLA истёк {Deadline:u}",
row.Id, row.TicketId, type.Name, decision, deadline);
// Дальше считаем по снимку, в котором решение УЖЕ применено: перечитывать
// список после каждой записи незачем — мы сами знаем, что изменили, а
// лишние чтения на каждом шаге заметно удлиняют проход.
var updated = rows
.Select(r => r.Id == row.Id ? r with { Status = decision } : r)
.ToList();
var activated = ActivationTargets(updated);
foreach (var target in activated)
{
await sharePoint.UpdateItemByListTitleAsync(
siteUrl, approvalListTitle, target.Row.Id,
// Активация — это статус ПЛЮС отметка времени «получил на
// согласование»: от неё отсчитывается SLA следующего шага (см.
// setActive в портале). Без даты шаг выпал бы и из напоминаний, и
// из автосогласования.
new { ApprovalStatus = target.Status, Date = now.UtcDateTime },
cancellationToken);
}
foreach (var target in activated)
updated[updated.FindIndex(r => r.Id == target.Row.Id)] = target.Row with { Status = target.Status };
await sharePoint.UpdateItemByListUrlAsync(
siteUrl, dataListUrl, row.TicketId,
new { RequestStatus = ComputeRequestStatus(updated) },
cancellationToken);
return true;
}
private static bool IsActive(ApprovalRow row) =>
row.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution;
/// <summary>
/// Кого активировать после положительного решения — зеркало
/// ApprovalListService.activateNextQueued из request-portal.
///
/// Среди ещё ждущих строк текущей фазы активируются те, что делят НАИМЕНЬШИЙ
/// StepOrder: обычно это одна строка, несколько — параллельный шаг. Фаза
/// исполнения активируется, только когда фаза согласования пройдена целиком;
/// если согласование остановлено (отклонение/возврат), не активируется никто.
/// </summary>
private static IReadOnlyList<(ApprovalRow Row, string Status)> ActivationTargets(IReadOnlyList<ApprovalRow> rows)
{
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
if (approvalRows.Any(r => r.Status is ApprovalStatuses.Rejected or ApprovalStatuses.Returned))
return [];
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
var (phaseRows, activeStatus) = approvalDone
? (execRows, ApprovalStatuses.OnExecution)
: (approvalRows, ApprovalStatuses.OnApproval);
// В фазе уже есть активная строка — очередь двигать не нужно (так бывает при
// параллельном шаге, где решение принято ещё не всеми).
if (phaseRows.Any(r => r.Status == activeStatus)) return [];
var queued = phaseRows.Where(r => r.Status == ApprovalStatuses.Queued).ToList();
if (queued.Count == 0) return [];
var minOrder = queued.Min(r => r.Order);
return queued.Where(r => r.Order == minOrder).Select(r => (r, activeStatus)).ToList();
}
/// <summary>
/// Статус ЗАЯВКИ по строкам маршрута — зеркало computeRequestStatus из
/// request-portal (approverDecision.service.ts). Порядок проверок важен и
/// повторён дословно: отклонение и возврат старше незавершённого согласования,
/// а «Согласовано» — финал только для маршрута без фазы исполнения.
/// </summary>
private static string ComputeRequestStatus(IReadOnlyList<ApprovalRow> rows)
{
var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList();
var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList();
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Rejected)) return RequestStatuses.Rejected;
if (approvalRows.Any(r => r.Status == ApprovalStatuses.Returned)) return RequestStatuses.Returned;
var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved);
if (!approvalDone) return RequestStatuses.OnApproval;
if (execRows.Count == 0) return RequestStatuses.Approved;
if (execRows.All(r => r.Status == ApprovalStatuses.Executed)) return RequestStatuses.Closed;
return RequestStatuses.Execution;
}
private Task<IReadOnlyList<ApprovalRow>> ReadTicketRowsAsync(
string siteUrl, string approvalListTitle, int ticketId, CancellationToken cancellationToken) =>
ReadRowsAsync(siteUrl, approvalListTitle, $"{RowSelect}&$filter=TicketId eq {ticketId}", cancellationToken);
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken)
{
var items = await sharePoint.GetItemsByListTitleAsync(siteUrl, listTitle, odataQuery, cancellationToken);
return items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
}
}
@@ -0,0 +1,20 @@
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
/// <summary>
/// Заявка, поданная по тому же основанию (СТ, потребность). Номер, статус и сумму
/// видят все: от них зависит проверка «сумма оплат не больше основания». Ссылку
/// на саму заявку фронтенд показывает только при CanOpen.
/// </summary>
/// <param name="Id">Id элемента списка данных.</param>
/// <param name="ListId">Серверный относительный URL списка партиции, где лежит заявка.</param>
/// <param name="UniqueCode">RequestUniqueCode; null — номер ещё не присвоен.</param>
/// <param name="Status">RequestStatus заявки.</param>
/// <param name="Amount">Сумма заявки (PayAmount).</param>
/// <param name="CanOpen">У вызывающего есть право просмотра элемента.</param>
public sealed record BasisRequestDto(
int Id,
string ListId,
string? UniqueCode,
string Status,
double Amount,
bool CanOpen);
@@ -0,0 +1,116 @@
using System.Globalization;
using System.Text.Json;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
/// <summary>
/// Заявки, поданные по одному основанию (колонка BasisKey: «ct:&lt;Id&gt;»,
/// «need:&lt;oid&gt;») — для таблицы прежних оплат в «Заявке на оплату».
///
/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и
/// под своей учёткой заявитель видел бы только собственные заявки — сумма
/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы
/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё
/// есть права: CanOpen проверяется по каждому элементу для вызывающего.
/// </summary>
public sealed class BasisRequestsService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
ICurrentUser currentUser)
{
private const string BasisKeyField = "BasisKey";
private const string AmountField = "PayAmount";
// Больше по одному основанию не бывает: СТ оплачивают частями, но не сотнями.
private const int MaxItemsPerPartition = 500;
// Проверка прав — отдельный запрос на элемент; ограничиваем параллельность,
// чтобы не упереться в троттлинг SharePoint (429).
private const int MaxParallelPermissionChecks = 4;
public async Task<Result<IReadOnlyList<BasisRequestDto>>> GetByBasisAsync(
int requestTypeId, string basisKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(basisKey))
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new ValidationError("Не указано основание (basisKey)."));
var requestType = await db.RequestTypes.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new NotFoundError("Тип заявки", requestTypeId));
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
return Result.Ok<IReadOnlyList<BasisRequestDto>>([]);
var siteUrl = requestType.SiteUrl;
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
var listIds = archived.Select(x => x.ListId).Append(current.ListId).ToList();
// Кавычка в OData-литерале удваивается; сам литерал кодируем — в ключе бывают «:» и прочее.
var literal = Uri.EscapeDataString(basisKey.Trim().Replace("'", "''"));
var query = $"$select=Id,RequestUniqueCode,RequestStatus,{AmountField}" +
$"&$filter={BasisKeyField} eq '{literal}'&$orderby=Id&$top={MaxItemsPerPartition}";
var found = new List<(JsonElement Item, string ListId)>();
foreach (var listId in listIds)
{
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listId, query, cancellationToken);
found.AddRange(items.Select(i => (i, listId)));
}
var email = currentUser.Email;
using var gate = new SemaphoreSlim(MaxParallelPermissionChecks);
var result = await Task.WhenAll(found.Select(async x =>
{
var id = GetInt(x.Item, "Id") ?? 0;
var canOpen = false;
if (!string.IsNullOrWhiteSpace(email) && id > 0)
{
await gate.WaitAsync(cancellationToken);
try
{
canOpen = await sharePoint.CanUserViewItemAsync(siteUrl, x.ListId, id, email, cancellationToken);
}
finally
{
gate.Release();
}
}
return new BasisRequestDto(
id,
x.ListId,
GetString(x.Item, "RequestUniqueCode"),
GetString(x.Item, "RequestStatus") ?? string.Empty,
GetDouble(x.Item, AmountField) ?? 0,
canOpen);
}));
return Result.Ok<IReadOnlyList<BasisRequestDto>>(result);
}
private static string? GetString(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static int? GetInt(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
private static double? GetDouble(JsonElement e, string name)
{
if (!e.TryGetProperty(name, out var v)) return null;
if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d;
if (v.ValueKind == JsonValueKind.String &&
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
return parsed;
return null;
}
}
@@ -0,0 +1,44 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.Common;
/// <summary>
/// Русские названия типов руководителя и ролей в проекте — для всего, что видит
/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя
/// enum'а («DirectionHead») в тексте для пользователя недопустимо.
/// </summary>
public static class StepLabels
{
public static string Of(HeadType headType) => headType switch
{
HeadType.Chief => "Непосредственный руководитель",
HeadType.DirectionHead => "Руководитель направления",
HeadType.DirectorTansu => "Директор Tansu",
HeadType.ManagingDirectorTansu => "Управляющий директор Tansu",
HeadType.DepartmentHead => "Руководитель указанного отдела",
HeadType.CompanyDirectors => "Директора компаний инициатора",
_ => headType.ToString()
};
/// <summary>Название с маленькой буквы — для середины фразы («Не найден руководитель направления»).</summary>
public static string InSentence(HeadType headType) => Lower(Of(headType));
/// <summary>
/// Все названия мужского рода («найден руководитель проекта»), кроме «Безопасность» —
/// для неё нужна отдельная формулировка, иначе выйдет «найден безопасность».
/// </summary>
public static string InSentence(ProjectRole projectRole) =>
projectRole == ProjectRole.Security ? "сотрудник службы безопасности" : Lower(Of(projectRole));
private static string Lower(string label) => char.ToLowerInvariant(label[0]) + label[1..];
public static string Of(ProjectRole projectRole) => projectRole switch
{
ProjectRole.ProjectManager => "Руководитель проекта",
ProjectRole.Planner => "Планировщик",
ProjectRole.ManagerHSE => "Менеджер HSE",
ProjectRole.Security => "Безопасность",
ProjectRole.Keeper => "Заведующий склада",
_ => projectRole.ToString()
};
}
@@ -78,7 +78,12 @@ public sealed class ListPartitionService(
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
/// </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
.Where(x => x.ListId != null && x.SiteUrl != null)
@@ -88,6 +93,14 @@ public sealed class ListPartitionService(
foreach (var requestType in requestTypes)
{
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
if (!string.IsNullOrWhiteSpace(title))
partitions = partitions
.Where(x => x.Title is not null && x.Title.Contains(title, StringComparison.OrdinalIgnoreCase))
.ToList();
if (partitions.Count == 0) continue;
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
}
@@ -3,6 +3,7 @@ using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
@@ -163,7 +164,7 @@ public sealed class ReminderService(
return [];
}
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null)
{
logger.LogWarning(
@@ -258,18 +259,6 @@ public sealed class ReminderService(
.Replace("{listId}", Uri.EscapeDataString(listId))
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
/// <summary>«Заявка на приём — данные» → «Заявка на приём — согласование».</summary>
private static string? ApprovalListTitleFor(string dataListTitle)
{
var index = dataListTitle.LastIndexOf('—');
if (index < 0) return null;
var suffix = dataListTitle[(index + 1)..].Trim();
return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase)
? $"{dataListTitle[..index].TrimEnd()} — согласование"
: null;
}
private const string RowSelect =
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
"TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
@@ -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();
}
@@ -26,7 +26,7 @@ internal sealed record SelectionRow(
/// </summary>
public const string Select =
"$select=Id,RequestStatus,Position,DepartmentName,ObjectProject,OpenVacanciesCount," +
"SubmitDate,Created,OpenReason,OpenReasonDetails,Responsible/Title&$expand=Responsible&$top=5000";
"SubmitDate,Created,VacancyOpenReason,VacancyOpenReasonDetails,Responsible/Title&$expand=Responsible&$top=5000";
public static SelectionRow? TryParse(JsonElement item, string listId)
{
@@ -50,7 +50,7 @@ internal sealed record SelectionRow(
// иначе заявка попадала бы в отчёт, но с пустой колонкой H.
SharePointJson.Date(item, "SubmitDate") ?? SharePointJson.Date(item, "Created"),
SharePointJson.PersonTitle(item, "Responsible"),
SharePointJson.String(item, "OpenReason"),
SharePointJson.String(item, "OpenReasonDetails"));
SharePointJson.String(item, "VacancyOpenReason"),
SharePointJson.String(item, "VacancyOpenReasonDetails"));
}
}
@@ -24,7 +24,7 @@ namespace Tnsu.ApprovalRoute.Application.Reports;
/// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не
/// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно
/// через REST):
/// • «Заявка на подбор»: OpenReason, OpenReasonDetails;
/// • «Заявка на подбор»: VacancyOpenReason, VacancyOpenReasonDetails;
/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
@@ -0,0 +1,76 @@
namespace Tnsu.ApprovalRoute.Application.RequestManage;
// Формы 1:1 с фронтендом (frontend/src/webparts/approvalAdmin/models/requestManage.ts,
// manageByUser.ts) — эти DTO заменяют собой то, что раньше собирал PnP-клиент
// НАПРЯМУЮ в SharePoint от имени текущего пользователя (см. requestManageService.ts):
// тут это делает бэкенд от имени приложения (см. ISharePointClient), поэтому
// результат не зависит от прав конкретного пользователя на списки SharePoint.
/// <summary>Заявка (строка списка «… — данные»), как её видит комбобокс выбора заявки.</summary>
public sealed record ManagedRequestSummaryDto(
int Id,
string Title,
string Status,
/// <summary>ISO 8601 или пустая строка.</summary>
string Created,
string AuthorName,
string? UniqueCode,
/// <summary>Партиция (серверный относительный url списка данных), где физически лежит заявка.</summary>
string ListId);
public sealed record ManagedRequestPageDto(IReadOnlyList<ManagedRequestSummaryDto> Items, bool HasMore);
/// <summary>Строка списка «… — согласование».</summary>
public sealed record ManagedApprovalRowDto(
int Id,
int TicketId,
int? ApproverId,
string ApproverName,
string ApproverEmail,
string Position,
string Status,
/// <summary>ISO 8601 или пустая строка — форматирование под «дд.мм.гггг» остаётся на фронтенде.</summary>
string DateIso,
string Comment,
double Order,
bool AllowReassign,
string Phase,
int? SlaHours,
bool? AskOverdueReason);
/// <summary>Заявка + полный маршрут (обе фазы) — для экрана редактирования заявок.</summary>
public sealed record ManagedRequestMatrixDto(
ManagedRequestSummaryDto Request,
string SiteUrl,
string DataListTitle,
string DataListUrl,
string ApprovalListTitle,
IReadOnlyList<ManagedApprovalRowDto> Rows);
/// <summary>
/// Строка согласования, найденная сразу по ВСЕМ типам заявок и партициям — для
/// экрана «Заявки по согласующему» (UserApprovalRow во фронтенде — тот же набор
/// полей, что и ManagedApprovalRowDto, только развёрнутый, а не вложенный).
/// </summary>
public sealed record UserApprovalRowDto(
int Id,
int TicketId,
int? ApproverId,
string ApproverName,
string ApproverEmail,
string Position,
string Status,
string DateIso,
string Comment,
double Order,
bool AllowReassign,
string Phase,
int? SlaHours,
bool? AskOverdueReason,
int RequestTypeId,
string RequestTypeName,
string SiteUrl,
string ListId,
string ApprovalListTitle,
string RequestTitle,
string? RequestUniqueCode);
@@ -0,0 +1,444 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.RegularExpressions;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.RequestManage;
/// <summary>
/// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок»
/// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не
/// текущего пользователя.
///
/// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде
/// (см. requestManageService.ts) от имени залогиненного администратора — и
/// список видимых заявок молча урезался правами САМОГО администратора на списки
/// SharePoint (item-level security), хотя вкладка задумана как «видно всё».
/// Правка строк маршрута (переназначить/удалить/переставить) остаётся на
/// фронтенде через PnP — сюда вынесено только чтение.
/// </summary>
public sealed class RequestManageService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
ILogger<RequestManageService> logger)
{
private const int DefaultPageSize = 20;
// Сколько типов заявок читаем из SharePoint одновременно. Без ограничения
// 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались
// и экран показывал пустой список.
private const int MaxParallelTypes = 3;
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
// requestManageService.ts (тот же самый список SharePoint, тот же провижининг).
private const string ApproverField = "TansuSiteApprover";
private const string StatusField = "ApprovalStatus";
private const string TicketIdField = "TicketId";
private const string DateField = "Date";
private const string CommentField = "Comment";
private const string OrderField = "StepOrder";
private const string AllowReassignField = "AllowReassignToOtherApprovers";
private const string PhaseField = "Phase";
private const string SlaHoursField = "SLAhours";
private const string AskOverdueReasonField = "AskOverdueReason";
private const string ApprovalRowSelect =
$"$select=Id,Title,{StatusField},{DateField},{CommentField},{OrderField},{AllowReassignField},{PhaseField}," +
$"{SlaHoursField},{AskOverdueReasonField},{TicketIdField},{ApproverField}/Id,{ApproverField}/Title,{ApproverField}/EMail" +
$"&$expand={ApproverField}";
private const string DataRowSelect = "$select=Id,Title,RequestStatus,Created,Author/Title,RequestUniqueCode&$expand=Author";
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
// (см. requestManageService.ts/DATA_LIST_SUFFIX).
private static readonly Regex DataListSuffix = new(@"\s*—\s*данные\s*$", RegexOptions.Compiled);
/// <summary>
/// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая),
/// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen).
/// </summary>
public async Task<Result<ManagedRequestPageDto>> SearchAsync(
int requestTypeId, string query, int page, int? pageSize, CancellationToken cancellationToken)
{
var requestType = await db.RequestTypes.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<ManagedRequestPageDto>(new NotFoundError("Тип заявки", requestTypeId));
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
return Result.Ok(new ManagedRequestPageDto([], false));
var size = pageSize is > 0 ? pageSize.Value : DefaultPageSize;
var siteUrl = requestType.SiteUrl;
var partitionIds = await GetAllPartitionListIdsAsync(requestType, cancellationToken);
var all = new List<ManagedRequestSummaryDto>();
foreach (var listId in partitionIds)
{
var items = await sharePoint.GetItemsByListUrlAsync(
siteUrl, listId, $"{DataRowSelect}&$top=5000", cancellationToken);
all.AddRange(items.Select(i => ToSummary(i, listId)));
}
var words = SplitQueryWords(query);
var filtered = all.Where(x => MatchesQuery(x, words))
// Тот же порядок, что и раньше (Search сортировал по Created по возрастанию).
.OrderBy(x => x.Created, StringComparer.Ordinal)
.ToList();
var pageItems = filtered.Skip((page - 1) * size).Take(size).ToList();
return Result.Ok(new ManagedRequestPageDto(pageItems, page * size < filtered.Count));
}
/// <summary>Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.</summary>
public async Task<Result<ManagedRequestMatrixDto>> GetMatrixAsync(
int requestTypeId, string listId, int requestId, CancellationToken cancellationToken)
{
var requestType = await db.RequestTypes.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Тип заявки", requestTypeId));
if (string.IsNullOrWhiteSpace(requestType.SiteUrl))
return Result.Fail<ManagedRequestMatrixDto>(
new ValidationError($"У типа заявки {requestTypeId} не указан сайт SharePoint."));
var siteUrl = requestType.SiteUrl;
var rawRequestItems = await sharePoint.GetItemsByListUrlAsync(
siteUrl, listId, $"{DataRowSelect}&$filter=Id eq {requestId}&$top=1", cancellationToken);
var rawRequest = rawRequestItems.FirstOrDefault();
if (rawRequest.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Заявка", requestId));
var request = ToSummary(rawRequest, listId);
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken) ?? string.Empty;
var dataListUrl = ListAbsUrl(siteUrl, listId);
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null)
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, string.Empty, []));
var rawRows = await sharePoint.GetItemsByListTitleAsync(
siteUrl, approvalListTitle,
$"{ApprovalRowSelect}&$filter={TicketIdField} eq {requestId}&$orderby={OrderField}", cancellationToken);
var rows = rawRows.Select(ToApprovalRow).ToList();
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows));
}
/// <summary>
/// Активные строки согласования данного человека сразу по всем типам заявок и
/// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему».
/// </summary>
public async Task<Result<IReadOnlyList<UserApprovalRowDto>>> FindActiveApprovalRowsForApproverAsync(
string approverEmail, IReadOnlyList<string> activeStatuses, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0)
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>([]);
var requestTypes = await db.RequestTypes.AsNoTracking()
.Where(x => x.ListId != null && x.SiteUrl != null)
.ToListAsync(cancellationToken);
var email = approverEmail.Trim();
// DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО,
// и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше
// GetArchivedAsync вызывался из параллельных задач — «вторая операция на
// контексте» падала, а пустой catch превращал это в пустой список.
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
foreach (var rt in requestTypes)
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
using var gate = new SemaphoreSlim(MaxParallelTypes);
// Search не поймать и не подменить обходом списков: если он недоступен, это
// видно по ошибке запроса, а не по тихо неполному результату.
var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken);
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(rows);
}
private const string SearchApproverProperty = "TansuSiteApproverOWSUSER";
private const string SearchStatusProperty = "ApprovalStatusOWSCHCS";
private static readonly Regex ApprovalListSuffix = new(@"\s*—\s*согласование\s*$", RegexOptions.Compiled);
private static UserApprovalRowDto ToUserRow(
ManagedApprovalRowDto r, RequestType requestType, string siteUrl, string dataListId, string approvalListTitle,
ManagedRequestSummaryDto? dataRow) =>
new(
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
/// <summary>
/// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН
/// запрос Search на сайт (по человеку и статусам, сразу по всем спискам
/// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования
/// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные
/// строки дочитываются по REST — только в тех списках, где они реально нашлись.
/// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST.
/// </summary>
private async Task<IReadOnlyList<UserApprovalRowDto>> FindViaSearchAsync(
IReadOnlyList<RequestType> requestTypes,
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
IReadOnlyList<string> activeStatuses,
string approverEmail,
SemaphoreSlim gate,
CancellationToken cancellationToken)
{
var email = approverEmail.Replace("\"", string.Empty);
var statusQuery = string.Join(
" OR ", activeStatuses.Select(s => $"{SearchStatusProperty}:\"{s.Replace("\"", string.Empty)}\""));
var kql = $"{SearchApproverProperty}:\"{email}\" ({statusQuery}) contentclass:STS_ListItem_GenericList";
var result = new List<UserApprovalRowDto>();
foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase))
{
var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken);
// Путь вида https://host/sites/x/Lists/L/DispForm.aspx?ID=5 → серверный url списка.
var byList = hits
.Select(h => (
Url: ListUrlFromHitPath(h.GetValueOrDefault("Path")),
Id: int.TryParse(h.GetValueOrDefault("ListItemID"), out var id) ? id : 0))
.Where(x => x.Url is not null && x.Id > 0)
.GroupBy(x => x.Url!, StringComparer.OrdinalIgnoreCase)
.ToList();
var siteTypes = requestTypes
.Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase))
.ToList();
var perList = await Task.WhenAll(byList.Select(async group =>
{
await gate.WaitAsync(cancellationToken);
try
{
return await ReadSearchHitListAsync(
siteUrl, group.Key, group.Select(x => x.Id).Distinct().ToList(), siteTypes, archivedByType,
activeStatuses, approverEmail, cancellationToken);
}
finally
{
gate.Release();
}
}));
result.AddRange(perList.SelectMany(x => x));
}
return result;
}
// Пара «список согласования → (его заголовок, адрес парного списка данных)»
// не меняется, пока список не переименуют, — запоминаем на время жизни процесса,
// чтобы не тратить два запроса к SharePoint на каждый вызов. Ключ — адрес списка.
private static readonly ConcurrentDictionary<string, (string ApprovalTitle, string DataListUrl)> ListPairs =
new(StringComparer.OrdinalIgnoreCase);
private async Task<(string ApprovalTitle, string DataListUrl)?> ResolveListPairAsync(
string siteUrl, string approvalListUrl, CancellationToken cancellationToken)
{
if (ListPairs.TryGetValue(approvalListUrl, out var cached)) return cached;
var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken);
if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return null;
var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные");
var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken);
if (dataList is null) return null;
var pair = (approvalList.Title, dataList.ServerRelativeUrl);
ListPairs[approvalListUrl] = pair;
return pair;
}
private static string? ListUrlFromHitPath(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null;
var local = Uri.UnescapeDataString(uri.AbsolutePath);
var cut = local.LastIndexOf('/');
return cut > 0 ? local[..cut] : null;
}
private async Task<IReadOnlyList<UserApprovalRowDto>> ReadSearchHitListAsync(
string siteUrl,
string approvalListUrl,
IReadOnlyList<int> itemIds,
IReadOnlyList<RequestType> siteTypes,
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
IReadOnlyList<string> activeStatuses,
string approverEmail,
CancellationToken cancellationToken)
{
try
{
var pair = await ResolveListPairAsync(siteUrl, approvalListUrl, cancellationToken);
if (pair is null) return [];
var (approvalTitle, dataListUrl) = pair.Value;
// Какому типу заявок принадлежит эта партиция (текущая или архивная).
var requestType = siteTypes.FirstOrDefault(rt =>
string.Equals(rt.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)
|| archivedByType[rt.Id].Any(a =>
string.Equals(a.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)));
if (requestType is null) return [];
var rows = new List<ManagedApprovalRowDto>();
foreach (var chunk in itemIds.Chunk(50))
{
var filter = string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
var items = await sharePoint.GetItemsByListUrlAsync(
siteUrl, approvalListUrl, $"{ApprovalRowSelect}&$filter={filter}&$top={chunk.Length}",
cancellationToken);
rows.AddRange(items.Select(ToApprovalRow));
}
var matched = rows
.Where(r => activeStatuses.Contains(r.Status)
&& string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matched.Count == 0) return [];
var dataById = await ReadDataRowsAsync(
siteUrl, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(),
cancellationToken);
return matched
.Select(r => ToUserRow(
r, requestType, siteUrl, dataListUrl, approvalTitle,
dataById.GetValueOrDefault(r.TicketId)))
.ToList();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "by-approver: список «{ApprovalListUrl}» из Search пропущен", approvalListUrl);
return [];
}
}
private async Task<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
{
if (ticketIds.Count == 0) return new Dictionary<int, ManagedRequestSummaryDto>();
var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}"));
var items = await sharePoint.GetItemsByListUrlAsync(
siteUrl, listId, $"{DataRowSelect}&$filter={filter}&$top={ticketIds.Count}", cancellationToken);
return items.Select(i => ToSummary(i, listId)).ToDictionary(x => x.Id);
}
/// <summary>Все партиции типа заявок (архив + текущая), только их listId — для поиска.</summary>
private async Task<IReadOnlyList<string>> GetAllPartitionListIdsAsync(
RequestType requestType, CancellationToken cancellationToken)
{
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
return archived.Select(x => x.ListId).Append(current.ListId).ToList();
}
private static ManagedRequestSummaryDto ToSummary(JsonElement item, string listId) =>
new(
GetInt(item, "Id") ?? 0,
GetString(item, "Title") is { Length: > 0 } title ? title : $"#{GetInt(item, "Id") ?? 0}",
GetString(item, "RequestStatus") ?? string.Empty,
GetString(item, "Created") ?? string.Empty,
item.TryGetProperty("Author", out var author) && author.ValueKind == JsonValueKind.Object
? GetString(author, "Title") ?? string.Empty
: string.Empty,
GetString(item, "RequestUniqueCode"),
listId);
private static ManagedApprovalRowDto ToApprovalRow(JsonElement item)
{
var approver = item.TryGetProperty(ApproverField, out var a) && a.ValueKind == JsonValueKind.Object ? a : (JsonElement?)null;
return new ManagedApprovalRowDto(
GetInt(item, "Id") ?? 0,
GetInt(item, TicketIdField) ?? 0,
approver is { } ap ? GetInt(ap, "Id") : null,
approver is { } an ? GetString(an, "Title") ?? string.Empty : string.Empty,
approver is { } ae ? GetString(ae, "EMail") ?? string.Empty : string.Empty,
GetString(item, "Title") ?? string.Empty,
GetString(item, StatusField) ?? string.Empty,
GetString(item, DateField) ?? string.Empty,
GetString(item, CommentField) ?? string.Empty,
GetDouble(item, OrderField) ?? 0,
GetBool(item, AllowReassignField) ?? false,
GetString(item, PhaseField) is { Length: > 0 } phase ? phase : "Согласование",
GetInt(item, SlaHoursField),
GetBool(item, AskOverdueReasonField));
}
private static string? ApprovalListTitleFor(string dataListTitle) =>
DataListSuffix.IsMatch(dataListTitle)
? $"{DataListSuffix.Replace(dataListTitle, string.Empty)} — согласование"
: null;
private static string SiteOrigin(string siteUrl)
{
var match = Regex.Match(siteUrl, "^https?://[^/]+");
return match.Success ? match.Value : siteUrl;
}
private static string ListAbsUrl(string siteUrl, string listId) => $"{SiteOrigin(siteUrl)}{listId}";
// KQL во фронтенде терпел только буквы/цифры/пробелы (см. sanitizeForKql в
// requestManageService.ts) — здесь та же санитизация для сравнения без учёта
// регистра по Title/UniqueCode, только вместо полнотекстового индекса Search
// сравниваем подстроки напрямую (см. MatchesQuery).
private static IReadOnlyList<string> SplitQueryWords(string query)
{
var sanitized = Regex.Replace(query, @"[^a-zA-Zа-яА-ЯёЁ0-9\s]", " ");
return sanitized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
}
private static bool MatchesQuery(ManagedRequestSummaryDto summary, IReadOnlyList<string> words)
{
if (words.Count == 0) return true;
var haystack = $"{summary.Title} {summary.UniqueCode}".ToLowerInvariant();
return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal));
}
private static string? GetString(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static int? GetInt(JsonElement e, string name)
{
if (!e.TryGetProperty(name, out var v)) return null;
if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i)) return i;
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var parsed)) return parsed;
return null;
}
private static double? GetDouble(JsonElement e, string name)
{
if (!e.TryGetProperty(name, out var v)) return null;
if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d;
if (v.ValueKind == JsonValueKind.String && double.TryParse(v.GetString(), out var parsed)) return parsed;
return null;
}
private static bool? GetBool(JsonElement e, string name)
{
if (!e.TryGetProperty(name, out var v)) return null;
if (v.ValueKind is JsonValueKind.True or JsonValueKind.False) return v.GetBoolean();
if (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var parsed)) return parsed;
return null;
}
}
@@ -0,0 +1,18 @@
using Tnsu.ApprovalRoute.Application.ListPartitioning;
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
/// <param name="SharePointLists">
/// Вся серия SharePoint-списков данных (архив + текущая партиция) типа заявки —
/// именно на них нужно выдать email'у право чтения, иначе поиск по порталу
/// продолжит отдавать только его собственные заявки: доступ здесь настраивает
/// видимость в UI approval-route, но не права SharePoint, которые остаются
/// отдельным ручным шагом.
/// </param>
public sealed record RequestTypeAccessDto(
int Id,
int RequestTypeId,
string Email,
IReadOnlyList<RequestListPartitionDto> SharePointLists);
public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email);
@@ -0,0 +1,107 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
public sealed class RequestTypeAccessService(
ApprovalRouteDbContext db,
ListPartitionService partitions,
ICurrentUser currentUser,
ILogger<RequestTypeAccessService> logger)
{
public async Task<Result<IReadOnlyList<RequestTypeAccessDto>>> ListAsync(
int? requestTypeId, CancellationToken cancellationToken)
{
var entities = await db.RequestTypeAccesses.AsNoTracking()
.Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId)
.OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email)
.ToListAsync(cancellationToken);
// Партиции запрашиваются РАЗ на тип заявки, а не на строку — иначе список
// из N email по одному типу заново обходил бы SharePoint N раз подряд.
var listsByType = new Dictionary<int, IReadOnlyList<RequestListPartitionDto>>();
var items = new List<RequestTypeAccessDto>(entities.Count);
foreach (var e in entities)
{
var lists = await GetListsCachedAsync(listsByType, e.RequestTypeId, cancellationToken);
items.Add(new RequestTypeAccessDto(e.Id, e.RequestTypeId, e.Email, lists));
}
return Result.Ok<IReadOnlyList<RequestTypeAccessDto>>(items);
}
private async Task<IReadOnlyList<RequestListPartitionDto>> GetListsCachedAsync(
Dictionary<int, IReadOnlyList<RequestListPartitionDto>> cache,
int requestTypeId,
CancellationToken cancellationToken)
{
if (cache.TryGetValue(requestTypeId, out var cached))
return cached;
var result = await partitions.ListAsync(requestTypeId, cancellationToken);
var lists = result.IsSuccess ? result.Value : Array.Empty<RequestListPartitionDto>();
cache[requestTypeId] = lists;
return lists;
}
/// <summary>Список email'ов с доступом к типу заявок — дёргает Power Automate.</summary>
public async Task<Result<IReadOnlyList<string>>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken)
{
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == requestTypeId, cancellationToken))
return Result.Fail<IReadOnlyList<string>>(new NotFoundError("Тип заявки", requestTypeId));
var emails = await db.RequestTypeAccesses.AsNoTracking()
.Where(x => x.RequestTypeId == requestTypeId)
.OrderBy(x => x.Email)
.Select(x => x.Email)
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<string>>(emails);
}
public async Task<Result<RequestTypeAccessDto>> CreateAsync(
CreateRequestTypeAccessRequest request, CancellationToken cancellationToken)
{
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken))
return Result.Fail<RequestTypeAccessDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
var email = request.Email.Trim();
var exists = await db.RequestTypeAccesses.AsNoTracking()
.AnyAsync(x => x.RequestTypeId == request.RequestTypeId && x.Email == email, cancellationToken);
if (exists)
return Result.Fail<RequestTypeAccessDto>(
new ConflictError($"У {email} уже есть доступ к этому типу заявок."));
var entity = new RequestTypeAccess { RequestTypeId = request.RequestTypeId, Email = email };
db.RequestTypeAccesses.Add(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}",
entity.RequestTypeId, entity.Email, currentUser.Email);
var listsResult = await partitions.ListAsync(entity.RequestTypeId, cancellationToken);
var lists = listsResult.IsSuccess ? listsResult.Value : Array.Empty<RequestListPartitionDto>();
return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email, lists));
}
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
{
var entity = await db.RequestTypeAccesses.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null)
return Result.Fail(new NotFoundError("Доступ к типу заявки", id));
db.RequestTypeAccesses.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Доступ к типу заявки {RequestTypeId} для {Email} отозван пользователем {Author}",
entity.RequestTypeId, entity.Email, currentUser.Email);
return Result.Ok();
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
public sealed class CreateRequestTypeAccessRequestValidator : AbstractValidator<CreateRequestTypeAccessRequest>
{
public CreateRequestTypeAccessRequestValidator()
{
RuleFor(x => x.RequestTypeId).GreaterThan(0).WithMessage("Тип заявки обязателен.");
RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("Укажите корректный email.");
}
}
@@ -2,6 +2,9 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
// AutoApprove — «согласовать шаг автоматически по истечении SLA». Обычный bool,
// а не три состояния: наследовать его неоткуда (см. TemplateRouteStep.AutoApprove).
//
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
@@ -24,7 +27,8 @@ public sealed record TemplateRouteStepDto(
ConditionsLogic ConditionsLogic,
string? Warning = null,
int? SlaHours = null,
bool? AskOverdueReason = null);
bool? AskOverdueReason = null,
bool AutoApprove = false);
public sealed record CreateTemplateRouteStepRequest(
int OrderNo,
@@ -37,7 +41,8 @@ public sealed record CreateTemplateRouteStepRequest(
IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic,
int? SlaHours = null,
bool? AskOverdueReason = null);
bool? AskOverdueReason = null,
bool AutoApprove = false);
public sealed record UpdateTemplateRouteStepRequest(
int OrderNo,
@@ -50,7 +55,8 @@ public sealed record UpdateTemplateRouteStepRequest(
IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic,
int? SlaHours = null,
bool? AskOverdueReason = null);
bool? AskOverdueReason = null,
bool AutoApprove = false);
/// <summary>
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
@@ -68,4 +74,5 @@ public sealed record PatchTemplateRouteStepRequest(
IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic? ConditionsLogic = null,
int? SlaHours = null,
bool? AskOverdueReason = null);
bool? AskOverdueReason = null,
bool? AutoApprove = null);
@@ -1,6 +1,7 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
@@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
var entity = new TemplateRouteStep
{
@@ -97,7 +98,8 @@ public sealed class TemplateRouteStepService(
ConditionsLogic = r.ConditionsLogic,
Conditions = ToEntities(r.Conditions),
SlaHours = r.SlaHours,
AskOverdueReason = r.AskOverdueReason
AskOverdueReason = r.AskOverdueReason,
AutoApprove = r.AutoApprove
};
db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken);
@@ -140,7 +142,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail;
@@ -153,6 +155,7 @@ public sealed class TemplateRouteStepService(
ReplaceConditions(entity, r.Conditions);
entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason;
entity.AutoApprove = r.AutoApprove;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
@@ -208,7 +211,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail;
@@ -224,6 +227,7 @@ public sealed class TemplateRouteStepService(
// наследованию SLA от типа заявки можно через PUT.
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
@@ -366,5 +370,5 @@ public sealed class TemplateRouteStepService(
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason);
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason, e.AutoApprove);
}
@@ -20,6 +20,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
/// </param>
/// <param name="AutoApprove">
/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть —
/// наследовать его неоткуда (у типа заявки такого поля нет, см.
/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто
/// переносит его в строку списка согласования: решает по нему не она, а фоновая
/// задача бэкенда (AutoApproveService).
/// </param>
public sealed record ResolvedRouteStepDto(
int Id,
int OrderNo,
@@ -29,7 +36,8 @@ public sealed record ResolvedRouteStepDto(
string? Warning = null,
int? SlaHours = null,
bool? AskOverdueReason = null,
HeadType? PositionCode = null);
HeadType? PositionCode = null,
bool AutoApprove = false);
public sealed record ResolveTemplateRouteResultDto(
int RouteId,
@@ -3,6 +3,7 @@ using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
@@ -385,7 +386,8 @@ public sealed class TemplateRouteService(
combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours,
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);
}
@@ -436,7 +438,7 @@ public sealed class TemplateRouteService(
{
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
}
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
@@ -468,7 +470,14 @@ public sealed class TemplateRouteService(
_ => null
};
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден руководитель типа «{step.HeadType}».";
{
var headLabel = StepLabels.InSentence(step.HeadType.Value);
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
// справочника сотрудников — там email не «не найден», а «не проставлен».
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
? $"Не проставлен {headLabel}."
: $"Не найден {headLabel}.";
}
}
else
{
@@ -611,7 +620,7 @@ public sealed class TemplateRouteService(
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
{
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 }
.Where(w => !string.IsNullOrWhiteSpace(w)));
result[index] = existing with { Warning = combinedWarning };
@@ -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>
public bool? AskOverdueReason { get; set; }
/// <summary>
/// Согласовывать шаг автоматически, когда истёк его SLA (см. <see cref="SlaHours"/>).
///
/// Не nullable, в отличие от SLA-полей рядом: наследовать тут нечего — у типа
/// заявки такого «значения по умолчанию» нет и быть не должно (включить
/// автосогласование сразу всем шагам типа — не настройка, а авария), поэтому
/// три состояния не нужны, хватает обычного «включено/выключено».
///
/// Действует только вместе с SLA: без действующего SLA (ни на шаге, ни на типе
/// заявки) отсчитывать нечего, и фоновая задача такой шаг пропускает
/// (см. AutoApproveService).
/// </summary>
public bool AutoApprove { get; set; }
/// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
{
public DbSet<RequestType> RequestTypes => Set<RequestType>();
public DbSet<RequestTypeAccess> RequestTypeAccesses => Set<RequestTypeAccess>();
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
@@ -35,6 +36,15 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
});
modelBuilder.Entity<RequestTypeAccess>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Email).HasMaxLength(256).IsRequired();
e.HasOne(x => x.RequestType).WithMany().HasForeignKey(x => x.RequestTypeId);
// Одна и та же пара тип+email не должна выдаваться дважды.
e.HasIndex(x => new { x.RequestTypeId, x.Email }).IsUnique();
});
modelBuilder.Entity<RequestListPartition>(e =>
{
e.HasKey(x => x.Id);
@@ -0,0 +1,370 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260911130101_AddStepAutoApprove")]
partial class AddStepAutoApprove
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ListId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<int>("Number")
.HasColumnType("integer")
.HasColumnName("number");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ListId")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.HasKey("Id")
.HasName("pk_request_types");
b.ToTable("request_types", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_step_conditions");
b.HasIndex("StepId", "OrderNo")
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
b.ToTable("template_route_step_conditions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{
b.Property<string>("OptionName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("option_name");
b.Property<byte[]>("FileData")
.HasColumnType("bytea")
.HasColumnName("file_data");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("OptionName")
.HasName("pk_settings");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
.WithMany("Conditions")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
b.Navigation("Step");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("ListPartitions");
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStepAutoApprove : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "auto_approve",
table: "template_route_steps",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "auto_approve",
table: "template_route_steps");
}
}
}
@@ -0,0 +1,411 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260925092352_AddRequestTypeAccess")]
partial class AddRequestTypeAccess
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ListId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<int>("Number")
.HasColumnType("integer")
.HasColumnName("number");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ListId")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.HasKey("Id")
.HasName("pk_request_types");
b.ToTable("request_types", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("email");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.HasKey("Id")
.HasName("pk_request_type_accesses");
b.HasIndex("RequestTypeId", "Email")
.IsUnique()
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
b.ToTable("request_type_accesses", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_step_conditions");
b.HasIndex("StepId", "OrderNo")
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
b.ToTable("template_route_step_conditions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{
b.Property<string>("OptionName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("option_name");
b.Property<byte[]>("FileData")
.HasColumnType("bytea")
.HasColumnName("file_data");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("OptionName")
.HasName("pk_settings");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany()
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
.WithMany("Conditions")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
b.Navigation("Step");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("ListPartitions");
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRequestTypeAccess : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "request_type_accesses",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
request_type_id = table.Column<int>(type: "integer", nullable: false),
email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_request_type_accesses", x => x.id);
table.ForeignKey(
name: "fk_request_type_accesses_request_types_request_type_id",
column: x => x.request_type_id,
principalTable: "request_types",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_request_type_accesses_request_type_id_email",
table: "request_type_accesses",
columns: new[] { "request_type_id", "email" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "request_type_accesses");
}
}
}
@@ -119,6 +119,35 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("request_types", (string)null);
});
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")
@@ -175,6 +204,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
@@ -305,6 +338,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
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")
@@ -53,6 +53,35 @@ public interface ISharePointClient
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
/// <summary>
/// Может ли пользователь (по email) открыть элемент списка: эффективные права
/// человека на элемент с учётом разорванного наследования, флаг ViewListItems.
/// Неизвестный SharePoint логин — не ошибка, а «прав нет».
/// </summary>
Task<bool> CanUserViewItemAsync(
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken);
/// <summary>
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
/// элементы, а не только доступные пользователю. Возвращает строки результата
/// как «имя managed property → значение» (RowLimit — не больше 500).
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
/// </summary>
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken);
/// <summary>
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
/// перезаписываются, остальные остаются как были.
/// </summary>
Task UpdateItemByListTitleAsync(
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken);
/// <summary>То же по серверному относительному url списка (см. RequestType.ListId).</summary>
Task UpdateItemByListUrlAsync(
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken);
}
/// <summary>
@@ -208,6 +237,105 @@ public sealed class SharePointClient(
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
cancellationToken);
public async Task<bool> CanUserViewItemAsync(
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken)
{
// Claims-логин Entra ID в SharePoint Online. Передаём через параметр-псевдоним
// @u: в пути функции «|» и «#» SharePoint не разбирает.
var login = Uri.EscapeDataString($"i:0#.f|membership|{userEmail.Trim().ToLowerInvariant()}");
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})" +
$"/getUserEffectivePermissions(@u)?@u='{login}'";
var json = await GetAsync(siteUrl, url, cancellationToken);
// BasePermissions приходит как {High, Low} — числа строками. ViewListItems — бит 0x1 в Low.
return json.TryGetProperty("Low", out var low)
&& ulong.TryParse(low.ValueKind == JsonValueKind.String ? low.GetString() : low.GetRawText(), out var bits)
&& (bits & ViewListItemsMask) != 0;
}
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
{
var body = JsonSerializer.Serialize(new
{
request = new
{
Querytext = queryText,
RowLimit = 500,
SelectProperties = selectProperties,
TrimDuplicates = false
}
});
var json = await SendAsync(
HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body,
allowNotFound: false, cancellationToken);
var result = new List<IReadOnlyDictionary<string, string?>>();
if (json is null
|| !json.Value.TryGetProperty("PrimaryQueryResult", out var primary)
|| !primary.TryGetProperty("RelevantResults", out var relevant)
|| !relevant.TryGetProperty("Table", out var table)
|| !table.TryGetProperty("Rows", out var rows))
return result;
// nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба.
if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped))
rows = wrapped;
if (rows.ValueKind != JsonValueKind.Array) return result;
foreach (var row in rows.EnumerateArray())
{
if (!row.TryGetProperty("Cells", out var cells)) continue;
if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells))
cells = wrappedCells;
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var cell in cells.EnumerateArray())
{
var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null;
if (key is null) continue;
dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}
result.Add(dict);
}
return result;
}
public Task UpdateItemByListTitleAsync(
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
MergeAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items({itemId})",
fields,
cancellationToken);
public Task UpdateItemByListUrlAsync(
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken) =>
MergeAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})",
fields,
cancellationToken);
/// <summary>
/// Частичное обновление элемента: у SharePoint REST это POST с заголовком
/// X-HTTP-Method: MERGE (PATCH он не понимает), и обязательным If-Match.
///
/// If-Match: * — намеренно «перезаписать, чей бы ни был последний етаг», а не
/// оптимистичная блокировка по конкретной версии. Пишем мы точечно, по
/// отдельным колонкам, а единственный конкурент — человек, принявший решение
/// по тому же шагу вручную; его защищает не етаг, а проверка статуса строки
/// перед записью (см. AutoApproveService). Реальный етаг пришлось бы читать
/// отдельным запросом, и он всё равно устаревал бы к моменту записи.
/// </summary>
private Task MergeAsync(string siteUrl, string url, object fields, CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post, siteUrl, url, JsonSerializer.Serialize(fields),
allowNotFound: false, cancellationToken, merge: true);
private async Task<IReadOnlyList<JsonElement>> GetItemsAsync(
string siteUrl, string url, CancellationToken cancellationToken)
{
@@ -240,9 +368,16 @@ public sealed class SharePointClient(
string url,
string? body,
bool allowNotFound,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool merge = false)
{
using var request = new HttpRequestMessage(method, url);
if (merge)
{
request.Headers.Add("X-HTTP-Method", "MERGE");
request.Headers.Add("If-Match", "*");
}
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
@@ -315,6 +450,8 @@ public sealed class SharePointClient(
}
}
private const ulong ViewListItemsMask = 0x1;
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
// списков содержат тире и кириллицу, но кавычка теоретически возможна —
// без экранирования она сломала бы запрос.
@@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
public Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken));
public Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken));
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
@@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient(
return employees
.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();
}
@@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient(
[property: JsonPropertyName("fio")] string? Fio,
[property: JsonPropertyName("id")] Guid? Id = null);
private sealed record DictionaryItemResponse(
[property: JsonPropertyName("id")] Guid? Id,
[property: JsonPropertyName("name")] string? Name);
private sealed record ManagerResponse(
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
@@ -20,6 +20,12 @@ public interface ITnsuPortalClient
/// </summary>
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
/// <summary>Справочник компаний — /Dictionary/Companies.</summary>
Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken);
/// <summary>Справочник отделов — /Dictionary/Departments.</summary>
Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken);
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
/// <summary>
@@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company,
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
/// </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);
@@ -0,0 +1,353 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Автосогласование по SLA. SharePoint подменён моком, отдающим сырой JSON в том
/// же виде, что и REST, — как в ReminderServiceTests; записи (MERGE) проверяются
/// через Verify на моке, потому что именно записи здесь и есть результат работы
/// сервиса, а возвращает он только счётчик.
///
/// Все проверки идут через <see cref="Rows"/> — маленький «список в памяти»: мок
/// отдаёт из него и выборку кандидатов, и строки заявки, и применяет к нему
/// записи. Без этого тесты, где решение по одной строке двигает очередь, пришлось
/// бы писать на жёстко заданных последовательностях ответов, и они проверяли бы
/// порядок вызовов, а не поведение.
/// </summary>
public sealed class AutoApproveServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string DataListTitle = "Заявка авто — данные";
private const string ApprovalListTitle = "Заявка авто — согласование";
private static readonly DateTimeOffset Now = new(2026, 9, 11, 12, 0, 0, TimeSpan.Zero);
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService;
public AutoApproveServiceTests(PostgresDatabaseFixture fixture)
{
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
}
/// <summary>
/// База у тестов класса общая, а сервис обходит ВСЕ типы заявок — поэтому у
/// каждого теста свой список: типы соседних тестов на мок не настроены,
/// заголовок списка для них не найдётся, и в выборку они не попадут.
/// </summary>
private async Task<string> CreateTypeAsync(string name, int? slaHours = null)
{
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(name, $"/{name}", listUrl, SiteUrl, "General", slaHours),
CancellationToken.None);
return listUrl;
}
/// <summary>Строка списка согласования в том виде, в каком её отдаёт REST.</summary>
private sealed record Row(
int Id,
int TicketId,
string Status,
double Order,
string Phase = "Согласование",
string? Date = null,
int? SlaHours = null,
bool AutoApprove = false)
{
public JsonElement ToJson() => JsonSerializer.SerializeToElement(new
{
Id,
TicketId,
ApprovalStatus = Status,
Phase,
Date,
SLAhours = SlaHours,
AutoApprove,
StepOrder = Order,
TansuSiteApprover = new { EMail = "approver@tnsu.kz" }
});
}
/// <summary>Записанные сервисом поля: (id элемента, имя колонки) → значение.</summary>
private sealed class Writes
{
public List<(string List, int Id, Dictionary<string, object?> Fields)> Items { get; } = [];
public Dictionary<string, object?>? For(string list, int id) =>
Items.LastOrDefault(x => x.List == list && x.Id == id).Fields;
}
private (AutoApproveService Service, Writes Writes) BuildService(
string listUrl, List<Row> rows, DateTimeOffset? now = null)
{
var sharePoint = new Mock<ISharePointClient>();
var writes = new Writes();
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
// Кандидаты: активные строки с включённым автосогласованием — тот же отбор,
// что делает $filter на стороне SharePoint.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("AutoApprove eq 1")),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => rows
.Where(r => r.AutoApprove && r.Status is "На согласовании" or "На исполнении")
.Select(r => r.ToJson()).ToList());
// Строки одной заявки — $filter=TicketId eq N.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")),
It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string _, string query, CancellationToken _) =>
{
var ticketId = int.Parse(query.Split("TicketId eq ")[1].Split('&')[0].Trim());
return rows.Where(r => r.TicketId == ticketId).Select(r => r.ToJson()).ToList();
});
// Запись в строку маршрута применяется к самому списку rows — чтобы
// следующее чтение видело уже изменённое состояние, как в SharePoint.
sharePoint
.Setup(x => x.UpdateItemByListTitleAsync(
SiteUrl, ApprovalListTitle, It.IsAny<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Callback((string _, string list, int id, object fields, CancellationToken _) =>
{
var map = ToMap(fields);
writes.Items.Add((list, id, map));
var index = rows.FindIndex(r => r.Id == id);
if (index >= 0 && map.TryGetValue("ApprovalStatus", out var status))
rows[index] = rows[index] with { Status = (string)status! };
})
.Returns(Task.CompletedTask);
sharePoint
.Setup(x => x.UpdateItemByListUrlAsync(
SiteUrl, listUrl, It.IsAny<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Callback((string _, string list, int id, object fields, CancellationToken _) =>
writes.Items.Add((list, id, ToMap(fields))))
.Returns(Task.CompletedTask);
var service = new AutoApproveService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
new FixedTimeProvider(now ?? Now),
Options.Create(new AutoApproveOptions()),
NullLogger<AutoApproveService>.Instance);
return (service, writes);
}
// Анонимный объект, который сервис отдаёт клиенту SharePoint, — в словарь:
// сравнивать поля по именам колонок нагляднее, чем городить ожидаемый
// анонимный тип той же формы.
private static Dictionary<string, object?> ToMap(object fields) =>
fields.GetType().GetProperties().ToDictionary(p => p.Name, p => p.GetValue(fields));
[Fact(DisplayName = "SLA истёк — шаг согласуется, очередь двигается, статус заявки пересчитан")]
public async Task Overdue_ApprovesAndAdvancesQueue()
{
var listUrl = await CreateTypeAsync("Auto basic", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(11, 1, "Ждёт очереди", 2)
};
var (service, writes) = BuildService(listUrl, rows);
var approved = await service.RunAsync(CancellationToken.None);
approved.ShouldBe(1);
var decided = writes.For(ApprovalListTitle, 10).ShouldNotBeNull();
decided["ApprovalStatus"].ShouldBe("Согласовано");
decided["Date"].ShouldBe(Now.UtcDateTime);
decided["Comment"].ShouldBe(new AutoApproveOptions().Comment);
// Следующий шаг активирован — со своей датой активации, иначе от него не
// отсчитать ни напоминание, ни собственное автосогласование.
var activated = writes.For(ApprovalListTitle, 11).ShouldNotBeNull();
activated["ApprovalStatus"].ShouldBe("На согласовании");
activated["Date"].ShouldBe(Now.UtcDateTime);
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании");
}
[Fact(DisplayName = "Срок ещё не вышел — строка не трогается")]
public async Task WithinSla_DoesNothing()
{
var listUrl = await CreateTypeAsync("Auto not due", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Флаг выключен — просроченный шаг остаётся ждать человека")]
public async Task WithoutFlag_DoesNothing()
{
var listUrl = await CreateTypeAsync("Auto flag off", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"))
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "SLA шага перекрывает SLA типа заявки")]
public async Task RowSla_OverridesTypeSla()
{
var listUrl = await CreateTypeAsync("Auto row sla", slaHours: 100);
var rows = new List<Row>
{
// По типу (100 ч) срок ещё не вышел, по шагу (1 ч) — вышел.
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-5).ToString("O"), SlaHours: 1, AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 10).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Согласовано");
}
[Fact(DisplayName = "SLA не задан нигде — шаг пропущен, а не согласован немедленно")]
public async Task WithoutSla_Skipped()
{
var listUrl = await CreateTypeAsync("Auto no sla");
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Дата активации не заполнена — отсчитывать не от чего, шаг пропущен")]
public async Task WithoutDate_Skipped()
{
var listUrl = await CreateTypeAsync("Auto no date", slaHours: 24);
var rows = new List<Row> { new(10, 1, "На согласовании", 1, AutoApprove: true) };
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Последний шаг согласования без исполнителей — заявка «Согласовано»")]
public async Task LastApprovalStep_ClosesRequestAsApproved()
{
var listUrl = await CreateTypeAsync("Auto last step", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Согласовано", 1),
new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Согласовано");
}
[Fact(DisplayName = "Согласование пройдено — активируется фаза исполнения, заявка «Выполнение»")]
public async Task LastApprovalStep_ActivatesExecutionPhase()
{
var listUrl = await CreateTypeAsync("Auto to execution", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(11, 1, "Ждёт очереди", 2, Phase: "Исполнение")
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("На исполнении");
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Выполнение");
}
[Fact(DisplayName = "Шаг исполнения закрывается как «Выполнено», заявка — «Закрыта»")]
public async Task ExecutionStep_MarkedExecuted()
{
var listUrl = await CreateTypeAsync("Auto execution", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Согласовано", 1),
new(11, 1, "На исполнении", 2, Phase: "Исполнение",
Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Выполнено");
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Закрыта");
}
[Fact(DisplayName = "Параллельный шаг: решён только один — очередь не двигается")]
public async Task ParallelStep_DoesNotAdvanceWhileSiblingPending()
{
var listUrl = await CreateTypeAsync("Auto parallel", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
// Второй согласующий того же шага (общий StepOrder) решения ещё не принял.
new(11, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O")),
new(12, 1, "Ждёт очереди", 2)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 12).ShouldBeNull();
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании");
}
[Fact(DisplayName = "Шаг отклонён человеком — очередь не двигается, статус заявки «Отклонено»")]
public async Task RejectedSibling_StopsQueue()
{
var listUrl = await CreateTypeAsync("Auto rejected", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Отклонено", 1),
new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(12, 1, "Ждёт очереди", 3)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 12).ShouldBeNull();
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Отклонено");
}
}
@@ -0,0 +1,91 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.BasisRequests;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Заявки по одному основанию: видны все (чтение от имени приложения), а
/// CanOpen — только там, где у вызывающего есть права на элемент.
/// </summary>
public sealed class BasisRequestsServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string CallerEmail = "initiator@tnsu.kz";
private const string BasisKey = "need:8028c7ac-ac23-8dd8-11f0-946facc42464";
private readonly ServiceProvider _services;
public BasisRequestsServiceTests(PostgresDatabaseFixture fixture)
{
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, CallerEmail);
}
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
private async Task<(BasisRequestsService Service, Mock<ISharePointClient> SharePoint, int TypeId)> BuildAsync(
string name, string listUrl, IReadOnlyList<object> items, IReadOnlySet<int> openableIds)
{
var type = await _services.GetRequiredService<RequestTypeService>().CreateAsync(
new CreateRequestTypeRequest($"Оплата {name}", $"/оплата-{name}", listUrl, SiteUrl, "General"),
CancellationToken.None);
var sharePoint = new Mock<ISharePointClient>();
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(items.Select(Json).ToList());
sharePoint
.Setup(x => x.CanUserViewItemAsync(SiteUrl, listUrl, It.IsAny<int>(), CallerEmail, It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string _, int id, string _, CancellationToken _) => openableIds.Contains(id));
var service = new BasisRequestsService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
new TestCurrentUser(CallerEmail));
return (service, sharePoint, type.Value.Id);
}
[Fact]
public async Task Returns_all_requests_and_marks_only_permitted_as_openable()
{
const string list = "/sites/requests/Lists/Оплата-all";
var (service, sharePoint, typeId) = await BuildAsync("all", list,
[
// Number-колонка приходит дробной («50000.0») — тест держит её такой же.
new { Id = 1, RequestUniqueCode = "24-2610-1", RequestStatus = "Согласовано", PayAmount = 50000.0 },
new { Id = 2, RequestUniqueCode = "24-2610-2", RequestStatus = "На согласовании", PayAmount = 35000.5 }
], new HashSet<int> { 2 });
var result = await service.GetByBasisAsync(typeId, BasisKey, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(2);
result.Value.Single(x => x.Id == 1).ShouldBe(new BasisRequestDto(1, list, "24-2610-1", "Согласовано", 50000, false));
result.Value.Single(x => x.Id == 2).ShouldBe(new BasisRequestDto(2, list, "24-2610-2", "На согласовании", 35000.5, true));
// Фильтр по основанию уходит в SharePoint, а не применяется в памяти.
sharePoint.Verify(x => x.GetItemsByListUrlAsync(
SiteUrl, list,
It.Is<string>(q => q.Contains("BasisKey eq 'need%3A8028c7ac-ac23-8dd8-11f0-946facc42464'")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Empty_basis_key_is_a_validation_error()
{
var (service, _, typeId) = await BuildAsync("empty", "/sites/requests/Lists/Оплата-empty", [], new HashSet<int>());
var result = await service.GetByBasisAsync(typeId, " ", CancellationToken.None);
result.IsFailed.ShouldBeTrue();
}
}
@@ -361,8 +361,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull();
warning.ShouldContain("Chief");
warning.ShouldContain("DirectionHead");
warning.ShouldContain("Непосредственный руководитель");
warning.ShouldContain("Руководитель направления");
warning.ShouldNotContain("DirectionHead");
}
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
@@ -1050,7 +1051,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull();
warning.ShouldContain("руководитель");
warning.ShouldBe("Не найден непосредственный руководитель.");
}
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
@@ -1108,7 +1109,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull();
warning.ShouldContain("роль");
warning.ShouldBe("В проекте не найден сотрудник службы безопасности.");
}
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
@@ -75,8 +75,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
OpenVacanciesCount = vacancies,
SubmitDate = submitDate,
Created = created,
OpenReason = openReason,
OpenReasonDetails = openReasonDetails,
VacancyOpenReason = openReason,
VacancyOpenReasonDetails = openReasonDetails,
Responsible = new { Title = responsible }
};
@@ -14,11 +14,13 @@ import { texts } from '../../texts';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { DashboardScreen } from '../screens/DashboardScreen';
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen';
import { RoutesScreen } from '../screens/RoutesScreen';
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
import { ResolveScreen } from '../screens/ResolveScreen';
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
import { RoutesExportScreen } from '../screens/RoutesExportScreen';
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
@@ -29,8 +31,10 @@ const NAV_ITEMS: ISidebarItem[] = [
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
{ 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: '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 {
@@ -147,6 +151,14 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
<div className={styles.content}>
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
{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 && (
<RoutesScreen
api={api}
@@ -174,6 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
route={route.resolve}
/>
)}
{route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
{route.tab === 'manageRequests' && (
<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 { RequestManageService } from '../../services/requestManageService';
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 { shortGuid } from '../../utils/guid';
import { describeStepCondition } from '../../utils/stepExecutor';
@@ -15,7 +15,6 @@ import { mapWithLimit } from '../../utils/parallel';
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -95,7 +94,6 @@ interface IReplaceScope {
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
const requestTypes = useRequestTypes(api);
const listSeries = useRequestTypeListsAll(api);
const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary);
@@ -125,27 +123,21 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
});
}, [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 [liveLoading, setLiveLoading] = React.useState(false);
const [liveError, setLiveError] = React.useState<string | undefined>();
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
const reloadLiveRows = React.useCallback(() => {
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return;
if (!employeeEmail || requestTypes.isLoading) return;
setLiveLoading(true);
setLiveError(undefined);
requestManage
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES)
api.requestManage
.byApprover(employeeEmail, ACTIVE_STATUSES)
.then(setLiveRows)
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover')))
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover')))
.finally(() => setLiveLoading(false));
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]);
}, [api, requestTypes.isLoading, employeeEmail]);
React.useEffect(() => {
setLiveRows([]);
@@ -273,7 +265,11 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
const replaced = await mapWithLimit(
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 }
);
replaced.forEach(result => {
@@ -74,6 +74,11 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
const excludedSet = new Set(excludedEmails.map(e => e.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 trimmedEmail = email.trim();
if (!trimmedEmail) {
@@ -95,7 +100,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
};
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>
<Field label={texts.manageRequests.approverLabel} required full>
<SearchableSelect
@@ -214,13 +214,23 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
const isBlockStart = block.start === i;
const movable = blockIndex > lastLockedBlockIndex;
const rowEditable = isEditableRowStatus(r.status);
// Уведомлять можно только активный шаг (флоу шлёт письмо тому,
// чей RowID мы передали, независимо от статуса — сам он не
// Уведомлять можно активный шаг (флоу шлёт письмо тому, чей
// RowID мы передали, независимо от статуса — сам он не
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
// слать по нему нечего: согласующий/исполнитель пока не
// должен действовать (см. 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
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
// а не на rowEditable: у «Возвращена на доработку» кнопка
@@ -235,12 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback(
(query: string, page: number) => {
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
if (!requestType || !requestType.siteUrl) {
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 — фоновое обновление (после своего же действия, автообновление
@@ -270,8 +270,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
}
if (!silent) setMatrixLoading(true);
setMatrixError(undefined);
requestManage
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
api.requestManage
.matrix(requestType.id, listId, selectedRequest.id)
.then(m => {
setMatrix(m);
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
@@ -296,12 +296,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: matrix')))
.finally(() => {
if (!silent) setMatrixLoading(false);
});
},
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
[api, requestType, selectedRequest, partitions.items, partitions.isLoading]
);
React.useEffect(() => {
@@ -433,7 +433,10 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// SharePoint, и параллельные update по одному элементу списка могут
// затереть друг друга (последний выигрывает).
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(() => {
const found = findEmployeeByEmail(employees.items, email);
@@ -507,7 +510,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
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))
.catch((e: Error) => {
setMatrix(previous);
@@ -0,0 +1,33 @@
.filters {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 18px;
}
.filterField {
min-width: 220px;
max-width: 320px;
flex: 1;
}
.chosen {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.chosenItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 8px 4px 12px;
border: 1px solid #e1e4ea;
border-radius: 6px;
}
.chosenEmpty {
opacity: 0.6;
}
@@ -0,0 +1,306 @@
import * as React from 'react';
import styles from './RequestTypeAccessScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeAccess } from '../../hooks/useRequestTypeAccess';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useFormDraft } from '../../hooks/useFormDraft';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { toEmployeeOption } from '../../utils/employee';
import { replaceRoute } from '../../routing';
import { RequestTypeAccess } from '../../models';
import {
ActionsCell,
Alert,
Breadcrumbs,
Button,
ConfirmDialog,
DataTable,
Field,
FormGrid,
FormModal,
IconButton,
PageHeader,
PrimaryCell,
SearchableSelect,
Select
} from '../ui';
export interface IRequestTypeAccessScreenProps {
api: ApprovalRouteApi;
dictionary?: PortalDictionaryClient;
/** Фильтры из ссылки (aaacctype/aaaccemail) — перекрывают сохранённые, если заданы. */
initialFilterRequestTypeId?: string;
initialFilterEmail?: string;
}
// Фильтры переживают перезагрузку — тот же приём, что и ROUTES_FILTER_STORAGE_KEY в RoutesScreen.
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess';
interface IFormState {
requestTypeId: string;
emails: string[];
}
const EMPTY_FORM: IFormState = { requestTypeId: '', emails: [] };
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
api,
dictionary,
initialFilterRequestTypeId,
initialFilterEmail
}) => {
const requestTypes = useRequestTypes(api);
const access = useRequestTypeAccess(api);
const employees = usePortalEmployees(dictionary);
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
const { requestTypeId, emails } = draft.value;
const setEmails = (update: (current: string[]) => string[]): void =>
draft.setValue(f => ({ ...f, emails: update(f.emails) }));
const submit = useAsyncSubmit();
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
const [filterEmail, setFilterEmail] = useLocalStorageState(ACCESS_FILTER_EMAIL_STORAGE_KEY, '');
// Ссылка с фильтром сильнее сохранённого значения — только на монтировании
// (см. тот же приём в RoutesScreen).
React.useEffect(() => {
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (initialFilterEmail) setFilterEmail(initialFilterEmail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Держим адрес синхронным с фильтрами — отфильтрованную таблицу можно
// передать ссылкой. replaceRoute, а не navigate: смена фильтра не должна
// плодить записи в истории браузера.
React.useEffect(() => {
replaceRoute({ tab: 'requestTypeAccess', accessFilterRequestTypeId: filterRequestTypeId, accessFilterEmail: filterEmail });
}, [filterRequestTypeId, filterEmail]);
const deleteTarget = useDeleteTarget<RequestTypeAccess>(item => api.requestTypeAccess.remove(item.id), access.reload);
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
const filteredItems = access.items.filter(item => {
if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false;
if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false;
return true;
});
// Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
// здесь настраивает только видимость в UI approval-route, но не в самом
// SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
const formatSharePointLists = (item: RequestTypeAccess): string =>
item.sharePointLists.length === 0
? texts.common.empty
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
const openCreate = (): void => {
createOpen.open();
};
const submitForm = (): void => {
if (!requestTypeId) {
submit.fail(texts.requestTypeAccess.validationRequestType);
return;
}
if (emails.length === 0) {
submit.fail(texts.requestTypeAccess.validationEmail);
return;
}
// По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на
// одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы
// можно было поправить и нажать ещё раз.
submit.run(async () => {
const failures: string[] = [];
const failedEmails: string[] = [];
for (const email of emails) {
try {
await api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email });
} catch (e) {
failedEmails.push(email);
failures.push(`${email}: ${(e as Error).message}`);
}
}
if (failedEmails.length < emails.length) access.reload();
if (failures.length === 0) {
draft.reset();
createOpen.close();
return;
}
setEmails(() => failedEmails);
throw new Error(texts.requestTypeAccess.createFailed(failures));
});
};
// Тех, у кого доступ к этому типу уже есть (и кого уже добавили в список), показываем
// в поиске с пометкой, но выбрать нельзя — так видно, что человек не потерялся.
const grantedEmails = new Set(
access.items.filter(a => String(a.requestTypeId) === requestTypeId).map(a => a.email.toLowerCase())
);
const chosenEmails = new Set(emails.map(e => e.toLowerCase()));
const employeeOptions = employees.items.map(employee => {
const option = toEmployeeOption(employee);
const key = employee.email.toLowerCase();
if (grantedEmails.has(key)) {
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyGranted}` };
}
if (chosenEmails.has(key)) {
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyChosen}` };
}
return option;
});
const employeeLabel = (email: string): string => {
const employee = employees.items.find(e => e.email.toLowerCase() === email.toLowerCase());
return employee ? `${employee.fio} · ${employee.email}` : email;
};
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypeAccess.breadcrumb }]} />
<PageHeader
icon="people"
subtitle={texts.requestTypeAccess.subtitle}
title={texts.requestTypeAccess.title}
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypeAccess.add}</Button>}
/>
<div className={styles.filters}>
<div className={styles.filterField}>
<Field label={texts.requestTypeAccess.filterRequestTypeLabel}>
<Select
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.requestTypeAccess.filterRequestTypeAll}
value={filterRequestTypeId}
onChange={e => setFilterRequestTypeId(e.target.value)}
/>
</Field>
</div>
<div className={styles.filterField}>
<Field label={texts.requestTypeAccess.filterEmailLabel}>
<SearchableSelect
options={employees.items.map(toEmployeeOption)}
value={filterEmail}
loading={employees.isLoading}
allowClear
clearLabel={texts.requestTypeAccess.filterEmailAll}
placeholder={texts.requestTypeAccess.filterEmailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={setFilterEmail}
/>
</Field>
</div>
</div>
{access.error && <Alert kind="err">{access.error}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> },
{ key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email },
{ key: 'sharePointLists', header: texts.requestTypeAccess.columnSharePointLists, render: formatSharePointLists },
{
key: 'actions',
header: '',
width: '100px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={filteredItems}
keyOf={r => r.id}
emptyMessage={
access.isLoading
? texts.common.loading
: access.items.length > 0 && filteredItems.length === 0
? texts.requestTypeAccess.emptyFiltered
: texts.requestTypeAccess.emptyList
}
/>
{createOpen.isOpen && (
<FormModal
title={texts.requestTypeAccess.createTitle}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={createOpen.close}
onReset={draft.reset}
canReset={draft.isDirty}
>
<FormGrid>
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
<Select
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
value={requestTypeId}
onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))}
/>
</Field>
<Field label={texts.requestTypeAccess.emailLabel} required full>
<div>
<SearchableSelect
options={employeeOptions}
value=""
loading={employees.isLoading}
placeholder={texts.requestTypeAccess.emailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={value => value && setEmails(current => [...current, value])}
/>
<div className={styles.chosen}>
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
{emails.map(email => (
<div key={email} className={styles.chosenItem}>
<span>{employeeLabel(email)}</span>
<IconButton
icon="close"
label={texts.requestTypeAccess.removeEmail}
onClick={() => setEmails(current => current.filter(x => x !== email))}
/>
</div>
))}
</div>
</div>
</Field>
</FormGrid>
</FormModal>
)}
{deleteTarget.target && (
<ConfirmDialog
title={texts.requestTypeAccess.deleteTitle}
text={texts.requestTypeAccess.deleteText(deleteTarget.target.email, requestTypeName(deleteTarget.target.requestTypeId))}
confirmLabel={texts.common.remove}
busy={deleteTarget.isBusy}
onCancel={deleteTarget.cancel}
onConfirm={deleteTarget.confirm}
/>
)}
</div>
);
};
@@ -19,6 +19,8 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useFormDraft } from '../../hooks/useFormDraft';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import {
ActionsCell,
Alert,
@@ -57,6 +59,22 @@ const EMPTY_FORM: IFormState = {
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-контракт бэкенда
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
@@ -78,8 +96,18 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const list = useRequestTypes(api);
const [editing, setEditing] = React.useState<RequestType | undefined>();
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
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 sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
@@ -92,21 +120,14 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const openCreate = (): void => {
setEditing(undefined);
setForm(EMPTY_FORM);
setShowForm(true);
setShowForm(false);
createOpen.open();
};
const openEdit = (item: RequestType): void => {
setEditing(item);
setForm({
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listId: item.listId ?? '',
slaHours: slaHoursToInput(item.slaHours),
askOverdueReason: boolToTriState(item.askOverdueReason)
});
setEditForm(toFormState(item));
createOpen.close();
setShowForm(true);
};
@@ -143,7 +164,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
submit.run(() =>
request.then(() => {
setShowForm(false);
if (!editing) draft.reset();
closeForm();
list.reload();
})
);
@@ -192,13 +214,16 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
/>
{showForm && (
{(showForm || createOpen.isOpen) && (
<FormModal
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={() => setShowForm(false)}
onClose={closeForm}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
isDirty={isEditDirty}
>
<FormGrid>
<Field label={texts.requestTypes.nameLabel} required full>
@@ -47,7 +47,8 @@ function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
conditions: step.conditions,
conditionsLogic: step.conditionsLogic,
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 { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useFormDraft } from '../../hooks/useFormDraft';
import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
@@ -27,6 +28,9 @@ interface IFormState {
requiresControl: boolean;
}
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route';
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) {
return {
@@ -58,7 +62,10 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onSaved,
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();
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
@@ -93,6 +100,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.run(() =>
request.then(() => {
draft.reset();
onSaved();
onClose();
})
@@ -106,6 +114,9 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={onClose}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
isDirty={Boolean(editing) && draft.isDirty}
>
<FormGrid>
<Field label={texts.routes.requestTypeLabel} required full>
@@ -24,6 +24,7 @@ import {
import { detectExecutorKind } from '../../utils/stepExecutor';
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useFormDraft } from '../../hooks/useFormDraft';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
@@ -77,6 +78,7 @@ interface IFormState {
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
slaHours: string;
askOverdueReason: TriState;
autoApprove: boolean;
}
const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' };
@@ -118,6 +120,9 @@ const IS_MANAGER_VALUE_OPTIONS = [
{ value: 'false', label: texts.common.no }
];
/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */
const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`;
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) {
return {
@@ -133,7 +138,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
conditions: [EMPTY_CONDITION],
conditionsLogic: ConditionsLogic.And,
slaHours: '',
askOverdueReason: ''
askOverdueReason: '',
autoApprove: false
};
}
return {
@@ -149,7 +155,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
conditionsLogic: editing.conditionsLogic,
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,
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 hasListId = Boolean(requestType?.listId);
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);
return;
}
// Автосогласование отсчитывается от SLA — без него фоновая задача шаг просто
// пропустит (см. AutoApproveService.DeadlineOf), и настройка тихо не сработала
// бы. Пустое поле здесь не значит «нет SLA»: оно значит «взять у типа заявки»,
// поэтому ошибка только когда SLA нет и там.
if (form.autoApprove && parseSlaHours(form.slaHours) === null && requestType?.slaHours === null) {
submit.fail(texts.routeSteps.validationAutoApproveWithoutSla);
return;
}
const payload = {
orderNo,
@@ -379,7 +397,8 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
: null,
conditionsLogic: form.conditionsLogic,
slaHours: parseSlaHours(form.slaHours),
askOverdueReason: triStateToBool(form.askOverdueReason)
askOverdueReason: triStateToBool(form.askOverdueReason),
autoApprove: form.autoApprove
};
const request = editing
@@ -388,6 +407,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.run(() =>
request.then(step => {
draft.reset();
onSaved(step);
onClose();
})
@@ -401,6 +421,9 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={onClose}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
isDirty={Boolean(editing) && draft.isDirty}
>
<FormGrid>
<Field label={texts.routeSteps.orderNoLabel} required>
@@ -505,6 +528,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/>
</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}>
<Checkbox
label={texts.routeSteps.conditionCheckbox}
@@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u
import { reportError } from '../../utils/errors';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import {
@@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen),
// ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal).
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>(
item => api.templateRouteSteps.remove(route.id, item.id),
@@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
const openCreate = (): void => {
setEditing(undefined);
setShowForm(true);
setShowForm(false);
createOpen.open();
};
const openEdit = (item: TemplateRouteStep): void => {
setEditing(item);
createOpen.close();
setShowForm(true);
};
@@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
</div>
)}
{showForm && (
{(showForm || createOpen.isOpen) && (
<RouteStepFormModal
// Undefined, если у маршрута компания не задана (запасной маршрут,
// применяется к любой компании инициатора) — тогда подсказка
@@ -293,7 +303,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
employees={employees.items}
employeesLoading={employees.isLoading}
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 { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { replaceRoute } from '../../routing';
import {
@@ -39,6 +40,8 @@ export interface IRoutesScreenProps {
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
api,
@@ -69,7 +72,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
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 deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
@@ -83,11 +92,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
const openCreate = (): void => {
setEditing(undefined);
setShowForm(true);
setShowForm(false);
createOpen.open();
};
const openEdit = (item: TemplateRoute): void => {
setEditing(item);
createOpen.close();
setShowForm(true);
};
@@ -159,7 +170,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
/>
{showForm && (
{(showForm || createOpen.isOpen) && (
<RouteFormModal
api={api}
editing={editing}
@@ -169,7 +180,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
departments={portalDictionaries.departments}
dictionaryLoading={portalDictionaries.isLoading}
onSaved={routes.reload}
onClose={() => setShowForm(false)}
onClose={closeForm}
/>
)}
@@ -16,12 +16,13 @@
gap: 16px;
box-shadow: $sh-1;
cursor: pointer;
@include card-hover-gold;
border: 1px solid $gray-200;
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
// Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls.
@include card-hover-gold;
&::before {
content: '';
@@ -1,12 +1,15 @@
import * as React from 'react';
import styles from './Modal.module.scss';
import { Button } from './Button';
import { ModalOverlay } from './ModalOverlay';
import { texts } from '../../texts';
export interface IConfirmDialogProps {
title: string;
text: string;
confirmLabel: string;
/** Подпись кнопки отказа — по умолчанию «Отмена». */
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
busy?: boolean;
@@ -16,18 +19,19 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title,
text,
confirmLabel,
cancelLabel = texts.common.cancel,
onConfirm,
onCancel,
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.title}>{title}</div>
<p className={styles.text}>{text}</p>
<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>
</div>
</div>
</div>
</ModalOverlay>
);
@@ -1,7 +1,9 @@
import * as React from 'react';
import { Alert } from './Alert';
import { Button } from './Button';
import { ConfirmDialog } from './ConfirmDialog';
import { Modal } from './Modal';
import styles from './Modal.module.scss';
import { texts } from '../../texts';
export interface IFormModalProps {
@@ -15,6 +17,18 @@ export interface IFormModalProps {
savingLabel?: string;
onSubmit: () => void;
onClose: () => void;
/**
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
*/
onReset?: () => void;
canReset?: boolean;
/**
* В форме есть несохранённые правки существующей записи: выход (крестик,
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
* не нужен — их черновик сохраняется сам (см. useFormDraft).
*/
isDirty?: boolean;
children: React.ReactNode;
}
@@ -30,14 +44,32 @@ export const FormModal: React.FC<IFormModalProps> = ({
savingLabel = texts.common.saving,
onSubmit,
onClose,
onReset,
canReset,
isDirty,
children
}) => (
}) => {
const [confirmingClose, setConfirmingClose] = React.useState(false);
// Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл.
const requestClose = (): void => {
if (isSaving) return;
if (isDirty) setConfirmingClose(true);
else onClose();
};
return (
<>
<Modal
title={title}
onClose={onClose}
onClose={requestClose}
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}>
{isSaving ? savingLabel : submitLabel}
</Button>
@@ -47,4 +79,16 @@ export const FormModal: React.FC<IFormModalProps> = ({
{error && <Alert kind="err">{error}</Alert>}
{children}
</Modal>
);
{confirmingClose && (
<ConfirmDialog
title={texts.common.discardTitle}
text={texts.common.discardText}
confirmLabel={texts.common.discardConfirm}
cancelLabel={texts.common.discardKeepEditing}
onConfirm={onClose}
onCancel={() => setConfirmingClose(false)}
/>
)}
</>
);
};
@@ -7,9 +7,17 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
// Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится
// порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую.
z-index: 100000;
backdrop-filter: blur(6px);
padding: 20px;
// Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss)
// сюда не наследуются, задаём заново.
font-family: $font-sans;
font-size: $fs-body;
line-height: 1.55;
box-sizing: border-box;
}
.box {
@@ -63,4 +71,7 @@
padding: 16px 26px 22px;
}
/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */
.footerStart { margin-right: auto; }
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
@@ -1,6 +1,7 @@
import * as React from 'react';
import styles from './Modal.module.scss';
import { IconButton } from './IconButton';
import { ModalOverlay } from './ModalOverlay';
import { texts } from '../../texts';
export interface IModalProps {
@@ -11,8 +12,7 @@ export interface IModalProps {
}
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<ModalOverlay onBackdropClick={onClose}>
<div className={styles.box}>
<div className={styles.header}>
<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>
{footer && <div className={styles.footer}>{footer}</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';
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
// Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может
// открыть сама длительная операция (например, разрешение конфликта), должны
// быть ПОВЕРХ этой подложки, а не под ней.
.overlay {
@@ -73,7 +73,7 @@
box-shadow: $sh-modal;
border: 1px solid $gray-200;
padding: 6px;
z-index: 400;
z-index: 100001; // над оверлеем модалки (Modal.module.scss)
max-height: 320px;
display: flex;
flex-direction: column;
@@ -116,6 +116,7 @@
&:hover, &.active { background: $gold-50; color: $navy-900; }
&.selected { font-weight: 600; color: $navy-900; }
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
}
.itemSub {
@@ -9,6 +9,8 @@ export interface ISearchableOption {
label: string;
/** Второстепенная строка под названием — например, компания у отдела. */
sublabel?: string;
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
disabled?: boolean;
}
export interface ISearchableSelectProps {
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
type="button"
key={opt.value}
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
disabled={opt.disabled}
onClick={() => pick(opt.value)}
>
{opt.label}
@@ -17,6 +17,7 @@ export * from './PrimaryCell';
export * from './ActionsCell';
export * from './CellNote';
export * from './Modal';
export * from './ModalOverlay';
export * from './ConfirmDialog';
export * from './FormModal';
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, но значение переживает перезагрузку страницы — хранится
* в localStorage под key. Падение доступа к localStorage (приватный режим,
* квота) не ломает форму — просто не персистит.
*
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
* обновления в форму добавили новое поле, у пользователей со старой записью в
* localStorage оно не потеряется как undefined (что уронило бы рендер на
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
* Сохранённое значение, приведённое к форме initial. Запись могла быть сделана
* старой версией формы, поэтому ей нельзя доверять слепо:
* - объект мёржится с initial — добавленные с тех пор поля получают дефолт;
* - ключи, которых в initial нет (поле удалили), отбрасываются;
* - значение, чей тип не совпал с initial (было число — стала строка, было
* [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер;
* - null/undefined в initial типа не задают — там принимаем любое сохранённое.
*/
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = React.useState<T>(() => {
export function coerceStored<T>(initial: T, stored: unknown): 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 {
const raw = window.localStorage.getItem(key);
if (!raw) return initial;
const parsed = JSON.parse(raw) as T;
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
return coerceStored(initial, JSON.parse(raw));
} catch {
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(() => {
try {
@@ -0,0 +1,17 @@
import { useLocalStorageState } from './useLocalStorageState';
export interface IPersistedOpen {
isOpen: boolean;
open: () => void;
close: () => void;
}
/**
* Признак «модалка создания открыта», переживающий F5: после перезагрузки окно
* открывается снова, а поля подставляются из черновика (см. useFormDraft).
* close() сбрасывает только признак — черновик остаётся до сохранения или «Очистить».
*/
export function usePersistedOpen(key: string): IPersistedOpen {
const [isOpen, setIsOpen] = useLocalStorageState<boolean>(key, false);
return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false) };
}
@@ -0,0 +1,10 @@
import * as React from 'react';
import { RequestTypeAccess } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/** Реестр доступа «видеть все заявки типа» — целиком, по всем типам заявок. */
export function useRequestTypeAccess(api: ApprovalRouteApi): AsyncList<RequestTypeAccess> {
const load = React.useCallback(() => api.requestTypeAccess.list(), [api]);
return useAsyncList(load);
}
@@ -2,6 +2,7 @@
export * from './enums';
export * from './dictionaries';
export * from './requestTypes';
export * from './requestTypeAccess';
export * from './requestTypeLists';
export * from './templateRoutes';
export * from './templateRouteSteps';
@@ -23,6 +23,9 @@ export interface UserApprovalRow extends ManagedApprovalRow {
requestUniqueCode: string | null;
}
/** Форма, в которой UserApprovalRow отдаёт Tnsu.ApprovalRoute.Api — см. requestManage.ts/ManagedApprovalRowApi. */
export type UserApprovalRowApi = Omit<UserApprovalRow, 'date'>;
/** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */
export interface UserTemplateStepRow extends TemplateRouteStep {
requestTypeName: string;
@@ -154,3 +154,17 @@ export interface ManagedRequestMatrix {
/** Обе фазы вместе, упорядочены по StepOrder. */
rows: ManagedApprovalRow[];
}
// Формы, в которых те же данные отдаёт Tnsu.ApprovalRoute.Api
// (backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageDtos.cs) —
// сервис читает списки SharePoint от имени ПРИЛОЖЕНИЯ, а не текущего
// пользователя (см. services/api.ts/requestManage), поэтому видимость заявок не
// зависит от прав администратора на списки. Отличаются от ManagedApprovalRow/
// ManagedRequestMatrix только одним полем: `date` (отформатированная
// «дд.мм.гггг») здесь нет — она вычисляется на фронтенде из `dateIso` (см.
// services/api.ts/toApprovalRow), бэкенду незачем знать про формат отображения.
export type ManagedApprovalRowApi = Omit<ManagedApprovalRow, 'date'>;
export interface ManagedRequestMatrixApi extends Omit<ManagedRequestMatrix, 'rows'> {
rows: ManagedApprovalRowApi[];
}
@@ -0,0 +1,20 @@
// DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs).
import { RequestListPartition } from './requestTypeLists';
export interface RequestTypeAccess {
id: number;
requestTypeId: number;
email: string;
/**
* Вся серия SharePoint-списков данных типа заявки (архив + текущая партиция) —
* именно на них нужно вручную выдать email'у право чтения, иначе поиск по
* порталу продолжит отдавать только его собственные заявки: этот реестр
* настраивает видимость в UI approval-route, а не права SharePoint.
*/
sharePointLists: RequestListPartition[];
}
export interface CreateRequestTypeAccessInput {
requestTypeId: number;
email: string;
}
@@ -34,6 +34,15 @@ export interface TemplateRouteStep {
slaHours: number | null;
/** Спрашивать ли причину просрочки на этом шаге. null — наследовать от типа. */
askOverdueReason: boolean | null;
/**
* Согласовывать шаг автоматически, когда истечёт его SLA (фоновая задача бэкенда,
* см. AutoApproveService). Обычный boolean, а не «три состояния», как у полей выше:
* наследовать его неоткуда — у типа заявки такой настройки нет.
*
* Работает только вместе с SLA: без действующего SLA (ни здесь, ни у типа заявки)
* отсчитывать нечего, и задача такой шаг пропускает.
*/
autoApprove: boolean;
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
warning?: string | null;
}
@@ -50,6 +59,7 @@ export interface CreateTemplateRouteStepInput {
conditionsLogic: ConditionsLogic;
slaHours: number | null;
askOverdueReason: boolean | null;
autoApprove: boolean;
}
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
@@ -66,6 +76,7 @@ export interface PatchTemplateRouteStepInput {
conditionsLogic?: ConditionsLogic;
slaHours?: number | null;
askOverdueReason?: boolean | null;
autoApprove?: boolean;
}
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
+17 -1
View File
@@ -69,6 +69,14 @@ export interface IAdminRoute {
* к тому же отфильтрованному списку, а не сбросить фильтр.
*/
routesFilter?: string;
/**
* Фильтры таблицы «Доступ к заявкам» (id типа как строка, email) — только при
* tab='requestTypeAccess'. Та же логика, что и у routesFilter: живут в адресе,
* чтобы отфильтрованную ссылку можно было передать и чтобы фильтр не терялся
* при возврате на вкладку.
*/
accessFilterRequestTypeId?: string;
accessFilterEmail?: string;
/** Задан только при tab='resolve' и непустом requestTypeId. */
resolve?: IResolveRouteParams;
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
@@ -81,10 +89,11 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'routesExport', 'manageRequests', 'manageByUser', 'requestTypeAccess'];
const PARAM_KEYS = [
'aatab', 'aaroute', 'aarfilter',
'aaacctype', 'aaaccemail',
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
'aamtype', 'aamreq', 'aamlist',
'aabuser', 'aabtarget'
@@ -146,6 +155,8 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
tab,
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
accessFilterRequestTypeId: (tab === 'requestTypeAccess' ? params.get('aaacctype') : null) || undefined,
accessFilterEmail: (tab === 'requestTypeAccess' ? params.get('aaaccemail') : null) || undefined,
resolve,
manage,
manageByUser: byUserEmail
@@ -167,6 +178,11 @@ function buildQuery(route: IAdminRoute): string {
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
}
if (route.tab === 'requestTypeAccess') {
if (route.accessFilterRequestTypeId) params.set('aaacctype', route.accessFilterRequestTypeId);
if (route.accessFilterEmail) params.set('aaaccemail', route.accessFilterEmail);
}
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
params.set('aartype', route.resolve.requestTypeId);
if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail);
@@ -1,13 +1,20 @@
import { IHttpClient } from './httpClient';
import { IDownloadedFile, IHttpClient } from './httpClient';
import {
CreateRequestTypeAccessInput,
CreateRequestTypeInput,
CreateTemplateRouteInput,
CreateTemplateRouteStepInput,
ManagedApprovalRow,
ManagedApprovalRowApi,
ManagedRequestMatrix,
ManagedRequestMatrixApi,
ManagedRequestPage,
PatchRequestTypeInput,
PatchTemplateRouteInput,
PatchTemplateRouteStepInput,
RequestListPartition,
RequestType,
RequestTypeAccess,
RequestTypeListSeries,
ResolveTemplateRouteQuery,
ResolveTemplateRouteResult,
@@ -15,8 +22,26 @@ import {
TemplateRouteStep,
UpdateRequestTypeInput,
UpdateTemplateRouteInput,
UpdateTemplateRouteStepInput
UpdateTemplateRouteStepInput,
UserApprovalRow,
UserApprovalRowApi
} from '../models';
import { formatDateIso } from '../utils/date';
// Api-формы (dateIso, без отформатированного date) → формы, которыми пользуются
// экраны (ManageRequestsScreen/ManageByUserScreen) — см. requestManage.ts/
// ManagedApprovalRowApi. Единственная разница — производное поле date.
function toApprovalRow(r: ManagedApprovalRowApi): ManagedApprovalRow {
return { ...r, date: formatDateIso(r.dateIso) };
}
function toMatrix(m: ManagedRequestMatrixApi): ManagedRequestMatrix {
return { ...m, rows: m.rows.map(toApprovalRow) };
}
function toUserApprovalRow(r: UserApprovalRowApi): UserApprovalRow {
return { ...r, date: formatDateIso(r.dateIso) };
}
// Тонкий 1:1 фасад над контроллерами бэкенда:
// backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
@@ -47,8 +72,8 @@ export class ApprovalRouteApi {
// ListPartitionService.ListAllAsync) — для дашборда (счётчик партиций) и
// портала, которому нужен весь список адресов разом, а не по одному запросу
// на тип.
listsAll: (): Promise<RequestTypeListSeries[]> =>
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists'),
listsAll: (title?: string): Promise<RequestTypeListSeries[]> =>
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists', { title }),
// Вся серия партиций ОДНОГО типа заявки (архив + текущая) — нужна экрану
// редактирования заявок: заявка может лежать в любой из них, не только в
@@ -57,6 +82,21 @@ export class ApprovalRouteApi {
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
};
// Доступ «видеть все заявки типа» (Application/RequestTypeAccesses/*.cs) —
// тот же список email по типу, что и ручка для Power Automate
// (GET .../{requestTypeId}/emails), только здесь читается ВЕСЬ реестр разом
// (requestTypeId не передан) — админке нужна одна таблица по всем типам.
public readonly requestTypeAccess = {
list: (requestTypeId?: number): Promise<RequestTypeAccess[]> =>
this.http.get<RequestTypeAccess[]>('/api/request-type-access', { requestTypeId }),
create: (body: CreateRequestTypeAccessInput): Promise<RequestTypeAccess> =>
this.http.post<RequestTypeAccess>('/api/request-type-access', body),
remove: (id: number): Promise<void> =>
this.http.delete(`/api/request-type-access/${id}`)
};
public readonly templateRoutes = {
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
@@ -107,4 +147,35 @@ export class ApprovalRouteApi {
listByExecutorEmail: (executorEmail: string): Promise<TemplateRouteStep[]> =>
this.http.get<TemplateRouteStep[]>('/api/template-route-steps', { executorEmail })
};
// Чтение заявок и маршрутов согласования от имени ПРИЛОЖЕНИЯ (см.
// backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs) —
// в отличие от остальных групп выше, это НЕ CRUD над сущностями самого API:
// данные по-прежнему живут в списках SharePoint, которыми управляет
// request-portal (см. requestManageService.ts), только читаются они теперь
// не PnP-клиентом текущего пользователя, а этим бэкендом, поэтому видимость
// заявок не зависит от прав администратора на списки SharePoint.
public readonly requestManage = {
search: (requestTypeId: number, query: string, page: number, pageSize = 20): Promise<ManagedRequestPage> =>
this.http.get<ManagedRequestPage>('/api/request-manage/search', { requestTypeId, query, page, pageSize }),
matrix: (requestTypeId: number, listId: string, requestId: number): Promise<ManagedRequestMatrix> =>
this.http
.get<ManagedRequestMatrixApi>('/api/request-manage/matrix', { requestTypeId, listId, requestId })
.then(toMatrix),
byApprover: (approverEmail: string, activeStatuses: string[]): Promise<UserApprovalRow[]> =>
this.http
.get<UserApprovalRowApi[]>('/api/request-manage/by-approver', {
approverEmail,
statuses: activeStatuses.join(',')
})
.then(rows => rows.map(toUserApprovalRow))
};
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
public readonly reports = {
routes: (requestTypeIds: number[]): Promise<IDownloadedFile> =>
this.http.postForBlob('/api/reports/routes', { requestTypeIds })
};
}
@@ -18,6 +18,28 @@ export class ApiClientError extends Error {
}
}
/** Скачанный файл; fileName пуст, если сервер не прислал Content-Disposition. */
export interface IDownloadedFile {
blob: Blob;
fileName?: string;
}
// filename*=UTF-8''... (RFC 5987, так отдаёт ASP.NET для кириллицы) или обычный filename=...
// eslint-disable-next-line @rushstack/no-new-null -- header — результат Headers.get() (DOM API)
function fileNameFromDisposition(header: string | null | undefined): string | undefined {
if (!header) return undefined;
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (encoded) {
try {
return decodeURIComponent(encoded[1]);
} catch {
// Битая кодировка — падаем на обычный filename ниже.
}
}
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain ? plain[1] : undefined;
}
export type QueryParams = Record<string, string | number | boolean | undefined>;
export interface IHttpClient {
@@ -26,6 +48,8 @@ export interface IHttpClient {
put<T>(path: string, body?: unknown): Promise<T>;
patch<T>(path: string, body?: unknown): Promise<T>;
delete(path: string): Promise<void>;
/** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */
postForBlob(path: string, body: unknown): Promise<IDownloadedFile>;
}
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
@@ -105,6 +129,21 @@ export class AadApiHttpClient implements IHttpClient {
await this.send<void>('DELETE', path);
}
public async postForBlob(path: string, body: unknown): Promise<IDownloadedFile> {
const url = buildUrl(this.baseUrl, path);
const response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
// Ошибка приходит обычным JSON-конвертом ({ messages } или { errors }) — разбираем тем же парсером.
await parseResponse<void>(response);
throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
}
return { blob: await response.blob(), fileName: fileNameFromDisposition(response.headers.get('Content-Disposition')) };
}
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = buildUrl(this.baseUrl, path);
return this.client
@@ -3,29 +3,9 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
// sp.batched() — findActiveApprovalRowsForApprover читает все партиции ОДНОГО
// сайта одним HTTP-запросом (см. метод ниже). Импорт side-effect'ный: без него
// метод есть в типах, но в рантайме не зарегистрирован.
import '@pnp/sp/batching';
import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
APPROVAL_PHASE,
ManagedApprovalRow,
ManagedRequestMatrix,
ManagedRequestPage,
ManagedRequestSummary,
RequestListPartition,
RequestType,
UserApprovalRow
} from '../models';
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
// списки SharePoint, только с другого клиента).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
import { ManagedRequestMatrix, UserApprovalRow } from '../models';
// Тот же поток оповещения согласующего/исполнителя, что и в request-portal
// (src/webparts/requestPortal/shared/config.ts) — один и тот же список
@@ -42,79 +22,26 @@ const FLOW_NOTIFY_URL =
// буквально — здесь их не выбирают заново, а просто дублируют.
const FIELDS = {
approver: 'TansuSiteApprover',
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки (см. IRawDataRow.RequestStatus ниже)
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки
ticket: 'Ticket',
date: 'Date',
comment: 'Comment',
order: 'StepOrder',
allowReassign: 'AllowReassignToOtherApprovers',
phase: 'Phase',
// Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
slaHours: 'SLAhours',
askOverdueReason: 'AskOverdueReason'
order: 'StepOrder'
};
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
function listAbsUrl(siteUrl: string, listId: string): string {
return `${siteOrigin(siteUrl)}${listId}`;
}
// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
// (features/myRequests/myRequests.service.ts/sanitizeForKql).
function sanitizeForKql(text: string): string {
return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function freeTextClause(query: string): string | undefined {
const q = sanitizeForKql(query);
if (!q) return undefined;
return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
}
function formatDateIso(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
interface IRawDataRow {
Id: number;
Title?: string;
/** Статус ЗАЯВКИ (список «— данные») — не путать с FIELDS.status (статус строки маршрута). */
RequestStatus?: string;
Created?: string;
Author?: { Title?: string };
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
RequestUniqueCode?: string | null;
}
function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary {
return {
id: r.Id,
title: r.Title || `#${r.Id}`,
status: r.RequestStatus || '',
created: r.Created || '',
authorName: r.Author?.Title || '',
uniqueCode: r.RequestUniqueCode || null,
listId
};
}
/**
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
* данные заявок и строки маршрута живут только в списках SharePoint, которыми
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
* ему не принадлежит.
* Правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint, от имени
* ТЕКУЩЕГО пользователя — в отличие от остального этого веб-парта (который
* говорит с Tnsu.ApprovalRoute.Api), данные заявок и строки маршрута живут
* только в списках SharePoint, которыми управляет request-portal.
*
* Чтение заявок (поиск, матрица, строки по согласующему) сюда больше не входит —
* оно переехало на бэкенд (см. services/api.ts/requestManage,
* backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs):
* PnP-клиент читал списки от имени залогиненного администратора, и видимость
* заявок молча урезалась его собственными правами на списки SharePoint —
* бэкенд же читает от имени приложения. Правка (переназначить/удалить/
* переставить/уведомить) по-прежнему требует прав самого пользователя на
* запись в эти списки, поэтому остаётся здесь.
*/
export class RequestManageService {
constructor(private readonly context: WebPartContext) {}
@@ -123,202 +50,6 @@ export class RequestManageService {
return spfi(siteUrl).using(SPFx(this.context));
}
// Title списка данных → Title списка согласования. undefined, если Title не
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
// управлять маршрутом для него отсюда нельзя).
private async listTitles(sp: SPFI, listId: string): Promise<{ dataListTitle: string; approvalListTitle: string | undefined }> {
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
const approvalListTitle = DATA_LIST_SUFFIX.test(info.Title)
? `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`
: undefined;
return { dataListTitle: info.Title, approvalListTitle };
}
// К какой партиции относится хит Search — по префиксу его Path. Партиции
// определяются вызывающей стороной (ListPartitionService.ListAsync), их
// список короткий (единицы), линейный поиск тут не проблема.
private partitionForPath(
siteUrl: string,
partitions: RequestListPartition[],
path: string
): RequestListPartition | undefined {
return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0];
}
/**
* Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
* постранично, для комбобокса выбора заявки. Сначала Search по названию
* (свободный терм, пустой query — просто последние без фильтра), затем
* дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
* поля списка надёжно (та же двухшаговая схема, что в request-portal).
*
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
* заявки из архивных партиций в комбобоксе просто не найти.
*/
public async searchRequests(
partitions: RequestListPartition[],
siteUrl: string,
query: string,
page: number,
pageSize = 20
): Promise<ManagedRequestPage> {
if (partitions.length === 0) return { items: [], hasMore: false };
const sp = this.clientFor(siteUrl);
const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
const clauses = [pathClause];
const text = freeTextClause(query);
if (text) clauses.push(text);
const result = await sp.search({
Querytext: clauses.join(' AND '),
SelectProperties: ['ListItemID', 'Path'],
RowLimit: pageSize,
StartRow: (page - 1) * pageSize,
TrimDuplicates: false,
SortList: [{ Property: 'Created', Direction: 1 }]
});
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
// Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
// партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
// одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
// false выше выключает штатную дедупликацию Search (она иногда схлопывает
// нужные нам заявки), и тогда индекс может отдать один и тот же элемент
// несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
// причина и то же лечение, что в request-portal
// (features/myRequests/myRequests.service.ts/hitsToEntries).
const seen = new Set<string>();
const hits: Array<{ id: number; partition: RequestListPartition }> = [];
rawRows.forEach(r => {
const id = Number(r.ListItemID);
if (isNaN(id) || id <= 0 || !r.Path) return;
const partition = this.partitionForPath(siteUrl, partitions, r.Path);
if (!partition) return;
const key = `${partition.listId}|${id}`;
if (seen.has(key)) return;
seen.add(key);
hits.push({ id, partition });
});
if (hits.length === 0) return { items: [], hasMore: false };
const idsByListId = new Map<string, number[]>();
hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
const rowByKey = new Map<string, IRawDataRow>();
await Promise.all(
Array.from(idsByListId.entries()).map(async ([listId, ids]) => {
const list = sp.web.getList(listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r));
})
);
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = hits
.map(h => {
const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
return row ? toSummary(row, h.partition.listId) : undefined;
})
.filter((r): r is ManagedRequestSummary => !!r);
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
}
/**
* Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
*
* listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
* ManagedRequestSummary.listId, отданного searchRequests/предыдущей
* getMatrix) — не requestType.listId: та партиция может быть уже не той, где
* лежит эта заявка (см. ТЗ по архивации).
*/
public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise<ManagedRequestMatrix> {
const sp = this.clientFor(siteUrl);
const dataList = sp.web.getList(listId);
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
dataList.items
.getById(requestId)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')() as Promise<IRawDataRow>,
this.listTitles(sp, listId)
]);
const dataListUrl = listAbsUrl(siteUrl, listId);
if (!approvalListTitle) {
return {
request: toSummary(requestRow, listId),
siteUrl,
dataListTitle,
dataListUrl,
approvalListTitle: '',
rows: []
};
}
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
const rawRows = (await approvalList.items
.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.date,
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
FIELDS.slaHours,
FIELDS.askOverdueReason,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => this.toApprovalRow(r, requestId, i));
return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
}
/**
* Строка списка согласования (сырые OData-поля) → ManagedApprovalRow. Общий
* маппинг для getMatrix (одна заявка, ticketId известен заранее) и
* findActiveApprovalRowsForApprover (много заявок сразу, ticketId — из самой строки).
*/
private toApprovalRow(r: Record<string, unknown>, ticketId: number, index: number): ManagedApprovalRow {
const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
return {
id: r.Id as number,
ticketId,
approverId: approver?.Id ?? null,
approverName: approver?.Title || '',
approverEmail: approver?.EMail || '',
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: formatDateIso((r[FIELDS.date] as string) || ''),
dateIso: (r[FIELDS.date] as string) || '',
comment: (r[FIELDS.comment] as string) || '',
order: (r[FIELDS.order] as number) ?? index + 1,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
// ?? null, а не || null: ноль здесь — значимое значение («напоминать
// сразу»), и превращать его в «не задано» нельзя.
slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null
};
}
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
@@ -326,166 +57,6 @@ export class RequestManageService {
return user.Id;
}
/**
* Активные строки согласования данного человека СРАЗУ по всем типам заявок и
* всем их партициям (архив + текущая) — для экрана «Заявки по согласующему»
* (ManageByUserScreen): найти всё, что нужно переназначить перед увольнением/
* сменой должности, без похода в каждый тип заявки по отдельности.
*
* REST-only, без Search API — в отличие от request-portal/myRequests.service.ts
* (тот ищет по текущему пользователю на КАЖДОЙ загрузке страницы, и там счёт
* идёт на миллисекунды). Здесь редкий admin-вызов на одного конкретного
* сотрудника, а задержка индексации Search — это ровно тот класс багов
* (устаревшее назначение), ради исправления которого существует сам экран, так
* что жертвовать точностью ради скорости здесь не место.
*
* partitionsByType — серия партиций КАЖДОГО типа (архив+текущая), уже
* полученная вызывающей стороной через api.requestTypes.listsAll(). Partition
* даёт Title списка данных бесплатно (без похода в REST) — approvalListTitle
* выводится из него той же конвенцией имён, что и listTitles() выше.
*/
public async findActiveApprovalRowsForApprover(
requestTypes: RequestType[],
partitionsByType: Map<number, RequestListPartition[]>,
approverEmail: string,
activeStatuses: string[]
): Promise<UserApprovalRow[]> {
interface ITarget {
requestTypeId: number;
requestTypeName: string;
siteUrl: string;
dataListId: string;
approvalListTitle: string;
}
const targets: ITarget[] = [];
requestTypes.forEach(rt => {
(partitionsByType.get(rt.id) || []).forEach(p => {
if (!p.title || !DATA_LIST_SUFFIX.test(p.title)) return;
targets.push({
requestTypeId: rt.id,
requestTypeName: rt.name,
siteUrl: p.siteUrl,
dataListId: p.listId,
approvalListTitle: `${p.title.replace(DATA_LIST_SUFFIX, '')} — согласование`
});
});
});
if (targets.length === 0) return [];
const bySite = new Map<string, ITarget[]>();
targets.forEach(t => bySite.set(t.siteUrl, [...(bySite.get(t.siteUrl) || []), t]));
const statusFilter = activeStatuses.map(s => `${FIELDS.status} eq '${s}'`).join(' or ');
const rows: UserApprovalRow[] = [];
await Promise.all(
Array.from(bySite.values()).map(async siteTargets => {
const siteUrl = siteTargets[0].siteUrl;
try {
const sp = this.clientFor(siteUrl);
// Один раз на сайт — тот же приём, что в fetchRecentApprovalRows
// request-portal: резолвить email в Id и фильтровать по approverId
// надёжнее, чем `TansuSiteApprover/EMail eq '...'` в $filter по
// Person-полю (последнее в SharePoint REST ненадёжно).
const approverId = await this.resolveUserId(sp, approverEmail);
// Фаза A: строки согласования — ОДИН batch-запрос на все партиции сайта.
const approvalHitsByList = new Map<string, Array<Record<string, unknown>>>();
const [batchedApprovalSp, executeApproval] = sp.batched();
const pendingApproval = siteTargets.map(t =>
batchedApprovalSp.web.lists
.getByTitle(t.approvalListTitle)
.items.filter(`${FIELDS.approver}Id eq ${approverId} and (${statusFilter})`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.date,
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
FIELDS.slaHours,
FIELDS.askOverdueReason,
`${FIELDS.ticket}Id`,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
)
.expand(FIELDS.approver)
.top(2000)()
.then(items => { approvalHitsByList.set(t.dataListId, items as Array<Record<string, unknown>>); })
.catch((e: Error) => {
// Одна сломанная/переименованная вручную партиция не должна
// лишить результата остальные партиции того же сайта.
console.error(`[Заявки по согласующему] «${t.approvalListTitle}» (${t.dataListId}): ${e.message}`);
})
);
await executeApproval();
await Promise.all(pendingApproval);
// Фаза B: Title/статус/уникальный код заявок — по найденным Id,
// сгруппированным по СПИСКУ ДАННЫХ, тоже одним batch-запросом на сайт.
const ticketIdsByList = new Map<string, Set<number>>();
siteTargets.forEach(t => {
(approvalHitsByList.get(t.dataListId) || []).forEach(h => {
const ticketId = h[`${FIELDS.ticket}Id`] as number | undefined;
if (!ticketId) return;
const set = ticketIdsByList.get(t.dataListId) || new Set<number>();
set.add(ticketId);
ticketIdsByList.set(t.dataListId, set);
});
});
const dataRowsByList = new Map<string, IRawDataRow[]>();
if (ticketIdsByList.size > 0) {
const [batchedDataSp, executeData] = sp.batched();
const pendingData = Array.from(ticketIdsByList.entries()).map(([dataListId, idsSet]) => {
const ids = Array.from(idsSet);
return batchedDataSp.web
.getList(dataListId)
.items.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'RequestUniqueCode')
.top(ids.length)()
.then(items => { dataRowsByList.set(dataListId, items as IRawDataRow[]); })
.catch((e: Error) => {
console.error(`[Заявки по согласующему] список данных ${dataListId}: ${e.message}`);
});
});
await executeData();
await Promise.all(pendingData);
}
// Склейка.
siteTargets.forEach(t => {
const hits = approvalHitsByList.get(t.dataListId) || [];
const dataById = new Map((dataRowsByList.get(t.dataListId) || []).map(r => [r.Id, r]));
hits.forEach((h, i) => {
const ticketId = h[`${FIELDS.ticket}Id`] as number;
if (!ticketId) return;
const dataRow = dataById.get(ticketId);
rows.push({
...this.toApprovalRow(h, ticketId, i),
requestTypeId: t.requestTypeId,
requestTypeName: t.requestTypeName,
siteUrl: t.siteUrl,
listId: t.dataListId,
approvalListTitle: t.approvalListTitle,
requestTitle: dataRow?.Title || `#${ticketId}`,
requestUniqueCode: dataRow?.RequestUniqueCode || null
});
});
});
} catch (e) {
console.error(`[Заявки по согласующему] сайт ${siteUrl}: ${(e as Error).message}`);
}
})
);
return rows;
}
/**
* Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку
* row переназначить на него — та же проверка, что ManageRequestsScreen делает
@@ -516,13 +87,32 @@ export class RequestManageService {
});
}
/**
* Права на заявку новому человеку в маршруте выдаёт бэкенд портала по вебхуку
* на изменение элемента СПИСКА ДАННЫХ. Правка одного лишь списка согласования
* вебхук не вызывает — новый человек видел бы «Элемент не найден». Поэтому
* после смены/добавления человека пишем в заявку её текущий RequestStatus как
* есть: значение то же, но у элемента появляется новая версия и приходит
* вебхук (то же делает request-portal, ApproverService.touchRequest).
*
* dataListId — серверный относительный url списка данных (партиции), где лежит
* заявка (ManagedRequestSummary.listId / UserApprovalRow.listId).
*/
private async touchRequest(siteUrl: string, dataListId: string, ticketId: number): Promise<void> {
const item = this.clientFor(siteUrl).web.getList(dataListId).items.getById(ticketId);
const current = (await item.select('RequestStatus')()) as { RequestStatus?: string };
await item.update({ RequestStatus: current.RequestStatus || '' });
}
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет.
* request — заявка строки: по ней «трогается» элемент списка данных, см. touchRequest. */
public async reassignApprover(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
rowId: number,
email: string,
position: string
position: string,
request: { ticketId: number; dataListId: string }
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const approverId = await this.resolveUserId(sp, email);
@@ -530,6 +120,7 @@ export class RequestManageService {
Title: position,
[`${FIELDS.approver}Id`]: approverId
});
await this.touchRequest(matrix.siteUrl, request.dataListId, request.ticketId);
}
/**
@@ -549,8 +140,8 @@ export class RequestManageService {
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
[FIELDS.slaHours]: slaHours,
[FIELDS.askOverdueReason]: askOverdueReason
SLAhours: slaHours,
AskOverdueReason: askOverdueReason
});
}
@@ -577,7 +168,8 @@ export class RequestManageService {
status: string,
email: string,
position: string,
allowReassign: boolean
allowReassign: boolean,
dataListId: string
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const approverId = await this.resolveUserId(sp, email);
@@ -587,9 +179,10 @@ export class RequestManageService {
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: ticketId,
[FIELDS.order]: order,
[FIELDS.allowReassign]: allowReassign,
AllowReassignToOtherApprovers: allowReassign,
[FIELDS.phase]: phase
});
await this.touchRequest(matrix.siteUrl, dataListId, ticketId);
}
/**
@@ -618,12 +211,21 @@ export class RequestManageService {
}
/**
* Отправить уведомление согласующему/исполнителю конкретной строки маршрута —
* тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL
* выше): поток сам находит по RowID согласующего, статус и заявку и шлёт
* письмо. В отличие от request-portal (там вызывается автоматически после
* решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому
* ошибку НЕ глушим — вызывающая сторона показывает её пользователю.
* Отправить уведомление по конкретной строке маршрута — тот же поток Power
* Automate, что и request-portal (см. FLOW_NOTIFY_URL выше): поток сам
* находит по RowID строку, её статус и заявку.
*
* Обычно (статус «На согласовании»/«На исполнении») он уведомляет
* согласующего/исполнителя ЭТОЙ строки — см. вызывающую сторону,
* ManageRequestsPhaseTable/notifiable. Для строки в статусе «Возвращена на
* доработку» этот же поток (по сложившейся практике портала — он уже читает
* статус строки, см. комментарий у FLOW_NOTIFY_URL) уведомляет автора заявки,
* а не согласующего, вернувшего её — так интерфейс редактирования заявок
* получает кнопку «сообщить автору о доработке» без отдельного потока.
*
* В отличие от request-portal (там вызывается автоматически после решения),
* здесь — по явному клику на кнопку в таблице маршрута, поэтому ошибку НЕ
* глушим — вызывающая сторона показывает её пользователю.
*/
public async notifyRow(
requestTypeId: number,
@@ -76,6 +76,13 @@ export const texts = {
remove: 'Удалить',
edit: 'Редактировать',
close: 'Закрыть',
/** Сброс черновика формы создания — сам черновик переживает закрытие окна. */
resetDraft: 'Очистить',
/** Предупреждение при выходе из формы редактирования с несохранёнными правками. */
discardTitle: 'Закрыть без сохранения?',
discardText: 'Внесённые изменения не сохранятся.',
discardConfirm: 'Закрыть без сохранения',
discardKeepEditing: 'Продолжить редактирование',
loading: 'Загрузка...',
yes: 'Да',
no: 'Нет',
@@ -93,8 +100,10 @@ export const texts = {
title: 'Панель настройки маршрутов',
dashboard: 'Дашборд',
requestTypes: 'Типы заявок',
requestTypeAccess: 'Доступ к заявкам',
routes: 'Маршруты',
resolve: 'Проверка маршрута',
routesExport: 'Выгрузка маршрутов',
manageRequests: 'Редактирование заявок',
manageByUser: 'Заявки по участнику'
},
@@ -178,6 +187,53 @@ export const texts = {
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
},
requestTypeAccess: {
breadcrumb: 'Доступ к заявкам',
subtitle: 'Кто видит все заявки типа, а не только свои',
title: 'Доступ к заявкам',
add: 'Выдать доступ',
emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.',
emptyFiltered: 'Ничего не найдено по заданным фильтрам.',
filterRequestTypeLabel: 'Фильтр по типу заявки',
filterRequestTypeAll: 'Все типы заявок',
filterEmailLabel: 'Фильтр по сотруднику',
filterEmailAll: 'Все сотрудники',
filterEmailPlaceholder: 'Найдите по ФИО или email',
columnRequestType: 'Тип заявки',
columnEmail: 'Сотрудник',
columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)',
createTitle: 'Выдать доступ',
requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки',
emailLabel: 'Сотрудники',
emailPlaceholder: 'Добавьте сотрудника — найдите по ФИО или email',
removeEmail: 'Убрать из списка',
alreadyGranted: 'доступ уже есть',
alreadyChosen: 'уже в списке',
noEmailsSelected: 'Пока никого не выбрано.',
createFailed: (failures: string[]): string =>
`Не удалось выдать доступ (остались в списке): ${failures.join('; ')}`,
validationRequestType: 'Выберите тип заявки.',
validationEmail: 'Выберите хотя бы одного сотрудника.',
deleteTitle: 'Отозвать доступ?',
deleteText: (email: string, requestTypeName: string): string =>
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
},
routesExport: {
breadcrumb: 'Выгрузка маршрутов',
subtitle: 'Маршруты согласования в Excel',
title: 'Выгрузка маршрутов',
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).',
selectAll: 'Выбрать все',
export: 'Выгрузить',
exporting: 'Формирование файла...',
emptyTypes: 'Типов заявок пока нет.',
selected: (count: number): string => `Выбрано: ${count}`,
fileName: 'Маршруты согласования.xlsx',
failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}`
},
routes: {
breadcrumb: 'Маршруты согласования',
subtitle: 'Настройка согласований',
@@ -329,6 +385,12 @@ export const texts = {
slaHoursPlaceholder: 'Как у типа заявки',
askOverdueReasonLabel: 'Спрашивать причину просрочки',
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
autoApproveLabel: 'Автосогласование',
autoApproveCheckbox: 'Согласовать шаг автоматически, когда истечёт SLA',
autoApproveHint:
'Если согласующий не примет решение в срок, шаг будет согласован без него и заявка пойдёт дальше. Проверка идёт раз в час, поэтому согласование произойдёт в течение часа после истечения срока.',
validationAutoApproveWithoutSla:
'Автосогласование считается от SLA — задайте SLA шага или SLA типа заявки.',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint:
@@ -0,0 +1,12 @@
// ISO 8601 (или пустая строка) → «дд.мм.гггг» для отображения в таблицах
// маршрута. Раздельно от dateIso: последняя нужна для расчёта просрочки
// (см. utils/sla.ts) и не должна терять точность через локаль при парсинге
// уже отформатированной строки назад.
export function formatDateIso(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
@@ -16,6 +16,7 @@ function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
conditionsLogic: ConditionsLogic.And,
slaHours: null,
askOverdueReason: null,
autoApprove: false,
...overrides
};
}