< Summary

Information
Class: Services.FeedPriceServices.FeedPriceService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/FeedPriceServices/FeedPriceService.cs
Line coverage
94%
Covered lines: 70
Uncovered lines: 4
Coverable lines: 74
Total lines: 163
Line coverage: 94.5%
Branch coverage
90%
Covered branches: 40
Total branches: 44
Branch coverage: 90.9%
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%
GetAsync(...)100%11100%
GetCurrentAsync(...)100%11100%
GetCurrentRowsAsync()90%202092.59%
LoadAsync()87.5%161691.3%
WithDeltas(...)100%88100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/FeedPriceServices/FeedPriceService.cs

#LineLine coverage
 1using Microsoft.Extensions.Caching.Memory;
 2using Microsoft.Extensions.Logging;
 3using Services.SystemMonitoringServices;
 4
 5namespace Services.FeedPriceServices;
 6
 7public interface IFeedPriceService
 8{
 9    Task<FeedPriceResult> GetAsync(IReadOnlyCollection<string> articleKeys, CancellationToken ct = default);
 10
 11    /// <summary>Только текущие данные, без истории: облегчённый ответ для колонок грида.</summary>
 12    Task<FeedPriceResult> GetCurrentAsync(IReadOnlyCollection<string> articleKeys, CancellationToken ct = default);
 13
 14    /// <summary>
 15    /// Текущие строки фида по любому числу ключей: недостающие в кеше запрашиваются пачками до
 16    /// <see cref="FeedPriceService.MaxKeysPerRequest"/> ключей, кеш ведётся по каждому ключу отдельно.
 17    /// </summary>
 18    Task<FeedRowsResult> GetCurrentRowsAsync(IReadOnlyCollection<string> articleKeys, CancellationToken ct = default);
 19}
 20
 21/// <summary>
 22/// Цена и остатки артикула в фиде для модалки заказа. Недоступность фид-сервиса не должна ронять страницу,
 23/// поэтому ошибка возвращается в модели; ответ с ошибкой не кешируется.
 24/// </summary>
 25public sealed class FeedPriceService : IFeedPriceService
 26{
 27    private const string CacheKeyPrefix = "feed_price_v1_";
 28    private const string CurrentCacheKeyPrefix = "feed_price_current_v1_";
 29    private const string RowsCacheKeyPrefix = "feed_price_rows_v1_";
 30
 31    /// <summary>Сколько ключей feed API принимает в одном запросе price-stock.</summary>
 32    public const int MaxKeysPerRequest = 100;
 133    private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(90);
 34
 35    private readonly IFeedPriceReader _reader;
 36    private readonly FeedPriceOptions _options;
 37    private readonly IMemoryCache _cache;
 38    private readonly ILogger<FeedPriceService> _logger;
 39    private readonly ISystemMonitoringSignalWriter? _signals;
 40
 1041    public FeedPriceService(IFeedPriceReader reader, FeedPriceOptions options, IMemoryCache cache, ILogger<FeedPriceServ
 1042        ISystemMonitoringSignalWriter? signals = null)
 43    {
 1044        _reader = reader;
 1045        _options = options;
 1046        _cache = cache;
 1047        _logger = logger;
 1048        _signals = signals;
 1049    }
 50
 51    public Task<FeedPriceResult> GetAsync(IReadOnlyCollection<string> articleKeys, CancellationToken ct = default) =>
 652        LoadAsync(articleKeys, _options.HistoryRuns, includeHistory: true, CacheKeyPrefix, ct);
 53
 54    public Task<FeedPriceResult> GetCurrentAsync(IReadOnlyCollection<string> articleKeys, CancellationToken ct = default
 255        LoadAsync(articleKeys, historyRuns: 1, includeHistory: false, CurrentCacheKeyPrefix, ct);
 56
 57    public async Task<FeedRowsResult> GetCurrentRowsAsync(IReadOnlyCollection<string> articleKeys, CancellationToken ct 
 58    {
 859        var keys = articleKeys
 56360            .Where(x => !string.IsNullOrWhiteSpace(x))
 56161            .Select(x => x.Trim())
 862            .Distinct(StringComparer.OrdinalIgnoreCase)
 863            .ToArray();
 864        var rows = new Dictionary<string, IReadOnlyList<FeedCurrentRow>>(StringComparer.OrdinalIgnoreCase);
 865        var failed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 866        var missing = new List<string>();
 113867        foreach (var key in keys)
 68        {
 56169            if (_cache.TryGetValue<IReadOnlyList<FeedCurrentRow>>(RowsCacheKeyPrefix + key.ToUpperInvariant(), out var c
 10570                rows[key] = cached;
 71            else
 45672                missing.Add(key);
 73        }
 74
 3475        foreach (var chunk in missing.Chunk(MaxKeysPerRequest))
 76        {
 77            try
 78            {
 979                var snapshot = await _reader.GetAsync(chunk, 1, ct);
 1380                var byArticle = snapshot.Current.ToLookup(row => row.Article, StringComparer.OrdinalIgnoreCase);
 82881                foreach (var key in chunk)
 82                {
 83                    // Ключа нет в фиде: пустой список тоже кешируется, иначе каждая ячейка гнала бы запрос заново.
 40684                    IReadOnlyList<FeedCurrentRow> found = byArticle[key].ToArray();
 40685                    rows[key] = found;
 40686                    _cache.Set(RowsCacheKeyPrefix + key.ToUpperInvariant(), found, CacheTtl);
 87                }
 88
 889                await (_signals?.RecordAsync("feed-price-stock", "success", cancellationToken: ct) ?? Task.CompletedTask
 890            }
 091            catch (OperationCanceledException) when (ct.IsCancellationRequested)
 92            {
 093                throw;
 94            }
 195            catch (Exception ex)
 96            {
 197                _logger.LogWarning(ex, "Фид: не удалось прочитать цену пачки из {Count} артикулов", chunk.Length);
 198                await (_signals?.RecordAsync("feed-price-stock", "failure", message: "Не удалось получить цены и остатки
 15299                foreach (var key in chunk) failed.Add(key);
 100            }
 9101        }
 102
 8103        return new FeedRowsResult(rows, failed);
 8104    }
 105
 106    private async Task<FeedPriceResult> LoadAsync(IReadOnlyCollection<string> articleKeys, int historyRuns, bool include
 107        string cachePrefix, CancellationToken ct)
 108    {
 8109        var keys = articleKeys
 9110            .Where(x => !string.IsNullOrWhiteSpace(x))
 7111            .Select(x => x.Trim())
 8112            .Distinct(StringComparer.OrdinalIgnoreCase)
 8113            .ToArray();
 8114        if (keys.Length == 0)
 1115            return new FeedPriceResult { Error = "Не удалось собрать ключ артикула: у заказа нет артикула или префикса п
 116
 21117        var cacheKey = cachePrefix + string.Join('|', keys.Select(x => x.ToUpperInvariant()).OrderBy(x => x, StringCompa
 7118        if (_cache.TryGetValue<FeedPriceResult>(cacheKey, out var cached) && cached is not null)
 1119            return cached;
 120
 121        try
 122        {
 6123            var snapshot = await _reader.GetAsync(keys, historyRuns, ct);
 124
 5125            var result = new FeedPriceResult
 5126            {
 5127                ArticleKeys = keys,
 5128                Current = snapshot.Current,
 5129                History = includeHistory ? WithDeltas(snapshot.History) : Array.Empty<FeedHistoryRow>()
 5130            };
 5131            _cache.Set(cacheKey, result, CacheTtl);
 5132            await (_signals?.RecordAsync("feed-price-stock", "success", cancellationToken: ct) ?? Task.CompletedTask);
 5133            return result;
 134        }
 0135        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 136        {
 0137            throw;
 138        }
 139        catch (Exception ex)
 140        {
 1141            _logger.LogWarning(ex, "Фид: не удалось прочитать цену и остатки артикула {Articles}", string.Join(", ", key
 1142            await (_signals?.RecordAsync("feed-price-stock", "failure", message: "Не удалось получить цены и остатки из 
 1143            return new FeedPriceResult { ArticleKeys = keys, Error = "Фид-сервис недоступен. Повторите позже." };
 144        }
 8145    }
 146
 147    /// <summary>Разница цены к предыдущему по времени прогону того же кабинета и артикула; строки — свежие сверху.</sum
 148    internal static IReadOnlyList<FeedHistoryRow> WithDeltas(IEnumerable<FeedHistoryRow> rows)
 149    {
 19150        var ordered = rows.OrderByDescending(x => x.AssembledAt).ThenByDescending(x => x.RunId).ToList();
 25151        foreach (var group in ordered.GroupBy(x => (x.Cabinet, x.Article)))
 152        {
 4153            var chain = group.ToList();
 14154            for (var i = 0; i < chain.Count - 1; i++)
 155            {
 3156                if (chain[i].Price is { } now && chain[i + 1].Price is { } before)
 2157                    chain[i].PriceDelta = now - before;
 158            }
 159        }
 160
 5161        return ordered;
 162    }
 163}