< Summary

Information
Class: OzonOrdersWeb.TransactionServices.FavoritPartsCartOperationResult
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/FavoritPartsCartService.cs
Line coverage
100%
Covered lines: 13
Uncovered lines: 0
Coverable lines: 13
Total lines: 1018
Line coverage: 100%
Branch coverage
50%
Covered branches: 5
Total branches: 10
Branch coverage: 50%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Added()100%11100%
ToHtmlBlock(...)50%1010100%

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
 1626public sealed record FavoritPartsCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 27{
 28    public string ToHtmlBlock(string providerRoute = "favoritparts", string supplierName = "FavoritParts")
 29    {
 230        if (Added == 0 && Skipped == 0 && Failed == 0) return string.Empty;
 231        var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>");
 232        var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty;
 233        var cartLink = $"<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?provider={WebUtility.UrlEncode(pr
 234        var verificationNotice = Added > 0
 235            ? $"<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'><
 236            : string.Empty;
 237        return "<div class='alert alert-info border-primary-subtle mb-0'>" +
 238               $"<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина {WebUtility
 239               $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b
 240               $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" +
 241               $"<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{
 76    protected string ProviderKey { get; }
 77    private readonly string _supplierDisplayName;
 78    private readonly string _providerRoute;
 79    private const string LogoUrl = "/images/favorit-parts-logo.png";
 80    private string ProfileCacheKey => $"{ProviderKey}:Profile";
 81    /// <summary>Ограничение поля comment в cart/add и в строке заказа (документация: строка &lt;100 символов).</summary
 82    public const int CommentMaxLength = 100;
 83
 84    private static readonly TimeZoneInfo MoscowZone = RussianTimeZoneHelper.Zone;
 85    private static readonly JsonSerializerOptions JsonOptions = new()
 86    {
 87        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
 88    };
 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
 97    public FavoritPartsCartService(ApplicationDbContext db, FavoritPartsDataManager favoritParts,
 98        IOrderHistoryDataService history, IMemoryCache cache, IOrderSupplierStateService supplierState,
 99        ILogger<FavoritPartsCartService> logger, string providerKey = ApiSupplierSettings.FavoritPartsProviderKey,
 100        string supplierDisplayName = "Фаворит Союз", string providerRoute = "favoritparts")
 101    {
 102        _db = db;
 103        _favoritParts = favoritParts;
 104        _history = history;
 105        _cache = cache;
 106        _supplierState = supplierState;
 107        _logger = logger;
 108        ProviderKey = providerKey;
 109        _supplierDisplayName = supplierDisplayName;
 110        _providerRoute = providerRoute;
 111    }
 112
 113    public static string BuildCartKey(Guid goods, Guid warehouseGroup) =>
 114        $"{goods:D}|{warehouseGroup:D}".ToLowerInvariant();
 115
 116    internal static DateOnly MoscowToday =>
 117        DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, MoscowZone).DateTime);
 118
 119    /// <summary>Читает реальную корзину FavoritParts и разворачивает её в строки, готовые к отображению.</summary>
 120    public async Task<IReadOnlyList<FavoritPartsApiCartLine>> GetApiCartAsync(CancellationToken ct = default) =>
 121        ParseCartLines(await _favoritParts.GetCartAsync(ct));
 122
 123    public Task<JToken> GetApiOrdersAsync(bool active, bool rowsOrder, CancellationToken ct = default) =>
 124        _favoritParts.GetOrdersAsync(active: active, rowsOrder: rowsOrder, ct: ct);
 125
 126    public Task<JToken> GetSingleApiOrderAsync(Guid externalOrderId, CancellationToken ct = default) =>
 127        _favoritParts.GetOrderAsync(externalOrderId, ct);
 128
 129    /// <summary>Количество строк в реальной корзине — для индикатора в боковом меню.</summary>
 130    public async Task<int> CountApiCartAsync(CancellationToken ct = default)
 131    {
 132        var response = await _favoritParts.GetCartAsync(ct);
 133        return response is JObject obj && obj["cart"] is JArray cart ? cart.Count : 0;
 134    }
 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    {
 142        var result = new Dictionary<string, IReadOnlyList<FavoritPartsCartLink>>();
 143        if (lines.Count == 0) return result;
 144
 145        var local = await LoadActiveLocalItemsAsync(ct);
 146        var byKey = local.GroupBy(x => BuildCartKey(x.GoodsId, x.WarehouseGroupId))
 147            .ToDictionary(g => g.Key, g => g.ToList());
 148
 149        // Строки, добавленные до появления Id в комментарии ("Studio2 {номер отправления}"),
 150        // ищем по номеру отправления — иначе их нечем оформить.
 151        var pendingLabels = lines
 152            .Where(x => !byKey.ContainsKey(x.Key) && x.StudioOrderId is null)
 153            .Select(x => StudioOrderCommentLink.ExtractOrderLabel(x.Comment))
 154            .Where(x => !string.IsNullOrWhiteSpace(x))
 155            .Select(x => x!).Distinct().ToList();
 156        var byShipmentNumber = pendingLabels.Count == 0
 157            ? new Dictionary<string, int>()
 158            : await _db.Orders.AsNoTracking()
 159                .Where(o => o.ShipmentNumber != null && pendingLabels.Contains(o.ShipmentNumber))
 160                .GroupBy(o => o.ShipmentNumber!)
 161                .Select(g => new { ShipmentNumber = g.Key, OrderId = g.Min(o => o.Id) })
 162                .ToDictionaryAsync(x => x.ShipmentNumber, x => x.OrderId, ct);
 163
 164        foreach (var line in lines)
 165        {
 166            if (byKey.TryGetValue(line.Key, out var rows) && rows.Count > 0)
 167            {
 168                result[line.Key] = rows
 169                    .Select(x => new FavoritPartsCartLink(x.ActiveStudioOrderId!.Value, x.Quantity, x.Id))
 170                    .OrderBy(x => x.StudioOrderId).ToList();
 171                continue;
 172            }
 173            if (line.StudioOrderId is int fromComment)
 174            {
 175                result[line.Key] = new[] { new FavoritPartsCartLink(fromComment, line.Count, null) };
 176                continue;
 177            }
 178
 179            var label = StudioOrderCommentLink.ExtractOrderLabel(line.Comment);
 180            if (label is not null && byShipmentNumber.TryGetValue(label, out var byNumber))
 181                result[line.Key] = new[] { new FavoritPartsCartLink(byNumber, line.Count, null) };
 182        }
 183        return result;
 184    }
 185
 186    /// <summary>Добавляет позиции в реальную корзину FavoritParts (без оформления заказа) —
 187    /// вызывается после успешного проведения транзакции. Заказ создаётся отдельно на /SupplierCart.</summary>
 188    public async Task<FavoritPartsCartOperationResult> QueueAsync(IReadOnlyCollection<FavoritPartsQueueItem> queue,
 189        string changedBy, CancellationToken ct = default)
 190    {
 191        if (queue.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, Array.Empty<string>());
 192
 193        var messages = new List<string>();
 194        int added = 0, skipped = 0, failed = 0;
 195
 196        var (groups, invalid) = GroupQueue(queue);
 197        foreach (var item in invalid)
 198        {
 199            failed++;
 200            messages.Add($"{OrderLabel(item)}: не найден товар/склад FavoritParts.");
 201        }
 202
 203        var remote = await TryLoadRemoteCountsAsync(ct);
 204        foreach (var group in groups)
 205        {
 206            var fresh = new List<FavoritPartsQueueItem>();
 207            foreach (var item in group.Items)
 208            {
 209                if (await WasSuccessfullyPlacedAsync(item.OrderId, ct))
 210                {
 211                    skipped++;
 212                    messages.Add($"{OrderLabel(item)}: уже добавлен в корзину FavoritParts ранее.");
 213                    continue;
 214                }
 215                fresh.Add(item);
 216            }
 217            if (fresh.Count == 0) continue;
 218
 219            var (count, comment) = await BuildCartAddArgumentsAsync(group.GoodsId, group.WarehouseGroupId,
 220                fresh, remote, ct);
 221
 222            var offerError = await GetOfferValidationErrorAsync(fresh[0].Article, fresh[0].Brand,
 223                group.GoodsId, group.WarehouseGroupId, count, ct);
 224            if (offerError is not null)
 225            {
 226                foreach (var item in fresh)
 227                {
 228                    failed++;
 229                    messages.Add($"{OrderLabel(item)}: {offerError}");
 230                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false, offerError, changedBy);
 231                }
 232                continue;
 233            }
 234
 235            var error = await AddToCartAsync(group.GoodsId, group.WarehouseGroupId, count, comment, ct);
 236            if (error is not null)
 237            {
 238                _logger.LogWarning(error,
 239                    "FavoritParts: cart/add отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} qty={Quantity} s
 240                    group.GoodsId, group.WarehouseGroupId, count, error.StatusCode, string.Join(",", fresh.Select(x => x
 241                foreach (var item in fresh)
 242                {
 243                    failed++;
 244                    messages.Add($"{OrderLabel(item)}: {error.Message}");
 245                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false,
 246                        $"{error.Message} [goods={group.GoodsId:D} wh={group.WarehouseGroupId:D} qty={count} status={err
 247                }
 248                continue;
 249            }
 250
 251            foreach (var item in fresh)
 252            {
 253                await UpsertLocalCartItemAsync(item, group.GoodsId, group.WarehouseGroupId, changedBy, ct, remote);
 254                added++;
 255                await TrackCartAsync(item.OrderId, SupplierCartStatusCode.Pending, changedBy, ct,
 256                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 257                await TryRecordHistoryAsync(item, OrderLabel(item), success: true, errorMessage: null, changedBy);
 258            }
 259            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 260        }
 261
 262        return new FavoritPartsCartOperationResult(added, skipped, failed, messages);
 263    }
 264
 265    /// <summary>Добавляет выбранные позиции в корзину и сразу оформляет заказ(ы) — для «Оформить с проведением».</summa
 266    public async Task<FavoritPartsDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<FavoritPartsQueueItem> q
 267        string changedBy, CancellationToken ct = default)
 268    {
 269        if (queue.Count == 0)
 270            return new FavoritPartsDirectCheckoutResult(false, ["Не выбраны позиции FavoritParts для оформления."]);
 271
 272        var messages = new List<string>();
 273        var lines = new List<CheckoutLine>();
 274        var (groups, invalid) = GroupQueue(queue);
 275        foreach (var item in invalid)
 276            messages.Add($"{OrderLabel(item)}: не найден товар/склад FavoritParts.");
 277
 278        var remote = await TryLoadRemoteCountsAsync(ct);
 279        foreach (var group in groups)
 280        {
 281            var (count, comment) = await BuildCartAddArgumentsAsync(group.GoodsId, group.WarehouseGroupId,
 282                group.Items, remote, ct);
 283
 284            var offerError = await GetOfferValidationErrorAsync(group.Items[0].Article, group.Items[0].Brand,
 285                group.GoodsId, group.WarehouseGroupId, count, ct);
 286            if (offerError is not null)
 287            {
 288                foreach (var item in group.Items)
 289                {
 290                    messages.Add($"{OrderLabel(item)}: {offerError}");
 291                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false, offerError, changedBy);
 292                }
 293                continue;
 294            }
 295
 296            var error = await AddToCartAsync(group.GoodsId, group.WarehouseGroupId, count, comment, ct);
 297            if (error is not null)
 298            {
 299                _logger.LogWarning(error,
 300                    "FavoritParts: cart/add отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} qty={Quantity} s
 301                    group.GoodsId, group.WarehouseGroupId, count, error.StatusCode, string.Join(",", group.Items.Select(
 302                foreach (var item in group.Items)
 303                {
 304                    messages.Add($"{OrderLabel(item)}: {error.Message}");
 305                    await TryRecordHistoryAsync(item, OrderLabel(item), success: false,
 306                        $"{error.Message} [goods={group.GoodsId:D} wh={group.WarehouseGroupId:D} qty={count} status={err
 307                }
 308                continue;
 309            }
 310
 311            foreach (var item in group.Items)
 312            {
 313                var cartItem = await UpsertLocalCartItemAsync(item, group.GoodsId, group.WarehouseGroupId, changedBy, ct
 314                lines.Add(new CheckoutLine(item.OrderId, OrderLabel(item), group.GoodsId, group.WarehouseGroupId,
 315                    item.Article, item.Brand, item.Quantity, item.CurrencyCode ?? "RUB", cartItem.Id));
 316            }
 317        }
 318
 319        if (lines.Count == 0)
 320            return new FavoritPartsDirectCheckoutResult(false, messages.Count > 0 ? messages : ["Не удалось добавить поз
 321
 322        var orderResult = await CreateOrdersFromLinesAsync(lines, changedBy, ct);
 323        messages.AddRange(orderResult.Messages);
 324        return new FavoritPartsDirectCheckoutResult(orderResult.OrderedCount > 0, messages);
 325    }
 326
 327    /// <summary>Свежая сверка выбранных строк реальной корзины (цена/остаток) без оформления заказа.
 328    /// Ничего не пишет в БД — Checkout сам перепроверяет корзину перед созданием заказа.</summary>
 329    public async Task<FavoritPartsCartOperationResult> TestAsync(IReadOnlyCollection<string>? selectedKeys,
 330        CancellationToken ct = default)
 331    {
 332        IReadOnlyList<FavoritPartsApiCartLine> lines;
 333        try { lines = await GetApiCartAsync(ct); }
 334        catch (FavoritPartsApiException ex)
 335        {
 336            return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag
 337        }
 338
 339        var targeted = selectedKeys is null ? lines : lines.Where(l => selectedKeys.Contains(l.Key)).ToList();
 340        if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Корзина пуста."]);
 341
 342        var messages = targeted.Select(l =>
 343        {
 344            var label = string.IsNullOrWhiteSpace(l.Number) ? l.Goods.ToString() : l.Number;
 345            var shortage = l.Stock < l.Count ? $" — в корзине {l.Count:0.##}, доступно меньше!" : string.Empty;
 346            return $"{label}: остаток {l.Stock:0.##}, цена {l.Price:0.##} RUB.{shortage}";
 347        }).ToList();
 348        return new FavoritPartsCartOperationResult(targeted.Count, 0, 0, messages);
 349    }
 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;
 357        try { lines = await GetApiCartAsync(ct); }
 358        catch (FavoritPartsApiException ex)
 359        {
 360            return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag
 361        }
 362
 363        var targeted = selectedKeys is null ? lines : lines.Where(l => selectedKeys.Contains(l.Key)).ToList();
 364        if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Корзина пуста."]);
 365
 366        var messages = new List<string>();
 367        var links = await ResolveStudioOrdersAsync(targeted, ct);
 368        var withOrder = new List<(FavoritPartsApiCartLine Line, FavoritPartsCartLink Link)>();
 369        foreach (var line in targeted)
 370        {
 371            if (!links.TryGetValue(line.Key, out var lineLinks) || lineLinks.Count == 0)
 372            {
 373                messages.Add($"{(string.IsNullOrWhiteSpace(line.Number) ? line.Goods.ToString() : line.Number)}: нет при
 374                continue;
 375            }
 376            withOrder.AddRange(lineLinks.Select(link => (line, link)));
 377        }
 378        if (withOrder.Count == 0) return new FavoritPartsCartOperationResult(0, 0, targeted.Count, messages);
 379
 380        var orderIds = withOrder.Select(x => x.Link.StudioOrderId).Distinct().ToList();
 381        var orders = await _db.Orders.AsNoTracking()
 382            .Where(o => orderIds.Contains(o.Id)).ToDictionaryAsync(o => o.Id, ct);
 383
 384        var checkoutLines = withOrder.Select(x =>
 385        {
 386            orders.TryGetValue(x.Link.StudioOrderId, out var order);
 387            var orderLabel = !string.IsNullOrWhiteSpace(order?.ShipmentNumber) ? order!.ShipmentNumber! : $"ID {x.Link.S
 388            return new CheckoutLine(x.Link.StudioOrderId, orderLabel, x.Line.Goods, x.Line.WarehouseGroup,
 389                x.Line.Number ?? string.Empty, x.Line.Brand ?? string.Empty, x.Link.Quantity, "RUB", x.Link.CartItemId);
 390        }).ToList();
 391
 392        var result = await CreateOrdersFromLinesAsync(checkoutLines, changedBy, ct);
 393        messages.AddRange(result.Messages);
 394        return new FavoritPartsCartOperationResult(result.OrderedCount, 0, checkoutLines.Count - result.OrderedCount, me
 395    }
 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;
 403        try { lines = await GetApiCartAsync(ct); }
 404        catch (FavoritPartsApiException ex)
 405        {
 406            return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag
 407        }
 408
 409        var targeted = removeAll ? lines : lines.Where(l => selectedKeys?.Contains(l.Key) == true).ToList();
 410        if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Нечего удалять."]);
 411
 412        var links = await ResolveStudioOrdersAsync(targeted, ct);
 413        var removed = 0; var failed = 0; var messages = new List<string>();
 414        foreach (var line in targeted)
 415        {
 416            var label = string.IsNullOrWhiteSpace(line.Number) ? line.Goods.ToString() : line.Number;
 417            try
 418            {
 419                await _favoritParts.CartAddAsync(line.Goods, line.WarehouseGroup, 0, null, ct);
 420            }
 421            catch (FavoritPartsApiException ex)
 422            {
 423                _logger.LogWarning(ex,
 424                    "FavoritParts: cart/add count=0 отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} status={
 425                    line.Goods, line.WarehouseGroup, ex.StatusCode);
 426
 427                // Сервис мог оборвать ответ уже после применения изменения — сверяемся с корзиной,
 428                // прежде чем показывать пользователю ошибку.
 429                if (await IsMissingFromCartAsync(line, ct))
 430                {
 431                    messages.Add($"{label}: удалена (поставщик вернул ошибку «{ex.Message}», но позиции в корзине уже не
 432                }
 433                else
 434                {
 435                    failed++;
 436                    messages.Add($"{label}: {ex.Message}");
 437                    continue;
 438                }
 439            }
 440            removed++;
 441            // Строка корзины могла обслуживать несколько заказов Studio2 — закрываем состояние у всех.
 442            var lineLinks = links.TryGetValue(line.Key, out var l) ? l : Array.Empty<FavoritPartsCartLink>();
 443            foreach (var link in lineLinks)
 444            {
 445                await CloseLocalCartItemAsync(link.CartItemId, FavoritPartsCartItemStatus.Removed, ct);
 446                await TrackRemovedAsync(link.StudioOrderId, null, ct);
 447            }
 448        }
 449        if (removed > 0) _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 450
 451        return new FavoritPartsCartOperationResult(removed, 0, failed, messages);
 452    }
 453
 454    /// <summary>Перечитывает корзину и проверяет, что строки в ней действительно нет.
 455    /// Если проверка сама не удалась — считаем строку оставшейся (ошибку не проглатываем).</summary>
 456    private async Task<bool> IsMissingFromCartAsync(FavoritPartsApiCartLine line, CancellationToken ct)
 457    {
 458        try
 459        {
 460            var actual = await GetApiCartAsync(ct);
 461            return actual.All(x => x.Goods != line.Goods || x.WarehouseGroup != line.WarehouseGroup);
 462        }
 463        catch (FavoritPartsApiException)
 464        {
 465            return false;
 466        }
 467    }
 468
 469    /// <summary>Заказ Studio2 за строкой корзины FavoritParts: количество и (если есть) строка локальной привязки.</sum
 470    public sealed record FavoritPartsCartLink(int StudioOrderId, decimal Quantity, long? CartItemId);
 471
 472    internal sealed record CheckoutLine(int StudioOrderId, string OrderLabel, Guid GoodsId, Guid WarehouseGroupId,
 473        string Article, string Brand, decimal Quantity, string CurrencyCode, long? CartItemId = null);
 474
 475    private sealed record OrderCreationSummary(int OrderedCount, IReadOnlyList<string> Messages);
 476
 477    /// <summary>Позиции очереди, схлопнутые по (товар, склад хранения): cart/add не суммирует количество,
 478    /// а устанавливает последнее переданное, поэтому один и тот же товар от разных заказов Studio2
 479    /// обязан уйти в корзину одной строкой на суммарное количество.</summary>
 480    public sealed record FavoritPartsQueueGroup(Guid GoodsId, Guid WarehouseGroupId, IReadOnlyList<FavoritPartsQueueItem
 481    {
 482        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    {
 488        var groups = new List<FavoritPartsQueueGroup>();
 489        var invalid = new List<FavoritPartsQueueItem>();
 490        var index = new Dictionary<(Guid, Guid), List<FavoritPartsQueueItem>>();
 491
 492        foreach (var item in queue)
 493        {
 494            if (!Guid.TryParse(item.GoodsId, out var goodsId) || goodsId == Guid.Empty ||
 495                !Guid.TryParse(item.WarehouseGroupId, out var warehouseGroupId) || warehouseGroupId == Guid.Empty)
 496            {
 497                invalid.Add(item);
 498                continue;
 499            }
 500
 501            var key = (goodsId, warehouseGroupId);
 502            if (!index.TryGetValue(key, out var items))
 503            {
 504                items = new List<FavoritPartsQueueItem>();
 505                index[key] = items;
 506                groups.Add(new FavoritPartsQueueGroup(goodsId, warehouseGroupId, items));
 507            }
 508            items.Add(item);
 509        }
 510        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    {
 520        var key = BuildCartKey(goodsId, warehouseGroupId);
 521        var remoteLine = remote.GetValueOrDefault(key);
 522        var orderIds = items.Select(x => x.OrderId).ToHashSet();
 523
 524        var replaced = await _db.FavoritPartsCartItems
 525            .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null
 526                        && orderIds.Contains(x.ActiveStudioOrderId.Value)
 527                        && x.GoodsId == goodsId && x.WarehouseGroupId == warehouseGroupId
 528                        && FavoritPartsCartItemStatus.Active.Contains(x.Status))
 529            .SumAsync(x => (decimal?)x.Quantity, ct) ?? 0m;
 530
 531        var count = Math.Max(0m, (remoteLine?.Count ?? 0m) - replaced) + items.Sum(x => x.Quantity);
 532        // Комментарий существующей строки не трогаем: он привязывает её к заказу, размещённому первым.
 533        var comment = !string.IsNullOrWhiteSpace(remoteLine?.Comment)
 534            ? remoteLine!.Comment!
 535            : StudioOrderCommentLink.Build(OrderLabel(items[0]), items[0].OrderId, CommentMaxLength);
 536        return (count, comment);
 537    }
 538
 539    private async Task<IReadOnlyDictionary<string, FavoritPartsApiCartLine>> TryLoadRemoteCountsAsync(CancellationToken 
 540    {
 541        try
 542        {
 543            var lines = await GetApiCartAsync(ct);
 544            return lines.GroupBy(x => x.Key).ToDictionary(g => g.Key, g => g.First());
 545        }
 546        catch (FavoritPartsApiException ex)
 547        {
 548            // Без снимка корзины считаем её пустой: хуже чужого количества только не размещённый заказ.
 549            _logger.LogWarning(ex, "FavoritParts: не удалось прочитать корзину перед добавлением позиций");
 550            return new Dictionary<string, FavoritPartsApiCartLine>();
 551        }
 552    }
 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    {
 561        var messages = new List<string>();
 562        Dictionary<(Guid Goods, Guid WarehouseGroup), FavoritPartsApiCartLine> snapshot;
 563        try { snapshot = (await GetApiCartAsync(ct)).GroupBy(l => (l.Goods, l.WarehouseGroup)).ToDictionary(g => g.Key, 
 564        catch (FavoritPartsApiException ex)
 565        {
 566            messages.Add($"Не удалось прочитать корзину FavoritParts: {ex.Message}");
 567            return new OrderCreationSummary(0, messages);
 568        }
 569
 570        var matched = new List<(CheckoutLine Line, FavoritPartsApiCartLine Snapshot)>();
 571        foreach (var line in lines)
 572        {
 573            if (!snapshot.TryGetValue((line.GoodsId, line.WarehouseGroupId), out var snap))
 574            {
 575                messages.Add($"{line.OrderLabel}: позиция не найдена в актуальной корзине FavoritParts (возможно, уже оф
 576                continue;
 577            }
 578            // ShippingDate обязателен и проверяется сервисом на «не раньше расчётной»: подставлять сегодня
 579            // вместо неизвестной даты нельзя — заказ будет отклонён с 400 либо уедет не на ту дату.
 580            if (snap.ShippingDate is null)
 581            {
 582                messages.Add($"{line.OrderLabel}: FavoritParts не вернул дату отгрузки по позиции — заказ не создан, обн
 583                continue;
 584            }
 585            matched.Add((line, snap));
 586        }
 587        if (matched.Count == 0) return new OrderCreationSummary(0, messages);
 588
 589        (Guid TradePointId, int PaymentType, int DeliveryType, int TransportType) defaults;
 590        try { defaults = await GetOrderDefaultsAsync(ct); }
 591        catch (FavoritPartsApiException ex)
 592        {
 593            messages.Add($"Не удалось получить профиль FavoritParts (торговая точка/способ оплаты): {ex.Message}");
 594            return new OrderCreationSummary(0, messages);
 595        }
 596
 597        var orderedCount = 0;
 598        foreach (var group in matched.GroupBy(x => (x.Snapshot.WarehouseShipping,
 599                     ShippingDate: x.Snapshot.ShippingDate!.Value)))
 600        {
 601            // Остаток мог упасть после добавления в корзину (документация предупреждает об этом явно):
 602            // заказ с количеством больше доступного поставщик снимет уже после создания.
 603            var goodsGroups = new List<(FavoritPartsApiCartLine Snapshot, List<CheckoutLine> Lines)>();
 604            foreach (var byGoods in group.GroupBy(x => (x.Line.GoodsId, x.Line.WarehouseGroupId)))
 605            {
 606                var snap = byGoods.First().Snapshot;
 607                var required = byGoods.Sum(x => x.Line.Quantity);
 608                var available = Math.Min(snap.Stock, snap.Count);
 609                if (required > available)
 610                {
 611                    var label = string.IsNullOrWhiteSpace(snap.Number) ? snap.Goods.ToString() : snap.Number;
 612                    foreach (var (line, _) in byGoods)
 613                        messages.Add($"{line.OrderLabel}: {label} — нужно {required:0.##} шт., в корзине доступно {avail
 614                    continue;
 615                }
 616                goodsGroups.Add((snap, byGoods.Select(x => x.Line).ToList()));
 617            }
 618            if (goodsGroups.Count == 0) continue;
 619
 620            var request = new FavoritPartsOrderRequest
 621            {
 622                WarehouseShipping = group.Key.WarehouseShipping,
 623                ShippingDate = group.Key.ShippingDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
 624                TradePoint = defaults.TradePointId,
 625                PaymentType = defaults.PaymentType,
 626                DeliveryType = defaults.DeliveryType,
 627                TransportType = defaults.TransportType,
 628                Comment = "Studio2",
 629                GoodsList = goodsGroups.Select(x => new FavoritPartsOrderGoodsLine
 630                {
 631                    Goods = x.Snapshot.Goods,
 632                    WarehouseGroup = x.Snapshot.WarehouseGroup,
 633                    Count = x.Lines.Sum(line => line.Quantity),
 634                    Comment = StudioOrderCommentLink.Build(x.Lines[0].OrderLabel, x.Lines[0].StudioOrderId, CommentMaxLe
 635                }).ToList()
 636            };
 637
 638            Guid externalOrderId;
 639            try { externalOrderId = await _favoritParts.CreateOrderAsync(request, ct); }
 640            catch (FavoritPartsApiException ex)
 641            {
 642                foreach (var line in goodsGroups.SelectMany(x => x.Lines)) messages.Add($"{line.OrderLabel}: {ex.Message
 643                continue;
 644            }
 645
 646            var order = new FavoritPartsOrder
 647            {
 648                ProviderKey = ProviderKey,
 649                ExternalOrderId = externalOrderId,
 650                WarehouseShippingId = group.Key.WarehouseShipping,
 651                ShippingDate = group.Key.ShippingDate,
 652                TradePointId = defaults.TradePointId,
 653                Status = "Заказан",
 654                CreatedBy = changedBy,
 655                RequestJson = JsonSerializer.Serialize(request)
 656            };
 657            _db.FavoritPartsOrders.Add(order);
 658
 659            var externalOrderLabel = FavoritPartsGuid.Format(externalOrderId);
 660            string? postError = null;
 661            try
 662            {
 663                var orderDetails = await _favoritParts.GetOrderAsync(externalOrderId, ct);
 664                if (orderDetails is JObject obj)
 665                {
 666                    postError = obj.Value<string>("postError");
 667                    var orderNumber = obj.Value<string>("number");
 668                    var status = (obj["status"] as JObject)?.Value<string>("name");
 669                    if (!string.IsNullOrWhiteSpace(orderNumber))
 670                    {
 671                        order.Number = orderNumber.Length > 40 ? orderNumber[..40] : orderNumber;
 672                        externalOrderLabel = order.Number;
 673                    }
 674                    if (!string.IsNullOrWhiteSpace(status))
 675                        order.Status = status.Length > 30 ? status[..30] : status;
 676
 677                    order.ResponseJson = JsonSerializer.Serialize(new
 678                    {
 679                        number = order.Number,
 680                        status = order.Status,
 681                        postError,
 682                        fetchedAtUtc = DateTime.UtcNow
 683                    }, JsonOptions);
 684                }
 685            }
 686            catch (Exception ex)
 687            {
 688                _logger.LogDebug(ex, "FavoritParts: не удалось уточнить статус созданного заказа {ExternalOrderId}", ext
 689            }
 690
 691            var hasPostError = !string.IsNullOrWhiteSpace(postError);
 692            if (hasPostError)
 693            {
 694                _logger.LogInformation(
 695                    "FavoritParts: заказ {ExternalOrderId} создан на сайте поставщика, но не проведён: {PostError}",
 696                    externalOrderId, postError);
 697
 698                try
 699                {
 700                    await NotificationService.NotifyAsync(
 701                        "FavoritParts",
 702                        "#D97706",
 703                        $"Заказ {externalOrderLabel} не проведён у поставщика: {postError}",
 704                        "fa-triangle-exclamation");
 705                }
 706                catch
 707                {
 708                    // Игнорируем отсутствие активного NotificationService (например, в фоновом runner без веб-контекста
 709                }
 710            }
 711
 712            foreach (var (snap, groupLines) in goodsGroups)
 713            {
 714                foreach (var line in groupLines)
 715                {
 716                    order.Items.Add(new FavoritPartsOrderItem
 717                    {
 718                        StudioOrderId = line.StudioOrderId,
 719                        FavoritPartsCartItemId = line.CartItemId,
 720                        GoodsId = line.GoodsId,
 721                        WarehouseGroupId = line.WarehouseGroupId,
 722                        Brand = line.Brand,
 723                        Quantity = line.Quantity,
 724                        Price = snap.Price,
 725                        CurrencyCode = line.CurrencyCode
 726                    });
 727                    await CloseLocalCartItemAsync(line.CartItemId, FavoritPartsCartItemStatus.Ordered, ct);
 728                    if (hasPostError)
 729                    {
 730                        messages.Add($"{line.OrderLabel}: заказ FavoritParts №{externalOrderLabel} создан на сайте, но Н
 731                    }
 732                    else
 733                    {
 734                        messages.Add($"{line.OrderLabel}: заказ FavoritParts №{externalOrderLabel} создан.");
 735                    }
 736                    orderedCount++;
 737                }
 738            }
 739
 740            // Сохраняем сразу: заказ у поставщика уже создан, и падение на следующей группе не должно
 741            // откатить связь с ним — иначе заказ существует, а в Studio2 о нём нет записи.
 742            await _db.SaveChangesAsync(ct);
 743            foreach (var line in goodsGroups.SelectMany(x => x.Lines))
 744                await TrackOrderedAsync(line.StudioOrderId, externalOrderLabel, changedBy, ct);
 745        }
 746
 747        if (orderedCount > 0) _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 748        return new OrderCreationSummary(orderedCount, messages);
 749    }
 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    {
 756        var result = new List<FavoritPartsApiCartLine>();
 757        if (response is not JObject obj || obj["cart"] is not JArray cart) return result;
 758        var goodsDict = obj["goods"] as JObject;
 759        var warehouseGroupDict = obj["warehouseGroup"] as JObject;
 760        var warehouseShippingDict = obj["warehouseShipping"] as JObject;
 761
 762        foreach (var row in cart.OfType<JObject>())
 763        {
 764            var goodsRaw = row.Value<string>("goods");
 765            var warehouseGroupRaw = row.Value<string>("warehouseGroup");
 766            if (!Guid.TryParse(goodsRaw, out var goods) || !Guid.TryParse(warehouseGroupRaw, out var warehouseGroup))
 767                continue;
 768            Guid.TryParse(row.Value<string>("warehouseShipping"), out var warehouseShipping);
 769
 770            var goodsInfo = FindByGuid(goodsDict, goodsRaw);
 771            var warehouseGroupInfo = FindByGuid(warehouseGroupDict, warehouseGroupRaw);
 772            var warehouseShippingInfo = FindByGuid(warehouseShippingDict, row.Value<string>("warehouseShipping"));
 773
 774            result.Add(new FavoritPartsApiCartLine(
 775                goods, warehouseGroup, warehouseShipping,
 776                row.Value<decimal?>("count") ?? 0m,
 777                row.Value<decimal?>("price") ?? 0m,
 778                row.Value<decimal?>("stock") ?? 0m,
 779                row.Value<bool?>("notRefund") ?? false,
 780                FavoritPartsPriceMapper.ReadOffset(row, "dateShipment"),
 781                row.Value<string>("comment"),
 782                goodsInfo?.Value<string>("Name"),
 783                goodsInfo?.Value<string>("Brand"),
 784                goodsInfo?.Value<string>("Number"),
 785                goodsInfo?.Value<int?>("Rate") ?? 1,
 786                warehouseGroupInfo?.Value<string>("code"),
 787                warehouseShippingInfo?.Value<string>("name")));
 788        }
 789        return result;
 790    }
 791
 792    private static JObject? FindByGuid(JObject? dict, string? rawId)
 793    {
 794        if (dict is null || string.IsNullOrEmpty(rawId)) return null;
 795        if (dict[rawId] is JObject exact) return exact;
 796        var prop = dict.Properties().FirstOrDefault(p => string.Equals(p.Name, rawId, StringComparison.OrdinalIgnoreCase
 797        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;
 806        try { response = await _favoritParts.SearchGoodsAsync(article, brand, analogues: false, ct); }
 807        catch (FavoritPartsApiException) { return null; } // сбой самой проверки не должен блокировать добавление
 808
 809        var offer = FavoritPartsPriceMapper.Map(response, article, null, null).FirstOrDefault(o =>
 810            string.Equals(o.ProviderData?.GetValueOrDefault("goodsId"), goodsId.ToString(), StringComparison.OrdinalIgno
 811            string.Equals(o.ProviderData?.GetValueOrDefault("warehouseGroupId"), warehouseGroupId.ToString(), StringComp
 812        if (offer is null)
 813            return "предложение FavoritParts больше не актуально (истёк срок цены/остатка). Обновите проценку и повторит
 814
 815        var rate = offer.MinQuantity;
 816        return rate > 1 && quantity % rate != 0
 817            ? $"FavoritParts принимает эту позицию только кратно {rate} шт.; в строке корзины получается {quantity:0.##}
 818            : null;
 819    }
 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        {
 829            await _favoritParts.CartAddAsync(goodsId, warehouseGroupId, quantity, comment, ct);
 830            return null;
 831        }
 832        catch (FavoritPartsApiException ex) { return ex; }
 833    }
 834
 835    private Task<List<FavoritPartsCartItem>> LoadActiveLocalItemsAsync(CancellationToken ct) =>
 836        _db.FavoritPartsCartItems.AsNoTracking()
 837            .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null
 838                        && FavoritPartsCartItemStatus.Active.Contains(x.Status))
 839            .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    {
 847        var cartItem = await _db.FavoritPartsCartItems
 848            .FirstOrDefaultAsync(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId == item.OrderId, ct);
 849        if (cartItem is not null && remote is not null &&
 850            (cartItem.GoodsId != goodsId || cartItem.WarehouseGroupId != warehouseGroupId))
 851        {
 852            await ReleasePositionAsync(cartItem, remote, ct);
 853        }
 854        if (cartItem is null)
 855        {
 856            cartItem = new FavoritPartsCartItem
 857            {
 858                ProviderKey = ProviderKey,
 859                StudioOrderId = item.OrderId,
 860                ActiveStudioOrderId = item.OrderId,
 861                CreatedBy = changedBy
 862            };
 863            _db.FavoritPartsCartItems.Add(cartItem);
 864        }
 865
 866        cartItem.GoodsId = goodsId;
 867        cartItem.WarehouseGroupId = warehouseGroupId;
 868        cartItem.Article = Truncate(item.Article, 40) ?? string.Empty;
 869        cartItem.Brand = Truncate(item.Brand, 80) ?? string.Empty;
 870        cartItem.ProductName = Truncate(item.ProductName, 160);
 871        cartItem.Quantity = item.Quantity;
 872        cartItem.SourcePrice = item.SourcePrice;
 873        cartItem.CurrencyCode = item.CurrencyCode ?? "RUB";
 874        cartItem.Status = FavoritPartsCartItemStatus.Pending;
 875        cartItem.LastError = null;
 876        cartItem.UpdatedAtUtc = DateTime.UtcNow;
 877        await _db.SaveChangesAsync(ct);
 878        return cartItem;
 879    }
 880
 881    /// <summary>Заказ перевыставили на другой товар/склад: уменьшаем количество прежней строки корзины
 882    /// на его долю, иначе она осталась бы висеть и ушла бы в заказ повторно.</summary>
 883    private async Task ReleasePositionAsync(FavoritPartsCartItem previous,
 884        IReadOnlyDictionary<string, FavoritPartsApiCartLine> remote, CancellationToken ct)
 885    {
 886        if (!remote.TryGetValue(BuildCartKey(previous.GoodsId, previous.WarehouseGroupId), out var line)) return;
 887        var rest = Math.Max(0m, line.Count - previous.Quantity);
 888        try
 889        {
 890            await _favoritParts.CartAddAsync(previous.GoodsId, previous.WarehouseGroupId, rest, line.Comment, ct);
 891        }
 892        catch (FavoritPartsApiException ex)
 893        {
 894            _logger.LogWarning(ex,
 895                "FavoritParts: не удалось освободить прежнюю позицию корзины заказа {OrderId} (goods={GoodsId} warehouse
 896                previous.ActiveStudioOrderId, previous.GoodsId, previous.WarehouseGroupId);
 897        }
 898    }
 899
 900    private async Task CloseLocalCartItemAsync(long? cartItemId, string status, CancellationToken ct)
 901    {
 902        if (cartItemId is null) return;
 903        var cartItem = await _db.FavoritPartsCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId.Value, ct);
 904        if (cartItem is null) return;
 905        cartItem.Status = status;
 906        cartItem.ActiveStudioOrderId = null;
 907        cartItem.UpdatedAtUtc = DateTime.UtcNow;
 908    }
 909
 910    private async Task<(Guid TradePointId, int PaymentType, int DeliveryType, int TransportType)> GetOrderDefaultsAsync(
 911    {
 912        if (_cache.TryGetValue(ProfileCacheKey, out (Guid, int, int, int) cached)) return cached;
 913
 914        var profile = await _favoritParts.GetProfileAsync(ct);
 915        var tradePointDef = profile.Value<string>("tradePointDef");
 916        if (!Guid.TryParse(tradePointDef, out var tradePointId))
 917            throw new FavoritPartsApiException("В профиле FavoritParts не задана торговая точка по умолчанию (tradePoint
 918
 919        // Способы оплаты 1 (наличная), 2 (безналичная) и 3 (карта) сервис принимает — проверено на боевом
 920        // API, хотя в списке ошибок документации упомянуты только 1 и 2. Не заданный (0) считаем безналичным.
 921        var paymentType = profile.Value<int?>("paymentType") is int payment and (1 or 2 or 3) ? payment : 2;
 922        var deliveryType = profile.Value<int?>("deliveryType") is > 0 ? profile.Value<int>("deliveryType") : 1;
 923        var transportType = deliveryType == 2 ? 0 : (profile.Value<int?>("transportType") is > 0 ? profile.Value<int>("t
 924
 925        var value = (tradePointId, paymentType, deliveryType, transportType);
 926        _cache.Set(ProfileCacheKey, value, TimeSpan.FromMinutes(10));
 927        return value;
 928    }
 929
 930    private static string OrderLabel(FavoritPartsQueueItem item) =>
 931        !string.IsNullOrWhiteSpace(item.ShipmentNumber) ? item.ShipmentNumber! : $"ID {item.OrderId}";
 932
 933    private static string? Truncate(string? value, int maxLength) =>
 934        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        {
 941            await _supplierState.MarkInCartAsync(orderId, ProviderKey, cartStatusCode, user: user,
 942                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 943        }
 944        catch (Exception ex)
 945        {
 946            _logger.LogWarning(ex, "FavoritParts: не удалось записать состояние корзины для заказа {OrderId}", orderId);
 947        }
 948    }
 949
 950    private async Task TrackOrderedAsync(int orderId, string externalOrderId, string? user, CancellationToken ct)
 951    {
 952        try
 953        {
 954            await _supplierState.MarkOrderedAsync(orderId, ProviderKey, externalOrderId,
 955                externalOrderId: externalOrderId, user: user, ct: ct);
 956        }
 957        catch (Exception ex)
 958        {
 959            _logger.LogWarning(ex, "FavoritParts: не удалось привязать заказ {OrderId} к заказу поставщика", orderId);
 960        }
 961    }
 962
 963    private async Task TrackRemovedAsync(int orderId, string? user, CancellationToken ct)
 964    {
 965        try
 966        {
 967            await _supplierState.RemoveFromCartAsync(orderId, ProviderKey, user, ct);
 968        }
 969        catch (Exception ex)
 970        {
 971            _logger.LogWarning(ex, "FavoritParts: не удалось закрыть состояние корзины для заказа {OrderId}", orderId);
 972        }
 973    }
 974
 975    private async Task<bool> WasSuccessfullyPlacedAsync(int orderId, CancellationToken ct)
 976    {
 977        var history = await _history.GetByOrderIdAsync(orderId);
 978        return history.Any(entry =>
 979        {
 980            if (!string.Equals(entry.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName, StringComparison.Ordin
 981                || string.IsNullOrWhiteSpace(entry.NewValue))
 982                return false;
 983
 984            var placement = ApiSupplierBasketHistoryEntry.TryParse(entry.NewValue);
 985            return placement is { Success: true } && string.Equals(placement.ProviderKey, ProviderKey, StringComparison.
 986        });
 987    }
 988
 989    private async Task TryRecordHistoryAsync(FavoritPartsQueueItem item, string orderLabel, bool success,
 990        string? errorMessage, string changedBy)
 991    {
 992        try
 993        {
 994            var entry = new ApiSupplierBasketHistoryEntry(
 995                ProviderKey, _supplierDisplayName, LogoUrl, success, errorMessage,
 996                ApiItemId: item.GoodsId, item.Article, item.ProductName, item.SourcePrice,
 997                item.CurrencyCode ?? "RUB", item.Quantity, orderLabel,
 998                ClientName: item.ClientName,
 999                CartStatus: success ? ApiSupplierBasketCartStatus.Pending : ApiSupplierBasketCartStatus.Failed,
 1000                SupplierCartUrl: $"/SupplierCart?provider={_providerRoute}&supplierTab=basket");
 1001
 1002            await _history.AddOrderHistory(new OrderHistory
 1003            {
 1004                OrderId = item.OrderId,
 1005                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 1006                ColumnDisplayName = $"Корзина {_supplierDisplayName}",
 1007                OldValue = null,
 1008                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 1009                ChangedAt = DateTime.Now,
 1010                ChangedBy = changedBy
 1011            });
 1012        }
 1013        catch
 1014        {
 1015            // Не ломаем размещение в корзине из-за журнала истории.
 1016        }
 1017    }
 1018}