< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ProfitLigaCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ProfitLigaCartService.cs
Line coverage
93%
Covered lines: 441
Uncovered lines: 29
Coverable lines: 470
Total lines: 800
Line coverage: 93.8%
Branch coverage
82%
Covered branches: 149
Total branches: 180
Branch coverage: 82.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetActiveAsync(...)100%11100%
CountActiveAsync(...)100%11100%
GetOrdersAsync(...)100%11100%
GetApiCartAsync(...)100%11100%
GetApiOrdersAsync(...)100%11100%
QueueAsync()83.33%181889.83%
TestAsync()95%2020100%
CheckoutAsync()93.75%161688.46%
CheckoutDirectAsync()77.77%1818100%
CheckoutGroupAsync()76.92%302682.22%
SynchronizeOrderStatusesAsync()100%44100%
CreateOrderRecordAsync()100%88100%
RemoveManyAsync()100%88100%
ClearApiCartAsync()100%1010100%
LoadForActionAsync(...)100%11100%
FindOfferAsync()66.66%1212100%
ReleaseAsync()75%44100%
RollbackApiCartAsync()100%3250%
IsSameApiLine(...)100%22100%
RemoveApiLinesAsync()75%4476.92%
RestoreApiMirrorAsync()87.5%8881.81%
ResolveMethod(...)75%88100%
ResolvePayment(...)75%44100%
BuildComment(...)50%66100%
ItemLabel(...)50%44100%
RecordHistoryAsync()100%22100%
TrackAsync()100%11100%
get_Price()100%11100%
get_DeliveryAtUtc()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>
 16public sealed record ProfitLigaQueueItem(
 17    int OrderId, string ArticleId, string WarehouseId, string? ProductCode, string Article, string Brand,
 18    string? ProductName, string? WarehouseName, string? ShipmentNumber, string? ClientName,
 19    decimal Quantity, int Multi, decimal? SourcePrice, string? CurrencyCode,
 20    string? ZasylWarehouseName = null, int? DeliveryDaysMin = null, int? DeliveryDaysMax = null,
 21    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>
 161    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
 4971    public ProfitLigaCartService(
 4972        ApplicationDbContext db,
 4973        ProfitLigaDataManager profitLiga,
 4974        ProfitLigaPointMappingService points,
 4975        IOrderHistoryDataService history,
 4976        IOrderSupplierStateService supplierState,
 4977        IOrderSupplierStatusSyncService supplierStatusSync,
 4978        ILogger<ProfitLigaCartService> logger)
 79    {
 4980        _db = db;
 4981        _profitLiga = profitLiga;
 4982        _points = points;
 4983        _history = history;
 4984        _supplierState = supplierState;
 4985        _supplierStatusSync = supplierStatusSync;
 4986        _logger = logger;
 4987    }
 88
 89    // ---------- чтение ----------
 90
 91    public Task<List<ProfitLigaCartItem>> GetActiveAsync(CancellationToken ct = default) =>
 192        _db.ProfitLigaCartItems.AsNoTracking().Include(x => x.StudioOrder)
 193            .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status))
 194            .OrderByDescending(x => x.CreatedAtUtc)
 195            .ToListAsync(ct);
 96
 97    public Task<int> CountActiveAsync(CancellationToken ct = default) =>
 198        _db.ProfitLigaCartItems.CountAsync(x => ProfitLigaCartItemStatus.Active.Contains(x.Status), ct);
 99
 100    public Task<List<ProfitLigaOrder>> GetOrdersAsync(CancellationToken ct = default) =>
 1101        _db.ProfitLigaOrders.AsNoTracking().Include(x => x.Items)
 1102            .OrderByDescending(x => x.CreatedAtUtc).Take(200).ToListAsync(ct);
 103
 104    /// <summary>Состояние зеркала API; используется для проверки целостности гибридной корзины.</summary>
 105    public Task<IReadOnlyList<ProfitLigaCartLine>> GetApiCartAsync(CancellationToken ct = default) =>
 1106        _profitLiga.GetCartAsync(ct);
 107
 108    public Task<Newtonsoft.Json.Linq.JToken> GetApiOrdersAsync(int page = 0, CancellationToken ct = default) =>
 1109        _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    {
 14118        if (queue.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, []);
 119
 36120        int added = 0, skipped = 0, failed = 0;
 12121        var messages = new List<string>();
 122
 48123        foreach (var item in queue)
 124        {
 12125            var label = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber!;
 126
 12127            if (string.IsNullOrWhiteSpace(item.ProductCode))
 128            {
 2129                failed++;
 2130                messages.Add($"{label}: у предложения нет кода номенклатуры — Профит-Лига не примет его в корзину.");
 2131                continue;
 132            }
 133
 10134            if (await _db.ProfitLigaCartItems.AnyAsync(
 10135                    x => x.StudioOrderId == item.OrderId && ProfitLigaCartItemStatus.Active.Contains(x.Status), ct))
 136            {
 1137                skipped++;
 1138                messages.Add($"{label}: позиция Профит-Лиги уже в корзине.");
 1139                continue;
 140            }
 141
 9142            var point = await _points.ResolveAsync(item.OrderId, item.ZasylWarehouseName, ct);
 143            ProfitLigaCartOperation apiOperation;
 144            try
 145            {
 9146                apiOperation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity,
 9147                    item.ProductCode, $"Studio2 {label} (ID {item.OrderId})", ct);
 8148            }
 1149            catch (ProfitLigaApiException ex)
 150            {
 1151                failed++;
 1152                messages.Add($"{label}: не удалось добавить позицию в корзину Профит-Лиги. {ex.Message}");
 1153                continue;
 154            }
 8155            if (!apiOperation.IsSuccess)
 156            {
 1157                failed++;
 1158                messages.Add($"{label}: Профит-Лига не добавила позицию в корзину — {apiOperation.Describe()}.");
 1159                continue;
 160            }
 161
 7162            var cartItem = new ProfitLigaCartItem
 7163            {
 7164                StudioOrderId = item.OrderId,
 7165                ActiveStudioOrderId = item.OrderId,
 7166                ArticleId = item.ArticleId,
 7167                WarehouseId = item.WarehouseId,
 7168                ProductCode = item.ProductCode,
 7169                Article = item.Article,
 7170                Brand = item.Brand,
 7171                ProductName = item.ProductName,
 7172                WarehouseName = item.WarehouseName,
 7173                PointCode = point?.PointCode,
 7174                PointName = point?.PointName,
 7175                Quantity = item.Quantity,
 7176                Multi = item.Multi,
 7177                SourcePrice = item.SourcePrice,
 7178                CurrencyCode = string.IsNullOrWhiteSpace(item.CurrencyCode) ? CurrencyCode : item.CurrencyCode!,
 7179                CreatedBy = changedBy
 7180            };
 181
 7182            _db.ProfitLigaCartItems.Add(cartItem);
 183            try
 184            {
 7185                await _db.SaveChangesAsync(ct);
 7186            }
 0187            catch (DbUpdateException)
 188            {
 189                // Гонка по уникальному индексу ActiveStudioOrderId: позицию уже положил параллельный запрос.
 0190                _db.Entry(cartItem).State = EntityState.Detached;
 0191                await RollbackApiCartAsync([cartItem], ct);
 0192                skipped++;
 0193                messages.Add($"{label}: позиция Профит-Лиги уже в корзине.");
 0194                continue;
 195            }
 196
 7197            added++;
 7198            await RecordHistoryAsync(cartItem, label, true, null, ApiSupplierBasketCartStatus.Pending, null, changedBy);
 14199            await TrackAsync(() => _supplierState.MarkInCartAsync(item.OrderId, ProviderKey,
 14200                SupplierCartStatusCode.Pending, cartItem.Id, user: changedBy,
 14201                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax,
 14202                direction: item.Direction, ct: ct));
 7203        }
 204
 12205        return new ProfitLigaCartOperationResult(added, skipped, failed, messages);
 13206    }
 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    {
 9215        var items = await LoadForActionAsync(cartItemIds, ct);
 10216        if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для проверки."]);
 217
 16218        int ready = 0, failed = 0;
 8219        var messages = new List<string>();
 220
 32221        foreach (var item in items)
 222        {
 8223            var label = $"Арт. {item.Article} · {item.Brand}";
 224            try
 225            {
 8226                var offer = await FindOfferAsync(item, ct);
 7227                if (offer is null)
 228                {
 2229                    item.Status = ProfitLigaCartItemStatus.Failed;
 2230                    item.LastError = $"{label}: предложение на складе «{item.WarehouseName ?? item.WarehouseId}» больше 
 2231                    failed++;
 2232                    messages.Add(item.LastError);
 2233                    continue;
 234                }
 235
 5236                if (offer.Quantity < item.Quantity)
 237                {
 1238                    item.Status = ProfitLigaCartItemStatus.Failed;
 1239                    item.LastError = $"{label}: на складе {offer.Quantity.ToString("0.##", CultureInfo.InvariantCulture)
 1240                    failed++;
 1241                    messages.Add(item.LastError);
 1242                    continue;
 243                }
 244
 4245                if (offer.Multi > 1 && item.Quantity % offer.Multi != 0)
 246                {
 1247                    item.Status = ProfitLigaCartItemStatus.Failed;
 1248                    item.LastError = $"{label}: количество должно быть кратно {offer.Multi}.";
 1249                    failed++;
 1250                    messages.Add(item.LastError);
 1251                    continue;
 252                }
 253
 3254                var priceChanged = item.SourcePrice != offer.Price;
 3255                item.SourcePrice = offer.Price;
 3256                item.Multi = offer.Multi;
 3257                item.ProductCode = offer.ProductCode ?? item.ProductCode;
 3258                item.DeliveryAtUtc = offer.DeliveryAtUtc;
 3259                item.Status = ProfitLigaCartItemStatus.Ready;
 3260                item.LastError = null;
 3261                item.TestedAtUtc = DateTime.UtcNow;
 3262                item.UpdatedAtUtc = DateTime.UtcNow;
 3263                item.TestResultJson = JsonSerializer.Serialize(offer);
 3264                ready++;
 3265                messages.Add(priceChanged
 3266                    ? $"{label}: подтверждено, цена обновлена до {offer.Price.ToString("0.##", CultureInfo.InvariantCult
 3267                    : $"{label}: подтверждено.");
 3268            }
 1269            catch (ProfitLigaApiException ex)
 270            {
 1271                item.Status = ProfitLigaCartItemStatus.Failed;
 1272                item.LastError = $"{label}: {ex.Message}";
 1273                failed++;
 1274                messages.Add(item.LastError);
 1275            }
 4276        }
 277
 8278        await _db.SaveChangesAsync(ct);
 32279        foreach (var item in items)
 280        {
 16281            await TrackAsync(() => _supplierState.MarkInCartAsync(item.StudioOrderId, ProviderKey,
 16282                item.Status, item.Id, user: changedBy, ct: ct));
 283        }
 8284        return new ProfitLigaCartOperationResult(ready, 0, failed, messages);
 9285    }
 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    {
 15294        var items = await LoadForActionAsync(cartItemIds, ct);
 16295        if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для оформления."]);
 296
 29297        var stale = items.Where(x => x.Status != ProfitLigaCartItemStatus.Ready
 29298                                     || !x.TestedAtUtc.HasValue
 29299                                     || x.TestedAtUtc.Value < DateTime.UtcNow - ReadyWindow).ToList();
 14300        if (stale.Count > 0)
 1301            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 1302                ["Перед оформлением выполните свежую проверку Профит-Лиги (действует 5 минут)."]);
 303
 26304        if (items.Any(x => string.IsNullOrWhiteSpace(x.PointCode)))
 1305            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 1306                ["Для части позиций не выбрана торговая точка. Задайте связь склада и точки в настройках API-поставщика.
 307
 12308        var apiCart = await _profitLiga.GetCartAsync(ct);
 12309        var activeItems = await _db.ProfitLigaCartItems.AsNoTracking()
 12310            .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status))
 12311            .ToListAsync(ct);
 16312        if (apiCart.Any(line => !activeItems.Any(item => IsSameApiLine(item, line))))
 1313            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 1314                ["В API-корзине Профит-Лиги есть позиции не из Studio2. Удалите их у поставщика и повторите оформление."
 315
 22316        var ids = items.Select(x => x.Id).ToList();
 11317        var locked = await _db.ProfitLigaCartItems
 11318            .Where(x => ids.Contains(x.Id) && x.Status == ProfitLigaCartItemStatus.Ready)
 11319            .ExecuteUpdateAsync(s => s
 11320                .SetProperty(x => x.Status, ProfitLigaCartItemStatus.Processing)
 11321                .SetProperty(x => x.UpdatedAtUtc, DateTime.UtcNow), ct);
 11322        if (locked != items.Count)
 0323            return new ProfitLigaCartOperationResult(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]);
 324
 55325        foreach (var item in items) item.Status = ProfitLigaCartItemStatus.Processing;
 326
 11327        var clearResult = await RemoveApiLinesAsync(apiCart, ct);
 11328        if (clearResult.Failed > 0)
 329        {
 1330            await ReleaseAsync(items, "Не удалось подготовить корзину поставщика к оформлению.", ct);
 1331            await RestoreApiMirrorAsync(ct);
 1332            return clearResult;
 333        }
 334
 20335        int ordered = 0, failed = 0;
 10336        var messages = new List<string>();
 337        ProfitLigaOrderParams parameters;
 338        try
 339        {
 10340            parameters = await _profitLiga.GetOrderParamsAsync(ct);
 9341        }
 1342        catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException or TaskCanceledException)
 343        {
 1344            await ReleaseAsync(items, ex.Message, ct);
 1345            await RestoreApiMirrorAsync(ct);
 1346            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 1347                [$"Профит-Лига: не удалось получить параметры оформления. {ex.Message}"]);
 348        }
 349
 350        // Точка доставки — состояние аккаунта, поэтому одна группа = один поход в /cart/point и /cart/order.
 45351        foreach (var group in items.GroupBy(x => x.PointCode!, StringComparer.Ordinal))
 352        {
 9353            var groupItems = group.ToList();
 354            try
 355            {
 9356                var result = await CheckoutGroupAsync(group.Key, groupItems, parameters, methodId, paymentId, changedBy,
 9357                ordered += result.Added;
 9358                failed += result.Failed;
 9359                messages.AddRange(result.Messages);
 9360            }
 0361            catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException or TaskCanceledException)
 362            {
 0363                _logger.LogWarning(ex, "Профит-Лига: оформление точки {PointCode} не выполнено", group.Key);
 0364                await ReleaseAsync(groupItems, ex.Message, ct);
 0365                failed += groupItems.Count;
 0366                messages.Add($"Профит-Лига: {ex.Message}");
 367            }
 9368        }
 369
 9370        await _db.SaveChangesAsync(ct);
 9371        await RestoreApiMirrorAsync(ct);
 9372        await SynchronizeOrderStatusesAsync(items, messages, ct);
 9373        return new ProfitLigaCartOperationResult(ordered, 0, failed, messages);
 15374    }
 375
 376    /// <summary>«Оформить с проведением»: кладёт позиции в локальную корзину и сразу оформляет их.</summary>
 377    public async Task<ProfitLigaDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ProfitLigaQueueItem> queue
 378        string changedBy, CancellationToken ct = default)
 379    {
 3380        var queued = await QueueAsync(queue, changedBy, ct);
 3381        if (queued.Added == 0)
 1382            return new ProfitLigaDirectCheckoutResult(false, queued.Messages.Count > 0
 1383                ? queued.Messages
 1384                : ["Профит-Лига: не удалось поставить позиции в корзину."]);
 385
 4386        var orderIds = queue.Select(x => x.OrderId).ToList();
 2387        var ids = await _db.ProfitLigaCartItems
 2388            .Where(x => orderIds.Contains(x.StudioOrderId) && ProfitLigaCartItemStatus.Active.Contains(x.Status))
 2389            .Select(x => x.Id).ToListAsync(ct);
 390
 2391        var tested = await TestAsync(ids, changedBy, ct);
 2392        if (tested.Added == 0)
 1393            return new ProfitLigaDirectCheckoutResult(false, [.. queued.Messages, .. tested.Messages]);
 394
 1395        var checkedOut = await CheckoutAsync(ids, null, null, changedBy, ct);
 1396        return new ProfitLigaDirectCheckoutResult(checkedOut.IsSuccess && checkedOut.Added > 0,
 1397            [.. queued.Messages, .. tested.Messages, .. checkedOut.Messages]);
 3398    }
 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    {
 22404        var point = parameters.AllPoints.FirstOrDefault(x => string.Equals(x.Code, pointCode, StringComparison.Ordinal))
 9405        if (point is null)
 406        {
 1407            await ReleaseAsync(items, "Торговая точка недоступна в текущих настройках заказа Профит-Лиги.", ct);
 1408            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 1409                ["Профит-Лига: сохранённая торговая точка больше не доступна. Обновите связь в настройках."]);
 410        }
 411
 8412        var method = ResolveMethod(parameters, point, methodId);
 8413        var payment = ResolvePayment(parameters, paymentId);
 8414        if (method is null || payment is null)
 415        {
 0416            await ReleaseAsync(items, "Профит-Лига не вернула способы доставки или оплаты.", ct);
 0417            return new ProfitLigaCartOperationResult(0, 0, items.Count,
 0418                ["Профит-Лига: не удалось определить способ доставки или оплаты."]);
 419        }
 420
 8421        await _profitLiga.SetPointAsync(pointCode, ct);
 422
 8423        var placed = new List<ProfitLigaCartItem>();
 8424        var messages = new List<string>();
 32425        foreach (var item in items)
 426        {
 8427            var comment = BuildComment(item);
 8428            var operation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity,
 8429                item.ProductCode ?? string.Empty, comment, ct);
 24430            if (operation.IsSuccess) { placed.Add(item); continue; }
 431
 0432            messages.Add($"Арт. {item.Article} · {item.Brand}: {operation.Describe()}.");
 0433            await ReleaseAsync([item], operation.Describe(), ct);
 0434        }
 435
 8436        if (placed.Count == 0)
 437        {
 0438            await RollbackApiCartAsync(placed, ct);
 0439            return new ProfitLigaCartOperationResult(0, 0, items.Count, messages);
 440        }
 441
 8442        var created = await _profitLiga.CreateOrderAsync(method.Id, payment.Id,
 8443            point.IsPickup ? null : point.Code, point.IsPickup ? null : point.Address,
 8444            point.IsPickup ? point.Code : null, ct);
 445
 8446        if (!created.IsSuccess)
 447        {
 448            // Заказ не создан — строки остались в корзине поставщика и помешали бы следующей попытке.
 1449            await RollbackApiCartAsync(placed, ct);
 1450            var error = string.IsNullOrWhiteSpace(created.Error) ? "заказ не создан." : created.Error!;
 1451            await ReleaseAsync(placed, error, ct);
 1452            messages.Add($"Профит-Лига: {error}");
 1453            return new ProfitLigaCartOperationResult(0, 0, items.Count, messages);
 454        }
 455
 7456        var numbers = string.Join(", ", created.OrderNumbers);
 7457        var order = await CreateOrderRecordAsync(created, point, method, payment, placed, changedBy, ct);
 458
 28459        foreach (var item in placed)
 460        {
 7461            item.Status = ProfitLigaCartItemStatus.Ordered;
 7462            item.ActiveStudioOrderId = null;
 7463            item.LastError = null;
 7464            item.UpdatedAtUtc = DateTime.UtcNow;
 7465            await RecordHistoryAsync(item, ItemLabel(item), true, null, ApiSupplierBasketCartStatus.Ordered, numbers, ch
 14466            await TrackAsync(() => _supplierState.MarkOrderedAsync(item.StudioOrderId, ProviderKey, numbers,
 14467                user: changedBy, direction: item.WarehouseName, ct: ct));
 7468        }
 469
 7470        messages.Add($"Профит-Лига: создан заказ {numbers} ({placed.Count} поз.).");
 471        _ = order;
 7472        return new ProfitLigaCartOperationResult(placed.Count, 0, items.Count - placed.Count, messages);
 9473    }
 474
 475    /// <summary>Сразу после оформления читаем /orders/list, не заставляя пользователя ждать Hangfire.</summary>
 476    private async Task SynchronizeOrderStatusesAsync(IReadOnlyCollection<ProfitLigaCartItem> items,
 477        List<string> messages, CancellationToken ct)
 478    {
 18479        var orderIds = items.Where(x => x.Status == ProfitLigaCartItemStatus.Ordered)
 16480            .Select(x => x.StudioOrderId).Distinct().ToList();
 11481        if (orderIds.Count == 0) return;
 482
 483        try
 484        {
 7485            await _supplierStatusSync.SyncOrdersAsync(orderIds, ct);
 6486            var statuses = await _db.OrderSupplierStates.AsNoTracking()
 6487                .Include(x => x.SupplierStatus)
 6488                .Where(x => x.ProviderKey == ProviderKey && x.Stage == OrderSupplierStage.Ordered &&
 6489                            orderIds.Contains(x.OrderId) && x.LastSyncedAt != null && x.SupplierStatus != null)
 6490                .Select(x => x.SupplierStatus!.Name)
 6491                .Distinct()
 6492                .ToListAsync(ct);
 6493            if (statuses.Count > 0)
 1494                messages.Add($"Профит-Лига: статус в Studio2 обновлён — {string.Join(", ", statuses)}.");
 6495        }
 1496        catch (Exception ex)
 497        {
 498            // Заказ уже создан у поставщика: сбой немедленного опроса не должен превращать оформление в ошибку.
 1499            _logger.LogWarning(ex, "Профит-Лига: не удалось сразу обновить статус заказов {OrderIds}",
 1500                string.Join(", ", orderIds));
 1501        }
 9502    }
 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    {
 7510        ProfitLigaOrder? first = null;
 7511        var isFirst = true;
 512
 30513        foreach (var number in created.OrderNumbers)
 514        {
 8515            var order = new ProfitLigaOrder
 8516            {
 8517                OrderNumber = number,
 8518                PointCode = point.Code,
 8519                PointName = point.DisplayName,
 8520                DeliveryMethodId = method.Id,
 8521                DeliveryMethodName = method.Name,
 8522                PaymentId = payment.Id,
 8523                PaymentName = payment.Name,
 8524                Status = "Создан",
 8525                CreatedBy = changedBy,
 8526                ResponseJson = created.ResponseJson
 8527            };
 528
 529            // Состав по номерам API не раскрывает — позиции пишем к первому заказу,
 530            // а точная разбивка приезжает позже из /orders/list через статус-провайдер.
 8531            if (isFirst)
 532            {
 28533                foreach (var item in items)
 7534                    order.Items.Add(new ProfitLigaOrderItem
 7535                    {
 7536                        ProfitLigaCartItemId = item.Id,
 7537                        StudioOrderId = item.StudioOrderId,
 7538                        Article = item.Article,
 7539                        Brand = item.Brand,
 7540                        ArticleId = item.ArticleId,
 7541                        ProductCode = item.ProductCode,
 7542                        Quantity = item.Quantity,
 7543                        Price = item.SourcePrice,
 7544                        CurrencyCode = item.CurrencyCode
 7545                    });
 7546                isFirst = false;
 547            }
 548
 8549            _db.ProfitLigaOrders.Add(order);
 8550            first ??= order;
 551        }
 552
 7553        await _db.SaveChangesAsync(ct);
 7554        return first!;
 7555    }
 556
 557    // ---------- удаление ----------
 558
 559    public async Task<ProfitLigaCartOperationResult> RemoveManyAsync(IReadOnlyCollection<long> cartItemIds,
 560        bool removeAll, string changedBy, CancellationToken ct = default)
 561    {
 6562        var query = _db.ProfitLigaCartItems.Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status));
 11563        if (!removeAll) query = query.Where(x => cartItemIds.Contains(x.Id));
 564
 6565        var items = await query.Include(x => x.StudioOrder).ToListAsync(ct);
 566        // Строку, которая прямо сейчас оформляется, удалять нельзя — её судьбу решает CheckoutAsync.
 12567        items = items.Where(x => x.Status != ProfitLigaCartItemStatus.Processing).ToList();
 8568        if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для удаления."]);
 569
 4570        var removed = new List<ProfitLigaCartItem>();
 4571        var messages = new List<string>();
 18572        foreach (var item in items)
 573        {
 574            try
 575            {
 5576                var operation = await _profitLiga.RemoveFromCartAsync(item.ArticleId, item.WarehouseId, ct);
 4577                if (!operation.IsSuccess)
 578                {
 1579                    messages.Add($"{ItemLabel(item)}: {operation.Describe()}.");
 1580                    continue;
 581                }
 3582            }
 1583            catch (ProfitLigaApiException ex)
 584            {
 1585                messages.Add($"{ItemLabel(item)}: {ex.Message}");
 1586                continue;
 587            }
 3588            item.Status = ProfitLigaCartItemStatus.Removed;
 3589            item.ActiveStudioOrderId = null;
 3590            item.UpdatedAtUtc = DateTime.UtcNow;
 6591            await TrackAsync(() => _supplierState.RemoveFromCartAsync(item.StudioOrderId, ProviderKey, changedBy, ct));
 3592            removed.Add(item);
 3593        }
 594
 4595        await _db.SaveChangesAsync(ct);
 4596        return new ProfitLigaCartOperationResult(removed.Count, 0, items.Count - removed.Count,
 4597            [$"Удалено позиций: {removed.Count}.", .. messages]);
 6598    }
 599
 600    /// <summary>Очистка реальной корзины поставщика построчно — операции «очистить всё» у API нет.</summary>
 601    public async Task<ProfitLigaCartOperationResult> ClearApiCartAsync(IReadOnlyCollection<string>? keys,
 602        CancellationToken ct = default)
 603    {
 4604        var lines = await _profitLiga.GetCartAsync(ct);
 4605        var target = keys is null || keys.Count == 0
 4606            ? lines
 6607            : lines.Where(x => keys.Contains(x.Key, StringComparer.Ordinal)).ToList();
 5608        if (target.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Корзина Профит-Лиги уже пуста."]);
 609
 6610        int removed = 0, failed = 0;
 3611        var messages = new List<string>();
 16612        foreach (var line in target)
 613        {
 614            try
 615            {
 5616                var operation = await _profitLiga.RemoveFromCartAsync(line.ArticleId, line.WarehouseId, ct);
 10617                if (operation.IsSuccess) { removed++; continue; }
 1618                failed++;
 1619                messages.Add($"{line.Article}: {operation.Describe()}.");
 1620            }
 1621            catch (ProfitLigaApiException ex)
 622            {
 1623                failed++;
 1624                messages.Add($"{line.Article}: {ex.Message}");
 1625            }
 2626        }
 627
 3628        messages.Insert(0, $"Удалено из корзины поставщика: {removed}.");
 3629        return new ProfitLigaCartOperationResult(removed, 0, failed, messages);
 4630    }
 631
 632    // ---------- вспомогательное ----------
 633
 634    private Task<List<ProfitLigaCartItem>> LoadForActionAsync(IReadOnlyCollection<long> cartItemIds,
 635        CancellationToken ct) =>
 24636        _db.ProfitLigaCartItems.Include(x => x.StudioOrder)
 24637            .Where(x => cartItemIds.Contains(x.Id) && ProfitLigaCartItemStatus.Active.Contains(x.Status))
 24638            .ToListAsync(ct);
 639
 640    /// <summary>Ищет то же предложение (тот же склад поставщика) в свежей проценке.</summary>
 641    private async Task<ProfitLigaOfferSnapshot?> FindOfferAsync(ProfitLigaCartItem item, CancellationToken ct)
 642    {
 8643        var response = await _profitLiga.SearchCrossesAsync(item.Article, item.Brand, replaces: false, ct);
 7644        var offers = ProfitLigaPriceMapper.MapOriginals(response, item.Article, item.Brand, null, null);
 7645        var match = offers.FirstOrDefault(x =>
 13646            string.Equals(x.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.WarehouseIdKey), item.WarehouseId,
 13647                StringComparison.Ordinal));
 9648        if (match is null) return null;
 649
 5650        var quantity = decimal.TryParse(match.Rest, NumberStyles.Any, CultureInfo.InvariantCulture, out var rest) ? rest
 5651        var multi = int.TryParse(match.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.MultiKey),
 5652            NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedMulti) ? parsedMulti : 0;
 653
 5654        return new ProfitLigaOfferSnapshot(
 5655            match.SourcePrice ?? 0m,
 5656            quantity,
 5657            multi,
 5658            match.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.ProductCodeKey),
 5659            match.DeliveryDaysMax > 0 ? DateTime.UtcNow.AddDays(match.DeliveryDaysMax) : null);
 7660    }
 661
 662    /// <summary>Возврат строк из Processing в Failed, чтобы корзина не зависла в оформлении.</summary>
 663    private async Task ReleaseAsync(IReadOnlyCollection<ProfitLigaCartItem> items, string error, CancellationToken ct)
 664    {
 16665        foreach (var item in items)
 666        {
 4667            item.Status = ProfitLigaCartItemStatus.Failed;
 4668            item.LastError = error.Length > 1000 ? error[..1000] : error;
 4669            item.UpdatedAtUtc = DateTime.UtcNow;
 670        }
 4671        await _db.SaveChangesAsync(ct);
 4672    }
 673
 674    /// <summary>Убирает из корзины поставщика строки, добавленные неудачной попыткой оформления.</summary>
 675    private async Task RollbackApiCartAsync(IReadOnlyCollection<ProfitLigaCartItem> placed, CancellationToken ct)
 676    {
 4677        foreach (var item in placed)
 678        {
 2679            try { await _profitLiga.RemoveFromCartAsync(item.ArticleId, item.WarehouseId, ct); }
 0680            catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException)
 681            {
 0682                _logger.LogWarning(ex, "Профит-Лига: не удалось откатить позицию {ArticleId} из корзины поставщика",
 0683                    item.ArticleId);
 0684            }
 1685        }
 1686    }
 687
 688    private static bool IsSameApiLine(ProfitLigaCartItem item, ProfitLigaCartLine line) =>
 3689        string.Equals(item.ArticleId, line.ArticleId, StringComparison.Ordinal) &&
 3690        string.Equals(item.WarehouseId, line.WarehouseId, StringComparison.Ordinal);
 691
 692    private async Task<ProfitLigaCartOperationResult> RemoveApiLinesAsync(
 693        IReadOnlyCollection<ProfitLigaCartLine> lines, CancellationToken ct)
 694    {
 11695        var removed = 0;
 11696        var messages = new List<string>();
 24697        foreach (var line in lines)
 698        {
 699            try
 700            {
 1701                var operation = await _profitLiga.RemoveFromCartAsync(line.ArticleId, line.WarehouseId, ct);
 1702                if (operation.IsSuccess) { removed++; continue; }
 1703                messages.Add($"{line.Article}: {operation.Describe()}.");
 1704            }
 0705            catch (ProfitLigaApiException ex)
 706            {
 0707                messages.Add($"{line.Article}: {ex.Message}");
 0708            }
 1709        }
 710
 11711        return new ProfitLigaCartOperationResult(removed, 0, lines.Count - removed, messages);
 11712    }
 713
 714    private async Task RestoreApiMirrorAsync(CancellationToken ct)
 715    {
 11716        var active = await _db.ProfitLigaCartItems.AsNoTracking()
 11717            .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status))
 11718            .ToListAsync(ct);
 11719        var actual = await _profitLiga.GetCartAsync(ct);
 33720        foreach (var item in active.Where(item => !actual.Any(line => IsSameApiLine(item, line))))
 721        {
 3722            var operation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity,
 3723                item.ProductCode ?? string.Empty, $"Studio2 ID {item.StudioOrderId}", ct);
 3724            if (!operation.IsSuccess)
 0725                _logger.LogWarning("Профит-Лига: не удалось восстановить API-зеркало позиции {CartItemId}: {Status}",
 0726                    item.Id, operation.Status);
 3727        }
 11728    }
 729
 730    private static ProfitLigaDeliveryMethod? ResolveMethod(ProfitLigaOrderParams parameters, ProfitLigaPoint point,
 731        string? methodId)
 732    {
 8733        if (!string.IsNullOrWhiteSpace(methodId))
 2734            return parameters.Methods.FirstOrDefault(x => string.Equals(x.Id, methodId, StringComparison.Ordinal));
 735
 736        // Точка самовывоза требует способа «самовывоз», торговая точка — доставки.
 21737        var pickup = parameters.Methods.FirstOrDefault(x => x.Name.Contains("самовывоз", StringComparison.OrdinalIgnoreC
 14738        var delivery = parameters.Methods.FirstOrDefault(x => x.Name.Contains("доставка", StringComparison.OrdinalIgnore
 7739        return point.IsPickup ? pickup ?? parameters.Methods.FirstOrDefault()
 7740                              : delivery ?? parameters.Methods.FirstOrDefault();
 741    }
 742
 743    private static ProfitLigaPaymentMethod? ResolvePayment(ProfitLigaOrderParams parameters, string? paymentId) =>
 8744        !string.IsNullOrWhiteSpace(paymentId)
 2745            ? parameters.Payments.FirstOrDefault(x => string.Equals(x.Id, paymentId, StringComparison.Ordinal))
 7746            : parameters.Payments.FirstOrDefault(x => x.Name.Contains("безнал", StringComparison.OrdinalIgnoreCase))
 8747              ?? parameters.Payments.FirstOrDefault();
 748
 749    /// <summary>Комментарий — единственный способ увидеть заказ Studio2 в интерфейсе поставщика.</summary>
 750    private static string BuildComment(ProfitLigaCartItem item)
 751    {
 8752        var label = string.IsNullOrWhiteSpace(item.StudioOrder?.ShipmentNumber)
 8753            ? $"ID {item.StudioOrderId}"
 8754            : item.StudioOrder!.ShipmentNumber;
 8755        var comment = $"Studio2 {label} (ID {item.StudioOrderId})";
 8756        return comment.Length <= ProfitLigaDataManager.CommentMaxLength
 8757            ? comment
 8758            : comment[..ProfitLigaDataManager.CommentMaxLength];
 759    }
 760
 761    private static string ItemLabel(ProfitLigaCartItem item) =>
 9762        string.IsNullOrWhiteSpace(item.StudioOrder?.ShipmentNumber)
 9763            ? $"ID {item.StudioOrderId}"
 9764            : 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        {
 14771            var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, SupplierDisplayName, LogoUrl, success, error,
 14772                item.ArticleId, item.Article, item.ProductName ?? item.Brand, item.SourcePrice, item.CurrencyCode,
 14773                item.Quantity, label, WarehouseName: item.WarehouseName, DeliveryAddressName: item.PointName,
 14774                CartStatus: cartStatus, SupplierOrderNumber: orderNumber, SupplierCartUrl: CartUrl);
 14775            await _history.AddOrderHistory(new OrderHistory
 14776            {
 14777                OrderId = item.StudioOrderId,
 14778                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 14779                ColumnDisplayName = "Корзина Профит-Лиги",
 14780                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 14781                ChangedAt = DateTime.Now,
 14782                ChangedBy = changedBy
 14783            });
 13784        }
 1785        catch (Exception ex)
 786        {
 787            // Журнал не должен ломать уже выполненную работу с корзиной поставщика.
 1788            _logger.LogWarning(ex, "Профит-Лига: не удалось записать историю по заказу {OrderId}", item.StudioOrderId);
 1789        }
 14790    }
 791
 792    private async Task TrackAsync(Func<Task> action)
 793    {
 49794        try { await action(); }
 3795        catch (Exception ex) { _logger.LogWarning(ex, "Профит-Лига: не удалось обновить состояние заказа у поставщика");
 25796    }
 797
 42798    private sealed record ProfitLigaOfferSnapshot(decimal Price, decimal Quantity, int Multi, string? ProductCode,
 11799        DateTime? DeliveryAtUtc);
 800}

Methods/Properties

.cctor()
.ctor(OzonRepositories.Context.ApplicationDbContext,Services.ApiServices.ProfitLigaApi.ProfitLigaDataManager,Services.ApiSupplierSettingsServices.ProfitLigaPointMappingService,IOrderHistoryDataService,Services.SupplierStatusServices.IOrderSupplierStateService,Services.SupplierStatusServices.IOrderSupplierStatusSyncService,Microsoft.Extensions.Logging.ILogger`1<OzonOrdersWeb.TransactionServices.ProfitLigaCartService>)
GetActiveAsync(System.Threading.CancellationToken)
CountActiveAsync(System.Threading.CancellationToken)
GetOrdersAsync(System.Threading.CancellationToken)
GetApiCartAsync(System.Threading.CancellationToken)
GetApiOrdersAsync(System.Int32,System.Threading.CancellationToken)
QueueAsync()
TestAsync()
CheckoutAsync()
CheckoutDirectAsync()
CheckoutGroupAsync()
SynchronizeOrderStatusesAsync()
CreateOrderRecordAsync()
RemoveManyAsync()
ClearApiCartAsync()
LoadForActionAsync(System.Collections.Generic.IReadOnlyCollection`1<System.Int64>,System.Threading.CancellationToken)
FindOfferAsync()
ReleaseAsync()
RollbackApiCartAsync()
IsSameApiLine(OzonDomains.Models.ProfitLigaCartItem,Services.ApiServices.ProfitLigaApi.ProfitLigaCartLine)
RemoveApiLinesAsync()
RestoreApiMirrorAsync()
ResolveMethod(Services.ApiServices.ProfitLigaApi.ProfitLigaOrderParams,Services.ApiServices.ProfitLigaApi.ProfitLigaPoint,System.String)
ResolvePayment(Services.ApiServices.ProfitLigaApi.ProfitLigaOrderParams,System.String)
BuildComment(OzonDomains.Models.ProfitLigaCartItem)
ItemLabel(OzonDomains.Models.ProfitLigaCartItem)
RecordHistoryAsync()
TrackAsync()
get_Price()
get_DeliveryAtUtc()