diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs
index a3e35f4..c3a4711 100644
--- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -83,15 +84,34 @@ public sealed class CreateNextListPartitionJob(
var nextNumber = maxNumber + 1;
logger.LogInformation(
- "Создаю партицию №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.",
- nextNumber, requestTypeId, requestType.ListId);
+ "Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
+ "по структуре {SourceListId}.",
+ nextNumber, requestTypeId, requestType.Name, requestType.ListId);
- // Партиция всегда создаётся на том же сайте, что и текущая (см. комментарий
- // на RequestListPartition о недопустимости разъезда серии по сайтам) — сайт
- // берём из RequestType, а не откуда-либо ещё, так что расхождение
- // структурно невозможно.
- var created = await provisioner.CreateNextListPairAsync(
- requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
+ var stopwatch = Stopwatch.StartNew();
+ ProvisionedList created;
+ try
+ {
+ // Партиция всегда создаётся на том же сайте, что и текущая (см.
+ // комментарий на RequestListPartition о недопустимости разъезда серии
+ // по сайтам) — сайт берём из RequestType, а не откуда-либо ещё, так что
+ // расхождение структурно невозможно.
+ created = await provisioner.CreateNextListPairAsync(
+ requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ // Логируем с контекстом (номер партиции, тип заявок) здесь, а не
+ // полагаемся только на то, что исключение долетит до лога Hangfire:
+ // там оно будет без этих подробностей, вперемешку с логами других задач.
+ // Дальше даём ему пойти в Hangfire как обычно — так сработает
+ // AutomaticRetry(Attempts = 3).
+ logger.LogError(
+ ex,
+ "Провижининг партиции №{Number} для типа заявок {RequestTypeId} не удался за {ElapsedMs} мс.",
+ nextNumber, requestTypeId, stopwatch.ElapsedMilliseconds);
+ throw;
+ }
// Реестр и переключение записи — одной транзакцией: иначе можно остаться
// с записанной в реестр партицией, в которую никто не пишет, или наоборот.
@@ -112,8 +132,9 @@ public sealed class CreateNextListPartitionJob(
await transaction.CommitAsync(cancellationToken);
logger.LogInformation(
- "Партиция №{Number} ({ListId}) создана, запись по типу заявок {RequestTypeId} переключена на неё.",
- nextNumber, created.ListId, requestTypeId);
+ "Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
+ "{RequestTypeId} переключена на неё.",
+ nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId);
}
finally
{
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs
index e2111fa..9021951 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
@@ -23,6 +24,11 @@ public sealed class SharePointListProvisioner(
int number,
CancellationToken cancellationToken)
{
+ logger.LogInformation(
+ "Дублирую пару списков №{Number} на {SiteUrl} по образцу {SourceListId}.",
+ number, siteUrl, sourceDataListId);
+ var stopwatch = Stopwatch.StartNew();
+
var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken)
?? throw new InvalidOperationException(
$"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего.");
@@ -31,11 +37,16 @@ public sealed class SharePointListProvisioner(
siteUrl,
sourceData,
ListTitleConvention.WithNumber(sourceData.Title, number),
+ role: "данные",
retarget: null,
cancellationToken);
await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken);
+ logger.LogInformation(
+ "Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).",
+ number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl);
+
return new ProvisionedList(newData.ServerRelativeUrl, newData.Title);
}
@@ -73,6 +84,7 @@ public sealed class SharePointListProvisioner(
siteUrl,
sourceApproval,
ListTitleConvention.WithNumber(sourceApproval.Title, number),
+ role: "согласование",
// Ticket списка согласования смотрит на список данных — в дубле он должен
// смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок
// будут ссылаться в старый список.
@@ -80,10 +92,15 @@ public sealed class SharePointListProvisioner(
cancellationToken);
}
+ ///
+ /// role — только для логов («данные»/«согласование»): по нему в логе видно, какую
+ /// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов.
+ ///
private async Task CloneAsync(
string siteUrl,
SharePointListInfo source,
string newTitle,
+ string role,
(Guid From, Guid To)? retarget,
CancellationToken cancellationToken)
{
@@ -91,13 +108,16 @@ public sealed class SharePointListProvisioner(
var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken);
logger.LogInformation(
- existing is null ? "Создан список «{Title}»." : "Список «{Title}» уже существует — дозаполняю колонки.",
- newTitle);
+ existing is null
+ ? "Список {Role} «{Title}» создан."
+ : "Список {Role} «{Title}» уже существует — дозаполняю недостающие колонки.",
+ role, newTitle);
var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken);
var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken);
var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var copied = 0;
foreach (var field in sourceFields)
{
if (alreadyThere.Contains(field.InternalName)) continue;
@@ -107,9 +127,19 @@ public sealed class SharePointListProvisioner(
: RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To);
await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken);
- logger.LogInformation(" колонка {InternalName} → «{Title}»", field.InternalName, newTitle);
+ copied++;
+
+ // По каждой колонке — только на Debug: при штатной первой партии их
+ // десятки на список, и на Information это был бы шум. Итог всё равно
+ // виден в сводке ниже.
+ logger.LogDebug(
+ "Список {Role} «{Title}»: скопирована колонка {InternalName}.", role, newTitle, field.InternalName);
}
+ logger.LogInformation(
+ "Список {Role} «{Title}»: скопировано колонок {Copied}, уже было {AlreadyThere}.",
+ role, newTitle, copied, sourceFields.Count - copied);
+
return target;
}