175 lines
9.4 KiB
C#
175 lines
9.4 KiB
C#
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;
|
|
}
|
|
}
|