< Summary

Information
Class: Services.LabelServices.ArticleLabelResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/ArticleLabelService.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 199
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
get_Content()100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.Caching.Memory;
 2using OzonDomains.Models;
 3
 4namespace Services.LabelServices;
 5
 6/// <summary>
 7/// Этикетка по артикулу заказа: отдаёт сохранённый PDF, а при его отсутствии генерирует и сохраняет.
 8/// </summary>
 9public class ArticleLabelService
 10{
 11    private const string FolderIndexCacheKey = "article_label_names_index_v1";
 12    private static readonly TimeSpan FolderIndexCacheExpiration = TimeSpan.FromMinutes(10);
 13    private static readonly TimeSpan FolderIndexFailureCacheExpiration = TimeSpan.FromSeconds(30);
 14
 15    /// <summary>
 16    /// Сколько запрос готов ждать построения индекса папки. Листинг папки с десятками тысяч PDF на
 17    /// холодном кэше может длиться дольше таймаута прокси (504) — индекс нужен лишь для поиска
 18    /// файлов в старом регистре, поэтому по истечении срока этикетка генерируется без него,
 19    /// а индекс достраивается в фоне для следующих запросов.
 20    /// </summary>
 21    private static readonly TimeSpan DefaultFolderIndexWaitTimeout = TimeSpan.FromSeconds(5);
 22
 23    // Один построитель индекса на процесс: параллельные запросы ждут его, а не листают папку каждый сам.
 24    private static readonly SemaphoreSlim FolderIndexBuildGate = new(1, 1);
 25    private static Task<Dictionary<string, string>>? _folderIndexBuild;
 26
 27    private readonly ILabelFileStorage _labelFileStorage;
 28    private readonly BarcodeLabelService _barcodeLabelService;
 29    private readonly IMemoryCache _cache;
 30    private readonly TimeSpan _folderIndexWaitTimeout;
 31
 32    public ArticleLabelService(
 33        ILabelFileStorage labelFileStorage,
 34        BarcodeLabelService barcodeLabelService,
 35        IMemoryCache cache,
 36        TimeSpan? folderIndexWaitTimeout = null)
 37    {
 38        _folderIndexWaitTimeout = folderIndexWaitTimeout ?? DefaultFolderIndexWaitTimeout;
 39        _labelFileStorage = labelFileStorage;
 40        _barcodeLabelService = barcodeLabelService;
 41        _cache = cache;
 42    }
 43
 44    /// <summary>
 45    /// Имя файла этикетки с учётом производителя и товара. Нижний регистр — обычная ФС
 46    /// (в отличие от Dropbox) регистрозависима, без канонизации один и тот же товар с разным
 47    /// регистром названия/производителя между запросами породил бы дублирующиеся файлы.
 48    /// </summary>
 49    public static string BuildFileName(string cleanedArticle, string? producer, string? productName)
 50    {
 51        var safeProducer = Sanitize(producer);
 52        var safeProduct = Sanitize(productName);
 53        return $"{cleanedArticle.ToLowerInvariant()}_{safeProducer}_{safeProduct}.pdf";
 54    }
 55
 56    private static string Sanitize(string? value) =>
 57        string.Join("_", (value ?? string.Empty).Split(Path.GetInvalidFileNameChars())).ToLowerInvariant();
 58
 59    /// <summary>
 60    /// Возвращает PDF этикетки заказа: из хранилища, либо генерирует новую и сохраняет её.
 61    /// У заказа должен быть непустой <see cref="Order.CleanedArticle"/>.
 62    /// </summary>
 63    public async Task<byte[]> GetOrCreateAsync(Order order, CancellationToken cancellationToken = default) =>
 64        (await GetOrCreateWithMetadataAsync(order, cancellationToken).ConfigureAwait(false)).Content;
 65
 66    /// <summary>Возвращает PDF и признак, что он был успешно создан и сохранён этим вызовом.</summary>
 67    public async Task<ArticleLabelResult> GetOrCreateWithMetadataAsync(Order order, CancellationToken cancellationToken 
 68    {
 69        var cleanedArticle = order.CleanedArticle ?? string.Empty;
 70        var producer = order.EtProducer?.Name ?? string.Empty;
 71        var fileName = BuildFileName(cleanedArticle, producer, order.ProductName);
 72
 73        var existingBytes = await TryDownloadIgnoringCaseAsync(fileName, cancellationToken);
 74        if (existingBytes != null)
 75        {
 76            return new ArticleLabelResult(existingBytes, fileName, false);
 77        }
 78
 79        var request = new LabelRequest
 80        {
 81            Article = order.Article ?? string.Empty,
 82            Producer = producer,
 83            ProductName = string.IsNullOrWhiteSpace(producer)
 84                ? order.ProductName ?? string.Empty
 85                : producer + " " + (order.ProductName ?? string.Empty),
 86            Quantity = order.TrueQuantity ?? order.Quantity ?? 1
 87        };
 88
 89        var pdfBytes = _barcodeLabelService.GenerateLabelsPdf(new[] { request });
 90
 91        try
 92        {
 93            await _labelFileStorage.UploadAsync(pdfBytes, fileName, overwrite: true, cancellationToken);
 94            return new ArticleLabelResult(pdfBytes, fileName, true);
 95        }
 96        catch
 97        {
 98            // Не блокируем выдачу уже сгенерированного PDF пользователю при сбое сохранения
 99            return new ArticleLabelResult(pdfBytes, fileName, false);
 100        }
 101    }
 102
 103    /// <summary>
 104    /// Ищет файл сначала по точному имени, затем без учёта регистра: этикетки, сохранённые до
 105    /// перехода на канонические имена (в том числе перенесённые из Dropbox), лежат в исходном
 106    /// регистре и иначе не находились бы — каждая генерировалась бы заново с новым дубликатом.
 107    /// </summary>
 108    private async Task<byte[]?> TryDownloadIgnoringCaseAsync(string fileName, CancellationToken cancellationToken)
 109    {
 110        var exactBytes = await _labelFileStorage.TryDownloadAsync(fileName, cancellationToken);
 111        if (exactBytes != null)
 112        {
 113            return exactBytes;
 114        }
 115
 116        var folderIndex = await GetFolderIndexAsync(cancellationToken);
 117        if (!folderIndex.TryGetValue(fileName, out var storedFileName)
 118            || string.Equals(storedFileName, fileName, StringComparison.Ordinal))
 119        {
 120            return null;
 121        }
 122
 123        return await _labelFileStorage.TryDownloadAsync(storedFileName, cancellationToken);
 124    }
 125
 126    /// <summary>
 127    /// Каноническое имя → фактическое имя файла в хранилище. Кэш безопасен: только что записанные
 128    /// этикетки находятся по точному имени, а исторические имена уже не меняются.
 129    /// </summary>
 130    private async Task<Dictionary<string, string>> GetFolderIndexAsync(CancellationToken cancellationToken)
 131    {
 132        if (_cache.TryGetValue(FolderIndexCacheKey, out Dictionary<string, string>? cached) && cached is not null)
 133        {
 134            return cached;
 135        }
 136
 137        Task<Dictionary<string, string>> build;
 138        await FolderIndexBuildGate.WaitAsync(cancellationToken);
 139        try
 140        {
 141            if (_cache.TryGetValue(FolderIndexCacheKey, out cached) && cached is not null)
 142            {
 143                return cached;
 144            }
 145
 146            // Построение не привязано к токену запроса: оно общее для всех и должно дойти до кэша,
 147            // даже если пользователь, который его запустил, закрыл вкладку.
 148            build = _folderIndexBuild is { IsCompleted: false }
 149                ? _folderIndexBuild
 150                : _folderIndexBuild = Task.Run(BuildFolderIndexAsync);
 151        }
 152        finally
 153        {
 154            FolderIndexBuildGate.Release();
 155        }
 156
 157        var winner = await Task.WhenAny(build, Task.Delay(_folderIndexWaitTimeout, cancellationToken));
 158        if (winner == build)
 159        {
 160            return await build;
 161        }
 162
 163        cancellationToken.ThrowIfCancellationRequested();
 164        Console.WriteLine("Индекс артикульных этикеток ещё строится — этикетка формируется без поиска в старом регистре.
 165        return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 166    }
 167
 168    private async Task<Dictionary<string, string>> BuildFolderIndexAsync()
 169    {
 170        var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 171        var succeeded = false;
 172        try
 173        {
 174            var files = await _labelFileStorage.ListAsync();
 175            foreach (var file in files)
 176            {
 177                if (file.IsDirectory || !file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
 178                {
 179                    continue;
 180                }
 181
 182                index.TryAdd(file.Name, file.Name);
 183            }
 184
 185            succeeded = true;
 186        }
 187        catch (Exception ex)
 188        {
 189            Console.WriteLine($"Ошибка при построении индекса артикульных этикеток: {ex.Message}");
 190        }
 191
 192        // Пустой индекс после сбоя не должен прятать все старые этикетки на 10 минут.
 193        _cache.Set(FolderIndexCacheKey, index,
 194            succeeded ? FolderIndexCacheExpiration : FolderIndexFailureCacheExpiration);
 195        return index;
 196    }
 197}
 198
 13199public sealed record ArticleLabelResult(byte[] Content, string FileName, bool WasStored);

Methods/Properties

get_Content()