< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ArmtekCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ArmtekCartService.cs
Line coverage
68%
Covered lines: 414
Uncovered lines: 189
Coverable lines: 603
Total lines: 1025
Line coverage: 68.6%
Branch coverage
49%
Covered branches: 198
Total branches: 400
Branch coverage: 49.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
TrackCartAsync()100%1157.14%
TrackOrderedAsync()100%1157.14%
TrackRemovedAsync()100%1150%
QueueAsync()68.75%433277.77%
GetActiveAsync(...)100%210%
RequoteAsync()0%506220%
<RequoteAsync()100%210%
ReplaceWithRequotedOfferAsync()0%7280%
GetOrdersAsync(...)100%210%
BuildDirectOrderPreviewAsync()0%620%
CheckoutDirectAsync()83.33%424298.07%
TestAsync()75%292479.59%
UpdateDeliveryAddressAsync()50%121293.75%
CheckoutAsync()34.09%5074437.93%
RemoveAsync()0%620%
RemoveManyAsync()71.42%141495.23%
CountActiveAsync(...)100%210%
GetTrackedActiveAsync(...)100%22100%
ResolveClient(...)50%66100%
BuildDestinationError(...)100%44100%
GroupByDestination(...)100%11100%
FindActiveSupplierOrdersAsync()85.71%1414100%
PrepareDirectOrderItemsAsync()66.66%262484.21%
get_Item()100%11100%
ToRequestItem(...)100%22100%
OrderComment(...)100%11100%
FormatDateMax(...)50%22100%
DescribeDestination(...)0%620%
GetOrCreateOrderAsync()50%22100%
RecordHistoryAsync()50%2294.28%
CartUrl(...)50%22100%
ToRequestItems(...)50%22100%
FindResponseItem(...)25%211260%
FindResponseItem(...)25%211260%
HasApiItemError(...)100%22100%
GetItemError(...)75%1212100%
RejectOrderPositionsAsync()0%1482380%
ArmtekMessages(...)0%4260%
IsProvider(...)50%44100%
NormalizePin(...)100%11100%
ValidateSelection(...)75%44100%
ProviderName(...)50%22100%
BuildError(...)87.5%88100%
ParseDecimal(...)50%44100%
TryRefreshOfferAsync()50%4470%
CheckRefreshedOfferAsync()66.66%66100%
HasEnoughStock(...)50%22100%
ParseStock(...)50%44100%
ApplyRefreshedOffer(...)57.14%1414100%
get_Offer()100%11100%
get_TestResponse()100%11100%
ParseDate(...)50%22100%
ParseApiDate(...)75%44100%
Describe(...)50%66100%
ToQueueItem(...)50%22100%
LogApiExchange(...)75%44100%
InvalidateCount()100%11100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Text.Json;
 4using System.Text.Json.Serialization;
 5using Microsoft.EntityFrameworkCore;
 6using Microsoft.Extensions.Caching.Memory;
 7using Microsoft.Extensions.Logging;
 8using OzonDomains.Models;
 9using OzonDomains.Models.SupplierStatuses;
 10using OzonRepositories.Context;
 11using Services.ApiServices.ArmtekApi;
 12using Services.ApiServices.ArmtekApi.Models.Response;
 13using Services.ApiSupplierSettingsServices;
 14using Services.DataServices;
 15using Services.SupplierStatusServices;
 16using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart;
 17
 18namespace OzonOrdersWeb.TransactionServices;
 19
 20public sealed record ArmtekCartQueueItem(
 21    int OrderId, string ProviderKey, string ArtId, string Pin, string Brand, string? Keyzak,
 22    string? WarehouseName, string? PartnerWarehouseCode, string? ProductName, string? ShipmentNumber, string? ClientName
 23    decimal? SourcePrice, string? CurrencyCode, string? DeliveryAtUtc, string? ZasylWarehouseName = null,
 24    int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, string? Direction = null);
 25
 26/// <summary>Шапка заказа Armtek: покупатель и адрес доставки. Позиции с разными парами уходят разными запросами.</summa
 27public sealed record ArmtekDestination(string Kunrg, string Kunza);
 28
 29public sealed record ArmtekCartOperationResult(
 30    int Added,
 31    int Skipped,
 32    int Failed,
 33    IReadOnlyList<string> Messages,
 34    IReadOnlyCollection<string>? ProviderKeys = null)
 35{
 36    public string ToHtmlBlock()
 37    {
 38        var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>");
 39        var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty;
 40        var cartLinks = (ProviderKeys ?? Array.Empty<string>())
 41            .Distinct(StringComparer.OrdinalIgnoreCase)
 42            .Select(provider => provider switch
 43            {
 44                "Armtek" => "<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?provider=armtek&amp;supplierT
 45                "ArmtekBy" => "<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?provider=armtekby&amp;suppl
 46                _ => string.Empty
 47            })
 48            .Where(link => !string.IsNullOrEmpty(link));
 49        var verificationNotice = Added > 0
 50            ? "<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'></
 51            : string.Empty;
 52        return "<div class='alert alert-info border-primary-subtle mb-0'>" +
 53               "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина Армтек</div>
 54               $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b
 55               $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" +
 56               $"<div class='d-flex flex-wrap gap-2 mt-2'>{string.Join(string.Empty, cartLinks)}</div>{verificationNotic
 57    }
 58}
 59
 60/// <summary>Безопасное для UI представление тела createOrder (без учётных данных).</summary>
 61public sealed record ArmtekOrderPreviewItem(
 62    [property: JsonPropertyName("PIN")] string Pin,
 63    [property: JsonPropertyName("BRAND")] string Brand,
 64    [property: JsonPropertyName("KWMENG")] decimal Quantity,
 65    [property: JsonPropertyName("KEYZAK")] string? Keyzak,
 66    [property: JsonPropertyName("PRICEMAX")] decimal? PriceMax,
 67    [property: JsonPropertyName("DATEMAX")] string? DateMax);
 68public sealed record ArmtekOrderPreview(
 69    [property: JsonPropertyName("providerKey")] string ProviderKey,
 70    [property: JsonPropertyName("VKORG")] string Vkorg,
 71    [property: JsonPropertyName("KUNRG")] string Kunrg,
 72    [property: JsonPropertyName("KUNZA")] string Kunza,
 73    [property: JsonPropertyName("INCOTERMS")] string Incoterms,
 74    [property: JsonPropertyName("DBTYP")] string Dbtyp,
 75    [property: JsonPropertyName("ITEMS")] IReadOnlyCollection<ArmtekOrderPreviewItem> Items);
 76public sealed record ArmtekDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages);
 77
 78/// <summary>Актуальное предложение из повторной проценки Armtek для замены строки корзины.</summary>
 79public sealed record ArmtekCartRequoteOffer(string Keyzak, string ArtId, string WarehouseCode, string WarehouseName, str
 80    decimal Price, string CurrencyCode, decimal? AvailableQuantity, DateTime? DeliveryAtUtc, bool IsOrderable);
 81public sealed record ArmtekCartRequoteResult(string OriginalWarehouse, string DeliveryAddress, IReadOnlyList<ArmtekCartR
 82
 83/// <summary>Локальная корзина Armtek. В API Armtek корзины нет: только test-order и real order.</summary>
 84public sealed class ArmtekCartService
 85{
 186    private static readonly SemaphoreSlim RefreshTestConcurrency = new(3, 3);
 87
 88    /// <summary>
 89    /// Срок, в течение которого строка ArmtekOrderItems без записи в отслеживании статусов считается активным заказом.
 90    /// Старше — это заказы до появления отслеживания, их повторное оформление не блокируем.
 91    /// </summary>
 192    private static readonly TimeSpan UntrackedOrderWindow = TimeSpan.FromDays(7);
 93    private readonly ApplicationDbContext _db;
 94    private readonly ArmtekDataManager _armtek;
 95    private readonly ArmtekByDataManager _armtekBy;
 96    private readonly IOrderHistoryDataService _history;
 97    private readonly IMemoryCache _cache;
 98    private readonly ArmtekDeliveryAddressMappingService _deliveryAddressMappings;
 99    private readonly IOrderSupplierStateService _supplierState;
 100    private readonly ILogger<ArmtekCartService> _logger;
 101    private readonly ISupplierOrderGuard? _guard;
 102
 32103    public ArmtekCartService(ApplicationDbContext db, ArmtekDataManager armtek, ArmtekByDataManager armtekBy, IOrderHist
 32104        ISupplierOrderGuard? guard = null)
 105    {
 32106        _guard = guard;
 32107        _db = db;
 32108        _armtek = armtek;
 32109        _armtekBy = armtekBy;
 32110        _history = history;
 32111        _cache = cache;
 32112        _deliveryAddressMappings = deliveryAddressMappings;
 32113        _supplierState = supplierState;
 32114        _logger = logger;
 32115    }
 116
 117    /// <summary>
 118    /// Отражает состояние строки корзины в отслеживании статусов поставщика.
 119    /// Промежуточные переходы внутри корзины (Ready/Processing/Failed) отдельно не пишем:
 120    /// их подхватывает LocalCartStatusReader, читая Status строки напрямую. Здесь фиксируем
 121    /// только появление строки, оформление и уход из корзины. Сбой журнала не ломает корзину.
 122    /// </summary>
 123    private async Task TrackCartAsync(int orderId, string providerKey, string cartStatusCode, long cartItemId, string? u
 124        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null)
 125    {
 126        try
 127        {
 2128            await _supplierState.MarkInCartAsync(orderId, providerKey, cartStatusCode, cartItemId, user: user,
 2129                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 2130        }
 0131        catch (Exception ex)
 132        {
 0133            _logger.LogWarning(ex, "{Provider}: не удалось записать состояние корзины для заказа {OrderId}", providerKey
 0134        }
 2135    }
 136
 137    private async Task TrackOrderedAsync(int orderId, string providerKey, string? orderNumbers, string? user, Cancellati
 138        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null)
 139    {
 140        try
 141        {
 8142            await _supplierState.MarkOrderedAsync(orderId, providerKey, orderNumbers, user: user,
 8143                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 8144        }
 0145        catch (Exception ex)
 146        {
 0147            _logger.LogWarning(ex, "{Provider}: не удалось привязать заказ {OrderId} к заказу поставщика", providerKey, 
 0148        }
 8149    }
 150
 151    private async Task TrackRemovedAsync(int orderId, string providerKey, string? user, CancellationToken ct)
 152    {
 153        try
 154        {
 2155            await _supplierState.RemoveFromCartAsync(orderId, providerKey, user, ct);
 2156        }
 0157        catch (Exception ex)
 158        {
 0159            _logger.LogWarning(ex, "{Provider}: не удалось закрыть состояние корзины для заказа {OrderId}", providerKey,
 0160        }
 2161    }
 162
 163    public async Task<ArmtekCartOperationResult> QueueAsync(IReadOnlyCollection<ArmtekCartQueueItem> queue, string chang
 164    {
 2165        var messages = new List<string>();
 6166        var added = 0; var skipped = 0; var failed = 0;
 8167        foreach (var item in queue)
 168        {
 2169            if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 || string.IsNullOrWhiteSpace(it
 170            {
 0171                failed++;
 172                const string incompleteError = "Неполные данные предложения Armtek.";
 0173                messages.Add($"{Describe(item)} — неполные данные предложения Armtek.");
 0174                if (IsProvider(item.ProviderKey) && item.OrderId > 0)
 0175                    await RecordHistoryAsync(item.OrderId, item, false, incompleteError, ApiSupplierBasketCartStatus.Fai
 0176                        supplierOrderNumber: null, buyerKunnr: null, deliveryAddressName: null, changedBy, ct);
 0177                continue;
 178            }
 2179            var exists = await _db.ArmtekCartItems.AnyAsync(x => x.ProviderKey == item.ProviderKey && x.StudioOrderId ==
 2180            if (exists) { skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.")
 181
 182            // Клиент и адрес доставки берём той же связью «склад отгрузки → адрес Armtek», что и проценка,
 183            // и фиксируем в строке: поздние правки справочника не должны менять уже собранную корзину.
 2184            var deliveryMapping = await _deliveryAddressMappings.ResolveAsync(item.ProviderKey, item.OrderId, item.Zasyl
 2185            var cartItem = new ArmtekCartItem
 2186            {
 2187                ProviderKey = item.ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, ArtId 
 2188                Brand = item.Brand, Keyzak = item.Keyzak, WarehouseName = item.WarehouseName,
 2189                PartnerWarehouseCode = item.PartnerWarehouseCode, ProductName = item.ProductName,
 2190                BuyerKunnr = deliveryMapping?.BuyerKunnr, DeliveryAddressKunnr = deliveryMapping?.DeliveryAddressKunnr,
 2191                Quantity = item.Quantity, SourcePrice = item.SourcePrice, CurrencyCode = item.CurrencyCode ?? "RUB",
 2192                DeliveryAtUtc = ParseApiDate(item.DeliveryAtUtc), CreatedBy = changedBy
 2193            };
 2194            _db.ArmtekCartItems.Add(cartItem);
 195            try
 196            {
 2197                await _db.SaveChangesAsync(ct);
 2198            }
 0199            catch (DbUpdateException)
 200            {
 0201                _db.Entry(cartItem).State = EntityState.Detached;
 0202                skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.");
 0203                continue;
 204            }
 2205            added++;
 2206            messages.Add(deliveryMapping is null
 2207                ? $"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}, но склад отгрузки не связан
 2208                : $"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}; требуется проверка перед оф
 2209            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending,
 2210                supplierOrderNumber: null, buyerKunnr: deliveryMapping?.BuyerKunnr,
 2211                deliveryAddressName: deliveryMapping?.DeliveryAddressKunnr, changedBy, ct);
 2212            await TrackCartAsync(item.OrderId, item.ProviderKey, ArmtekCartItemStatus.Pending, cartItem.Id, changedBy, c
 2213                item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 2214        }
 2215        InvalidateCount();
 2216        var providerKeys = queue
 217            .Where(item => IsProvider(item.ProviderKey))
 218            .Select(item => item.ProviderKey)
 2219            .Distinct(StringComparer.OrdinalIgnoreCase)
 2220            .ToArray();
 2221        return new ArmtekCartOperationResult(added, skipped, failed, messages, providerKeys);
 2222    }
 223
 224    public Task<List<ArmtekCartItem>> GetActiveAsync(string providerKey, CancellationToken ct = default) =>
 0225        _db.ArmtekCartItems.AsNoTracking().Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo)
 0226            .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer)
 0227            .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 0228            .Where(x => x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(x.Status))
 0229            .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 230
 231    /// <summary>Повторная проценка позиции для зафиксированных клиента и адреса доставки, без привязки к старому складу
 232    public async Task<ArmtekCartRequoteResult> RequoteAsync(string providerKey, long cartItemId, CancellationToken ct = 
 233    {
 0234        var item = await _db.ArmtekCartItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cartItemId && x.ProviderKey
 0235            ?? throw new InvalidOperationException("Позиция корзины не найдена или уже оформляется.");
 0236        if (string.IsNullOrWhiteSpace(item.BuyerKunnr) || string.IsNullOrWhiteSpace(item.DeliveryAddressKunnr))
 0237            throw new InvalidOperationException("Сначала выберите клиента и адрес доставки Armtek для этой позиции.");
 238
 0239        var (client, vkorg) = ResolveClient(providerKey);
 0240        var response = await client.SearchAsync(item.BuyerKunnr, NormalizePin(item.Pin), item.Brand.Trim(), vkorg,
 0241            queryType: 1, kunnrZa: item.DeliveryAddressKunnr, incotermsSelfPickup: false, ct: ct);
 0242        var error = BuildError(response);
 0243        if (!string.IsNullOrWhiteSpace(error)) throw new ArmtekApiException(error);
 244
 0245        var stores = await client.GetStoreListAsync(vkorg, ct);
 246        var storeNames = (stores.Resp ?? []).Where(x => !string.IsNullOrWhiteSpace(x.SklCode))
 247            .GroupBy(x => x.SklCode, StringComparer.Ordinal).ToDictionary(x => x.Key, x => x.First().SklName, StringComp
 248
 0249        var rawOffers = (response.Resp ?? [])
 0250            .Where(x => string.Equals(NormalizePin(x.Pin), NormalizePin(item.Pin), StringComparison.OrdinalIgnoreCase)
 0251                        && string.Equals(x.Brand, item.Brand, StringComparison.OrdinalIgnoreCase))
 252            .Select(x => new { Offer = x, Price = ParseDecimal(x.Price) })
 0253            .Where(x => x.Price is not null && HasEnoughStock(x.Offer.Rvalue, item.Quantity) && !string.IsNullOrWhiteSpa
 254            .OrderBy(x => x.Price)
 255            .ThenBy(x => ParseApiDate(x.Offer.DeliveryDate) ?? DateTime.MaxValue)
 0256            .Take(8)
 257            .Select(x => new { x.Offer, Price = x.Price!.Value })
 0258            .ToArray();
 0259        var destination = new ArmtekDestination(item.BuyerKunnr, item.DeliveryAddressKunnr);
 0260        var confirmed = await Task.WhenAll(rawOffers.Select(async x => new
 0261        {
 0262            x.Offer,
 0263            x.Price,
 0264            IsOrderable = await CheckRefreshedOfferAsync(client, vkorg, destination, item, x.Offer, ct) is not null
 0265        }));
 0266        var offers = confirmed
 0267            .Select(x => new ArmtekCartRequoteOffer(x.Offer.Keyzak, x.Offer.ArtId, x.Offer.PartnerWarehouseCode,
 0268                storeNames.GetValueOrDefault(x.Offer.PartnerWarehouseCode, "Склад Armtek"), x.Offer.Name,
 0269                x.Price, string.IsNullOrWhiteSpace(x.Offer.Currency) ? item.CurrencyCode : x.Offer.Currency,
 0270                ParseStock(x.Offer.Rvalue), ParseApiDate(x.Offer.DeliveryDate), x.IsOrderable))
 0271            .ToArray();
 0272        return new ArmtekCartRequoteResult(
 0273            string.IsNullOrWhiteSpace(item.WarehouseName) ? item.PartnerWarehouseCode ?? "—" : $"{item.WarehouseName} ({
 0274            item.DeliveryAddressName ?? item.DeliveryAddressKunnr ?? "—", offers);
 0275    }
 276
 277    /// <summary>Заменяет отклонённое предложение на выбранный вариант свежей проценки и требует новую проверку.</summar
 278    public async Task<bool> ReplaceWithRequotedOfferAsync(string providerKey, long cartItemId, string keyzak, Cancellati
 279    {
 0280        var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId && x.ProviderKey == providerKey
 0281        if (item is null || item.Status == ArmtekCartItemStatus.Processing) return false;
 0282        var requote = await RequoteAsync(providerKey, cartItemId, ct);
 0283        var offer = requote.Offers.FirstOrDefault(x => string.Equals(x.Keyzak, keyzak, StringComparison.Ordinal) && x.Is
 0284        if (offer is null) throw new InvalidOperationException("Выбранное предложение уже не актуально. Выполните процен
 285
 0286        item.ArtId = offer.ArtId;
 0287        item.Keyzak = offer.Keyzak;
 0288        item.PartnerWarehouseCode = offer.WarehouseCode;
 0289        item.ProductName = string.IsNullOrWhiteSpace(offer.Name) ? item.ProductName : offer.Name;
 0290        item.SourcePrice = offer.Price;
 0291        item.CurrencyCode = offer.CurrencyCode;
 0292        item.DeliveryAtUtc = offer.DeliveryAtUtc;
 0293        item.Status = ArmtekCartItemStatus.Pending;
 0294        item.TestedAtUtc = null;
 0295        item.TestResultJson = null;
 0296        item.LastError = "Выбрано актуальное предложение из новой проценки. Перед оформлением выполните проверку.";
 0297        item.UpdatedAtUtc = DateTime.UtcNow;
 0298        await _db.SaveChangesAsync(ct);
 0299        return true;
 0300    }
 301
 302    public Task<List<ArmtekOrder>> GetOrdersAsync(string providerKey, CancellationToken ct = default) =>
 0303        _db.ArmtekOrders.AsNoTracking().Include(x => x.Items).ThenInclude(x => x.StudioOrder)
 0304            .Where(x => x.ProviderKey == providerKey).OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 305
 306    /// <summary>Строит фактические параметры прямого оформления по актуальной связи склада и адреса Armtek.</summary>
 307    public async Task<(IReadOnlyList<ArmtekOrderPreview> Previews, IReadOnlyList<string> Errors)> BuildDirectOrderPrevie
 308        IReadOnlyCollection<ArmtekCartQueueItem> queue, CancellationToken ct = default)
 309    {
 0310        var prepared = await PrepareDirectOrderItemsAsync(queue, ct);
 0311        if (prepared.Errors.Count > 0) return ([], prepared.Errors);
 312        return (prepared.Items.GroupBy(x => new { x.Item.ProviderKey, x.Vkorg, x.Destination })
 313            .Select(group => new ArmtekOrderPreview(group.Key.ProviderKey, group.Key.Vkorg, group.Key.Destination.Kunrg,
 314                group.Key.Destination.Kunza, "0", "3", group.Select(x => new ArmtekOrderPreviewItem(x.Item.Pin, x.Item.B
 315                    x.Item.Quantity, x.Item.Keyzak, x.Item.SourcePrice, FormatDateMax(x.Item.DeliveryAtUtc))).ToArray())
 0316            .ToArray(), []);
 0317    }
 318
 319    /// <summary>
 320    /// Оформляет позиции без локальной корзины. Вызывается до проведения статусов Studio2.
 321    /// Позиции, по которым у Armtek уже есть активный заказ, повторно не отправляются: повтор запроса
 322    /// (перезагрузка страницы, двойной клик) иначе создал бы у поставщика второй заказ с теми же позициями.
 323    /// </summary>
 324    /// <param name="confirmedReorderOrderIds">Заказы Studio2, по которым пользователь явно подтвердил повторный заказ
 325    /// (см. <see cref="ISupplierOrderGuard"/>): для них защита от дубля не срабатывает. Фоновые вызовы передают <c>null
 326    public async Task<ArmtekDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ArmtekCartQueueItem> queue,
 327        string changedBy, CancellationToken ct = default, IReadOnlySet<int>? confirmedReorderOrderIds = null)
 328    {
 18329        var alreadyOrdered = await FindActiveSupplierOrdersAsync(queue, ct);
 18330        if (confirmedReorderOrderIds is { Count: > 0 })
 331        {
 10332            foreach (var key in alreadyOrdered.Keys.Where(x => confirmedReorderOrderIds.Contains(x.OrderId)).ToList())
 1333                alreadyOrdered.Remove(key);
 334        }
 18335        var messages = new List<string>();
 18336        if (alreadyOrdered.Count > 0)
 337        {
 4338            messages.AddRange(queue
 5339                .Where(x => alreadyOrdered.ContainsKey((x.ProviderKey, x.OrderId)))
 8340                .Select(x => $"{x.Pin}: заказ Армтек {alreadyOrdered[(x.ProviderKey, x.OrderId)]} уже оформлен ранее — п
 8341                             "Если заказ нужно оформить заново, сначала отмените его у поставщика."));
 9342            queue = queue.Where(x => !alreadyOrdered.ContainsKey((x.ProviderKey, x.OrderId))).ToArray();
 7343            if (queue.Count == 0) return new(true, messages);
 344        }
 345
 15346        var prepared = await PrepareDirectOrderItemsAsync(queue, ct);
 16347        if (prepared.Errors.Count > 0) return new(false, prepared.Errors);
 348
 349        var groups = prepared.Items.GroupBy(x => new { x.Item.ProviderKey, x.Vkorg, x.Destination }).ToArray();
 55350        foreach (var group in groups)
 351        {
 352            var requestItems = group.Select(x => ToRequestItem(x.Item)).ToArray();
 14353            var response = await ResolveClient(group.Key.ProviderKey).Client.CreateTestOrderAsync(group.Key.Destination.
 14354                requestItems, group.Key.Vkorg, group.Key.Destination.Kunza, false, ct);
 14355            LogApiExchange(group.Key.ProviderKey, "createTestOrder/direct", group.Key.Vkorg, group.Key.Destination, requ
 14356            var error = BuildError(response);
 357            foreach (var (preparedItem, index) in group.Select((item, index) => (item, index)))
 358            {
 14359                error ??= GetItemError(FindResponseItem(response, preparedItem.Item, index));
 14360                if (!string.IsNullOrWhiteSpace(error))
 1361                    return new(false, [$"{preparedItem.Item.Pin}: {error}"]);
 362            }
 13363        }
 364
 47365        foreach (var group in groups)
 366        {
 367            var requestItems = group.Select(x => ToRequestItem(x.Item)).ToArray();
 13368            var response = await ResolveClient(group.Key.ProviderKey).Client.CreateOrderAsync(group.Key.Destination.Kunr
 13369                requestItems, group.Key.Vkorg, group.Key.Destination.Kunza, false, ct,
 370                OrderComment(group.Select(x => x.Item.OrderId)));
 9371            LogApiExchange(group.Key.ProviderKey, "createOrder/direct", group.Key.Vkorg, group.Key.Destination, requestI
 9372            var responseError = BuildError(response);
 10373            if (!string.IsNullOrWhiteSpace(responseError)) return new(false, [responseError]);
 374
 375            // Заказ у Armtek уже создан: отмена запроса не должна оборвать запись его номера в БД,
 376            // иначе повтор не увидит первый заказ и оформит второй.
 8377            var persistCt = CancellationToken.None;
 8378            var requestJson = JsonSerializer.Serialize(requestItems);
 8379            var responseJson = JsonSerializer.Serialize(response);
 380            foreach (var (preparedItem, index) in group.Select((item, index) => (item, index)))
 381            {
 8382                var apiItem = FindResponseItem(response, preparedItem.Item, index);
 8383                var itemError = GetItemError(apiItem);
 384                var allocations = apiItem?.Result.Where(x => !string.IsNullOrWhiteSpace(x.Vbeln) && !HasApiItemError(x.E
 8385                if (!string.IsNullOrWhiteSpace(itemError) || allocations.Count == 0)
 0386                    return new(false, [$"{preparedItem.Item.Pin}: {itemError ?? "Армтек не вернул номер созданного заказ
 387
 32388                foreach (var allocation in allocations)
 389                {
 8390                    var order = await GetOrCreateOrderAsync(preparedItem.Item.ProviderKey, group.Key.Vkorg, group.Key.De
 8391                        allocation.Vbeln!, changedBy, requestJson, responseJson, persistCt);
 8392                    _db.ArmtekOrderItems.Add(new OzonDomains.Models.ArmtekOrderItem
 8393                    {
 8394                        ArmtekOrderId = order.Id, StudioOrderId = preparedItem.Item.OrderId, Posnr = allocation.Posnr,
 8395                        Pin = preparedItem.Item.Pin, Brand = preparedItem.Item.Brand,
 8396                        Quantity = ParseDecimal(allocation.Quantity) ?? preparedItem.Item.Quantity,
 8397                        Price = ParseDecimal(allocation.Price), CurrencyCode = allocation.Currency,
 8398                        DeliveryAtUtc = ParseDate(allocation.DeliveryDate), Error = allocation.Error
 8399                    });
 8400                }
 401                var supplierOrderNumbers = string.Join(", ", allocations.Select(x => x.Vbeln).Distinct());
 402                // Прямое оформление обходит локальную корзину, поэтому фиксируем состояние
 403                // сразу здесь, а не ждём фонового опроса поставщика.
 8404                await TrackOrderedAsync(preparedItem.Item.OrderId, preparedItem.Item.ProviderKey,
 8405                    supplierOrderNumbers, changedBy, persistCt,
 8406                    preparedItem.Item.DeliveryDaysMin, preparedItem.Item.DeliveryDaysMax, preparedItem.Item.Direction);
 8407                messages.Add($"{preparedItem.Item.Pin}: заказ Армтек {supplierOrderNumbers} создан.");
 8408            }
 8409            await _db.SaveChangesAsync(persistCt);
 8410        }
 8411        return new(true, messages);
 14412    }
 413
 414    public async Task<ArmtekCartOperationResult> TestAsync(string providerKey, string changedBy, IReadOnlyCollection<lon
 415    {
 2416        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 2417        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 2418        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 2419        if (selectedIds is not null && items.Count != selectedIds.Count)
 0420            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите проверку."]);
 2421        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 2422        var destinationError = BuildDestinationError(items);
 3423        if (destinationError is not null) return new(0, 0, items.Count, [destinationError]);
 424
 1425        var (client, vkorg) = ResolveClient(providerKey);
 1426        var ready = 0;
 1427        var failed = 0;
 1428        var messages = new List<string>();
 429        // KUNRG/KUNZA — параметры шапки заказа, поэтому на каждый адрес доставки уходит отдельный запрос.
 4430        foreach (var group in GroupByDestination(items))
 431        {
 1432            var groupItems = group.ToList();
 1433            var requestItems = ToRequestItems(groupItems);
 1434            var response = await client.CreateTestOrderAsync(group.Key.Kunrg, requestItems, vkorg, group.Key.Kunza, fals
 1435            LogApiExchange(providerKey, "createTestOrder", vkorg, group.Key, requestItems, response);
 1436            var responseError = BuildError(response);
 1437            var resultJson = JsonSerializer.Serialize(response);
 438            foreach (var (item, index) in groupItems.Select((item, index) => (item, index)))
 439            {
 1440                var apiItem = FindResponseItem(response, item, index);
 1441                var error = responseError ?? GetItemError(apiItem);
 3442                item.TestedAtUtc = DateTime.UtcNow; item.TestResultJson = resultJson; item.UpdatedAtUtc = DateTime.UtcNo
 1443                if (string.IsNullOrWhiteSpace(error))
 444                {
 0445                    item.Status = ArmtekCartItemStatus.Ready;
 0446                    item.LastError = null;
 0447                    ready++;
 0448                    messages.Add($"{item.Pin}: проверено у поставщика ({DescribeDestination(item)}). Можно оформлять зак
 449                }
 450                else
 451                {
 1452                    var refresh = await TryRefreshOfferAsync(client, vkorg, group.Key, item, ct);
 1453                    if (refresh is not null)
 454                    {
 1455                        var refreshMessage = ApplyRefreshedOffer(item, refresh.Offer, error);
 1456                        item.Status = ArmtekCartItemStatus.Ready;
 1457                        item.TestedAtUtc = DateTime.UtcNow;
 1458                        item.TestResultJson = JsonSerializer.Serialize(refresh.TestResponse);
 1459                        item.LastError = refreshMessage;
 1460                        ready++;
 1461                        messages.Add($"{item.Pin}: {refreshMessage}");
 1462                        await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), true, refreshMessage,
 1463                            ApiSupplierBasketCartStatus.Ready, supplierOrderNumber: null, item.BuyerKunnr,
 1464                            item.DeliveryAddressName ?? item.DeliveryAddressKunnr, changedBy, ct);
 1465                        continue;
 466                    }
 467
 0468                    item.Status = ArmtekCartItemStatus.Failed;
 0469                    item.LastError = $"{error} для Арт. {item.Pin} · {item.Brand}.";
 0470                    failed++;
 0471                    messages.Add(item.LastError);
 472                }
 0473            }
 1474        }
 1475        await _db.SaveChangesAsync(ct);
 1476        return new(ready, 0, failed, messages);
 2477    }
 478
 479    /// <summary>Меняет адрес доставки строки корзины: связь склада могла отсутствовать или устареть.</summary>
 480    public async Task<bool> UpdateDeliveryAddressAsync(long id, string providerKey, string buyerKunnr, string deliveryAd
 481        string? deliveryAddressName, CancellationToken ct = default)
 482    {
 1483        if (!IsProvider(providerKey) || string.IsNullOrWhiteSpace(buyerKunnr) || string.IsNullOrWhiteSpace(deliveryAddre
 0484            return false;
 1485        var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(
 1486            x => x.Id == id && x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(x.Status), ct);
 1487        if (item is null || item.Status == ArmtekCartItemStatus.Processing) return false;
 488
 1489        item.BuyerKunnr = buyerKunnr.Trim();
 1490        item.DeliveryAddressKunnr = deliveryAddressKunnr.Trim();
 1491        item.DeliveryAddressName = string.IsNullOrWhiteSpace(deliveryAddressName) ? null : deliveryAddressName.Trim();
 492        // Адрес влияет на цену и склад: подтверждение Armtek больше не действительно.
 1493        item.Status = ArmtekCartItemStatus.Pending;
 1494        item.TestedAtUtc = null;
 1495        item.TestResultJson = null;
 1496        item.LastError = null;
 1497        item.UpdatedAtUtc = DateTime.UtcNow;
 1498        await _db.SaveChangesAsync(ct);
 1499        return true;
 1500    }
 501
 502    public async Task<ArmtekCartOperationResult> CheckoutAsync(string providerKey, string changedBy, IReadOnlyCollection
 503    {
 1504        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 1505        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 1506        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 1507        if (selectedIds is not null && items.Count != selectedIds.Count)
 0508            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите оформление."])
 1509        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 510        if (items.Any(x => x.Status != ArmtekCartItemStatus.Ready || x.TestedAtUtc < DateTime.UtcNow.AddMinutes(-5)))
 0511            return new(0, 0, items.Count, ["Перед оформлением выполните свежую проверку Armtek."]);
 1512        var destinationError = BuildDestinationError(items);
 1513        if (destinationError is not null) return new(0, 0, items.Count, [destinationError]);
 514
 515        var ids = items.Select(x => x.Id).ToArray();
 1516        var locked = await _db.ArmtekCartItems.Where(x => ids.Contains(x.Id) && x.Status == ArmtekCartItemStatus.Ready)
 1517            .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ArmtekCartItemStatus.Processing).SetProperty(x => x.Up
 1518        if (locked != items.Count) return new(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]);
 519
 520        try
 521        {
 1522            var (client, vkorg) = ResolveClient(providerKey);
 3523            var success = 0; var failed = 0; var messages = new List<string>();
 3524            foreach (var group in GroupByDestination(items))
 525            {
 1526                var groupItems = group.ToList();
 1527                var requestItems = ToRequestItems(groupItems);
 1528                var response = await client.CreateOrderAsync(group.Key.Kunrg, requestItems, vkorg, group.Key.Kunza, fals
 529                    OrderComment(groupItems.Select(x => x.StudioOrderId)));
 0530                LogApiExchange(providerKey, "createOrder", vkorg, group.Key, requestItems, response);
 0531                var responseJson = JsonSerializer.Serialize(response);
 0532                var requestJson = JsonSerializer.Serialize(requestItems);
 0533                var apiError = BuildError(response);
 0534                for (var index = 0; index < groupItems.Count; index++)
 535                {
 0536                    var cartItem = groupItems[index];
 0537                    var apiItem = FindResponseItem(response, cartItem, index);
 538                    var allocations = apiItem?.Result.Where(x => !string.IsNullOrWhiteSpace(x.Vbeln) && !HasApiItemError
 0539                    var itemError = apiError ?? GetItemError(apiItem);
 0540                    if (allocations.Count == 0 || !string.IsNullOrWhiteSpace(itemError))
 541                    {
 0542                        cartItem.Status = ArmtekCartItemStatus.Failed;
 0543                        cartItem.LastError = itemError ?? "Армтек не вернул номер созданного заказа.";
 0544                        failed++; messages.Add($"{cartItem.Pin}: {cartItem.LastError}");
 0545                        continue;
 546                    }
 0547                    foreach (var allocation in allocations)
 548                    {
 0549                        var order = await GetOrCreateOrderAsync(providerKey, vkorg, group.Key, allocation.Vbeln!, change
 0550                        _db.ArmtekOrderItems.Add(new OzonDomains.Models.ArmtekOrderItem
 0551                        {
 0552                            ArmtekOrderId = order.Id, ArmtekCartItemId = cartItem.Id, StudioOrderId = cartItem.StudioOrd
 0553                            Posnr = allocation.Posnr, Pin = cartItem.Pin, Brand = cartItem.Brand,
 0554                            Quantity = ParseDecimal(allocation.Quantity) ?? cartItem.Quantity, Price = ParseDecimal(allo
 0555                            CurrencyCode = allocation.Currency, DeliveryAtUtc = ParseDate(allocation.DeliveryDate), Erro
 0556                        });
 0557                    }
 0558                    cartItem.Status = ArmtekCartItemStatus.Ordered; cartItem.ActiveStudioOrderId = null; cartItem.LastEr
 559                    var orderNumbers = string.Join(", ", allocations.Select(x => x.Vbeln).Distinct());
 0560                    success++; messages.Add($"{cartItem.Pin}: заказ Армтек {orderNumbers} создан ({DescribeDestination(c
 0561                    await RecordHistoryAsync(cartItem.StudioOrderId, ToQueueItem(cartItem), true, null,
 0562                        ApiSupplierBasketCartStatus.Ordered, orderNumbers, cartItem.BuyerKunnr,
 0563                        cartItem.DeliveryAddressName ?? cartItem.DeliveryAddressKunnr, changedBy, ct);
 0564                    await TrackOrderedAsync(cartItem.StudioOrderId, providerKey, orderNumbers, changedBy, ct);
 0565                }
 0566            }
 0567            await _db.SaveChangesAsync(ct);
 0568            InvalidateCount();
 0569            return new(success, 0, failed, messages);
 570        }
 1571        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 572        {
 573            // Обрыв соединения мог случиться уже после того, как Армтек принял заказ — исход неизвестен.
 2574            if (ex is HttpRequestException) _guard?.CurrentDispatch?.MarkUncertain(providerKey);
 1575            await _db.ArmtekCartItems.Where(x => ids.Contains(x.Id) && x.Status == ArmtekCartItemStatus.Processing)
 1576                .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ArmtekCartItemStatus.Failed).SetProperty(x => x.La
 1577            return new(0, 0, items.Count, [$"Армтек: {ex.Message}"]);
 578        }
 1579    }
 580
 581    public async Task<bool> RemoveAsync(long id, string providerKey, CancellationToken ct = default)
 582    {
 0583        var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(x => x.Id == id && x.ProviderKey == providerKey && Armt
 0584        if (item is null) return false;
 0585        item.Status = ArmtekCartItemStatus.Removed; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.UtcNow
 0586        await _db.SaveChangesAsync(ct);
 0587        await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 0588        InvalidateCount(); return true;
 0589    }
 590
 591    /// <summary>Убирает из корзины выбранные строки (или всю корзину провайдера, если ids не заданы).</summary>
 592    public async Task<ArmtekCartOperationResult> RemoveManyAsync(string providerKey, IReadOnlyCollection<long>? cartItem
 593    {
 2594        if (!IsProvider(providerKey)) return new(0, 0, 0, ["Неизвестный поставщик Армтек."]);
 2595        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 2596        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 597
 598        // Оформляемые строки не трогаем: заказ уже ушёл в Armtek и его нельзя отменить удалением из корзины.
 2599        var items = (await GetTrackedActiveAsync(providerKey, selectedIds, ct))
 600            .Where(x => x.Status != ArmtekCartItemStatus.Processing)
 2601            .ToList();
 8602        foreach (var item in items)
 603        {
 2604            item.Status = ArmtekCartItemStatus.Removed;
 2605            item.ActiveStudioOrderId = null;
 2606            item.UpdatedAtUtc = DateTime.UtcNow;
 607        }
 2608        await _db.SaveChangesAsync(ct);
 8609        foreach (var item in items)
 610        {
 2611            await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 612        }
 2613        var removed = items.Count;
 2614        InvalidateCount();
 2615        if (removed == 0)
 0616            return new(0, 0, 0, ["Нечего удалять: строки уже удалены или сейчас оформляются."]);
 2617        var skipped = selectedIds is null ? 0 : selectedIds.Count - removed;
 2618        var messages = new List<string> { $"Удалено из корзины {ProviderName(providerKey)}: {removed}." };
 2619        if (skipped > 0) messages.Add($"Пропущено (оформляются или уже удалены): {skipped}.");
 2620        return new(removed, skipped, 0, messages);
 2621    }
 622
 0623    public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.ArmtekCartItems.CountAsync(x => ArmtekCartI
 624    private Task<List<ArmtekCartItem>> GetTrackedActiveAsync(string providerKey, IReadOnlyCollection<long>? cartItemIds,
 625    {
 5626        var query = _db.ArmtekCartItems.Where(x => x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(
 5627        if (cartItemIds is not null)
 2628            query = query.Where(x => cartItemIds.Contains(x.Id));
 5629        return query.OrderBy(x => x.Id).ToListAsync(ct);
 630    }
 631    private (ArmtekDataManager Client, string Vkorg) ResolveClient(string providerKey)
 632    {
 43633        if (!IsProvider(providerKey)) throw new ArgumentException("Неизвестный Armtek provider.", nameof(providerKey));
 43634        var client = providerKey == "ArmtekBy" ? (ArmtekDataManager)_armtekBy : _armtek;
 43635        return (client, providerKey == "ArmtekBy" ? "2000" : "4000");
 636    }
 637    private static string? BuildDestinationError(IReadOnlyCollection<ArmtekCartItem> items)
 638    {
 3639        var unlinked = items
 3640            .Where(x => string.IsNullOrWhiteSpace(x.BuyerKunnr) || string.IsNullOrWhiteSpace(x.DeliveryAddressKunnr))
 4641            .Select(x => x.Pin).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
 3642        return unlinked.Length == 0
 3643            ? null
 3644            : $"Не указан клиент и адрес доставки Armtek: {string.Join(", ", unlinked)}. Выберите адрес в корзине либо с
 645    }
 646    private static IEnumerable<IGrouping<ArmtekDestination, ArmtekCartItem>> GroupByDestination(IEnumerable<ArmtekCartIt
 4647        items.GroupBy(x => new ArmtekDestination(x.BuyerKunnr!, x.DeliveryAddressKunnr!));
 648
 649    /// <summary>
 650    /// Активные заказы Armtek по позициям очереди: ключ — (провайдер, id заказа Studio2), значение — номера заказов Arm
 651    /// Активный — заказ в стадии Ordered отслеживания статусов; а если отслеживание не записалось (сбой журнала) —
 652    /// есть строка ArmtekOrderItems и заказ не закрыт поставщиком (стадия Closed).
 653    /// </summary>
 654    private async Task<Dictionary<(string ProviderKey, int OrderId), string>> FindActiveSupplierOrdersAsync(
 655        IReadOnlyCollection<ArmtekCartQueueItem> queue, CancellationToken ct)
 656    {
 18657        var result = new Dictionary<(string ProviderKey, int OrderId), string>();
 658        foreach (var group in queue.Where(x => IsProvider(x.ProviderKey) && x.OrderId > 0).GroupBy(x => x.ProviderKey))
 659        {
 18660            var providerKey = group.Key;
 661            var orderIds = group.Select(x => x.OrderId).Distinct().ToList();
 662
 18663            var states = await _db.OrderSupplierStates.AsNoTracking()
 18664                .Where(x => x.ProviderKey == providerKey && orderIds.Contains(x.OrderId))
 18665                .Select(x => new { x.OrderId, x.Stage, x.ActiveOrderId, x.ExternalOrderNumber })
 18666                .ToListAsync(ct);
 18667            var ordered = states
 668                .Where(x => x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ordered)
 669                .ToDictionary(x => x.OrderId, x => x.ExternalOrderNumber);
 670            var closed = states.Where(x => x.Stage == OrderSupplierStage.Closed).Select(x => x.OrderId).ToHashSet();
 671
 18672            var untrackedSince = DateTime.UtcNow - UntrackedOrderWindow;
 18673            var placed = await _db.ArmtekOrderItems.AsNoTracking()
 18674                .Where(x => orderIds.Contains(x.StudioOrderId) && x.ArmtekOrder!.ProviderKey == providerKey
 18675                            && x.ArmtekOrder.CreatedAtUtc >= untrackedSince
 18676                            && (x.Error == null || x.Error == "" || x.Error == "0"))
 18677                .Select(x => new { x.StudioOrderId, x.ArmtekOrder!.Vbeln })
 18678                .ToListAsync(ct);
 18679            var placedNumbers = placed
 680                .GroupBy(x => x.StudioOrderId)
 681                .ToDictionary(x => x.Key, x => string.Join(", ", x.Select(i => i.Vbeln).Distinct()));
 682
 74683            foreach (var orderId in orderIds)
 684            {
 19685                if (ordered.TryGetValue(orderId, out var stateNumber))
 686                {
 4687                    result[(providerKey, orderId)] = !string.IsNullOrWhiteSpace(stateNumber)
 4688                        ? stateNumber
 4689                        : placedNumbers.GetValueOrDefault(orderId) ?? "(номер не записан)";
 690                }
 15691                else if (placedNumbers.TryGetValue(orderId, out var numbers) && !closed.Contains(orderId))
 692                {
 1693                    result[(providerKey, orderId)] = numbers;
 694                }
 695            }
 18696        }
 18697        return result;
 18698    }
 699
 700    private async Task<(List<ArmtekPreparedDirectItem> Items, List<string> Errors)> PrepareDirectOrderItemsAsync(
 701        IReadOnlyCollection<ArmtekCartQueueItem> queue, CancellationToken ct)
 702    {
 15703        var items = new List<ArmtekPreparedDirectItem>();
 15704        var errors = new List<string>();
 60705        foreach (var item in queue)
 706        {
 15707            if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 ||
 15708                string.IsNullOrWhiteSpace(item.ArtId) || string.IsNullOrWhiteSpace(item.Pin) || string.IsNullOrWhiteSpac
 15709                string.IsNullOrWhiteSpace(item.Keyzak))
 710            {
 0711                errors.Add($"{Describe(item)}: неполные данные предложения Armtek.");
 0712                continue;
 713            }
 15714            var destination = await _deliveryAddressMappings.ResolveAsync(item.ProviderKey, item.OrderId, item.ZasylWare
 15715            if (destination is null || string.IsNullOrWhiteSpace(destination.BuyerKunnr) || string.IsNullOrWhiteSpace(de
 716            {
 1717                errors.Add($"{item.Pin}: не указан клиент и адрес доставки Armtek для склада отгрузки.");
 1718                continue;
 719            }
 14720            var (_, vkorg) = ResolveClient(item.ProviderKey);
 14721            items.Add(new ArmtekPreparedDirectItem(item with { Pin = NormalizePin(item.Pin), Brand = item.Brand.Trim() }
 14722                new ArmtekDestination(destination.BuyerKunnr, destination.DeliveryAddressKunnr)));
 14723        }
 724        if (queue.GroupBy(x => new { x.ProviderKey, x.OrderId }).Any(group => group.Count() > 1))
 0725            errors.Add("Для одного заказа выбрано несколько предложений Armtek одного поставщика.");
 15726        return (items, errors);
 15727    }
 728
 199729    private sealed record ArmtekPreparedDirectItem(ArmtekCartQueueItem Item, string Vkorg, ArmtekDestination Destination
 27730    private static ArmtekOrderRequestItem ToRequestItem(ArmtekCartQueueItem item) => new(
 27731        item.Pin, item.Brand, item.Quantity, item.Keyzak, item.SourcePrice, FormatDateMax(item.DeliveryAtUtc),
 27732        StudioOrderCommentLink.BuildFor(item.ProviderKey, string.IsNullOrWhiteSpace(item.ShipmentNumber) ? "заказ" : ite
 733
 734    /// <summary>Метка Studio2 в <c>TEXT_ORD</c>: по ней заказ находится у поставщика при сверке перед повтором.</summar
 735    private static string OrderComment(IEnumerable<int> studioOrderIds) =>
 14736        StudioOrderCommentLink.BuildBatch(studioOrderIds, StudioOrderCommentLink.MaxLengthFor("Armtek") ?? 100);
 27737    private static string? FormatDateMax(string? value) => ParseApiDate(value)?.ToString("yyyyMMddHHmmss", CultureInfo.I
 738    private static string DescribeDestination(ArmtekCartItem item) =>
 0739        $"клиент {item.BuyerKunnr}, адрес {item.DeliveryAddressName ?? item.DeliveryAddressKunnr}";
 740    private async Task<ArmtekOrder> GetOrCreateOrderAsync(string providerKey, string vkorg, ArmtekDestination destinatio
 741    {
 8742        var existing = await _db.ArmtekOrders.FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Vbeln == vbeln,
 8743        if (existing is not null) return existing;
 8744        var order = new ArmtekOrder { ProviderKey = providerKey, Vkorg = vkorg, Kunrg = destination.Kunrg, Kunza = desti
 24745        _db.ArmtekOrders.Add(order); await _db.SaveChangesAsync(ct); return order;
 8746    }
 747    /// <summary>Пишет запись в orders_history в общей форме API-поставщиков. Ошибка журнала не должна
 748    /// ломать уже выполненное размещение — строка корзины к этому моменту уже сохранена.</summary>
 749    private async Task RecordHistoryAsync(int orderId, ArmtekCartQueueItem item, bool success, string? error,
 750        string cartStatus, string? supplierOrderNumber, string? buyerKunnr, string? deliveryAddressName,
 751        string changedBy, CancellationToken ct)
 752    {
 753        try
 754        {
 3755            var displayName = ProviderName(item.ProviderKey);
 3756            var entry = new ApiSupplierBasketHistoryEntry(
 3757                item.ProviderKey,
 3758                displayName,
 3759                ApiSupplierBasketHistoryEntry.ResolveLogoUrl(item.ProviderKey),
 3760                success,
 3761                error,
 3762                ApiItemId: null,
 3763                Article: item.Pin,
 3764                ProductName: item.ProductName ?? item.Brand,
 3765                Price: item.SourcePrice,
 3766                CurrencyCode: item.CurrencyCode,
 3767                Quantity: item.Quantity,
 3768                ShipmentNumber: item.ShipmentNumber,
 3769                ClientName: item.ClientName,
 3770                WarehouseName: item.WarehouseName,
 3771                BuyerKunnr: buyerKunnr,
 3772                DeliveryAddressName: deliveryAddressName,
 3773                CartStatus: cartStatus,
 3774                SupplierOrderNumber: supplierOrderNumber,
 3775                SupplierCartUrl: CartUrl(item.ProviderKey));
 776
 3777            await _history.AddOrderHistory(new OrderHistory
 3778            {
 3779                OrderId = orderId,
 3780                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 3781                ColumnDisplayName = $"Корзина {displayName}",
 3782                OldValue = null,
 3783                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 3784                ChangedAt = DateTime.Now,
 3785                ChangedBy = changedBy
 3786            });
 3787        }
 0788        catch
 789        {
 790            // Не ломаем размещение в корзине из-за журнала истории.
 0791        }
 3792    }
 793
 794    private static string CartUrl(string providerKey) =>
 3795        providerKey == "ArmtekBy" ? "/SupplierCart?provider=armtekby&supplierTab=basket" : "/SupplierCart?provider=armte
 4796    private static IReadOnlyCollection<ArmtekOrderRequestItem> ToRequestItems(IEnumerable<ArmtekCartItem> items) => item
 4797        NormalizePin(x.Pin), x.Brand.Trim(), x.Quantity, x.Keyzak, x.SourcePrice,
 4798        x.DeliveryAtUtc?.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture),
 4799        StudioOrderCommentLink.BuildFor(x.ProviderKey, "заказ", x.StudioOrderId))).ToArray();
 800    private static ArmtekCreateOrderItemResult? FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekRe
 4801        response.Resp?.Items.FirstOrDefault(x => string.Equals(x.ArtId, cartItem.ArtId, StringComparison.Ordinal))
 2802        ?? response.Resp?.Items.FirstOrDefault(x =>
 0803            string.Equals(NormalizePin(x.Pin), NormalizePin(cartItem.Pin), StringComparison.OrdinalIgnoreCase) &&
 0804            string.Equals(x.Brand, cartItem.Brand, StringComparison.OrdinalIgnoreCase))
 2805        ?? response.Resp?.Items.ElementAtOrDefault(fallbackIndex);
 806    private static ArmtekCreateOrderItemResult? FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekRe
 44807        response.Resp?.Items.FirstOrDefault(x => string.Equals(x.ArtId, item.ArtId, StringComparison.Ordinal))
 22808        ?? response.Resp?.Items.FirstOrDefault(x =>
 0809            string.Equals(NormalizePin(x.Pin), NormalizePin(item.Pin), StringComparison.OrdinalIgnoreCase) &&
 0810            string.Equals(x.Brand, item.Brand, StringComparison.OrdinalIgnoreCase))
 22811        ?? response.Resp?.Items.ElementAtOrDefault(fallbackIndex);
 32812    private static bool HasApiItemError(string? error) => !string.IsNullOrWhiteSpace(error) && error != "0";
 813    private static string? GetItemError(ArmtekCreateOrderItemResult? item)
 814    {
 24815        if (item is null) return "Армтек не вернул результат проверки позиции.";
 25816        if (HasApiItemError(item.Error)) return item.ErrorMessage ?? item.Error;
 23817        if (item.Result.Count == 0) return "Армтек не подтвердил наличие позиции.";
 23818        return ParseDecimal(item.Remain) is > 0 ? $"Армтек подтвердил только часть количества (остаток: {item.Remain})."
 819    }
 820    /// <summary>
 821    /// Отклоняет у Армтек позиции заказа Studio2 (<c>editOrder</c>, количество 0) — для заказов, отменённых в Ozon.
 822    /// Перед вызовом перечитывает заказ (<c>getOrder</c> с <c>EDIT=1</c>): отгруженную позицию не трогает, а возвращает
 823    /// <see cref="SupplierOrderCancelOutcome.ReturnNeeded"/>. Причина — значение по умолчанию из <c>ABGRU_ITEMS</c>.
 824    /// </summary>
 825    public async Task<SupplierOrderCancelResult> RejectOrderPositionsAsync(string providerKey, int orderId, string? note
 826    {
 0827        if (!IsProvider(providerKey)) return SupplierOrderCancelResult.Failed($"Неизвестный провайдер Armtek: {providerK
 0828        var links = await _db.ArmtekOrderItems.AsNoTracking()
 0829            .Where(x => x.StudioOrderId == orderId && x.ArmtekOrder != null && x.ArmtekOrder.ProviderKey == providerKey 
 0830            .Select(x => new { x.Posnr, x.ArmtekOrder!.Vbeln, x.ArmtekOrder.Kunrg, x.ArmtekOrder.Vkorg })
 0831            .ToListAsync(ct);
 0832        if (links.Count == 0) return SupplierOrderCancelResult.Failed("в Studio2 нет связки с заказом Армтек (номер и по
 833
 0834        var (client, defaultVkorg) = ResolveClient(providerKey);
 0835        var messages = new List<string>();
 0836        var outcome = SupplierOrderCancelOutcome.Cancelled;
 837        foreach (var group in links.GroupBy(x => new { x.Vbeln, x.Kunrg, x.Vkorg }))
 838        {
 0839            var vkorg = string.IsNullOrWhiteSpace(group.Key.Vkorg) ? defaultVkorg : group.Key.Vkorg;
 840            try
 841            {
 0842                var order = await client.GetOrderAsync(group.Key.Kunrg, group.Key.Vbeln, vkorg, edit: true, ct);
 0843                if (order.HasErrors || order.Resp is null)
 0844                    return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ArmtekMessages(order.Messages) ?
 845
 846                var positions = group.Select(x => x.Posnr!).ToHashSet(StringComparer.OrdinalIgnoreCase);
 0847                var items = order.Resp.Items.Where(x => x.Posnr != null && positions.Contains(x.Posnr)).ToList();
 0848                if (items.Count == 0) return SupplierOrderCancelResult.Failed($"в заказе {group.Key.Vbeln} нет позиций {
 849                if (items.Any(x => ParseDecimal(x.KwmengR) is > 0))
 850                {
 0851                    outcome = SupplierOrderCancelOutcome.ReturnNeeded;
 0852                    messages.Add($"заказ {group.Key.Vbeln}: позиция уже отгружена — нужен возврат (createReturnInvoice)"
 0853                    continue;
 854                }
 855                var toReject = items.Where(x => ParseDecimal(x.Kwmeng) is > 0 && ParseDecimal(x.KwmengRej) != ParseDecim
 0856                if (toReject.Count == 0)
 857                {
 0858                    messages.Add($"заказ {group.Key.Vbeln}: позиции уже отклонены");
 0859                    continue;
 860                }
 861
 862                var abgru = order.Resp.AbgruItems.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Default))?.Abgru
 0863                            ?? order.Resp.AbgruItems.FirstOrDefault()?.Abgru;
 0864                if (string.IsNullOrWhiteSpace(abgru))
 0865                    return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: Армтек не дал причин отклонения (
 866
 867                var edit = await client.EditOrderAsync(group.Key.Kunrg, group.Key.Vbeln, toReject.Select(x => (x.Posnr!,
 0868                    abgru, vkorg, note, ct);
 0869                if (edit.HasErrors)
 0870                    return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ArmtekMessages(edit.Messages) ??
 871                messages.Add($"заказ {group.Key.Vbeln}, поз. {string.Join(", ", toReject.Select(x => x.Posnr))}: отклоне
 0872            }
 0873            catch (ArmtekApiException ex)
 874            {
 0875                return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ex.Message}");
 876            }
 0877        }
 0878        var text = string.Join("; ", messages);
 0879        return outcome == SupplierOrderCancelOutcome.ReturnNeeded ? SupplierOrderCancelResult.ReturnNeeded(text) : Suppl
 0880    }
 881
 882    private static string? ArmtekMessages(IEnumerable<Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekMessage> mess
 883    {
 0884        var text = string.Join("; ", messages.Where(x => x.Type is "E" or "A").Select(x => x.Text).Where(x => !string.Is
 0885        return text.Length == 0 ? null : text;
 886    }
 887
 84888    private static bool IsProvider(string key) => key is "Armtek" or "ArmtekBy";
 160889    private static string NormalizePin(string value) => string.Concat(value.Where(character => !char.IsWhiteSpace(charac
 890    private static string? ValidateSelection(IReadOnlyCollection<long>? ids, out IReadOnlyCollection<long>? normalizedId
 891    {
 5892        if (ids is null)
 893        {
 3894            normalizedIds = null;
 3895            return null;
 896        }
 897
 4898        normalizedIds = ids.Where(id => id > 0).Distinct().ToArray();
 2899        return normalizedIds.Count == 0 ? "Выберите хотя бы одну позицию корзины." : null;
 900    }
 7901    private static string ProviderName(string key) => key == "ArmtekBy" ? "Армтек BY" : "Армтек RU";
 27902    private static string? BuildError<T>(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse<T> response) => r
 43903    private static decimal? ParseDecimal(string? value) => decimal.TryParse(value?.Replace(',', '.'), NumberStyles.Any, 
 904    /// <summary>После отказа test-order повторно берём только тот же артикул и бренд у того же клиента/адреса.
 905    /// Проценка Armtek может вернуть устаревший склад, поэтому сохраняем только вариант, который затем сам Armtek подтв
 906    private async Task<ArmtekRefreshedOffer?> TryRefreshOfferAsync(ArmtekDataManager client, string vkorg,
 907        ArmtekDestination destination, ArmtekCartItem item, CancellationToken ct)
 908    {
 909        try
 910        {
 1911            var response = await client.SearchAsync(destination.Kunrg, NormalizePin(item.Pin), item.Brand.Trim(), vkorg,
 1912                queryType: 1, kunnrZa: destination.Kunza, incotermsSelfPickup: false, ct: ct);
 1913            if (response.HasErrors)
 0914                return null;
 915
 1916            var candidates = (response.Resp ?? [])
 1917                .Where(offer => string.Equals(NormalizePin(offer.Pin), NormalizePin(item.Pin), StringComparison.OrdinalI
 1918                                && string.Equals(offer.Brand, item.Brand, StringComparison.OrdinalIgnoreCase)
 1919                                && (string.IsNullOrWhiteSpace(item.ArtId) || string.Equals(offer.ArtId, item.ArtId, Stri
 1920                .Where(offer => ParseDecimal(offer.Price) is not null && HasEnoughStock(offer.Rvalue, item.Quantity))
 1921                .OrderByDescending(offer => string.Equals(offer.PartnerWarehouseCode, item.PartnerWarehouseCode, StringC
 922                .ThenBy(offer => ParseApiDate(offer.DeliveryDate) ?? DateTime.MaxValue)
 923                .ThenBy(offer => ParseDecimal(offer.Price))
 1924                .Take(8)
 1925                .ToArray();
 2926            var checkedOffers = await Task.WhenAll(candidates.Select(offer => CheckRefreshedOfferAsync(
 2927                client, vkorg, destination, item, offer, ct)));
 928            return checkedOffers.FirstOrDefault(offer => offer is not null);
 929        }
 0930        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 931        {
 0932            return null;
 933        }
 1934    }
 935    private async Task<ArmtekRefreshedOffer?> CheckRefreshedOfferAsync(ArmtekDataManager client, string vkorg,
 936        ArmtekDestination destination, ArmtekCartItem item, ArmtekSearchItem offer, CancellationToken ct)
 937    {
 1938        await RefreshTestConcurrency.WaitAsync(ct);
 939        try
 940        {
 1941            var requestItems = new[] { new ArmtekOrderRequestItem(NormalizePin(item.Pin), item.Brand.Trim(), item.Quanti
 1942                offer.Keyzak, ParseDecimal(offer.Price), ParseApiDate(offer.DeliveryDate)?.ToString("yyyyMMddHHmmss", Cu
 1943            var testResponse = await client.CreateTestOrderAsync(destination.Kunrg, requestItems, vkorg, destination.Kun
 1944            LogApiExchange(item.ProviderKey, "refreshTestOrder", vkorg, destination, requestItems, testResponse);
 1945            var testItem = FindResponseItem(testResponse, item);
 1946            return string.IsNullOrWhiteSpace(BuildError(testResponse) ?? GetItemError(testItem))
 1947                ? new ArmtekRefreshedOffer(offer, testResponse)
 1948                : null;
 949        }
 950        finally
 951        {
 1952            RefreshTestConcurrency.Release();
 953        }
 1954    }
 955    private static bool HasEnoughStock(string? stock, decimal quantity)
 956    {
 1957        var available = ParseStock(stock);
 1958        return available is null || available.Value >= quantity;
 959    }
 960
 961    /// <summary>Остаток Armtek приходит строкой и может нести знак «не менее» с любой стороны («&gt;10», «10&gt;»).</su
 962    private static decimal? ParseStock(string? stock)
 963    {
 1964        if (string.IsNullOrWhiteSpace(stock)) return null;
 1965        var normalized = stock.Trim().Trim('>', '<', '~', '=', ' ').Replace(',', '.');
 1966        return decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var available) ? availab
 967    }
 968    private static string ApplyRefreshedOffer(ArmtekCartItem item, ArmtekSearchItem offer, string originalError)
 969    {
 1970        var oldKeyzak = item.Keyzak;
 1971        var oldPrice = item.SourcePrice;
 1972        item.ArtId = offer.ArtId;
 1973        item.Keyzak = offer.Keyzak;
 1974        item.WarehouseName = null;
 1975        item.PartnerWarehouseCode = offer.PartnerWarehouseCode;
 1976        item.SourcePrice = ParseDecimal(offer.Price);
 1977        item.CurrencyCode = string.IsNullOrWhiteSpace(offer.Currency) ? item.CurrencyCode : offer.Currency;
 1978        item.DeliveryAtUtc = ParseApiDate(offer.DeliveryDate);
 2979        if (!string.IsNullOrWhiteSpace(offer.Name)) item.ProductName = offer.Name;
 980
 1981        var price = item.SourcePrice.HasValue
 1982            ? $"цена {oldPrice?.ToString("0.##", CultureInfo.InvariantCulture) ?? "—"} → {item.SourcePrice.Value.ToStrin
 1983            : "цена не получена";
 1984        var delivery = item.DeliveryAtUtc.HasValue
 1985            ? $", срок {item.DeliveryAtUtc.Value:dd.MM.yyyy HH:mm}"
 1986            : string.Empty;
 1987        return $"проверка отклонена: {originalError}. Найдено и повторно подтверждено актуальное предложение: ключ {oldK
 988    }
 989
 2990    private sealed record ArmtekRefreshedOffer(ArmtekSearchItem Offer,
 2991        Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse<ArmtekCreateOrderResponse> TestResponse);
 8992    private static DateTime? ParseDate(string? value) => DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.Inv
 993    private static DateTime? ParseApiDate(string? value) =>
 32994        DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compa
 32995        DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed) ? parse
 996    private static string Describe(ArmtekCartQueueItem item)
 997    {
 2998        var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber;
 2999        var product = string.IsNullOrWhiteSpace(item.ProductName) ? item.Pin : item.ProductName;
 21000        var client = string.IsNullOrWhiteSpace(item.ClientName) ? string.Empty : $", {item.ClientName}";
 21001        return $"{item.Pin}: заказ {order}, {product}{client}";
 1002    }
 11003    private static ArmtekCartQueueItem ToQueueItem(ArmtekCartItem item) => new(item.StudioOrderId, item.ProviderKey, ite
 1004    private void LogApiExchange<T>(string providerKey, string operation, string vkorg, ArmtekDestination destination, IR
 251005        _logger.LogInformation("Armtek cart {Provider} {Operation}. Request: {Request}. Response: {Response}",
 251006            providerKey, operation,
 251007            JsonSerializer.Serialize(new
 251008            {
 251009                VKORG = vkorg,
 251010                KUNRG = destination.Kunrg,
 251011                KUNZA = destination.Kunza,
 251012                INCOTERMS = "0",
 251013                ITEMS = requestItems.Select(item => new
 251014                {
 251015                    item.Pin,
 251016                    item.Brand,
 251017                    Quantity = item.Quantity.ToString("0.####", CultureInfo.InvariantCulture),
 251018                    item.Keyzak,
 251019                    PriceMax = item.PriceMax?.ToString("0.####", CultureInfo.InvariantCulture),
 251020                    DateMax = item.DateMax
 251021                })
 251022            }),
 251023            JsonSerializer.Serialize(response));
 41024    private void InvalidateCount() => _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 1025}

Methods/Properties

.cctor()
.ctor(OzonRepositories.Context.ApplicationDbContext,Services.ApiServices.ArmtekApi.ArmtekDataManager,Services.ApiServices.ArmtekApi.ArmtekByDataManager,IOrderHistoryDataService,Microsoft.Extensions.Caching.Memory.IMemoryCache,Services.ApiSupplierSettingsServices.ArmtekDeliveryAddressMappingService,Services.SupplierStatusServices.IOrderSupplierStateService,Microsoft.Extensions.Logging.ILogger`1<OzonOrdersWeb.TransactionServices.ArmtekCartService>,OzonOrdersWeb.TransactionServices.ISupplierOrderGuard)
TrackCartAsync()
TrackOrderedAsync()
TrackRemovedAsync()
QueueAsync()
GetActiveAsync(System.String,System.Threading.CancellationToken)
RequoteAsync()
<RequoteAsync()
ReplaceWithRequotedOfferAsync()
GetOrdersAsync(System.String,System.Threading.CancellationToken)
BuildDirectOrderPreviewAsync()
CheckoutDirectAsync()
TestAsync()
UpdateDeliveryAddressAsync()
CheckoutAsync()
RemoveAsync()
RemoveManyAsync()
CountActiveAsync(System.Threading.CancellationToken)
GetTrackedActiveAsync(System.String,System.Collections.Generic.IReadOnlyCollection`1<System.Int64>,System.Threading.CancellationToken)
ResolveClient(System.String)
BuildDestinationError(System.Collections.Generic.IReadOnlyCollection`1<OzonDomains.Models.ArmtekCartItem>)
GroupByDestination(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.ArmtekCartItem>)
FindActiveSupplierOrdersAsync()
PrepareDirectOrderItemsAsync()
get_Item()
ToRequestItem(OzonOrdersWeb.TransactionServices.ArmtekCartQueueItem)
OrderComment(System.Collections.Generic.IEnumerable`1<System.Int32>)
FormatDateMax(System.String)
DescribeDestination(OzonDomains.Models.ArmtekCartItem)
GetOrCreateOrderAsync()
RecordHistoryAsync()
CartUrl(System.String)
ToRequestItems(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.ArmtekCartItem>)
FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse`1<Services.ApiServices.ArmtekApi.Models.Response.ArmtekCreateOrderResponse>,OzonDomains.Models.ArmtekCartItem,System.Int32)
FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse`1<Services.ApiServices.ArmtekApi.Models.Response.ArmtekCreateOrderResponse>,OzonOrdersWeb.TransactionServices.ArmtekCartQueueItem,System.Int32)
HasApiItemError(System.String)
GetItemError(Services.ApiServices.ArmtekApi.Models.Response.ArmtekCreateOrderItemResult)
RejectOrderPositionsAsync()
ArmtekMessages(System.Collections.Generic.IEnumerable`1<Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekMessage>)
IsProvider(System.String)
NormalizePin(System.String)
ValidateSelection(System.Collections.Generic.IReadOnlyCollection`1<System.Int64>,System.Collections.Generic.IReadOnlyCollection`1<System.Int64>&)
ProviderName(System.String)
BuildError(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse`1<T>)
ParseDecimal(System.String)
TryRefreshOfferAsync()
CheckRefreshedOfferAsync()
HasEnoughStock(System.String,System.Decimal)
ParseStock(System.String)
ApplyRefreshedOffer(OzonDomains.Models.ArmtekCartItem,Services.ApiServices.ArmtekApi.Models.Response.ArmtekSearchItem,System.String)
get_Offer()
get_TestResponse()
ParseDate(System.String)
ParseApiDate(System.String)
Describe(OzonOrdersWeb.TransactionServices.ArmtekCartQueueItem)
ToQueueItem(OzonDomains.Models.ArmtekCartItem)
LogApiExchange(System.String,System.String,System.String,OzonOrdersWeb.TransactionServices.ArmtekDestination,System.Collections.Generic.IReadOnlyCollection`1<Services.ApiServices.ArmtekApi.ArmtekOrderRequestItem>,Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse`1<T>)
InvalidateCount()