< Summary

Information
Class: Services.LabelServices.LabelCatalogService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/LabelCatalogService.cs
Line coverage
67%
Covered lines: 86
Uncovered lines: 41
Coverable lines: 127
Total lines: 254
Line coverage: 67.7%
Branch coverage
68%
Covered branches: 41
Total branches: 60
Branch coverage: 68.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
GetSnapshotAsync(...)0%620%
RefreshAsync()100%210%
GetOrCreateAsync()0%110100%
BuildRows(...)85.41%484895.5%
get_Id()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";
 47    private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(10);
 48    private readonly IMemoryCache _cache;
 49
 50    public LabelCatalogCache(IMemoryCache cache)
 51    {
 52        _cache = cache;
 53    }
 54
 55    public bool TryGet(out LabelCatalogSnapshot? snapshot) =>
 56        _cache.TryGetValue(CacheKey, out snapshot);
 57
 58    public void Set(LabelCatalogSnapshot snapshot) =>
 59        _cache.Set(CacheKey, snapshot, Expiration);
 60
 61    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{
 077    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
 084    public LabelCatalogService(
 085        ILabelFileStorage labelFileStorage,
 086        ApplicationDbContext context,
 087        ILabelCatalogCache cache,
 088        ILabelPrintLogService labelPrintLogService)
 89    {
 090        _labelFileStorage = labelFileStorage;
 091        _context = context;
 092        _cache = cache;
 093        _labelPrintLogService = labelPrintLogService;
 094    }
 95
 96    public Task<LabelCatalogSnapshot> GetSnapshotAsync(bool forceRefresh = false, CancellationToken cancellationToken = 
 097        forceRefresh ? RefreshAsync(cancellationToken) : GetOrCreateAsync(cancellationToken);
 98
 99    public async Task<LabelCatalogSnapshot> RefreshAsync(CancellationToken cancellationToken = default)
 100    {
 0101        _cache.Invalidate();
 0102        return await GetOrCreateAsync(cancellationToken).ConfigureAwait(false);
 0103    }
 104
 105    private async Task<LabelCatalogSnapshot> GetOrCreateAsync(CancellationToken cancellationToken)
 106    {
 0107        if (_cache.TryGet(out var cached) && cached is not null)
 108        {
 0109            return cached;
 110        }
 111
 0112        await RefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 113        try
 114        {
 0115            if (_cache.TryGet(out cached) && cached is not null)
 116            {
 0117                return cached;
 118            }
 119
 0120            var files = await _labelFileStorage.ListAsync(cancellationToken).ConfigureAwait(false);
 0121            var orders = await _context.Orders.AsNoTracking()
 0122                .Where(x => x.ShipmentNumber != null && x.ShipmentNumber != string.Empty)
 0123                .Select(x => new CatalogOrder(
 0124                    x.Id,
 0125                    x.ShipmentNumber!,
 0126                    x.Scanit,
 0127                    x.OzonClient == null ? null : x.OzonClient.Name,
 0128                    x.OzonClient == null ? null : (ClientType?)x.OzonClient.ClientType))
 0129                .ToListAsync(cancellationToken)
 0130                .ConfigureAwait(false);
 131
 0132            var latestLogs = await _labelPrintLogService.GetLatestByPdfAsync(cancellationToken).ConfigureAwait(false);
 133
 0134            var snapshot = new LabelCatalogSnapshot(DateTimeOffset.UtcNow, BuildRows(files, orders, latestLogs));
 0135            _cache.Set(snapshot);
 0136            return snapshot;
 137        }
 138        finally
 139        {
 0140            RefreshLock.Release();
 141        }
 0142    }
 143
 144    internal static IReadOnlyList<LabelCatalogRow> BuildRows(
 145        IEnumerable<ServerFileInfo> files,
 146        IEnumerable<CatalogOrder> orders,
 147        IEnumerable<LabelPrintLog>? latestLogs = null)
 148    {
 4149        var pdfFiles = files
 7150            .Where(x => !x.IsDirectory && x.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
 4151            // Хранилище может кратковременно вернуть один файл дважды. По имени файл
 4152            // открывается из корня каталога, поэтому в грид должна попадать одна строка.
 5153            .GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 9154            .Select(x => x.OrderByDescending(file => file.LastWriteTimeUtc)
 5155                .ThenBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase)
 4156                .First())
 4157            .OrderByDescending(x => x.LastWriteTimeUtc)
 4158            .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 4159            .ToList();
 8160        var byFileName = pdfFiles.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
 7161        var ordersById = orders.ToDictionary(x => x.Id);
 4162        var logsByFileName = (latestLogs ?? [])
 1163            .GroupBy(x => x.FileName, StringComparer.OrdinalIgnoreCase)
 4164            .ToDictionary(
 1165                x => x.Key,
 3166                x => x.OrderByDescending(log => log.OccurredAtUtc).ThenByDescending(log => log.Id).First(),
 4167                StringComparer.OrdinalIgnoreCase);
 4168        var related = new Dictionary<string, List<CatalogOrder>>(StringComparer.OrdinalIgnoreCase);
 169
 14170        foreach (var order in orders)
 171        {
 14172            foreach (var candidate in OzonPostingNumberHelper.GetLabelFileNameCandidates(order.ShipmentNumber)
 3173                         .Distinct(StringComparer.OrdinalIgnoreCase))
 174            {
 4175                if (!byFileName.ContainsKey(candidate))
 176                {
 177                    continue;
 178                }
 179
 3180                if (!related.TryGetValue(candidate, out var list))
 181                {
 2182                    list = [];
 2183                    related[candidate] = list;
 184                }
 185
 3186                list.Add(order);
 187            }
 188        }
 189
 4190        return pdfFiles.Select((file, index) =>
 4191        {
 4192            related.TryGetValue(file.Name, out var linked);
 4193            linked ??= [];
 4194            logsByFileName.TryGetValue(file.Name, out var lastLog);
 4195            var fileStem = Path.GetFileNameWithoutExtension(file.Name);
 4196            var primary = linked
 3197                .OrderByDescending(x => string.Equals(x.ShipmentNumber, fileStem, StringComparison.OrdinalIgnoreCase))
 3198                .ThenBy(x => x.Id)
 4199                .FirstOrDefault();
 4200            if (primary is null && lastLog?.OrderId is { } loggedOrderId)
 4201            {
 0202                ordersById.TryGetValue(loggedOrderId, out primary);
 4203            }
 7204            var relatedOrderIds = linked.Select(x => x.Id)
 4205                .Concat(lastLog?.OrderId is { } loggedId ? [loggedId] : [])
 4206                .Distinct()
 3207                .OrderBy(x => x)
 4208                .ToList();
 7209            var scanits = linked.Select(x => x.Scanit)
 3210                .Where(x => !string.IsNullOrWhiteSpace(x))
 4211                .Distinct(StringComparer.OrdinalIgnoreCase);
 7212            var clients = linked.Select(x => x.Client)
 3213                .Where(x => !string.IsNullOrWhiteSpace(x))
 4214                .Distinct(StringComparer.OrdinalIgnoreCase);
 7215            var marketplaces = linked.Select(x => x.ClientType switch
 7216                {
 0217                    ClientType.YANDEX => "Яндекс",
 3218                    ClientType.OZON => "Ozon",
 0219                    _ => string.Empty
 7220                })
 3221                .Where(x => !string.IsNullOrEmpty(x))
 4222                .Distinct(StringComparer.Ordinal);
 4223
 4224            return new LabelCatalogRow
 4225            {
 4226                Id = index + 1L,
 4227                FileName = file.Name,
 4228                ShipmentNumber = primary?.ShipmentNumber ?? fileStem,
 4229                SizeBytes = file.Length,
 4230                ModifiedAtUtc = file.LastWriteTimeUtc,
 4231                PrintedAtUtc = lastLog is null
 4232                    ? file.LastWriteTimeUtc
 4233                    : new DateTimeOffset(DateTime.SpecifyKind(lastLog.OccurredAtUtc, DateTimeKind.Utc)),
 4234                UsesFileDate = lastLog is null,
 4235                PrintedBy = lastLog?.ActorName,
 4236                PrintSource = lastLog?.Source switch
 4237                {
 0238                    LabelPrintSource.Studio2 => "Studio2",
 1239                    LabelPrintSource.PartnerApi => "Partner API",
 3240                    _ => "Дата файла"
 4241                },
 4242                OrderId = primary?.Id,
 4243                Scanit = string.Join(" · ", scanits),
 4244                Client = string.Join(" · ", clients),
 4245                Marketplace = string.Join(" · ", marketplaces),
 4246                HasOrder = primary is not null,
 4247                RelatedOrderCount = relatedOrderIds.Count,
 4248                RelatedOrderIds = relatedOrderIds
 4249            };
 4250        }).ToList();
 251    }
 252
 31253    internal sealed record CatalogOrder(int Id, string ShipmentNumber, string? Scanit, string? Client, ClientType? Clien
 254}