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.ListPartitioning; /// /// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему /// эндпоинту, и фоновой задаче. /// /// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали /// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда /// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync). /// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId, /// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция /// реально стала архивом. /// public sealed class ListPartitionRegistry( ApprovalRouteDbContext db, ISharePointClient sharePoint, ILogger logger) { /// /// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей. /// /// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление /// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая /// партиция уже успела записаться архивом, а переключение указателя на новую ещё /// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы /// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см. /// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом /// переходном состоянии. /// public async Task> GetArchivedAsync( RequestType requestType, CancellationToken cancellationToken) => await db.RequestListPartitions.AsNoTracking() .Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId) .OrderBy(x => x.Number) .ToListAsync(cancellationToken); /// /// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions /// на запись: строка появится там только когда эта партиция реально станет /// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным /// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не /// кешируется — кешировать было бы негде без строки в БД. /// /// archived — уже загруженный список архивных партиций этого типа (см. /// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая /// сторона (которой archived обычно уже нужен для остальной серии) не делала /// один и тот же запрос дважды. /// public async Task BuildCurrentAsync( RequestType requestType, IReadOnlyList archived, CancellationToken cancellationToken) { var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1; var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken); return new RequestListPartitionDto( number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow); } /// /// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент, /// когда она перестаёт быть текущей (единственный вызывающий — /// CreateNextListPartitionJob, прямо перед тем как переключить /// RequestType.ListId на следующую партицию). /// /// Читает свой собственный, НЕфильтрованный список существующих партиций — /// намеренно не переиспользует GetArchivedAsync(): та фильтрует по /// ListId != RequestType.ListId, и ровно между этим вызовом и переключением /// указателя (см. CreateNextListPartitionJob) только что записанная строка /// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не /// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на /// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже /// записанное и вернуть его. /// public async Task ArchiveCurrentAsync( RequestType requestType, CancellationToken cancellationToken) { var series = await db.RequestListPartitions .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 RequestListPartition { RequestTypeId = requestType.Id, Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1, ListId = requestType.ListId!, Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken), CreatedAt = DateTimeOffset.UtcNow }; db.RequestListPartitions.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; } } }