< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ForumAutoCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ForumAutoCartService.cs
Line coverage
70%
Covered lines: 283
Uncovered lines: 116
Coverable lines: 399
Total lines: 674
Line coverage: 70.9%
Branch coverage
58%
Covered branches: 149
Total branches: 256
Branch coverage: 58.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
TrackCartAsync()100%1157.14%
TrackOrderedAsync()100%1157.14%
TrackRemovedAsync()100%1150%
GetActiveAsync(...)100%210%
GetOrdersAsync(...)100%210%
CountActiveAsync(...)100%210%
QueueAsync()65%232080.55%
RepriceAsync()75%88100%
TestAsync()66.66%292480%
CheckoutAsync()58.33%121292.85%
CheckoutDirectAsync()60.71%292890.9%
get_CartItemId()100%11100%
get_ProductName()100%11100%
get_DeliveryDaysMin()100%11100%
IsDuplicateDidException(...)0%7280%
PlaceOrderItemsAsync()60.81%2847466.3%
CancelOrderAsync()50%66100%
RemoveAsync()0%620%
RemoveManyAsync()64.28%1414100%
GetTrackedActiveAsync(...)50%22100%
CancelOrderAsync()0%110100%
ResolveClient(...)60%191055.55%
IsProvider(...)90%1010100%
ProviderName(...)62.5%11862.5%
CartUrl(...)62.5%11862.5%
ValidateSelection(...)25%9433.33%
Describe(...)50%66100%
ToQueueItem(...)50%44100%
ToQueueItem(...)100%11100%
RecordHistoryAsync()100%2290.47%
ParseApiDate(...)50%44100%
InvalidateCount()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
 47public 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
 1365    public ForumAutoCartService(ApplicationDbContext db, ForumAutoDataManager forumAuto, ForumAutoInterpartsDataManager 
 1366        ForumAutoPiterDataManager forumAutoPiter, ForumAutoRostovDataManager forumAutoRostov, ForumAutoIstraDataManager 
 1367        IOrderSupplierStateService supplierState, ILogger<ForumAutoCartService> logger)
 68    {
 1369        _db = db;
 1370        _forumAuto = forumAuto;
 1371        _forumAutoInterparts = forumAutoInterparts;
 1372        _forumAutoPiter = forumAutoPiter;
 1373        _forumAutoRostov = forumAutoRostov;
 1374        _forumAutoIstra = forumAutoIstra;
 1375        _history = history;
 1376        _cache = cache;
 1377        _supplierState = supplierState;
 1378        _logger = logger;
 1379    }
 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        {
 286            await _supplierState.MarkInCartAsync(orderId, providerKey, cartStatusCode, cartItemId, user: user,
 287                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 288        }
 089        catch (Exception ex)
 90        {
 091            _logger.LogWarning(ex, "{Provider}: не удалось записать состояние корзины для заказа {OrderId}", providerKey
 092        }
 293    }
 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        {
 5100            await _supplierState.MarkOrderedAsync(orderId, providerKey, orderNumber, user: user,
 5101                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 5102        }
 0103        catch (Exception ex)
 104        {
 0105            _logger.LogWarning(ex, "{Provider}: не удалось привязать заказ {OrderId} к заказу поставщика", providerKey, 
 0106        }
 5107    }
 108
 109    private async Task TrackRemovedAsync(int orderId, string providerKey, string? user, CancellationToken ct)
 110    {
 111        try
 112        {
 1113            await _supplierState.RemoveFromCartAsync(orderId, providerKey, user, ct);
 1114        }
 0115        catch (Exception ex)
 116        {
 0117            _logger.LogWarning(ex, "{Provider}: не удалось закрыть состояние корзины для заказа {OrderId}", providerKey,
 0118        }
 1119    }
 120
 121    public Task<List<ForumAutoCartItem>> GetActiveAsync(string providerKey, CancellationToken ct = default) =>
 0122        _db.ForumAutoCartItems.AsNoTracking().Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo)
 0123            .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer)
 0124            .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 0125            .Where(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Contains(x.Status))
 0126            .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 127
 128    public Task<List<ForumAutoOrder>> GetOrdersAsync(string providerKey, CancellationToken ct = default) =>
 0129        _db.ForumAutoOrders.AsNoTracking().Include(x => x.Items).ThenInclude(x => x.StudioOrder)
 0130            .Where(x => x.ProviderKey == providerKey).OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 131
 132    public Task<int> CountActiveAsync(CancellationToken ct = default) =>
 0133        _db.ForumAutoCartItems.CountAsync(x => ForumAutoCartItemStatus.Active.Contains(x.Status), ct);
 134
 135    public async Task<ForumAutoCartOperationResult> QueueAsync(IReadOnlyCollection<ForumAutoCartQueueItem> queue, string
 136    {
 3137        var messages = new List<string>();
 9138        var added = 0; var skipped = 0; var failed = 0;
 3139        string? providerKey = null;
 12140        foreach (var item in queue)
 141        {
 3142            providerKey ??= item.ProviderKey;
 3143            if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 ||
 3144                string.IsNullOrWhiteSpace(item.Tid) || string.IsNullOrWhiteSpace(item.Article) || string.IsNullOrWhiteSp
 145            {
 0146                failed++;
 0147                messages.Add($"{Describe(item)} — неполные данные предложения Форум-Авто.");
 0148                continue;
 149            }
 3150            var exists = await _db.ForumAutoCartItems.AnyAsync(
 3151                x => x.ProviderKey == item.ProviderKey && x.StudioOrderId == item.OrderId && ForumAutoCartItemStatus.Act
 6152            if (exists) { skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.")
 153
 2154            var cartItem = new ForumAutoCartItem
 2155            {
 2156                ProviderKey = item.ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId,
 2157                Tid = item.Tid, Article = item.Article, Brand = item.Brand, ProductName = item.ProductName,
 2158                Quantity = item.Quantity, SourcePrice = item.SourcePrice, CurrencyCode = item.CurrencyCode ?? "RUB",
 2159                DeliveryAtUtc = ParseApiDate(item.DeliveryAtUtc), CreatedBy = changedBy
 2160            };
 2161            _db.ForumAutoCartItems.Add(cartItem);
 162            try
 163            {
 2164                await _db.SaveChangesAsync(ct);
 2165            }
 0166            catch (DbUpdateException)
 167            {
 0168                _db.Entry(cartItem).State = EntityState.Detached;
 0169                skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.");
 0170                continue;
 171            }
 2172            added++;
 2173            messages.Add($"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}; требуется проверка п
 2174            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending, supplierOrderN
 2175            await TrackCartAsync(item.OrderId, item.ProviderKey, ForumAutoCartItemStatus.Pending, cartItem.Id, changedBy
 2176                item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 2177        }
 3178        InvalidateCount();
 3179        return new ForumAutoCartOperationResult(added, skipped, failed, messages, providerKey);
 3180    }
 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    {
 6187        var client = ResolveClient(providerKey);
 6188        var response = await client.ListGoodsAsync(article, brand, cross: false, ct: ct);
 6189        var items = ForumAutoPriceMapper.Map(response, article, null, null, providerKey);
 12190        var match = items.FirstOrDefault(x => string.Equals(x.ItemId, tid, StringComparison.Ordinal));
 6191        if (match is null) return (false, "снят с продажи или изменился gid — выполните проценку заново", null, null);
 6192        if (!int.TryParse(match.Rest, out var rest) || rest < quantity)
 2193            return (false, $"недостаточно остатка ({match.Rest ?? "0"} < {quantity:0.###})", match.SourcePrice, null);
 4194        return (true, null, match.SourcePrice, rest);
 6195    }
 196
 197    public async Task<ForumAutoCartOperationResult> TestAsync(string providerKey, string changedBy, IReadOnlyCollection<
 198    {
 2199        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 2200        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 2201        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 2202        if (selectedIds is not null && items.Count != selectedIds.Count)
 0203            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите проверку."]);
 2204        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 205
 6206        var ready = 0; var failed = 0; var messages = new List<string>();
 8207        foreach (var item in items)
 208        {
 209            try
 210            {
 2211                var (ok, message, newPrice, newRest) = await RepriceAsync(providerKey, item.Article, item.Brand, item.Ti
 4212                item.TestedAtUtc = DateTime.UtcNow; item.UpdatedAtUtc = DateTime.UtcNow;
 2213                if (ok)
 214                {
 215                    string priceNote;
 1216                    if (newPrice.HasValue && newPrice.Value != item.SourcePrice)
 1217                        priceNote = $" Цена обновлена: {item.SourcePrice?.ToString("0.##", CultureInfo.InvariantCulture)
 218                    else
 0219                        priceNote = string.Empty;
 2220                    if (newPrice.HasValue) item.SourcePrice = newPrice;
 1221                    item.Status = ForumAutoCartItemStatus.Ready;
 1222                    item.LastError = null;
 1223                    ready++;
 1224                    messages.Add($"{item.Article}: проверено у поставщика, остаток {newRest}.{priceNote}");
 1225                    await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), true, null, ApiSupplierBasketCartSta
 226                }
 227                else
 228                {
 1229                    item.Status = ForumAutoCartItemStatus.Failed;
 1230                    item.LastError = $"{message} для арт. {item.Article} · {item.Brand}.";
 1231                    failed++;
 1232                    messages.Add(item.LastError);
 1233                    await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), false, item.LastError, ApiSupplierBa
 234                }
 2235            }
 0236            catch (ForumAutoApiException ex)
 237            {
 0238                item.Status = ForumAutoCartItemStatus.Failed; item.LastError = ex.Message;
 0239                item.TestedAtUtc = DateTime.UtcNow; item.UpdatedAtUtc = DateTime.UtcNow;
 0240                failed++; messages.Add($"{item.Article}: {ex.Message}");
 0241                await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), false, ex.Message, ApiSupplierBasketCart
 242            }
 2243        }
 2244        await _db.SaveChangesAsync(ct);
 2245        return new(ready, 0, failed, messages, providerKey);
 2246    }
 247
 248    public async Task<ForumAutoCartOperationResult> CheckoutAsync(string providerKey, string changedBy, IReadOnlyCollect
 249    {
 3250        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 3251        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 3252        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 3253        if (selectedIds is not null && items.Count != selectedIds.Count)
 0254            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите оформление."])
 3255        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 7256        if (items.Any(x => x.Status != ForumAutoCartItemStatus.Ready || x.TestedAtUtc is null || x.TestedAtUtc < DateTim
 1257            return new(0, 0, items.Count, ["Перед оформлением выполните свежую проверку Форум-Авто."]);
 258
 5259        var ids = items.Select(x => x.Id).ToArray();
 2260        var locked = await _db.ForumAutoCartItems.Where(x => ids.Contains(x.Id) && x.Status == ForumAutoCartItemStatus.R
 2261            .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ForumAutoCartItemStatus.Processing).SetProperty(x => x
 2262        if (locked != items.Count) return new(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]);
 263
 5264        var lines = items.Select(item => new ForumAutoPlacementLine(item.Id, item.StudioOrderId, item.Tid, item.Article,
 5265            item.ProductName, item.Quantity, item.SourcePrice, item.CurrencyCode, null, null, null, null, null)).ToList(
 2266        var (success, failed, messages) = await PlaceOrderItemsAsync(providerKey, lines, changedBy, ct);
 2267        InvalidateCount();
 2268        return new(success, 0, failed, messages, providerKey);
 3269    }
 270
 271    /// <summary>Оформляет позиции без локальной корзины. Вызывается до проведения статусов Studio2.</summary>
 272    public async Task<ForumAutoDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ForumAutoCartQueueItem> que
 273    {
 4274        if (queue.Count == 0) return new(true, []);
 12275        if (queue.GroupBy(x => new { x.ProviderKey, x.OrderId }).Any(group => group.Count() > 1))
 0276            return new(false, ["Для одного заказа выбрано несколько предложений Форум-Авто."]);
 277
 4278        var messages = new List<string>();
 19279        foreach (var group in queue.GroupBy(x => x.ProviderKey))
 280        {
 4281            if (!IsProvider(group.Key)) return new(false, ["Неизвестный аккаунт Форум-Авто."]);
 4282            var lines = new List<ForumAutoPlacementLine>();
 15283            foreach (var item in group)
 284            {
 4285                if (item.OrderId <= 0 || item.Quantity <= 0 || string.IsNullOrWhiteSpace(item.Tid) ||
 4286                    string.IsNullOrWhiteSpace(item.Article) || string.IsNullOrWhiteSpace(item.Brand))
 0287                    return new(false, [$"{Describe(item)}: неполные данные предложения Форум-Авто."]);
 288
 4289                var (ok, message, newPrice, _) = await RepriceAsync(item.ProviderKey, item.Article, item.Brand, item.Tid
 5290                if (!ok) return new(false, [$"{item.Article}: {message}."]);
 291
 3292                lines.Add(new ForumAutoPlacementLine(null, item.OrderId, item.Tid, item.Article, item.Brand, item.Produc
 3293                    item.Quantity, newPrice ?? item.SourcePrice, item.CurrencyCode ?? "RUB", item.ShipmentNumber, item.C
 3294                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction));
 3295            }
 3296            var (success, failed, placeMessages) = await PlaceOrderItemsAsync(group.Key, lines, changedBy, ct);
 3297            messages.AddRange(placeMessages);
 3298            if (failed > 0) return new(false, messages);
 3299        }
 3300        InvalidateCount();
 3301        return new(true, messages);
 4302    }
 303
 90304    private sealed record ForumAutoPlacementLine(long? CartItemId, int StudioOrderId, string Tid, string Article, string
 52305        string? ProductName, decimal Quantity, decimal? SourcePrice, string? CurrencyCode, string? ShipmentNumber, strin
 39306        int? DeliveryDaysMin, int? DeliveryDaysMax, string? Direction);
 307
 308    private static bool IsDuplicateDidException(Exception ex)
 309    {
 0310        var msg = ex.InnerException?.Message ?? ex.Message;
 0311        return msg.Contains("UX_ForumAutoOrders_Provider_Did", StringComparison.OrdinalIgnoreCase)
 0312            || msg.Contains("ForumAutoOrders.ProviderKey", StringComparison.OrdinalIgnoreCase)
 0313            || 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    {
 5322        var client = ResolveClient(providerKey);
 5323        var order = new ForumAutoOrder { ProviderKey = providerKey, Status = "Creating", CreatedBy = changedBy };
 5324        _db.ForumAutoOrders.Add(order);
 5325        await _db.SaveChangesAsync(ct);
 5326        order.Eoid = order.Id;
 5327        await _db.SaveChangesAsync(ct);
 328
 15329        var success = 0; var failed = 0; var messages = new List<string>();
 5330        var seenEids = new HashSet<int>();
 22331        foreach (var line in lines)
 332        {
 333            try
 334            {
 6335                var response = await client.AddGoodsToOrderAsync(line.Tid, (int)Math.Round(line.Quantity), eid: null, eo
 336
 5337                var orderArray = (response as JArray)?.OfType<JObject>().ToList()
 5338                    ?? (response is JObject obj ? new List<JObject> { obj } : null)
 5339                    ?? 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 (наиболее свежий),
 5346                var orderObj = (order.Did.HasValue ? orderArray.FirstOrDefault(x => x.Value<int?>("did") == order.Did.Va
 6347                    ?? (order.Eoid > 0 ? orderArray.FirstOrDefault(x => x.Value<long?>("eoid") == order.Eoid || string.E
 6348                    ?? orderArray.FirstOrDefault(x => (x["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()).An
 0349                    ?? orderArray.OrderByDescending(x => x.Value<int?>("did") ?? 0).FirstOrDefault()
 5350                    ?? throw new ForumAutoApiException("Forum-Auto вернул неожиданный формат ответа addGoodsToOrder.");
 351
 5352                order.ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None);
 5353                var did = orderObj.Value<int?>("did");
 5354                if (did.HasValue)
 355                {
 5356                    if (!order.Did.HasValue)
 357                    {
 358                        // Проверяем, существует ли уже заказ с таким (ProviderKey, Did) в базе данных
 5359                        var existingOrder = await _db.ForumAutoOrders
 5360                            .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value && x.Id != orde
 361
 5362                        if (existingOrder != null)
 363                        {
 364                            // Поставщик привязал позицию к уже существующему заказу (например, дневной сводный заказ).
 365                            // Перепривязываем элементы к существующему заказу и удаляем черновой заказ-плейсхолдер.
 2366                            foreach (var itemEntry in _db.ChangeTracker.Entries<ForumAutoOrderItem>()
 1367                                .Where(e => e.Entity.ForumAutoOrderId == order.Id))
 368                            {
 0369                                itemEntry.Entity.ForumAutoOrderId = existingOrder.Id;
 370                            }
 371
 1372                            _db.ForumAutoOrders.Remove(order);
 1373                            order = existingOrder;
 1374                            order.ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None);
 375                        }
 376                        else
 377                        {
 4378                            order.Did = did.Value;
 379                        }
 380                    }
 0381                    else if (order.Did.Value != did.Value)
 382                    {
 383                        // Поставщик разбил заказ на другой did (например, другой склад отгрузки)
 0384                        var splitOrder = await _db.ForumAutoOrders
 0385                            .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value, ct);
 386
 0387                        if (splitOrder == null)
 388                        {
 0389                            splitOrder = new ForumAutoOrder
 0390                            {
 0391                                ProviderKey = providerKey,
 0392                                Did = did.Value,
 0393                                Eoid = order.Eoid,
 0394                                Status = "Creating",
 0395                                CreatedBy = changedBy,
 0396                                ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None)
 0397                            };
 0398                            _db.ForumAutoOrders.Add(splitOrder);
 0399                            await _db.SaveChangesAsync(ct);
 400                        }
 0401                        order = splitOrder;
 0402                    }
 403                }
 404
 5405                var matchedRow = (orderObj["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>())
 5406                    .Where(x => string.Equals(x.Value<string>("gid"), line.Tid, StringComparison.Ordinal))
 10407                    .FirstOrDefault(x => !seenEids.Contains(x.Value<int?>("eid") ?? -1));
 10408                if (matchedRow is not null) seenEids.Add(matchedRow.Value<int?>("eid") ?? -1);
 5409                var eid = matchedRow?.Value<int?>("eid");
 5410                var price = matchedRow?.Value<decimal?>("price") ?? line.SourcePrice;
 411
 5412                _db.ForumAutoOrderItems.Add(new ForumAutoOrderItem
 5413                {
 5414                    ForumAutoOrderId = order.Id, ForumAutoCartItemId = line.CartItemId, StudioOrderId = line.StudioOrder
 5415                    Eid = eid, Tid = line.Tid, Brand = line.Brand, Quantity = line.Quantity, Price = price, CurrencyCode
 5416                });
 417
 5418                if (line.CartItemId.HasValue)
 419                {
 2420                    var cartItem = await _db.ForumAutoCartItems.FirstAsync(x => x.Id == line.CartItemId.Value, ct);
 4421                    cartItem.Status = ForumAutoCartItemStatus.Ordered; cartItem.ActiveStudioOrderId = null;
 4422                    cartItem.LastError = null; cartItem.UpdatedAtUtc = DateTime.UtcNow;
 423                }
 424
 425                try
 426                {
 5427                    await _db.SaveChangesAsync(ct);
 5428                }
 0429                catch (DbUpdateException dbEx) when (IsDuplicateDidException(dbEx) && did.HasValue)
 430                {
 0431                    _logger.LogWarning(dbEx, "Конкурентное создание ForumAutoOrder для Did {Did}, перепривязываем к суще
 0432                    var winningOrder = await _db.ForumAutoOrders.AsTracking()
 0433                        .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value && x.Id != order.Id
 434
 0435                    if (winningOrder != null)
 436                    {
 0437                        foreach (var itemEntry in _db.ChangeTracker.Entries<ForumAutoOrderItem>()
 0438                            .Where(e => e.Entity.ForumAutoOrderId == order.Id))
 439                        {
 0440                            itemEntry.Entity.ForumAutoOrderId = winningOrder.Id;
 441                        }
 442
 0443                        _db.Entry(order).State = EntityState.Detached;
 0444                        await _db.ForumAutoOrders.Where(x => x.Id == order.Id).ExecuteDeleteAsync(ct);
 0445                        order = winningOrder;
 0446                        await _db.SaveChangesAsync(ct);
 447                    }
 448                    else
 449                    {
 0450                        throw;
 451                    }
 0452                }
 453
 5454                success++;
 5455                var orderLabel = order.Did.HasValue ? order.Did.Value.ToString(CultureInfo.InvariantCulture) : $"eoid {o
 5456                messages.Add($"{line.Article}: заказ Форум-Авто {orderLabel} создан.");
 5457                await RecordHistoryAsync(line.StudioOrderId, ToQueueItem(providerKey, line), true, null, ApiSupplierBask
 5458                await TrackOrderedAsync(line.StudioOrderId, providerKey, orderLabel, changedBy, ct, line.DeliveryDaysMin
 5459            }
 1460            catch (Exception ex) when (ex is ForumAutoApiException or HttpRequestException or DbUpdateException)
 461            {
 1462                failed++;
 1463                messages.Add($"{line.Article}: {ex.Message}");
 1464                if (line.CartItemId.HasValue)
 465                {
 1466                    var cartItem = await _db.ForumAutoCartItems.FirstOrDefaultAsync(x => x.Id == line.CartItemId.Value, 
 1467                    if (cartItem != null)
 468                    {
 3469                        cartItem.Status = ForumAutoCartItemStatus.Failed; cartItem.LastError = ex.Message; cartItem.Upda
 470                    }
 471                }
 1472                await RecordHistoryAsync(line.StudioOrderId, ToQueueItem(providerKey, line), false, ex.Message, ApiSuppl
 473            }
 6474        }
 475
 5476        if (_db.Entry(order).State != EntityState.Detached)
 477        {
 5478            order.Status = success == 0 ? "Failed" : failed == 0 ? "Created" : "PartiallyFailed";
 5479            await _db.SaveChangesAsync(ct);
 480        }
 5481        return (success, failed, messages);
 5482    }
 483
 484    /// <summary>Отменяет заказ у поставщика целиком (без восстановления). Пока без обязательной кнопки в UI.</summary>
 485    public async Task<bool> CancelOrderAsync(string providerKey, long localOrderId, string changedBy, CancellationToken 
 486    {
 1487        var order = await _db.ForumAutoOrders.FirstOrDefaultAsync(x => x.Id == localOrderId && x.ProviderKey == provider
 1488        if (order?.Did is null) return false;
 1489        var client = ResolveClient(providerKey);
 1490        var response = await client.CancelOrderAsync(order.Did.Value, ct);
 1491        if (response.Value<int?>("status") != 1) return false;
 1492        order.Status = "Cancelled";
 1493        await _db.SaveChangesAsync(ct);
 1494        return true;
 1495    }
 496
 497    public async Task<bool> RemoveAsync(long id, string providerKey, CancellationToken ct = default)
 498    {
 0499        var item = await _db.ForumAutoCartItems.FirstOrDefaultAsync(x => x.Id == id && x.ProviderKey == providerKey && F
 0500        if (item is null) return false;
 0501        item.Status = ForumAutoCartItemStatus.Removed; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.Utc
 0502        await _db.SaveChangesAsync(ct);
 0503        await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 0504        InvalidateCount(); return true;
 0505    }
 506
 507    /// <summary>Убирает из корзины выбранные строки (или всю корзину провайдера, если ids не заданы).</summary>
 508    public async Task<ForumAutoCartOperationResult> RemoveManyAsync(string providerKey, IReadOnlyCollection<long>? cartI
 509    {
 1510        if (!IsProvider(providerKey)) return new(0, 0, 0, ["Неизвестный аккаунт Форум-Авто."]);
 1511        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 1512        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 513
 1514        var items = (await GetTrackedActiveAsync(providerKey, selectedIds, ct))
 2515            .Where(x => x.Status != ForumAutoCartItemStatus.Processing)
 1516            .ToList();
 4517        foreach (var item in items)
 518        {
 1519            item.Status = ForumAutoCartItemStatus.Removed;
 1520            item.ActiveStudioOrderId = null;
 1521            item.UpdatedAtUtc = DateTime.UtcNow;
 522        }
 1523        await _db.SaveChangesAsync(ct);
 5524        foreach (var item in items) await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 1525        var removed = items.Count;
 1526        InvalidateCount();
 1527        if (removed == 0) return new(0, 0, 0, ["Нечего удалять: строки уже удалены или сейчас оформляются."]);
 1528        var skipped = selectedIds is null ? 0 : selectedIds.Count - removed;
 1529        var messages = new List<string> { $"Удалено из корзины {ProviderName(providerKey)}: {removed}." };
 1530        if (skipped > 0) messages.Add($"Пропущено (оформляются или уже удалены): {skipped}.");
 1531        return new(removed, skipped, 0, messages, providerKey);
 1532    }
 533
 534    private Task<List<ForumAutoCartItem>> GetTrackedActiveAsync(string providerKey, IReadOnlyCollection<long>? cartItemI
 535    {
 6536        var query = _db.ForumAutoCartItems.Where(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Con
 6537        if (cartItemIds is not null) query = query.Where(x => cartItemIds.Contains(x.Id));
 6538        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    {
 0549        if (!IsProvider(providerKey)) return SupplierOrderCancelResult.Failed($"Неизвестный аккаунт Форум-Авто: {provide
 0550        var dids = await _db.ForumAutoOrderItems.AsNoTracking()
 0551            .Where(x => x.StudioOrderId == orderId && x.ForumAutoOrder != null && x.ForumAutoOrder.ProviderKey == provid
 0552            .Select(x => x.ForumAutoOrder!.Did!.Value).Distinct().ToListAsync(ct);
 0553        if (dids.Count == 0) return SupplierOrderCancelResult.Failed("в Studio2 нет номера заказа Форум-Авто (did).");
 554
 0555        var neighbours = await _db.ForumAutoOrderItems.AsNoTracking()
 0556            .Where(x => x.ForumAutoOrder != null && x.ForumAutoOrder.ProviderKey == providerKey && x.ForumAutoOrder.Did 
 0557                        && dids.Contains(x.ForumAutoOrder.Did.Value) && x.StudioOrderId != orderId)
 0558            .Select(x => x.StudioOrderId).Distinct().ToListAsync(ct);
 0559        var live = neighbours.Where(x => !cancellableOrderIds.Contains(x)).ToList();
 0560        if (live.Count > 0)
 0561            return SupplierOrderCancelResult.Failed(
 0562                $"в заказе {string.Join(", ", dids)} есть другие заказы Studio2 ({string.Join(", ", live.Select(x => "#"
 563
 0564        var client = ResolveClient(providerKey);
 0565        var cancelled = new List<int>();
 0566        foreach (var did in dids)
 567        {
 568            try
 569            {
 0570                await client.CancelOrderAsync(did, ct);
 0571                cancelled.Add(did);
 0572            }
 0573            catch (ForumAutoApiException ex)
 574            {
 0575                var done = cancelled.Count > 0 ? $"; уже аннулированы: {string.Join(", ", cancelled)}" : string.Empty;
 0576                return SupplierOrderCancelResult.Failed($"заказ {did}: {ex.Message}{done}");
 577            }
 578        }
 0579        return SupplierOrderCancelResult.Cancelled($"заказ {string.Join(", ", cancelled)}: аннулирован");
 0580    }
 581
 12582    private ForumAutoDataManager ResolveClient(string providerKey) => providerKey switch
 12583    {
 10584        ApiSupplierSettings.ForumAutoProviderKey => _forumAuto,
 0585        ApiSupplierSettings.ForumAutoInterpartsProviderKey => _forumAutoInterparts,
 0586        ApiSupplierSettings.ForumAutoPiterProviderKey => _forumAutoPiter,
 0587        ApiSupplierSettings.ForumAutoRostovProviderKey => _forumAutoRostov,
 2588        ApiSupplierSettings.ForumAutoIstraProviderKey => _forumAutoIstra,
 0589        _ => throw new ArgumentException("Неизвестный аккаунт Форум-Авто.", nameof(providerKey))
 12590    };
 591
 8592    private static bool IsProvider(string key) => key is ApiSupplierSettings.ForumAutoProviderKey or ApiSupplierSettings
 593
 14594    private static string ProviderName(string key) => key switch
 14595    {
 0596        ApiSupplierSettings.ForumAutoInterpartsProviderKey => "Форум-Авто (Интерпартс)",
 0597        ApiSupplierSettings.ForumAutoPiterProviderKey => "Форум-Авто (Питер)",
 0598        ApiSupplierSettings.ForumAutoRostovProviderKey => "Форум-Авто (Ростов)",
 3599        ApiSupplierSettings.ForumAutoIstraProviderKey => "Форум-Авто (Истра)",
 11600        _ => "Форум-Авто (Союз)"
 14601    };
 602
 10603    private static string CartUrl(string providerKey) => providerKey switch
 10604    {
 0605        ApiSupplierSettings.ForumAutoInterpartsProviderKey => "/SupplierCart?provider=forumautointerparts&supplierTab=ba
 0606        ApiSupplierSettings.ForumAutoPiterProviderKey => "/SupplierCart?provider=forumautopiter&supplierTab=basket",
 0607        ApiSupplierSettings.ForumAutoRostovProviderKey => "/SupplierCart?provider=forumautorostov&supplierTab=basket",
 2608        ApiSupplierSettings.ForumAutoIstraProviderKey => "/SupplierCart?provider=forumautoistra&supplierTab=basket",
 8609        _ => "/SupplierCart?provider=forumauto&supplierTab=basket"
 10610    };
 611
 612    private static string? ValidateSelection(IReadOnlyCollection<long>? ids, out IReadOnlyCollection<long>? normalizedId
 613    {
 18614        if (ids is null) { normalizedIds = null; return null; }
 0615        normalizedIds = ids.Where(id => id > 0).Distinct().ToArray();
 0616        return normalizedIds.Count == 0 ? "Выберите хотя бы одну позицию корзины." : null;
 617    }
 618
 619    private static string Describe(ForumAutoCartQueueItem item)
 620    {
 3621        var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber;
 3622        var product = string.IsNullOrWhiteSpace(item.ProductName) ? item.Article : item.ProductName;
 3623        var client = string.IsNullOrWhiteSpace(item.ClientName) ? string.Empty : $", {item.ClientName}";
 3624        return $"{item.Article}: заказ {order}, {product}{client}";
 625    }
 626
 2627    private static ForumAutoCartQueueItem ToQueueItem(ForumAutoCartItem item) => new(
 2628        item.StudioOrderId, item.ProviderKey, item.Tid, item.Article, item.Brand, item.ProductName,
 2629        item.StudioOrder?.ShipmentNumber, null, item.Quantity, item.SourcePrice, item.CurrencyCode,
 2630        item.DeliveryAtUtc?.ToString("O", CultureInfo.InvariantCulture));
 631
 6632    private static ForumAutoCartQueueItem ToQueueItem(string providerKey, ForumAutoPlacementLine line) => new(
 6633        line.StudioOrderId, providerKey, line.Tid, line.Article, line.Brand, line.ProductName,
 6634        line.ShipmentNumber, line.ClientName, line.Quantity, line.SourcePrice, line.CurrencyCode, null,
 6635        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        {
 10644            var displayName = ProviderName(item.ProviderKey);
 10645            var entry = new ApiSupplierBasketHistoryEntry(
 10646                item.ProviderKey, displayName, LogoUrl, success, error,
 10647                ApiItemId: item.Tid, Article: item.Article, ProductName: item.ProductName ?? item.Brand,
 10648                Price: item.SourcePrice, CurrencyCode: item.CurrencyCode, Quantity: item.Quantity,
 10649                ShipmentNumber: item.ShipmentNumber, ClientName: item.ClientName,
 10650                CartStatus: cartStatus, SupplierOrderNumber: supplierOrderNumber, SupplierCartUrl: CartUrl(item.Provider
 651
 10652            await _history.AddOrderHistory(new OrderHistory
 10653            {
 10654                OrderId = orderId,
 10655                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 10656                ColumnDisplayName = $"Корзина {displayName}",
 10657                OldValue = null,
 10658                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 10659                ChangedAt = DateTime.Now,
 10660                ChangedBy = changedBy
 10661            });
 10662        }
 0663        catch
 664        {
 665            // Не ломаем размещение в корзине из-за журнала истории.
 0666        }
 10667    }
 668
 669    private static DateTime? ParseApiDate(string? value) =>
 2670        DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compa
 2671        DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed) ? parse
 672
 9673    private void InvalidateCount() => _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 674}

Methods/Properties

.ctor(OzonRepositories.Context.ApplicationDbContext,Services.ApiServices.ForumAutoApi.ForumAutoDataManager,Services.ApiServices.ForumAutoApi.ForumAutoInterpartsDataManager,Services.ApiServices.ForumAutoApi.ForumAutoPiterDataManager,Services.ApiServices.ForumAutoApi.ForumAutoRostovDataManager,Services.ApiServices.ForumAutoApi.ForumAutoIstraDataManager,IOrderHistoryDataService,Microsoft.Extensions.Caching.Memory.IMemoryCache,Services.SupplierStatusServices.IOrderSupplierStateService,Microsoft.Extensions.Logging.ILogger`1<OzonOrdersWeb.TransactionServices.ForumAutoCartService>)
TrackCartAsync()
TrackOrderedAsync()
TrackRemovedAsync()
GetActiveAsync(System.String,System.Threading.CancellationToken)
GetOrdersAsync(System.String,System.Threading.CancellationToken)
CountActiveAsync(System.Threading.CancellationToken)
QueueAsync()
RepriceAsync()
TestAsync()
CheckoutAsync()
CheckoutDirectAsync()
get_CartItemId()
get_ProductName()
get_DeliveryDaysMin()
IsDuplicateDidException(System.Exception)
PlaceOrderItemsAsync()
CancelOrderAsync()
RemoveAsync()
RemoveManyAsync()
GetTrackedActiveAsync(System.String,System.Collections.Generic.IReadOnlyCollection`1<System.Int64>,System.Threading.CancellationToken)
CancelOrderAsync()
ResolveClient(System.String)
IsProvider(System.String)
ProviderName(System.String)
CartUrl(System.String)
ValidateSelection(System.Collections.Generic.IReadOnlyCollection`1<System.Int64>,System.Collections.Generic.IReadOnlyCollection`1<System.Int64>&)
Describe(OzonOrdersWeb.TransactionServices.ForumAutoCartQueueItem)
ToQueueItem(OzonDomains.Models.ForumAutoCartItem)
ToQueueItem(System.String,OzonOrdersWeb.TransactionServices.ForumAutoCartService/ForumAutoPlacementLine)
RecordHistoryAsync()
ParseApiDate(System.String)
InvalidateCount()