< Summary

Information
Class: OzonOrdersWeb.TransactionServices.FavoritPartsCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/FavoritPartsCartService.cs
Line coverage
3%
Covered lines: 17
Uncovered lines: 544
Coverable lines: 561
Total lines: 1018
Line coverage: 3%
Branch coverage
4%
Covered branches: 12
Total branches: 268
Branch coverage: 4.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Text.Encodings.Web;
 4using System.Text.Json;
 5using Microsoft.EntityFrameworkCore;
 6using Microsoft.Extensions.Caching.Memory;
 7using Microsoft.Extensions.Logging;
 8using Newtonsoft.Json.Linq;
 9using OzonDomains.Models;
 10using OzonDomains.Models.SupplierStatuses;
 11using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart;
 12using OzonRepositories.Context;
 13using Services.ApiServices.FavoritPartsApi;
 14using Services.BackgroundJobsServices;
 15using Services.DataServices;
 16using Services.SignalRServices;
 17using Services.SupplierStatusServices;
 18
 19namespace OzonOrdersWeb.TransactionServices;
 20
 21public sealed record FavoritPartsQueueItem(
 22    int OrderId, string GoodsId, string WarehouseGroupId, string Article, string Brand, string? ProductName,
 23    string? ShipmentNumber, string? ClientName, decimal Quantity, decimal? SourcePrice, string? CurrencyCode,
 24    int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, string? Direction = null);
 25
 26public sealed record FavoritPartsCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 27{
 28    public string ToHtmlBlock(string providerRoute = "favoritparts", string supplierName = "FavoritParts")
 29    {
 30        if (Added == 0 && Skipped == 0 && Failed == 0) return string.Empty;
 31        var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>");
 32        var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty;
 33        var cartLink = $"<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?provider={WebUtility.UrlEncode(pr
 34        var verificationNotice = Added > 0
 35            ? $"<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'><
 36            : string.Empty;
 37        return "<div class='alert alert-info border-primary-subtle mb-0'>" +
 38               $"<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина {WebUtility
 39               $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b
 40               $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" +
 41               $"<div class='d-flex flex-wrap gap-2 mt-2'>{cartLink}</div>{verificationNotice}</div>";
 42    }
 43}
 44
 45public sealed record FavoritPartsDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages);
 46
 47/// <summary>Строка реальной серверной корзины FavoritParts (GET ws/v1/cart/), развёрнутая из вложенных
 48/// goods/warehouseGroup/warehouseShipping словарей ответа. Key — составной идентификатор
 49/// "{goods}|{warehouseGroup}", используется вместо суррогатного Id локальной таблицы: источник истины —
 50/// сам API, а не локальный кэш. Одна строка корзины может относиться сразу к нескольким заказам Studio2
 51/// (cart/add не суммирует количество, поэтому одинаковый товар с одного склада хранения кладётся одной
 52/// строкой на суммарное количество) — привязка хранится в FavoritPartsCartItems, а comment остаётся
 53/// вспомогательным каналом для строк, добавленных до перехода на агрегацию.</summary>
 54public sealed record FavoritPartsApiCartLine(
 55    Guid Goods, Guid WarehouseGroup, Guid WarehouseShipping,
 56    decimal Count, decimal Price, decimal Stock, bool NotRefund, DateTimeOffset? ShipmentDate, string? Comment,
 57    string? GoodsName, string? Brand, string? Number, int Rate,
 58    string? WarehouseGroupCode, string? WarehouseShippingName)
 59{
 60    public string Key => FavoritPartsCartService.BuildCartKey(Goods, WarehouseGroup);
 61    public int? StudioOrderId => StudioOrderCommentLink.ExtractOrderId(Comment);
 62    /// <summary>Дата отгрузки в московском поясе — в этом же виде её ждёт POST order/ (ShippingDate).</summary>
 63    public DateOnly? ShippingDate => FavoritPartsPriceMapper.ToMoscowDate(ShipmentDate);
 64}
 65
 66/// <summary>Реальная серверная корзина FavoritParts (ws/v1/cart/*) + реальное оформление заказа
 67/// (POST ws/v1/order/). Один заказ FavoritParts = один склад отгрузки + одна дата отгрузки — обе
 68/// становятся известны только из ответа GET cart/, поэтому группировка перед созданием заказа
 69/// происходит в момент оформления (CreateOrdersFromLinesAsync), а не в момент добавления в корзину.
 70/// Корзина и заказы на /SupplierCart читаются напрямую из API (GetApiCartAsync/GetApiOrdersAsync);
 71/// локальная таблица FavoritPartsCartItems держит привязку строк корзины к заказам Studio2 — по
 72/// документации cart/add устанавливает последнее переданное количество, а не сумму, поэтому одна
 73/// строка корзины может обслуживать несколько заказов Studio2, и comment на всех уже не хватает.</summary>
 74public class FavoritPartsCartService
 75{
 076    protected string ProviderKey { get; }
 77    private readonly string _supplierDisplayName;
 78    private readonly string _providerRoute;
 79    private const string LogoUrl = "/images/favorit-parts-logo.png";
 080    private string ProfileCacheKey => $"{ProviderKey}:Profile";
 81    /// <summary>Ограничение поля comment в cart/add и в строке заказа (документация: строка &lt;100 символов).</summary
 82    public const int CommentMaxLength = 100;
 83
 084    private static readonly TimeZoneInfo MoscowZone = RussianTimeZoneHelper.Zone;
 085    private static readonly JsonSerializerOptions JsonOptions = new()
 086    {
 087        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
 088    };
 89
 90    private readonly ApplicationDbContext _db;
 91    private readonly FavoritPartsDataManager _favoritParts;
 92    private readonly IOrderHistoryDataService _history;
 93    private readonly IMemoryCache _cache;
 94    private readonly IOrderSupplierStateService _supplierState;
 95    private readonly ILogger<FavoritPartsCartService> _logger;
 96
 097    public FavoritPartsCartService(ApplicationDbContext db, FavoritPartsDataManager favoritParts,
 098        IOrderHistoryDataService history, IMemoryCache cache, IOrderSupplierStateService supplierState,
 099        ILogger<FavoritPartsCartService> logger, string providerKey = ApiSupplierSettings.FavoritPartsProviderKey,
 0100        string supplierDisplayName = "Фаворит Союз", string providerRoute = "favoritparts")
 101    {
 0102        _db = db;
 0103        _favoritParts = favoritParts;
 0104        _history = history;
 0105        _cache = cache;
 0106        _supplierState = supplierState;
 0107        _logger = logger;
 0108        ProviderKey = providerKey;
 0109        _supplierDisplayName = supplierDisplayName;
 0110        _providerRoute = providerRoute;
 0111    }
 112
 113    public static string BuildCartKey(Guid goods, Guid warehouseGroup) =>
 0114        $"{goods:D}|{warehouseGroup:D}".ToLowerInvariant();
 115
 116    internal static DateOnly MoscowToday =>
 0117        DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, MoscowZone).DateTime);
 118
 119    /// <summary>Читает реальную корзину FavoritParts и разворачивает её в строки, готовые к отображению.</summary>
 120    public async Task<IReadOnlyList<FavoritPartsApiCartLine>> GetApiCartAsync(CancellationToken ct = default) =>
 0121        ParseCartLines(await _favoritParts.GetCartAsync(ct));
 122
 123    public Task<JToken> GetApiOrdersAsync(bool active, bool rowsOrder, CancellationToken ct = default) =>
 0124        _favoritParts.GetOrdersAsync(active: active, rowsOrder: rowsOrder, ct: ct);
 125
 126    public Task<JToken> GetSingleApiOrderAsync(Guid externalOrderId, CancellationToken ct = default) =>
 0127        _favoritParts.GetOrderAsync(externalOrderId, ct);
 128
 129    /// <summary>Количество строк в реальной корзине — для индикатора в боковом меню.</summary>
 130    public async Task<int> CountApiCartAsync(CancellationToken ct = default)
 131    {
 0132        var response = await _favoritParts.GetCartAsync(ct);
 0133        return response is JObject obj && obj["cart"] is JArray cart ? cart.Count : 0;
 0134    }
 135
 136    /// <summary>Заказы Studio2, стоящие за строками корзины: ключ строки → список (заказ, количество).
 137    /// Основной источник — локальная таблица привязок, comment остаётся запасным вариантом
 138    /// для строк, добавленных вне Studio2 или до перехода на агрегацию.</summary>
 139    public async Task<IReadOnlyDictionary<string, IReadOnlyList<FavoritPartsCartLink>>> ResolveStudioOrdersAsync(
 140        IReadOnlyList<FavoritPartsApiCartLine> lines, CancellationToken ct = default)
 141    {
 0142        var result = new Dictionary<string, IReadOnlyList<FavoritPartsCartLink>>();
 0143        if (lines.Count == 0) return result;
 144
 0145        var local = await LoadActiveLocalItemsAsync(ct);
 0146        var byKey = local.GroupBy(x => BuildCartKey(x.GoodsId, x.WarehouseGroupId))
 0147            .ToDictionary(g => g.Key, g => g.ToList());
 148
 149        // Строки, добавленные до появления Id в комментарии ("Studio2 {номер отправления}"),
 150        // ищем по номеру отправления — иначе их нечем оформить.
 0151        var pendingLabels = lines
 0152            .Where(x => !byKey.ContainsKey(x.Key) && x.StudioOrderId is null)
 0153            .Select(x => StudioOrderCommentLink.ExtractOrderLabel(x.Comment))
 0154            .Where(x => !string.IsNullOrWhiteSpace(x))
 0155            .Select(x => x!).Distinct().ToList();
 0156        var byShipmentNumber = pendingLabels.Count == 0
 0157            ? new Dictionary<string, int>()
 0158            : await _db.Orders.AsNoTracking()
 0159                .Where(o => o.ShipmentNumber != null && pendingLabels.Contains(o.ShipmentNumber))
 0160                .GroupBy(o => o.ShipmentNumber!)
 0161                .Select(g => new { ShipmentNumber = g.Key, OrderId = g.Min(o => o.Id) })
 0162                .ToDictionaryAsync(x => x.ShipmentNumber, x => x.OrderId, ct);
 163
 0164        foreach (var line in lines)
 165        {
 0166            if (byKey.TryGetValue(line.Key, out var rows) && rows.Count > 0)
 167            {
 0168                result[line.Key] = rows
 0169                    .Select(x => new FavoritPartsCartLink(x.ActiveStudioOrderId!.Value, x.Quantity, x.Id))
 0170                    .OrderBy(x => x.StudioOrderId).ToList();
 0171                continue;
 172            }
 0173            if (line.StudioOrderId is int fromComment)
 174            {
 0175                result[line.Key] = new[] { new FavoritPartsCartLink(fromComment, line.Count, null) };
 0176                continue;
 177            }
 178
 0179            var label = StudioOrderCommentLink.ExtractOrderLabel(line.Comment);
 0180            if (label is not null && byShipmentNumber.TryGetValue(label, out var byNumber))
 0181                result[line.Key] = new[] { new FavoritPartsCartLink(byNumber, line.Count, null) };
 182        }
 0183        return result;
 0184    }
 185
 186    /// <summary>Добавляет позиции в реальную корзину FavoritParts (без оформления заказа) —
 187    /// вызывается после успешного проведения транзакции. Заказ создаётся отдельно на /SupplierCart.</summary>
 188    public async Task<FavoritPartsCartOperationResult> QueueAsync(IReadOnlyCollection<FavoritPartsQueueItem> queue,
 189        string changedBy, CancellationToken ct = default)
 190    {
 0191        if (queue.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, Array.Empty<string>());
 192
 0193        var messages = new List<string>();
 0194        int added = 0, skipped = 0, failed = 0;
 195
 0196        var (groups, invalid) = GroupQueue(queue);
 0197        foreach (var item in invalid)
 198        {
 0199            failed++;
 0200            messages.Add($"{OrderLabel(item)}: не найден товар/склад FavoritParts.");
 201        }
 202
 0203        var remote = await TryLoadRemoteCountsAsync(ct);
 0204        foreach (var group in groups)
 205        {
 0206            var fresh = new List<FavoritPartsQueueItem>();
 0207            foreach (var item in group.Items)
 208            {
 0209                if (await WasSuccessfullyPlacedAsync(item.OrderId, ct))
 210                {
 0211                    skipped++;
 0212                    messages.Add($"{OrderLabel(item)}: уже добавлен в корзину FavoritParts ранее.");
 0213                    continue;
 214                }
 0215                fresh.Add(item);
 0216            }
 0217            if (fresh.Count == 0) continue;
 218
 0219            var (count, comment) = await BuildCartAddArgumentsAsync(group.GoodsId, group.WarehouseGroupId,
 0220                fresh, remote, ct);
 221
 0222            var offerError = await GetOfferValidationErrorAsync(fresh[0].Article, fresh[0].Brand,
 0223                group.GoodsId, group.WarehouseGroupId, count, ct);
 0224            if (offerError is not null)
 225            {
 0226                foreach (var item in fresh)
 227                {
 0228                    failed++;
 0229                    messages.Add($"{OrderLabel(item)}: {offerError}");
 0230                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false, offerError, changedBy);
 231                }
 0232                continue;
 233            }
 234
 0235            var error = await AddToCartAsync(group.GoodsId, group.WarehouseGroupId, count, comment, ct);
 0236            if (error is not null)
 237            {
 0238                _logger.LogWarning(error,
 0239                    "FavoritParts: cart/add отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} qty={Quantity} s
 0240                    group.GoodsId, group.WarehouseGroupId, count, error.StatusCode, string.Join(",", fresh.Select(x => x
 0241                foreach (var item in fresh)
 242                {
 0243                    failed++;
 0244                    messages.Add($"{OrderLabel(item)}: {error.Message}");
 0245                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false,
 0246                        $"{error.Message} [goods={group.GoodsId:D} wh={group.WarehouseGroupId:D} qty={count} status={err
 247                }
 0248                continue;
 249            }
 250
 0251            foreach (var item in fresh)
 252            {
 0253                await UpsertLocalCartItemAsync(item, group.GoodsId, group.WarehouseGroupId, changedBy, ct, remote);
 0254                added++;
 0255                await TrackCartAsync(item.OrderId, SupplierCartStatusCode.Pending, changedBy, ct,
 0256                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 0257                await TryRecordHistoryAsync(item, OrderLabel(item), success: true, errorMessage: null, changedBy);
 0258            }
 0259            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 0260        }
 261
 0262        return new FavoritPartsCartOperationResult(added, skipped, failed, messages);
 0263    }
 264
 265    /// <summary>Добавляет выбранные позиции в корзину и сразу оформляет заказ(ы) — для «Оформить с проведением».</summa
 266    public async Task<FavoritPartsDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<FavoritPartsQueueItem> q
 267        string changedBy, CancellationToken ct = default)
 268    {
 0269        if (queue.Count == 0)
 0270            return new FavoritPartsDirectCheckoutResult(false, ["Не выбраны позиции FavoritParts для оформления."]);
 271
 0272        var messages = new List<string>();
 0273        var lines = new List<CheckoutLine>();
 0274        var (groups, invalid) = GroupQueue(queue);
 0275        foreach (var item in invalid)
 0276            messages.Add($"{OrderLabel(item)}: не найден товар/склад FavoritParts.");
 277
 0278        var remote = await TryLoadRemoteCountsAsync(ct);
 0279        foreach (var group in groups)
 280        {
 0281            var (count, comment) = await BuildCartAddArgumentsAsync(group.GoodsId, group.WarehouseGroupId,
 0282                group.Items, remote, ct);
 283
 0284            var offerError = await GetOfferValidationErrorAsync(group.Items[0].Article, group.Items[0].Brand,
 0285                group.GoodsId, group.WarehouseGroupId, count, ct);
 0286            if (offerError is not null)
 287            {
 0288                foreach (var item in group.Items)
 289                {
 0290                    messages.Add($"{OrderLabel(item)}: {offerError}");
 0291                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false, offerError, changedBy);
 292                }
 0293                continue;
 294            }
 295
 0296            var error = await AddToCartAsync(group.GoodsId, group.WarehouseGroupId, count, comment, ct);
 0297            if (error is not null)
 298            {
 0299                _logger.LogWarning(error,
 0300                    "FavoritParts: cart/add отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} qty={Quantity} s
 0301                    group.GoodsId, group.WarehouseGroupId, count, error.StatusCode, string.Join(",", group.Items.Select(
 0302                foreach (var item in group.Items)
 303                {
 0304                    messages.Add($"{OrderLabel(item)}: {error.Message}");
 0305                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false,
 0306                        $"{error.Message} [goods={group.GoodsId:D} wh={group.WarehouseGroupId:D} qty={count} status={err
 307                }
 0308                continue;
 309            }
 310
 0311            foreach (var item in group.Items)
 312            {
 0313                var cartItem = await UpsertLocalCartItemAsync(item, group.GoodsId, group.WarehouseGroupId, changedBy, ct
 0314                lines.Add(new CheckoutLine(item.OrderId, OrderLabel(item), group.GoodsId, group.WarehouseGroupId,
 0315                    item.Article, item.Brand, item.Quantity, item.CurrencyCode ?? "RUB", cartItem.Id));
 0316            }
 0317        }
 318
 0319        if (lines.Count == 0)
 0320            return new FavoritPartsDirectCheckoutResult(false, messages.Count > 0 ? messages : ["Не удалось добавить поз
 321
 0322        var orderResult = await CreateOrdersFromLinesAsync(lines, changedBy, ct);
 0323        messages.AddRange(orderResult.Messages);
 0324        return new FavoritPartsDirectCheckoutResult(orderResult.OrderedCount > 0, messages);
 0325    }
 326
 327    /// <summary>Свежая сверка выбранных строк реальной корзины (цена/остаток) без оформления заказа.
 328    /// Ничего не пишет в БД — Checkout сам перепроверяет корзину перед созданием заказа.</summary>
 329    public async Task<FavoritPartsCartOperationResult> TestAsync(IReadOnlyCollection<string>? selectedKeys,
 330        CancellationToken ct = default)
 331    {
 332        IReadOnlyList<FavoritPartsApiCartLine> lines;
 0333        try { lines = await GetApiCartAsync(ct); }
 0334        catch (FavoritPartsApiException ex)
 335        {
 0336            return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag
 337        }
 338
 0339        var targeted = selectedKeys is null ? lines : lines.Where(l => selectedKeys.Contains(l.Key)).ToList();
 0340        if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Корзина пуста."]);
 341
 0342        var messages = targeted.Select(l =>
 0343        {
 0344            var label = string.IsNullOrWhiteSpace(l.Number) ? l.Goods.ToString() : l.Number;
 0345            var shortage = l.Stock < l.Count ? $" — в корзине {l.Count:0.##}, доступно меньше!" : string.Empty;
 0346            return $"{label}: остаток {l.Stock:0.##}, цена {l.Price:0.##} RUB.{shortage}";
 0347        }).ToList();
 0348        return new FavoritPartsCartOperationResult(targeted.Count, 0, 0, messages);
 0349    }
 350
 351    /// <summary>Оформляет заказ(ы) по выбранным строкам реальной корзины (или по всем, если ничего не выбрано).
 352    /// Строки без привязки к заказу Studio2 пропускаются — оформлять их нечего.</summary>
 353    public async Task<FavoritPartsCartOperationResult> CheckoutAsync(string changedBy,
 354        IReadOnlyCollection<string>? selectedKeys, CancellationToken ct = default)
 355    {
 356        IReadOnlyList<FavoritPartsApiCartLine> lines;
 0357        try { lines = await GetApiCartAsync(ct); }
 0358        catch (FavoritPartsApiException ex)
 359        {
 0360            return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag
 361        }
 362
 0363        var targeted = selectedKeys is null ? lines : lines.Where(l => selectedKeys.Contains(l.Key)).ToList();
 0364        if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Корзина пуста."]);
 365
 0366        var messages = new List<string>();
 0367        var links = await ResolveStudioOrdersAsync(targeted, ct);
 0368        var withOrder = new List<(FavoritPartsApiCartLine Line, FavoritPartsCartLink Link)>();
 0369        foreach (var line in targeted)
 370        {
 0371            if (!links.TryGetValue(line.Key, out var lineLinks) || lineLinks.Count == 0)
 372            {
 0373                messages.Add($"{(string.IsNullOrWhiteSpace(line.Number) ? line.Goods.ToString() : line.Number)}: нет при
 0374                continue;
 375            }
 0376            withOrder.AddRange(lineLinks.Select(link => (line, link)));
 377        }
 0378        if (withOrder.Count == 0) return new FavoritPartsCartOperationResult(0, 0, targeted.Count, messages);
 379
 0380        var orderIds = withOrder.Select(x => x.Link.StudioOrderId).Distinct().ToList();
 0381        var orders = await _db.Orders.AsNoTracking()
 0382            .Where(o => orderIds.Contains(o.Id)).ToDictionaryAsync(o => o.Id, ct);
 383
 0384        var checkoutLines = withOrder.Select(x =>
 0385        {
 0386            orders.TryGetValue(x.Link.StudioOrderId, out var order);
 0387            var orderLabel = !string.IsNullOrWhiteSpace(order?.ShipmentNumber) ? order!.ShipmentNumber! : $"ID {x.Link.S
 0388            return new CheckoutLine(x.Link.StudioOrderId, orderLabel, x.Line.Goods, x.Line.WarehouseGroup,
 0389                x.Line.Number ?? string.Empty, x.Line.Brand ?? string.Empty, x.Link.Quantity, "RUB", x.Link.CartItemId);
 0390        }).ToList();
 391
 0392        var result = await CreateOrdersFromLinesAsync(checkoutLines, changedBy, ct);
 0393        messages.AddRange(result.Messages);
 0394        return new FavoritPartsCartOperationResult(result.OrderedCount, 0, checkoutLines.Count - result.OrderedCount, me
 0395    }
 396
 397    /// <summary>Удаляет выбранные позиции (или все) из реальной корзины FavoritParts построчно —
 398    /// у API нет операции «очистить всё», как и у АВД.</summary>
 399    public async Task<FavoritPartsCartOperationResult> RemoveManyAsync(IReadOnlyCollection<string>? selectedKeys,
 400        bool removeAll, CancellationToken ct = default)
 401    {
 402        IReadOnlyList<FavoritPartsApiCartLine> lines;
 0403        try { lines = await GetApiCartAsync(ct); }
 0404        catch (FavoritPartsApiException ex)
 405        {
 0406            return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag
 407        }
 408
 0409        var targeted = removeAll ? lines : lines.Where(l => selectedKeys?.Contains(l.Key) == true).ToList();
 0410        if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Нечего удалять."]);
 411
 0412        var links = await ResolveStudioOrdersAsync(targeted, ct);
 0413        var removed = 0; var failed = 0; var messages = new List<string>();
 0414        foreach (var line in targeted)
 415        {
 0416            var label = string.IsNullOrWhiteSpace(line.Number) ? line.Goods.ToString() : line.Number;
 417            try
 418            {
 0419                await _favoritParts.CartAddAsync(line.Goods, line.WarehouseGroup, 0, null, ct);
 0420            }
 0421            catch (FavoritPartsApiException ex)
 422            {
 0423                _logger.LogWarning(ex,
 0424                    "FavoritParts: cart/add count=0 отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} status={
 0425                    line.Goods, line.WarehouseGroup, ex.StatusCode);
 426
 427                // Сервис мог оборвать ответ уже после применения изменения — сверяемся с корзиной,
 428                // прежде чем показывать пользователю ошибку.
 0429                if (await IsMissingFromCartAsync(line, ct))
 430                {
 0431                    messages.Add($"{label}: удалена (поставщик вернул ошибку «{ex.Message}», но позиции в корзине уже не
 432                }
 433                else
 434                {
 0435                    failed++;
 0436                    messages.Add($"{label}: {ex.Message}");
 0437                    continue;
 438                }
 0439            }
 0440            removed++;
 441            // Строка корзины могла обслуживать несколько заказов Studio2 — закрываем состояние у всех.
 0442            var lineLinks = links.TryGetValue(line.Key, out var l) ? l : Array.Empty<FavoritPartsCartLink>();
 0443            foreach (var link in lineLinks)
 444            {
 0445                await CloseLocalCartItemAsync(link.CartItemId, FavoritPartsCartItemStatus.Removed, ct);
 0446                await TrackRemovedAsync(link.StudioOrderId, null, ct);
 0447            }
 0448        }
 0449        if (removed > 0) _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 450
 0451        return new FavoritPartsCartOperationResult(removed, 0, failed, messages);
 0452    }
 453
 454    /// <summary>Перечитывает корзину и проверяет, что строки в ней действительно нет.
 455    /// Если проверка сама не удалась — считаем строку оставшейся (ошибку не проглатываем).</summary>
 456    private async Task<bool> IsMissingFromCartAsync(FavoritPartsApiCartLine line, CancellationToken ct)
 457    {
 458        try
 459        {
 0460            var actual = await GetApiCartAsync(ct);
 0461            return actual.All(x => x.Goods != line.Goods || x.WarehouseGroup != line.WarehouseGroup);
 462        }
 0463        catch (FavoritPartsApiException)
 464        {
 0465            return false;
 466        }
 0467    }
 468
 469    /// <summary>Заказ Studio2 за строкой корзины FavoritParts: количество и (если есть) строка локальной привязки.</sum
 0470    public sealed record FavoritPartsCartLink(int StudioOrderId, decimal Quantity, long? CartItemId);
 471
 0472    internal sealed record CheckoutLine(int StudioOrderId, string OrderLabel, Guid GoodsId, Guid WarehouseGroupId,
 0473        string Article, string Brand, decimal Quantity, string CurrencyCode, long? CartItemId = null);
 474
 0475    private sealed record OrderCreationSummary(int OrderedCount, IReadOnlyList<string> Messages);
 476
 477    /// <summary>Позиции очереди, схлопнутые по (товар, склад хранения): cart/add не суммирует количество,
 478    /// а устанавливает последнее переданное, поэтому один и тот же товар от разных заказов Studio2
 479    /// обязан уйти в корзину одной строкой на суммарное количество.</summary>
 8480    public sealed record FavoritPartsQueueGroup(Guid GoodsId, Guid WarehouseGroupId, IReadOnlyList<FavoritPartsQueueItem
 481    {
 3482        public decimal Quantity => Items.Sum(x => x.Quantity);
 483    }
 484
 485    public static (IReadOnlyList<FavoritPartsQueueGroup> Groups, IReadOnlyList<FavoritPartsQueueItem> Invalid) GroupQueu
 486        IEnumerable<FavoritPartsQueueItem> queue)
 487    {
 3488        var groups = new List<FavoritPartsQueueGroup>();
 3489        var invalid = new List<FavoritPartsQueueItem>();
 3490        var index = new Dictionary<(Guid, Guid), List<FavoritPartsQueueItem>>();
 491
 18492        foreach (var item in queue)
 493        {
 6494            if (!Guid.TryParse(item.GoodsId, out var goodsId) || goodsId == Guid.Empty ||
 6495                !Guid.TryParse(item.WarehouseGroupId, out var warehouseGroupId) || warehouseGroupId == Guid.Empty)
 496            {
 1497                invalid.Add(item);
 1498                continue;
 499            }
 500
 5501            var key = (goodsId, warehouseGroupId);
 5502            if (!index.TryGetValue(key, out var items))
 503            {
 4504                items = new List<FavoritPartsQueueItem>();
 4505                index[key] = items;
 4506                groups.Add(new FavoritPartsQueueGroup(goodsId, warehouseGroupId, items));
 507            }
 5508            items.Add(item);
 509        }
 3510        return (groups, invalid);
 511    }
 512
 513    /// <summary>Количество и комментарий для cart/add. Количество абсолютное: к тому, что уже лежит в строке
 514    /// корзины, прибавляем новые заказы и вычитаем их же прежние (пере)размещения, иначе чужие позиции
 515    /// в этой строке (другие заказы Studio2, ручное добавление на сайте поставщика) будут затёрты.</summary>
 516    private async Task<(decimal Count, string Comment)> BuildCartAddArgumentsAsync(Guid goodsId, Guid warehouseGroupId,
 517        IReadOnlyList<FavoritPartsQueueItem> items, IReadOnlyDictionary<string, FavoritPartsApiCartLine> remote,
 518        CancellationToken ct)
 519    {
 0520        var key = BuildCartKey(goodsId, warehouseGroupId);
 0521        var remoteLine = remote.GetValueOrDefault(key);
 0522        var orderIds = items.Select(x => x.OrderId).ToHashSet();
 523
 0524        var replaced = await _db.FavoritPartsCartItems
 0525            .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null
 0526                        && orderIds.Contains(x.ActiveStudioOrderId.Value)
 0527                        && x.GoodsId == goodsId && x.WarehouseGroupId == warehouseGroupId
 0528                        && FavoritPartsCartItemStatus.Active.Contains(x.Status))
 0529            .SumAsync(x => (decimal?)x.Quantity, ct) ?? 0m;
 530
 0531        var count = Math.Max(0m, (remoteLine?.Count ?? 0m) - replaced) + items.Sum(x => x.Quantity);
 532        // Комментарий существующей строки не трогаем: он привязывает её к заказу, размещённому первым.
 0533        var comment = !string.IsNullOrWhiteSpace(remoteLine?.Comment)
 0534            ? remoteLine!.Comment!
 0535            : StudioOrderCommentLink.Build(OrderLabel(items[0]), items[0].OrderId, CommentMaxLength);
 0536        return (count, comment);
 0537    }
 538
 539    private async Task<IReadOnlyDictionary<string, FavoritPartsApiCartLine>> TryLoadRemoteCountsAsync(CancellationToken 
 540    {
 541        try
 542        {
 0543            var lines = await GetApiCartAsync(ct);
 0544            return lines.GroupBy(x => x.Key).ToDictionary(g => g.Key, g => g.First());
 545        }
 0546        catch (FavoritPartsApiException ex)
 547        {
 548            // Без снимка корзины считаем её пустой: хуже чужого количества только не размещённый заказ.
 0549            _logger.LogWarning(ex, "FavoritParts: не удалось прочитать корзину перед добавлением позиций");
 0550            return new Dictionary<string, FavoritPartsApiCartLine>();
 551        }
 0552    }
 553
 554    /// <summary>Сверяет строки с актуальным ответом GET cart/ (цена/остаток/склад отгрузки могли измениться),
 555    /// группирует по (WarehouseShipping, ShippingDate) — обязательное требование API — и создаёт заказ на
 556    /// каждую группу отдельным POST order/. Внутри группы позиции схлопываются по (товар, склад хранения):
 557    /// строка корзины одна, и двух строк GoodsList на неё быть не должно.</summary>
 558    private async Task<OrderCreationSummary> CreateOrdersFromLinesAsync(IReadOnlyList<CheckoutLine> lines,
 559        string changedBy, CancellationToken ct)
 560    {
 0561        var messages = new List<string>();
 562        Dictionary<(Guid Goods, Guid WarehouseGroup), FavoritPartsApiCartLine> snapshot;
 0563        try { snapshot = (await GetApiCartAsync(ct)).GroupBy(l => (l.Goods, l.WarehouseGroup)).ToDictionary(g => g.Key, 
 0564        catch (FavoritPartsApiException ex)
 565        {
 0566            messages.Add($"Не удалось прочитать корзину FavoritParts: {ex.Message}");
 0567            return new OrderCreationSummary(0, messages);
 568        }
 569
 0570        var matched = new List<(CheckoutLine Line, FavoritPartsApiCartLine Snapshot)>();
 0571        foreach (var line in lines)
 572        {
 0573            if (!snapshot.TryGetValue((line.GoodsId, line.WarehouseGroupId), out var snap))
 574            {
 0575                messages.Add($"{line.OrderLabel}: позиция не найдена в актуальной корзине FavoritParts (возможно, уже оф
 0576                continue;
 577            }
 578            // ShippingDate обязателен и проверяется сервисом на «не раньше расчётной»: подставлять сегодня
 579            // вместо неизвестной даты нельзя — заказ будет отклонён с 400 либо уедет не на ту дату.
 0580            if (snap.ShippingDate is null)
 581            {
 0582                messages.Add($"{line.OrderLabel}: FavoritParts не вернул дату отгрузки по позиции — заказ не создан, обн
 0583                continue;
 584            }
 0585            matched.Add((line, snap));
 586        }
 0587        if (matched.Count == 0) return new OrderCreationSummary(0, messages);
 588
 589        (Guid TradePointId, int PaymentType, int DeliveryType, int TransportType) defaults;
 0590        try { defaults = await GetOrderDefaultsAsync(ct); }
 0591        catch (FavoritPartsApiException ex)
 592        {
 0593            messages.Add($"Не удалось получить профиль FavoritParts (торговая точка/способ оплаты): {ex.Message}");
 0594            return new OrderCreationSummary(0, messages);
 595        }
 596
 0597        var orderedCount = 0;
 0598        foreach (var group in matched.GroupBy(x => (x.Snapshot.WarehouseShipping,
 0599                     ShippingDate: x.Snapshot.ShippingDate!.Value)))
 600        {
 601            // Остаток мог упасть после добавления в корзину (документация предупреждает об этом явно):
 602            // заказ с количеством больше доступного поставщик снимет уже после создания.
 0603            var goodsGroups = new List<(FavoritPartsApiCartLine Snapshot, List<CheckoutLine> Lines)>();
 0604            foreach (var byGoods in group.GroupBy(x => (x.Line.GoodsId, x.Line.WarehouseGroupId)))
 605            {
 0606                var snap = byGoods.First().Snapshot;
 0607                var required = byGoods.Sum(x => x.Line.Quantity);
 0608                var available = Math.Min(snap.Stock, snap.Count);
 0609                if (required > available)
 610                {
 0611                    var label = string.IsNullOrWhiteSpace(snap.Number) ? snap.Goods.ToString() : snap.Number;
 0612                    foreach (var (line, _) in byGoods)
 0613                        messages.Add($"{line.OrderLabel}: {label} — нужно {required:0.##} шт., в корзине доступно {avail
 614                    continue;
 615                }
 0616                goodsGroups.Add((snap, byGoods.Select(x => x.Line).ToList()));
 617            }
 0618            if (goodsGroups.Count == 0) continue;
 619
 0620            var request = new FavoritPartsOrderRequest
 0621            {
 0622                WarehouseShipping = group.Key.WarehouseShipping,
 0623                ShippingDate = group.Key.ShippingDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
 0624                TradePoint = defaults.TradePointId,
 0625                PaymentType = defaults.PaymentType,
 0626                DeliveryType = defaults.DeliveryType,
 0627                TransportType = defaults.TransportType,
 0628                Comment = "Studio2",
 0629                GoodsList = goodsGroups.Select(x => new FavoritPartsOrderGoodsLine
 0630                {
 0631                    Goods = x.Snapshot.Goods,
 0632                    WarehouseGroup = x.Snapshot.WarehouseGroup,
 0633                    Count = x.Lines.Sum(line => line.Quantity),
 0634                    Comment = StudioOrderCommentLink.Build(x.Lines[0].OrderLabel, x.Lines[0].StudioOrderId, CommentMaxLe
 0635                }).ToList()
 0636            };
 637
 638            Guid externalOrderId;
 0639            try { externalOrderId = await _favoritParts.CreateOrderAsync(request, ct); }
 0640            catch (FavoritPartsApiException ex)
 641            {
 0642                foreach (var line in goodsGroups.SelectMany(x => x.Lines)) messages.Add($"{line.OrderLabel}: {ex.Message
 0643                continue;
 644            }
 645
 0646            var order = new FavoritPartsOrder
 0647            {
 0648                ProviderKey = ProviderKey,
 0649                ExternalOrderId = externalOrderId,
 0650                WarehouseShippingId = group.Key.WarehouseShipping,
 0651                ShippingDate = group.Key.ShippingDate,
 0652                TradePointId = defaults.TradePointId,
 0653                Status = "Заказан",
 0654                CreatedBy = changedBy,
 0655                RequestJson = JsonSerializer.Serialize(request)
 0656            };
 0657            _db.FavoritPartsOrders.Add(order);
 658
 0659            var externalOrderLabel = FavoritPartsGuid.Format(externalOrderId);
 0660            string? postError = null;
 661            try
 662            {
 0663                var orderDetails = await _favoritParts.GetOrderAsync(externalOrderId, ct);
 0664                if (orderDetails is JObject obj)
 665                {
 0666                    postError = obj.Value<string>("postError");
 0667                    var orderNumber = obj.Value<string>("number");
 0668                    var status = (obj["status"] as JObject)?.Value<string>("name");
 0669                    if (!string.IsNullOrWhiteSpace(orderNumber))
 670                    {
 0671                        order.Number = orderNumber.Length > 40 ? orderNumber[..40] : orderNumber;
 0672                        externalOrderLabel = order.Number;
 673                    }
 0674                    if (!string.IsNullOrWhiteSpace(status))
 0675                        order.Status = status.Length > 30 ? status[..30] : status;
 676
 0677                    order.ResponseJson = JsonSerializer.Serialize(new
 0678                    {
 0679                        number = order.Number,
 0680                        status = order.Status,
 0681                        postError,
 0682                        fetchedAtUtc = DateTime.UtcNow
 0683                    }, JsonOptions);
 684                }
 0685            }
 0686            catch (Exception ex)
 687            {
 0688                _logger.LogDebug(ex, "FavoritParts: не удалось уточнить статус созданного заказа {ExternalOrderId}", ext
 0689            }
 690
 0691            var hasPostError = !string.IsNullOrWhiteSpace(postError);
 0692            if (hasPostError)
 693            {
 0694                _logger.LogInformation(
 0695                    "FavoritParts: заказ {ExternalOrderId} создан на сайте поставщика, но не проведён: {PostError}",
 0696                    externalOrderId, postError);
 697
 698                try
 699                {
 0700                    await NotificationService.NotifyAsync(
 0701                        "FavoritParts",
 0702                        "#D97706",
 0703                        $"Заказ {externalOrderLabel} не проведён у поставщика: {postError}",
 0704                        "fa-triangle-exclamation");
 0705                }
 0706                catch
 707                {
 708                    // Игнорируем отсутствие активного NotificationService (например, в фоновом runner без веб-контекста
 0709                }
 710            }
 711
 0712            foreach (var (snap, groupLines) in goodsGroups)
 713            {
 0714                foreach (var line in groupLines)
 715                {
 0716                    order.Items.Add(new FavoritPartsOrderItem
 0717                    {
 0718                        StudioOrderId = line.StudioOrderId,
 0719                        FavoritPartsCartItemId = line.CartItemId,
 0720                        GoodsId = line.GoodsId,
 0721                        WarehouseGroupId = line.WarehouseGroupId,
 0722                        Brand = line.Brand,
 0723                        Quantity = line.Quantity,
 0724                        Price = snap.Price,
 0725                        CurrencyCode = line.CurrencyCode
 0726                    });
 0727                    await CloseLocalCartItemAsync(line.CartItemId, FavoritPartsCartItemStatus.Ordered, ct);
 0728                    if (hasPostError)
 729                    {
 0730                        messages.Add($"{line.OrderLabel}: заказ FavoritParts №{externalOrderLabel} создан на сайте, но Н
 731                    }
 732                    else
 733                    {
 0734                        messages.Add($"{line.OrderLabel}: заказ FavoritParts №{externalOrderLabel} создан.");
 735                    }
 0736                    orderedCount++;
 0737                }
 0738            }
 739
 740            // Сохраняем сразу: заказ у поставщика уже создан, и падение на следующей группе не должно
 741            // откатить связь с ним — иначе заказ существует, а в Studio2 о нём нет записи.
 0742            await _db.SaveChangesAsync(ct);
 0743            foreach (var line in goodsGroups.SelectMany(x => x.Lines))
 0744                await TrackOrderedAsync(line.StudioOrderId, externalOrderLabel, changedBy, ct);
 0745        }
 746
 0747        if (orderedCount > 0) _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 0748        return new OrderCreationSummary(orderedCount, messages);
 0749    }
 750
 751    /// <summary>Разворачивает ответ GET cart/ (cart[] + goods{}/warehouseGroup{}/warehouseShipping{} справочники
 752    /// по GUID-ключу) в плоский список строк. Ключи справочников приходят в том же регистре, что и cart[].goods,
 753    /// но JSON-объект в Newtonsoft ищет свойство точным совпадением — сверяем без учёта регистра на всякий случай.</sum
 754    private static List<FavoritPartsApiCartLine> ParseCartLines(JToken response)
 755    {
 0756        var result = new List<FavoritPartsApiCartLine>();
 0757        if (response is not JObject obj || obj["cart"] is not JArray cart) return result;
 0758        var goodsDict = obj["goods"] as JObject;
 0759        var warehouseGroupDict = obj["warehouseGroup"] as JObject;
 0760        var warehouseShippingDict = obj["warehouseShipping"] as JObject;
 761
 0762        foreach (var row in cart.OfType<JObject>())
 763        {
 0764            var goodsRaw = row.Value<string>("goods");
 0765            var warehouseGroupRaw = row.Value<string>("warehouseGroup");
 0766            if (!Guid.TryParse(goodsRaw, out var goods) || !Guid.TryParse(warehouseGroupRaw, out var warehouseGroup))
 767                continue;
 0768            Guid.TryParse(row.Value<string>("warehouseShipping"), out var warehouseShipping);
 769
 0770            var goodsInfo = FindByGuid(goodsDict, goodsRaw);
 0771            var warehouseGroupInfo = FindByGuid(warehouseGroupDict, warehouseGroupRaw);
 0772            var warehouseShippingInfo = FindByGuid(warehouseShippingDict, row.Value<string>("warehouseShipping"));
 773
 0774            result.Add(new FavoritPartsApiCartLine(
 0775                goods, warehouseGroup, warehouseShipping,
 0776                row.Value<decimal?>("count") ?? 0m,
 0777                row.Value<decimal?>("price") ?? 0m,
 0778                row.Value<decimal?>("stock") ?? 0m,
 0779                row.Value<bool?>("notRefund") ?? false,
 0780                FavoritPartsPriceMapper.ReadOffset(row, "dateShipment"),
 0781                row.Value<string>("comment"),
 0782                goodsInfo?.Value<string>("Name"),
 0783                goodsInfo?.Value<string>("Brand"),
 0784                goodsInfo?.Value<string>("Number"),
 0785                goodsInfo?.Value<int?>("Rate") ?? 1,
 0786                warehouseGroupInfo?.Value<string>("code"),
 0787                warehouseShippingInfo?.Value<string>("name")));
 788        }
 0789        return result;
 790    }
 791
 792    private static JObject? FindByGuid(JObject? dict, string? rawId)
 793    {
 0794        if (dict is null || string.IsNullOrEmpty(rawId)) return null;
 0795        if (dict[rawId] is JObject exact) return exact;
 0796        var prop = dict.Properties().FirstOrDefault(p => string.Equals(p.Name, rawId, StringComparison.OrdinalIgnoreCase
 0797        return prop?.Value as JObject;
 798    }
 799
 800    /// <summary>Проверяет актуальность выбранного товара и кратность упаковки из свежей проценки.
 801    /// Никогда не подменяет склад: если предложение ещё есть, в корзину идёт именно выбранная строка.</summary>
 802    private async Task<string?> GetOfferValidationErrorAsync(string article, string brand, Guid goodsId,
 803        Guid warehouseGroupId, decimal quantity, CancellationToken ct)
 804    {
 805        JToken response;
 0806        try { response = await _favoritParts.SearchGoodsAsync(article, brand, analogues: false, ct); }
 0807        catch (FavoritPartsApiException) { return null; } // сбой самой проверки не должен блокировать добавление
 808
 0809        var offer = FavoritPartsPriceMapper.Map(response, article, null, null).FirstOrDefault(o =>
 0810            string.Equals(o.ProviderData?.GetValueOrDefault("goodsId"), goodsId.ToString(), StringComparison.OrdinalIgno
 0811            string.Equals(o.ProviderData?.GetValueOrDefault("warehouseGroupId"), warehouseGroupId.ToString(), StringComp
 0812        if (offer is null)
 0813            return "предложение FavoritParts больше не актуально (истёк срок цены/остатка). Обновите проценку и повторит
 814
 0815        var rate = offer.MinQuantity;
 0816        return rate > 1 && quantity % rate != 0
 0817            ? $"FavoritParts принимает эту позицию только кратно {rate} шт.; в строке корзины получается {quantity:0.##}
 0818            : null;
 0819    }
 820
 821    /// <summary>Отправляет один прямой запрос cart/add/ для выбранного товара и склада.
 822    /// Повторные запросы намеренно не выполняются: результат поставщика возвращается пользователю
 823    /// от единственной попытки.</summary>
 824    private async Task<FavoritPartsApiException?> AddToCartAsync(Guid goodsId, Guid warehouseGroupId,
 825        decimal quantity, string comment, CancellationToken ct)
 826    {
 827        try
 828        {
 0829            await _favoritParts.CartAddAsync(goodsId, warehouseGroupId, quantity, comment, ct);
 0830            return null;
 831        }
 0832        catch (FavoritPartsApiException ex) { return ex; }
 0833    }
 834
 835    private Task<List<FavoritPartsCartItem>> LoadActiveLocalItemsAsync(CancellationToken ct) =>
 0836        _db.FavoritPartsCartItems.AsNoTracking()
 0837            .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null
 0838                        && FavoritPartsCartItemStatus.Active.Contains(x.Status))
 0839            .ToListAsync(ct);
 840
 841    /// <summary>Привязка строки корзины к заказу Studio2: одна активная строка на заказ
 842    /// (защищено уникальным индексом ProviderKey + ActiveStudioOrderId).</summary>
 843    private async Task<FavoritPartsCartItem> UpsertLocalCartItemAsync(FavoritPartsQueueItem item, Guid goodsId,
 844        Guid warehouseGroupId, string changedBy, CancellationToken ct,
 845        IReadOnlyDictionary<string, FavoritPartsApiCartLine>? remote = null)
 846    {
 0847        var cartItem = await _db.FavoritPartsCartItems
 0848            .FirstOrDefaultAsync(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId == item.OrderId, ct);
 0849        if (cartItem is not null && remote is not null &&
 0850            (cartItem.GoodsId != goodsId || cartItem.WarehouseGroupId != warehouseGroupId))
 851        {
 0852            await ReleasePositionAsync(cartItem, remote, ct);
 853        }
 0854        if (cartItem is null)
 855        {
 0856            cartItem = new FavoritPartsCartItem
 0857            {
 0858                ProviderKey = ProviderKey,
 0859                StudioOrderId = item.OrderId,
 0860                ActiveStudioOrderId = item.OrderId,
 0861                CreatedBy = changedBy
 0862            };
 0863            _db.FavoritPartsCartItems.Add(cartItem);
 864        }
 865
 0866        cartItem.GoodsId = goodsId;
 0867        cartItem.WarehouseGroupId = warehouseGroupId;
 0868        cartItem.Article = Truncate(item.Article, 40) ?? string.Empty;
 0869        cartItem.Brand = Truncate(item.Brand, 80) ?? string.Empty;
 0870        cartItem.ProductName = Truncate(item.ProductName, 160);
 0871        cartItem.Quantity = item.Quantity;
 0872        cartItem.SourcePrice = item.SourcePrice;
 0873        cartItem.CurrencyCode = item.CurrencyCode ?? "RUB";
 0874        cartItem.Status = FavoritPartsCartItemStatus.Pending;
 0875        cartItem.LastError = null;
 0876        cartItem.UpdatedAtUtc = DateTime.UtcNow;
 0877        await _db.SaveChangesAsync(ct);
 0878        return cartItem;
 0879    }
 880
 881    /// <summary>Заказ перевыставили на другой товар/склад: уменьшаем количество прежней строки корзины
 882    /// на его долю, иначе она осталась бы висеть и ушла бы в заказ повторно.</summary>
 883    private async Task ReleasePositionAsync(FavoritPartsCartItem previous,
 884        IReadOnlyDictionary<string, FavoritPartsApiCartLine> remote, CancellationToken ct)
 885    {
 0886        if (!remote.TryGetValue(BuildCartKey(previous.GoodsId, previous.WarehouseGroupId), out var line)) return;
 0887        var rest = Math.Max(0m, line.Count - previous.Quantity);
 888        try
 889        {
 0890            await _favoritParts.CartAddAsync(previous.GoodsId, previous.WarehouseGroupId, rest, line.Comment, ct);
 0891        }
 0892        catch (FavoritPartsApiException ex)
 893        {
 0894            _logger.LogWarning(ex,
 0895                "FavoritParts: не удалось освободить прежнюю позицию корзины заказа {OrderId} (goods={GoodsId} warehouse
 0896                previous.ActiveStudioOrderId, previous.GoodsId, previous.WarehouseGroupId);
 0897        }
 0898    }
 899
 900    private async Task CloseLocalCartItemAsync(long? cartItemId, string status, CancellationToken ct)
 901    {
 0902        if (cartItemId is null) return;
 0903        var cartItem = await _db.FavoritPartsCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId.Value, ct);
 0904        if (cartItem is null) return;
 0905        cartItem.Status = status;
 0906        cartItem.ActiveStudioOrderId = null;
 0907        cartItem.UpdatedAtUtc = DateTime.UtcNow;
 0908    }
 909
 910    private async Task<(Guid TradePointId, int PaymentType, int DeliveryType, int TransportType)> GetOrderDefaultsAsync(
 911    {
 0912        if (_cache.TryGetValue(ProfileCacheKey, out (Guid, int, int, int) cached)) return cached;
 913
 0914        var profile = await _favoritParts.GetProfileAsync(ct);
 0915        var tradePointDef = profile.Value<string>("tradePointDef");
 0916        if (!Guid.TryParse(tradePointDef, out var tradePointId))
 0917            throw new FavoritPartsApiException("В профиле FavoritParts не задана торговая точка по умолчанию (tradePoint
 918
 919        // Способы оплаты 1 (наличная), 2 (безналичная) и 3 (карта) сервис принимает — проверено на боевом
 920        // API, хотя в списке ошибок документации упомянуты только 1 и 2. Не заданный (0) считаем безналичным.
 0921        var paymentType = profile.Value<int?>("paymentType") is int payment and (1 or 2 or 3) ? payment : 2;
 0922        var deliveryType = profile.Value<int?>("deliveryType") is > 0 ? profile.Value<int>("deliveryType") : 1;
 0923        var transportType = deliveryType == 2 ? 0 : (profile.Value<int?>("transportType") is > 0 ? profile.Value<int>("t
 924
 0925        var value = (tradePointId, paymentType, deliveryType, transportType);
 0926        _cache.Set(ProfileCacheKey, value, TimeSpan.FromMinutes(10));
 0927        return value;
 0928    }
 929
 930    private static string OrderLabel(FavoritPartsQueueItem item) =>
 0931        !string.IsNullOrWhiteSpace(item.ShipmentNumber) ? item.ShipmentNumber! : $"ID {item.OrderId}";
 932
 933    private static string? Truncate(string? value, int maxLength) =>
 0934        value is null || value.Length <= maxLength ? value : value[..maxLength];
 935
 936    private async Task TrackCartAsync(int orderId, string cartStatusCode, string? user, CancellationToken ct,
 937        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null)
 938    {
 939        try
 940        {
 0941            await _supplierState.MarkInCartAsync(orderId, ProviderKey, cartStatusCode, user: user,
 0942                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 0943        }
 0944        catch (Exception ex)
 945        {
 0946            _logger.LogWarning(ex, "FavoritParts: не удалось записать состояние корзины для заказа {OrderId}", orderId);
 0947        }
 0948    }
 949
 950    private async Task TrackOrderedAsync(int orderId, string externalOrderId, string? user, CancellationToken ct)
 951    {
 952        try
 953        {
 0954            await _supplierState.MarkOrderedAsync(orderId, ProviderKey, externalOrderId,
 0955                externalOrderId: externalOrderId, user: user, ct: ct);
 0956        }
 0957        catch (Exception ex)
 958        {
 0959            _logger.LogWarning(ex, "FavoritParts: не удалось привязать заказ {OrderId} к заказу поставщика", orderId);
 0960        }
 0961    }
 962
 963    private async Task TrackRemovedAsync(int orderId, string? user, CancellationToken ct)
 964    {
 965        try
 966        {
 0967            await _supplierState.RemoveFromCartAsync(orderId, ProviderKey, user, ct);
 0968        }
 0969        catch (Exception ex)
 970        {
 0971            _logger.LogWarning(ex, "FavoritParts: не удалось закрыть состояние корзины для заказа {OrderId}", orderId);
 0972        }
 0973    }
 974
 975    private async Task<bool> WasSuccessfullyPlacedAsync(int orderId, CancellationToken ct)
 976    {
 0977        var history = await _history.GetByOrderIdAsync(orderId);
 0978        return history.Any(entry =>
 0979        {
 0980            if (!string.Equals(entry.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName, StringComparison.Ordin
 0981                || string.IsNullOrWhiteSpace(entry.NewValue))
 0982                return false;
 0983
 0984            var placement = ApiSupplierBasketHistoryEntry.TryParse(entry.NewValue);
 0985            return placement is { Success: true } && string.Equals(placement.ProviderKey, ProviderKey, StringComparison.
 0986        });
 0987    }
 988
 989    private async Task TryRecordHistoryAsync(FavoritPartsQueueItem item, string orderLabel, bool success,
 990        string? errorMessage, string changedBy)
 991    {
 992        try
 993        {
 0994            var entry = new ApiSupplierBasketHistoryEntry(
 0995                ProviderKey, _supplierDisplayName, LogoUrl, success, errorMessage,
 0996                ApiItemId: item.GoodsId, item.Article, item.ProductName, item.SourcePrice,
 0997                item.CurrencyCode ?? "RUB", item.Quantity, orderLabel,
 0998                ClientName: item.ClientName,
 0999                CartStatus: success ? ApiSupplierBasketCartStatus.Pending : ApiSupplierBasketCartStatus.Failed,
 01000                SupplierCartUrl: $"/SupplierCart?provider={_providerRoute}&supplierTab=basket");
 1001
 01002            await _history.AddOrderHistory(new OrderHistory
 01003            {
 01004                OrderId = item.OrderId,
 01005                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 01006                ColumnDisplayName = $"Корзина {_supplierDisplayName}",
 01007                OldValue = null,
 01008                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 01009                ChangedAt = DateTime.Now,
 01010                ChangedBy = changedBy
 01011            });
 01012        }
 01013        catch
 1014        {
 1015            // Не ломаем размещение в корзине из-за журнала истории.
 01016        }
 01017    }
 1018}

Methods/Properties

get_ProviderKey()
get_ProfileCacheKey()
.cctor()
.ctor(OzonRepositories.Context.ApplicationDbContext,Services.ApiServices.FavoritPartsApi.FavoritPartsDataManager,IOrderHistoryDataService,Microsoft.Extensions.Caching.Memory.IMemoryCache,Services.SupplierStatusServices.IOrderSupplierStateService,Microsoft.Extensions.Logging.ILogger`1<OzonOrdersWeb.TransactionServices.FavoritPartsCartService>,System.String,System.String,System.String)
BuildCartKey(System.Guid,System.Guid)
get_MoscowToday()
GetApiCartAsync()
GetApiOrdersAsync(System.Boolean,System.Boolean,System.Threading.CancellationToken)
GetSingleApiOrderAsync(System.Guid,System.Threading.CancellationToken)
CountApiCartAsync()
ResolveStudioOrdersAsync()
QueueAsync()
CheckoutDirectAsync()
TestAsync()
CheckoutAsync()
RemoveManyAsync()
IsMissingFromCartAsync()
get_StudioOrderId()
get_StudioOrderId()
get_Article()
get_OrderedCount()
get_GoodsId()
get_Quantity()
GroupQueue(System.Collections.Generic.IEnumerable`1<OzonOrdersWeb.TransactionServices.FavoritPartsQueueItem>)
BuildCartAddArgumentsAsync()
TryLoadRemoteCountsAsync()
CreateOrdersFromLinesAsync()
ParseCartLines(Newtonsoft.Json.Linq.JToken)
FindByGuid(Newtonsoft.Json.Linq.JObject,System.String)
GetOfferValidationErrorAsync()
AddToCartAsync()
LoadActiveLocalItemsAsync(System.Threading.CancellationToken)
UpsertLocalCartItemAsync()
ReleasePositionAsync()
CloseLocalCartItemAsync()
GetOrderDefaultsAsync()
OrderLabel(OzonOrdersWeb.TransactionServices.FavoritPartsQueueItem)
Truncate(System.String,System.Int32)
TrackCartAsync()
TrackOrderedAsync()
TrackRemovedAsync()
WasSuccessfullyPlacedAsync()
TryRecordHistoryAsync()