< Summary

Information
Class: OzonOrdersWeb.TransactionServices.MlAutoCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/MlAutoCartService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 239
Coverable lines: 239
Total lines: 368
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 212
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)0%620%
TrackAsync()100%210%
GetActiveAsync(...)100%210%
GetOrdersAsync(...)100%210%
CountActiveAsync(...)100%210%
QueueAsync()0%420200%
CheckoutDirectAsync()0%1482380%
TestAsync()0%210140%
CheckoutAsync()0%420200%
UpdateAddressAsync()0%2040%
RequoteAsync()0%210140%
ReplaceWithRequotedOfferAsync()0%4260%
ImportOrderAsync()0%812280%
SelectedAsync(...)0%620%
BuildRequest(...)0%7280%
RepriceAsync()0%210140%
EnsureTestAccepted(...)0%156120%
Find(...)0%2040%
FindToken(...)0%2040%
ParseDate(...)0%620%
HistoryAsync()0%2040%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/MlAutoCartService.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Text.Json;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Logging;
 6using Microsoft.Extensions.Logging.Abstractions;
 7using Newtonsoft.Json;
 8using Newtonsoft.Json.Linq;
 9using OzonDomains.Models;
 10using OzonOrdersWeb.TransactionServices;
 11using OzonRepositories.Context;
 12using Services.ApiServices.MlAutoApi;
 13using Services.DataServices;
 14using Services.ApiSupplierSettingsServices;
 15using Services.SupplierStatusServices;
 16
 17namespace OzonOrdersWeb.TransactionServices;
 18
 19public sealed record MlAutoCartQueueItem(int OrderId, string Pin, string Brand, string? StorageCode, string? WarehouseNa
 20public sealed record MlAutoCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 21{
 22    public string ToHtmlBlock()
 23    {
 24        var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>");
 25        var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty;
 26        var verificationNotice = Added > 0
 27            ? "<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'></
 28            : string.Empty;
 29
 30        return "<div class='alert alert-info border-primary-subtle mb-0'>" +
 31               "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина ML-Auto BY</
 32               $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b
 33               $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" +
 34               "<div class='d-flex flex-wrap gap-2 mt-2'><a class='btn btn-outline-primary btn-sm' href='/SupplierCart?p
 35               verificationNotice + "</div>";
 36    }
 37}
 38
 39public sealed record MlAutoCartRequoteOffer(string StorageCode, string WarehouseName, decimal Price, decimal? AvailableQ
 40public sealed record MlAutoCartRequoteResult(string OriginalWarehouse, string DeliveryAddress, IReadOnlyList<MlAutoCartR
 41public sealed record MlAutoDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages);
 42
 43public sealed class MlAutoCartService
 44{
 45    private const string Logo = "https://www.ml-auto.by/media/files/settings/inside-placeholder-logo-mobile.png";
 46    private readonly ApplicationDbContext _db; private readonly MlAutoDataManager _api; private readonly MlAutoDeliveryA
 47    private readonly IOrderSupplierStateService _supplierState;
 48    private readonly ILogger<MlAutoCartService> _logger;
 049    public MlAutoCartService(ApplicationDbContext db, MlAutoDataManager api, MlAutoDeliveryAddressMappingService mapping
 050        IOrderHistoryDataService history, IOrderSupplierStateService supplierState, ILogger<MlAutoCartService>? logger =
 51    {
 052        _db = db; _api = api; _mappings = mappings; _history = history; _supplierState = supplierState;
 053        _logger = logger ?? NullLogger<MlAutoCartService>.Instance;
 054    }
 55
 56    /// <summary>
 57    /// Отражает строку корзины в отслеживании статусов поставщика. Переходы внутри корзины
 58    /// (Ready/Processing/Failed) отдельно не пишем — их читает LocalCartStatusReader из самой строки.
 59    /// Сбой журнала не должен ломать работу с корзиной.
 60    /// </summary>
 61    private async Task TrackAsync(Func<Task> track)
 62    {
 063        try { await track(); }
 064        catch (Exception exception) { _logger.LogWarning(exception, "Не удалось обновить состояние заказа у поставщика M
 065    }
 66
 067    public Task<List<MlAutoCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.MlAutoCartItems.Include(x =>
 068    public Task<List<MlAutoOrder>> GetOrdersAsync(CancellationToken ct = default) => _db.MlAutoOrders.AsNoTracking().Inc
 069    public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.MlAutoCartItems.CountAsync(x => MlAutoCartI
 70
 71    public async Task<MlAutoCartResult> QueueAsync(IReadOnlyCollection<MlAutoCartQueueItem> queue, string user, Cancella
 72    {
 073        var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>();
 074        foreach (var item in queue)
 75        {
 076            if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.Pin) || string.IsNullOrWhiteSpace(item.Brand) || ite
 077            if (item.MinimumQuantity > 0 && item.Quantity < item.MinimumQuantity) { failed++; messages.Add($"{item.Pin}:
 078            if (await _db.MlAutoCartItems.AnyAsync(x => x.StudioOrderId == item.OrderId && MlAutoCartItemStatus.Active.C
 079            var destination = await _mappings.ResolveAsync(item.OrderId, item.ZasylWarehouseName, ct);
 080            var cart = new MlAutoCartItem { StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, Pin = item
 081            _db.MlAutoCartItems.Add(cart); await _db.SaveChangesAsync(ct); added++; messages.Add(item.Pin + ": добавлен 
 082            await TrackAsync(() => _supplierState.MarkInCartAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoProvider
 083                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction,
 084        }
 085        return new MlAutoCartResult(added, skipped, failed, messages);
 086    }
 87
 88    /// <summary>Проверяет и оформляет предложения без промежуточного сохранения в локальную корзину.</summary>
 89    public async Task<MlAutoDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<MlAutoCartQueueItem> queue, st
 90    {
 091        var messages = new List<string>();
 092        if (queue.Count == 0) return new(false, ["Не выбраны позиции ML-Auto BY для оформления."]);
 093        if (queue.GroupBy(x => x.OrderId).Any(x => x.Key <= 0 || x.Count() > 1))
 094            return new(false, ["Для прямого оформления ML-Auto BY требуется ровно одно предложение на заказ."]);
 95
 096        var prepared = new List<MlAutoCartItem>();
 097        foreach (var source in queue)
 98        {
 099            if (string.IsNullOrWhiteSpace(source.Pin) || string.IsNullOrWhiteSpace(source.Brand) || source.Quantity <= 0
 0100                (source.MinimumQuantity > 0 && source.Quantity < source.MinimumQuantity))
 101            {
 0102                messages.Add($"{source.Pin}: некорректное количество или предложение ML-Auto BY.");
 0103                continue;
 104            }
 105
 0106            var destination = await _mappings.ResolveAsync(source.OrderId, source.ZasylWarehouseName, ct);
 0107            if (destination is null)
 108            {
 0109                messages.Add($"{source.Pin}: для склада заказа не настроен адрес доставки ML-Auto BY.");
 0110                continue;
 111            }
 112
 0113            var item = new MlAutoCartItem
 0114            {
 0115                StudioOrderId = source.OrderId, Pin = source.Pin.Trim(), Brand = source.Brand.Trim(),
 0116                StorageCode = source.StorageCode, WarehouseName = source.WarehouseName, ProductName = source.ProductName
 0117                DeliveryAddressId = destination.DeliveryAddressId, DeliveryAddressName = destination.DeliveryAddressId,
 0118                Quantity = source.Quantity, SourcePrice = source.SourcePrice, CurrencyCode = source.CurrencyCode ?? "BYN
 0119                DeliveryAtUtc = ParseDate(source.DeliveryAtUtc), CreatedBy = user
 0120            };
 121            try
 122            {
 0123                await RepriceAsync(item, ct);
 0124                var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c
 0125                item.TestResultJson = test.Response.ToString(Formatting.None);
 0126                var confirmed = EnsureTestAccepted(test.Response, test.Message);
 0127                item.SourcePrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE")) ?? item.SourcePrice;
 0128                prepared.Add(item);
 0129            }
 0130            catch (MlAutoApiException ex) { messages.Add($"{source.Pin}: {ex.Message}"); }
 0131        }
 132
 0133        if (messages.Count > 0) return new(false, messages);
 134        // Прямое оформление минует локальную корзину: срок/направление берём обратно из queue
 135        // по OrderId (проверка выше гарантирует не более одного предложения на заказ).
 0136        var sourceByOrderId = queue.ToDictionary(x => x.OrderId);
 0137        foreach (var group in prepared.GroupBy(x => x.DeliveryAddressId!))
 138        {
 139            try
 140            {
 0141                var request = BuildRequest(group.Key, group.ToList(), true);
 0142                var response = await _api.CreateOrderAsync(request, ct);
 0143                var number = Find(response, "ORDER-NUMBER");
 0144                if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto BY не создал заказ: в ответ
 0145                var details = await _api.GetOrderByNumberAsync(number, ct);
 0146                var order = new MlAutoOrder
 0147                {
 0148                    OrderNumber = number, DeliveryAddressId = group.Key, DeliveryAddressName = group.First().DeliveryAdd
 0149                    IsCashless = true, CreatedBy = user, Status = Find(details, "STATUS") ?? "Created",
 0150                    RequestJson = System.Text.Json.JsonSerializer.Serialize(request), ResponseJson = details.ToString(Fo
 0151                };
 0152                foreach (var item in group)
 153                {
 0154                    order.Items.Add(new MlAutoOrderItem { StudioOrderId = item.StudioOrderId, Pin = item.Pin, Brand = it
 0155                    await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ordered, number, ct);
 156                    // При прямом оформлении нет строки локальной корзины, но состояние заказа
 157                    // у поставщика должно появиться немедленно, до первого Hangfire-опроса.
 0158                    sourceByOrderId.TryGetValue(item.StudioOrderId, out var source);
 0159                    await TrackAsync(() => _supplierState.MarkOrderedAsync(
 0160                        item.StudioOrderId, ApiSupplierSettings.MlAutoProviderKey, number, user: user,
 0161                        deliveryDaysMin: source?.DeliveryDaysMin, deliveryDaysMax: source?.DeliveryDaysMax, direction: s
 0162                }
 0163                _db.MlAutoOrders.Add(order);
 0164                await _db.SaveChangesAsync(ct);
 0165                messages.Add($"Создан заказ ML-Auto BY №{number}.");
 0166            }
 0167            catch (MlAutoApiException ex) { messages.Add(ex.Message); return new(false, messages); }
 0168        }
 0169        return new(true, messages);
 0170    }
 171
 172    public async Task<MlAutoCartResult> TestAsync(IReadOnlyCollection<long>? ids, string user, CancellationToken ct = de
 173    {
 0174        var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto BY пуста."
 0175        var result = new List<string>(); var ok = 0; var failed = 0;
 0176        foreach (var item in items)
 177        {
 0178            if (string.IsNullOrWhiteSpace(item.DeliveryAddressId)) { item.Status = MlAutoCartItemStatus.Failed; item.Las
 179            try
 180            {
 0181                var reprice = await RepriceAsync(item, ct);
 0182                var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c
 0183                item.TestResultJson = test.Response.ToString(Formatting.None);
 0184                var confirmed = EnsureTestAccepted(test.Response, test.Message);
 0185                var confirmedPrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE"));
 0186                if (confirmedPrice.HasValue) item.SourcePrice = confirmedPrice;
 0187                var providerResult = confirmed.Value<string>("MSG");
 0188                item.TestedAtUtc = DateTime.UtcNow; item.Status = MlAutoCartItemStatus.Ready; item.LastError = null; ok+
 0189                result.Add($"{item.Pin}: принято ML-Auto BY; склад {item.StorageCode ?? "—"}, цена поставщика {item.Sour
 0190                await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ready, null, ct);
 0191            }
 0192            catch (MlAutoApiException ex) { item.Status = MlAutoCartItemStatus.Failed; item.LastError = ex.Message; fail
 0193            item.UpdatedAtUtc = DateTime.UtcNow;
 0194        }
 0195        await _db.SaveChangesAsync(ct); return new(ok, 0, failed, result);
 0196    }
 197
 198    public async Task<MlAutoCartResult> CheckoutAsync(IReadOnlyCollection<long>? ids, bool cashless, string user, Cancel
 199    {
 0200        var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto BY пуста."
 0201        if (items.Any(x => x.Status != MlAutoCartItemStatus.Ready || !x.TestedAtUtc.HasValue || x.TestedAtUtc.Value < Da
 0202        var messages = new List<string>(); var ok = 0; var failed = 0;
 0203        foreach (var group in items.GroupBy(x => x.DeliveryAddressId))
 204        {
 0205            if (string.IsNullOrWhiteSpace(group.Key)) { failed += group.Count(); messages.Add("Не выбран адрес доставки 
 0206            foreach (var item in group) item.Status = MlAutoCartItemStatus.Processing;
 0207            await _db.SaveChangesAsync(ct);
 208            try
 209            {
 0210                var request = BuildRequest(group.Key, group.ToList(), cashless); var response = await _api.CreateOrderAs
 0211                if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto не создал заказ: в ответе о
 0212                var details = await _api.GetOrderByNumberAsync(number, ct);
 0213                var order = new MlAutoOrder { OrderNumber = number, DeliveryAddressId = group.Key!, DeliveryAddressName 
 0214                foreach (var item in group) { item.Status = MlAutoCartItemStatus.Ordered; item.ActiveStudioOrderId = nul
 0215                _db.MlAutoOrders.Add(order); await _db.SaveChangesAsync(ct); ok += group.Count(); messages.Add("Создан з
 0216            }
 0217            catch (MlAutoApiException ex) { foreach (var item in group) { item.Status = MlAutoCartItemStatus.Failed; ite
 0218        }
 0219        return new(ok, 0, failed, messages);
 0220    }
 221
 0222    public async Task<bool> UpdateAddressAsync(long id, string addressId, string? addressName, CancellationToken ct = de
 223
 224    /// <summary>Возвращает актуальные варианты ML-Auto для сохранённого адреса без привязки к устаревшему складу.</summ
 225    public async Task<MlAutoCartRequoteResult> RequoteAsync(long cartItemId, CancellationToken ct = default)
 226    {
 0227        var item = await _db.MlAutoCartItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoCartIte
 0228            ?? throw new InvalidOperationException("Позиция корзины не найдена или уже оформляется.");
 0229        if (string.IsNullOrWhiteSpace(item.DeliveryAddressId))
 0230            throw new InvalidOperationException("Сначала выберите адрес доставки ML-Auto для этой позиции.");
 231
 0232        var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN
 0233        var offers = (response as JArray ?? new JArray()).OfType<JObject>()
 0234            .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq
 0235            .Select(x => new MlAutoCartRequoteOffer(
 0236                x.Value<string>("STORAGE_CODE") ?? string.Empty,
 0237                x.Value<string>("STORAGE_NAME") ?? x.Value<string>("STORAGE") ?? "Склад ML-Auto",
 0238                MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")) ?? -1m,
 0239                MlAutoValueParser.ParseQuantity(x.Value<string>("QUANTITY")),
 0240                MlAutoValueParser.ParseDate(x.Value<string>("DATE"))))
 0241            .Where(x => x.Price >= 0 && x.AvailableQuantity.GetValueOrDefault() >= item.Quantity && !string.IsNullOrWhit
 0242            .OrderBy(x => x.Price).ThenBy(x => x.DeliveryAtUtc ?? DateTime.MaxValue).Take(8).ToArray();
 0243        return new MlAutoCartRequoteResult(
 0244            string.IsNullOrWhiteSpace(item.WarehouseName) ? item.StorageCode ?? "—" : $"{item.WarehouseName} ({item.Stor
 0245            item.DeliveryAddressName ?? item.DeliveryAddressId,
 0246            offers);
 0247    }
 248
 249    /// <summary>Заменяет строку корзины выбранным свежим предложением и требует повторную проверку.</summary>
 250    public async Task<bool> ReplaceWithRequotedOfferAsync(long cartItemId, string storageCode, CancellationToken ct = de
 251    {
 0252        var item = await _db.MlAutoCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoCartItemStatus.Active.
 0253        if (item is null || item.Status == MlAutoCartItemStatus.Processing) return false;
 0254        var offer = (await RequoteAsync(cartItemId, ct)).Offers.FirstOrDefault(x => string.Equals(x.StorageCode, storage
 0255        if (offer is null) throw new InvalidOperationException("Выбранное предложение уже не актуально. Выполните процен
 0256        item.StorageCode = offer.StorageCode;
 0257        item.WarehouseName = offer.WarehouseName;
 0258        item.SourcePrice = offer.Price;
 0259        item.DeliveryAtUtc = offer.DeliveryAtUtc;
 0260        item.Status = MlAutoCartItemStatus.Pending;
 0261        item.TestedAtUtc = null;
 0262        item.TestResultJson = null;
 0263        item.LastError = "Выбрано актуальное предложение ML-Auto. Выполните проверку перед оформлением.";
 0264        item.UpdatedAtUtc = DateTime.UtcNow;
 0265        await _db.SaveChangesAsync(ct);
 0266        return true;
 0267    }
 268
 269    public async Task<MlAutoCartResult> ImportOrderAsync(string orderNumber, string user, CancellationToken ct = default
 270    {
 0271        if (await _db.MlAutoOrders.AnyAsync(x => x.OrderNumber == orderNumber, ct))
 0272            return new(0, 1, 0, [$"Заказ ML-Auto №{orderNumber} уже импортирован."]);
 0273        var response = await _api.GetOrderByNumberAsync(orderNumber, ct);
 0274        var sourceItems = FindToken(response, "ITEMS") as JArray ?? new JArray();
 0275        if (sourceItems.Count == 0) return new(0, 0, 1, [$"ML-Auto не вернул позиции заказа №{orderNumber}."]);
 0276        var order = new MlAutoOrder { OrderNumber = orderNumber, DeliveryAddressId = string.Empty, Status = Find(respons
 0277        var imported = 0;
 0278        foreach (var source in sourceItems.OfType<JObject>())
 279        {
 0280            var pin = source.Value<string>("PIN") ?? string.Empty;
 0281            var brand = source.Value<string>("BRAND") ?? string.Empty;
 0282            var cart = await _db.MlAutoCartItems.FirstOrDefaultAsync(x => MlAutoCartItemStatus.Active.Contains(x.Status)
 0283            if (cart is null) continue;
 0284            cart.Status = MlAutoCartItemStatus.Ordered; cart.ActiveStudioOrderId = null; cart.LastError = null;
 0285            order.DeliveryAddressId = string.IsNullOrWhiteSpace(order.DeliveryAddressId) ? cart.DeliveryAddressId ?? str
 0286            order.DeliveryAddressName ??= cart.DeliveryAddressName;
 0287            order.Items.Add(new MlAutoOrderItem { MlAutoCartItemId = cart.Id, StudioOrderId = cart.StudioOrderId, Pin = 
 0288            await HistoryAsync(cart, user, true, null, ApiSupplierBasketCartStatus.Ordered, orderNumber, ct);
 0289            await TrackAsync(() => _supplierState.MarkOrderedAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoProvide
 0290            imported++;
 0291        }
 0292        if (imported == 0) return new(0, 0, 1, [$"В корзине нет активных позиций, совпадающих с заказом ML-Auto №{orderN
 0293        _db.MlAutoOrders.Add(order); await _db.SaveChangesAsync(ct);
 0294        return new(imported, 0, 0, [$"Заказ ML-Auto №{orderNumber} импортирован: позиций {imported}."]);
 0295    }
 0296    public async Task<int> RemoveAsync(IReadOnlyCollection<long>? ids, CancellationToken ct = default) { var items = awa
 0297    private Task<List<MlAutoCartItem>> SelectedAsync(IReadOnlyCollection<long>? ids, CancellationToken ct) { var q = _db
 298    /// <summary>ML-Auto принимает массив позиций как PHP form-поля <c>ITEMS[1][PIN]</c>, а не JSON в поле ITEMS.</summa
 299    private static Dictionary<string, string?> BuildRequest(string address, IReadOnlyCollection<MlAutoCartItem> items, b
 300    {
 0301        var request = new Dictionary<string, string?>
 0302        {
 0303            ["ADDRESS_ID"] = address,
 0304            ["ONE-TIME-DELIVERY"] = "1",
 0305            ["BEZNAL"] = cashless ? "1" : "0"
 0306        };
 0307        foreach (var (item, index) in items.Select((item, index) => (item, index + 1)))
 308        {
 0309            var prefix = $"ITEMS[{index}]";
 0310            request[$"{prefix}[PIN]"] = item.Pin;
 0311            request[$"{prefix}[BRAND]"] = item.Brand;
 0312            request[$"{prefix}[QUANTITY]"] = item.Quantity.ToString("0.###", CultureInfo.InvariantCulture);
 0313            request[$"{prefix}[STORAGE-CODE]"] = item.StorageCode;
 0314            request[$"{prefix}[MAX-PRICE]"] = item.SourcePrice?.ToString("0.####", CultureInfo.InvariantCulture);
 0315            request[$"{prefix}[MAX-DATE]"] = item.DeliveryAtUtc?.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture
 316        }
 0317        return request;
 318    }
 319    private async Task<string> RepriceAsync(MlAutoCartItem item, CancellationToken ct)
 320    {
 0321        var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN
 0322        var offers = (response as JArray ?? new JArray()).OfType<JObject>()
 0323            .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq
 0324            .Select(x => new { Token = x, Price = MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")), Quantity = M
 0325            .Where(x => x.Price.HasValue && x.Quantity.GetValueOrDefault() >= item.Quantity && x.Minimum <= item.Quantit
 0326            .OrderByDescending(x => string.Equals(x.Token.Value<string>("STORAGE_CODE"), item.StorageCode, StringCompari
 0327            .ThenBy(x => x.Price).ThenBy(x => x.Date ?? DateTime.MaxValue).ToArray();
 0328        var offer = offers.FirstOrDefault() ?? throw new MlAutoApiException("ML-Auto: актуальное предложение для проверк
 0329        var changed = item.StorageCode != offer.Token.Value<string>("STORAGE_CODE") || item.SourcePrice != offer.Price |
 0330        item.StorageCode = offer.Token.Value<string>("STORAGE_CODE");
 0331        item.SourcePrice = offer.Price;
 0332        item.DeliveryAtUtc = offer.Date;
 0333        return changed ? " Данные корзины обновлены по актуальной проценке." : string.Empty;
 0334    }
 335
 336    private static JObject EnsureTestAccepted(JToken response, string? providerMessage)
 337    {
 0338        var items = FindToken(response, "ITEMS");
 0339        if (items is JArray { Count: > 0 } array && array.OfType<JObject>().FirstOrDefault() is { } result) return resul
 0340        if (items is JObject { HasValues: true } item) return item;
 0341        throw new MlAutoApiException(string.IsNullOrWhiteSpace(providerMessage)
 0342            ? "ML-Auto не подтвердил ни одной позиции. Проверьте минимальное количество, склад, цену и срок поставки."
 0343            : $"ML-Auto: {providerMessage}. Поставщик не подтвердил позицию.");
 344    }
 0345    private static string? Find(JToken token, string name) => (token is JContainer container ? container.DescendantsAndS
 0346    private static JToken? FindToken(JToken token, string name) => (token is JContainer container ? container.Descendant
 0347    private static DateTime? ParseDate(string? value) => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTime
 348    private async Task HistoryAsync(MlAutoCartItem item, string user, bool success, string? error, string status, string
 349    {
 350        try
 351        {
 0352            var entry = new ApiSupplierBasketHistoryEntry("MlAuto", "ML-Auto BY", Logo, success, error, null, item.Pin,
 0353                item.ProductName ?? item.Brand, item.SourcePrice, item.CurrencyCode, item.Quantity, item.StudioOrder?.Sh
 0354                CartStatus: status, SupplierOrderNumber: number, DeliveryAddressName: item.DeliveryAddressName,
 0355                SupplierCartUrl: "/SupplierCart?provider=mlauto&supplierTab=basket");
 0356            await _history.AddOrderHistory(new OrderHistory
 0357            {
 0358                OrderId = item.StudioOrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 0359                ColumnDisplayName = "Корзина ML-Auto BY", NewValue = System.Text.Json.JsonSerializer.Serialize(entry, Ap
 0360                ChangedAt = DateTime.Now, ChangedBy = user
 0361            });
 0362        }
 0363        catch (Exception exception)
 364        {
 0365            _logger.LogWarning(exception, "Не удалось записать историю корзины ML-Auto BY для заказа {OrderId}", item.St
 0366        }
 0367    }
 368}