| | | 1 | | using Microsoft.Extensions.Caching.Memory; |
| | | 2 | | using OzonRepositories.Data; |
| | | 3 | | using Services.ApiServices.ArmtekApi.Models.Envelope; |
| | | 4 | | using Services.SignalRServices; |
| | | 5 | | |
| | | 6 | | namespace Services.ApiServices.ArmtekApi; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Переводит бренд из Studio2/ET в точное написание, которое принимает Armtek. |
| | | 10 | | /// Справочник Armtek кэшируется отдельно для каждого аккаунта, поскольку доступный набор брендов |
| | | 11 | | /// может различаться между RU и BY. |
| | | 12 | | /// </summary> |
| | | 13 | | public sealed class ArmtekBrandResolver |
| | | 14 | | { |
| | | 15 | | private readonly ArmtekHttpClient _client; |
| | | 16 | | private readonly EtProducerRepository _etProducers; |
| | | 17 | | private readonly IMemoryCache _cache; |
| | | 18 | | private readonly string _providerKey; |
| | | 19 | | |
| | 2 | 20 | | public ArmtekBrandResolver(ArmtekHttpClient client, EtProducerRepository etProducers, IMemoryCache cache, |
| | 2 | 21 | | string providerKey) |
| | | 22 | | { |
| | 2 | 23 | | _client = client; |
| | 2 | 24 | | _etProducers = etProducers; |
| | 2 | 25 | | _cache = cache; |
| | 2 | 26 | | _providerKey = providerKey; |
| | 2 | 27 | | } |
| | | 28 | | |
| | | 29 | | public async Task<string> ResolveAsync(string brand, CancellationToken ct = default) |
| | | 30 | | { |
| | 3 | 31 | | var requestedBrand = brand.Trim(); |
| | 3 | 32 | | if (requestedBrand.Length == 0) |
| | 0 | 33 | | return requestedBrand; |
| | | 34 | | |
| | 3 | 35 | | var brands = await GetArmtekBrandsAsync(ct); |
| | 2 | 36 | | if (brands.Count == 0) |
| | 2 | 37 | | return requestedBrand; |
| | | 38 | | |
| | 0 | 39 | | var aliases = await _etProducers.GetAliasesByPrefixAsync(requestedBrand); |
| | 0 | 40 | | return SelectApiBrand(requestedBrand, brands, aliases) ?? requestedBrand; |
| | 2 | 41 | | } |
| | | 42 | | |
| | | 43 | | internal static string? SelectApiBrand(string requestedBrand, IEnumerable<string> availableBrands, |
| | | 44 | | IEnumerable<string> etAliases) |
| | | 45 | | { |
| | 2 | 46 | | var available = availableBrands |
| | 3 | 47 | | .Where(brand => !string.IsNullOrWhiteSpace(brand)) |
| | 3 | 48 | | .Select(brand => brand.Trim()) |
| | 2 | 49 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 2 | 50 | | .ToArray(); |
| | 2 | 51 | | var requestedNormalized = Normalize(requestedBrand); |
| | | 52 | | |
| | 2 | 53 | | var exact = available.FirstOrDefault(brand => |
| | 5 | 54 | | string.Equals(Normalize(brand), requestedNormalized, StringComparison.Ordinal)); |
| | 2 | 55 | | if (exact is not null) |
| | 1 | 56 | | return exact; |
| | | 57 | | |
| | 1 | 58 | | var aliases = etAliases |
| | 1 | 59 | | .Append(requestedBrand) |
| | 1 | 60 | | .Select(Normalize) |
| | 4 | 61 | | .Where(alias => alias.Length > 0) |
| | 1 | 62 | | .ToHashSet(StringComparer.Ordinal); |
| | | 63 | | |
| | 2 | 64 | | return available.FirstOrDefault(brand => aliases.Contains(Normalize(brand))); |
| | | 65 | | } |
| | | 66 | | |
| | 1 | 67 | | private static readonly TimeSpan BrandListCacheTtl = TimeSpan.FromHours(6); |
| | 1 | 68 | | private static readonly TimeSpan BrandListErrorCacheTtl = TimeSpan.FromMinutes(30); |
| | | 69 | | |
| | | 70 | | private async Task<IReadOnlyList<string>> GetArmtekBrandsAsync(CancellationToken ct) |
| | | 71 | | { |
| | 3 | 72 | | var cacheKey = $"api-supplier:{_providerKey}:brand-list"; |
| | 3 | 73 | | if (_cache.TryGetValue(cacheKey, out IReadOnlyList<string>? cached) && cached is not null) |
| | 1 | 74 | | return cached; |
| | | 75 | | |
| | | 76 | | ArmtekResponse<List<string>> response; |
| | | 77 | | try |
| | | 78 | | { |
| | 2 | 79 | | response = await _client.GetAsync<List<string>>("ws_user/getBrandList", ct: ct); |
| | 1 | 80 | | } |
| | 0 | 81 | | catch (ArmtekApiException ex) |
| | | 82 | | { |
| | 0 | 83 | | _cache.Set(cacheKey, Array.Empty<string>(), BrandListErrorCacheTtl); |
| | | 84 | | |
| | 0 | 85 | | var isDailyLimit = ex.Message.Contains("Превышено количество запросов", StringComparison.OrdinalIgnoreCase); |
| | 0 | 86 | | var errorKey = $"api-supplier:{_providerKey}:brand-list-error-notified"; |
| | 0 | 87 | | if (!_cache.TryGetValue(errorKey, out _)) |
| | | 88 | | { |
| | 0 | 89 | | _cache.Set(errorKey, true, TimeSpan.FromHours(1)); |
| | 0 | 90 | | var toastMessage = isDailyLimit |
| | 0 | 91 | | ? "Превышено суточное количество запросов к API Armtek для данного логина. Сопоставление брендов вре |
| | 0 | 92 | | : $"Не удалось получить справочник брендов Armtek: {ex.Message}. Используется исходное написание."; |
| | | 93 | | |
| | | 94 | | try |
| | | 95 | | { |
| | 0 | 96 | | await NotificationService.NotifyAsync( |
| | 0 | 97 | | "Armtek", |
| | 0 | 98 | | "#D97706", |
| | 0 | 99 | | toastMessage, |
| | 0 | 100 | | "fa-triangle-exclamation"); |
| | 0 | 101 | | } |
| | 0 | 102 | | catch |
| | | 103 | | { |
| | | 104 | | // Игнорируем отсутствие активного NotificationService (например, в консоли/тестах) |
| | 0 | 105 | | } |
| | | 106 | | } |
| | | 107 | | |
| | 0 | 108 | | return Array.Empty<string>(); |
| | | 109 | | } |
| | | 110 | | |
| | 1 | 111 | | if (response.HasErrors || response.Resp is null) |
| | | 112 | | { |
| | 1 | 113 | | _cache.Set(cacheKey, Array.Empty<string>(), BrandListErrorCacheTtl); |
| | | 114 | | |
| | 1 | 115 | | if (ArmtekDailyQuotaGuard.IsExceeded(response.Messages)) |
| | 1 | 116 | | await NotifyDailyQuotaExceededAsync(); |
| | | 117 | | |
| | 1 | 118 | | return Array.Empty<string>(); |
| | | 119 | | } |
| | | 120 | | |
| | 0 | 121 | | var brands = response.Resp |
| | 0 | 122 | | .Where(brand => !string.IsNullOrWhiteSpace(brand)) |
| | 0 | 123 | | .Select(brand => brand.Trim()) |
| | 0 | 124 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 125 | | .ToArray(); |
| | 0 | 126 | | _cache.Set(cacheKey, brands, BrandListCacheTtl); |
| | 0 | 127 | | return brands; |
| | 2 | 128 | | } |
| | | 129 | | |
| | | 130 | | private async Task NotifyDailyQuotaExceededAsync() |
| | | 131 | | { |
| | 1 | 132 | | var errorKey = $"api-supplier:{_providerKey}:brand-list-error-notified"; |
| | 1 | 133 | | if (_cache.TryGetValue(errorKey, out _)) |
| | 0 | 134 | | return; |
| | | 135 | | |
| | 1 | 136 | | _cache.Set(errorKey, true, TimeSpan.FromHours(1)); |
| | | 137 | | try |
| | | 138 | | { |
| | 1 | 139 | | await NotificationService.NotifyAsync( |
| | 1 | 140 | | "Armtek", |
| | 1 | 141 | | "#D97706", |
| | 1 | 142 | | "Превышено суточное количество запросов к API Armtek для данного логина. Сопоставление брендов временно |
| | 1 | 143 | | "fa-triangle-exclamation"); |
| | 0 | 144 | | } |
| | 1 | 145 | | catch |
| | | 146 | | { |
| | | 147 | | // Игнорируем отсутствие активного NotificationService (например, в консоли/тестах) |
| | 1 | 148 | | } |
| | 1 | 149 | | } |
| | | 150 | | |
| | 10 | 151 | | private static string Normalize(string value) => new(value |
| | 10 | 152 | | .Where(char.IsLetterOrDigit) |
| | 10 | 153 | | .Select(char.ToUpperInvariant) |
| | 10 | 154 | | .ToArray()); |
| | | 155 | | } |