< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ForumAutoDirectCheckoutResult
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ForumAutoCartService.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 674
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
get_IsSuccess()100%11100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Text.Json;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Caching.Memory;
 6using Microsoft.Extensions.Logging;
 7using Newtonsoft.Json.Linq;
 8using OzonDomains.Models;
 9using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart;
 10using OzonRepositories.Context;
 11using Services.ApiServices.ForumAutoApi;
 12using Services.DataServices;
 13using Services.SupplierStatusServices;
 14
 15namespace OzonOrdersWeb.TransactionServices;
 16
 17public sealed record ForumAutoCartQueueItem(
 18    int OrderId, string ProviderKey, string Tid, string Article, string Brand, string? ProductName,
 19    string? ShipmentNumber, string? ClientName, decimal Quantity, decimal? SourcePrice, string? CurrencyCode,
 20    string? DeliveryAtUtc, int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, string? Direction = null);
 21
 22public sealed record ForumAutoCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages, st
 23{
 24    public string ToHtmlBlock()
 25    {
 26        var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>");
 27        var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty;
 28        var cartLink = ProviderKey switch
 29        {
 30            ApiSupplierSettings.ForumAutoProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?p
 31            ApiSupplierSettings.ForumAutoInterpartsProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/Supp
 32            ApiSupplierSettings.ForumAutoPiterProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/SupplierC
 33            ApiSupplierSettings.ForumAutoRostovProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/Supplier
 34            _ => string.Empty
 35        };
 36        var verificationNotice = Added > 0
 37            ? "<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'></
 38            : string.Empty;
 39        return "<div class='alert alert-info border-primary-subtle mb-0'>" +
 40               "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина Форум-Авто</
 41               $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b
 42               $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" +
 43               $"<div class='d-flex flex-wrap gap-2 mt-2'>{cartLink}</div>{verificationNotice}</div>";
 44    }
 45}
 46
 947public sealed record ForumAutoDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages);
 48
 49/// <summary>Локальная корзина Forum-Auto. У API нет отдельной корзины и нет тестового эндпоинта —
 50/// "проверка" перед заказом это свежий listGoods, а addGoodsToOrder сразу мутирует заказ поставщика.</summary>
 51public sealed class ForumAutoCartService
 52{
 53    private const string LogoUrl = "/images/forum-auto-logo.svg";
 54    private readonly ApplicationDbContext _db;
 55    private readonly ForumAutoDataManager _forumAuto;
 56    private readonly ForumAutoInterpartsDataManager _forumAutoInterparts;
 57    private readonly ForumAutoPiterDataManager _forumAutoPiter;
 58    private readonly ForumAutoRostovDataManager _forumAutoRostov;
 59    private readonly ForumAutoIstraDataManager _forumAutoIstra;
 60    private readonly IOrderHistoryDataService _history;
 61    private readonly IMemoryCache _cache;
 62    private readonly IOrderSupplierStateService _supplierState;
 63    private readonly ILogger<ForumAutoCartService> _logger;
 64
 65    public ForumAutoCartService(ApplicationDbContext db, ForumAutoDataManager forumAuto, ForumAutoInterpartsDataManager 
 66        ForumAutoPiterDataManager forumAutoPiter, ForumAutoRostovDataManager forumAutoRostov, ForumAutoIstraDataManager 
 67        IOrderSupplierStateService supplierState, ILogger<ForumAutoCartService> logger)
 68    {
 69        _db = db;
 70        _forumAuto = forumAuto;
 71        _forumAutoInterparts = forumAutoInterparts;
 72        _forumAutoPiter = forumAutoPiter;
 73        _forumAutoRostov = forumAutoRostov;
 74        _forumAutoIstra = forumAutoIstra;
 75        _history = history;
 76        _cache = cache;
 77        _supplierState = supplierState;
 78        _logger = logger;
 79    }
 80
 81    private async Task TrackCartAsync(int orderId, string providerKey, string cartStatusCode, long cartItemId, string? u
 82        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null)
 83    {
 84        try
 85        {
 86            await _supplierState.MarkInCartAsync(orderId, providerKey, cartStatusCode, cartItemId, user: user,
 87                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 88        }
 89        catch (Exception ex)
 90        {
 91            _logger.LogWarning(ex, "{Provider}: не удалось записать состояние корзины для заказа {OrderId}", providerKey
 92        }
 93    }
 94
 95    private async Task TrackOrderedAsync(int orderId, string providerKey, string? orderNumber, string? user, Cancellatio
 96        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null)
 97    {
 98        try
 99        {
 100            await _supplierState.MarkOrderedAsync(orderId, providerKey, orderNumber, user: user,
 101                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 102        }
 103        catch (Exception ex)
 104        {
 105            _logger.LogWarning(ex, "{Provider}: не удалось привязать заказ {OrderId} к заказу поставщика", providerKey, 
 106        }
 107    }
 108
 109    private async Task TrackRemovedAsync(int orderId, string providerKey, string? user, CancellationToken ct)
 110    {
 111        try
 112        {
 113            await _supplierState.RemoveFromCartAsync(orderId, providerKey, user, ct);
 114        }
 115        catch (Exception ex)
 116        {
 117            _logger.LogWarning(ex, "{Provider}: не удалось закрыть состояние корзины для заказа {OrderId}", providerKey,
 118        }
 119    }
 120
 121    public Task<List<ForumAutoCartItem>> GetActiveAsync(string providerKey, CancellationToken ct = default) =>
 122        _db.ForumAutoCartItems.AsNoTracking().Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo)
 123            .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer)
 124            .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 125            .Where(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Contains(x.Status))
 126            .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 127
 128    public Task<List<ForumAutoOrder>> GetOrdersAsync(string providerKey, CancellationToken ct = default) =>
 129        _db.ForumAutoOrders.AsNoTracking().Include(x => x.Items).ThenInclude(x => x.StudioOrder)
 130            .Where(x => x.ProviderKey == providerKey).OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 131
 132    public Task<int> CountActiveAsync(CancellationToken ct = default) =>
 133        _db.ForumAutoCartItems.CountAsync(x => ForumAutoCartItemStatus.Active.Contains(x.Status), ct);
 134
 135    public async Task<ForumAutoCartOperationResult> QueueAsync(IReadOnlyCollection<ForumAutoCartQueueItem> queue, string
 136    {
 137        var messages = new List<string>();
 138        var added = 0; var skipped = 0; var failed = 0;
 139        string? providerKey = null;
 140        foreach (var item in queue)
 141        {
 142            providerKey ??= item.ProviderKey;
 143            if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 ||
 144                string.IsNullOrWhiteSpace(item.Tid) || string.IsNullOrWhiteSpace(item.Article) || string.IsNullOrWhiteSp
 145            {
 146                failed++;
 147                messages.Add($"{Describe(item)} — неполные данные предложения Форум-Авто.");
 148                continue;
 149            }
 150            var exists = await _db.ForumAutoCartItems.AnyAsync(
 151                x => x.ProviderKey == item.ProviderKey && x.StudioOrderId == item.OrderId && ForumAutoCartItemStatus.Act
 152            if (exists) { skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.")
 153
 154            var cartItem = new ForumAutoCartItem
 155            {
 156                ProviderKey = item.ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId,
 157                Tid = item.Tid, Article = item.Article, Brand = item.Brand, ProductName = item.ProductName,
 158                Quantity = item.Quantity, SourcePrice = item.SourcePrice, CurrencyCode = item.CurrencyCode ?? "RUB",
 159                DeliveryAtUtc = ParseApiDate(item.DeliveryAtUtc), CreatedBy = changedBy
 160            };
 161            _db.ForumAutoCartItems.Add(cartItem);
 162            try
 163            {
 164                await _db.SaveChangesAsync(ct);
 165            }
 166            catch (DbUpdateException)
 167            {
 168                _db.Entry(cartItem).State = EntityState.Detached;
 169                skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.");
 170                continue;
 171            }
 172            added++;
 173            messages.Add($"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}; требуется проверка п
 174            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending, supplierOrderN
 175            await TrackCartAsync(item.OrderId, item.ProviderKey, ForumAutoCartItemStatus.Pending, cartItem.Id, changedBy
 176                item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 177        }
 178        InvalidateCount();
 179        return new ForumAutoCartOperationResult(added, skipped, failed, messages, providerKey);
 180    }
 181
 182    /// <summary>Репрайсинг перед заказом: у Forum-Auto нет тестового эндпоинта, поэтому "проверка" —
 183    /// это свежий listGoods по исходному артикулу и сверка найденной строки с нашим tid (=gid).</summary>
 184    private async Task<(bool Ok, string? Message, decimal? NewPrice, int? NewRest)> RepriceAsync(
 185        string providerKey, string article, string brand, string tid, decimal quantity, CancellationToken ct)
 186    {
 187        var client = ResolveClient(providerKey);
 188        var response = await client.ListGoodsAsync(article, brand, cross: false, ct: ct);
 189        var items = ForumAutoPriceMapper.Map(response, article, null, null, providerKey);
 190        var match = items.FirstOrDefault(x => string.Equals(x.ItemId, tid, StringComparison.Ordinal));
 191        if (match is null) return (false, "снят с продажи или изменился gid — выполните проценку заново", null, null);
 192        if (!int.TryParse(match.Rest, out var rest) || rest < quantity)
 193            return (false, $"недостаточно остатка ({match.Rest ?? "0"} < {quantity:0.###})", match.SourcePrice, null);
 194        return (true, null, match.SourcePrice, rest);
 195    }
 196
 197    public async Task<ForumAutoCartOperationResult> TestAsync(string providerKey, string changedBy, IReadOnlyCollection<
 198    {
 199        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 200        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 201        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 202        if (selectedIds is not null && items.Count != selectedIds.Count)
 203            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите проверку."]);
 204        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 205
 206        var ready = 0; var failed = 0; var messages = new List<string>();
 207        foreach (var item in items)
 208        {
 209            try
 210            {
 211                var (ok, message, newPrice, newRest) = await RepriceAsync(providerKey, item.Article, item.Brand, item.Ti
 212                item.TestedAtUtc = DateTime.UtcNow; item.UpdatedAtUtc = DateTime.UtcNow;
 213                if (ok)
 214                {
 215                    string priceNote;
 216                    if (newPrice.HasValue && newPrice.Value != item.SourcePrice)
 217                        priceNote = $" Цена обновлена: {item.SourcePrice?.ToString("0.##", CultureInfo.InvariantCulture)
 218                    else
 219                        priceNote = string.Empty;
 220                    if (newPrice.HasValue) item.SourcePrice = newPrice;
 221                    item.Status = ForumAutoCartItemStatus.Ready;
 222                    item.LastError = null;
 223                    ready++;
 224                    messages.Add($"{item.Article}: проверено у поставщика, остаток {newRest}.{priceNote}");
 225                    await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), true, null, ApiSupplierBasketCartSta
 226                }
 227                else
 228                {
 229                    item.Status = ForumAutoCartItemStatus.Failed;
 230                    item.LastError = $"{message} для арт. {item.Article} · {item.Brand}.";
 231                    failed++;
 232                    messages.Add(item.LastError);
 233                    await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), false, item.LastError, ApiSupplierBa
 234                }
 235            }
 236            catch (ForumAutoApiException ex)
 237            {
 238                item.Status = ForumAutoCartItemStatus.Failed; item.LastError = ex.Message;
 239                item.TestedAtUtc = DateTime.UtcNow; item.UpdatedAtUtc = DateTime.UtcNow;
 240                failed++; messages.Add($"{item.Article}: {ex.Message}");
 241                await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), false, ex.Message, ApiSupplierBasketCart
 242            }
 243        }
 244        await _db.SaveChangesAsync(ct);
 245        return new(ready, 0, failed, messages, providerKey);
 246    }
 247
 248    public async Task<ForumAutoCartOperationResult> CheckoutAsync(string providerKey, string changedBy, IReadOnlyCollect
 249    {
 250        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 251        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 252        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 253        if (selectedIds is not null && items.Count != selectedIds.Count)
 254            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите оформление."])
 255        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 256        if (items.Any(x => x.Status != ForumAutoCartItemStatus.Ready || x.TestedAtUtc is null || x.TestedAtUtc < DateTim
 257            return new(0, 0, items.Count, ["Перед оформлением выполните свежую проверку Форум-Авто."]);
 258
 259        var ids = items.Select(x => x.Id).ToArray();
 260        var locked = await _db.ForumAutoCartItems.Where(x => ids.Contains(x.Id) && x.Status == ForumAutoCartItemStatus.R
 261            .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ForumAutoCartItemStatus.Processing).SetProperty(x => x
 262        if (locked != items.Count) return new(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]);
 263
 264        var lines = items.Select(item => new ForumAutoPlacementLine(item.Id, item.StudioOrderId, item.Tid, item.Article,
 265            item.ProductName, item.Quantity, item.SourcePrice, item.CurrencyCode, null, null, null, null, null)).ToList(
 266        var (success, failed, messages) = await PlaceOrderItemsAsync(providerKey, lines, changedBy, ct);
 267        InvalidateCount();
 268        return new(success, 0, failed, messages, providerKey);
 269    }
 270
 271    /// <summary>Оформляет позиции без локальной корзины. Вызывается до проведения статусов Studio2.</summary>
 272    public async Task<ForumAutoDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ForumAutoCartQueueItem> que
 273    {
 274        if (queue.Count == 0) return new(true, []);
 275        if (queue.GroupBy(x => new { x.ProviderKey, x.OrderId }).Any(group => group.Count() > 1))
 276            return new(false, ["Для одного заказа выбрано несколько предложений Форум-Авто."]);
 277
 278        var messages = new List<string>();
 279        foreach (var group in queue.GroupBy(x => x.ProviderKey))
 280        {
 281            if (!IsProvider(group.Key)) return new(false, ["Неизвестный аккаунт Форум-Авто."]);
 282            var lines = new List<ForumAutoPlacementLine>();
 283            foreach (var item in group)
 284            {
 285                if (item.OrderId <= 0 || item.Quantity <= 0 || string.IsNullOrWhiteSpace(item.Tid) ||
 286                    string.IsNullOrWhiteSpace(item.Article) || string.IsNullOrWhiteSpace(item.Brand))
 287                    return new(false, [$"{Describe(item)}: неполные данные предложения Форум-Авто."]);
 288
 289                var (ok, message, newPrice, _) = await RepriceAsync(item.ProviderKey, item.Article, item.Brand, item.Tid
 290                if (!ok) return new(false, [$"{item.Article}: {message}."]);
 291
 292                lines.Add(new ForumAutoPlacementLine(null, item.OrderId, item.Tid, item.Article, item.Brand, item.Produc
 293                    item.Quantity, newPrice ?? item.SourcePrice, item.CurrencyCode ?? "RUB", item.ShipmentNumber, item.C
 294                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction));
 295            }
 296            var (success, failed, placeMessages) = await PlaceOrderItemsAsync(group.Key, lines, changedBy, ct);
 297            messages.AddRange(placeMessages);
 298            if (failed > 0) return new(false, messages);
 299        }
 300        InvalidateCount();
 301        return new(true, messages);
 302    }
 303
 304    private sealed record ForumAutoPlacementLine(long? CartItemId, int StudioOrderId, string Tid, string Article, string
 305        string? ProductName, decimal Quantity, decimal? SourcePrice, string? CurrencyCode, string? ShipmentNumber, strin
 306        int? DeliveryDaysMin, int? DeliveryDaysMax, string? Direction);
 307
 308    private static bool IsDuplicateDidException(Exception ex)
 309    {
 310        var msg = ex.InnerException?.Message ?? ex.Message;
 311        return msg.Contains("UX_ForumAutoOrders_Provider_Did", StringComparison.OrdinalIgnoreCase)
 312            || msg.Contains("ForumAutoOrders.ProviderKey", StringComparison.OrdinalIgnoreCase)
 313            || msg.Contains("1062", StringComparison.OrdinalIgnoreCase);
 314    }
 315
 316    /// <summary>Общий алгоритм размещения: создаёт заказ Форум-Авто (наш eoid = Id новой строки ForumAutoOrders),
 317    /// последовательно вызывает addGoodsToOrder по каждой позиции — первый успешный ответ даёт реальный did.
 318    /// Ошибка одной позиции не откатывает остальные (в отличие от Armtek — здесь один вызов на позицию).</summary>
 319    private async Task<(int Success, int Failed, List<string> Messages)> PlaceOrderItemsAsync(
 320        string providerKey, IReadOnlyList<ForumAutoPlacementLine> lines, string changedBy, CancellationToken ct)
 321    {
 322        var client = ResolveClient(providerKey);
 323        var order = new ForumAutoOrder { ProviderKey = providerKey, Status = "Creating", CreatedBy = changedBy };
 324        _db.ForumAutoOrders.Add(order);
 325        await _db.SaveChangesAsync(ct);
 326        order.Eoid = order.Id;
 327        await _db.SaveChangesAsync(ct);
 328
 329        var success = 0; var failed = 0; var messages = new List<string>();
 330        var seenEids = new HashSet<int>();
 331        foreach (var line in lines)
 332        {
 333            try
 334            {
 335                var response = await client.AddGoodsToOrderAsync(line.Tid, (int)Math.Round(line.Quantity), eid: null, eo
 336
 337                var orderArray = (response as JArray)?.OfType<JObject>().ToList()
 338                    ?? (response is JObject obj ? new List<JObject> { obj } : null)
 339                    ?? throw new ForumAutoApiException("Forum-Auto вернул неожиданный формат ответа addGoodsToOrder.");
 340
 341                // Ищем целевой заказ в ответе поставщика:
 342                // 1) Если did уже известен для текущего заказа — ищем по did
 343                // 2) Если у нас есть eoid — ищем по eoid (число или строка)
 344                // 3) Ищем заказ, в arTovs которого присутствует только что добавленный line.Tid (и eid не был учтен ран
 345                // 4) Если не удалось найти точным сопоставлением — выбираем заказ с максимальным did (наиболее свежий),
 346                var orderObj = (order.Did.HasValue ? orderArray.FirstOrDefault(x => x.Value<int?>("did") == order.Did.Va
 347                    ?? (order.Eoid > 0 ? orderArray.FirstOrDefault(x => x.Value<long?>("eoid") == order.Eoid || string.E
 348                    ?? orderArray.FirstOrDefault(x => (x["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()).An
 349                    ?? orderArray.OrderByDescending(x => x.Value<int?>("did") ?? 0).FirstOrDefault()
 350                    ?? throw new ForumAutoApiException("Forum-Auto вернул неожиданный формат ответа addGoodsToOrder.");
 351
 352                order.ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None);
 353                var did = orderObj.Value<int?>("did");
 354                if (did.HasValue)
 355                {
 356                    if (!order.Did.HasValue)
 357                    {
 358                        // Проверяем, существует ли уже заказ с таким (ProviderKey, Did) в базе данных
 359                        var existingOrder = await _db.ForumAutoOrders
 360                            .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value && x.Id != orde
 361
 362                        if (existingOrder != null)
 363                        {
 364                            // Поставщик привязал позицию к уже существующему заказу (например, дневной сводный заказ).
 365                            // Перепривязываем элементы к существующему заказу и удаляем черновой заказ-плейсхолдер.
 366                            foreach (var itemEntry in _db.ChangeTracker.Entries<ForumAutoOrderItem>()
 367                                .Where(e => e.Entity.ForumAutoOrderId == order.Id))
 368                            {
 369                                itemEntry.Entity.ForumAutoOrderId = existingOrder.Id;
 370                            }
 371
 372                            _db.ForumAutoOrders.Remove(order);
 373                            order = existingOrder;
 374                            order.ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None);
 375                        }
 376                        else
 377                        {
 378                            order.Did = did.Value;
 379                        }
 380                    }
 381                    else if (order.Did.Value != did.Value)
 382                    {
 383                        // Поставщик разбил заказ на другой did (например, другой склад отгрузки)
 384                        var splitOrder = await _db.ForumAutoOrders
 385                            .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value, ct);
 386
 387                        if (splitOrder == null)
 388                        {
 389                            splitOrder = new ForumAutoOrder
 390                            {
 391                                ProviderKey = providerKey,
 392                                Did = did.Value,
 393                                Eoid = order.Eoid,
 394                                Status = "Creating",
 395                                CreatedBy = changedBy,
 396                                ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None)
 397                            };
 398                            _db.ForumAutoOrders.Add(splitOrder);
 399                            await _db.SaveChangesAsync(ct);
 400                        }
 401                        order = splitOrder;
 402                    }
 403                }
 404
 405                var matchedRow = (orderObj["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>())
 406                    .Where(x => string.Equals(x.Value<string>("gid"), line.Tid, StringComparison.Ordinal))
 407                    .FirstOrDefault(x => !seenEids.Contains(x.Value<int?>("eid") ?? -1));
 408                if (matchedRow is not null) seenEids.Add(matchedRow.Value<int?>("eid") ?? -1);
 409                var eid = matchedRow?.Value<int?>("eid");
 410                var price = matchedRow?.Value<decimal?>("price") ?? line.SourcePrice;
 411
 412                _db.ForumAutoOrderItems.Add(new ForumAutoOrderItem
 413                {
 414                    ForumAutoOrderId = order.Id, ForumAutoCartItemId = line.CartItemId, StudioOrderId = line.StudioOrder
 415                    Eid = eid, Tid = line.Tid, Brand = line.Brand, Quantity = line.Quantity, Price = price, CurrencyCode
 416                });
 417
 418                if (line.CartItemId.HasValue)
 419                {
 420                    var cartItem = await _db.ForumAutoCartItems.FirstAsync(x => x.Id == line.CartItemId.Value, ct);
 421                    cartItem.Status = ForumAutoCartItemStatus.Ordered; cartItem.ActiveStudioOrderId = null;
 422                    cartItem.LastError = null; cartItem.UpdatedAtUtc = DateTime.UtcNow;
 423                }
 424
 425                try
 426                {
 427                    await _db.SaveChangesAsync(ct);
 428                }
 429                catch (DbUpdateException dbEx) when (IsDuplicateDidException(dbEx) && did.HasValue)
 430                {
 431                    _logger.LogWarning(dbEx, "Конкурентное создание ForumAutoOrder для Did {Did}, перепривязываем к суще
 432                    var winningOrder = await _db.ForumAutoOrders.AsTracking()
 433                        .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value && x.Id != order.Id
 434
 435                    if (winningOrder != null)
 436                    {
 437                        foreach (var itemEntry in _db.ChangeTracker.Entries<ForumAutoOrderItem>()
 438                            .Where(e => e.Entity.ForumAutoOrderId == order.Id))
 439                        {
 440                            itemEntry.Entity.ForumAutoOrderId = winningOrder.Id;
 441                        }
 442
 443                        _db.Entry(order).State = EntityState.Detached;
 444                        await _db.ForumAutoOrders.Where(x => x.Id == order.Id).ExecuteDeleteAsync(ct);
 445                        order = winningOrder;
 446                        await _db.SaveChangesAsync(ct);
 447                    }
 448                    else
 449                    {
 450                        throw;
 451                    }
 452                }
 453
 454                success++;
 455                var orderLabel = order.Did.HasValue ? order.Did.Value.ToString(CultureInfo.InvariantCulture) : $"eoid {o
 456                messages.Add($"{line.Article}: заказ Форум-Авто {orderLabel} создан.");
 457                await RecordHistoryAsync(line.StudioOrderId, ToQueueItem(providerKey, line), true, null, ApiSupplierBask
 458                await TrackOrderedAsync(line.StudioOrderId, providerKey, orderLabel, changedBy, ct, line.DeliveryDaysMin
 459            }
 460            catch (Exception ex) when (ex is ForumAutoApiException or HttpRequestException or DbUpdateException)
 461            {
 462                failed++;
 463                messages.Add($"{line.Article}: {ex.Message}");
 464                if (line.CartItemId.HasValue)
 465                {
 466                    var cartItem = await _db.ForumAutoCartItems.FirstOrDefaultAsync(x => x.Id == line.CartItemId.Value, 
 467                    if (cartItem != null)
 468                    {
 469                        cartItem.Status = ForumAutoCartItemStatus.Failed; cartItem.LastError = ex.Message; cartItem.Upda
 470                    }
 471                }
 472                await RecordHistoryAsync(line.StudioOrderId, ToQueueItem(providerKey, line), false, ex.Message, ApiSuppl
 473            }
 474        }
 475
 476        if (_db.Entry(order).State != EntityState.Detached)
 477        {
 478            order.Status = success == 0 ? "Failed" : failed == 0 ? "Created" : "PartiallyFailed";
 479            await _db.SaveChangesAsync(ct);
 480        }
 481        return (success, failed, messages);
 482    }
 483
 484    /// <summary>Отменяет заказ у поставщика целиком (без восстановления). Пока без обязательной кнопки в UI.</summary>
 485    public async Task<bool> CancelOrderAsync(string providerKey, long localOrderId, string changedBy, CancellationToken 
 486    {
 487        var order = await _db.ForumAutoOrders.FirstOrDefaultAsync(x => x.Id == localOrderId && x.ProviderKey == provider
 488        if (order?.Did is null) return false;
 489        var client = ResolveClient(providerKey);
 490        var response = await client.CancelOrderAsync(order.Did.Value, ct);
 491        if (response.Value<int?>("status") != 1) return false;
 492        order.Status = "Cancelled";
 493        await _db.SaveChangesAsync(ct);
 494        return true;
 495    }
 496
 497    public async Task<bool> RemoveAsync(long id, string providerKey, CancellationToken ct = default)
 498    {
 499        var item = await _db.ForumAutoCartItems.FirstOrDefaultAsync(x => x.Id == id && x.ProviderKey == providerKey && F
 500        if (item is null) return false;
 501        item.Status = ForumAutoCartItemStatus.Removed; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.Utc
 502        await _db.SaveChangesAsync(ct);
 503        await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 504        InvalidateCount(); return true;
 505    }
 506
 507    /// <summary>Убирает из корзины выбранные строки (или всю корзину провайдера, если ids не заданы).</summary>
 508    public async Task<ForumAutoCartOperationResult> RemoveManyAsync(string providerKey, IReadOnlyCollection<long>? cartI
 509    {
 510        if (!IsProvider(providerKey)) return new(0, 0, 0, ["Неизвестный аккаунт Форум-Авто."]);
 511        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 512        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 513
 514        var items = (await GetTrackedActiveAsync(providerKey, selectedIds, ct))
 515            .Where(x => x.Status != ForumAutoCartItemStatus.Processing)
 516            .ToList();
 517        foreach (var item in items)
 518        {
 519            item.Status = ForumAutoCartItemStatus.Removed;
 520            item.ActiveStudioOrderId = null;
 521            item.UpdatedAtUtc = DateTime.UtcNow;
 522        }
 523        await _db.SaveChangesAsync(ct);
 524        foreach (var item in items) await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 525        var removed = items.Count;
 526        InvalidateCount();
 527        if (removed == 0) return new(0, 0, 0, ["Нечего удалять: строки уже удалены или сейчас оформляются."]);
 528        var skipped = selectedIds is null ? 0 : selectedIds.Count - removed;
 529        var messages = new List<string> { $"Удалено из корзины {ProviderName(providerKey)}: {removed}." };
 530        if (skipped > 0) messages.Add($"Пропущено (оформляются или уже удалены): {skipped}.");
 531        return new(removed, skipped, 0, messages, providerKey);
 532    }
 533
 534    private Task<List<ForumAutoCartItem>> GetTrackedActiveAsync(string providerKey, IReadOnlyCollection<long>? cartItemI
 535    {
 536        var query = _db.ForumAutoCartItems.Where(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Con
 537        if (cartItemIds is not null) query = query.Where(x => cartItemIds.Contains(x.Id));
 538        return query.OrderBy(x => x.Id).ToListAsync(ct);
 539    }
 540
 541    /// <summary>
 542    /// Аннулирует у Форум-Авто заказ (<c>cancelOrder</c> по did), в котором лежит заказ Studio2, отменённый в Ozon.
 543    /// Метод отменяет did целиком, поэтому вызывается, только если все заказы Studio2 этого did входят в
 544    /// <paramref name="cancellableOrderIds"/>; иначе — <see cref="SupplierOrderCancelOutcome.Failed"/> с просьбой снять
 545    /// </summary>
 546    public async Task<SupplierOrderCancelResult> CancelOrderAsync(string providerKey, int orderId, IReadOnlySet<int> can
 547        CancellationToken ct = default)
 548    {
 549        if (!IsProvider(providerKey)) return SupplierOrderCancelResult.Failed($"Неизвестный аккаунт Форум-Авто: {provide
 550        var dids = await _db.ForumAutoOrderItems.AsNoTracking()
 551            .Where(x => x.StudioOrderId == orderId && x.ForumAutoOrder != null && x.ForumAutoOrder.ProviderKey == provid
 552            .Select(x => x.ForumAutoOrder!.Did!.Value).Distinct().ToListAsync(ct);
 553        if (dids.Count == 0) return SupplierOrderCancelResult.Failed("в Studio2 нет номера заказа Форум-Авто (did).");
 554
 555        var neighbours = await _db.ForumAutoOrderItems.AsNoTracking()
 556            .Where(x => x.ForumAutoOrder != null && x.ForumAutoOrder.ProviderKey == providerKey && x.ForumAutoOrder.Did 
 557                        && dids.Contains(x.ForumAutoOrder.Did.Value) && x.StudioOrderId != orderId)
 558            .Select(x => x.StudioOrderId).Distinct().ToListAsync(ct);
 559        var live = neighbours.Where(x => !cancellableOrderIds.Contains(x)).ToList();
 560        if (live.Count > 0)
 561            return SupplierOrderCancelResult.Failed(
 562                $"в заказе {string.Join(", ", dids)} есть другие заказы Studio2 ({string.Join(", ", live.Select(x => "#"
 563
 564        var client = ResolveClient(providerKey);
 565        var cancelled = new List<int>();
 566        foreach (var did in dids)
 567        {
 568            try
 569            {
 570                await client.CancelOrderAsync(did, ct);
 571                cancelled.Add(did);
 572            }
 573            catch (ForumAutoApiException ex)
 574            {
 575                var done = cancelled.Count > 0 ? $"; уже аннулированы: {string.Join(", ", cancelled)}" : string.Empty;
 576                return SupplierOrderCancelResult.Failed($"заказ {did}: {ex.Message}{done}");
 577            }
 578        }
 579        return SupplierOrderCancelResult.Cancelled($"заказ {string.Join(", ", cancelled)}: аннулирован");
 580    }
 581
 582    private ForumAutoDataManager ResolveClient(string providerKey) => providerKey switch
 583    {
 584        ApiSupplierSettings.ForumAutoProviderKey => _forumAuto,
 585        ApiSupplierSettings.ForumAutoInterpartsProviderKey => _forumAutoInterparts,
 586        ApiSupplierSettings.ForumAutoPiterProviderKey => _forumAutoPiter,
 587        ApiSupplierSettings.ForumAutoRostovProviderKey => _forumAutoRostov,
 588        ApiSupplierSettings.ForumAutoIstraProviderKey => _forumAutoIstra,
 589        _ => throw new ArgumentException("Неизвестный аккаунт Форум-Авто.", nameof(providerKey))
 590    };
 591
 592    private static bool IsProvider(string key) => key is ApiSupplierSettings.ForumAutoProviderKey or ApiSupplierSettings
 593
 594    private static string ProviderName(string key) => key switch
 595    {
 596        ApiSupplierSettings.ForumAutoInterpartsProviderKey => "Форум-Авто (Интерпартс)",
 597        ApiSupplierSettings.ForumAutoPiterProviderKey => "Форум-Авто (Питер)",
 598        ApiSupplierSettings.ForumAutoRostovProviderKey => "Форум-Авто (Ростов)",
 599        ApiSupplierSettings.ForumAutoIstraProviderKey => "Форум-Авто (Истра)",
 600        _ => "Форум-Авто (Союз)"
 601    };
 602
 603    private static string CartUrl(string providerKey) => providerKey switch
 604    {
 605        ApiSupplierSettings.ForumAutoInterpartsProviderKey => "/SupplierCart?provider=forumautointerparts&supplierTab=ba
 606        ApiSupplierSettings.ForumAutoPiterProviderKey => "/SupplierCart?provider=forumautopiter&supplierTab=basket",
 607        ApiSupplierSettings.ForumAutoRostovProviderKey => "/SupplierCart?provider=forumautorostov&supplierTab=basket",
 608        ApiSupplierSettings.ForumAutoIstraProviderKey => "/SupplierCart?provider=forumautoistra&supplierTab=basket",
 609        _ => "/SupplierCart?provider=forumauto&supplierTab=basket"
 610    };
 611
 612    private static string? ValidateSelection(IReadOnlyCollection<long>? ids, out IReadOnlyCollection<long>? normalizedId
 613    {
 614        if (ids is null) { normalizedIds = null; return null; }
 615        normalizedIds = ids.Where(id => id > 0).Distinct().ToArray();
 616        return normalizedIds.Count == 0 ? "Выберите хотя бы одну позицию корзины." : null;
 617    }
 618
 619    private static string Describe(ForumAutoCartQueueItem item)
 620    {
 621        var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber;
 622        var product = string.IsNullOrWhiteSpace(item.ProductName) ? item.Article : item.ProductName;
 623        var client = string.IsNullOrWhiteSpace(item.ClientName) ? string.Empty : $", {item.ClientName}";
 624        return $"{item.Article}: заказ {order}, {product}{client}";
 625    }
 626
 627    private static ForumAutoCartQueueItem ToQueueItem(ForumAutoCartItem item) => new(
 628        item.StudioOrderId, item.ProviderKey, item.Tid, item.Article, item.Brand, item.ProductName,
 629        item.StudioOrder?.ShipmentNumber, null, item.Quantity, item.SourcePrice, item.CurrencyCode,
 630        item.DeliveryAtUtc?.ToString("O", CultureInfo.InvariantCulture));
 631
 632    private static ForumAutoCartQueueItem ToQueueItem(string providerKey, ForumAutoPlacementLine line) => new(
 633        line.StudioOrderId, providerKey, line.Tid, line.Article, line.Brand, line.ProductName,
 634        line.ShipmentNumber, line.ClientName, line.Quantity, line.SourcePrice, line.CurrencyCode, null,
 635        line.DeliveryDaysMin, line.DeliveryDaysMax, line.Direction);
 636
 637    /// <summary>Пишет запись в orders_history в общей форме API-поставщиков. Ошибка журнала не должна
 638    /// ломать уже выполненное размещение — строка корзины к этому моменту уже сохранена.</summary>
 639    private async Task RecordHistoryAsync(int orderId, ForumAutoCartQueueItem item, bool success, string? error,
 640        string cartStatus, string? supplierOrderNumber, string changedBy, CancellationToken ct)
 641    {
 642        try
 643        {
 644            var displayName = ProviderName(item.ProviderKey);
 645            var entry = new ApiSupplierBasketHistoryEntry(
 646                item.ProviderKey, displayName, LogoUrl, success, error,
 647                ApiItemId: item.Tid, Article: item.Article, ProductName: item.ProductName ?? item.Brand,
 648                Price: item.SourcePrice, CurrencyCode: item.CurrencyCode, Quantity: item.Quantity,
 649                ShipmentNumber: item.ShipmentNumber, ClientName: item.ClientName,
 650                CartStatus: cartStatus, SupplierOrderNumber: supplierOrderNumber, SupplierCartUrl: CartUrl(item.Provider
 651
 652            await _history.AddOrderHistory(new OrderHistory
 653            {
 654                OrderId = orderId,
 655                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 656                ColumnDisplayName = $"Корзина {displayName}",
 657                OldValue = null,
 658                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 659                ChangedAt = DateTime.Now,
 660                ChangedBy = changedBy
 661            });
 662        }
 663        catch
 664        {
 665            // Не ломаем размещение в корзине из-за журнала истории.
 666        }
 667    }
 668
 669    private static DateTime? ParseApiDate(string? value) =>
 670        DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compa
 671        DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed) ? parse
 672
 673    private void InvalidateCount() => _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 674}

Methods/Properties

get_IsSuccess()