using System.Diagnostics; using Microsoft.Extensions.Logging; using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; /// /// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого /// списка (его пользовательские колонки) и повторяет её в новом списке с /// номером N+1. Манифесты provisioning.json request-portal при этом не читаются — /// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что /// реально в списке. /// /// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки /// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а /// не создаёт второй список. /// public sealed class SharePointListProvisioner( ISharePointClient sharePoint, ISharePointWebhookSubscriber webhookSubscriber, ILogger logger) : ISharePointListProvisioner { public async Task 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); } /// /// Дублирует парный список согласования. Его не на что сослаться, кроме имени: /// в БД хранится ссылка только на список данных, а согласование ищется по /// конвенции — так же, как это делают портал и админка. /// 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); } /// /// role — только для логов («данные»/«согласование»): по нему в логе видно, какую /// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов. /// private async Task 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; } /// /// Перенацеливает 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; } }