< Summary

Information
Class: OzonOrdersWeb.TransactionServices.MoskvorechieIstraQueueItem
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/MoskvorechieIstraCartService.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 424
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_OrderId()100%11100%
get_ShipmentNumber()100%11100%
get_DeliveryDaysMin()100%11100%

File(s)

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

#LineLine coverage
 1using System.Net;
 2using System.Text.Json;
 3using Microsoft.EntityFrameworkCore;
 4using Newtonsoft.Json.Linq;
 5using OzonDomains.Models;
 6using OzonRepositories.Context;
 7using Services.ApiServices.MoskvorechieApi;
 8using Services.DataServices;
 9using Services.SupplierStatusServices;
 10
 11namespace OzonOrdersWeb.TransactionServices;
 12
 15913public sealed record MoskvorechieIstraQueueItem(int OrderId, string OfferId, string Article, string Brand, string? Produ
 7714    string? ShipmentNumber, string? ClientName, decimal Quantity, decimal MinimumQuantity, decimal? SourcePrice,
 4215    int? DeliveryDaysMin, int? DeliveryDaysMax, string? Direction);
 16
 17public sealed record MoskvorechieIstraCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 18{
 19    public string ToHtmlBlock()
 20    {
 21        var css = Failed > 0 ? "alert-warning" : "alert-success";
 22        var list = string.Join(string.Empty, Messages.Select(x => $"<li>{WebUtility.HtmlEncode(x)}</li>"));
 23        return $"<div class='alert {css} mb-0'><strong>Москворечье: добавлено {Added}, пропущено {Skipped}, ошибок {Fail
 24    }
 25}
 26
 27public sealed class MoskvorechieIstraCartService
 28{
 29    private readonly ApplicationDbContext _db;
 30    private readonly MoskvorechieIstraDataManager _api;
 31    private readonly IOrderHistoryDataService _orderHistory;
 32    private readonly IOrderSupplierStateService _supplierState;
 33    public MoskvorechieIstraCartService(ApplicationDbContext db, MoskvorechieIstraDataManager api,
 34        IOrderHistoryDataService orderHistory, IOrderSupplierStateService supplierState) =>
 35        (_db, _api, _orderHistory, _supplierState) = (db, api, orderHistory, supplierState);
 36
 37    public async Task<MoskvorechieIstraCartResult> QueueAsync(IReadOnlyCollection<MoskvorechieIstraQueueItem> queue, str
 38    {
 39        var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>();
 40        foreach (var item in queue)
 41        {
 42            if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.OfferId) || item.Quantity < Math.Max(1, item.Minimum
 43            { failed++; messages.Add($"Заказ {item.OrderId}: неверная позиция или количество меньше минимальной партии."
 44            if (await _db.MoskvorechieIstraCartItems.AnyAsync(x => x.ActiveStudioOrderId == item.OrderId, ct))
 45            { skipped++; messages.Add($"Заказ {item.OrderId}: уже добавлен в корзину Москворечья."); continue; }
 46            var comment = $"Studio2 {item.ShipmentNumber ?? $"заказ {item.OrderId}"} (ID {item.OrderId})";
 47            try
 48            {
 49                // API-корзина — единственный источник истины: локальную связь создаём лишь после
 50                // успешного to_basket и повторного my_basket, где подтверждены gid и комментарий Studio2.
 51                var externalBasket = await _api.GetBasketAsync(ct);
 52                var alreadyInExternalBasket = BasketContains(externalBasket, item.Article, item.Brand, item.Quantity,
 53                    item.SourcePrice, item.OfferId, comment);
 54                if (!alreadyInExternalBasket)
 55                {
 56                    await _api.AddToBasketAsync(item.OfferId, item.Quantity, comment, ct);
 57                    externalBasket = await _api.GetBasketAsync(ct);
 58                }
 59                if (!BasketContains(externalBasket, item.Article, item.Brand, item.Quantity, item.SourcePrice, item.Offe
 60                    throw new MoskvorechieApiException("API Москворечья не подтвердил позицию в своей корзине. Локальная
 61                _db.MoskvorechieIstraCartItems.Add(new MoskvorechieIstraCartItem
 62                {
 63                    StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, ExternalBasketItemId = item.OfferI
 64                    Article = item.Article, Brand = item.Brand, ProductName = item.ProductName, Quantity = item.Quantity
 65                    SourcePrice = item.SourcePrice, Warehouse = item.Direction, DeliveryDaysMin = item.DeliveryDaysMin,
 66                    DeliveryDaysMax = item.DeliveryDaysMax, Comment = comment, CreatedBy = changedBy
 67                });
 68                await _db.SaveChangesAsync(ct); added++; messages.Add(alreadyInExternalBasket
 69                    ? $"Заказ {item.OrderId}: позиция уже была в API-корзине, локальная связь восстановлена."
 70                    : $"Заказ {item.OrderId}: добавлен в корзину Москворечья.");
 71                await TrackPlacementAsync(item, comment, changedBy, ct);
 72            }
 73            catch (MoskvorechieApiException ex) { failed++; messages.Add($"Заказ {item.OrderId}: {ex.Message}"); }
 74        }
 75        return new MoskvorechieIstraCartResult(added, skipped, failed, messages);
 76    }
 77
 78    /// <summary>Сверяет активные связи с <c>my_basket</c>. Исчезнувшие из API строки больше не показываются в Studio2.<
 79    public async Task SynchronizeAsync(CancellationToken ct)
 80    {
 81        var local = await _db.MoskvorechieIstraCartItems
 82            .Where(x => x.ActiveStudioOrderId != null && x.Status != "Removed")
 83            .ToListAsync(ct);
 84        if (local.Count == 0) return;
 85
 86        var externalBasket = await _api.GetBasketAsync(ct);
 87        var changed = false;
 88        foreach (var item in local)
 89        {
 90            var external = FindBasketRow(externalBasket, item.Article, item.Brand, item.Quantity, item.SourcePrice,
 91                item.ExternalBasketItemId, item.Comment);
 92            if (external is null)
 93            {
 94                item.Status = "Removed";
 95                item.ActiveStudioOrderId = null;
 96                item.LastError = "Позиция отсутствует в API-корзине Москворечья.";
 97                item.UpdatedAtUtc = DateTime.UtcNow;
 98                changed = true;
 99                continue;
 100            }
 101
 102            // Обновляем отображаемый снимок реальными значениями my_basket.
 103            item.ProductName = Read(external, "name") ?? item.ProductName;
 104            item.SourcePrice = ReadDecimal(external, "price") ?? item.SourcePrice;
 105            var deliveryDays = ParseDays(Read(external, "delivery"));
 106            item.DeliveryDaysMin = deliveryDays ?? item.DeliveryDaysMin;
 107            item.DeliveryDaysMax = deliveryDays ?? item.DeliveryDaysMax;
 108            item.LastError = null;
 109            item.UpdatedAtUtc = DateTime.UtcNow;
 110            changed = true;
 111        }
 112        if (changed) await _db.SaveChangesAsync(ct);
 113    }
 114
 115    public async Task<MoskvorechieIstraCartResult> TestAsync(IReadOnlyCollection<long>? cartItemIds, CancellationToken c
 116    {
 117        await SynchronizeAsync(ct);
 118        var items = await SelectedItemsAsync(cartItemIds, ct);
 119        return items.Count == 0
 120            ? new(0, 0, 1, ["В API-корзине нет выбранных позиций Москворечья."])
 121            : new(items.Count, 0, 0, items.Select(x => $"{x.Article}: подтверждён в API-корзине.").ToList());
 122    }
 123
 124    /// <summary>Обновляет начальный статус каждого известного внешнего заказа через make_order_v2?oids=….</summary>
 125    public async Task SynchronizeOrdersAsync(CancellationToken ct)
 126    {
 127        var orders = await _db.MoskvorechieIstraOrders.Include(x => x.Items)
 128            .Where(x => x.ExternalOrderNumber != null && x.ExternalOrderNumber != "")
 129            .ToListAsync(ct);
 130        if (orders.Count == 0) return;
 131
 132        foreach (var batch in orders.Chunk(50))
 133        {
 134            var response = await _api.GetOrderStatesAsync(batch.Select(x => x.ExternalOrderNumber!).ToList(), ct);
 135            foreach (var row in GetOrderRows(response))
 136            {
 137                var externalId = Read(row, "id");
 138                if (string.IsNullOrWhiteSpace(externalId)) continue;
 139                var order = batch.FirstOrDefault(x => string.Equals(x.ExternalOrderNumber, externalId, StringComparison.
 140                if (order is null) continue;
 141
 142                var statusId = Read(row, "status_id");
 143                var statusText = Read(row, "status_txt");
 144                order.Status = string.IsNullOrWhiteSpace(statusText)
 145                    ? GetOrderStatusName(statusId)
 146                    : statusText;
 147                order.ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None);
 148                if (row["pos_list"] is JArray positions)
 149                    foreach (var position in positions.OfType<JObject>())
 150                    {
 151                        var line = order.Items.FirstOrDefault(x =>
 152                            string.Equals(x.Article, Read(position, "nr"), StringComparison.OrdinalIgnoreCase) &&
 153                            string.Equals(x.Brand, Read(position, "firm"), StringComparison.OrdinalIgnoreCase));
 154                        if (line is null) continue;
 155                        line.Quantity = ReadDecimal(position, "cnt_chk", "cnt") ?? line.Quantity;
 156                        line.Price = ReadDecimal(position, "price_chk", "price") ?? line.Price;
 157                    }
 158            }
 159        }
 160        await _db.SaveChangesAsync(ct);
 161    }
 162
 163    /// <summary>Восстанавливает заказ, созданный до сохранения локального результата, по известному ID портала.</summar
 164    public async Task<MoskvorechieIstraCartResult> ImportOrderAsync(string? externalOrderId, string changedBy, Cancellat
 165    {
 166        var orderId = externalOrderId?.Trim();
 167        if (string.IsNullOrWhiteSpace(orderId)) return new(0, 0, 1, ["Укажите ID заказа Москворечья."]);
 168
 169        var response = await _api.GetOrderStatesAsync([orderId], ct);
 170        var row = GetOrderRows(response).FirstOrDefault(x => string.Equals(Read(x, "id"), orderId, StringComparison.Ordi
 171        if (row is null || Read(row, "status_id") == "0") return new(0, 0, 1, ["API Москворечья не нашёл заказ с указанн
 172
 173        var order = await _db.MoskvorechieIstraOrders.Include(x => x.Items)
 174            .FirstOrDefaultAsync(x => x.ExternalOrderNumber == orderId, ct);
 175        if (order is not null)
 176        {
 177            order.Status = Read(row, "status_txt") ?? GetOrderStatusName(Read(row, "status_id"));
 178            order.ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None);
 179            await _db.SaveChangesAsync(ct);
 180            return new(0, 1, 0, [$"Заказ Москворечья №{orderId} уже был сохранён; статус обновлён."]);
 181        }
 182
 183        var positionRows = row["pos_list"] is JArray positions ? positions.OfType<JObject>().ToList() : [];
 184        if (positionRows.Count == 0) return new(0, 0, 1, ["API ещё не вернул состав заказа. Повторите импорт после разме
 185
 186        var links = new List<(JObject Position, MoskvorechieIstraCartItem CartItem)>();
 187        foreach (var position in positionRows)
 188        {
 189            var article = Read(position, "nr");
 190            var brand = Read(position, "firm");
 191            if (string.IsNullOrWhiteSpace(article) || string.IsNullOrWhiteSpace(brand))
 192                return new(0, 0, 1, ["API вернул позицию без артикула или производителя."]);
 193            var candidates = await _db.MoskvorechieIstraCartItems
 194                .Where(x => x.Article == article && x.Brand == brand && (x.Status == "Pending" || x.Status == "Removed")
 195                .OrderByDescending(x => x.CreatedAtUtc).Take(2).ToListAsync(ct);
 196            if (candidates.Count != 1)
 197                return new(0, 0, 1, [$"Нельзя безопасно связать {article} {brand} с заказом Studio2: найдено строк {cand
 198            links.Add((position, candidates[0]));
 199        }
 200
 201        order = new MoskvorechieIstraOrder
 202        {
 203            ExternalOrderNumber = orderId,
 204            Status = Read(row, "status_txt") ?? GetOrderStatusName(Read(row, "status_id")),
 205            ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None),
 206            CreatedBy = changedBy
 207        };
 208        _db.MoskvorechieIstraOrders.Add(order);
 209        foreach (var (position, cartItem) in links)
 210        {
 211            var quantity = ReadDecimal(position, "cnt_chk", "cnt") ?? cartItem.Quantity;
 212            var price = ReadDecimal(position, "price_chk", "price") ?? cartItem.SourcePrice;
 213            _db.MoskvorechieIstraOrderItems.Add(new MoskvorechieIstraOrderItem
 214            {
 215                MoskvorechieIstraOrder = order, StudioOrderId = cartItem.StudioOrderId, Article = cartItem.Article,
 216                Brand = cartItem.Brand, Quantity = quantity, Price = price, ExternalBasketItemId = cartItem.ExternalBask
 217            });
 218            cartItem.Status = "Ordered";
 219            cartItem.ActiveStudioOrderId = null;
 220            cartItem.UpdatedAtUtc = DateTime.UtcNow;
 221            await _supplierState.MarkOrderedAsync(cartItem.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderK
 222                orderId, externalOrderId: orderId, user: changedBy, ct: ct);
 223        }
 224        await _db.SaveChangesAsync(ct);
 225        return new(links.Count, 0, 0, [$"Заказ Москворечья №{orderId} восстановлен из API и связан с заказами Studio2."]
 226    }
 227
 228    /// <summary>
 229    /// У API Москворечья нет адресного удаления строки: только <c>clean_basket</c> для всей корзины.
 230    /// Поэтому очистка разрешена лишь когда внешняя корзина полностью состоит из удаляемых строк Studio2.
 231    /// </summary>
 232    public async Task<MoskvorechieIstraCartResult> RemoveAsync(IReadOnlyCollection<long>? cartItemIds, CancellationToken
 233    {
 234        if (cartItemIds is not { Count: > 0 })
 235            return new(0, 0, 1, ["Выберите хотя бы одну позицию Москворечья."]);
 236
 237        await SynchronizeAsync(ct);
 238        var selected = await SelectedItemsAsync(cartItemIds, ct);
 239        if (selected.Count == 0)
 240            return new(0, 0, 1, ["Выбранные позиции отсутствуют в API-корзине Москворечья."]);
 241
 242        var active = await SelectedItemsAsync(null, ct);
 243        var externalBasket = await _api.GetBasketAsync(ct);
 244        var externalRows = GetBasketRows(externalBasket);
 245        var allSelected = active.Count == selected.Count &&
 246                          externalRows.Count == selected.Count &&
 247                          selected.All(item => FindBasketRow(externalBasket, item.Article, item.Brand, item.Quantity,
 248                              item.SourcePrice, item.ExternalBasketItemId, item.Comment) is not null);
 249        if (!allSelected)
 250            return new(0, 0, 1, ["API Москворечья поддерживает только очистку всей корзины. В ней есть невыбранные либо 
 251
 252        await _api.CleanBasketAsync(ct);
 253        if (GetBasketRows(await _api.GetBasketAsync(ct)).Count != 0)
 254            return new(0, 0, selected.Count, ["Москворечье не подтвердил очистку API-корзины. Локальные строки сохранены
 255
 256        foreach (var item in selected)
 257        {
 258            item.Status = "Removed";
 259            item.ActiveStudioOrderId = null;
 260            item.LastError = null;
 261            item.UpdatedAtUtc = DateTime.UtcNow;
 262        }
 263        await _db.SaveChangesAsync(ct);
 264        foreach (var item in selected)
 265            await _supplierState.RemoveFromCartAsync(item.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderKe
 266        return new(selected.Count, 0, 0, ["Позиции удалены из API- и локальной корзины Москворечья."]);
 267    }
 268
 269    public async Task<MoskvorechieIstraCartResult> CheckoutAsync(string changedBy, IReadOnlyCollection<long>? cartItemId
 270    {
 271        await SynchronizeAsync(ct);
 272        var items = await SelectedItemsAsync(cartItemIds, ct);
 273        if (items.Count == 0) return new(0, 0, 1, ["В API-корзине нет выбранных позиций Москворечья."]);
 274        var response = await _api.MakeOrderAsync(ct); // Вызывается только после TestAsync и явного подтверждения UI.
 275        var result = response["result"] ?? response;
 276        var resultObject = result as JObject;
 277        var status = resultObject?.Value<string>("status");
 278        if (!string.IsNullOrWhiteSpace(status) && status != "0")
 279            return new(0, 0, items.Count, [$"Москворечье не оформил заказ: {resultObject?.Value<string>("msg") ?? status
 280        var number = FindOrderNumber(result);
 281        if (string.IsNullOrWhiteSpace(number))
 282            return new(0, 0, items.Count, ["Москворечье не вернул номер созданного заказа."]);
 283        var order = new MoskvorechieIstraOrder { ExternalOrderNumber = number, ResponseJson = response.ToString(Newtonso
 284        _db.MoskvorechieIstraOrders.Add(order);
 285        foreach (var item in items)
 286        {
 287            _db.MoskvorechieIstraOrderItems.Add(new MoskvorechieIstraOrderItem { MoskvorechieIstraOrder = order, StudioO
 288            item.Status = "Ordered"; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.UtcNow;
 289            await _supplierState.MarkOrderedAsync(item.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderKey, 
 290        }
 291        await _db.SaveChangesAsync(ct);
 292        return new(items.Count, 0, 0, [$"Заказ Москворечья {(number is null ? "принят API" : $"№{number}")} создан."]);
 293    }
 294
 295    public async Task<MoskvorechieIstraCartResult> CheckoutDirectAsync(IReadOnlyCollection<MoskvorechieIstraQueueItem> q
 296    {
 297        var placed = await QueueAsync(queue, changedBy, ct);
 298        if (placed.Failed > 0) return placed;
 299        var orderIds = queue.Select(x => x.OrderId).Distinct().ToList();
 300        var ids = await _db.MoskvorechieIstraCartItems.Where(x => x.ActiveStudioOrderId != null && orderIds.Contains(x.S
 301            .Select(x => x.Id).ToListAsync(ct);
 302        return await CheckoutAsync(changedBy, ids, ct);
 303    }
 304
 305    private async Task<List<MoskvorechieIstraCartItem>> SelectedItemsAsync(IReadOnlyCollection<long>? ids, CancellationT
 306    {
 307        var query = _db.MoskvorechieIstraCartItems.Where(x => x.ActiveStudioOrderId != null && x.Status == "Pending");
 308        if (ids is { Count: > 0 }) query = query.Where(x => ids.Contains(x.Id));
 309        return await query.ToListAsync(ct);
 310    }
 311
 312    private static string? FindOrderNumber(JToken token)
 313    {
 314        var values = token is JContainer container ? container.DescendantsAndSelf().OfType<JValue>() : new[] { token }.O
 315        return values.FirstOrDefault(value => value.Parent is JProperty property && property.Name is "id" or "order" or 
 316    }
 317
 318    private static IReadOnlyList<JObject> GetBasketRows(JToken basket)
 319    {
 320        var result = basket["result"] ?? basket;
 321        return result is JArray array ? array.OfType<JObject>().ToList() : [];
 322    }
 323
 324    private static IReadOnlyList<JObject> GetOrderRows(JToken response)
 325    {
 326        var result = response["result"] ?? response;
 327        return result is JArray array ? array.OfType<JObject>().ToList() : [];
 328    }
 329
 330    private static string GetOrderStatusName(string? statusId) => statusId switch
 331    {
 332        "-1" => "Подключение к учётной системе",
 333        "-2" => "Создание заказа",
 334        "-3" => "Резервирование товара",
 335        "-4" => "Создание расходной накладной",
 336        "-9" => "Передача менеджеру",
 337        "0" => "Заказ не найден",
 338        "1" => "Размещён в учётной системе",
 339        "2" => "Создана расходная накладная",
 340        "3" => "Создана заявка",
 341        "9" => "Ручная обработка менеджером",
 342        _ => string.IsNullOrWhiteSpace(statusId) ? "Ожидает статуса" : $"Статус API {statusId}"
 343    };
 344
 345    /// <remarks>
 346    /// <c>my_basket</c> возвращает nr/brand/quantity/price, но не gid и не comment (это подтверждено
 347    /// фактическим ответом API). Поэтому сначала используем точные gid+comment, если они есть, а затем
 348    /// сверяем доступный контракт строки корзины.
 349    /// </remarks>
 350    private static bool BasketContains(JToken basket, string article, string brand, decimal quantity, decimal? sourcePri
 351        string offerId, string? comment) => FindBasketRow(basket, article, brand, quantity, sourcePrice, offerId, commen
 352
 353    internal static JObject? FindBasketRow(JToken basket, string article, string brand, decimal quantity, decimal? sourc
 354        string offerId, string? comment)
 355    {
 356        var tokens = basket is JContainer container ? container.DescendantsAndSelf() : new[] { basket };
 357        return tokens.OfType<JObject>().FirstOrDefault(row =>
 358        {
 359            var ids = row.Properties().Where(property => property.Name.Equals("gid", StringComparison.OrdinalIgnoreCase)
 360                    || property.Name.Equals("id", StringComparison.OrdinalIgnoreCase)
 361                    || property.Name.Equals("good_id", StringComparison.OrdinalIgnoreCase))
 362                .Select(property => property.Value.ToString());
 363            var commentMatches = string.IsNullOrWhiteSpace(comment) || row.Properties()
 364                .Where(property => property.Name.Contains("comment", StringComparison.OrdinalIgnoreCase))
 365                .Any(property => string.Equals(property.Value.ToString(), comment, StringComparison.Ordinal));
 366            if (ids.Contains(offerId, StringComparer.Ordinal) && commentMatches) return true;
 367
 368            var apiArticle = Read(row, "nr", "article", "code");
 369            var apiBrand = Read(row, "brand", "firm", "producer");
 370            if (!string.Equals(NormalizeArticle(apiArticle), NormalizeArticle(article), StringComparison.Ordinal) ||
 371                !string.Equals(apiBrand, brand, StringComparison.OrdinalIgnoreCase)) return false;
 372            // Остаток может прийти со знаком «не менее» («>500»): без его снятия позиция
 373            // считалась бы недоступной и проведение транзакции срывалось бы на живом товаре.
 374            if (!decimal.TryParse(Read(row, "quantity", "qty", "count")?.Trim().Trim('>', '<', '~', '='), System.Globali
 375                    System.Globalization.CultureInfo.InvariantCulture, out var apiQuantity) || apiQuantity < quantity) r
 376            return !sourcePrice.HasValue ||
 377                   (decimal.TryParse(Read(row, "price"), System.Globalization.NumberStyles.Number,
 378                        System.Globalization.CultureInfo.InvariantCulture, out var apiPrice) &&
 379                    Math.Abs(apiPrice - sourcePrice.Value) < 0.01m);
 380        });
 381    }
 382
 383    private static string? Read(JObject row, params string[] names) => row.Properties()
 384        .FirstOrDefault(property => names.Contains(property.Name, StringComparer.OrdinalIgnoreCase))?.Value.ToString();
 385
 386    private static string NormalizeArticle(string? value) => new string((value ?? string.Empty)
 387        .Where(char.IsLetterOrDigit)
 388        .Select(char.ToUpperInvariant)
 389        .ToArray());
 390
 391    private static decimal? ReadDecimal(JObject row, params string[] names) => decimal.TryParse(Read(row, names),
 392        System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var value) ? va
 393
 394    private static int? ParseDays(string? value)
 395    {
 396        var digits = new string((value ?? string.Empty).Where(char.IsDigit).ToArray());
 397        return int.TryParse(digits, out var days) ? days : null;
 398    }
 399
 400    private async Task TrackPlacementAsync(MoskvorechieIstraQueueItem item, string comment, string changedBy, Cancellati
 401    {
 402        try
 403        {
 404            await _supplierState.MarkInCartAsync(item.OrderId, ApiSupplierSettings.MoskvorechieIstraProviderKey,
 405                ApiSupplierBasketCartStatus.Pending, externalBasketItemId: item.OfferId, user: changedBy,
 406                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction,
 407            var entry = new ApiSupplierBasketHistoryEntry(ApiSupplierSettings.MoskvorechieIstraProviderKey,
 408                "Москворечье (Истра)", "/images/moskvorechie-logo.gif", true, null, item.OfferId,
 409                item.Article, item.ProductName, item.SourcePrice, "RUB", item.Quantity, item.ShipmentNumber,
 410                ClientName: item.ClientName, WarehouseName: item.Direction, CartStatus: ApiSupplierBasketCartStatus.Pend
 411                SupplierCartUrl: "/SupplierCart?provider=moskvorechieistra&supplierTab=basket");
 412            await _orderHistory.AddOrderHistory(new OrderHistory
 413            {
 414                OrderId = item.OrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 415                ColumnDisplayName = "Корзина Москворечье (Истра)", NewValue = JsonSerializer.Serialize(entry, ApiSupplie
 416                ChangedAt = DateTime.Now, ChangedBy = changedBy
 417            });
 418        }
 419        catch
 420        {
 421            // Запись статуса и истории не должна отменять уже успешно добавленную строку портала.
 422        }
 423    }
 424}