< Summary

Information
Class: Services.LabelServices.ArticleLabelService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/ArticleLabelService.cs
Line coverage
93%
Covered lines: 76
Uncovered lines: 5
Coverable lines: 81
Total lines: 199
Line coverage: 93.8%
Branch coverage
72%
Covered branches: 36
Total branches: 50
Branch coverage: 72%
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%22100%
BuildFileName(...)100%11100%
Sanitize(...)50%22100%
GetOrCreateAsync()100%11100%
GetOrCreateWithMetadataAsync()55.55%1818100%
TryDownloadIgnoringCaseAsync()100%66100%
GetFolderIndexAsync()71.42%141488.23%
BuildFolderIndexAsync()87.5%9880%

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";
 112    private static readonly TimeSpan FolderIndexCacheExpiration = TimeSpan.FromMinutes(10);
 113    private static readonly TimeSpan FolderIndexFailureCacheExpiration = TimeSpan.FromSeconds(30);
 14
 15    /// <summary>
 16    /// Сколько запрос готов ждать построения индекса папки. Листинг папки с десятками тысяч PDF на
 17    /// холодном кэше может длиться дольше таймаута прокси (504) — индекс нужен лишь для поиска
 18    /// файлов в старом регистре, поэтому по истечении срока этикетка генерируется без него,
 19    /// а индекс достраивается в фоне для следующих запросов.
 20    /// </summary>
 121    private static readonly TimeSpan DefaultFolderIndexWaitTimeout = TimeSpan.FromSeconds(5);
 22
 23    // Один построитель индекса на процесс: параллельные запросы ждут его, а не листают папку каждый сам.
 124    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
 532    public ArticleLabelService(
 533        ILabelFileStorage labelFileStorage,
 534        BarcodeLabelService barcodeLabelService,
 535        IMemoryCache cache,
 536        TimeSpan? folderIndexWaitTimeout = null)
 37    {
 538        _folderIndexWaitTimeout = folderIndexWaitTimeout ?? DefaultFolderIndexWaitTimeout;
 539        _labelFileStorage = labelFileStorage;
 540        _barcodeLabelService = barcodeLabelService;
 541        _cache = cache;
 542    }
 43
 44    /// <summary>
 45    /// Имя файла этикетки с учётом производителя и товара. Нижний регистр — обычная ФС
 46    /// (в отличие от Dropbox) регистрозависима, без канонизации один и тот же товар с разным
 47    /// регистром названия/производителя между запросами породил бы дублирующиеся файлы.
 48    /// </summary>
 49    public static string BuildFileName(string cleanedArticle, string? producer, string? productName)
 50    {
 751        var safeProducer = Sanitize(producer);
 752        var safeProduct = Sanitize(productName);
 753        return $"{cleanedArticle.ToLowerInvariant()}_{safeProducer}_{safeProduct}.pdf";
 54    }
 55
 56    private static string Sanitize(string? value) =>
 1457        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) =>
 564        (await GetOrCreateWithMetadataAsync(order, cancellationToken).ConfigureAwait(false)).Content;
 65
 66    /// <summary>Возвращает PDF и признак, что он был успешно создан и сохранён этим вызовом.</summary>
 67    public async Task<ArticleLabelResult> GetOrCreateWithMetadataAsync(Order order, CancellationToken cancellationToken 
 68    {
 669        var cleanedArticle = order.CleanedArticle ?? string.Empty;
 670        var producer = order.EtProducer?.Name ?? string.Empty;
 671        var fileName = BuildFileName(cleanedArticle, producer, order.ProductName);
 72
 673        var existingBytes = await TryDownloadIgnoringCaseAsync(fileName, cancellationToken);
 674        if (existingBytes != null)
 75        {
 276            return new ArticleLabelResult(existingBytes, fileName, false);
 77        }
 78
 479        var request = new LabelRequest
 480        {
 481            Article = order.Article ?? string.Empty,
 482            Producer = producer,
 483            ProductName = string.IsNullOrWhiteSpace(producer)
 484                ? order.ProductName ?? string.Empty
 485                : producer + " " + (order.ProductName ?? string.Empty),
 486            Quantity = order.TrueQuantity ?? order.Quantity ?? 1
 487        };
 88
 489        var pdfBytes = _barcodeLabelService.GenerateLabelsPdf(new[] { request });
 90
 91        try
 92        {
 493            await _labelFileStorage.UploadAsync(pdfBytes, fileName, overwrite: true, cancellationToken);
 394            return new ArticleLabelResult(pdfBytes, fileName, true);
 95        }
 196        catch
 97        {
 98            // Не блокируем выдачу уже сгенерированного PDF пользователю при сбое сохранения
 199            return new ArticleLabelResult(pdfBytes, fileName, false);
 100        }
 6101    }
 102
 103    /// <summary>
 104    /// Ищет файл сначала по точному имени, затем без учёта регистра: этикетки, сохранённые до
 105    /// перехода на канонические имена (в том числе перенесённые из Dropbox), лежат в исходном
 106    /// регистре и иначе не находились бы — каждая генерировалась бы заново с новым дубликатом.
 107    /// </summary>
 108    private async Task<byte[]?> TryDownloadIgnoringCaseAsync(string fileName, CancellationToken cancellationToken)
 109    {
 6110        var exactBytes = await _labelFileStorage.TryDownloadAsync(fileName, cancellationToken);
 6111        if (exactBytes != null)
 112        {
 1113            return exactBytes;
 114        }
 115
 5116        var folderIndex = await GetFolderIndexAsync(cancellationToken);
 5117        if (!folderIndex.TryGetValue(fileName, out var storedFileName)
 5118            || string.Equals(storedFileName, fileName, StringComparison.Ordinal))
 119        {
 4120            return null;
 121        }
 122
 1123        return await _labelFileStorage.TryDownloadAsync(storedFileName, cancellationToken);
 6124    }
 125
 126    /// <summary>
 127    /// Каноническое имя → фактическое имя файла в хранилище. Кэш безопасен: только что записанные
 128    /// этикетки находятся по точному имени, а исторические имена уже не меняются.
 129    /// </summary>
 130    private async Task<Dictionary<string, string>> GetFolderIndexAsync(CancellationToken cancellationToken)
 131    {
 5132        if (_cache.TryGetValue(FolderIndexCacheKey, out Dictionary<string, string>? cached) && cached is not null)
 133        {
 0134            return cached;
 135        }
 136
 137        Task<Dictionary<string, string>> build;
 5138        await FolderIndexBuildGate.WaitAsync(cancellationToken);
 139        try
 140        {
 5141            if (_cache.TryGetValue(FolderIndexCacheKey, out cached) && cached is not null)
 142            {
 0143                return cached;
 144            }
 145
 146            // Построение не привязано к токену запроса: оно общее для всех и должно дойти до кэша,
 147            // даже если пользователь, который его запустил, закрыл вкладку.
 5148            build = _folderIndexBuild is { IsCompleted: false }
 5149                ? _folderIndexBuild
 5150                : _folderIndexBuild = Task.Run(BuildFolderIndexAsync);
 5151        }
 152        finally
 153        {
 5154            FolderIndexBuildGate.Release();
 155        }
 156
 5157        var winner = await Task.WhenAny(build, Task.Delay(_folderIndexWaitTimeout, cancellationToken));
 5158        if (winner == build)
 159        {
 3160            return await build;
 161        }
 162
 2163        cancellationToken.ThrowIfCancellationRequested();
 2164        Console.WriteLine("Индекс артикульных этикеток ещё строится — этикетка формируется без поиска в старом регистре.
 2165        return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 5166    }
 167
 168    private async Task<Dictionary<string, string>> BuildFolderIndexAsync()
 169    {
 4170        var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 4171        var succeeded = false;
 172        try
 173        {
 4174            var files = await _labelFileStorage.ListAsync();
 10175            foreach (var file in files)
 176            {
 1177                if (file.IsDirectory || !file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
 178                {
 179                    continue;
 180                }
 181
 1182                index.TryAdd(file.Name, file.Name);
 183            }
 184
 4185            succeeded = true;
 4186        }
 0187        catch (Exception ex)
 188        {
 0189            Console.WriteLine($"Ошибка при построении индекса артикульных этикеток: {ex.Message}");
 0190        }
 191
 192        // Пустой индекс после сбоя не должен прятать все старые этикетки на 10 минут.
 4193        _cache.Set(FolderIndexCacheKey, index,
 4194            succeeded ? FolderIndexCacheExpiration : FolderIndexFailureCacheExpiration);
 3195        return index;
 3196    }
 197}
 198
 199public sealed record ArticleLabelResult(byte[] Content, string FileName, bool WasStored);