< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.ZZapMoscowPriceController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/ZZapMoscowPriceController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 40
Coverable lines: 40
Total lines: 101
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 8
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
Search()0%7280%
GetSearchResponseAsync()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/ZZapMoscowPriceController.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Authorization;
 2using Microsoft.AspNetCore.Mvc;
 3using Microsoft.Extensions.Caching.Memory;
 4using Newtonsoft.Json.Linq;
 5using OzonOrdersWeb.Filters;
 6using Services.ApiServices.TradesoftApi.Models.Response;
 7using Services.ApiServices.ZZapApi;
 8using Services.ApiSupplierSettingsServices;
 9
 10namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 11
 12/// <summary>Проценка ZZap Москва: /search/light только по запрошенному номеру, новые детали.
 13/// Запросы к ZZap идут через общий троттлинг клиента (1 запрос в 3,5 с), поэтому при массовом
 14/// прогреве строк этот источник отвечает позже остальных — это ограничение API, а не сбой.</summary>
 15[Area("Studio2")]
 16[Authorize(Roles = "User,Admin")]
 17[ServiceFilter(typeof(SupplierQuotationHistoryFilter), Order = 0)]
 18[ServiceFilter(typeof(SupplierDeliverySurchargeFilter), Order = 1)]
 19[ServiceFilter(typeof(SupplierWarehouseRestrictionFilter), Order = 2)]
 20public sealed class ZZapMoscowPriceController : Controller
 21{
 22    private readonly ZZapDataManager _zzap;
 23    private readonly ApiSupplierSettingsService _settings;
 24    private readonly IMemoryCache _cache;
 25
 26    // Ответ ZZap живёт недолго: цена меняется, но повторное раскрытие строки не должно занимать слот лимита API.
 027    private static readonly object CreateLock = new();
 028    private static readonly TimeSpan ResponseCacheTtl = TimeSpan.FromMinutes(3);
 29
 030    public ZZapMoscowPriceController(ZZapDataManager zzap, ApiSupplierSettingsService settings, IMemoryCache cache)
 31    {
 032        _zzap = zzap;
 033        _settings = settings;
 034        _cache = cache;
 035    }
 36
 37    [HttpGet]
 38    public async Task<IActionResult> Search(string code, string? brand, int? orderId = null, string? zasylWarehouseName 
 39    {
 040        if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(brand))
 041            return Ok(Array.Empty<PreOrderItem>());
 42
 43        try
 44        {
 045            var settings = await _settings.GetZZapMoscowAsync(HttpContext.RequestAborted);
 046            if (!settings.IsQuotationEnabled)
 047                return Ok(Array.Empty<PreOrderItem>());
 48
 049            var response = await GetSearchResponseAsync(code.Trim(), brand.Trim());
 050            var items = ZZapPriceMapper.MapOriginals(response, code.Trim(), brand.Trim(), settings, _zzap.DefaultCodeReg
 051            return Ok(SupplierQuotationDeliveryDestination.Apply(items, settings.DeliveryAddress));
 52        }
 053        catch (ZZapApiException ex)
 54        {
 055            return ex.StatusCode == 429
 056                ? StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = ex.Message })
 057                : Ok(new { items = Array.Empty<PreOrderItem>(), error = ex.Message });
 58        }
 059        catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
 60        {
 061            return StatusCode(499, new { error = "request_canceled" });
 62        }
 063        catch (Exception ex) when (ex is HttpRequestException or TimeoutException or TaskCanceledException)
 64        {
 065            return Ok(new { items = Array.Empty<PreOrderItem>(), error = "Сервис ZZap временно недоступен." });
 66        }
 067    }
 68
 69    /// <summary>
 70    /// Кэш и объединение одинаковых запросов: параллельные вызовы с тем же артикулом и брендом ждут один
 71    /// ответ ZZap, а не стоят в очереди лимита по очереди. Отмена одного клиента не рвёт общий запрос,
 72    /// ошибки не кэшируются.
 73    /// </summary>
 74    private async Task<JObject> GetSearchResponseAsync(string code, string brand)
 75    {
 076        var key = $"api-supplier:ZZapMoscow:search-light:{_zzap.DefaultCodeRegion}:{code.ToUpperInvariant()}:{brand.ToUp
 77        Task<JObject> task;
 78        // GetOrCreate не атомарен: без блокировки два одновременных запроса оба ушли бы в очередь лимита.
 079        lock (CreateLock)
 80        {
 081            task = _cache.GetOrCreate(key, entry =>
 082            {
 083                entry.AbsoluteExpirationRelativeToNow = ResponseCacheTtl;
 084                var started = _zzap.SearchLightAsync(code, brand, ZZapDataManager.SearchLightNewOnly, ct: CancellationTo
 085                // Если все ждавшие клиенты отменились, ошибку всё равно нужно «наблюдать» (UnobservedTaskException).
 086                _ = started.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
 087                return started;
 088            })!;
 089        }
 90
 91        try
 92        {
 093            return await task.WaitAsync(HttpContext.RequestAborted);
 94        }
 095        catch when (task.IsFaulted || task.IsCanceled)
 96        {
 097            _cache.Remove(key);
 098            throw;
 99        }
 0100    }
 101}