< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_OrderId()100%11100%
get_ProductName()100%11100%
get_Quantity()100%11100%
get_ZasylWarehouseName()100%11100%
get_Direction()100%11100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Json;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.Logging;
 5using OzonDomains.Models;
 6using OzonDomains.Models.SupplierStatuses;
 7using OzonRepositories.Context;
 8using Services.ApiServices.ProfitLigaApi;
 9using Services.ApiSupplierSettingsServices;
 10using Services.DataServices;
 11using Services.SupplierStatusServices;
 12
 13namespace OzonOrdersWeb.TransactionServices;
 14
 15/// <summary>Позиция, выбранная в проценке и переданная на сервер при проведении транзакции.</summary>
 1216public sealed record ProfitLigaQueueItem(
 12517    int OrderId, string ArticleId, string WarehouseId, string? ProductCode, string Article, string Brand,
 3818    string? ProductName, string? WarehouseName, string? ShipmentNumber, string? ClientName,
 4319    decimal Quantity, int Multi, decimal? SourcePrice, string? CurrencyCode,
 2320    string? ZasylWarehouseName = null, int? DeliveryDaysMin = null, int? DeliveryDaysMax = null,
 1921    string? Direction = null);
 22
 23public sealed record ProfitLigaCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 24{
 25    public bool IsSuccess => Failed == 0;
 26
 27    public string ToHtmlBlock()
 28    {
 29        if (Added == 0 && Skipped == 0 && Failed == 0) return string.Empty;
 30        var css = Failed > 0 ? "alert-warning" : "alert-success";
 31        var summary = Failed == 0
 32            ? $"Добавлено в гибридную корзину Studio2 и Профит-Лиги: {Added}. Пропущено: {Skipped}."
 33            : $"Добавлено: {Added}, пропущено: {Skipped}, ошибок: {Failed}.";
 34        var details = Messages.Count == 0
 35            ? string.Empty
 36            : "<br/>" + string.Join("<br/>", Messages.Select(System.Net.WebUtility.HtmlEncode));
 37        return $"<div class='alert {css} mb-0'><strong>Корзина Профит-Лиги.</strong> " +
 38               $"{summary}{details}</div>";
 39    }
 40}
 41
 42public sealed record ProfitLigaDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages);
 43
 44/// <summary>
 45/// Гибридная корзина Профит-Лиги: каждая позиция одновременно хранится в ProfitLigaCartItems и
 46/// добавляется в корзину поставщика. Studio2 остаётся источником связи с заказом и точкой доставки.
 47/// Перед оформлением API-корзина временно собирается только из выбранной группы, потому что
 48/// POST /cart/order оформляет её целиком.
 49/// Аналога createTestOrder у API нет, поэтому проверка перед заказом — это повторная проценка
 50/// (/search/crosses) со сверкой цены и остатка.
 51/// </summary>
 52public sealed class ProfitLigaCartService
 53{
 54    public const string ProviderKey = "ProfitLiga";
 55    private const string SupplierDisplayName = "Профит-Лига";
 56    private const string LogoUrl = "/images/profit-liga-logo.svg";
 57    private const string CartUrl = "/SupplierCart?provider=profitliga&supplierTab=basket";
 58    private const string CurrencyCode = "RUB";
 59
 60    /// <summary>Окно действия проверки — как у Armtek, ML-Auto и FavoritParts.</summary>
 61    private static readonly TimeSpan ReadyWindow = TimeSpan.FromMinutes(5);
 62
 63    private readonly ApplicationDbContext _db;
 64    private readonly ProfitLigaDataManager _profitLiga;
 65    private readonly ProfitLigaPointMappingService _points;
 66    private readonly IOrderHistoryDataService _history;
 67    private readonly IOrderSupplierStateService _supplierState;
 68    private readonly IOrderSupplierStatusSyncService _supplierStatusSync;
 69    private readonly ILogger<ProfitLigaCartService> _logger;
 70
 71    public ProfitLigaCartService(
 72        ApplicationDbContext db,
 73        ProfitLigaDataManager profitLiga,
 74        ProfitLigaPointMappingService points,
 75        IOrderHistoryDataService history,
 76        IOrderSupplierStateService supplierState,
 77        IOrderSupplierStatusSyncService supplierStatusSync,
 78        ILogger<ProfitLigaCartService> logger)
 79    {
 80        _db = db;
 81        _profitLiga = profitLiga;
 82        _points = points;
 83        _history = history;
 84        _supplierState = supplierState;
 85        _supplierStatusSync = supplierStatusSync;
 86        _logger = logger;
 87    }
 88
 89    // ---------- чтение ----------
 90
 91    public Task<List<ProfitLigaCartItem>> GetActiveAsync(CancellationToken ct = default) =>
 92        _db.ProfitLigaCartItems.AsNoTracking().Include(x => x.StudioOrder)
 93            .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status))
 94            .OrderByDescending(x => x.CreatedAtUtc)
 95            .ToListAsync(ct);
 96
 97    public Task<int> CountActiveAsync(CancellationToken ct = default) =>
 98        _db.ProfitLigaCartItems.CountAsync(x => ProfitLigaCartItemStatus.Active.Contains(x.Status), ct);
 99
 100    public Task<List<ProfitLigaOrder>> GetOrdersAsync(CancellationToken ct = default) =>
 101        _db.ProfitLigaOrders.AsNoTracking().Include(x => x.Items)
 102            .OrderByDescending(x => x.CreatedAtUtc).Take(200).ToListAsync(ct);
 103
 104    /// <summary>Состояние зеркала API; используется для проверки целостности гибридной корзины.</summary>
 105    public Task<IReadOnlyList<ProfitLigaCartLine>> GetApiCartAsync(CancellationToken ct = default) =>
 106        _profitLiga.GetCartAsync(ct);
 107
 108    public Task<Newtonsoft.Json.Linq.JToken> GetApiOrdersAsync(int page = 0, CancellationToken ct = default) =>
 109        _profitLiga.GetOrdersAsync(page, ct: ct);
 110
 111    // ---------- постановка в корзину ----------
 112
 113    /// <summary>Кладёт позицию одновременно в локальную и API-корзину. Если API не принял строку,
 114    /// локальная запись не создаётся: две части гибридной корзины не расходятся.</summary>
 115    public async Task<ProfitLigaCartOperationResult> QueueAsync(IReadOnlyCollection<ProfitLigaQueueItem> queue,
 116        string changedBy, CancellationToken ct = default)
 117    {
 118        if (queue.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, []);
 119
 120        int added = 0, skipped = 0, failed = 0;
 121        var messages = new List<string>();
 122
 123        foreach (var item in queue)
 124        {
 125            var label = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber!;
 126
 127            if (string.IsNullOrWhiteSpace(item.ProductCode))
 128            {
 129                failed++;
 130                messages.Add($"{label}: у предложения нет кода номенклатуры — Профит-Лига не примет его в корзину.");
 131                continue;
 132            }
 133
 134            if (await _db.ProfitLigaCartItems.AnyAsync(
 135                    x => x.StudioOrderId == item.OrderId && ProfitLigaCartItemStatus.Active.Contains(x.Status), ct))
 136            {
 137                skipped++;
 138                messages.Add($"{label}: позиция Профит-Лиги уже в корзине.");
 139                continue;
 140            }
 141
 142            var point = await _points.ResolveAsync(item.OrderId, item.ZasylWarehouseName, ct);
 143            ProfitLigaCartOperation apiOperation;
 144            try
 145            {
 146                apiOperation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity,
 147                    item.ProductCode, $"Studio2 {label} (ID {item.OrderId})", ct);
 148            }
 149            catch (ProfitLigaApiException ex)
 150            {
 151                failed++;
 152                messages.Add($"{label}: не удалось добавить позицию в корзину Профит-Лиги. {ex.Message}");
 153                continue;
 154            }
 155            if (!apiOperation.IsSuccess)
 156            {
 157                failed++;
 158                messages.Add($"{label}: Профит-Лига не добавила позицию в корзину — {apiOperation.Describe()}.");
 159                continue;
 160            }
 161
 162            var cartItem = new ProfitLigaCartItem
 163            {
 164                StudioOrderId = item.OrderId,
 165                ActiveStudioOrderId = item.OrderId,
 166                ArticleId = item.ArticleId,
 167                WarehouseId = item.WarehouseId,
 168                ProductCode = item.ProductCode,
 169                Article = item.Article,
 170                Brand = item.Brand,
 171                ProductName = item.ProductName,
 172                WarehouseName = item.WarehouseName,
 173                PointCode = point?.PointCode,
 174                PointName = point?.PointName,
 175                Quantity = item.Quantity,
 176                Multi = item.Multi,
 177                SourcePrice = item.SourcePrice,
 178                CurrencyCode = string.IsNullOrWhiteSpace(item.CurrencyCode) ? CurrencyCode : item.CurrencyCode!,
 179                CreatedBy = changedBy
 180            };
 181
 182            _db.ProfitLigaCartItems.Add(cartItem);
 183            try
 184            {
 185                await _db.SaveChangesAsync(ct);
 186            }
 187            catch (DbUpdateException)
 188            {
 189                // Гонка по уникальному индексу ActiveStudioOrderId: позицию уже положил параллельный запрос.
 190                _db.Entry(cartItem).State = EntityState.Detached;
 191                await RollbackApiCartAsync([cartItem], ct);
 192                skipped++;
 193                messages.Add($"{label}: позиция Профит-Лиги уже в корзине.");
 194                continue;
 195            }
 196
 197            added++;
 198            await RecordHistoryAsync(cartItem, label, true, null, ApiSupplierBasketCartStatus.Pending, null, changedBy);
 199            await TrackAsync(() => _supplierState.MarkInCartAsync(item.OrderId, ProviderKey,
 200                SupplierCartStatusCode.Pending, cartItem.Id, user: changedBy,
 201                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax,
 202                direction: item.Direction, ct: ct));
 203        }
 204
 205        return new ProfitLigaCartOperationResult(added, skipped, failed, messages);
 206    }
 207
 208    // ---------- проверка ----------
 209
 210    /// <summary>Свежая перепроценка выбранных строк: у API нет dry-run, поэтому сверяем цену, остаток
 211    /// и кратность повторным /search/crosses по тому же складу поставщика.</summary>
 212    public async Task<ProfitLigaCartOperationResult> TestAsync(IReadOnlyCollection<long> cartItemIds,
 213        string changedBy, CancellationToken ct = default)
 214    {
 215        var items = await LoadForActionAsync(cartItemIds, ct);
 216        if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для проверки."]);
 217
 218        int ready = 0, failed = 0;
 219        var messages = new List<string>();
 220
 221        foreach (var item in items)
 222        {
 223            var label = $"Арт. {item.Article} · {item.Brand}";
 224            try
 225            {
 226                var offer = await FindOfferAsync(item, ct);
 227                if (offer is null)
 228                {
 229                    item.Status = ProfitLigaCartItemStatus.Failed;
 230                    item.LastError = $"{label}: предложение на складе «{item.WarehouseName ?? item.WarehouseId}» больше 
 231                    failed++;
 232                    messages.Add(item.LastError);
 233                    continue;
 234                }
 235
 236                if (offer.Quantity < item.Quantity)
 237                {
 238                    item.Status = ProfitLigaCartItemStatus.Failed;
 239                    item.LastError = $"{label}: на складе {offer.Quantity.ToString("0.##", CultureInfo.InvariantCulture)
 240                    failed++;
 241                    messages.Add(item.LastError);
 242                    continue;
 243                }
 244
 245                if (offer.Multi > 1 && item.Quantity % offer.Multi != 0)
 246                {
 247                    item.Status = ProfitLigaCartItemStatus.Failed;
 248                    item.LastError = $"{label}: количество должно быть кратно {offer.Multi}.";
 249                    failed++;
 250                    messages.Add(item.LastError);
 251                    continue;
 252                }
 253
 254                var priceChanged = item.SourcePrice != offer.Price;
 255                item.SourcePrice = offer.Price;
 256                item.Multi = offer.Multi;
 257                item.ProductCode = offer.ProductCode ?? item.ProductCode;
 258                item.DeliveryAtUtc = offer.DeliveryAtUtc;
 259                item.Status = ProfitLigaCartItemStatus.Ready;
 260                item.LastError = null;
 261                item.TestedAtUtc = DateTime.UtcNow;
 262                item.UpdatedAtUtc = DateTime.UtcNow;
 263                item.TestResultJson = JsonSerializer.Serialize(offer);
 264                ready++;
 265                messages.Add(priceChanged
 266                    ? $"{label}: подтверждено, цена обновлена до {offer.Price.ToString("0.##", CultureInfo.InvariantCult
 267                    : $"{label}: подтверждено.");
 268            }
 269            catch (ProfitLigaApiException ex)
 270            {
 271                item.Status = ProfitLigaCartItemStatus.Failed;
 272                item.LastError = $"{label}: {ex.Message}";
 273                failed++;
 274                messages.Add(item.LastError);
 275            }
 276        }
 277
 278        await _db.SaveChangesAsync(ct);
 279        foreach (var item in items)
 280        {
 281            await TrackAsync(() => _supplierState.MarkInCartAsync(item.StudioOrderId, ProviderKey,
 282                item.Status, item.Id, user: changedBy, ct: ct));
 283        }
 284        return new ProfitLigaCartOperationResult(ready, 0, failed, messages);
 285    }
 286
 287    // ---------- оформление ----------
 288
 289    /// <summary>Оформляет выбранные строки. Гибридное зеркало временно освобождается, чтобы
 290    /// /cart/order не захватил позиции другой точки доставки, затем оставшиеся строки возвращаются в API.</summary>
 291    public async Task<ProfitLigaCartOperationResult> CheckoutAsync(IReadOnlyCollection<long> cartItemIds,
 292        string? methodId, string? paymentId, string changedBy, CancellationToken ct = default)
 293    {
 294        var items = await LoadForActionAsync(cartItemIds, ct);
 295        if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для оформления."]);
 296
 297        var stale = items.Where(x => x.Status != ProfitLigaCartItemStatus.Ready
 298                                     || !x.TestedAtUtc.HasValue
 299                                     || x.TestedAtUtc.Value < DateTime.UtcNow - ReadyWindow).ToList();
 300        if (stale.Count > 0)
 301            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 302                ["Перед оформлением выполните свежую проверку Профит-Лиги (действует 5 минут)."]);
 303
 304        if (items.Any(x => string.IsNullOrWhiteSpace(x.PointCode)))
 305            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 306                ["Для части позиций не выбрана торговая точка. Задайте связь склада и точки в настройках API-поставщика.
 307
 308        var apiCart = await _profitLiga.GetCartAsync(ct);
 309        var activeItems = await _db.ProfitLigaCartItems.AsNoTracking()
 310            .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status))
 311            .ToListAsync(ct);
 312        if (apiCart.Any(line => !activeItems.Any(item => IsSameApiLine(item, line))))
 313            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 314                ["В API-корзине Профит-Лиги есть позиции не из Studio2. Удалите их у поставщика и повторите оформление."
 315
 316        var ids = items.Select(x => x.Id).ToList();
 317        var locked = await _db.ProfitLigaCartItems
 318            .Where(x => ids.Contains(x.Id) && x.Status == ProfitLigaCartItemStatus.Ready)
 319            .ExecuteUpdateAsync(s => s
 320                .SetProperty(x => x.Status, ProfitLigaCartItemStatus.Processing)
 321                .SetProperty(x => x.UpdatedAtUtc, DateTime.UtcNow), ct);
 322        if (locked != items.Count)
 323            return new ProfitLigaCartOperationResult(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]);
 324
 325        foreach (var item in items) item.Status = ProfitLigaCartItemStatus.Processing;
 326
 327        var clearResult = await RemoveApiLinesAsync(apiCart, ct);
 328        if (clearResult.Failed > 0)
 329        {
 330            await ReleaseAsync(items, "Не удалось подготовить корзину поставщика к оформлению.", ct);
 331            await RestoreApiMirrorAsync(ct);
 332            return clearResult;
 333        }
 334
 335        int ordered = 0, failed = 0;
 336        var messages = new List<string>();
 337        ProfitLigaOrderParams parameters;
 338        try
 339        {
 340            parameters = await _profitLiga.GetOrderParamsAsync(ct);
 341        }
 342        catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException or TaskCanceledException)
 343        {
 344            await ReleaseAsync(items, ex.Message, ct);
 345            await RestoreApiMirrorAsync(ct);
 346            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 347                [$"Профит-Лига: не удалось получить параметры оформления. {ex.Message}"]);
 348        }
 349
 350        // Точка доставки — состояние аккаунта, поэтому одна группа = один поход в /cart/point и /cart/order.
 351        foreach (var group in items.GroupBy(x => x.PointCode!, StringComparer.Ordinal))
 352        {
 353            var groupItems = group.ToList();
 354            try
 355            {
 356                var result = await CheckoutGroupAsync(group.Key, groupItems, parameters, methodId, paymentId, changedBy,
 357                ordered += result.Added;
 358                failed += result.Failed;
 359                messages.AddRange(result.Messages);
 360            }
 361            catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException or TaskCanceledException)
 362            {
 363                _logger.LogWarning(ex, "Профит-Лига: оформление точки {PointCode} не выполнено", group.Key);
 364                await ReleaseAsync(groupItems, ex.Message, ct);
 365                failed += groupItems.Count;
 366                messages.Add($"Профит-Лига: {ex.Message}");
 367            }
 368        }
 369
 370        await _db.SaveChangesAsync(ct);
 371        await RestoreApiMirrorAsync(ct);
 372        await SynchronizeOrderStatusesAsync(items, messages, ct);
 373        return new ProfitLigaCartOperationResult(ordered, 0, failed, messages);
 374    }
 375
 376    /// <summary>«Оформить с проведением»: кладёт позиции в локальную корзину и сразу оформляет их.</summary>
 377    public async Task<ProfitLigaDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ProfitLigaQueueItem> queue
 378        string changedBy, CancellationToken ct = default)
 379    {
 380        var queued = await QueueAsync(queue, changedBy, ct);
 381        if (queued.Added == 0)
 382            return new ProfitLigaDirectCheckoutResult(false, queued.Messages.Count > 0
 383                ? queued.Messages
 384                : ["Профит-Лига: не удалось поставить позиции в корзину."]);
 385
 386        var orderIds = queue.Select(x => x.OrderId).ToList();
 387        var ids = await _db.ProfitLigaCartItems
 388            .Where(x => orderIds.Contains(x.StudioOrderId) && ProfitLigaCartItemStatus.Active.Contains(x.Status))
 389            .Select(x => x.Id).ToListAsync(ct);
 390
 391        var tested = await TestAsync(ids, changedBy, ct);
 392        if (tested.Added == 0)
 393            return new ProfitLigaDirectCheckoutResult(false, [.. queued.Messages, .. tested.Messages]);
 394
 395        var checkedOut = await CheckoutAsync(ids, null, null, changedBy, ct);
 396        return new ProfitLigaDirectCheckoutResult(checkedOut.IsSuccess && checkedOut.Added > 0,
 397            [.. queued.Messages, .. tested.Messages, .. checkedOut.Messages]);
 398    }
 399
 400    private async Task<ProfitLigaCartOperationResult> CheckoutGroupAsync(string pointCode,
 401        List<ProfitLigaCartItem> items, ProfitLigaOrderParams parameters, string? methodId, string? paymentId,
 402        string changedBy, CancellationToken ct)
 403    {
 404        var point = parameters.AllPoints.FirstOrDefault(x => string.Equals(x.Code, pointCode, StringComparison.Ordinal))
 405        if (point is null)
 406        {
 407            await ReleaseAsync(items, "Торговая точка недоступна в текущих настройках заказа Профит-Лиги.", ct);
 408            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 409                ["Профит-Лига: сохранённая торговая точка больше не доступна. Обновите связь в настройках."]);
 410        }
 411
 412        var method = ResolveMethod(parameters, point, methodId);
 413        var payment = ResolvePayment(parameters, paymentId);
 414        if (method is null || payment is null)
 415        {
 416            await ReleaseAsync(items, "Профит-Лига не вернула способы доставки или оплаты.", ct);
 417            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 418                ["Профит-Лига: не удалось определить способ доставки или оплаты."]);
 419        }
 420
 421        await _profitLiga.SetPointAsync(pointCode, ct);
 422
 423        var placed = new List<ProfitLigaCartItem>();
 424        var messages = new List<string>();
 425        foreach (var item in items)
 426        {
 427            var comment = BuildComment(item);
 428            var operation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity,
 429                item.ProductCode ?? string.Empty, comment, ct);
 430            if (operation.IsSuccess) { placed.Add(item); continue; }
 431
 432            messages.Add($"Арт. {item.Article} · {item.Brand}: {operation.Describe()}.");
 433            await ReleaseAsync([item], operation.Describe(), ct);
 434        }
 435
 436        if (placed.Count == 0)
 437        {
 438            await RollbackApiCartAsync(placed, ct);
 439            return new ProfitLigaCartOperationResult(0, 0, items.Count, messages);
 440        }
 441
 442        var created = await _profitLiga.CreateOrderAsync(method.Id, payment.Id,
 443            point.IsPickup ? null : point.Code, point.IsPickup ? null : point.Address,
 444            point.IsPickup ? point.Code : null, ct);
 445
 446        if (!created.IsSuccess)
 447        {
 448            // Заказ не создан — строки остались в корзине поставщика и помешали бы следующей попытке.
 449            await RollbackApiCartAsync(placed, ct);
 450            var error = string.IsNullOrWhiteSpace(created.Error) ? "заказ не создан." : created.Error!;
 451            await ReleaseAsync(placed, error, ct);
 452            messages.Add($"Профит-Лига: {error}");
 453            return new ProfitLigaCartOperationResult(0, 0, items.Count, messages);
 454        }
 455
 456        var numbers = string.Join(", ", created.OrderNumbers);
 457        var order = await CreateOrderRecordAsync(created, point, method, payment, placed, changedBy, ct);
 458
 459        foreach (var item in placed)
 460        {
 461            item.Status = ProfitLigaCartItemStatus.Ordered;
 462            item.ActiveStudioOrderId = null;
 463            item.LastError = null;
 464            item.UpdatedAtUtc = DateTime.UtcNow;
 465            await RecordHistoryAsync(item, ItemLabel(item), true, null, ApiSupplierBasketCartStatus.Ordered, numbers, ch
 466            await TrackAsync(() => _supplierState.MarkOrderedAsync(item.StudioOrderId, ProviderKey, numbers,
 467                user: changedBy, direction: item.WarehouseName, ct: ct));
 468        }
 469
 470        messages.Add($"Профит-Лига: создан заказ {numbers} ({placed.Count} поз.).");
 471        _ = order;
 472        return new ProfitLigaCartOperationResult(placed.Count, 0, items.Count - placed.Count, messages);
 473    }
 474
 475    /// <summary>Сразу после оформления читаем /orders/list, не заставляя пользователя ждать Hangfire.</summary>
 476    private async Task SynchronizeOrderStatusesAsync(IReadOnlyCollection<ProfitLigaCartItem> items,
 477        List<string> messages, CancellationToken ct)
 478    {
 479        var orderIds = items.Where(x => x.Status == ProfitLigaCartItemStatus.Ordered)
 480            .Select(x => x.StudioOrderId).Distinct().ToList();
 481        if (orderIds.Count == 0) return;
 482
 483        try
 484        {
 485            await _supplierStatusSync.SyncOrdersAsync(orderIds, ct);
 486            var statuses = await _db.OrderSupplierStates.AsNoTracking()
 487                .Include(x => x.SupplierStatus)
 488                .Where(x => x.ProviderKey == ProviderKey && x.Stage == OrderSupplierStage.Ordered &&
 489                            orderIds.Contains(x.OrderId) && x.LastSyncedAt != null && x.SupplierStatus != null)
 490                .Select(x => x.SupplierStatus!.Name)
 491                .Distinct()
 492                .ToListAsync(ct);
 493            if (statuses.Count > 0)
 494                messages.Add($"Профит-Лига: статус в Studio2 обновлён — {string.Join(", ", statuses)}.");
 495        }
 496        catch (Exception ex)
 497        {
 498            // Заказ уже создан у поставщика: сбой немедленного опроса не должен превращать оформление в ошибку.
 499            _logger.LogWarning(ex, "Профит-Лига: не удалось сразу обновить статус заказов {OrderIds}",
 500                string.Join(", ", orderIds));
 501        }
 502    }
 503
 504    /// <summary>Один номер из orders[] — одна строка ProfitLigaOrders: поставщик разбивает
 505    /// корзину по складам и возвращает несколько номеров на один вызов.</summary>
 506    private async Task<ProfitLigaOrder> CreateOrderRecordAsync(ProfitLigaCreateOrderResult created,
 507        ProfitLigaPoint point, ProfitLigaDeliveryMethod method, ProfitLigaPaymentMethod payment,
 508        List<ProfitLigaCartItem> items, string changedBy, CancellationToken ct)
 509    {
 510        ProfitLigaOrder? first = null;
 511        var isFirst = true;
 512
 513        foreach (var number in created.OrderNumbers)
 514        {
 515            var order = new ProfitLigaOrder
 516            {
 517                OrderNumber = number,
 518                PointCode = point.Code,
 519                PointName = point.DisplayName,
 520                DeliveryMethodId = method.Id,
 521                DeliveryMethodName = method.Name,
 522                PaymentId = payment.Id,
 523                PaymentName = payment.Name,
 524                Status = "Создан",
 525                CreatedBy = changedBy,
 526                ResponseJson = created.ResponseJson
 527            };
 528
 529            // Состав по номерам API не раскрывает — позиции пишем к первому заказу,
 530            // а точная разбивка приезжает позже из /orders/list через статус-провайдер.
 531            if (isFirst)
 532            {
 533                foreach (var item in items)
 534                    order.Items.Add(new ProfitLigaOrderItem
 535                    {
 536                        ProfitLigaCartItemId = item.Id,
 537                        StudioOrderId = item.StudioOrderId,
 538                        Article = item.Article,
 539                        Brand = item.Brand,
 540                        ArticleId = item.ArticleId,
 541                        ProductCode = item.ProductCode,
 542                        Quantity = item.Quantity,
 543                        Price = item.SourcePrice,
 544                        CurrencyCode = item.CurrencyCode
 545                    });
 546                isFirst = false;
 547            }
 548
 549            _db.ProfitLigaOrders.Add(order);
 550            first ??= order;
 551        }
 552
 553        await _db.SaveChangesAsync(ct);
 554        return first!;
 555    }
 556
 557    // ---------- удаление ----------
 558
 559    public async Task<ProfitLigaCartOperationResult> RemoveManyAsync(IReadOnlyCollection<long> cartItemIds,
 560        bool removeAll, string changedBy, CancellationToken ct = default)
 561    {
 562        var query = _db.ProfitLigaCartItems.Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status));
 563        if (!removeAll) query = query.Where(x => cartItemIds.Contains(x.Id));
 564
 565        var items = await query.Include(x => x.StudioOrder).ToListAsync(ct);
 566        // Строку, которая прямо сейчас оформляется, удалять нельзя — её судьбу решает CheckoutAsync.
 567        items = items.Where(x => x.Status != ProfitLigaCartItemStatus.Processing).ToList();
 568        if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для удаления."]);
 569
 570        var removed = new List<ProfitLigaCartItem>();
 571        var messages = new List<string>();
 572        foreach (var item in items)
 573        {
 574            try
 575            {
 576                var operation = await _profitLiga.RemoveFromCartAsync(item.ArticleId, item.WarehouseId, ct);
 577                if (!operation.IsSuccess)
 578                {
 579                    messages.Add($"{ItemLabel(item)}: {operation.Describe()}.");
 580                    continue;
 581                }
 582            }
 583            catch (ProfitLigaApiException ex)
 584            {
 585                messages.Add($"{ItemLabel(item)}: {ex.Message}");
 586                continue;
 587            }
 588            item.Status = ProfitLigaCartItemStatus.Removed;
 589            item.ActiveStudioOrderId = null;
 590            item.UpdatedAtUtc = DateTime.UtcNow;
 591            await TrackAsync(() => _supplierState.RemoveFromCartAsync(item.StudioOrderId, ProviderKey, changedBy, ct));
 592            removed.Add(item);
 593        }
 594
 595        await _db.SaveChangesAsync(ct);
 596        return new ProfitLigaCartOperationResult(removed.Count, 0, items.Count - removed.Count,
 597            [$"Удалено позиций: {removed.Count}.", .. messages]);
 598    }
 599
 600    /// <summary>Очистка реальной корзины поставщика построчно — операции «очистить всё» у API нет.</summary>
 601    public async Task<ProfitLigaCartOperationResult> ClearApiCartAsync(IReadOnlyCollection<string>? keys,
 602        CancellationToken ct = default)
 603    {
 604        var lines = await _profitLiga.GetCartAsync(ct);
 605        var target = keys is null || keys.Count == 0
 606            ? lines
 607            : lines.Where(x => keys.Contains(x.Key, StringComparer.Ordinal)).ToList();
 608        if (target.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Корзина Профит-Лиги уже пуста."]);
 609
 610        int removed = 0, failed = 0;
 611        var messages = new List<string>();
 612        foreach (var line in target)
 613        {
 614            try
 615            {
 616                var operation = await _profitLiga.RemoveFromCartAsync(line.ArticleId, line.WarehouseId, ct);
 617                if (operation.IsSuccess) { removed++; continue; }
 618                failed++;
 619                messages.Add($"{line.Article}: {operation.Describe()}.");
 620            }
 621            catch (ProfitLigaApiException ex)
 622            {
 623                failed++;
 624                messages.Add($"{line.Article}: {ex.Message}");
 625            }
 626        }
 627
 628        messages.Insert(0, $"Удалено из корзины поставщика: {removed}.");
 629        return new ProfitLigaCartOperationResult(removed, 0, failed, messages);
 630    }
 631
 632    // ---------- вспомогательное ----------
 633
 634    private Task<List<ProfitLigaCartItem>> LoadForActionAsync(IReadOnlyCollection<long> cartItemIds,
 635        CancellationToken ct) =>
 636        _db.ProfitLigaCartItems.Include(x => x.StudioOrder)
 637            .Where(x => cartItemIds.Contains(x.Id) && ProfitLigaCartItemStatus.Active.Contains(x.Status))
 638            .ToListAsync(ct);
 639
 640    /// <summary>Ищет то же предложение (тот же склад поставщика) в свежей проценке.</summary>
 641    private async Task<ProfitLigaOfferSnapshot?> FindOfferAsync(ProfitLigaCartItem item, CancellationToken ct)
 642    {
 643        var response = await _profitLiga.SearchCrossesAsync(item.Article, item.Brand, replaces: false, ct);
 644        var offers = ProfitLigaPriceMapper.MapOriginals(response, item.Article, item.Brand, null, null);
 645        var match = offers.FirstOrDefault(x =>
 646            string.Equals(x.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.WarehouseIdKey), item.WarehouseId,
 647                StringComparison.Ordinal));
 648        if (match is null) return null;
 649
 650        var quantity = decimal.TryParse(match.Rest, NumberStyles.Any, CultureInfo.InvariantCulture, out var rest) ? rest
 651        var multi = int.TryParse(match.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.MultiKey),
 652            NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedMulti) ? parsedMulti : 0;
 653
 654        return new ProfitLigaOfferSnapshot(
 655            match.SourcePrice ?? 0m,
 656            quantity,
 657            multi,
 658            match.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.ProductCodeKey),
 659            match.DeliveryDaysMax > 0 ? DateTime.UtcNow.AddDays(match.DeliveryDaysMax) : null);
 660    }
 661
 662    /// <summary>Возврат строк из Processing в Failed, чтобы корзина не зависла в оформлении.</summary>
 663    private async Task ReleaseAsync(IReadOnlyCollection<ProfitLigaCartItem> items, string error, CancellationToken ct)
 664    {
 665        foreach (var item in items)
 666        {
 667            item.Status = ProfitLigaCartItemStatus.Failed;
 668            item.LastError = error.Length > 1000 ? error[..1000] : error;
 669            item.UpdatedAtUtc = DateTime.UtcNow;
 670        }
 671        await _db.SaveChangesAsync(ct);
 672    }
 673
 674    /// <summary>Убирает из корзины поставщика строки, добавленные неудачной попыткой оформления.</summary>
 675    private async Task RollbackApiCartAsync(IReadOnlyCollection<ProfitLigaCartItem> placed, CancellationToken ct)
 676    {
 677        foreach (var item in placed)
 678        {
 679            try { await _profitLiga.RemoveFromCartAsync(item.ArticleId, item.WarehouseId, ct); }
 680            catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException)
 681            {
 682                _logger.LogWarning(ex, "Профит-Лига: не удалось откатить позицию {ArticleId} из корзины поставщика",
 683                    item.ArticleId);
 684            }
 685        }
 686    }
 687
 688    private static bool IsSameApiLine(ProfitLigaCartItem item, ProfitLigaCartLine line) =>
 689        string.Equals(item.ArticleId, line.ArticleId, StringComparison.Ordinal) &&
 690        string.Equals(item.WarehouseId, line.WarehouseId, StringComparison.Ordinal);
 691
 692    private async Task<ProfitLigaCartOperationResult> RemoveApiLinesAsync(
 693        IReadOnlyCollection<ProfitLigaCartLine> lines, CancellationToken ct)
 694    {
 695        var removed = 0;
 696        var messages = new List<string>();
 697        foreach (var line in lines)
 698        {
 699            try
 700            {
 701                var operation = await _profitLiga.RemoveFromCartAsync(line.ArticleId, line.WarehouseId, ct);
 702                if (operation.IsSuccess) { removed++; continue; }
 703                messages.Add($"{line.Article}: {operation.Describe()}.");
 704            }
 705            catch (ProfitLigaApiException ex)
 706            {
 707                messages.Add($"{line.Article}: {ex.Message}");
 708            }
 709        }
 710
 711        return new ProfitLigaCartOperationResult(removed, 0, lines.Count - removed, messages);
 712    }
 713
 714    private async Task RestoreApiMirrorAsync(CancellationToken ct)
 715    {
 716        var active = await _db.ProfitLigaCartItems.AsNoTracking()
 717            .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status))
 718            .ToListAsync(ct);
 719        var actual = await _profitLiga.GetCartAsync(ct);
 720        foreach (var item in active.Where(item => !actual.Any(line => IsSameApiLine(item, line))))
 721        {
 722            var operation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity,
 723                item.ProductCode ?? string.Empty, $"Studio2 ID {item.StudioOrderId}", ct);
 724            if (!operation.IsSuccess)
 725                _logger.LogWarning("Профит-Лига: не удалось восстановить API-зеркало позиции {CartItemId}: {Status}",
 726                    item.Id, operation.Status);
 727        }
 728    }
 729
 730    private static ProfitLigaDeliveryMethod? ResolveMethod(ProfitLigaOrderParams parameters, ProfitLigaPoint point,
 731        string? methodId)
 732    {
 733        if (!string.IsNullOrWhiteSpace(methodId))
 734            return parameters.Methods.FirstOrDefault(x => string.Equals(x.Id, methodId, StringComparison.Ordinal));
 735
 736        // Точка самовывоза требует способа «самовывоз», торговая точка — доставки.
 737        var pickup = parameters.Methods.FirstOrDefault(x => x.Name.Contains("самовывоз", StringComparison.OrdinalIgnoreC
 738        var delivery = parameters.Methods.FirstOrDefault(x => x.Name.Contains("доставка", StringComparison.OrdinalIgnore
 739        return point.IsPickup ? pickup ?? parameters.Methods.FirstOrDefault()
 740                              : delivery ?? parameters.Methods.FirstOrDefault();
 741    }
 742
 743    private static ProfitLigaPaymentMethod? ResolvePayment(ProfitLigaOrderParams parameters, string? paymentId) =>
 744        !string.IsNullOrWhiteSpace(paymentId)
 745            ? parameters.Payments.FirstOrDefault(x => string.Equals(x.Id, paymentId, StringComparison.Ordinal))
 746            : parameters.Payments.FirstOrDefault(x => x.Name.Contains("безнал", StringComparison.OrdinalIgnoreCase))
 747              ?? parameters.Payments.FirstOrDefault();
 748
 749    /// <summary>Комментарий — единственный способ увидеть заказ Studio2 в интерфейсе поставщика.</summary>
 750    private static string BuildComment(ProfitLigaCartItem item)
 751    {
 752        var label = string.IsNullOrWhiteSpace(item.StudioOrder?.ShipmentNumber)
 753            ? $"ID {item.StudioOrderId}"
 754            : item.StudioOrder!.ShipmentNumber;
 755        var comment = $"Studio2 {label} (ID {item.StudioOrderId})";
 756        return comment.Length <= ProfitLigaDataManager.CommentMaxLength
 757            ? comment
 758            : comment[..ProfitLigaDataManager.CommentMaxLength];
 759    }
 760
 761    private static string ItemLabel(ProfitLigaCartItem item) =>
 762        string.IsNullOrWhiteSpace(item.StudioOrder?.ShipmentNumber)
 763            ? $"ID {item.StudioOrderId}"
 764            : item.StudioOrder!.ShipmentNumber!;
 765
 766    private async Task RecordHistoryAsync(ProfitLigaCartItem item, string label, bool success, string? error,
 767        string cartStatus, string? orderNumber, string changedBy)
 768    {
 769        try
 770        {
 771            var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, SupplierDisplayName, LogoUrl, success, error,
 772                item.ArticleId, item.Article, item.ProductName ?? item.Brand, item.SourcePrice, item.CurrencyCode,
 773                item.Quantity, label, WarehouseName: item.WarehouseName, DeliveryAddressName: item.PointName,
 774                CartStatus: cartStatus, SupplierOrderNumber: orderNumber, SupplierCartUrl: CartUrl);
 775            await _history.AddOrderHistory(new OrderHistory
 776            {
 777                OrderId = item.StudioOrderId,
 778                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 779                ColumnDisplayName = "Корзина Профит-Лиги",
 780                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 781                ChangedAt = DateTime.Now,
 782                ChangedBy = changedBy
 783            });
 784        }
 785        catch (Exception ex)
 786        {
 787            // Журнал не должен ломать уже выполненную работу с корзиной поставщика.
 788            _logger.LogWarning(ex, "Профит-Лига: не удалось записать историю по заказу {OrderId}", item.StudioOrderId);
 789        }
 790    }
 791
 792    private async Task TrackAsync(Func<Task> action)
 793    {
 794        try { await action(); }
 795        catch (Exception ex) { _logger.LogWarning(ex, "Профит-Лига: не удалось обновить состояние заказа у поставщика");
 796    }
 797
 798    private sealed record ProfitLigaOfferSnapshot(decimal Price, decimal Quantity, int Multi, string? ProductCode,
 799        DateTime? DeliveryAtUtc);
 800}