Архивация списков (#13)
Tests / frontend-test (push) Successful in 44s
Tests / backend-test (push) Failing after 17m4s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-19 09:08:36 +00:00
parent a2672042d7
commit 82512eb1db
56 changed files with 2587 additions and 51 deletions
@@ -0,0 +1,174 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
/// <summary>
/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого
/// списка (его пользовательские колонки) и повторяет её в новом списке с
/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются —
/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что
/// реально в списке.
///
/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки
/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а
/// не создаёт второй список.
/// </summary>
public sealed class SharePointListProvisioner(
ISharePointClient sharePoint,
ISharePointWebhookSubscriber webhookSubscriber,
ILogger<SharePointListProvisioner> logger) : ISharePointListProvisioner
{
public async Task<ProvisionedList> CreateNextListPairAsync(
string siteUrl,
string sourceDataListId,
int number,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Дублирую пару списков №{Number} на {SiteUrl} по образцу {SourceListId}.",
number, siteUrl, sourceDataListId);
var stopwatch = Stopwatch.StartNew();
var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken)
?? throw new InvalidOperationException(
$"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего.");
var newData = await CloneAsync(
siteUrl,
sourceData,
ListTitleConvention.WithNumber(sourceData.Title, number),
role: "данные",
retarget: null,
cancellationToken);
await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken);
logger.LogInformation(
"Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).",
number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl);
// Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы:
// • подписывать нечего, пока списка нет;
// • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку
// согласования подписка не нужна;
// • в самом конце, а не сразу после создания списка данных: сорвись
// дублирование пары на списке согласования, подписка была бы уже
// заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же
// подписку (идемпотентна ли ручка портала — неизвестно).
// Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber).
await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, cancellationToken);
return new ProvisionedList(newData.ServerRelativeUrl, newData.Title);
}
/// <summary>
/// Дублирует парный список согласования. Его не на что сослаться, кроме имени:
/// в БД хранится ссылка только на список данных, а согласование ищется по
/// конвенции — так же, как это делают портал и админка.
/// </summary>
private async Task CloneApprovalPairAsync(
string siteUrl,
SharePointListInfo sourceData,
SharePointListInfo newData,
int number,
CancellationToken cancellationToken)
{
var sourceApprovalTitle = ListTitleConvention.ToApprovalTitle(sourceData.Title);
if (sourceApprovalTitle is null)
{
logger.LogWarning(
"Список «{Title}» не по конвенции «… — данные» — парный список согласования не дублируется.",
sourceData.Title);
return;
}
var sourceApproval = await sharePoint.GetListByTitleAsync(siteUrl, sourceApprovalTitle, cancellationToken);
if (sourceApproval is null)
{
logger.LogWarning(
"Список согласования «{Title}» не найден — дублируется только список данных.",
sourceApprovalTitle);
return;
}
await CloneAsync(
siteUrl,
sourceApproval,
ListTitleConvention.WithNumber(sourceApproval.Title, number),
role: "согласование",
// Ticket списка согласования смотрит на список данных — в дубле он должен
// смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок
// будут ссылаться в старый список.
retarget: (sourceData.Id, newData.Id),
cancellationToken);
}
/// <summary>
/// role — только для логов («данные»/«согласование»): по нему в логе видно, какую
/// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов.
/// </summary>
private async Task<SharePointListInfo> CloneAsync(
string siteUrl,
SharePointListInfo source,
string newTitle,
string role,
(Guid From, Guid To)? retarget,
CancellationToken cancellationToken)
{
var existing = await sharePoint.GetListByTitleAsync(siteUrl, newTitle, cancellationToken);
var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken);
logger.LogInformation(
existing is null
? "Список {Role} «{Title}» создан."
: "Список {Role} «{Title}» уже существует — дозаполняю недостающие колонки.",
role, newTitle);
var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken);
var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken);
var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase);
var copied = 0;
foreach (var field in sourceFields)
{
if (alreadyThere.Contains(field.InternalName)) continue;
var schemaXml = retarget is null
? field.SchemaXml
: RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To);
await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken);
copied++;
// По каждой колонке — только на Debug: при штатной первой партии их
// десятки на список, и на Information это был бы шум. Итог всё равно
// виден в сводке ниже.
logger.LogDebug(
"Список {Role} «{Title}»: скопирована колонка {InternalName}.", role, newTitle, field.InternalName);
}
logger.LogInformation(
"Список {Role} «{Title}»: скопировано колонок {Copied}, уже было {AlreadyThere}.",
role, newTitle, copied, sourceFields.Count - copied);
return target;
}
/// <summary>
/// Перенацеливает Lookup на новый список данных.
///
/// Целевой список Lookup хранит в SchemaXml GUID'ом (атрибут List), поэтому
/// подменяем именно GUID. Подменяем ТОЛЬКО ссылки на исходный список данных:
/// кросс-формные lookup'ы (например, «организация рабочего места» ссылается на
/// список «приема на работу») должны и дальше смотреть на список ЧУЖОЙ формы.
/// </summary>
private static string RetargetLookup(string schemaXml, Guid from, Guid to)
{
var fromText = from.ToString("D");
return schemaXml.Contains(fromText, StringComparison.OrdinalIgnoreCase)
? schemaXml.Replace(fromText, to.ToString("D"), StringComparison.OrdinalIgnoreCase)
: schemaXml;
}
}