< Summary

Information
Class: Services.LabelServices.LabelCatalogCache
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/LabelCatalogService.cs
Line coverage
100%
Covered lines: 7
Uncovered lines: 0
Coverable lines: 7
Total lines: 254
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
TryGet(...)100%11100%
Set(...)100%11100%
Invalidate()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/LabelCatalogService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Caching.Memory;
 3using OzonDomains;
 4using OzonDomains.Models;
 5using OzonRepositories.Context;
 6using Services.FileStorageServices;
 7
 8namespace Services.LabelServices;
 9
 10/// <summary>Строка каталога сохранённых PDF-этикеток для страницы Labels.</summary>
 11public sealed class LabelCatalogRow
 12{
 13    public long Id { get; init; }
 14    public string FileName { get; init; } = string.Empty;
 15    public string ShipmentNumber { get; init; } = string.Empty;
 16    public long SizeBytes { get; init; }
 17    public DateTimeOffset ModifiedAtUtc { get; init; }
 18    public DateTimeOffset PrintedAtUtc { get; init; }
 19    public bool UsesFileDate { get; init; }
 20    public string? PrintedBy { get; init; }
 21    public string PrintSource { get; init; } = string.Empty;
 22    public int? OrderId { get; init; }
 23    public IReadOnlyList<int> RelatedOrderIds { get; init; } = [];
 24    public string? Scanit { get; init; }
 25    public string? Client { get; init; }
 26    public string? Marketplace { get; init; }
 27    public bool HasOrder { get; init; }
 28    public int RelatedOrderCount { get; init; }
 29
 30    /// <summary>Строковое поле для быстрого поиска ID заказа без клиентской конвертации.</summary>
 31    public string OrderIdText => OrderId?.ToString() ?? string.Empty;
 32}
 33
 34public sealed record LabelCatalogSnapshot(DateTimeOffset RefreshedAtUtc, IReadOnlyList<LabelCatalogRow> Rows);
 35
 36/// <summary>Общий кэш каталога; хранилище инвалидирует его после изменения PDF.</summary>
 37public interface ILabelCatalogCache
 38{
 39    bool TryGet(out LabelCatalogSnapshot? snapshot);
 40    void Set(LabelCatalogSnapshot snapshot);
 41    void Invalidate();
 42}
 43
 44public sealed class LabelCatalogCache : ILabelCatalogCache
 45{
 46    private const string CacheKey = "labels_catalog_v1";
 147    private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(10);
 48    private readonly IMemoryCache _cache;
 49
 250    public LabelCatalogCache(IMemoryCache cache)
 51    {
 252        _cache = cache;
 253    }
 54
 55    public bool TryGet(out LabelCatalogSnapshot? snapshot) =>
 356        _cache.TryGetValue(CacheKey, out snapshot);
 57
 58    public void Set(LabelCatalogSnapshot snapshot) =>
 259        _cache.Set(CacheKey, snapshot, Expiration);
 60
 261    public void Invalidate() => _cache.Remove(CacheKey);
 62}
 63
 64public interface ILabelCatalogService
 65{
 66    Task<LabelCatalogSnapshot> GetSnapshotAsync(bool forceRefresh = false, CancellationToken cancellationToken = default
 67    Task<LabelCatalogSnapshot> RefreshAsync(CancellationToken cancellationToken = default);
 68}
 69
 70/// <summary>
 71/// Сводит записи файлового хранилища и минимальные данные заказов в один кэшируемый каталог.
 72/// PDF не имеет собственной таблицы, поэтому связь вычисляется по тем же вариантам номера,
 73/// что применяются при повторном открытии этикетки split-отправления.
 74/// </summary>
 75public sealed class LabelCatalogService : ILabelCatalogService
 76{
 77    private static readonly SemaphoreSlim RefreshLock = new(1, 1);
 78
 79    private readonly ILabelFileStorage _labelFileStorage;
 80    private readonly ApplicationDbContext _context;
 81    private readonly ILabelCatalogCache _cache;
 82    private readonly ILabelPrintLogService _labelPrintLogService;
 83
 84    public LabelCatalogService(
 85        ILabelFileStorage labelFileStorage,
 86        ApplicationDbContext context,
 87        ILabelCatalogCache cache,
 88        ILabelPrintLogService labelPrintLogService)
 89    {
 90        _labelFileStorage = labelFileStorage;
 91        _context = context;
 92        _cache = cache;
 93        _labelPrintLogService = labelPrintLogService;
 94    }
 95
 96    public Task<LabelCatalogSnapshot> GetSnapshotAsync(bool forceRefresh = false, CancellationToken cancellationToken = 
 97        forceRefresh ? RefreshAsync(cancellationToken) : GetOrCreateAsync(cancellationToken);
 98
 99    public async Task<LabelCatalogSnapshot> RefreshAsync(CancellationToken cancellationToken = default)
 100    {
 101        _cache.Invalidate();
 102        return await GetOrCreateAsync(cancellationToken).ConfigureAwait(false);
 103    }
 104
 105    private async Task<LabelCatalogSnapshot> GetOrCreateAsync(CancellationToken cancellationToken)
 106    {
 107        if (_cache.TryGet(out var cached) && cached is not null)
 108        {
 109            return cached;
 110        }
 111
 112        await RefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 113        try
 114        {
 115            if (_cache.TryGet(out cached) && cached is not null)
 116            {
 117                return cached;
 118            }
 119
 120            var files = await _labelFileStorage.ListAsync(cancellationToken).ConfigureAwait(false);
 121            var orders = await _context.Orders.AsNoTracking()
 122                .Where(x => x.ShipmentNumber != null && x.ShipmentNumber != string.Empty)
 123                .Select(x => new CatalogOrder(
 124                    x.Id,
 125                    x.ShipmentNumber!,
 126                    x.Scanit,
 127                    x.OzonClient == null ? null : x.OzonClient.Name,
 128                    x.OzonClient == null ? null : (ClientType?)x.OzonClient.ClientType))
 129                .ToListAsync(cancellationToken)
 130                .ConfigureAwait(false);
 131
 132            var latestLogs = await _labelPrintLogService.GetLatestByPdfAsync(cancellationToken).ConfigureAwait(false);
 133
 134            var snapshot = new LabelCatalogSnapshot(DateTimeOffset.UtcNow, BuildRows(files, orders, latestLogs));
 135            _cache.Set(snapshot);
 136            return snapshot;
 137        }
 138        finally
 139        {
 140            RefreshLock.Release();
 141        }
 142    }
 143
 144    internal static IReadOnlyList<LabelCatalogRow> BuildRows(
 145        IEnumerable<ServerFileInfo> files,
 146        IEnumerable<CatalogOrder> orders,
 147        IEnumerable<LabelPrintLog>? latestLogs = null)
 148    {
 149        var pdfFiles = files
 150            .Where(x => !x.IsDirectory && x.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
 151            // Хранилище может кратковременно вернуть один файл дважды. По имени файл
 152            // открывается из корня каталога, поэтому в грид должна попадать одна строка.
 153            .GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 154            .Select(x => x.OrderByDescending(file => file.LastWriteTimeUtc)
 155                .ThenBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase)
 156                .First())
 157            .OrderByDescending(x => x.LastWriteTimeUtc)
 158            .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 159            .ToList();
 160        var byFileName = pdfFiles.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
 161        var ordersById = orders.ToDictionary(x => x.Id);
 162        var logsByFileName = (latestLogs ?? [])
 163            .GroupBy(x => x.FileName, StringComparer.OrdinalIgnoreCase)
 164            .ToDictionary(
 165                x => x.Key,
 166                x => x.OrderByDescending(log => log.OccurredAtUtc).ThenByDescending(log => log.Id).First(),
 167                StringComparer.OrdinalIgnoreCase);
 168        var related = new Dictionary<string, List<CatalogOrder>>(StringComparer.OrdinalIgnoreCase);
 169
 170        foreach (var order in orders)
 171        {
 172            foreach (var candidate in OzonPostingNumberHelper.GetLabelFileNameCandidates(order.ShipmentNumber)
 173                         .Distinct(StringComparer.OrdinalIgnoreCase))
 174            {
 175                if (!byFileName.ContainsKey(candidate))
 176                {
 177                    continue;
 178                }
 179
 180                if (!related.TryGetValue(candidate, out var list))
 181                {
 182                    list = [];
 183                    related[candidate] = list;
 184                }
 185
 186                list.Add(order);
 187            }
 188        }
 189
 190        return pdfFiles.Select((file, index) =>
 191        {
 192            related.TryGetValue(file.Name, out var linked);
 193            linked ??= [];
 194            logsByFileName.TryGetValue(file.Name, out var lastLog);
 195            var fileStem = Path.GetFileNameWithoutExtension(file.Name);
 196            var primary = linked
 197                .OrderByDescending(x => string.Equals(x.ShipmentNumber, fileStem, StringComparison.OrdinalIgnoreCase))
 198                .ThenBy(x => x.Id)
 199                .FirstOrDefault();
 200            if (primary is null && lastLog?.OrderId is { } loggedOrderId)
 201            {
 202                ordersById.TryGetValue(loggedOrderId, out primary);
 203            }
 204            var relatedOrderIds = linked.Select(x => x.Id)
 205                .Concat(lastLog?.OrderId is { } loggedId ? [loggedId] : [])
 206                .Distinct()
 207                .OrderBy(x => x)
 208                .ToList();
 209            var scanits = linked.Select(x => x.Scanit)
 210                .Where(x => !string.IsNullOrWhiteSpace(x))
 211                .Distinct(StringComparer.OrdinalIgnoreCase);
 212            var clients = linked.Select(x => x.Client)
 213                .Where(x => !string.IsNullOrWhiteSpace(x))
 214                .Distinct(StringComparer.OrdinalIgnoreCase);
 215            var marketplaces = linked.Select(x => x.ClientType switch
 216                {
 217                    ClientType.YANDEX => "Яндекс",
 218                    ClientType.OZON => "Ozon",
 219                    _ => string.Empty
 220                })
 221                .Where(x => !string.IsNullOrEmpty(x))
 222                .Distinct(StringComparer.Ordinal);
 223
 224            return new LabelCatalogRow
 225            {
 226                Id = index + 1L,
 227                FileName = file.Name,
 228                ShipmentNumber = primary?.ShipmentNumber ?? fileStem,
 229                SizeBytes = file.Length,
 230                ModifiedAtUtc = file.LastWriteTimeUtc,
 231                PrintedAtUtc = lastLog is null
 232                    ? file.LastWriteTimeUtc
 233                    : new DateTimeOffset(DateTime.SpecifyKind(lastLog.OccurredAtUtc, DateTimeKind.Utc)),
 234                UsesFileDate = lastLog is null,
 235                PrintedBy = lastLog?.ActorName,
 236                PrintSource = lastLog?.Source switch
 237                {
 238                    LabelPrintSource.Studio2 => "Studio2",
 239                    LabelPrintSource.PartnerApi => "Partner API",
 240                    _ => "Дата файла"
 241                },
 242                OrderId = primary?.Id,
 243                Scanit = string.Join(" · ", scanits),
 244                Client = string.Join(" · ", clients),
 245                Marketplace = string.Join(" · ", marketplaces),
 246                HasOrder = primary is not null,
 247                RelatedOrderCount = relatedOrderIds.Count,
 248                RelatedOrderIds = relatedOrderIds
 249            };
 250        }).ToList();
 251    }
 252
 253    internal sealed record CatalogOrder(int Id, string ShipmentNumber, string? Scanit, string? Client, ClientType? Clien
 254}