< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.ShateMPriceController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/ShateMPriceController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 191
Coverable lines: 191
Total lines: 314
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 149
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using Microsoft.AspNetCore.Authorization;
 3using Microsoft.AspNetCore.Mvc;
 4using Microsoft.Extensions.Caching.Memory;
 5using Newtonsoft.Json.Linq;
 6using OzonDomains;
 7using OzonDomains.Models;
 8using Services.ApiServices.ShateMApi;
 9using Services.ApiServices.TradesoftApi.Models.Response;
 10using Services.ApiSupplierSettingsServices;
 11using Services.PriceСlculationServices;
 12using OzonOrdersWeb.Filters;
 13
 14namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 15
 16[Area("Studio2")]
 17[Authorize(Roles = "User,Admin")]
 18[ServiceFilter(typeof(SupplierQuotationHistoryFilter), Order = 0)]
 19[ServiceFilter(typeof(SupplierDeliverySurchargeFilter), Order = 1)]
 20[ServiceFilter(typeof(SupplierWarehouseRestrictionFilter), Order = 2)]
 21public sealed class ShateMPriceController : Controller
 22{
 23    private readonly ShateMDataManager _shateM;
 24    private readonly ApiSupplierSettingsService _settingsService;
 25    private readonly ICurrencyRateFetcher _currencyRateFetcher;
 26    private readonly IMemoryCache _cache;
 27
 028    public ShateMPriceController(
 029        ShateMDataManager shateM,
 030        ApiSupplierSettingsService settingsService,
 031        ICurrencyRateFetcher currencyRateFetcher,
 032        IMemoryCache cache)
 33    {
 034        _shateM = shateM;
 035        _settingsService = settingsService;
 036        _currencyRateFetcher = currencyRateFetcher;
 037        _cache = cache;
 038    }
 39
 40    /// <summary>Read-only проценка Шате-М, приведённая к общему формату строки транзакции.</summary>
 41    [HttpGet]
 42    public async Task<IActionResult> Search(string code, string? brand, int? orderId = null, bool includeAnalogs = false
 43    {
 044        if (string.IsNullOrWhiteSpace(code))
 045            return BadRequest(new { error = "Не указан артикул для поиска цен Шате‑М." });
 46
 47        try
 48        {
 049            var settings = await _settingsService.GetShateMAsync(HttpContext.RequestAborted);
 050            if (!settings.IsQuotationEnabled)
 051                return Ok(Array.Empty<PreOrderItem>());
 52
 053            var article = await FindArticleAsync(code.Trim(), brand?.Trim());
 054            if (article == null)
 055                return Ok(Array.Empty<PreOrderItem>());
 56
 057            var articleId = article.Value<int?>("id");
 058            if (articleId is null or <= 0)
 059                return Ok(Array.Empty<PreOrderItem>());
 60
 061            var prices = await _shateM.SearchAsync(
 062                "/api/v1/prices/search",
 063                new JArray(new JObject
 064                {
 065                    ["articleId"] = articleId.Value,
 066                    ["includeAnalogs"] = includeAnalogs
 067                }),
 068                HttpContext.RequestAborted);
 069            var locationsByCode = await GetLocationsByCodeAsync(HttpContext.RequestAborted);
 070            var deliveryAddresses = await GetDeliveryAddressesAsync(HttpContext.RequestAborted);
 071            var deliveryDestination = settings.DeliveryAddress ?? (deliveryAddresses.Count == 1 ? deliveryAddresses[0] :
 72
 073            var items = new List<PreOrderItem>();
 074            foreach (var offer in GetOffers(prices))
 75            {
 076                var item = await MapToPreOrderItemAsync(offer, article, settings.CurrencyCode, locationsByCode,
 077                    settings.SupplierId, settings.Supplier?.CurrencyCode, deliveryDestination);
 078                if (item != null)
 079                    items.Add(item);
 80            }
 081            return Ok(items);
 82        }
 083        catch (ShateMApiException ex)
 84        {
 085            return BadRequest(new { error = ex.Message });
 86        }
 087    }
 88
 89    private async Task<JObject?> FindArticleAsync(string code, string? brand)
 90    {
 091        var key = new JObject { ["articleCode"] = code };
 092        if (!string.IsNullOrWhiteSpace(brand))
 093            key["tradeMarkName"] = brand;
 94
 095        var response = await _shateM.SearchAsync(
 096            "/api/v1/articles/search",
 097            new JObject { ["keys"] = new JArray(key) },
 098            HttpContext.RequestAborted);
 99
 0100        var articles = response is JArray array
 101            ? array.Select(x => x["article"] as JObject ?? x as JObject).OfType<JObject>()
 0102            : Enumerable.Empty<JObject>();
 103
 0104        return articles.FirstOrDefault(x => string.Equals(x.Value<string>("code"), code, StringComparison.OrdinalIgnoreC
 0105            ?? articles.FirstOrDefault();
 0106    }
 107
 108    private static IEnumerable<JObject> GetOffers(JToken response)
 109    {
 0110        var rows = response as JArray ?? response["prices"] as JArray;
 0111        if (rows == null)
 0112            return Enumerable.Empty<JObject>();
 113
 0114        return rows.SelectMany(row => row["prices"] is JArray nested ? nested : new JArray(row))
 0115            .OfType<JObject>();
 116    }
 117
 118    private async Task<IReadOnlyDictionary<string, ShateMLocation>> GetLocationsByCodeAsync(CancellationToken cancellati
 119    {
 120        try
 121        {
 0122            var locations = await _cache.GetOrCreateAsync("api-supplier:shatem:locations", async entry =>
 0123            {
 0124                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
 0125                var response = await _shateM.GetAsync("/api/v1/locations", null, cancellationToken);
 0126                return (response as JArray ?? new JArray())
 0127                    .OfType<JObject>()
 128                    .Select(x => new ShateMLocation(
 129                        x.Value<string>("code") ?? string.Empty,
 130                        x.Value<string>("name") ?? string.Empty,
 131                        x.Value<string>("city") ?? string.Empty))
 132                    .Where(x => !string.IsNullOrWhiteSpace(x.Code))
 133                    .GroupBy(x => x.Code, StringComparer.OrdinalIgnoreCase)
 134                    .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase);
 0135            });
 0136            return locations ?? new Dictionary<string, ShateMLocation>(StringComparer.OrdinalIgnoreCase);
 137        }
 0138        catch (ShateMApiException)
 139        {
 0140            return new Dictionary<string, ShateMLocation>(StringComparer.OrdinalIgnoreCase);
 141        }
 0142    }
 143
 144    private async Task<IReadOnlyList<string>> GetDeliveryAddressesAsync(CancellationToken cancellationToken)
 145    {
 146        try
 147        {
 0148            var addresses = await _cache.GetOrCreateAsync("api-supplier:shatem:delivery-addresses", async entry =>
 0149            {
 0150                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
 0151                var response = await _shateM.GetAsync("/api/v1/delivery/addresses", null, cancellationToken);
 0152                return (response as JArray ?? new JArray()).OfType<JObject>()
 153                    .Select(address => string.Join(", ", new[] { address.Value<string>("city"), address.Value<string>("a
 154                        .Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!.Trim()).Distinct()))
 155                    .Where(value => !string.IsNullOrWhiteSpace(value))
 0156                    .Distinct(StringComparer.OrdinalIgnoreCase)
 0157                    .ToList();
 0158            });
 0159            return addresses ?? [];
 160        }
 0161        catch (ShateMApiException)
 162        {
 0163            return [];
 164        }
 0165    }
 166
 167    private async Task<PreOrderItem?> MapToPreOrderItemAsync(
 168        JObject offer,
 169        JObject article,
 170        CurrencyCode fallbackCurrency,
 171        IReadOnlyDictionary<string, ShateMLocation> locationsByCode,
 172        int? preferredSupplierId,
 173        CurrencyCode? preferredSupplierCurrencyCode,
 174        string? deliveryDestination)
 175    {
 0176        var sourcePrice = ReadDecimal(offer, "price.value") ?? ReadDecimal(offer, "price") ?? ReadDecimal(offer, "value"
 0177        if (sourcePrice is null)
 0178            return null;
 179
 0180        var sourceCurrency = ResolveCurrency(offer.SelectToken("price.currencyCode")?.Value<string>()
 0181            ?? offer.Value<string>("currencyCode"), fallbackCurrency);
 0182        var rateToRub = await GetRateToRubAsync(sourceCurrency);
 0183        var rubPrice = Math.Round(sourcePrice.Value * rateToRub, 0, MidpointRounding.AwayFromZero);
 0184        var locationCode = offer.Value<string>("locationCode") ?? offer.SelectToken("location.code")?.Value<string>() ??
 0185        var locationCodeReal = offer.Value<string>("locationCodeReal") ?? string.Empty;
 0186        locationsByCode.TryGetValue(locationCode, out var location);
 0187        if (location == null && !string.IsNullOrWhiteSpace(locationCodeReal))
 0188            locationsByCode.TryGetValue(locationCodeReal, out location);
 0189        var delivery = GetDeliveryDays(offer);
 0190        var quantity = ReadDecimal(offer, "quantity.available") ?? ReadDecimal(offer, "quantity") ?? 0m;
 0191        var minQuantity = ReadDecimal(offer, "quantity.minimum") ?? 0m;
 0192        var multiplicity = ReadDecimal(offer, "quantity.multiplicity");
 0193        var itemId = offer.Value<string>("id") ?? offer.Value<string>("hash")
 0194            ?? $"{article.Value<int?>("id")}|{locationCode}|{sourcePrice.Value.ToString(CultureInfo.InvariantCulture)}";
 0195        var direction = FormatLocation(location, locationCode);
 196
 0197        return new PreOrderItem
 0198        {
 0199            Caption = article.Value<string>("name") ?? article.Value<string>("code") ?? string.Empty,
 0200            Rest = quantity.ToString("0.##", CultureInfo.InvariantCulture),
 0201            DeliveryDays = delivery.Min?.ToString(CultureInfo.InvariantCulture) ?? "-",
 0202            DeliveryDaysMin = delivery.Min ?? 0,
 0203            DeliveryDaysMax = delivery.Max ?? delivery.Min ?? 0,
 0204            CurrencyCode = "RUB",
 0205            Producer = article.Value<string>("tradeMarkName") ?? string.Empty,
 0206            Code = article.Value<string>("code") ?? string.Empty,
 0207            Price = rubPrice.ToString("0", CultureInfo.InvariantCulture),
 0208            CostPrice = rubPrice.ToString("0", CultureInfo.InvariantCulture),
 0209            CostPriceFormatted = rubPrice.ToString("0", CultureInfo.InvariantCulture),
 0210            Direction = direction,
 0211            ItemHash = itemId.GetHashCode(StringComparison.Ordinal).ToString("X", CultureInfo.InvariantCulture),
 0212            ItemId = itemId,
 0213            MinQuantity = decimal.ToInt32(Math.Max(0m, minQuantity)),
 0214            SiteUrl = "https://api.shate-m.by/",
 0215            Title = "Шате‑М",
 0216            Description = BuildDescription(article, offer, quantity, multiplicity, delivery, sourcePrice.Value, sourceCu
 0217            PriceDescription = BuildPriceDescription(sourcePrice.Value, sourceCurrency, rateToRub, rubPrice),
 0218            SourcePrice = sourcePrice.Value,
 0219            SourceCurrencyCode = sourceCurrency.ToString(),
 0220            PreferredSupplierId = preferredSupplierId,
 0221            PreferredSupplierCurrencyCode = preferredSupplierCurrencyCode?.ToString(),
 0222            ProviderData = new Dictionary<string, string?>
 0223            {
 0224                ["providerKey"] = ApiSupplierSettings.ShateMProviderKey,
 0225                ["priceId"] = itemId,
 0226                ["locationCode"] = locationCode,
 0227                ["locationCodeReal"] = locationCodeReal,
 0228                ["deliveryDestination"] = deliveryDestination
 0229            }
 0230        };
 0231    }
 232
 233    private static string FormatLocation(ShateMLocation? location, string fallbackCode)
 234    {
 0235        if (location == null)
 0236            return string.IsNullOrWhiteSpace(fallbackCode) ? "Шате‑М" : fallbackCode;
 0237        if (!string.IsNullOrWhiteSpace(location.City) && !string.IsNullOrWhiteSpace(location.Name))
 0238            return $"{location.City} ({location.Name})";
 0239        return location.Name ?? location.City ?? fallbackCode;
 240    }
 241
 0242    private async Task<decimal> GetRateToRubAsync(CurrencyCode currency) => currency switch
 0243    {
 0244        CurrencyCode.RUB => 1m,
 0245        CurrencyCode.USD => await _currencyRateFetcher.GetUSDRateAsync(),
 0246        CurrencyCode.EUR => await _currencyRateFetcher.GetEURRateAsync(),
 0247        CurrencyCode.BYN => await _currencyRateFetcher.GetBYNRateAsync(),
 0248        _ => 1m
 0249    };
 250
 251    private static CurrencyCode ResolveCurrency(string? rawCurrency, CurrencyCode fallback) =>
 0252        Enum.TryParse<CurrencyCode>(rawCurrency, true, out var currency) && currency != CurrencyCode.NON
 0253            ? currency
 0254            : fallback;
 255
 256    private static decimal? ReadDecimal(JObject source, string path)
 257    {
 0258        var token = source.SelectToken(path);
 0259        if (token == null || token.Type is JTokenType.Null or JTokenType.Undefined)
 0260            return null;
 261
 0262        if (token.Type is JTokenType.Integer or JTokenType.Float)
 0263            return token.Value<decimal>();
 264
 265        // Наличие приходит строкой и может нести знак «не менее» («>10», «10>») — без его снятия
 266        // остаток разбирался как отсутствующий и попадал в проценку нулём.
 0267        var normalized = token.Value<string>()?.Trim().Replace(" ", string.Empty).Trim('>', '<', '~', '=').Replace(',', 
 0268        return decimal.TryParse(
 0269            normalized,
 0270            NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
 0271            CultureInfo.InvariantCulture,
 0272            out var value)
 0273            ? value
 0274            : null;
 275    }
 276
 277    private static (int? Min, int? Max) GetDeliveryDays(JObject offer)
 278    {
 0279        var dates = (offer["deliveryDateTimes"] as JArray ?? new JArray())
 0280            .Select(x => x["deliveryDateTime"]?.Value<string>())
 0281            .Append(offer["shippingDateTime"]?.Value<string>())
 0282            .Where(x => !string.IsNullOrWhiteSpace(x))
 0283            .Select(x => DateTimeOffset.TryParse(x, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out va
 0284            .OfType<DateTimeOffset>()
 0285            .Select(x => Math.Max(0, (int)Math.Ceiling((x - DateTimeOffset.UtcNow).TotalDays)))
 0286            .ToList();
 0287        return dates.Count == 0 ? (null, null) : (dates.Min(), dates.Max());
 288    }
 289
 290    private static string BuildDescription(JObject article, JObject offer, decimal quantity, decimal? multiplicity,
 291        (int? Min, int? Max) delivery, decimal sourcePrice, CurrencyCode currency, decimal rateToRub, string direction)
 292    {
 0293        var parts = new List<string>
 0294        {
 0295            $"Артикул: {article.Value<string>("code")}",
 0296            $"Бренд: {article.Value<string>("tradeMarkName")}",
 0297            $"Наличие: {quantity:0.##}",
 0298            $"Цена API: {sourcePrice:0.##} {currency}; курс: {rateToRub:0.####}"
 0299        };
 0300        if (!string.IsNullOrWhiteSpace(direction)) parts.Add($"Склад: {direction}");
 0301        if (multiplicity.HasValue) parts.Add($"Кратность: {multiplicity:0.##}");
 0302        if (delivery.Min.HasValue) parts.Add(delivery.Max > delivery.Min ? $"Срок: {delivery.Min}–{delivery.Max} дн." : 
 0303        var warning = offer.SelectToken("addInfo.warningText")?.Value<string>() ?? offer.SelectToken("addInfo.comment")?
 0304        if (!string.IsNullOrWhiteSpace(warning)) parts.Add(warning);
 0305        return string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
 306    }
 307
 308    private static string BuildPriceDescription(decimal sourcePrice, CurrencyCode currency, decimal rateToRub, decimal r
 0309        currency == CurrencyCode.RUB
 0310            ? $"Цена API: {sourcePrice:0.##} RUB"
 0311            : $"Цена до перевода: {sourcePrice:0.##} {currency}; курс ЦБ РФ: {rateToRub:0.####}; в таблице: {rubPrice:0}
 312
 0313    private sealed record ShateMLocation(string Code, string? Name, string? City);
 314}