< Summary

Information
Class: OzonOrdersWeb.TransactionServices.MlAutoRuCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/MlAutoRuCartService.cs
Line coverage
98%
Covered lines: 275
Uncovered lines: 4
Coverable lines: 279
Total lines: 443
Line coverage: 98.5%
Branch coverage
84%
Covered branches: 198
Total branches: 234
Branch coverage: 84.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
TrackAsync()100%11100%
GetActiveAsync(...)100%11100%
GetOrdersAsync(...)100%11100%
CountActiveAsync(...)100%11100%
QueueAsync()95%2020100%
CheckoutDirectAsync()92.1%383893.65%
RefreshOrderDetailsAsync()50%44100%
FindPlacedOrderNumbersAsync()91.66%1212100%
TestAsync()85.71%1414100%
CheckoutAsync()85%2020100%
UpdateAddressAsync()100%44100%
RequoteAsync()85.71%1414100%
ReplaceWithRequotedOfferAsync()100%66100%
ImportOrderAsync()75%2828100%
SelectedAsync(...)100%22100%
BuildRequest(...)100%88100%
RepriceAsync()78.57%1414100%
EnsureTestAccepted(...)100%1212100%
Find(...)75%44100%
FindToken(...)50%44100%
ParseDate(...)50%22100%
HistoryAsync()100%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/MlAutoRuCartService.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 MlAutoRuCartQueueItem(int OrderId, string Pin, string Brand, string? StorageCode, string? Warehouse
 20public sealed record MlAutoRuCartResult(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 RU</
 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 MlAutoRuCartRequoteOffer(string StorageCode, string WarehouseName, decimal Price, decimal? Availabl
 40public sealed record MlAutoRuCartRequoteResult(string OriginalWarehouse, string DeliveryAddress, IReadOnlyList<MlAutoRuC
 41public sealed record MlAutoRuDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages);
 42
 43public sealed class MlAutoRuCartService
 44{
 45    private const string Logo = "https://ml-auto.ru/media/files/settings/inside-placeholder-logo-mobile.png";
 46    /// <summary>Ключ поставщика в <c>orders_history</c> (ApiSupplierBasketPlacement); под тем же ColumnName пишут и дру
 47    private const string HistoryProviderKey = "MlAutoRu";
 48    private readonly ApplicationDbContext _db; private readonly MlAutoRuDataManager _api; private readonly MlAutoRuDeliv
 49    private readonly IOrderSupplierStateService _supplierState;
 50    private readonly ILogger<MlAutoRuCartService> _logger;
 6651    public MlAutoRuCartService(ApplicationDbContext db, MlAutoRuDataManager api, MlAutoRuDeliveryAddressMappingService m
 6652        IOrderHistoryDataService history, IOrderSupplierStateService supplierState, ILogger<MlAutoRuCartService>? logger
 53    {
 33054        _db = db; _api = api; _mappings = mappings; _history = history; _supplierState = supplierState;
 6655        _logger = logger ?? NullLogger<MlAutoRuCartService>.Instance;
 6656    }
 57
 58    /// <summary>
 59    /// Отражает строку корзины в отслеживании статусов поставщика. Переходы внутри корзины
 60    /// читает LocalCartStatusReader напрямую из строки, поэтому здесь только появление,
 61    /// оформление и уход из корзины. Сбой журнала не ломает работу с корзиной.
 62    /// </summary>
 63    private async Task TrackAsync(Func<Task> track)
 64    {
 4065        try { await track(); }
 666        catch (Exception exception) { _logger.LogWarning(exception, "Не удалось обновить состояние заказа у поставщика M
 2167    }
 68
 169    public Task<List<MlAutoRuCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.MlAutoRuCartItems.Include(
 170    public Task<List<MlAutoRuOrder>> GetOrdersAsync(CancellationToken ct = default) => _db.MlAutoRuOrders.AsNoTracking()
 171    public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.MlAutoRuCartItems.CountAsync(x => MlAutoRuC
 72
 73    public async Task<MlAutoRuCartResult> QueueAsync(IReadOnlyCollection<MlAutoRuCartQueueItem> queue, string user, Canc
 74    {
 5675        var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>();
 5676        foreach (var item in queue)
 77        {
 3878            if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.Pin) || string.IsNullOrWhiteSpace(item.Brand) || ite
 979            if (item.MinimumQuantity > 0 && item.Quantity < item.MinimumQuantity) { failed++; messages.Add($"{item.Pin}:
 880            if (await _db.MlAutoRuCartItems.AnyAsync(x => x.StudioOrderId == item.OrderId && MlAutoRuCartItemStatus.Acti
 481            var destination = await _mappings.ResolveAsync(item.OrderId, item.ZasylWarehouseName, ct);
 482            var cart = new MlAutoRuCartItem { StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, Pin = it
 2083            _db.MlAutoRuCartItems.Add(cart); await _db.SaveChangesAsync(ct); added++; messages.Add(item.Pin + ": добавле
 884            await TrackAsync(() => _supplierState.MarkInCartAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoRuProvid
 885                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction,
 486        }
 1487        return new MlAutoRuCartResult(added, skipped, failed, messages);
 1488    }
 89
 90    /// <summary>Проверяет и оформляет предложения без промежуточного сохранения в локальную корзину.</summary>
 91    public async Task<MlAutoRuDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<MlAutoRuCartQueueItem> queue
 92    {
 2393        var messages = new List<string>();
 2494        if (queue.Count == 0) return new(false, ["Не выбраны позиции ML-Auto RU для оформления."]);
 6995        if (queue.GroupBy(x => x.OrderId).Any(x => x.Key <= 0 || x.Count() > 1))
 196            return new(false, ["Для прямого оформления ML-Auto RU требуется ровно одно предложение на заказ."]);
 97
 98        // Идемпотентность: заказ Studio2, уже оформленный у ML-Auto RU, повторно не отправляем
 99        // (повторная отправка транзакции после сбоя иначе создаёт у поставщика дубль реального заказа).
 43100        var alreadyPlaced = await FindPlacedOrderNumbersAsync(queue.Select(x => x.OrderId).ToArray(), ct);
 43101        var skippedMessages = queue.Where(x => alreadyPlaced.ContainsKey(x.OrderId))
 27102            .Select(x => $"{x.Pin}: уже оформлено, заказ ML-Auto RU №{alreadyPlaced[x.OrderId]}; повторно не отправлялся
 43103        var pending = queue.Where(x => !alreadyPlaced.ContainsKey(x.OrderId)).ToList();
 26104        if (pending.Count == 0) return new(true, skippedMessages);
 105
 16106        var prepared = new List<MlAutoRuCartItem>();
 64107        foreach (var source in pending)
 108        {
 16109            if (string.IsNullOrWhiteSpace(source.Pin) || string.IsNullOrWhiteSpace(source.Brand) || source.Quantity <= 0
 16110                (source.MinimumQuantity > 0 && source.Quantity < source.MinimumQuantity))
 111            {
 1112                messages.Add($"{source.Pin}: некорректное количество или предложение ML-Auto RU.");
 1113                continue;
 114            }
 115
 15116            var destination = await _mappings.ResolveAsync(source.OrderId, source.ZasylWarehouseName, ct);
 15117            if (destination is null)
 118            {
 1119                messages.Add($"{source.Pin}: для склада заказа не настроен адрес доставки ML-Auto RU.");
 1120                continue;
 121            }
 122
 14123            var item = new MlAutoRuCartItem
 14124            {
 14125                StudioOrderId = source.OrderId, Pin = source.Pin.Trim(), Brand = source.Brand.Trim(),
 14126                StorageCode = source.StorageCode, WarehouseName = source.WarehouseName, ProductName = source.ProductName
 14127                DeliveryAddressId = destination.DeliveryAddressId, DeliveryAddressName = destination.DeliveryAddressId,
 14128                Quantity = source.Quantity, SourcePrice = source.SourcePrice, CurrencyCode = source.CurrencyCode ?? "RUB
 14129                DeliveryAtUtc = ParseDate(source.DeliveryAtUtc), CreatedBy = user
 14130            };
 131            try
 132            {
 14133                await RepriceAsync(item, ct);
 13134                var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c
 13135                item.TestResultJson = test.Response.ToString(Formatting.None);
 13136                var confirmed = EnsureTestAccepted(test.Response, test.Message);
 13137                item.SourcePrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE")) ?? item.SourcePrice;
 13138                prepared.Add(item);
 13139            }
 3140            catch (MlAutoApiException ex) { messages.Add($"{source.Pin}: {ex.Message}"); }
 14141        }
 142
 19143        if (messages.Count > 0) return new(false, messages);
 144        // Прямое оформление минует локальную корзину: срок/направление берём обратно из queue
 145        // по OrderId (проверка выше гарантирует не более одного предложения на заказ).
 26146        var sourceByOrderId = pending.ToDictionary(x => x.OrderId);
 13147        var created = new List<string>();
 64148        foreach (var group in prepared.GroupBy(x => x.DeliveryAddressId!))
 149        {
 150            Dictionary<string, string?> request;
 151            string number;
 152            JToken response;
 153            try
 154            {
 13155                request = BuildRequest(group.Key, group.ToList(), true);
 13156                response = await _api.CreateOrderAsync(request, ct);
 13157                number = Find(response, "ORDER-NUMBER") ?? string.Empty;
 14158                if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto RU не создал заказ: в ответ
 12159            }
 2160            catch (MlAutoApiException ex) { return new(false, [.. skippedMessages, .. created, ex.Message]); }
 161
 162            // С этого места заказ у поставщика уже существует: любой сбой дальше не должен терять его номер,
 163            // иначе повторная отправка создаст дубль. Поэтому сохраняем сразу и без токена запроса.
 12164            var order = new MlAutoRuOrder
 12165            {
 12166                OrderNumber = number, DeliveryAddressId = group.Key, DeliveryAddressName = group.First().DeliveryAddress
 12167                IsCashless = true, CreatedBy = user, Status = "Created",
 12168                RequestJson = System.Text.Json.JsonSerializer.Serialize(request), ResponseJson = response.ToString(Forma
 12169            };
 48170            foreach (var item in group)
 12171                order.Items.Add(new MlAutoRuOrderItem { StudioOrderId = item.StudioOrderId, Pin = item.Pin, Brand = item
 172            try
 173            {
 12174                _db.MlAutoRuOrders.Add(order);
 12175                await _db.SaveChangesAsync(CancellationToken.None);
 12176            }
 0177            catch (Exception exception)
 178            {
 0179                _logger.LogError(exception, "Заказ ML-Auto RU №{OrderNumber} создан, но не сохранён в БД; защита от повт
 0180                _db.ChangeTracker.Clear();
 0181            }
 182
 48183            foreach (var item in group)
 184            {
 12185                await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ordered, number, CancellationToke
 186                // Прямое оформление обходит локальную корзину: показываем статус сразу.
 12187                sourceByOrderId.TryGetValue(item.StudioOrderId, out var source);
 24188                await TrackAsync(() => _supplierState.MarkOrderedAsync(
 24189                    item.StudioOrderId, ApiSupplierSettings.MlAutoRuProviderKey, number, user: user,
 24190                    deliveryDaysMin: source?.DeliveryDaysMin, deliveryDaysMax: source?.DeliveryDaysMax, direction: sourc
 12191            }
 192
 12193            await RefreshOrderDetailsAsync(order, number);
 12194            created.Add($"Создан заказ ML-Auto RU №{number}.");
 12195        }
 12196        return new(true, [.. skippedMessages, .. created]);
 23197    }
 198
 199    /// <summary>Подтягивает статус и ответ заказа поставщика. Заказ уже создан, поэтому сбой здесь только логируется.</
 200    private async Task RefreshOrderDetailsAsync(MlAutoRuOrder order, string number)
 201    {
 202        try
 203        {
 204            // Id нет, если сохранение заказа не удалось: обновлять в БД нечего.
 12205            if (order.Id <= 0) return;
 12206            var details = await _api.GetOrderByNumberAsync(number, CancellationToken.None);
 11207            order.Status = Find(details, "STATUS") ?? order.Status;
 11208            order.ResponseJson = details.ToString(Formatting.None);
 11209            await _db.SaveChangesAsync(CancellationToken.None);
 11210        }
 1211        catch (Exception exception)
 212        {
 1213            _logger.LogWarning(exception, "Не удалось получить детали заказа ML-Auto RU №{OrderNumber}; заказ уже создан
 1214        }
 12215    }
 216
 217    /// <summary>
 218    /// Номера заказов ML-Auto RU, в которые заказ Studio2 уже оформлен: по сохранённым позициям <c>MlAutoRuOrders</c>,
 219    /// а если их нет — по успешной записи <c>ApiSupplierBasketPlacement</c> (ProviderKey = MlAutoRu, статус Ordered) в 
 220    /// Записи корзины (Pending/Ready) и других поставщиков не считаются оформлением.
 221    /// </summary>
 222    private async Task<Dictionary<int, string>> FindPlacedOrderNumbersAsync(IReadOnlyCollection<int> studioOrderIds, Can
 223    {
 21224        var result = new Dictionary<int, string>();
 43225        var ids = studioOrderIds.Where(x => x > 0).Distinct().ToArray();
 21226        if (ids.Length == 0) return result;
 227
 21228        var saved = await _db.MlAutoRuOrders.AsNoTracking()
 21229            .SelectMany(order => order.Items.Where(item => ids.Contains(item.StudioOrderId) && item.Error == null),
 21230                (order, item) => new { item.StudioOrderId, order.OrderNumber, order.CreatedAtUtc })
 21231            .OrderBy(x => x.CreatedAtUtc).ToListAsync(ct);
 54232        foreach (var row in saved) result.TryAdd(row.StudioOrderId, row.OrderNumber);
 233
 100234        foreach (var id in ids.Where(x => !result.ContainsKey(x)))
 235        {
 18236            var number = (await _history.GetByOrderIdAsync(id))
 6237                .Where(x => string.Equals(x.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName, StringCompariso
 6238                .Select(x => ApiSupplierBasketHistoryEntry.TryParse(x.NewValue))
 24239                .FirstOrDefault(x => x is { Success: true, CartStatus: ApiSupplierBasketCartStatus.Ordered } &&
 24240                                     string.Equals(x.ProviderKey, HistoryProviderKey, StringComparison.Ordinal) &&
 24241                                     !string.IsNullOrWhiteSpace(x.SupplierOrderNumber))?.SupplierOrderNumber;
 20242            if (number is not null) result[id] = number;
 243        }
 21244        return result;
 21245    }
 246
 247    public async Task<MlAutoRuCartResult> TestAsync(IReadOnlyCollection<long>? ids, string user, CancellationToken ct = 
 248    {
 19249        var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto RU пуста."
 24250        var result = new List<string>(); var ok = 0; var failed = 0;
 32251        foreach (var item in items)
 252        {
 12253            if (string.IsNullOrWhiteSpace(item.DeliveryAddressId)) { item.Status = MlAutoRuCartItemStatus.Failed; item.L
 254            try
 255            {
 7256                var reprice = await RepriceAsync(item, ct);
 6257                var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c
 6258                item.TestResultJson = test.Response.ToString(Formatting.None);
 6259                var confirmed = EnsureTestAccepted(test.Response, test.Message);
 4260                var confirmedPrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE"));
 8261                if (confirmedPrice.HasValue) item.SourcePrice = confirmedPrice;
 4262                var providerResult = confirmed.Value<string>("MSG");
 16263                item.TestedAtUtc = DateTime.UtcNow; item.Status = MlAutoRuCartItemStatus.Ready; item.LastError = null; o
 4264                result.Add($"{item.Pin}: принято ML-Auto RU; склад {item.StorageCode ?? "—"}, цена поставщика {item.Sour
 4265                await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ready, null, ct);
 4266            }
 18267            catch (MlAutoApiException ex) { item.Status = MlAutoRuCartItemStatus.Failed; item.LastError = ex.Message; fa
 7268            item.UpdatedAtUtc = DateTime.UtcNow;
 7269        }
 16270        await _db.SaveChangesAsync(ct); return new(ok, 0, failed, result);
 9271    }
 272
 273    public async Task<MlAutoRuCartResult> CheckoutAsync(IReadOnlyCollection<long>? ids, bool cashless, string user, Canc
 274    {
 11275        var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto RU пуста."
 10276        if (items.Any(x => x.Status != MlAutoRuCartItemStatus.Ready || !x.TestedAtUtc.HasValue || x.TestedAtUtc.Value < 
 6277        var messages = new List<string>(); var ok = 0; var failed = 0;
 10278        foreach (var group in items.GroupBy(x => x.DeliveryAddressId))
 279        {
 2280            if (string.IsNullOrWhiteSpace(group.Key)) { failed += group.Count(); messages.Add("Не выбран адрес доставки 
 10281            foreach (var item in group) item.Status = MlAutoRuCartItemStatus.Processing;
 2282            await _db.SaveChangesAsync(ct);
 283            try
 284            {
 5285                var request = BuildRequest(group.Key, group.ToList(), cashless); var response = await _api.CreateOrderAs
 1286                if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto RU не создал заказ: в ответ
 1287                var details = await _api.GetOrderByNumberAsync(number, ct);
 1288                var order = new MlAutoRuOrder { OrderNumber = number, DeliveryAddressId = group.Key!, DeliveryAddressNam
 12289                foreach (var item in group) { item.Status = MlAutoRuCartItemStatus.Ordered; item.ActiveStudioOrderId = n
 4290                _db.MlAutoRuOrders.Add(order); await _db.SaveChangesAsync(ct); ok += group.Count(); messages.Add("Создан
 1291            }
 12292            catch (MlAutoApiException ex) { foreach (var item in group) { item.Status = MlAutoRuCartItemStatus.Failed; i
 2293        }
 2294        return new(ok, 0, failed, messages);
 5295    }
 296
 23297    public async Task<bool> UpdateAddressAsync(long id, string addressId, string? addressName, CancellationToken ct = de
 298
 299    /// <summary>Возвращает актуальные варианты ML-Auto RU для сохранённого адреса без привязки к устаревшему складу.</s
 300    public async Task<MlAutoRuCartRequoteResult> RequoteAsync(long cartItemId, CancellationToken ct = default)
 301    {
 5302        var item = await _db.MlAutoRuCartItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoRuCar
 5303            ?? throw new InvalidOperationException("Позиция корзины не найдена или уже оформляется.");
 4304        if (string.IsNullOrWhiteSpace(item.DeliveryAddressId))
 1305            throw new InvalidOperationException("Сначала выберите адрес доставки ML-Auto RU для этой позиции.");
 306
 3307        var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN
 3308        var offers = (response as JArray ?? new JArray()).OfType<JObject>()
 6309            .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq
 5310            .Select(x => new MlAutoRuCartRequoteOffer(
 5311                x.Value<string>("STORAGE_CODE") ?? string.Empty,
 5312                x.Value<string>("STORAGE_NAME") ?? x.Value<string>("STORAGE") ?? "Склад ML-Auto RU",
 5313                MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")) ?? -1m,
 5314                MlAutoValueParser.ParseQuantity(x.Value<string>("QUANTITY")),
 5315                MlAutoValueParser.ParseDate(x.Value<string>("DATE"))))
 5316            .Where(x => x.Price >= 0 && x.AvailableQuantity.GetValueOrDefault() >= item.Quantity && !string.IsNullOrWhit
 11317            .OrderBy(x => x.Price).ThenBy(x => x.DeliveryAtUtc ?? DateTime.MaxValue).Take(8).ToArray();
 3318        return new MlAutoRuCartRequoteResult(
 3319            string.IsNullOrWhiteSpace(item.WarehouseName) ? item.StorageCode ?? "—" : $"{item.WarehouseName} ({item.Stor
 3320            item.DeliveryAddressName ?? item.DeliveryAddressId,
 3321            offers);
 3322    }
 323
 324    /// <summary>Заменяет строку корзины выбранным свежим предложением и требует повторную проверку.</summary>
 325    public async Task<bool> ReplaceWithRequotedOfferAsync(long cartItemId, string storageCode, CancellationToken ct = de
 326    {
 4327        var item = await _db.MlAutoRuCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoRuCartItemStatus.Act
 6328        if (item is null || item.Status == MlAutoRuCartItemStatus.Processing) return false;
 4329        var offer = (await RequoteAsync(cartItemId, ct)).Offers.FirstOrDefault(x => string.Equals(x.StorageCode, storage
 3330        if (offer is null) throw new InvalidOperationException("Выбранное предложение уже не актуально. Выполните процен
 1331        item.StorageCode = offer.StorageCode;
 1332        item.WarehouseName = offer.WarehouseName;
 1333        item.SourcePrice = offer.Price;
 1334        item.DeliveryAtUtc = offer.DeliveryAtUtc;
 1335        item.Status = MlAutoRuCartItemStatus.Pending;
 1336        item.TestedAtUtc = null;
 1337        item.TestResultJson = null;
 1338        item.LastError = "Выбрано актуальное предложение ML-Auto RU. Выполните проверку перед оформлением.";
 1339        item.UpdatedAtUtc = DateTime.UtcNow;
 1340        await _db.SaveChangesAsync(ct);
 1341        return true;
 3342    }
 343
 344    public async Task<MlAutoRuCartResult> ImportOrderAsync(string orderNumber, string user, CancellationToken ct = defau
 345    {
 4346        if (await _db.MlAutoRuOrders.AnyAsync(x => x.OrderNumber == orderNumber, ct))
 1347            return new(0, 1, 0, [$"Заказ ML-Auto RU №{orderNumber} уже импортирован."]);
 3348        var response = await _api.GetOrderByNumberAsync(orderNumber, ct);
 3349        var sourceItems = FindToken(response, "ITEMS") as JArray ?? new JArray();
 4350        if (sourceItems.Count == 0) return new(0, 0, 1, [$"ML-Auto RU не вернул позиции заказа №{orderNumber}."]);
 2351        var order = new MlAutoRuOrder { OrderNumber = orderNumber, DeliveryAddressId = string.Empty, Status = Find(respo
 2352        var imported = 0;
 8353        foreach (var source in sourceItems.OfType<JObject>())
 354        {
 2355            var pin = source.Value<string>("PIN") ?? string.Empty;
 2356            var brand = source.Value<string>("BRAND") ?? string.Empty;
 2357            var cart = await _db.MlAutoRuCartItems.FirstOrDefaultAsync(x => MlAutoRuCartItemStatus.Active.Contains(x.Sta
 2358            if (cart is null) continue;
 3359            cart.Status = MlAutoRuCartItemStatus.Ordered; cart.ActiveStudioOrderId = null; cart.LastError = null;
 1360            order.DeliveryAddressId = string.IsNullOrWhiteSpace(order.DeliveryAddressId) ? cart.DeliveryAddressId ?? str
 1361            order.DeliveryAddressName ??= cart.DeliveryAddressName;
 1362            order.Items.Add(new MlAutoRuOrderItem { MlAutoRuCartItemId = cart.Id, StudioOrderId = cart.StudioOrderId, Pi
 1363            await HistoryAsync(cart, user, true, null, ApiSupplierBasketCartStatus.Ordered, orderNumber, ct);
 2364            await TrackAsync(() => _supplierState.MarkOrderedAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoRuProvi
 1365            imported++;
 1366        }
 3367        if (imported == 0) return new(0, 0, 1, [$"В корзине нет активных позиций, совпадающих с заказом ML-Auto RU №{ord
 2368        _db.MlAutoRuOrders.Add(order); await _db.SaveChangesAsync(ct);
 1369        return new(imported, 0, 0, [$"Заказ ML-Auto RU №{orderNumber} импортирован: позиций {imported}."]);
 4370    }
 56371    public async Task<int> RemoveAsync(IReadOnlyCollection<long>? ids, CancellationToken ct = default) { var items = awa
 65372    private Task<List<MlAutoRuCartItem>> SelectedAsync(IReadOnlyCollection<long>? ids, CancellationToken ct) { var q = _
 373    /// <summary>ML-Auto RU принимает массив позиций как PHP form-поля <c>ITEMS[1][PIN]</c>, а не JSON в поле ITEMS.</su
 374    private static Dictionary<string, string?> BuildRequest(string address, IReadOnlyCollection<MlAutoRuCartItem> items,
 375    {
 34376        var request = new Dictionary<string, string?>
 34377        {
 34378            ["ADDRESS_ID"] = address,
 34379            ["ONE-TIME-DELIVERY"] = "1",
 34380            ["BEZNAL"] = cashless ? "1" : "0"
 34381        };
 170382        foreach (var (item, index) in items.Select((item, index) => (item, index + 1)))
 383        {
 34384            var prefix = $"ITEMS[{index}]";
 34385            request[$"{prefix}[PIN]"] = item.Pin;
 34386            request[$"{prefix}[BRAND]"] = item.Brand;
 34387            request[$"{prefix}[QUANTITY]"] = item.Quantity.ToString("0.###", CultureInfo.InvariantCulture);
 34388            request[$"{prefix}[STORAGE-CODE]"] = item.StorageCode;
 34389            request[$"{prefix}[MAX-PRICE]"] = item.SourcePrice?.ToString("0.####", CultureInfo.InvariantCulture);
 34390            request[$"{prefix}[MAX-DATE]"] = item.DeliveryAtUtc?.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture
 391        }
 34392        return request;
 393    }
 394    private async Task<string> RepriceAsync(MlAutoRuCartItem item, CancellationToken ct)
 395    {
 21396        var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN
 21397        var offers = (response as JArray ?? new JArray()).OfType<JObject>()
 19398            .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq
 19399            .Select(x => new { Token = x, Price = MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")), Quantity = M
 19400            .Where(x => x.Price.HasValue && x.Quantity.GetValueOrDefault() >= item.Quantity && x.Minimum <= item.Quantit
 19401            .OrderByDescending(x => string.Equals(x.Token.Value<string>("STORAGE_CODE"), item.StorageCode, StringCompari
 59402            .ThenBy(x => x.Price).ThenBy(x => x.Date ?? DateTime.MaxValue).ToArray();
 21403        var offer = offers.FirstOrDefault() ?? throw new MlAutoApiException("ML-Auto RU: актуальное предложение для пров
 19404        var changed = item.StorageCode != offer.Token.Value<string>("STORAGE_CODE") || item.SourcePrice != offer.Price |
 19405        item.StorageCode = offer.Token.Value<string>("STORAGE_CODE");
 19406        item.SourcePrice = offer.Price;
 19407        item.DeliveryAtUtc = offer.Date;
 19408        return changed ? " Данные корзины обновлены по актуальной проценке." : string.Empty;
 19409    }
 410
 411    private static JObject EnsureTestAccepted(JToken response, string? providerMessage)
 412    {
 19413        var items = FindToken(response, "ITEMS");
 35414        if (items is JArray { Count: > 0 } array && array.OfType<JObject>().FirstOrDefault() is { } result) return resul
 4415        if (items is JObject { HasValues: true } item) return item;
 2416        throw new MlAutoApiException(string.IsNullOrWhiteSpace(providerMessage)
 2417            ? "ML-Auto RU не подтвердил ни одной позиции. Проверьте минимальное количество, склад, цену и срок поставки.
 2418            : $"ML-Auto RU: {providerMessage}. Поставщик не подтвердил позицию.");
 419    }
 72420    private static string? Find(JToken token, string name) => (token is JContainer container ? container.DescendantsAndS
 46421    private static JToken? FindToken(JToken token, string name) => (token is JContainer container ? container.Descendant
 18422    private static DateTime? ParseDate(string? value) => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTime
 423    private async Task HistoryAsync(MlAutoRuCartItem item, string user, bool success, string? error, string status, stri
 424    {
 425        try
 426        {
 26427            var entry = new ApiSupplierBasketHistoryEntry(HistoryProviderKey, "ML-Auto RU", Logo, success, error, null, 
 26428                item.ProductName ?? item.Brand, item.SourcePrice, item.CurrencyCode, item.Quantity, item.StudioOrder?.Sh
 26429                CartStatus: status, SupplierOrderNumber: number, DeliveryAddressName: item.DeliveryAddressName,
 26430                SupplierCartUrl: "/SupplierCart?provider=mlautoru&supplierTab=basket");
 26431            await _history.AddOrderHistory(new OrderHistory
 26432            {
 26433                OrderId = item.StudioOrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 26434                ColumnDisplayName = "Корзина ML-Auto RU", NewValue = System.Text.Json.JsonSerializer.Serialize(entry, Ap
 26435                ChangedAt = DateTime.Now, ChangedBy = user
 26436            });
 25437        }
 1438        catch (Exception exception)
 439        {
 1440            _logger.LogWarning(exception, "Не удалось записать историю корзины ML-Auto RU для заказа {OrderId}", item.St
 1441        }
 26442    }
 443}