< Summary

Information
Class: Services.ApiServices.ArmtekApi.ArmtekStoreDirectory
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekApi/ArmtekStoreDirectory.cs
Line coverage
89%
Covered lines: 53
Uncovered lines: 6
Coverable lines: 59
Total lines: 134
Line coverage: 89.8%
Branch coverage
65%
Covered branches: 21
Total branches: 32
Branch coverage: 65.6%
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%
get_LoadedKeys()100%210%
TryGet(...)100%22100%
IsLoadDue(...)0%620%
RegisterFailure(...)100%11100%
RequestLoad(...)50%66100%
LoadAsync()83.33%181892.3%
BuildKey(...)50%22100%
ParseKey(...)0%620%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekApi/ArmtekStoreDirectory.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Microsoft.Extensions.DependencyInjection;
 3using Microsoft.Extensions.Logging;
 4using OzonDomains.Models;
 5
 6namespace Services.ApiServices.ArmtekApi;
 7
 8/// <summary>
 9/// Process-wide кэш справочника складов Armtek (getStoreList). Запрос занимает десятки секунд и
 10/// нужен только для подписи склада, поэтому загружается в фоне при старте приложения и обновляется
 11/// по расписанию (<see cref="ArmtekStoreDirectoryHostedService"/>). Проценка читает только из памяти
 12/// и никогда не ждёт Armtek: пока справочника нет, склад подписывается кодом KEYZAK.
 13/// </summary>
 14public sealed class ArmtekStoreDirectory
 15{
 16    /// <summary>Пауза после 1-го, 2-го, 3-го и последующих сбоев подряд: недоступный getStoreList не долбим каждые пару
 117    private static readonly TimeSpan[] FailureBackoffSteps = [TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(10), TimeSpa
 18
 19    private readonly IServiceScopeFactory _scopes;
 20    private readonly ILogger<ArmtekStoreDirectory> _logger;
 1321    private readonly ConcurrentDictionary<string, IReadOnlyDictionary<string, string>> _byKeyzak = new();
 1322    private readonly ConcurrentDictionary<string, DateTime> _nextAttemptUtc = new();
 1323    private readonly ConcurrentDictionary<string, byte> _inFlight = new();
 1324    private readonly ConcurrentDictionary<string, int> _failures = new();
 25
 1326    public ArmtekStoreDirectory(IServiceScopeFactory scopes, ILogger<ArmtekStoreDirectory> logger)
 27    {
 1328        _scopes = scopes;
 1329        _logger = logger;
 1330    }
 31
 32    /// <summary>Ключи (provider:vkorg) уже загруженных справочников — их обновляет фоновый сервис.</summary>
 33    public IReadOnlyCollection<(string ProviderKey, string? Vkorg)> LoadedKeys =>
 034        _byKeyzak.Keys.Select(ParseKey).ToArray();
 35
 36    public bool TryGet(string providerKey, string? vkorg, out IReadOnlyDictionary<string, string> storeNameByKeyzak)
 37    {
 538        if (_byKeyzak.TryGetValue(BuildKey(providerKey, vkorg), out var stores))
 39        {
 140            storeNameByKeyzak = stores;
 141            return true;
 42        }
 43
 444        storeNameByKeyzak = EmptyStores;
 445        return false;
 46    }
 47
 48    /// <summary>Нет ли паузы после сбоя: фоновый сервис не должен повторять загрузку раньше срока.</summary>
 49    public bool IsLoadDue(string providerKey, string? vkorg) =>
 050        !_nextAttemptUtc.TryGetValue(BuildKey(providerKey, vkorg), out var next) || next <= DateTime.UtcNow;
 51
 52    private void RegisterFailure(string key)
 53    {
 654        var count = _failures.AddOrUpdate(key, 1, (_, c) => c + 1);
 655        _nextAttemptUtc[key] = DateTime.UtcNow + FailureBackoffSteps[Math.Min(count, FailureBackoffSteps.Length) - 1];
 656    }
 57
 58    /// <summary>Запускает загрузку в фоне, если справочника нет и не идёт попытка/не действует пауза после сбоя.</summa
 59    public void RequestLoad(string providerKey, string? vkorg)
 60    {
 461        var key = BuildKey(providerKey, vkorg);
 462        if (_byKeyzak.ContainsKey(key)) return;
 463        if (_nextAttemptUtc.TryGetValue(key, out var next) && next > DateTime.UtcNow) return;
 864        _ = Task.Run(() => LoadAsync(providerKey, vkorg, CancellationToken.None));
 465    }
 66
 67    /// <summary>Загружает справочник из Armtek. При сбое прежнее содержимое кэша сохраняется.</summary>
 68    public async Task<bool> LoadAsync(string providerKey, string? vkorg, CancellationToken ct)
 69    {
 770        var key = BuildKey(providerKey, vkorg);
 771        if (!_inFlight.TryAdd(key, 0)) return _byKeyzak.ContainsKey(key);
 72
 73        try
 74        {
 775            await using var scope = _scopes.CreateAsyncScope();
 776            ArmtekDataManager manager = providerKey == ApiSupplierSettings.ArmtekByProviderKey
 777                ? scope.ServiceProvider.GetRequiredService<ArmtekByDataManager>()
 778                : scope.ServiceProvider.GetRequiredService<ArmtekDataManager>();
 79
 380            var response = await manager.GetStoreListAsync(vkorg, ct);
 381            if (response.HasErrors || response.Resp is null || response.Resp.Count == 0)
 82            {
 283                RegisterFailure(key);
 484                var messages = string.Join("; ", (response.Messages ?? []).Select(m => m.Text));
 285                if (response.Messages != null && ArmtekDailyQuotaGuard.IsExceeded(response.Messages))
 86                {
 187                    _logger.LogInformation("Armtek {Provider}: getStoreList не вернул склады ({Messages})", providerKey,
 88                }
 89                else
 90                {
 191                    _logger.LogWarning("Armtek {Provider}: getStoreList не вернул склады ({Messages})", providerKey, mes
 92                }
 293                return _byKeyzak.ContainsKey(key);
 94            }
 95
 196            _byKeyzak[key] = response.Resp
 297                .Where(x => !string.IsNullOrWhiteSpace(x.Keyzak) && !string.IsNullOrWhiteSpace(x.SklName))
 298                .GroupBy(x => x.Keyzak, StringComparer.OrdinalIgnoreCase)
 599                .ToDictionary(g => g.Key, g => g.First().SklName, StringComparer.OrdinalIgnoreCase);
 1100            _nextAttemptUtc.TryRemove(key, out _);
 1101            _failures.TryRemove(key, out _);
 1102            return true;
 0103        }
 4104        catch (Exception ex) when (!ct.IsCancellationRequested)
 105        {
 4106            RegisterFailure(key);
 4107            if (ArmtekDailyQuotaGuard.IsExceeded(ex.Message))
 108            {
 0109                _logger.LogInformation(ex, "Armtek {Provider}: не удалось загрузить справочник складов — исчерпана суточ
 110            }
 111            else
 112            {
 4113                _logger.LogWarning(ex, "Armtek {Provider}: не удалось загрузить справочник складов", providerKey);
 114            }
 4115            return _byKeyzak.ContainsKey(key);
 116        }
 117        finally
 118        {
 7119            _inFlight.TryRemove(key, out _);
 120        }
 7121    }
 122
 1123    private static readonly IReadOnlyDictionary<string, string> EmptyStores =
 1124        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 125
 126    private static string BuildKey(string providerKey, string? vkorg) =>
 16127        $"{providerKey}|{(string.IsNullOrWhiteSpace(vkorg) ? string.Empty : vkorg.Trim())}";
 128
 129    private static (string ProviderKey, string? Vkorg) ParseKey(string key)
 130    {
 0131        var parts = key.Split('|', 2);
 0132        return (parts[0], parts[1].Length == 0 ? null : parts[1]);
 133    }
 134}