< Summary

Information
Class: OzonOrdersWeb.TransactionServices.MoskvorechieIstraCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/MoskvorechieIstraCartService.cs
Line coverage
100%
Covered lines: 276
Uncovered lines: 0
Coverable lines: 276
Total lines: 424
Line coverage: 100%
Branch coverage
88%
Covered branches: 207
Total branches: 233
Branch coverage: 88.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
QueueAsync()95%2020100%
SynchronizeAsync()93.75%1616100%
TestAsync()100%22100%
SynchronizeOrdersAsync()90.9%2222100%
ImportOrderAsync()84.37%3232100%
RemoveAsync()100%1818100%
CheckoutAsync()72.72%2222100%
CheckoutDirectAsync()100%22100%
SelectedItemsAsync()100%44100%
FindOrderNumber(...)92.85%1414100%
GetBasketRows(...)50%44100%
GetOrderRows(...)50%44100%
GetOrderStatusName(...)100%3535100%
BasketContains(...)100%11100%
FindBasketRow(...)84.61%2626100%
Read(...)100%22100%
NormalizeArticle(...)100%22100%
ReadDecimal(...)50%22100%
ParseDays(...)100%44100%
TrackPlacementAsync()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
 13public sealed record MoskvorechieIstraQueueItem(int OrderId, string OfferId, string Article, string Brand, string? Produ
 14    string? ShipmentNumber, string? ClientName, decimal Quantity, decimal MinimumQuantity, decimal? SourcePrice,
 15    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;
 5533    public MoskvorechieIstraCartService(ApplicationDbContext db, MoskvorechieIstraDataManager api,
 5534        IOrderHistoryDataService orderHistory, IOrderSupplierStateService supplierState) =>
 5535        (_db, _api, _orderHistory, _supplierState) = (db, api, orderHistory, supplierState);
 36
 37    public async Task<MoskvorechieIstraCartResult> QueueAsync(IReadOnlyCollection<MoskvorechieIstraQueueItem> queue, str
 38    {
 6039        var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>();
 6040        foreach (var item in queue)
 41        {
 1542            if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.OfferId) || item.Quantity < Math.Max(1, item.Minimum
 2143            { failed++; messages.Add($"Заказ {item.OrderId}: неверная позиция или количество меньше минимальной партии."
 844            if (await _db.MoskvorechieIstraCartItems.AnyAsync(x => x.ActiveStudioOrderId == item.OrderId, ct))
 345            { skipped++; messages.Add($"Заказ {item.OrderId}: уже добавлен в корзину Москворечья."); continue; }
 746            var comment = $"Studio2 {item.ShipmentNumber ?? $"заказ {item.OrderId}"} (ID {item.OrderId})";
 47            try
 48            {
 49                // API-корзина — единственный источник истины: локальную связь создаём лишь после
 50                // успешного to_basket и повторного my_basket, где подтверждены gid и комментарий Studio2.
 751                var externalBasket = await _api.GetBasketAsync(ct);
 752                var alreadyInExternalBasket = BasketContains(externalBasket, item.Article, item.Brand, item.Quantity,
 753                    item.SourcePrice, item.OfferId, comment);
 754                if (!alreadyInExternalBasket)
 55                {
 656                    await _api.AddToBasketAsync(item.OfferId, item.Quantity, comment, ct);
 457                    externalBasket = await _api.GetBasketAsync(ct);
 58                }
 559                if (!BasketContains(externalBasket, item.Article, item.Brand, item.Quantity, item.SourcePrice, item.Offe
 160                    throw new MoskvorechieApiException("API Москворечья не подтвердил позицию в своей корзине. Локальная
 461                _db.MoskvorechieIstraCartItems.Add(new MoskvorechieIstraCartItem
 462                {
 463                    StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, ExternalBasketItemId = item.OfferI
 464                    Article = item.Article, Brand = item.Brand, ProductName = item.ProductName, Quantity = item.Quantity
 465                    SourcePrice = item.SourcePrice, Warehouse = item.Direction, DeliveryDaysMin = item.DeliveryDaysMin,
 466                    DeliveryDaysMax = item.DeliveryDaysMax, Comment = comment, CreatedBy = changedBy
 467                });
 1268                await _db.SaveChangesAsync(ct); added++; messages.Add(alreadyInExternalBasket
 469                    ? $"Заказ {item.OrderId}: позиция уже была в API-корзине, локальная связь восстановлена."
 470                    : $"Заказ {item.OrderId}: добавлен в корзину Москворечья.");
 471                await TrackPlacementAsync(item, comment, changedBy, ct);
 472            }
 1273            catch (MoskvorechieApiException ex) { failed++; messages.Add($"Заказ {item.OrderId}: {ex.Message}"); }
 774        }
 1575        return new MoskvorechieIstraCartResult(added, skipped, failed, messages);
 1576    }
 77
 78    /// <summary>Сверяет активные связи с <c>my_basket</c>. Исчезнувшие из API строки больше не показываются в Studio2.<
 79    public async Task SynchronizeAsync(CancellationToken ct)
 80    {
 1481        var local = await _db.MoskvorechieIstraCartItems
 1482            .Where(x => x.ActiveStudioOrderId != null && x.Status != "Removed")
 1483            .ToListAsync(ct);
 1784        if (local.Count == 0) return;
 85
 1186        var externalBasket = await _api.GetBasketAsync(ct);
 1187        var changed = false;
 4488        foreach (var item in local)
 89        {
 1190            var external = FindBasketRow(externalBasket, item.Article, item.Brand, item.Quantity, item.SourcePrice,
 1191                item.ExternalBasketItemId, item.Comment);
 1192            if (external is null)
 93            {
 294                item.Status = "Removed";
 295                item.ActiveStudioOrderId = null;
 296                item.LastError = "Позиция отсутствует в API-корзине Москворечья.";
 297                item.UpdatedAtUtc = DateTime.UtcNow;
 298                changed = true;
 299                continue;
 100            }
 101
 102            // Обновляем отображаемый снимок реальными значениями my_basket.
 9103            item.ProductName = Read(external, "name") ?? item.ProductName;
 9104            item.SourcePrice = ReadDecimal(external, "price") ?? item.SourcePrice;
 9105            var deliveryDays = ParseDays(Read(external, "delivery"));
 9106            item.DeliveryDaysMin = deliveryDays ?? item.DeliveryDaysMin;
 9107            item.DeliveryDaysMax = deliveryDays ?? item.DeliveryDaysMax;
 9108            item.LastError = null;
 9109            item.UpdatedAtUtc = DateTime.UtcNow;
 9110            changed = true;
 111        }
 22112        if (changed) await _db.SaveChangesAsync(ct);
 14113    }
 114
 115    public async Task<MoskvorechieIstraCartResult> TestAsync(IReadOnlyCollection<long>? cartItemIds, CancellationToken c
 116    {
 2117        await SynchronizeAsync(ct);
 2118        var items = await SelectedItemsAsync(cartItemIds, ct);
 2119        return items.Count == 0
 2120            ? new(0, 0, 1, ["В API-корзине нет выбранных позиций Москворечья."])
 121            : new(items.Count, 0, 0, items.Select(x => $"{x.Article}: подтверждён в API-корзине.").ToList());
 2122    }
 123
 124    /// <summary>Обновляет начальный статус каждого известного внешнего заказа через make_order_v2?oids=….</summary>
 125    public async Task SynchronizeOrdersAsync(CancellationToken ct)
 126    {
 14127        var orders = await _db.MoskvorechieIstraOrders.Include(x => x.Items)
 14128            .Where(x => x.ExternalOrderNumber != null && x.ExternalOrderNumber != "")
 14129            .ToListAsync(ct);
 15130        if (orders.Count == 0) return;
 131
 52132        foreach (var batch in orders.Chunk(50))
 133        {
 134            var response = await _api.GetOrderStatesAsync(batch.Select(x => x.ExternalOrderNumber!).ToList(), ct);
 52135            foreach (var row in GetOrderRows(response))
 136            {
 13137                var externalId = Read(row, "id");
 13138                if (string.IsNullOrWhiteSpace(externalId)) continue;
 26139                var order = batch.FirstOrDefault(x => string.Equals(x.ExternalOrderNumber, externalId, StringComparison.
 13140                if (order is null) continue;
 141
 13142                var statusId = Read(row, "status_id");
 13143                var statusText = Read(row, "status_txt");
 13144                order.Status = string.IsNullOrWhiteSpace(statusText)
 13145                    ? GetOrderStatusName(statusId)
 13146                    : statusText;
 13147                order.ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None);
 13148                if (row["pos_list"] is JArray positions)
 52149                    foreach (var position in positions.OfType<JObject>())
 150                    {
 13151                        var line = order.Items.FirstOrDefault(x =>
 26152                            string.Equals(x.Article, Read(position, "nr"), StringComparison.OrdinalIgnoreCase) &&
 26153                            string.Equals(x.Brand, Read(position, "firm"), StringComparison.OrdinalIgnoreCase));
 13154                        if (line is null) continue;
 13155                        line.Quantity = ReadDecimal(position, "cnt_chk", "cnt") ?? line.Quantity;
 13156                        line.Price = ReadDecimal(position, "price_chk", "price") ?? line.Price;
 157                    }
 158            }
 13159        }
 13160        await _db.SaveChangesAsync(ct);
 14161    }
 162
 163    /// <summary>Восстанавливает заказ, созданный до сохранения локального результата, по известному ID портала.</summar
 164    public async Task<MoskvorechieIstraCartResult> ImportOrderAsync(string? externalOrderId, string changedBy, Cancellat
 165    {
 11166        var orderId = externalOrderId?.Trim();
 14167        if (string.IsNullOrWhiteSpace(orderId)) return new(0, 0, 1, ["Укажите ID заказа Москворечья."]);
 168
 8169        var response = await _api.GetOrderStatesAsync([orderId], ct);
 15170        var row = GetOrderRows(response).FirstOrDefault(x => string.Equals(Read(x, "id"), orderId, StringComparison.Ordi
 10171        if (row is null || Read(row, "status_id") == "0") return new(0, 0, 1, ["API Москворечья не нашёл заказ с указанн
 172
 6173        var order = await _db.MoskvorechieIstraOrders.Include(x => x.Items)
 6174            .FirstOrDefaultAsync(x => x.ExternalOrderNumber == orderId, ct);
 6175        if (order is not null)
 176        {
 1177            order.Status = Read(row, "status_txt") ?? GetOrderStatusName(Read(row, "status_id"));
 1178            order.ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None);
 1179            await _db.SaveChangesAsync(ct);
 1180            return new(0, 1, 0, [$"Заказ Москворечья №{orderId} уже был сохранён; статус обновлён."]);
 181        }
 182
 5183        var positionRows = row["pos_list"] is JArray positions ? positions.OfType<JObject>().ToList() : [];
 6184        if (positionRows.Count == 0) return new(0, 0, 1, ["API ещё не вернул состав заказа. Повторите импорт после разме
 185
 4186        var links = new List<(JObject Position, MoskvorechieIstraCartItem CartItem)>();
 13187        foreach (var position in positionRows)
 188        {
 4189            var article = Read(position, "nr");
 4190            var brand = Read(position, "firm");
 4191            if (string.IsNullOrWhiteSpace(article) || string.IsNullOrWhiteSpace(brand))
 1192                return new(0, 0, 1, ["API вернул позицию без артикула или производителя."]);
 3193            var candidates = await _db.MoskvorechieIstraCartItems
 3194                .Where(x => x.Article == article && x.Brand == brand && (x.Status == "Pending" || x.Status == "Removed")
 3195                .OrderByDescending(x => x.CreatedAtUtc).Take(2).ToListAsync(ct);
 3196            if (candidates.Count != 1)
 2197                return new(0, 0, 1, [$"Нельзя безопасно связать {article} {brand} с заказом Studio2: найдено строк {cand
 1198            links.Add((position, candidates[0]));
 1199        }
 200
 1201        order = new MoskvorechieIstraOrder
 1202        {
 1203            ExternalOrderNumber = orderId,
 1204            Status = Read(row, "status_txt") ?? GetOrderStatusName(Read(row, "status_id")),
 1205            ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None),
 1206            CreatedBy = changedBy
 1207        };
 1208        _db.MoskvorechieIstraOrders.Add(order);
 4209        foreach (var (position, cartItem) in links)
 210        {
 1211            var quantity = ReadDecimal(position, "cnt_chk", "cnt") ?? cartItem.Quantity;
 1212            var price = ReadDecimal(position, "price_chk", "price") ?? cartItem.SourcePrice;
 1213            _db.MoskvorechieIstraOrderItems.Add(new MoskvorechieIstraOrderItem
 1214            {
 1215                MoskvorechieIstraOrder = order, StudioOrderId = cartItem.StudioOrderId, Article = cartItem.Article,
 1216                Brand = cartItem.Brand, Quantity = quantity, Price = price, ExternalBasketItemId = cartItem.ExternalBask
 1217            });
 1218            cartItem.Status = "Ordered";
 1219            cartItem.ActiveStudioOrderId = null;
 1220            cartItem.UpdatedAtUtc = DateTime.UtcNow;
 1221            await _supplierState.MarkOrderedAsync(cartItem.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderK
 1222                orderId, externalOrderId: orderId, user: changedBy, ct: ct);
 223        }
 1224        await _db.SaveChangesAsync(ct);
 1225        return new(links.Count, 0, 0, [$"Заказ Москворечья №{orderId} восстановлен из API и связан с заказами Studio2."]
 11226    }
 227
 228    /// <summary>
 229    /// У API Москворечья нет адресного удаления строки: только <c>clean_basket</c> для всей корзины.
 230    /// Поэтому очистка разрешена лишь когда внешняя корзина полностью состоит из удаляемых строк Studio2.
 231    /// </summary>
 232    public async Task<MoskvorechieIstraCartResult> RemoveAsync(IReadOnlyCollection<long>? cartItemIds, CancellationToken
 233    {
 6234        if (cartItemIds is not { Count: > 0 })
 2235            return new(0, 0, 1, ["Выберите хотя бы одну позицию Москворечья."]);
 236
 4237        await SynchronizeAsync(ct);
 4238        var selected = await SelectedItemsAsync(cartItemIds, ct);
 4239        if (selected.Count == 0)
 1240            return new(0, 0, 1, ["Выбранные позиции отсутствуют в API-корзине Москворечья."]);
 241
 3242        var active = await SelectedItemsAsync(null, ct);
 3243        var externalBasket = await _api.GetBasketAsync(ct);
 3244        var externalRows = GetBasketRows(externalBasket);
 3245        var allSelected = active.Count == selected.Count &&
 3246                          externalRows.Count == selected.Count &&
 5247                          selected.All(item => FindBasketRow(externalBasket, item.Article, item.Brand, item.Quantity,
 5248                              item.SourcePrice, item.ExternalBasketItemId, item.Comment) is not null);
 3249        if (!allSelected)
 1250            return new(0, 0, 1, ["API Москворечья поддерживает только очистку всей корзины. В ней есть невыбранные либо 
 251
 2252        await _api.CleanBasketAsync(ct);
 2253        if (GetBasketRows(await _api.GetBasketAsync(ct)).Count != 0)
 1254            return new(0, 0, selected.Count, ["Москворечье не подтвердил очистку API-корзины. Локальные строки сохранены
 255
 4256        foreach (var item in selected)
 257        {
 1258            item.Status = "Removed";
 1259            item.ActiveStudioOrderId = null;
 1260            item.LastError = null;
 1261            item.UpdatedAtUtc = DateTime.UtcNow;
 262        }
 1263        await _db.SaveChangesAsync(ct);
 4264        foreach (var item in selected)
 1265            await _supplierState.RemoveFromCartAsync(item.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderKe
 1266        return new(selected.Count, 0, 0, ["Позиции удалены из API- и локальной корзины Москворечья."]);
 6267    }
 268
 269    public async Task<MoskvorechieIstraCartResult> CheckoutAsync(string changedBy, IReadOnlyCollection<long>? cartItemId
 270    {
 5271        await SynchronizeAsync(ct);
 5272        var items = await SelectedItemsAsync(cartItemIds, ct);
 6273        if (items.Count == 0) return new(0, 0, 1, ["В API-корзине нет выбранных позиций Москворечья."]);
 4274        var response = await _api.MakeOrderAsync(ct); // Вызывается только после TestAsync и явного подтверждения UI.
 4275        var result = response["result"] ?? response;
 4276        var resultObject = result as JObject;
 4277        var status = resultObject?.Value<string>("status");
 4278        if (!string.IsNullOrWhiteSpace(status) && status != "0")
 1279            return new(0, 0, items.Count, [$"Москворечье не оформил заказ: {resultObject?.Value<string>("msg") ?? status
 3280        var number = FindOrderNumber(result);
 3281        if (string.IsNullOrWhiteSpace(number))
 1282            return new(0, 0, items.Count, ["Москворечье не вернул номер созданного заказа."]);
 2283        var order = new MoskvorechieIstraOrder { ExternalOrderNumber = number, ResponseJson = response.ToString(Newtonso
 2284        _db.MoskvorechieIstraOrders.Add(order);
 8285        foreach (var item in items)
 286        {
 2287            _db.MoskvorechieIstraOrderItems.Add(new MoskvorechieIstraOrderItem { MoskvorechieIstraOrder = order, StudioO
 6288            item.Status = "Ordered"; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.UtcNow;
 2289            await _supplierState.MarkOrderedAsync(item.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderKey, 
 290        }
 2291        await _db.SaveChangesAsync(ct);
 2292        return new(items.Count, 0, 0, [$"Заказ Москворечья {(number is null ? "принят API" : $"№{number}")} создан."]);
 5293    }
 294
 295    public async Task<MoskvorechieIstraCartResult> CheckoutDirectAsync(IReadOnlyCollection<MoskvorechieIstraQueueItem> q
 296    {
 2297        var placed = await QueueAsync(queue, changedBy, ct);
 3298        if (placed.Failed > 0) return placed;
 299        var orderIds = queue.Select(x => x.OrderId).Distinct().ToList();
 1300        var ids = await _db.MoskvorechieIstraCartItems.Where(x => x.ActiveStudioOrderId != null && orderIds.Contains(x.S
 1301            .Select(x => x.Id).ToListAsync(ct);
 1302        return await CheckoutAsync(changedBy, ids, ct);
 2303    }
 304
 305    private async Task<List<MoskvorechieIstraCartItem>> SelectedItemsAsync(IReadOnlyCollection<long>? ids, CancellationT
 306    {
 14307        var query = _db.MoskvorechieIstraCartItems.Where(x => x.ActiveStudioOrderId != null && x.Status == "Pending");
 23308        if (ids is { Count: > 0 }) query = query.Where(x => ids.Contains(x.Id));
 14309        return await query.ToListAsync(ct);
 14310    }
 311
 312    private static string? FindOrderNumber(JToken token)
 313    {
 3314        var values = token is JContainer container ? container.DescendantsAndSelf().OfType<JValue>() : new[] { token }.O
 8315        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    {
 5320        var result = basket["result"] ?? basket;
 5321        return result is JArray array ? array.OfType<JObject>().ToList() : [];
 322    }
 323
 324    private static IReadOnlyList<JObject> GetOrderRows(JToken response)
 325    {
 21326        var result = response["result"] ?? response;
 21327        return result is JArray array ? array.OfType<JObject>().ToList() : [];
 328    }
 329
 12330    private static string GetOrderStatusName(string? statusId) => statusId switch
 12331    {
 1332        "-1" => "Подключение к учётной системе",
 1333        "-2" => "Создание заказа",
 1334        "-3" => "Резервирование товара",
 1335        "-4" => "Создание расходной накладной",
 1336        "-9" => "Передача менеджеру",
 1337        "0" => "Заказ не найден",
 1338        "1" => "Размещён в учётной системе",
 1339        "2" => "Создана расходная накладная",
 1340        "3" => "Создана заявка",
 1341        "9" => "Ручная обработка менеджером",
 2342        _ => string.IsNullOrWhiteSpace(statusId) ? "Ожидает статуса" : $"Статус API {statusId}"
 12343    };
 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
 12351        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    {
 35356        var tokens = basket is JContainer container ? container.DescendantsAndSelf() : new[] { basket };
 35357        return tokens.OfType<JObject>().FirstOrDefault(row =>
 35358        {
 149359            var ids = row.Properties().Where(property => property.Name.Equals("gid", StringComparison.OrdinalIgnoreCase)
 149360                    || property.Name.Equals("id", StringComparison.OrdinalIgnoreCase)
 149361                    || property.Name.Equals("good_id", StringComparison.OrdinalIgnoreCase))
 78362                .Select(property => property.Value.ToString());
 61363            var commentMatches = string.IsNullOrWhiteSpace(comment) || row.Properties()
 104364                .Where(property => property.Name.Contains("comment", StringComparison.OrdinalIgnoreCase))
 78365                .Any(property => string.Equals(property.Value.ToString(), comment, StringComparison.Ordinal));
 78366            if (ids.Contains(offerId, StringComparer.Ordinal) && commentMatches) return true;
 35367
 44368            var apiArticle = Read(row, "nr", "article", "code");
 44369            var apiBrand = Read(row, "brand", "firm", "producer");
 44370            if (!string.Equals(NormalizeArticle(apiArticle), NormalizeArticle(article), StringComparison.Ordinal) ||
 81371                !string.Equals(apiBrand, brand, StringComparison.OrdinalIgnoreCase)) return false;
 35372            // Остаток может прийти со знаком «не менее» («>500»): без его снятия позиция
 35373            // считалась бы недоступной и проведение транзакции срывалось бы на живом товаре.
 7374            if (!decimal.TryParse(Read(row, "quantity", "qty", "count")?.Trim().Trim('>', '<', '~', '='), System.Globali
 8375                    System.Globalization.CultureInfo.InvariantCulture, out var apiQuantity) || apiQuantity < quantity) r
 6376            return !sourcePrice.HasValue ||
 6377                   (decimal.TryParse(Read(row, "price"), System.Globalization.NumberStyles.Number,
 6378                        System.Globalization.CultureInfo.InvariantCulture, out var apiPrice) &&
 6379                    Math.Abs(apiPrice - sourcePrice.Value) < 0.01m);
 35380        });
 381    }
 382
 245383    private static string? Read(JObject row, params string[] names) => row.Properties()
 790384        .FirstOrDefault(property => names.Contains(property.Name, StringComparer.OrdinalIgnoreCase))?.Value.ToString();
 385
 88386    private static string NormalizeArticle(string? value) => new string((value ?? string.Empty)
 88387        .Where(char.IsLetterOrDigit)
 88388        .Select(char.ToUpperInvariant)
 88389        .ToArray());
 390
 37391    private static decimal? ReadDecimal(JObject row, params string[] names) => decimal.TryParse(Read(row, names),
 37392        System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var value) ? va
 393
 394    private static int? ParseDays(string? value)
 395    {
 9396        var digits = new string((value ?? string.Empty).Where(char.IsDigit).ToArray());
 9397        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        {
 4404            await _supplierState.MarkInCartAsync(item.OrderId, ApiSupplierSettings.MoskvorechieIstraProviderKey,
 4405                ApiSupplierBasketCartStatus.Pending, externalBasketItemId: item.OfferId, user: changedBy,
 4406                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction,
 3407            var entry = new ApiSupplierBasketHistoryEntry(ApiSupplierSettings.MoskvorechieIstraProviderKey,
 3408                "Москворечье (Истра)", "/images/moskvorechie-logo.gif", true, null, item.OfferId,
 3409                item.Article, item.ProductName, item.SourcePrice, "RUB", item.Quantity, item.ShipmentNumber,
 3410                ClientName: item.ClientName, WarehouseName: item.Direction, CartStatus: ApiSupplierBasketCartStatus.Pend
 3411                SupplierCartUrl: "/SupplierCart?provider=moskvorechieistra&supplierTab=basket");
 3412            await _orderHistory.AddOrderHistory(new OrderHistory
 3413            {
 3414                OrderId = item.OrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 3415                ColumnDisplayName = "Корзина Москворечье (Истра)", NewValue = JsonSerializer.Serialize(entry, ApiSupplie
 3416                ChangedAt = DateTime.Now, ChangedBy = changedBy
 3417            });
 3418        }
 1419        catch
 420        {
 421            // Запись статуса и истории не должна отменять уже успешно добавленную строку портала.
 1422        }
 4423    }
 424}