diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index 0ee4fae..bf4ee33 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -1,6 +1,12 @@
true
+
+ true
@@ -9,6 +15,9 @@
+
+
+
@@ -18,6 +27,8 @@
+
+
diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs
new file mode 100644
index 0000000..bffe626
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs
@@ -0,0 +1,37 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tnsu.ApprovalRoute.Api.Common;
+using Tnsu.ApprovalRoute.Application.RequestTypeLists;
+
+namespace Tnsu.ApprovalRoute.Api.Controllers;
+
+///
+/// Нумерованная серия SharePoint-списков по типу заявок: куда писать сейчас и вся
+/// серия для админки.
+///
+[ApiController]
+[Authorize]
+[Route("api/request-types/{requestTypeId:int}/lists")]
+[Produces("application/json")]
+public sealed class RequestTypeListsController(RequestTypeListService service) : ControllerBase
+{
+ /// Вся серия списков типа заявок (архив + текущий), от первого к последнему.
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task List(int requestTypeId, CancellationToken ct) =>
+ (await service.ListAsync(requestTypeId, ct)).ToActionResult();
+
+ ///
+ /// Список, в который нужно писать новые заявки этого типа.
+ ///
+ /// Не чистый GET: если список добрал порог элементов, запрос ставит в очередь
+ /// фоновую задачу создания следующего (см. RequestTypeListService.GetCurrentAsync).
+ ///
+ [HttpGet("current")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Current(int requestTypeId, CancellationToken ct) =>
+ (await service.GetCurrentAsync(requestTypeId, ct)).ToActionResult();
+}
diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs
index 178f2f3..c64e0ff 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Program.cs
+++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs
@@ -1,5 +1,7 @@
using System.Text.Json.Serialization;
using Extensions.Hosting.AsyncInitialization;
+using Hangfire;
+using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
@@ -10,6 +12,7 @@ using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Reminders;
+using Tnsu.ApprovalRoute.Application.RequestTypeLists;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure;
@@ -37,6 +40,20 @@ builder.Services.AddCors(options =>
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication();
builder.Services.Configure(builder.Configuration.GetSection("Reminders"));
+builder.Services.Configure(builder.Configuration.GetSection("ListProvisioning"));
+
+// Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам
+// (схема hangfire) при старте — отдельной EF-миграции для них не нужно.
+builder.Services.AddHangfire(configuration => configuration
+ .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
+ .UseSimpleAssemblyNameTypeSerializer()
+ .UseRecommendedSerializerSettings()
+ .UsePostgreSqlStorage(postgres =>
+ postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres"))));
+
+// Воркер живёт в процессе API: задач мало (создание списка — редкое событие), и
+// отдельный хост под них был бы лишней инфраструктурой.
+builder.Services.AddHangfireServer();
builder.Services.AddApprovalRouteAuth(builder.Configuration);
builder.Services.AddScoped();
builder.Services.AddOpenApi(options =>
diff --git a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj
index da2a380..a3f81e4 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj
+++ b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj
@@ -5,6 +5,8 @@
+
+
diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json
index e01d0f1..575395e 100644
--- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json
+++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json
@@ -19,6 +19,11 @@
"https://localhost:4321"
]
},
+ "ListProvisioning": {
+ "ItemCountThreshold": 4500,
+ "StaleLockAfter": "00:30:00",
+ "CheckThresholdOnRead": true
+ },
"SharePoint": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
index 3e5732c..a42a159 100644
--- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@ using System.Reflection;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Reminders;
+using Tnsu.ApprovalRoute.Application.RequestTypeLists;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -14,6 +15,10 @@ public static class ApplicationServiceCollectionExtensions
{
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ // Джоба резолвится Hangfire'ом из scope на каждое выполнение.
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs
new file mode 100644
index 0000000..5319632
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs
@@ -0,0 +1,111 @@
+using Hangfire;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Tnsu.ApprovalRoute.Domain.Entities;
+using Tnsu.ApprovalRoute.Infrastructure.Persistence;
+using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+
+namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
+
+///
+/// Фоновая задача: создать следующий список серии по типу заявок, записать его в
+/// реестр и переключить на него запись.
+///
+/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
+/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
+/// безопасен и не создаёт второй список. Больше трёх попыток смысла нет — если
+/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
+/// а не молотить впустую.
+///
+[AutomaticRetry(Attempts = 3)]
+public sealed class CreateNextRequestTypeListJob(
+ ApprovalRouteDbContext db,
+ RequestTypeProvisioningLock provisioningLock,
+ RequestTypeListRegistry registry,
+ ISharePointListProvisioner provisioner,
+ IOptions options,
+ ILogger logger)
+{
+ public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken)
+ {
+ // Единственная настоящая защита от параллельного запуска: атомарный захват
+ // флага в строке request_types. Проверка перед постановкой в очередь —
+ // только чтобы не плодить лишние задачи; гонку разруливает именно здесь.
+ if (!await provisioningLock.TryAcquireAsync(
+ requestTypeId, options.Value.StaleLockAfter, cancellationToken))
+ {
+ logger.LogInformation(
+ "Провижининг списка для типа заявок {RequestTypeId} уже идёт — задача пропущена.",
+ requestTypeId);
+ return;
+ }
+
+ try
+ {
+ var requestType = await db.RequestTypes
+ .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
+
+ if (requestType is null)
+ {
+ logger.LogWarning("Тип заявок {RequestTypeId} не найден — провижининг отменён.", requestTypeId);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
+ {
+ logger.LogWarning(
+ "У типа заявок {RequestTypeId} не настроен список SharePoint — провижининг отменён.",
+ requestTypeId);
+ return;
+ }
+
+ // Задачу могли поставить раньше, чем реестр успел узнать про текущий
+ // список (например, ручным запуском) — регистрируем его, иначе серия
+ // осталась бы без своего первого номера.
+ var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
+
+ var maxNumber = await db.RequestTypeLists
+ .Where(x => x.RequestTypeId == requestTypeId)
+ .MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number;
+
+ var nextNumber = maxNumber + 1;
+
+ logger.LogInformation(
+ "Создаю список №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.",
+ nextNumber, requestTypeId, requestType.ListId);
+
+ var created = await provisioner.CreateNextListPairAsync(
+ requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
+
+ // Реестр и переключение записи — одной транзакцией: иначе можно остаться
+ // с записанным в реестр списком, в который никто не пишет, или наоборот.
+ await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
+
+ db.RequestTypeLists.Add(new RequestTypeList
+ {
+ RequestTypeId = requestTypeId,
+ Number = nextNumber,
+ SiteUrl = requestType.SiteUrl,
+ ListId = created.ListId,
+ Title = created.Title,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+
+ requestType.ListId = created.ListId;
+
+ await db.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+
+ logger.LogInformation(
+ "Список №{Number} ({ListId}) создан, запись по типу заявок {RequestTypeId} переключена на него.",
+ nextNumber, created.ListId, requestTypeId);
+ }
+ finally
+ {
+ // Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
+ // держала бы тип заблокированным до истечения StaleLockAfter.
+ await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
+ }
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs
new file mode 100644
index 0000000..d2a5c9a
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs
@@ -0,0 +1,30 @@
+namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
+
+/// Настройки нумерованных списков заявок (секция ListProvisioning в конфигурации).
+public sealed class ListProvisioningOptions
+{
+ ///
+ /// Порог элементов в списке данных, по достижении которого ставится задача на
+ /// создание следующей пары списков.
+ ///
+ /// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
+ /// «list view threshold»: запросы, которым приходится просмотреть больше 5000
+ /// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
+ /// ниже, чтобы задача успела создать следующий список до того, как текущий
+ /// начнёт упираться.
+ ///
+ public int ItemCountThreshold { get; set; } = 4500;
+
+ ///
+ /// Через сколько блокировка провижининга считается брошенной и может быть
+ /// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListProvisioningStartedAt.
+ ///
+ public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
+ /// отдаёт список, не обращаясь к SharePoint и никого не ставя в очередь
+ /// (например, пока провижинер не готов).
+ ///
+ public bool CheckThresholdOnRead { get; set; } = true;
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs
new file mode 100644
index 0000000..7a28b51
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs
@@ -0,0 +1,30 @@
+namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
+
+/// Один список серии — для админки (вся серия) и для ответа «текущий список».
+///
+/// true — в этот список идёт запись новых заявок; остальные серии — архив только для чтения.
+///
+public sealed record RequestTypeListDto(
+ int Number,
+ string SiteUrl,
+ string ListId,
+ string? Title,
+ bool IsCurrent,
+ DateTimeOffset CreatedAt);
+
+/// Ответ «куда писать новую заявку».
+///
+/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
+/// null, если SharePoint не ответил (тогда условие создания следующего не проверялось).
+///
+///
+/// true — этим запросом поставлена в очередь задача создания следующего списка серии.
+///
+public sealed record CurrentRequestTypeListDto(
+ int RequestTypeId,
+ int Number,
+ string SiteUrl,
+ string ListId,
+ string? Title,
+ int? ItemCount = null,
+ bool ProvisioningQueued = false);
diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs
new file mode 100644
index 0000000..eb83d38
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs
@@ -0,0 +1,73 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Tnsu.ApprovalRoute.Domain.Entities;
+using Tnsu.ApprovalRoute.Infrastructure.Persistence;
+using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+
+namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
+
+///
+/// Наполнение реестра списков. Вынесено отдельно, потому что нужно и читающему
+/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них
+/// может встретить тип заявок, которого в реестре ещё нет.
+///
+public sealed class RequestTypeListRegistry(
+ ApprovalRouteDbContext db,
+ ISharePointClient sharePoint,
+ ILogger logger)
+{
+ ///
+ /// Гарантирует, что текущий список типа заявок есть в реестре, и возвращает его строку.
+ ///
+ /// Реестр самозаполняющийся: до этой фичи списки жили только в
+ /// RequestType.ListId, и переключить список руками в админке можно и сейчас.
+ /// Поэтому неизвестный реестру ListId дописывается следующим номером, а не
+ /// считается ошибкой.
+ ///
+ /// Требует заполненных ListId/SiteUrl — вызывающая сторона это проверяет.
+ ///
+ public async Task EnsureCurrentRegisteredAsync(
+ RequestType requestType, CancellationToken cancellationToken)
+ {
+ var series = await db.RequestTypeLists
+ .Where(x => x.RequestTypeId == requestType.Id)
+ .ToListAsync(cancellationToken);
+
+ var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId);
+ if (existing is not null) return existing;
+
+ var entity = new RequestTypeList
+ {
+ RequestTypeId = requestType.Id,
+ Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1,
+ SiteUrl = requestType.SiteUrl!,
+ ListId = requestType.ListId!,
+ Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken),
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+
+ db.RequestTypeLists.Add(entity);
+ await db.SaveChangesAsync(cancellationToken);
+
+ logger.LogInformation(
+ "Список {ListId} типа заявок {RequestTypeId} добавлен в реестр под номером {Number}.",
+ entity.ListId, requestType.Id, entity.Number);
+
+ return entity;
+ }
+
+ // Заголовок нужен только для отображения в админке — из-за его отсутствия
+ // регистрация списка падать не должна.
+ private async Task TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Не удалось прочитать заголовок списка {ListId}.", listId);
+ return null;
+ }
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs
new file mode 100644
index 0000000..3104733
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs
@@ -0,0 +1,131 @@
+using FluentResults;
+using Hangfire;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Tnsu.ApprovalRoute.Application.Common.Errors;
+using Tnsu.ApprovalRoute.Domain.Entities;
+using Tnsu.ApprovalRoute.Infrastructure.Persistence;
+using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+
+namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
+
+///
+/// Реестр нумерованных списков по типу заявок: какой список текущий (куда писать) и
+/// вся серия целиком (архив + текущий) для админки.
+///
+public sealed class RequestTypeListService(
+ ApprovalRouteDbContext db,
+ ISharePointClient sharePoint,
+ RequestTypeListRegistry registry,
+ IBackgroundJobClient backgroundJobs,
+ IOptions options,
+ ILogger logger)
+{
+ ///
+ /// Список, в который сейчас нужно писать новые заявки.
+ ///
+ /// Побочный эффект: если в списке набрался порог элементов, ставит в очередь
+ /// задачу создания следующего. Побочный эффект в GET — сознательный компромисс
+ /// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
+ /// записью, и это единственный момент, когда мы точно знаем, что список живой.
+ /// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
+ ///
+ public async Task> GetCurrentAsync(
+ int requestTypeId, CancellationToken cancellationToken)
+ {
+ var requestType = await db.RequestTypes
+ .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
+
+ if (requestType is null)
+ return Result.Fail(new NotFoundError("Тип заявки", requestTypeId));
+
+ if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
+ return Result.Fail(new ValidationError(
+ $"У типа заявки {requestTypeId} не настроен список SharePoint."));
+
+ var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
+
+ var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
+ var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
+
+ return Result.Ok(new CurrentRequestTypeListDto(
+ requestTypeId, current.Number, current.SiteUrl, current.ListId, current.Title, itemCount, queued));
+ }
+
+ /// Вся серия списков типа заявок, от первого к последнему — для админки.
+ public async Task>> ListAsync(
+ int requestTypeId, CancellationToken cancellationToken)
+ {
+ var requestType = await db.RequestTypes
+ .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
+
+ if (requestType is null)
+ return Result.Fail>(new NotFoundError("Тип заявки", requestTypeId));
+
+ // Тип без настроенного списка — пустая серия, а не ошибка: в админке такой
+ // тип заявок вполне может существовать до привязки к SharePoint.
+ if (!string.IsNullOrWhiteSpace(requestType.ListId) && !string.IsNullOrWhiteSpace(requestType.SiteUrl))
+ await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
+
+ var items = await db.RequestTypeLists.AsNoTracking()
+ .Where(x => x.RequestTypeId == requestTypeId)
+ .OrderBy(x => x.Number)
+ .Select(x => new RequestTypeListDto(
+ x.Number, x.SiteUrl, x.ListId, x.Title, x.ListId == requestType.ListId, x.CreatedAt))
+ .ToListAsync(cancellationToken);
+
+ return Result.Ok>(items);
+ }
+
+ ///
+ /// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать
+ /// адрес списка даже когда SharePoint недоступен, иначе портал не сможет
+ /// принять заявку из-за сбоя необязательной проверки порога.
+ ///
+ private async Task TryGetItemCountAsync(string siteUrl, string listId, CancellationToken cancellationToken)
+ {
+ if (!options.Value.CheckThresholdOnRead) return null;
+
+ try
+ {
+ return await sharePoint.GetItemCountAsync(siteUrl, listId, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Не удалось получить число элементов списка {ListId} — порог не проверен.", listId);
+ return null;
+ }
+ }
+
+ ///
+ /// Ставит задачу создания следующего списка, если порог достигнут. Возвращает
+ /// true, только если задача действительно поставлена.
+ ///
+ private async Task TryEnqueueProvisioningAsync(
+ RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken)
+ {
+ if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false;
+
+ // Следующий список уже создан, а запись на него ещё не переключили — задача
+ // не нужна (и переключать здесь нельзя: этим владеет провижининг).
+ var hasNext = await db.RequestTypeLists
+ .AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
+ if (hasNext) return false;
+
+ // Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
+ // постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
+ // самой задачи.
+ if (requestType.ListProvisioningStartedAt is not null) return false;
+
+ backgroundJobs.Enqueue(
+ job => job.RunAsync(requestType.Id, CancellationToken.None));
+
+ logger.LogInformation(
+ "В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
+ "поставлена задача создания списка №{NextNumber}.",
+ requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
+
+ return true;
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj
index a10ee5e..bc84cb2 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj
+++ b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj
@@ -7,6 +7,7 @@
+
diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs
index 70db213..a65a006 100644
--- a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs
+++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs
@@ -25,5 +25,23 @@ public class RequestType
///
public bool? AskOverdueReason { get; set; }
+ ///
+ /// Момент, когда фоновая задача создания следующего списка взяла эту строку в
+ /// работу. null — задача не выполняется.
+ ///
+ /// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует
+ /// только если сумела перевести поле из null в now() одним условным UPDATE
+ /// (см. RequestTypeProvisioningLock) — тогда две параллельные задачи по одному
+ /// типу заявок невозможны.
+ ///
+ /// Метка времени, а не bool, намеренно: упавший процесс не успевает снять
+ /// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка
+ /// просрочена, и её можно перехватить.
+ ///
+ public DateTimeOffset? ListProvisioningStartedAt { get; set; }
+
public ICollection Routes { get; set; } = new List();
+
+ /// Нумерованная серия списков этого типа заявок (архив + текущий).
+ public ICollection Lists { get; set; } = new List();
}
diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs
new file mode 100644
index 0000000..974f308
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs
@@ -0,0 +1,43 @@
+namespace Tnsu.ApprovalRoute.Domain.Entities;
+
+///
+/// Один SharePoint-список данных из нумерованной серии по типу заявок.
+///
+/// Списки заявок нумерованные («<Форма> <N> — данные», см. провижинер
+/// request-portal): когда в текущем списке набирается порог элементов, рядом
+/// создаётся следующая пара, а прежняя остаётся архивом только для чтения. Эта
+/// таблица — реестр всей серии: без неё поиск по заявкам типа не знает, по каким
+/// спискам искать, а провижинер — какой номер выдавать следующим.
+///
+/// Текущий (тот, куда идёт запись) список продолжает лежать в
+/// — реестр его дублирует строкой с наибольшим
+/// , но источником правды для записи остаётся RequestType.
+///
+public class RequestTypeList
+{
+ public int Id { get; set; }
+
+ public int RequestTypeId { get; set; }
+ public RequestType? RequestType { get; set; }
+
+ /// Номер списка в серии, с 1. Уникален в пределах типа заявок.
+ public int Number { get; set; }
+
+ /// Сайт SharePoint, где живёт список (серия может переезжать между сайтами).
+ public string SiteUrl { get; set; } = string.Empty;
+
+ ///
+ /// Серверный относительный url списка — та же семантика, что у
+ /// (несмотря на имя, это url, а не GUID).
+ ///
+ public string ListId { get; set; } = string.Empty;
+
+ ///
+ /// Title списка на момент создания — для админки, чтобы показать серию без
+ /// похода в SharePoint за каждым заголовком. Может разойтись с фактическим,
+ /// если список переименовали вручную.
+ ///
+ public string? Title { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs
index 9b1ba29..f187d8c 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs
@@ -20,12 +20,14 @@ public static class InfrastructureServiceCollectionExtensions
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
.UseSnakeCaseNamingConvention());
services.AddAsyncInitializer();
+ services.AddScoped();
services.Configure(configuration.GetSection("SharePoint"));
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
// время жизни процесса (см. DbCertificateProvider).
services.AddSingleton();
services.AddHttpClient();
+ services.AddScoped();
services.Configure(configuration.GetSection("TnsuPortal"));
services.AddHttpClient();
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs
index 3bf9ee4..a0a6f5d 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options)
{
public DbSet RequestTypes => Set();
+ public DbSet RequestTypeLists => Set();
public DbSet TemplateRoutes => Set();
public DbSet TemplateRouteSteps => Set();
public DbSet Settings => Set();
@@ -33,6 +34,19 @@ public class ApprovalRouteDbContext(DbContextOptions opt
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
});
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.Property(x => x.SiteUrl).HasMaxLength(512).IsRequired();
+ e.Property(x => x.ListId).HasMaxLength(512).IsRequired();
+ e.Property(x => x.Title).HasMaxLength(512);
+ e.HasOne(x => x.RequestType).WithMany(x => x.Lists).HasForeignKey(x => x.RequestTypeId);
+ // Номер уникален в пределах типа заявок — вторая попытка записать тот же
+ // номер (например, при двойном запуске задачи) упадёт на этом индексе,
+ // а не создаст в реестре дубль.
+ e.HasIndex(x => new { x.RequestTypeId, x.Number }).IsUnique();
+ });
+
modelBuilder.Entity(e =>
{
e.HasKey(x => x.Id);
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs
new file mode 100644
index 0000000..15f6299
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs
@@ -0,0 +1,320 @@
+//
+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("20260817084927_AddRequestTypeListsRegistry")]
+ partial class AddRequestTypeListsRegistry
+ {
+ ///
+ 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.RequestType", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AskOverdueReason")
+ .HasColumnType("boolean")
+ .HasColumnName("ask_overdue_reason");
+
+ b.Property("ListId")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("list_id");
+
+ b.Property("ListProvisioningStartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("list_provisioning_started_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("PageUrl")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("page_url");
+
+ b.Property("Section")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("section");
+
+ b.Property("SiteUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("site_url");
+
+ b.Property("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.RequestTypeList", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ListId")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("list_id");
+
+ b.Property("Number")
+ .HasColumnType("integer")
+ .HasColumnName("number");
+
+ b.Property("RequestTypeId")
+ .HasColumnType("integer")
+ .HasColumnName("request_type_id");
+
+ b.Property("SiteUrl")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("site_url");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("title");
+
+ b.HasKey("Id")
+ .HasName("pk_request_type_lists");
+
+ b.HasIndex("RequestTypeId", "Number")
+ .IsUnique()
+ .HasDatabaseName("ix_request_type_lists_request_type_id_number");
+
+ b.ToTable("request_type_lists", (string)null);
+ });
+
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ByProject")
+ .HasColumnType("boolean")
+ .HasColumnName("by_project");
+
+ b.Property("CompanyId")
+ .HasColumnType("uuid")
+ .HasColumnName("company_id");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uuid")
+ .HasColumnName("department_id");
+
+ b.Property("RequestTypeId")
+ .HasColumnType("integer")
+ .HasColumnName("request_type_id");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowReassignToOtherApprovers")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_reassign_to_other_approvers");
+
+ b.Property("AskOverdueReason")
+ .HasColumnType("boolean")
+ .HasColumnName("ask_overdue_reason");
+
+ b.Property("ExecutorEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("executor_email");
+
+ b.Property("FieldName")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("field_name");
+
+ b.Property("HeadType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("head_type");
+
+ b.Property("Operation")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("operation");
+
+ b.Property("OrderNo")
+ .HasColumnType("integer")
+ .HasColumnName("order_no");
+
+ b.Property("Position")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("position");
+
+ b.Property("ProjectRole")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("project_role");
+
+ b.Property("RouteId")
+ .HasColumnType("integer")
+ .HasColumnName("route_id");
+
+ b.Property("SlaHours")
+ .HasColumnType("integer")
+ .HasColumnName("sla_hours");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("status");
+
+ b.Property("Value")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("value");
+
+ b.HasKey("Id")
+ .HasName("pk_template_route_steps");
+
+ b.HasIndex("RouteId", "OrderNo")
+ .HasDatabaseName("ix_template_route_steps_route_id_order_no");
+
+ b.ToTable("template_route_steps", (string)null);
+ });
+
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
+ {
+ b.Property("OptionName")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("option_name");
+
+ b.Property("FileData")
+ .HasColumnType("bytea")
+ .HasColumnName("file_data");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("OptionName")
+ .HasName("pk_settings");
+
+ b.ToTable("settings", (string)null);
+ });
+
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
+ {
+ b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
+ .WithMany("Lists")
+ .HasForeignKey("RequestTypeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_request_type_lists_request_types_request_type_id");
+
+ b.Navigation("RequestType");
+ });
+
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
+ {
+ b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
+ .WithMany("Routes")
+ .HasForeignKey("RequestTypeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_template_routes_request_types_request_type_id");
+
+ b.Navigation("RequestType");
+ });
+
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
+ {
+ b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
+ .WithMany("Steps")
+ .HasForeignKey("RouteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_template_route_steps_template_routes_route_id");
+
+ b.Navigation("Route");
+ });
+
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
+ {
+ b.Navigation("Lists");
+
+ b.Navigation("Routes");
+ });
+
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
+ {
+ b.Navigation("Steps");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs
new file mode 100644
index 0000000..a67d3bf
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs
@@ -0,0 +1,63 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddRequestTypeListsRegistry : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "list_provisioning_started_at",
+ table: "request_types",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.CreateTable(
+ name: "request_type_lists",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ request_type_id = table.Column(type: "integer", nullable: false),
+ number = table.Column(type: "integer", nullable: false),
+ site_url = table.Column(type: "character varying(512)", maxLength: 512, nullable: false),
+ list_id = table.Column(type: "character varying(512)", maxLength: 512, nullable: false),
+ title = table.Column(type: "character varying(512)", maxLength: 512, nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_request_type_lists", x => x.id);
+ table.ForeignKey(
+ name: "fk_request_type_lists_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_lists_request_type_id_number",
+ table: "request_type_lists",
+ columns: new[] { "request_type_id", "number" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "request_type_lists");
+
+ migrationBuilder.DropColumn(
+ name: "list_provisioning_started_at",
+ table: "request_types");
+ }
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs
index a3209ad..69528d5 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs
@@ -40,6 +40,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
+ b.Property("ListProvisioningStartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("list_provisioning_started_at");
+
b.Property("Name")
.IsRequired()
.HasMaxLength(256)
@@ -73,6 +77,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("request_types", (string)null);
});
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ListId")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("list_id");
+
+ b.Property("Number")
+ .HasColumnType("integer")
+ .HasColumnName("number");
+
+ b.Property("RequestTypeId")
+ .HasColumnType("integer")
+ .HasColumnName("request_type_id");
+
+ b.Property("SiteUrl")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("site_url");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("title");
+
+ b.HasKey("Id")
+ .HasName("pk_request_type_lists");
+
+ b.HasIndex("RequestTypeId", "Number")
+ .IsUnique()
+ .HasDatabaseName("ix_request_type_lists_request_type_id_number");
+
+ b.ToTable("request_type_lists", (string)null);
+ });
+
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property("Id")
@@ -212,6 +264,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("settings", (string)null);
});
+ modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
+ {
+ b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
+ .WithMany("Lists")
+ .HasForeignKey("RequestTypeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_request_type_lists_request_types_request_type_id");
+
+ b.Navigation("RequestType");
+ });
+
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
@@ -238,6 +302,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
+ b.Navigation("Lists");
+
b.Navigation("Routes");
});
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs
new file mode 100644
index 0000000..47b5ed8
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs
@@ -0,0 +1,51 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
+
+///
+/// Блокировка «по типу заявок идёт создание следующего списка» на строке
+/// request_types.
+///
+/// Почему сырой SQL, а не обычный EF: через change tracker это было бы
+/// read-modify-write (прочитали флаг → проверили → записали), и две задачи,
+/// прочитавшие null одновременно, обе решили бы, что блокировка свободна.
+/// Здесь проверка и захват — ОДИН условный UPDATE, атомарный на уровне строки:
+/// Postgres берёт row lock на время оператора, поэтому ровно одна из
+/// конкурирующих задач увидит affected rows = 1, остальные — 0.
+///
+/// Никакого pg_advisory_lock: он живёт в рамках сессии, а нам нужно, чтобы
+/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы
+/// она переживала возврат соединения в пул между шагами задачи.
+///
+public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db)
+{
+ ///
+ /// Попытаться захватить блокировку. true — захватили (и только тогда можно
+ /// провижинить), false — держит кто-то другой либо типа заявок нет.
+ ///
+ /// Блокировка старше staleAfter считается брошенной (процесс упал, не сняв
+ /// флаг) и перехватывается.
+ ///
+ public async Task TryAcquireAsync(int requestTypeId, TimeSpan staleAfter, CancellationToken cancellationToken)
+ {
+ // Имена таблицы/колонки — snake_case из UseSnakeCaseNamingConvention
+ // (см. InfrastructureServiceCollectionExtensions).
+ var affected = await db.Database.ExecuteSqlInterpolatedAsync(
+ $"""
+ UPDATE request_types
+ SET list_provisioning_started_at = now()
+ WHERE id = {requestTypeId}
+ AND (list_provisioning_started_at IS NULL
+ OR list_provisioning_started_at < now() - CAST({staleAfter} AS interval))
+ """,
+ cancellationToken);
+
+ return affected == 1;
+ }
+
+ /// Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки.
+ public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) =>
+ db.Database.ExecuteSqlInterpolatedAsync(
+ $"UPDATE request_types SET list_provisioning_started_at = NULL WHERE id = {requestTypeId}",
+ cancellationToken);
+}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs
new file mode 100644
index 0000000..6e44bf1
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs
@@ -0,0 +1,27 @@
+namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+
+/// Созданный список: серверный относительный url (он же ListId) и Title.
+public sealed record ProvisionedList(string ListId, string Title);
+
+///
+/// Создание следующей пары списков серии в SharePoint («<Форма> <N> — данные»
+/// + «<Форма> <N> — согласование») дублированием текущей пары.
+///
+/// Интерфейс живёт в Infrastructure, а не в Application, потому что Application уже
+/// ссылается на Infrastructure (так же устроен ) —
+/// обратная ссылка замкнула бы проекты в кольцо.
+///
+public interface ISharePointListProvisioner
+{
+ ///
+ /// Создать пару списков с номером , скопировав структуру
+ /// со списка . Возвращает созданный список ДАННЫХ
+ /// (список согласования создаётся тем же вызовом, но наружу не отдаётся — его
+ /// находят по конвенции имени).
+ ///
+ Task CreateNextListPairAsync(
+ string siteUrl,
+ string sourceDataListId,
+ int number,
+ CancellationToken cancellationToken);
+}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs
new file mode 100644
index 0000000..8e3ee3e
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs
@@ -0,0 +1,54 @@
+using System.Text.RegularExpressions;
+
+namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+
+///
+/// Конвенция имён списков заявок: «<Форма> <N> — данные» и парный ему
+/// «<Форма> <N> — согласование».
+///
+/// Третья копия одного и того же соглашения — оно же в провижинере request-portal
+/// (provisioning/Provision-Lists.ps1, Get-NumberedListTitle) и в самом портале
+/// (shared/listTitles.ts). Держать в одном месте не получается: три разных runtime.
+/// Меняя здесь, менять и там.
+///
+/// Номер стоит ПЕРЕД суффиксом, потому что имя списка согласования и портал, и
+/// админка выводят из имени списка данных регексом, привязанным к концу строки.
+///
+internal static class ListTitleConvention
+{
+ private const string DataSuffix = "данные";
+ private const string ApprovalSuffix = "согласование";
+
+ private static readonly Regex TitlePattern = new(
+ @"^(?.+?)\s*—\s*(?данные|согласование)\s*$",
+ RegexOptions.Compiled);
+
+ private static readonly Regex TrailingNumber = new(@"\s+\d+$", RegexOptions.Compiled);
+
+ ///
+ /// Имя того же списка с номером . Уже имеющийся номер
+ /// заменяется, а не дописывается вторым.
+ ///
+ public static string WithNumber(string title, int number)
+ {
+ var match = TitlePattern.Match(title);
+ if (!match.Success)
+ throw new InvalidOperationException(
+ $"Имя списка «{title}» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда.");
+
+ var baseName = TrailingNumber.Replace(match.Groups["base"].Value, string.Empty);
+ return $"{baseName} {number} — {match.Groups["suffix"].Value}";
+ }
+
+ ///
+ /// Имя парного списка согласования по имени списка данных (номер сохраняется).
+ /// null — имя не по конвенции либо это не список данных.
+ ///
+ public static string? ToApprovalTitle(string dataListTitle)
+ {
+ var match = TitlePattern.Match(dataListTitle);
+ if (!match.Success || match.Groups["suffix"].Value != DataSuffix) return null;
+
+ return $"{match.Groups["base"].Value} — {ApprovalSuffix}";
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
index 510a7d0..ce5738f 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
@@ -7,11 +7,45 @@ using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+/// Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список.
+public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount);
+
+/// Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке.
+public sealed record SharePointFieldSchema(string InternalName, string SchemaXml);
+
public interface ISharePointClient
{
+ /// Список по серверному относительному url. null — списка нет.
+ Task GetListByUrlAsync(
+ string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
+
+ /// Список по заголовку. null — списка нет.
+ Task GetListByTitleAsync(
+ string siteUrl, string listTitle, CancellationToken cancellationToken);
+
+ ///
+ /// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные
+ /// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы:
+ /// они появляются в новом списке сами.
+ ///
+ Task> GetCustomFieldsAsync(
+ string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
+
+ /// Создать пустой список (GenericList с версионированием) и вернуть его.
+ Task CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken);
+
+ /// Создать колонку в списке из её SchemaXml.
+ Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken);
+
/// Заголовок списка по его серверному относительному URL (см. RequestType.ListId).
Task GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
+ ///
+ /// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) —
+ /// по нему решается, не пора ли заводить следующий список серии.
+ ///
+ Task GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
+
/// Элементы списка данных, найденного по серверному относительному URL.
Task> GetItemsByListUrlAsync(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
@@ -50,6 +84,105 @@ public sealed class SharePointClient(
return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
}
+ public Task GetListByUrlAsync(
+ string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) =>
+ GetListInfoAsync(
+ siteUrl,
+ $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')",
+ cancellationToken);
+
+ public Task GetListByTitleAsync(
+ string siteUrl, string listTitle, CancellationToken cancellationToken) =>
+ GetListInfoAsync(
+ siteUrl,
+ $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')",
+ cancellationToken);
+
+ private async Task GetListInfoAsync(
+ string siteUrl, string listUrl, CancellationToken cancellationToken)
+ {
+ var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder";
+ var json = await TryGetAsync(siteUrl, url, cancellationToken);
+ if (json is null) return null;
+
+ var value = json.Value;
+ var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder)
+ && rootFolder.TryGetProperty("ServerRelativeUrl", out var relative)
+ ? relative.GetString()
+ : null;
+
+ if (serverRelativeUrl is null) return null;
+
+ return new SharePointListInfo(
+ value.GetProperty("Id").GetGuid(),
+ value.GetProperty("Title").GetString() ?? string.Empty,
+ serverRelativeUrl,
+ value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0);
+ }
+
+ public async Task> GetCustomFieldsAsync(
+ string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
+ {
+ // FromBaseType отсеивает колонки базового типа (Title, Author, Created …) —
+ // они есть в любом новом списке. Hidden/ReadOnlyField — служебные, их создать
+ // всё равно нельзя. Фильтр в OData, а не в C#, чтобы не тащить лишнее по сети.
+ var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" +
+ "?$select=InternalName,SchemaXml,FromBaseType,Hidden,ReadOnlyField" +
+ "&$filter=Hidden eq false and ReadOnlyField eq false and FromBaseType eq false";
+
+ var json = await GetAsync(siteUrl, url, cancellationToken);
+ var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array
+ ? value.EnumerateArray()
+ : json.EnumerateArray();
+
+ var fields = new List();
+ foreach (var row in rows)
+ {
+ var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null;
+ var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null;
+ if (internalName is null || schemaXml is null) continue;
+
+ fields.Add(new SharePointFieldSchema(internalName, schemaXml));
+ }
+
+ return fields;
+ }
+
+ public async Task CreateListAsync(
+ string siteUrl, string title, CancellationToken cancellationToken)
+ {
+ // BaseTemplate 100 — GenericList, как -Template GenericList у провижинера.
+ await PostAsync(
+ siteUrl,
+ $"{siteUrl.TrimEnd('/')}/_api/web/lists",
+ JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }),
+ cancellationToken);
+
+ // Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url —
+ // дочитываем список отдельным запросом.
+ return await GetListByTitleAsync(siteUrl, title, cancellationToken)
+ ?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно.");
+ }
+
+ public Task AddFieldFromXmlAsync(
+ string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) =>
+ PostAsync(
+ siteUrl,
+ $"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml",
+ // Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить
+ // InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается
+ // код портала именно по внутреннему имени, так что это не косметика.
+ JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }),
+ cancellationToken);
+
+ public async Task GetItemCountAsync(
+ string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
+ {
+ var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount";
+ var json = await GetAsync(siteUrl, url, cancellationToken);
+ return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : null;
+ }
+
public Task> GetItemsByListUrlAsync(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
GetItemsAsync(
@@ -79,9 +212,26 @@ public sealed class SharePointClient(
: [];
}
- private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken)
+ private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
+ await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken)
+ ?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}.");
+
+ /// GET, для которого «нет такого списка» — не ошибка, а null.
+ private Task TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
+ SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken);
+
+ private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) =>
+ SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken);
+
+ private async Task SendAsync(
+ HttpMethod method,
+ string siteUrl,
+ string url,
+ string? body,
+ bool allowNotFound,
+ CancellationToken cancellationToken)
{
- using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ using var request = new HttpRequestMessage(method, url);
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
@@ -89,14 +239,30 @@ public sealed class SharePointClient(
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
});
+ if (body is not null)
+ {
+ request.Content = new StringContent(body);
+ // odata=nometadata и на запись: тогда в теле не нужен блок __metadata с
+ // типом сущности — SharePoint выводит тип из самого эндпоинта.
+ request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json")
+ {
+ Parameters = { new NameValueHeaderValue("odata", "nometadata") }
+ };
+ }
+
using var response = await httpClient.SendAsync(request, cancellationToken);
- var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ return null;
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException(
- $"SharePoint ответил {(int)response.StatusCode} на {url}: {body}");
+ $"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}");
- using var document = JsonDocument.Parse(body);
+ if (string.IsNullOrWhiteSpace(responseBody)) return null;
+
+ using var document = JsonDocument.Parse(responseBody);
return document.RootElement.Clone();
}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs
new file mode 100644
index 0000000..e2111fa
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs
@@ -0,0 +1,131 @@
+using Microsoft.Extensions.Logging;
+
+namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+
+///
+/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого
+/// списка (его пользовательские колонки) и повторяет её в новом списке с
+/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются —
+/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что
+/// реально в списке.
+///
+/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки
+/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а
+/// не создаёт второй список.
+///
+public sealed class SharePointListProvisioner(
+ ISharePointClient sharePoint,
+ ILogger logger) : ISharePointListProvisioner
+{
+ public async Task CreateNextListPairAsync(
+ string siteUrl,
+ string sourceDataListId,
+ int number,
+ CancellationToken cancellationToken)
+ {
+ var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken)
+ ?? throw new InvalidOperationException(
+ $"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего.");
+
+ var newData = await CloneAsync(
+ siteUrl,
+ sourceData,
+ ListTitleConvention.WithNumber(sourceData.Title, number),
+ retarget: null,
+ cancellationToken);
+
+ await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken);
+
+ return new ProvisionedList(newData.ServerRelativeUrl, newData.Title);
+ }
+
+ ///
+ /// Дублирует парный список согласования. Его не на что сослаться, кроме имени:
+ /// в БД хранится ссылка только на список данных, а согласование ищется по
+ /// конвенции — так же, как это делают портал и админка.
+ ///
+ private async Task CloneApprovalPairAsync(
+ string siteUrl,
+ SharePointListInfo sourceData,
+ SharePointListInfo newData,
+ int number,
+ CancellationToken cancellationToken)
+ {
+ var sourceApprovalTitle = ListTitleConvention.ToApprovalTitle(sourceData.Title);
+ if (sourceApprovalTitle is null)
+ {
+ logger.LogWarning(
+ "Список «{Title}» не по конвенции «… — данные» — парный список согласования не дублируется.",
+ sourceData.Title);
+ return;
+ }
+
+ var sourceApproval = await sharePoint.GetListByTitleAsync(siteUrl, sourceApprovalTitle, cancellationToken);
+ if (sourceApproval is null)
+ {
+ logger.LogWarning(
+ "Список согласования «{Title}» не найден — дублируется только список данных.",
+ sourceApprovalTitle);
+ return;
+ }
+
+ await CloneAsync(
+ siteUrl,
+ sourceApproval,
+ ListTitleConvention.WithNumber(sourceApproval.Title, number),
+ // Ticket списка согласования смотрит на список данных — в дубле он должен
+ // смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок
+ // будут ссылаться в старый список.
+ retarget: (sourceData.Id, newData.Id),
+ cancellationToken);
+ }
+
+ private async Task CloneAsync(
+ string siteUrl,
+ SharePointListInfo source,
+ string newTitle,
+ (Guid From, Guid To)? retarget,
+ CancellationToken cancellationToken)
+ {
+ var existing = await sharePoint.GetListByTitleAsync(siteUrl, newTitle, cancellationToken);
+ var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken);
+
+ logger.LogInformation(
+ existing is null ? "Создан список «{Title}»." : "Список «{Title}» уже существует — дозаполняю колонки.",
+ newTitle);
+
+ var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken);
+ var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken);
+ var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var field in sourceFields)
+ {
+ if (alreadyThere.Contains(field.InternalName)) continue;
+
+ var schemaXml = retarget is null
+ ? field.SchemaXml
+ : RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To);
+
+ await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken);
+ logger.LogInformation(" колонка {InternalName} → «{Title}»", field.InternalName, newTitle);
+ }
+
+ return target;
+ }
+
+ ///
+ /// Перенацеливает Lookup на новый список данных.
+ ///
+ /// Целевой список Lookup хранит в SchemaXml GUID'ом (атрибут List), поэтому
+ /// подменяем именно GUID. Подменяем ТОЛЬКО ссылки на исходный список данных:
+ /// кросс-формные lookup'ы (например, «организация рабочего места» ссылается на
+ /// список «приема на работу») должны и дальше смотреть на список ЧУЖОЙ формы.
+ ///
+ private static string RetargetLookup(string schemaXml, Guid from, Guid to)
+ {
+ var fromText = from.ToString("D");
+ return schemaXml.Contains(fromText, StringComparison.OrdinalIgnoreCase)
+ ? schemaXml.Replace(fromText, to.ToString("D"), StringComparison.OrdinalIgnoreCase)
+ : schemaXml;
+ }
+}
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs
new file mode 100644
index 0000000..490e77c
--- /dev/null
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs
@@ -0,0 +1,120 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Tnsu.ApprovalRoute.Domain.Entities;
+using Tnsu.ApprovalRoute.Infrastructure.Persistence;
+using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
+
+namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
+
+///
+/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать
+/// по списку каждая, поэтому проверяется именно на живом Postgres: смысл здесь несёт
+/// атомарность условного UPDATE, а не код на C#.
+///
+public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fixture)
+ : IClassFixture
+{
+ private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
+
+ private readonly ServiceProvider _provider = TestServiceProviderFactory.Build(fixture.ConnectionString);
+
+ [Fact(DisplayName = "Повторный захват занятой блокировки не удаётся")]
+ public async Task TryAcquire_WhenAlreadyHeld_ReturnsFalse()
+ {
+ // Arrange
+ var requestTypeId = await CreateRequestTypeAsync();
+
+ // Action
+ var first = await TryAcquireAsync(requestTypeId);
+ var second = await TryAcquireAsync(requestTypeId);
+
+ // Assert
+ first.ShouldBeTrue();
+ second.ShouldBeFalse();
+ }
+
+ [Fact(DisplayName = "При одновременном захвате блокировку получает ровно одна задача")]
+ public async Task TryAcquire_Concurrently_OnlyOneWins()
+ {
+ // Arrange
+ var requestTypeId = await CreateRequestTypeAsync();
+
+ // Action — каждый захват из своего scope, то есть своим соединением к БД:
+ // именно так это и происходит у параллельных задач Hangfire.
+ var results = await Task.WhenAll(
+ Enumerable.Range(0, 8).Select(_ => TryAcquireAsync(requestTypeId)));
+
+ // Assert
+ results.Count(x => x).ShouldBe(1);
+ }
+
+ [Fact(DisplayName = "После снятия блокировку можно захватить снова")]
+ public async Task TryAcquire_AfterRelease_Succeeds()
+ {
+ // Arrange
+ var requestTypeId = await CreateRequestTypeAsync();
+ (await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
+
+ // Action
+ using (var scope = _provider.CreateScope())
+ {
+ await scope.ServiceProvider.GetRequiredService()
+ .ReleaseAsync(requestTypeId, CancellationToken.None);
+ }
+
+ // Assert
+ (await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
+ }
+
+ [Fact(DisplayName = "Просроченная блокировка перехватывается, свежая — нет")]
+ public async Task TryAcquire_StaleLock_IsTakenOver()
+ {
+ // Arrange
+ var requestTypeId = await CreateRequestTypeAsync();
+ (await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
+
+ // Свежую блокировку с тем же порогом перехватить нельзя.
+ (await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeFalse();
+
+ // Action — отодвигаем метку в прошлое: так выглядит блокировка, которую
+ // оставил упавший, не снявший её процесс.
+ using (var scope = _provider.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.ExecuteSqlInterpolatedAsync(
+ $"UPDATE request_types SET list_provisioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}");
+ }
+
+ // Assert
+ (await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeTrue();
+ }
+
+ [Fact(DisplayName = "Захват блокировки для несуществующего типа заявки не удаётся")]
+ public async Task TryAcquire_UnknownRequestType_ReturnsFalse() =>
+ (await TryAcquireAsync(999999)).ShouldBeFalse();
+
+ private async Task TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null)
+ {
+ using var scope = _provider.CreateScope();
+ return await scope.ServiceProvider.GetRequiredService()
+ .TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None);
+ }
+
+ private async Task CreateRequestTypeAsync()
+ {
+ using var scope = _provider.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var entity = new RequestType
+ {
+ Name = $"Тип {Guid.NewGuid():N}",
+ PageUrl = "/test",
+ Section = "General"
+ };
+ db.RequestTypes.Add(entity);
+ await db.SaveChangesAsync();
+
+ return entity.Id;
+ }
+}