< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_OrderId()100%11100%
get_ItemHash()100%11100%
get_Quantity()100%11100%
get_Article()100%11100%
get_ProductName()100%11100%
get_ShipmentNumber()100%11100%
get_ClientName()100%11100%
get_Price()100%11100%
get_DeliveryDaysMin()100%11100%
get_DeliveryDaysMax()100%11100%
get_Direction()100%11100%

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
 814public sealed record AvdBasketQueueItem(
 3115    int OrderId,
 1516    string ItemHash,
 1717    decimal Quantity,
 2518    string? Article,
 2119    string? ProductName,
 2220    string? ShipmentNumber,
 2021    string? ClientName,
 822    decimal? Price = null,
 523    int? DeliveryDaysMin = null,
 524    int? DeliveryDaysMax = null,
 1325    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
 46    public AvdBasketPlacementService(
 47        AvdDataManager avd,
 48        IOrderHistoryDataService orderHistoryService,
 49        IMemoryCache cache,
 50        IOrderSupplierStateService supplierStateService)
 51    {
 52        _avd = avd;
 53        _orderHistoryService = orderHistoryService;
 54        _cache = cache;
 55        _supplierStateService = supplierStateService;
 56    }
 57
 58    public async Task<AvdBasketPlacementResult> PlaceQueuedItemsAsync(
 59        IReadOnlyCollection<AvdBasketQueueItem> items,
 60        string changedBy,
 61        CancellationToken cancellationToken = default)
 62    {
 63        if (items.Count == 0)
 64            return AvdBasketPlacementResult.Empty();
 65
 66        var entries = new List<AvdBasketPlacementEntry>();
 67        foreach (var item in items)
 68        {
 69            // Человекочитаемый номер заказа для комментария и результата — номер отправления, а не внутренний Id Studio
 70            var orderLabel = !string.IsNullOrWhiteSpace(item.ShipmentNumber) ? item.ShipmentNumber! : $"ID {item.OrderId
 71            var articleLabel = !string.IsNullOrWhiteSpace(item.Article) ? item.Article! : orderLabel;
 72
 73            if (await WasSuccessfullyPlacedAsync(item.OrderId))
 74            {
 75                entries.Add(AvdBasketPlacementEntry.Skipped(articleLabel,
 76                    $"заказ {orderLabel} уже был добавлен в корзину АВД ранее."));
 77                continue;
 78            }
 79
 80            if (string.IsNullOrWhiteSpace(item.ItemHash))
 81            {
 82                var missingHashMessage = $"заказ {orderLabel} — не найден hash предложения АВД.";
 83                entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, missingHashMessage));
 84                await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, missingHashMessage, chang
 85                continue;
 86            }
 87
 88            // Комментарий уходит в АВД и виден там в их собственной корзине — номер отправления + внутренний Id
 89            // Studio2 в скобках (по этому Id /SupplierCart находит системный заказ через StudioOrderCommentPattern).
 90            var comment = $"Studio2 {orderLabel} (ID {item.OrderId})";
 91            var detailParts = new List<string> { $"заказ {orderLabel}" };
 92            if (!string.IsNullOrWhiteSpace(item.ProductName)) detailParts.Add(item.ProductName!);
 93            if (!string.IsNullOrWhiteSpace(item.ClientName)) detailParts.Add(item.ClientName!);
 94            var detail = string.Join(", ", detailParts);
 95
 96            try
 97            {
 98                var response = await _avd.InsertToBasketAsync(item.ItemHash, item.Quantity, comment, onlyThis: false, ca
 99                var apiItemId = response.Descendants().FirstOrDefault(x => x.Name.LocalName == "InsertToBasketResult")?.
 100                if (TryGetInsertError(apiItemId, out var insertError))
 101                {
 102                    entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {insertError}"));
 103                    await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, insertError, changedB
 104                    continue;
 105                }
 106
 107                if (string.IsNullOrWhiteSpace(apiItemId))
 108                {
 109                    const string missingResultError = "АВД не вернул идентификатор добавленной позиции.";
 110                    entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {missingResultError}"));
 111                    await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, missingResultError, c
 112                    continue;
 113                }
 114
 115                entries.Add(AvdBasketPlacementEntry.Success(articleLabel, $"{detail} — добавлено в корзину АВД"));
 116                await TryRecordHistoryAsync(item, orderLabel, success: true, apiItemId, errorMessage: null, changedBy);
 117                await TryTrackCartAsync(item.OrderId, SupplierCartStatusCode.Pending, changedBy, apiItemId,
 118                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 119                _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 120            }
 121            catch (AvdApiException ex)
 122            {
 123                entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {ex.Message}"));
 124                await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, ex.Message, changedBy);
 125            }
 126        }
 127
 128        return new AvdBasketPlacementResult(entries);
 129    }
 130
 131    /// <summary>Добавляет выбранные позиции в корзину АВД и сразу оформляет только эти строки.</summary>
 132    public async Task<AvdDirectCheckoutResult> CheckoutDirectAsync(
 133        IReadOnlyCollection<AvdBasketQueueItem> items,
 134        string changedBy,
 135        CancellationToken cancellationToken = default)
 136    {
 137        if (items.Count == 0)
 138            return new(false, ["Не выбраны позиции АВД для оформления."]);
 139        if (items.GroupBy(x => x.OrderId).Any(x => x.Key <= 0 || x.Count() > 1) ||
 140            items.Any(x => string.IsNullOrWhiteSpace(x.ItemHash) || x.Quantity <= 0))
 141            return new(false, ["Для прямого оформления АВД требуется ровно одно корректное предложение на заказ."]);
 142
 143        var inserted = new List<(AvdBasketQueueItem Item, long BasketId)>();
 144        var messages = new List<string>();
 145        foreach (var item in items)
 146        {
 147            var orderLabel = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber
 148            try
 149            {
 150                var response = await _avd.InsertToBasketAsync(item.ItemHash, item.Quantity,
 151                    $"Studio2 {orderLabel} (ID {item.OrderId})", onlyThis: false, cancellationToken);
 152                var rawId = response.Descendants().FirstOrDefault(x => x.Name.LocalName == "InsertToBasketResult")?.Valu
 153                if (TryGetInsertError(rawId, out var error) || !long.TryParse(rawId, out var basketId) || basketId <= 0)
 154                {
 155                    var message = string.IsNullOrWhiteSpace(error) ? "АВД не вернул идентификатор добавленной позиции." 
 156                    messages.Add($"{item.Article ?? orderLabel}: {message}");
 157                    await TryRecordHistoryAsync(item, orderLabel, false, null, message, changedBy);
 158                    continue;
 159                }
 160                inserted.Add((item, basketId));
 161            }
 162            catch (AvdApiException ex)
 163            {
 164                messages.Add($"{item.Article ?? orderLabel}: {ex.Message}");
 165                await TryRecordHistoryAsync(item, orderLabel, false, null, ex.Message, changedBy);
 166            }
 167        }
 168
 169        if (inserted.Count != items.Count)
 170        {
 171            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 172            messages.Add("Корзина АВД могла получить часть позиций; заказ не создан.");
 173            return new(false, messages);
 174        }
 175
 176        try
 177        {
 178            var orderId = await _avd.CreateOrderByBasketIdsAsync(inserted.Select(x => x.BasketId).ToArray(),
 179                "Studio2: прямое оформление", cancellationToken);
 180            foreach (var (item, basketId) in inserted)
 181            {
 182                var orderLabel = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNu
 183                await TryRecordHistoryAsync(item, orderLabel, true, basketId.ToString(), null, changedBy,
 184                    orderId.ToString());
 185                await TryTrackOrderedAsync(item.OrderId, orderId.ToString(), basketId.ToString(), changedBy,
 186                    item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 187            }
 188            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 189            messages.Add($"Создан заказ АВД №{orderId}.");
 190            return new(true, messages);
 191        }
 192        catch (AvdApiException ex)
 193        {
 194            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 195            messages.Add($"АВД не оформил заказ: {ex.Message}. Добавленные позиции остались в корзине АВД.");
 196            return new(false, messages);
 197        }
 198    }
 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        {
 209            await _supplierStateService.MarkInCartAsync(
 210                orderId, ProviderKey, cartStatusCode, cartItemId: null,
 211                externalBasketItemId: basketItemId, user: changedBy,
 212                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction);
 213        }
 214        catch
 215        {
 216            // Отслеживание статуса — вспомогательный журнал, не критичный путь размещения.
 217        }
 218    }
 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        {
 226            await _supplierStateService.MarkOrderedAsync(
 227                orderId, ProviderKey, externalOrderNumber: externalOrderId,
 228                externalOrderId: externalOrderId, externalLineId: basketItemId, user: changedBy,
 229                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction);
 230        }
 231        catch
 232        {
 233            // См. TryTrackCartAsync.
 234        }
 235    }
 236
 237    private static bool TryGetInsertError(string? result, out string error)
 238    {
 239        error = result switch
 240        {
 241            "0" => "АВД: ошибка добавления позиции в корзину.",
 242            "1" => "АВД: не удалось проверить пользователя.",
 243            "2" => "АВД: IP-адрес не разрешён для клиента.",
 244            "3" => "АВД: ошибка аутентификации пользователя.",
 245            "4" => "АВД: hash предложения недействителен.",
 246            "5" => "АВД: ошибка подключения к базе данных.",
 247            "6" => "АВД: количество не соответствует кратности.",
 248            "7" => "АВД: запрошенное количество превышает остаток поставщика.",
 249            _ => string.Empty
 250        };
 251
 252        return error.Length > 0;
 253    }
 254
 255    private async Task<bool> WasSuccessfullyPlacedAsync(int orderId)
 256    {
 257        var history = await _orderHistoryService.GetByOrderIdAsync(orderId);
 258        return history.Any(entry =>
 259        {
 260            if (!string.Equals(entry.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName,
 261                    StringComparison.Ordinal) || string.IsNullOrWhiteSpace(entry.NewValue))
 262                return false;
 263
 264            // Под этим ColumnName пишут все API-поставщики, поэтому идемпотентность АВД должна
 265            // учитывать только собственные записи: успешное размещение в Армтеке не отменяет АВД.
 266            var placement = ApiSupplierBasketHistoryEntry.TryParse(entry.NewValue);
 267            return placement is { Success: true } &&
 268                   string.Equals(placement.ProviderKey, ProviderKey, StringComparison.Ordinal) &&
 269                   !TryGetInsertError(placement.ApiItemId, out _);
 270        });
 271    }
 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        {
 286            var entry = new ApiSupplierBasketHistoryEntry(
 287                ProviderKey,
 288                SupplierDisplayName,
 289                LogoUrl,
 290                success,
 291                errorMessage,
 292                apiItemId,
 293                item.Article,
 294                item.ProductName,
 295                item.Price,
 296                CurrencyCode,
 297                item.Quantity,
 298                orderLabel,
 299                ClientName: item.ClientName,
 300                CartStatus: success ? ApiSupplierBasketCartStatus.Ordered : ApiSupplierBasketCartStatus.Failed,
 301                SupplierOrderNumber: supplierOrderNumber,
 302                SupplierCartUrl: "/SupplierCart?provider=avd&supplierTab=basket");
 303
 304            await _orderHistoryService.AddOrderHistory(new OrderHistory
 305            {
 306                OrderId = item.OrderId,
 307                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 308                ColumnDisplayName = $"Корзина {SupplierDisplayName}",
 309                OldValue = null,
 310                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 311                ChangedAt = DateTime.Now,
 312                ChangedBy = changedBy
 313            });
 314        }
 315        catch
 316        {
 317            // Не ломаем размещение в корзине из-за журнала истории.
 318        }
 319    }
 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}