< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.MotexPriceController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/MotexPriceController.cs
Line coverage
92%
Covered lines: 150
Uncovered lines: 12
Coverable lines: 162
Total lines: 255
Line coverage: 92.5%
Branch coverage
73%
Covered branches: 70
Total branches: 95
Branch coverage: 73.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Search()87.5%161692.59%
GetDeliveryAddressesAsync()50%2266.66%
<GetDeliveryAddressesAsync()50%22100%
MapToPreOrderItem(...)73.07%262697.72%
get_Code()100%11100%
ConvertPricesAsync()83.33%66100%
GetRateToRubAsync()20%8550%
ReadDecimal(...)68.75%171683.33%
ParseMotexDate(...)100%44100%
BuildDescription(...)90%1010100%
BuildPriceDescription(...)50%22100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using Microsoft.AspNetCore.Authorization;
 4using Microsoft.AspNetCore.Mvc;
 5using Microsoft.Extensions.Caching.Memory;
 6using Newtonsoft.Json.Linq;
 7using OzonDomains;
 8using OzonDomains.Models;
 9using Services.ApiServices.MotexApi;
 10using Services.ApiServices.TradesoftApi.Models.Response;
 11using Services.ApiSupplierSettingsServices;
 12using Services.PriceСlculationServices;
 13using OzonOrdersWeb.Filters;
 14
 15namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 16
 17[Area("Studio2")]
 18[Authorize(Roles = "User,Admin")]
 19[ServiceFilter(typeof(SupplierQuotationHistoryFilter), Order = 0)]
 20[ServiceFilter(typeof(SupplierDeliverySurchargeFilter), Order = 1)]
 21[ServiceFilter(typeof(SupplierWarehouseRestrictionFilter), Order = 2)]
 22public sealed class MotexPriceController : Controller
 23{
 24    private readonly MotexDataManager _motex;
 25    private readonly ApiSupplierSettingsService _settingsService;
 26    private readonly ICurrencyRateFetcher _currencyRateFetcher;
 27    private readonly IMemoryCache _cache;
 28
 829    public MotexPriceController(
 830        MotexDataManager motex,
 831        ApiSupplierSettingsService settingsService,
 832        ICurrencyRateFetcher currencyRateFetcher,
 833        IMemoryCache cache)
 34    {
 835        _motex = motex;
 836        _settingsService = settingsService;
 837        _currencyRateFetcher = currencyRateFetcher;
 838        _cache = cache;
 839    }
 40
 41    /// <summary>Read-only проценка Motex (МоТехС), приведённая к общему формату строки транзакции.</summary>
 42    [HttpGet]
 43    public async Task<IActionResult> Search(string code, string? brand, int? orderId)
 44    {
 845        if (string.IsNullOrWhiteSpace(code))
 146            return BadRequest(new { error = "Не указан артикул для поиска цен МоТехС." });
 47
 48        try
 49        {
 750            var settings = await _settingsService.GetMotexAsync(HttpContext.RequestAborted);
 751            if (!settings.IsQuotationEnabled)
 152                return Ok(Array.Empty<PreOrderItem>());
 53
 654            var deliveryAddresses = await GetDeliveryAddressesAsync(HttpContext.RequestAborted);
 655            var deliveryAddress = deliveryAddresses.Count == 1 ? deliveryAddresses[0] : null;
 656            var deliveryDestination = settings.DeliveryAddress ?? deliveryAddress?.DisplayName;
 57
 658            var query = new Dictionary<string, string?>
 659            {
 660                ["code"] = code.Trim(),
 661                ["brand"] = brand?.Trim(),
 662                ["withAnalogs"] = "0",
 663                ["deliveryAddressCode"] = deliveryAddress?.Code
 664            };
 65
 666            var response = await _motex.GetAsync("/api/v1/Articles/Search", query, HttpContext.RequestAborted);
 567            var articles = (response["articles"] as JArray ?? new JArray())
 568                .OfType<JObject>()
 1169                .Where(x => (x.Value<int?>("isAnalog") ?? 0) == 0);
 70
 571            var items = articles
 572                .Select(article => MapToPreOrderItem(article, settings.CurrencyCode, settings.SupplierId, settings.Suppl
 573                .Where(item => item != null)
 574                .Cast<PreOrderItem>()
 575                .ToList();
 76
 577            return Ok(await ConvertPricesAsync(items));
 78        }
 179        catch (MotexApiException ex) when (ex.StatusCode == 404)
 80        {
 181            return Ok(Array.Empty<PreOrderItem>());
 82        }
 083        catch (MotexApiException ex)
 84        {
 085            return BadRequest(new { error = ex.Message });
 86        }
 887    }
 88
 89    private async Task<IReadOnlyList<MotexDeliveryAddress>> GetDeliveryAddressesAsync(CancellationToken cancellationToke
 90    {
 91        try
 92        {
 693            var addresses = await _cache.GetOrCreateAsync("api-supplier:motex:delivery-addresses", async entry =>
 694            {
 695                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
 696                var response = await _motex.GetAsync("/api/v1/Users/GetUserDeliveryAddresses", null, cancellationToken);
 697                return (response["deliveryAddresses"] as JArray ?? new JArray())
 698                    .OfType<JObject>()
 799                    .Select(x => new MotexDeliveryAddress(
 7100                        x.Value<string>("code")?.Trim() ?? string.Empty,
 7101                        string.Join(", ", new[] { x.Value<string>("city"), x.Value<string>("address"), x.Value<string>("
 33102                            .Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!.Trim()).Distinct()
 7103                    .Where(x => !string.IsNullOrWhiteSpace(x.Code))
 6104                    .ToList();
 12105            });
 6106            return addresses ?? [];
 107        }
 0108        catch (MotexApiException)
 109        {
 0110            return [];
 111        }
 6112    }
 113
 114    private static PreOrderItem? MapToPreOrderItem(
 115        JObject article,
 116        CurrencyCode fallbackCurrency,
 117        int? preferredSupplierId,
 118        CurrencyCode? preferredSupplierCurrencyCode,
 119        string? deliveryDestination)
 120    {
 5121        var sourcePrice = ReadDecimal(article, "price");
 5122        if (sourcePrice is null)
 0123            return null;
 124
 5125        var quantity = ReadDecimal(article, "quantity") ?? 0m;
 5126        var multiple = ReadDecimal(article, "multiple");
 5127        var storeCode = article.Value<string>("storeCode") ?? string.Empty;
 5128        var storeInfo = article.Value<string>("storeInfo");
 5129        var direction = string.IsNullOrWhiteSpace(storeInfo) ? storeCode : storeInfo;
 5130        var deliveryDate = ParseMotexDate(article.Value<string>("deliveryDate"));
 5131        var deliveryDays = deliveryDate.HasValue
 5132            ? Math.Max(0, (int)Math.Ceiling((deliveryDate.Value - DateTimeOffset.UtcNow).TotalDays))
 5133            : (int?)null;
 5134        var returnPeriod = article.Value<int?>("returnPeriod");
 5135        var itemId = $"{article.Value<string>("code")}|{storeCode}|{sourcePrice.Value.ToString(CultureInfo.InvariantCult
 136
 5137        return new PreOrderItem
 5138        {
 5139            Caption = article.Value<string>("description") ?? article.Value<string>("code") ?? string.Empty,
 5140            Rest = quantity.ToString("0.##", CultureInfo.InvariantCulture),
 5141            DeliveryDays = deliveryDays?.ToString(CultureInfo.InvariantCulture) ?? "-",
 5142            DeliveryDaysMin = deliveryDays ?? 0,
 5143            DeliveryDaysMax = deliveryDays ?? 0,
 5144            CurrencyCode = "RUB",
 5145            Producer = article.Value<string>("brand") ?? string.Empty,
 5146            Code = article.Value<string>("code") ?? string.Empty,
 5147            Direction = direction,
 5148            ItemHash = itemId.GetHashCode(StringComparison.Ordinal).ToString("X", CultureInfo.InvariantCulture),
 5149            ItemId = itemId,
 5150            MinQuantity = multiple.HasValue ? decimal.ToInt32(Math.Max(1m, multiple.Value)) : 1,
 5151            SiteUrl = "https://api.motexc.by/",
 5152            Title = "МоТехС",
 5153            Description = BuildDescription(article, quantity, multiple, direction, deliveryDays, returnPeriod),
 5154            SourcePrice = sourcePrice.Value,
 5155            SourceCurrencyCode = fallbackCurrency.ToString(),
 5156            PreferredSupplierId = preferredSupplierId,
 5157            PreferredSupplierCurrencyCode = preferredSupplierCurrencyCode?.ToString(),
 5158            ProviderData = new Dictionary<string, string?>
 5159            {
 5160                ["providerKey"] = ApiSupplierSettings.MotexProviderKey,
 5161                ["storeCode"] = storeCode,
 5162                ["storeInfo"] = storeInfo,
 5163                ["deliveryDestination"] = deliveryDestination,
 5164                ["deliveryAtUtc"] = deliveryDate?.ToString("O", CultureInfo.InvariantCulture)
 5165            }
 5166        };
 167    }
 168
 24169    private sealed record MotexDeliveryAddress(string Code, string DisplayName);
 170
 171    private async Task<List<PreOrderItem>> ConvertPricesAsync(List<PreOrderItem> items)
 172    {
 5173        var rateCache = new Dictionary<string, decimal>();
 20174        foreach (var item in items)
 175        {
 5176            var currency = Enum.TryParse<CurrencyCode>(item.SourceCurrencyCode, true, out var parsed) ? parsed : Currenc
 5177            if (!rateCache.TryGetValue(item.SourceCurrencyCode!, out var rate))
 178            {
 5179                rate = await GetRateToRubAsync(currency);
 5180                rateCache[item.SourceCurrencyCode!] = rate;
 181            }
 182
 5183            var rubPrice = Math.Round(item.SourcePrice!.Value * rate, 0, MidpointRounding.AwayFromZero);
 5184            item.Price = rubPrice.ToString("0", CultureInfo.InvariantCulture);
 5185            item.CostPrice = rubPrice.ToString("0", CultureInfo.InvariantCulture);
 5186            item.CostPriceFormatted = rubPrice.ToString("0", CultureInfo.InvariantCulture);
 5187            item.PriceDescription = BuildPriceDescription(item.SourcePrice.Value, currency, rate, rubPrice);
 5188        }
 5189        return items;
 5190    }
 191
 5192    private async Task<decimal> GetRateToRubAsync(CurrencyCode currency) => currency switch
 5193    {
 0194        CurrencyCode.RUB => 1m,
 0195        CurrencyCode.USD => await _currencyRateFetcher.GetUSDRateAsync(),
 0196        CurrencyCode.EUR => await _currencyRateFetcher.GetEURRateAsync(),
 5197        CurrencyCode.BYN => await _currencyRateFetcher.GetBYNRateAsync(),
 0198        _ => 1m
 5199    };
 200
 201    private static decimal? ReadDecimal(JObject source, string field)
 202    {
 15203        var token = source[field];
 15204        if (token == null || token.Type is JTokenType.Null or JTokenType.Undefined)
 0205            return null;
 206
 15207        if (token.Type is JTokenType.Integer or JTokenType.Float)
 10208            return token.Value<decimal>();
 209
 5210        var raw = token.Value<string>();
 5211        if (string.IsNullOrWhiteSpace(raw))
 0212            return null;
 213
 214        // Motex иногда возвращает количество с суффиксом ("20>" = "от 20 шт."); берём числовую часть.
 5215        var match = Regex.Match(raw, @"[-+]?[0-9]+(?:[.,][0-9]+)?");
 5216        if (!match.Success)
 0217            return null;
 218
 5219        return decimal.TryParse(
 5220            match.Value.Replace(',', '.'),
 5221            NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
 5222            CultureInfo.InvariantCulture,
 5223            out var value)
 5224            ? value
 5225            : null;
 226    }
 227
 228    private static DateTimeOffset? ParseMotexDate(string? raw) =>
 5229        !string.IsNullOrWhiteSpace(raw) && DateTime.TryParseExact(raw, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, D
 5230            ? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
 5231            : null;
 232
 233    private static string BuildDescription(JObject article, decimal quantity, decimal? multiple, string direction, int? 
 234    {
 5235        var parts = new List<string>
 5236        {
 5237            $"Артикул: {article.Value<string>("code")}",
 5238            $"Бренд: {article.Value<string>("brand")}",
 5239            $"Наличие: {quantity:0.##}"
 5240        };
 10241        if (!string.IsNullOrWhiteSpace(direction)) parts.Add($"Склад: {direction}");
 10242        if (multiple.HasValue) parts.Add($"Кратность: {multiple:0.##}");
 9243        if (deliveryDays.HasValue) parts.Add($"Срок: {deliveryDays} дн.");
 9244        if (returnPeriod.HasValue) parts.Add($"Возврат: {returnPeriod} дн.");
 5245        var resolutionTag = article.Value<int?>("resolutionTag");
 5246        var remainingMarkup = article.Value<string>("remainingMarkup");
 5247        if (resolutionTag == 1) parts.Add($"П.713: остаток надбавки {remainingMarkup}");
 38248        return string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
 249    }
 250
 251    private static string BuildPriceDescription(decimal sourcePrice, CurrencyCode currency, decimal rateToRub, decimal r
 5252        currency == CurrencyCode.RUB
 5253            ? $"Цена API: {sourcePrice:0.##} RUB"
 5254            : $"Цена до перевода: {sourcePrice:0.##} {currency}; курс ЦБ РФ: {rateToRub:0.####}; в таблице: {rubPrice:0}
 255}