< Summary

Information
Class: OzonOrdersWeb.TransactionServices.AvdBasketPlacementService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/AvdBasketPlacementService.cs
Line coverage
80%
Covered lines: 138
Uncovered lines: 33
Coverable lines: 171
Total lines: 366
Line coverage: 80.7%
Branch coverage
65%
Covered branches: 49
Total branches: 75
Branch coverage: 65.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
PlaceQueuedItemsAsync()79.16%242492.68%
CheckoutDirectAsync()52.77%1193660%
TryTrackCartAsync()100%1175%
TryTrackOrderedAsync()100%1175%
TryGetInsertError(...)46.15%391346.15%
WasSuccessfullyPlacedAsync()100%11100%
TryRecordHistoryAsync()100%2293.54%

File(s)

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

#LineLine coverage
 1using System.Net;
 2using System.Text.Json;
 3using System.Xml.Linq;
 4using Microsoft.Extensions.Caching.Memory;
 5using OzonDomains.Models;
 6using OzonDomains.Models.SupplierStatuses;
 7using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart;
 8using Services.ApiServices.AvdApi;
 9using Services.DataServices;
 10using Services.SupplierStatusServices;
 11
 12namespace OzonOrdersWeb.TransactionServices;
 13
 14public sealed record AvdBasketQueueItem(
 15    int OrderId,
 16    string ItemHash,
 17    decimal Quantity,
 18    string? Article,
 19    string? ProductName,
 20    string? ShipmentNumber,
 21    string? ClientName,
 22    decimal? Price = null,
 23    int? DeliveryDaysMin = null,
 24    int? DeliveryDaysMax = null,
 25    string? Direction = null);
 26
 27public sealed record AvdDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages);
 28
 29/// <summary>
 30/// Данные размещения в корзине API-поставщика для записи в orders_history — провайдеронезависимая
 31/// форма (ProviderKey/LogoUrl), чтобы таймлайн истории заказа умел показывать любого будущего
 32/// поставщика с реальным API-заказом, не только АВД.
 33/// </summary>
 34public sealed class AvdBasketPlacementService
 35{
 36    private const string ProviderKey = "Avd";
 37    private const string SupplierDisplayName = "АВД";
 38    private const string LogoUrl = "/images/avd-logo.svg";
 39    private const string CurrencyCode = "RUB";
 40
 41    private readonly AvdDataManager _avd;
 42    private readonly IOrderHistoryDataService _orderHistoryService;
 43    private readonly IMemoryCache _cache;
 44    private readonly IOrderSupplierStateService _supplierStateService;
 45
 846    public AvdBasketPlacementService(
 847        AvdDataManager avd,
 848        IOrderHistoryDataService orderHistoryService,
 849        IMemoryCache cache,
 850        IOrderSupplierStateService supplierStateService)
 51    {
 852        _avd = avd;
 853        _orderHistoryService = orderHistoryService;
 854        _cache = cache;
 855        _supplierStateService = supplierStateService;
 856    }
 57
 58    public async Task<AvdBasketPlacementResult> PlaceQueuedItemsAsync(
 59        IReadOnlyCollection<AvdBasketQueueItem> items,
 60        string changedBy,
 61        CancellationToken cancellationToken = default)
 62    {
 763        if (items.Count == 0)
 164            return AvdBasketPlacementResult.Empty();
 65
 666        var entries = new List<AvdBasketPlacementEntry>();
 2867        foreach (var item in items)
 68        {
 69            // Человекочитаемый номер заказа для комментария и результата — номер отправления, а не внутренний Id Studio
 870            var orderLabel = !string.IsNullOrWhiteSpace(item.ShipmentNumber) ? item.ShipmentNumber! : $"ID {item.OrderId
 871            var articleLabel = !string.IsNullOrWhiteSpace(item.Article) ? item.Article! : orderLabel;
 72
 873            if (await WasSuccessfullyPlacedAsync(item.OrderId))
 74            {
 175                entries.Add(AvdBasketPlacementEntry.Skipped(articleLabel,
 176                    $"заказ {orderLabel} уже был добавлен в корзину АВД ранее."));
 177                continue;
 78            }
 79
 780            if (string.IsNullOrWhiteSpace(item.ItemHash))
 81            {
 182                var missingHashMessage = $"заказ {orderLabel} — не найден hash предложения АВД.";
 183                entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, missingHashMessage));
 184                await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, missingHashMessage, chang
 185                continue;
 86            }
 87
 88            // Комментарий уходит в АВД и виден там в их собственной корзине — номер отправления + внутренний Id
 89            // Studio2 в скобках (по этому Id /SupplierCart находит системный заказ через StudioOrderCommentPattern).
 690            var comment = $"Studio2 {orderLabel} (ID {item.OrderId})";
 691            var detailParts = new List<string> { $"заказ {orderLabel}" };
 1292            if (!string.IsNullOrWhiteSpace(item.ProductName)) detailParts.Add(item.ProductName!);
 1293            if (!string.IsNullOrWhiteSpace(item.ClientName)) detailParts.Add(item.ClientName!);
 694            var detail = string.Join(", ", detailParts);
 95
 96            try
 97            {
 698                var response = await _avd.InsertToBasketAsync(item.ItemHash, item.Quantity, comment, onlyThis: false, ca
 2099                var apiItemId = response.Descendants().FirstOrDefault(x => x.Name.LocalName == "InsertToBasketResult")?.
 5100                if (TryGetInsertError(apiItemId, out var insertError))
 101                {
 1102                    entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {insertError}"));
 1103                    await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, insertError, changedB
 1104                    continue;
 105                }
 106
 4107                if (string.IsNullOrWhiteSpace(apiItemId))
 108                {
 109                    const string missingResultError = "АВД не вернул идентификатор добавленной позиции.";
 0110                    entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {missingResultError}"));
 0111                    await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, missingResultError, c
 0112                    continue;
 113                }
 114
 4115                entries.Add(AvdBasketPlacementEntry.Success(articleLabel, $"{detail} — добавлено в корзину АВД"));
 4116                await TryRecordHistoryAsync(item, orderLabel, success: true, apiItemId, errorMessage: null, changedBy);
 4117                await TryTrackCartAsync(item.OrderId, SupplierCartStatusCode.Pending, changedBy, apiItemId,
 4118                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 4119                _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 4120            }
 1121            catch (AvdApiException ex)
 122            {
 1123                entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {ex.Message}"));
 1124                await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, ex.Message, changedBy);
 125            }
 5126        }
 127
 6128        return new AvdBasketPlacementResult(entries);
 7129    }
 130
 131    /// <summary>Добавляет выбранные позиции в корзину АВД и сразу оформляет только эти строки.</summary>
 132    public async Task<AvdDirectCheckoutResult> CheckoutDirectAsync(
 133        IReadOnlyCollection<AvdBasketQueueItem> items,
 134        string changedBy,
 135        CancellationToken cancellationToken = default)
 136    {
 1137        if (items.Count == 0)
 0138            return new(false, ["Не выбраны позиции АВД для оформления."]);
 3139        if (items.GroupBy(x => x.OrderId).Any(x => x.Key <= 0 || x.Count() > 1) ||
 2140            items.Any(x => string.IsNullOrWhiteSpace(x.ItemHash) || x.Quantity <= 0))
 0141            return new(false, ["Для прямого оформления АВД требуется ровно одно корректное предложение на заказ."]);
 142
 1143        var inserted = new List<(AvdBasketQueueItem Item, long BasketId)>();
 1144        var messages = new List<string>();
 4145        foreach (var item in items)
 146        {
 1147            var orderLabel = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber
 148            try
 149            {
 1150                var response = await _avd.InsertToBasketAsync(item.ItemHash, item.Quantity,
 1151                    $"Studio2 {orderLabel} (ID {item.OrderId})", onlyThis: false, cancellationToken);
 4152                var rawId = response.Descendants().FirstOrDefault(x => x.Name.LocalName == "InsertToBasketResult")?.Valu
 1153                if (TryGetInsertError(rawId, out var error) || !long.TryParse(rawId, out var basketId) || basketId <= 0)
 154                {
 0155                    var message = string.IsNullOrWhiteSpace(error) ? "АВД не вернул идентификатор добавленной позиции." 
 0156                    messages.Add($"{item.Article ?? orderLabel}: {message}");
 0157                    await TryRecordHistoryAsync(item, orderLabel, false, null, message, changedBy);
 0158                    continue;
 159                }
 1160                inserted.Add((item, basketId));
 1161            }
 0162            catch (AvdApiException ex)
 163            {
 0164                messages.Add($"{item.Article ?? orderLabel}: {ex.Message}");
 0165                await TryRecordHistoryAsync(item, orderLabel, false, null, ex.Message, changedBy);
 166            }
 1167        }
 168
 1169        if (inserted.Count != items.Count)
 170        {
 0171            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 0172            messages.Add("Корзина АВД могла получить часть позиций; заказ не создан.");
 0173            return new(false, messages);
 174        }
 175
 176        try
 177        {
 2178            var orderId = await _avd.CreateOrderByBasketIdsAsync(inserted.Select(x => x.BasketId).ToArray(),
 1179                "Studio2: прямое оформление", cancellationToken);
 4180            foreach (var (item, basketId) in inserted)
 181            {
 1182                var orderLabel = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNu
 1183                await TryRecordHistoryAsync(item, orderLabel, true, basketId.ToString(), null, changedBy,
 1184                    orderId.ToString());
 1185                await TryTrackOrderedAsync(item.OrderId, orderId.ToString(), basketId.ToString(), changedBy,
 1186                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 1187            }
 1188            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 1189            messages.Add($"Создан заказ АВД №{orderId}.");
 1190            return new(true, messages);
 191        }
 0192        catch (AvdApiException ex)
 193        {
 0194            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 0195            messages.Add($"АВД не оформил заказ: {ex.Message}. Добавленные позиции остались в корзине АВД.");
 0196            return new(false, messages);
 197        }
 1198    }
 199
 200    /// <summary>
 201    /// Фиксирует стадию корзины у поставщика. Сбой отслеживания не должен ломать уже выполненное
 202    /// размещение — та же логика, что у TryRecordHistoryAsync.
 203    /// </summary>
 204    private async Task TryTrackCartAsync(int orderId, string cartStatusCode, string changedBy, string? basketItemId,
 205        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null)
 206    {
 207        try
 208        {
 4209            await _supplierStateService.MarkInCartAsync(
 4210                orderId, ProviderKey, cartStatusCode, cartItemId: null,
 4211                externalBasketItemId: basketItemId, user: changedBy,
 4212                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction);
 4213        }
 0214        catch
 215        {
 216            // Отслеживание статуса — вспомогательный журнал, не критичный путь размещения.
 0217        }
 4218    }
 219
 220    /// <summary>Фиксирует оформление заказа у АВД. Сбой не должен ломать уже созданный заказ.</summary>
 221    private async Task TryTrackOrderedAsync(int orderId, string externalOrderId, string? basketItemId, string changedBy,
 222        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null)
 223    {
 224        try
 225        {
 1226            await _supplierStateService.MarkOrderedAsync(
 1227                orderId, ProviderKey, externalOrderNumber: externalOrderId,
 1228                externalOrderId: externalOrderId, externalLineId: basketItemId, user: changedBy,
 1229                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction);
 1230        }
 0231        catch
 232        {
 233            // См. TryTrackCartAsync.
 0234        }
 1235    }
 236
 237    private static bool TryGetInsertError(string? result, out string error)
 238    {
 8239        error = result switch
 8240        {
 0241            "0" => "АВД: ошибка добавления позиции в корзину.",
 0242            "1" => "АВД: не удалось проверить пользователя.",
 0243            "2" => "АВД: IP-адрес не разрешён для клиента.",
 0244            "3" => "АВД: ошибка аутентификации пользователя.",
 0245            "4" => "АВД: hash предложения недействителен.",
 0246            "5" => "АВД: ошибка подключения к базе данных.",
 0247            "6" => "АВД: количество не соответствует кратности.",
 2248            "7" => "АВД: запрошенное количество превышает остаток поставщика.",
 6249            _ => string.Empty
 8250        };
 251
 8252        return error.Length > 0;
 253    }
 254
 255    private async Task<bool> WasSuccessfullyPlacedAsync(int orderId)
 256    {
 8257        var history = await _orderHistoryService.GetByOrderIdAsync(orderId);
 8258        return history.Any(entry =>
 8259        {
 3260            if (!string.Equals(entry.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName,
 3261                    StringComparison.Ordinal) || string.IsNullOrWhiteSpace(entry.NewValue))
 0262                return false;
 8263
 8264            // Под этим ColumnName пишут все API-поставщики, поэтому идемпотентность АВД должна
 8265            // учитывать только собственные записи: успешное размещение в Армтеке не отменяет АВД.
 3266            var placement = ApiSupplierBasketHistoryEntry.TryParse(entry.NewValue);
 3267            return placement is { Success: true } &&
 3268                   string.Equals(placement.ProviderKey, ProviderKey, StringComparison.Ordinal) &&
 3269                   !TryGetInsertError(placement.ApiItemId, out _);
 8270        });
 8271    }
 272
 273    /// <summary>Пишет запись в orders_history — по тому же безопасному принципу, что и
 274    /// OrderHistoryOneCPostingRecorder: ошибка журнала не должна ломать уже выполненное размещение.</summary>
 275    private async Task TryRecordHistoryAsync(
 276        AvdBasketQueueItem item,
 277        string orderLabel,
 278        bool success,
 279        string? apiItemId,
 280        string? errorMessage,
 281        string changedBy,
 282        string? supplierOrderNumber = null)
 283    {
 284        try
 285        {
 8286            var entry = new ApiSupplierBasketHistoryEntry(
 8287                ProviderKey,
 8288                SupplierDisplayName,
 8289                LogoUrl,
 8290                success,
 8291                errorMessage,
 8292                apiItemId,
 8293                item.Article,
 8294                item.ProductName,
 8295                item.Price,
 8296                CurrencyCode,
 8297                item.Quantity,
 8298                orderLabel,
 8299                ClientName: item.ClientName,
 8300                CartStatus: success ? ApiSupplierBasketCartStatus.Ordered : ApiSupplierBasketCartStatus.Failed,
 8301                SupplierOrderNumber: supplierOrderNumber,
 8302                SupplierCartUrl: "/SupplierCart?provider=avd&supplierTab=basket");
 303
 8304            await _orderHistoryService.AddOrderHistory(new OrderHistory
 8305            {
 8306                OrderId = item.OrderId,
 8307                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 8308                ColumnDisplayName = $"Корзина {SupplierDisplayName}",
 8309                OldValue = null,
 8310                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 8311                ChangedAt = DateTime.Now,
 8312                ChangedBy = changedBy
 8313            });
 8314        }
 0315        catch
 316        {
 317            // Не ломаем размещение в корзине из-за журнала истории.
 0318        }
 8319    }
 320}
 321
 322public sealed class AvdBasketPlacementResult
 323{
 324    public IReadOnlyCollection<AvdBasketPlacementEntry> Entries { get; }
 325
 326    public int SuccessCount => Entries.Count(e => e.Status == AvdBasketPlacementStatus.Added);
 327    public int FailedCount => Entries.Count(e => e.Status == AvdBasketPlacementStatus.Failed);
 328    public int SkippedCount => Entries.Count(e => e.Status == AvdBasketPlacementStatus.Skipped);
 329
 330    public AvdBasketPlacementResult(IReadOnlyCollection<AvdBasketPlacementEntry> entries) => Entries = entries;
 331
 332    public static AvdBasketPlacementResult Empty() => new(Array.Empty<AvdBasketPlacementEntry>());
 333
 334    public string ToHtmlBlock()
 335    {
 336        if (Entries.Count == 0)
 337            return string.Empty;
 338
 339        var rows = Entries
 340            .Take(20)
 341            .Select(e => $"<li><b>{WebUtility.HtmlEncode(e.Label)}</b>: {WebUtility.HtmlEncode(e.Message)}</li>");
 342
 343        var hiddenCount = Math.Max(0, Entries.Count - 20);
 344        var hiddenTail = hiddenCount > 0 ? $"<li>... и еще {hiddenCount}</li>" : string.Empty;
 345
 346        return "<div class='alert alert-info border-primary-subtle mb-0'>" +
 347               "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина АВД</div>" +
 348               $"<div class='small mb-1'>Добавлено: <b>{SuccessCount}</b>, уже было в корзине: <b>{SkippedCount}</b>, с 
 349               $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{hiddenTail}</ul>" +
 350               "</div>";
 351    }
 352}
 353
 354public enum AvdBasketPlacementStatus
 355{
 356    Added,
 357    Failed,
 358    Skipped
 359}
 360
 361public sealed record AvdBasketPlacementEntry(string Label, AvdBasketPlacementStatus Status, string Message)
 362{
 363    public static AvdBasketPlacementEntry Success(string label, string message) => new(label, AvdBasketPlacementStatus.A
 364    public static AvdBasketPlacementEntry Failed(string label, string message) => new(label, AvdBasketPlacementStatus.Fa
 365    public static AvdBasketPlacementEntry Skipped(string label, string message) => new(label, AvdBasketPlacementStatus.S
 366}