< Summary

Information
Class: OzonOrdersWeb.TransactionServices.MlAutoRuCartResult
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/MlAutoRuCartService.cs
Line coverage
100%
Covered lines: 12
Uncovered lines: 0
Coverable lines: 12
Total lines: 443
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Added()100%11100%
ToHtmlBlock()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
 15120public sealed record MlAutoRuCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 21{
 22    public string ToHtmlBlock()
 23    {
 2724        var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>");
 325        var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty;
 326        var verificationNotice = Added > 0
 327            ? "<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'></
 328            : string.Empty;
 29
 330        return "<div class='alert alert-info border-primary-subtle mb-0'>" +
 331               "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина ML-Auto RU</
 332               $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b
 333               $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" +
 334               "<div class='d-flex flex-wrap gap-2 mt-2'><a class='btn btn-outline-primary btn-sm' href='/SupplierCart?p
 335               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;
 51    public MlAutoRuCartService(ApplicationDbContext db, MlAutoRuDataManager api, MlAutoRuDeliveryAddressMappingService m
 52        IOrderHistoryDataService history, IOrderSupplierStateService supplierState, ILogger<MlAutoRuCartService>? logger
 53    {
 54        _db = db; _api = api; _mappings = mappings; _history = history; _supplierState = supplierState;
 55        _logger = logger ?? NullLogger<MlAutoRuCartService>.Instance;
 56    }
 57
 58    /// <summary>
 59    /// Отражает строку корзины в отслеживании статусов поставщика. Переходы внутри корзины
 60    /// читает LocalCartStatusReader напрямую из строки, поэтому здесь только появление,
 61    /// оформление и уход из корзины. Сбой журнала не ломает работу с корзиной.
 62    /// </summary>
 63    private async Task TrackAsync(Func<Task> track)
 64    {
 65        try { await track(); }
 66        catch (Exception exception) { _logger.LogWarning(exception, "Не удалось обновить состояние заказа у поставщика M
 67    }
 68
 69    public Task<List<MlAutoRuCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.MlAutoRuCartItems.Include(
 70    public Task<List<MlAutoRuOrder>> GetOrdersAsync(CancellationToken ct = default) => _db.MlAutoRuOrders.AsNoTracking()
 71    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    {
 75        var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>();
 76        foreach (var item in queue)
 77        {
 78            if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.Pin) || string.IsNullOrWhiteSpace(item.Brand) || ite
 79            if (item.MinimumQuantity > 0 && item.Quantity < item.MinimumQuantity) { failed++; messages.Add($"{item.Pin}:
 80            if (await _db.MlAutoRuCartItems.AnyAsync(x => x.StudioOrderId == item.OrderId && MlAutoRuCartItemStatus.Acti
 81            var destination = await _mappings.ResolveAsync(item.OrderId, item.ZasylWarehouseName, ct);
 82            var cart = new MlAutoRuCartItem { StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, Pin = it
 83            _db.MlAutoRuCartItems.Add(cart); await _db.SaveChangesAsync(ct); added++; messages.Add(item.Pin + ": добавле
 84            await TrackAsync(() => _supplierState.MarkInCartAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoRuProvid
 85                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction,
 86        }
 87        return new MlAutoRuCartResult(added, skipped, failed, messages);
 88    }
 89
 90    /// <summary>Проверяет и оформляет предложения без промежуточного сохранения в локальную корзину.</summary>
 91    public async Task<MlAutoRuDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<MlAutoRuCartQueueItem> queue
 92    {
 93        var messages = new List<string>();
 94        if (queue.Count == 0) return new(false, ["Не выбраны позиции ML-Auto RU для оформления."]);
 95        if (queue.GroupBy(x => x.OrderId).Any(x => x.Key <= 0 || x.Count() > 1))
 96            return new(false, ["Для прямого оформления ML-Auto RU требуется ровно одно предложение на заказ."]);
 97
 98        // Идемпотентность: заказ Studio2, уже оформленный у ML-Auto RU, повторно не отправляем
 99        // (повторная отправка транзакции после сбоя иначе создаёт у поставщика дубль реального заказа).
 100        var alreadyPlaced = await FindPlacedOrderNumbersAsync(queue.Select(x => x.OrderId).ToArray(), ct);
 101        var skippedMessages = queue.Where(x => alreadyPlaced.ContainsKey(x.OrderId))
 102            .Select(x => $"{x.Pin}: уже оформлено, заказ ML-Auto RU №{alreadyPlaced[x.OrderId]}; повторно не отправлялся
 103        var pending = queue.Where(x => !alreadyPlaced.ContainsKey(x.OrderId)).ToList();
 104        if (pending.Count == 0) return new(true, skippedMessages);
 105
 106        var prepared = new List<MlAutoRuCartItem>();
 107        foreach (var source in pending)
 108        {
 109            if (string.IsNullOrWhiteSpace(source.Pin) || string.IsNullOrWhiteSpace(source.Brand) || source.Quantity <= 0
 110                (source.MinimumQuantity > 0 && source.Quantity < source.MinimumQuantity))
 111            {
 112                messages.Add($"{source.Pin}: некорректное количество или предложение ML-Auto RU.");
 113                continue;
 114            }
 115
 116            var destination = await _mappings.ResolveAsync(source.OrderId, source.ZasylWarehouseName, ct);
 117            if (destination is null)
 118            {
 119                messages.Add($"{source.Pin}: для склада заказа не настроен адрес доставки ML-Auto RU.");
 120                continue;
 121            }
 122
 123            var item = new MlAutoRuCartItem
 124            {
 125                StudioOrderId = source.OrderId, Pin = source.Pin.Trim(), Brand = source.Brand.Trim(),
 126                StorageCode = source.StorageCode, WarehouseName = source.WarehouseName, ProductName = source.ProductName
 127                DeliveryAddressId = destination.DeliveryAddressId, DeliveryAddressName = destination.DeliveryAddressId,
 128                Quantity = source.Quantity, SourcePrice = source.SourcePrice, CurrencyCode = source.CurrencyCode ?? "RUB
 129                DeliveryAtUtc = ParseDate(source.DeliveryAtUtc), CreatedBy = user
 130            };
 131            try
 132            {
 133                await RepriceAsync(item, ct);
 134                var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c
 135                item.TestResultJson = test.Response.ToString(Formatting.None);
 136                var confirmed = EnsureTestAccepted(test.Response, test.Message);
 137                item.SourcePrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE")) ?? item.SourcePrice;
 138                prepared.Add(item);
 139            }
 140            catch (MlAutoApiException ex) { messages.Add($"{source.Pin}: {ex.Message}"); }
 141        }
 142
 143        if (messages.Count > 0) return new(false, messages);
 144        // Прямое оформление минует локальную корзину: срок/направление берём обратно из queue
 145        // по OrderId (проверка выше гарантирует не более одного предложения на заказ).
 146        var sourceByOrderId = pending.ToDictionary(x => x.OrderId);
 147        var created = new List<string>();
 148        foreach (var group in prepared.GroupBy(x => x.DeliveryAddressId!))
 149        {
 150            Dictionary<string, string?> request;
 151            string number;
 152            JToken response;
 153            try
 154            {
 155                request = BuildRequest(group.Key, group.ToList(), true);
 156                response = await _api.CreateOrderAsync(request, ct);
 157                number = Find(response, "ORDER-NUMBER") ?? string.Empty;
 158                if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto RU не создал заказ: в ответ
 159            }
 160            catch (MlAutoApiException ex) { return new(false, [.. skippedMessages, .. created, ex.Message]); }
 161
 162            // С этого места заказ у поставщика уже существует: любой сбой дальше не должен терять его номер,
 163            // иначе повторная отправка создаст дубль. Поэтому сохраняем сразу и без токена запроса.
 164            var order = new MlAutoRuOrder
 165            {
 166                OrderNumber = number, DeliveryAddressId = group.Key, DeliveryAddressName = group.First().DeliveryAddress
 167                IsCashless = true, CreatedBy = user, Status = "Created",
 168                RequestJson = System.Text.Json.JsonSerializer.Serialize(request), ResponseJson = response.ToString(Forma
 169            };
 170            foreach (var item in group)
 171                order.Items.Add(new MlAutoRuOrderItem { StudioOrderId = item.StudioOrderId, Pin = item.Pin, Brand = item
 172            try
 173            {
 174                _db.MlAutoRuOrders.Add(order);
 175                await _db.SaveChangesAsync(CancellationToken.None);
 176            }
 177            catch (Exception exception)
 178            {
 179                _logger.LogError(exception, "Заказ ML-Auto RU №{OrderNumber} создан, но не сохранён в БД; защита от повт
 180                _db.ChangeTracker.Clear();
 181            }
 182
 183            foreach (var item in group)
 184            {
 185                await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ordered, number, CancellationToke
 186                // Прямое оформление обходит локальную корзину: показываем статус сразу.
 187                sourceByOrderId.TryGetValue(item.StudioOrderId, out var source);
 188                await TrackAsync(() => _supplierState.MarkOrderedAsync(
 189                    item.StudioOrderId, ApiSupplierSettings.MlAutoRuProviderKey, number, user: user,
 190                    deliveryDaysMin: source?.DeliveryDaysMin, deliveryDaysMax: source?.DeliveryDaysMax, direction: sourc
 191            }
 192
 193            await RefreshOrderDetailsAsync(order, number);
 194            created.Add($"Создан заказ ML-Auto RU №{number}.");
 195        }
 196        return new(true, [.. skippedMessages, .. created]);
 197    }
 198
 199    /// <summary>Подтягивает статус и ответ заказа поставщика. Заказ уже создан, поэтому сбой здесь только логируется.</
 200    private async Task RefreshOrderDetailsAsync(MlAutoRuOrder order, string number)
 201    {
 202        try
 203        {
 204            // Id нет, если сохранение заказа не удалось: обновлять в БД нечего.
 205            if (order.Id <= 0) return;
 206            var details = await _api.GetOrderByNumberAsync(number, CancellationToken.None);
 207            order.Status = Find(details, "STATUS") ?? order.Status;
 208            order.ResponseJson = details.ToString(Formatting.None);
 209            await _db.SaveChangesAsync(CancellationToken.None);
 210        }
 211        catch (Exception exception)
 212        {
 213            _logger.LogWarning(exception, "Не удалось получить детали заказа ML-Auto RU №{OrderNumber}; заказ уже создан
 214        }
 215    }
 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    {
 224        var result = new Dictionary<int, string>();
 225        var ids = studioOrderIds.Where(x => x > 0).Distinct().ToArray();
 226        if (ids.Length == 0) return result;
 227
 228        var saved = await _db.MlAutoRuOrders.AsNoTracking()
 229            .SelectMany(order => order.Items.Where(item => ids.Contains(item.StudioOrderId) && item.Error == null),
 230                (order, item) => new { item.StudioOrderId, order.OrderNumber, order.CreatedAtUtc })
 231            .OrderBy(x => x.CreatedAtUtc).ToListAsync(ct);
 232        foreach (var row in saved) result.TryAdd(row.StudioOrderId, row.OrderNumber);
 233
 234        foreach (var id in ids.Where(x => !result.ContainsKey(x)))
 235        {
 236            var number = (await _history.GetByOrderIdAsync(id))
 237                .Where(x => string.Equals(x.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName, StringCompariso
 238                .Select(x => ApiSupplierBasketHistoryEntry.TryParse(x.NewValue))
 239                .FirstOrDefault(x => x is { Success: true, CartStatus: ApiSupplierBasketCartStatus.Ordered } &&
 240                                     string.Equals(x.ProviderKey, HistoryProviderKey, StringComparison.Ordinal) &&
 241                                     !string.IsNullOrWhiteSpace(x.SupplierOrderNumber))?.SupplierOrderNumber;
 242            if (number is not null) result[id] = number;
 243        }
 244        return result;
 245    }
 246
 247    public async Task<MlAutoRuCartResult> TestAsync(IReadOnlyCollection<long>? ids, string user, CancellationToken ct = 
 248    {
 249        var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto RU пуста."
 250        var result = new List<string>(); var ok = 0; var failed = 0;
 251        foreach (var item in items)
 252        {
 253            if (string.IsNullOrWhiteSpace(item.DeliveryAddressId)) { item.Status = MlAutoRuCartItemStatus.Failed; item.L
 254            try
 255            {
 256                var reprice = await RepriceAsync(item, ct);
 257                var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c
 258                item.TestResultJson = test.Response.ToString(Formatting.None);
 259                var confirmed = EnsureTestAccepted(test.Response, test.Message);
 260                var confirmedPrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE"));
 261                if (confirmedPrice.HasValue) item.SourcePrice = confirmedPrice;
 262                var providerResult = confirmed.Value<string>("MSG");
 263                item.TestedAtUtc = DateTime.UtcNow; item.Status = MlAutoRuCartItemStatus.Ready; item.LastError = null; o
 264                result.Add($"{item.Pin}: принято ML-Auto RU; склад {item.StorageCode ?? "—"}, цена поставщика {item.Sour
 265                await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ready, null, ct);
 266            }
 267            catch (MlAutoApiException ex) { item.Status = MlAutoRuCartItemStatus.Failed; item.LastError = ex.Message; fa
 268            item.UpdatedAtUtc = DateTime.UtcNow;
 269        }
 270        await _db.SaveChangesAsync(ct); return new(ok, 0, failed, result);
 271    }
 272
 273    public async Task<MlAutoRuCartResult> CheckoutAsync(IReadOnlyCollection<long>? ids, bool cashless, string user, Canc
 274    {
 275        var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto RU пуста."
 276        if (items.Any(x => x.Status != MlAutoRuCartItemStatus.Ready || !x.TestedAtUtc.HasValue || x.TestedAtUtc.Value < 
 277        var messages = new List<string>(); var ok = 0; var failed = 0;
 278        foreach (var group in items.GroupBy(x => x.DeliveryAddressId))
 279        {
 280            if (string.IsNullOrWhiteSpace(group.Key)) { failed += group.Count(); messages.Add("Не выбран адрес доставки 
 281            foreach (var item in group) item.Status = MlAutoRuCartItemStatus.Processing;
 282            await _db.SaveChangesAsync(ct);
 283            try
 284            {
 285                var request = BuildRequest(group.Key, group.ToList(), cashless); var response = await _api.CreateOrderAs
 286                if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto RU не создал заказ: в ответ
 287                var details = await _api.GetOrderByNumberAsync(number, ct);
 288                var order = new MlAutoRuOrder { OrderNumber = number, DeliveryAddressId = group.Key!, DeliveryAddressNam
 289                foreach (var item in group) { item.Status = MlAutoRuCartItemStatus.Ordered; item.ActiveStudioOrderId = n
 290                _db.MlAutoRuOrders.Add(order); await _db.SaveChangesAsync(ct); ok += group.Count(); messages.Add("Создан
 291            }
 292            catch (MlAutoApiException ex) { foreach (var item in group) { item.Status = MlAutoRuCartItemStatus.Failed; i
 293        }
 294        return new(ok, 0, failed, messages);
 295    }
 296
 297    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    {
 302        var item = await _db.MlAutoRuCartItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoRuCar
 303            ?? throw new InvalidOperationException("Позиция корзины не найдена или уже оформляется.");
 304        if (string.IsNullOrWhiteSpace(item.DeliveryAddressId))
 305            throw new InvalidOperationException("Сначала выберите адрес доставки ML-Auto RU для этой позиции.");
 306
 307        var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN
 308        var offers = (response as JArray ?? new JArray()).OfType<JObject>()
 309            .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq
 310            .Select(x => new MlAutoRuCartRequoteOffer(
 311                x.Value<string>("STORAGE_CODE") ?? string.Empty,
 312                x.Value<string>("STORAGE_NAME") ?? x.Value<string>("STORAGE") ?? "Склад ML-Auto RU",
 313                MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")) ?? -1m,
 314                MlAutoValueParser.ParseQuantity(x.Value<string>("QUANTITY")),
 315                MlAutoValueParser.ParseDate(x.Value<string>("DATE"))))
 316            .Where(x => x.Price >= 0 && x.AvailableQuantity.GetValueOrDefault() >= item.Quantity && !string.IsNullOrWhit
 317            .OrderBy(x => x.Price).ThenBy(x => x.DeliveryAtUtc ?? DateTime.MaxValue).Take(8).ToArray();
 318        return new MlAutoRuCartRequoteResult(
 319            string.IsNullOrWhiteSpace(item.WarehouseName) ? item.StorageCode ?? "—" : $"{item.WarehouseName} ({item.Stor
 320            item.DeliveryAddressName ?? item.DeliveryAddressId,
 321            offers);
 322    }
 323
 324    /// <summary>Заменяет строку корзины выбранным свежим предложением и требует повторную проверку.</summary>
 325    public async Task<bool> ReplaceWithRequotedOfferAsync(long cartItemId, string storageCode, CancellationToken ct = de
 326    {
 327        var item = await _db.MlAutoRuCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoRuCartItemStatus.Act
 328        if (item is null || item.Status == MlAutoRuCartItemStatus.Processing) return false;
 329        var offer = (await RequoteAsync(cartItemId, ct)).Offers.FirstOrDefault(x => string.Equals(x.StorageCode, storage
 330        if (offer is null) throw new InvalidOperationException("Выбранное предложение уже не актуально. Выполните процен
 331        item.StorageCode = offer.StorageCode;
 332        item.WarehouseName = offer.WarehouseName;
 333        item.SourcePrice = offer.Price;
 334        item.DeliveryAtUtc = offer.DeliveryAtUtc;
 335        item.Status = MlAutoRuCartItemStatus.Pending;
 336        item.TestedAtUtc = null;
 337        item.TestResultJson = null;
 338        item.LastError = "Выбрано актуальное предложение ML-Auto RU. Выполните проверку перед оформлением.";
 339        item.UpdatedAtUtc = DateTime.UtcNow;
 340        await _db.SaveChangesAsync(ct);
 341        return true;
 342    }
 343
 344    public async Task<MlAutoRuCartResult> ImportOrderAsync(string orderNumber, string user, CancellationToken ct = defau
 345    {
 346        if (await _db.MlAutoRuOrders.AnyAsync(x => x.OrderNumber == orderNumber, ct))
 347            return new(0, 1, 0, [$"Заказ ML-Auto RU №{orderNumber} уже импортирован."]);
 348        var response = await _api.GetOrderByNumberAsync(orderNumber, ct);
 349        var sourceItems = FindToken(response, "ITEMS") as JArray ?? new JArray();
 350        if (sourceItems.Count == 0) return new(0, 0, 1, [$"ML-Auto RU не вернул позиции заказа №{orderNumber}."]);
 351        var order = new MlAutoRuOrder { OrderNumber = orderNumber, DeliveryAddressId = string.Empty, Status = Find(respo
 352        var imported = 0;
 353        foreach (var source in sourceItems.OfType<JObject>())
 354        {
 355            var pin = source.Value<string>("PIN") ?? string.Empty;
 356            var brand = source.Value<string>("BRAND") ?? string.Empty;
 357            var cart = await _db.MlAutoRuCartItems.FirstOrDefaultAsync(x => MlAutoRuCartItemStatus.Active.Contains(x.Sta
 358            if (cart is null) continue;
 359            cart.Status = MlAutoRuCartItemStatus.Ordered; cart.ActiveStudioOrderId = null; cart.LastError = null;
 360            order.DeliveryAddressId = string.IsNullOrWhiteSpace(order.DeliveryAddressId) ? cart.DeliveryAddressId ?? str
 361            order.DeliveryAddressName ??= cart.DeliveryAddressName;
 362            order.Items.Add(new MlAutoRuOrderItem { MlAutoRuCartItemId = cart.Id, StudioOrderId = cart.StudioOrderId, Pi
 363            await HistoryAsync(cart, user, true, null, ApiSupplierBasketCartStatus.Ordered, orderNumber, ct);
 364            await TrackAsync(() => _supplierState.MarkOrderedAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoRuProvi
 365            imported++;
 366        }
 367        if (imported == 0) return new(0, 0, 1, [$"В корзине нет активных позиций, совпадающих с заказом ML-Auto RU №{ord
 368        _db.MlAutoRuOrders.Add(order); await _db.SaveChangesAsync(ct);
 369        return new(imported, 0, 0, [$"Заказ ML-Auto RU №{orderNumber} импортирован: позиций {imported}."]);
 370    }
 371    public async Task<int> RemoveAsync(IReadOnlyCollection<long>? ids, CancellationToken ct = default) { var items = awa
 372    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    {
 376        var request = new Dictionary<string, string?>
 377        {
 378            ["ADDRESS_ID"] = address,
 379            ["ONE-TIME-DELIVERY"] = "1",
 380            ["BEZNAL"] = cashless ? "1" : "0"
 381        };
 382        foreach (var (item, index) in items.Select((item, index) => (item, index + 1)))
 383        {
 384            var prefix = $"ITEMS[{index}]";
 385            request[$"{prefix}[PIN]"] = item.Pin;
 386            request[$"{prefix}[BRAND]"] = item.Brand;
 387            request[$"{prefix}[QUANTITY]"] = item.Quantity.ToString("0.###", CultureInfo.InvariantCulture);
 388            request[$"{prefix}[STORAGE-CODE]"] = item.StorageCode;
 389            request[$"{prefix}[MAX-PRICE]"] = item.SourcePrice?.ToString("0.####", CultureInfo.InvariantCulture);
 390            request[$"{prefix}[MAX-DATE]"] = item.DeliveryAtUtc?.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture
 391        }
 392        return request;
 393    }
 394    private async Task<string> RepriceAsync(MlAutoRuCartItem item, CancellationToken ct)
 395    {
 396        var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN
 397        var offers = (response as JArray ?? new JArray()).OfType<JObject>()
 398            .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq
 399            .Select(x => new { Token = x, Price = MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")), Quantity = M
 400            .Where(x => x.Price.HasValue && x.Quantity.GetValueOrDefault() >= item.Quantity && x.Minimum <= item.Quantit
 401            .OrderByDescending(x => string.Equals(x.Token.Value<string>("STORAGE_CODE"), item.StorageCode, StringCompari
 402            .ThenBy(x => x.Price).ThenBy(x => x.Date ?? DateTime.MaxValue).ToArray();
 403        var offer = offers.FirstOrDefault() ?? throw new MlAutoApiException("ML-Auto RU: актуальное предложение для пров
 404        var changed = item.StorageCode != offer.Token.Value<string>("STORAGE_CODE") || item.SourcePrice != offer.Price |
 405        item.StorageCode = offer.Token.Value<string>("STORAGE_CODE");
 406        item.SourcePrice = offer.Price;
 407        item.DeliveryAtUtc = offer.Date;
 408        return changed ? " Данные корзины обновлены по актуальной проценке." : string.Empty;
 409    }
 410
 411    private static JObject EnsureTestAccepted(JToken response, string? providerMessage)
 412    {
 413        var items = FindToken(response, "ITEMS");
 414        if (items is JArray { Count: > 0 } array && array.OfType<JObject>().FirstOrDefault() is { } result) return resul
 415        if (items is JObject { HasValues: true } item) return item;
 416        throw new MlAutoApiException(string.IsNullOrWhiteSpace(providerMessage)
 417            ? "ML-Auto RU не подтвердил ни одной позиции. Проверьте минимальное количество, склад, цену и срок поставки.
 418            : $"ML-Auto RU: {providerMessage}. Поставщик не подтвердил позицию.");
 419    }
 420    private static string? Find(JToken token, string name) => (token is JContainer container ? container.DescendantsAndS
 421    private static JToken? FindToken(JToken token, string name) => (token is JContainer container ? container.Descendant
 422    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        {
 427            var entry = new ApiSupplierBasketHistoryEntry(HistoryProviderKey, "ML-Auto RU", Logo, success, error, null, 
 428                item.ProductName ?? item.Brand, item.SourcePrice, item.CurrencyCode, item.Quantity, item.StudioOrder?.Sh
 429                CartStatus: status, SupplierOrderNumber: number, DeliveryAddressName: item.DeliveryAddressName,
 430                SupplierCartUrl: "/SupplierCart?provider=mlautoru&supplierTab=basket");
 431            await _history.AddOrderHistory(new OrderHistory
 432            {
 433                OrderId = item.StudioOrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 434                ColumnDisplayName = "Корзина ML-Auto RU", NewValue = System.Text.Json.JsonSerializer.Serialize(entry, Ap
 435                ChangedAt = DateTime.Now, ChangedBy = user
 436            });
 437        }
 438        catch (Exception exception)
 439        {
 440            _logger.LogWarning(exception, "Не удалось записать историю корзины ML-Auto RU для заказа {OrderId}", item.St
 441        }
 442    }
 443}

Methods/Properties

get_Added()
ToHtmlBlock()