< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Kunrg()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
 14427public 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{
 86    private static readonly SemaphoreSlim RefreshTestConcurrency = new(3, 3);
 87
 88    /// <summary>
 89    /// Срок, в течение которого строка ArmtekOrderItems без записи в отслеживании статусов считается активным заказом.
 90    /// Старше — это заказы до появления отслеживания, их повторное оформление не блокируем.
 91    /// </summary>
 92    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
 103    public ArmtekCartService(ApplicationDbContext db, ArmtekDataManager armtek, ArmtekByDataManager armtekBy, IOrderHist
 104        ISupplierOrderGuard? guard = null)
 105    {
 106        _guard = guard;
 107        _db = db;
 108        _armtek = armtek;
 109        _armtekBy = armtekBy;
 110        _history = history;
 111        _cache = cache;
 112        _deliveryAddressMappings = deliveryAddressMappings;
 113        _supplierState = supplierState;
 114        _logger = logger;
 115    }
 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        {
 128            await _supplierState.MarkInCartAsync(orderId, providerKey, cartStatusCode, cartItemId, user: user,
 129                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 130        }
 131        catch (Exception ex)
 132        {
 133            _logger.LogWarning(ex, "{Provider}: не удалось записать состояние корзины для заказа {OrderId}", providerKey
 134        }
 135    }
 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        {
 142            await _supplierState.MarkOrderedAsync(orderId, providerKey, orderNumbers, user: user,
 143                deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct);
 144        }
 145        catch (Exception ex)
 146        {
 147            _logger.LogWarning(ex, "{Provider}: не удалось привязать заказ {OrderId} к заказу поставщика", providerKey, 
 148        }
 149    }
 150
 151    private async Task TrackRemovedAsync(int orderId, string providerKey, string? user, CancellationToken ct)
 152    {
 153        try
 154        {
 155            await _supplierState.RemoveFromCartAsync(orderId, providerKey, user, ct);
 156        }
 157        catch (Exception ex)
 158        {
 159            _logger.LogWarning(ex, "{Provider}: не удалось закрыть состояние корзины для заказа {OrderId}", providerKey,
 160        }
 161    }
 162
 163    public async Task<ArmtekCartOperationResult> QueueAsync(IReadOnlyCollection<ArmtekCartQueueItem> queue, string chang
 164    {
 165        var messages = new List<string>();
 166        var added = 0; var skipped = 0; var failed = 0;
 167        foreach (var item in queue)
 168        {
 169            if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 || string.IsNullOrWhiteSpace(it
 170            {
 171                failed++;
 172                const string incompleteError = "Неполные данные предложения Armtek.";
 173                messages.Add($"{Describe(item)} — неполные данные предложения Armtek.");
 174                if (IsProvider(item.ProviderKey) && item.OrderId > 0)
 175                    await RecordHistoryAsync(item.OrderId, item, false, incompleteError, ApiSupplierBasketCartStatus.Fai
 176                        supplierOrderNumber: null, buyerKunnr: null, deliveryAddressName: null, changedBy, ct);
 177                continue;
 178            }
 179            var exists = await _db.ArmtekCartItems.AnyAsync(x => x.ProviderKey == item.ProviderKey && x.StudioOrderId ==
 180            if (exists) { skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.")
 181
 182            // Клиент и адрес доставки берём той же связью «склад отгрузки → адрес Armtek», что и проценка,
 183            // и фиксируем в строке: поздние правки справочника не должны менять уже собранную корзину.
 184            var deliveryMapping = await _deliveryAddressMappings.ResolveAsync(item.ProviderKey, item.OrderId, item.Zasyl
 185            var cartItem = new ArmtekCartItem
 186            {
 187                ProviderKey = item.ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, ArtId 
 188                Brand = item.Brand, Keyzak = item.Keyzak, WarehouseName = item.WarehouseName,
 189                PartnerWarehouseCode = item.PartnerWarehouseCode, ProductName = item.ProductName,
 190                BuyerKunnr = deliveryMapping?.BuyerKunnr, DeliveryAddressKunnr = deliveryMapping?.DeliveryAddressKunnr,
 191                Quantity = item.Quantity, SourcePrice = item.SourcePrice, CurrencyCode = item.CurrencyCode ?? "RUB",
 192                DeliveryAtUtc = ParseApiDate(item.DeliveryAtUtc), CreatedBy = changedBy
 193            };
 194            _db.ArmtekCartItems.Add(cartItem);
 195            try
 196            {
 197                await _db.SaveChangesAsync(ct);
 198            }
 199            catch (DbUpdateException)
 200            {
 201                _db.Entry(cartItem).State = EntityState.Detached;
 202                skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.");
 203                continue;
 204            }
 205            added++;
 206            messages.Add(deliveryMapping is null
 207                ? $"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}, но склад отгрузки не связан
 208                : $"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}; требуется проверка перед оф
 209            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending,
 210                supplierOrderNumber: null, buyerKunnr: deliveryMapping?.BuyerKunnr,
 211                deliveryAddressName: deliveryMapping?.DeliveryAddressKunnr, changedBy, ct);
 212            await TrackCartAsync(item.OrderId, item.ProviderKey, ArmtekCartItemStatus.Pending, cartItem.Id, changedBy, c
 213                item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction);
 214        }
 215        InvalidateCount();
 216        var providerKeys = queue
 217            .Where(item => IsProvider(item.ProviderKey))
 218            .Select(item => item.ProviderKey)
 219            .Distinct(StringComparer.OrdinalIgnoreCase)
 220            .ToArray();
 221        return new ArmtekCartOperationResult(added, skipped, failed, messages, providerKeys);
 222    }
 223
 224    public Task<List<ArmtekCartItem>> GetActiveAsync(string providerKey, CancellationToken ct = default) =>
 225        _db.ArmtekCartItems.AsNoTracking().Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo)
 226            .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer)
 227            .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 228            .Where(x => x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(x.Status))
 229            .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 230
 231    /// <summary>Повторная проценка позиции для зафиксированных клиента и адреса доставки, без привязки к старому складу
 232    public async Task<ArmtekCartRequoteResult> RequoteAsync(string providerKey, long cartItemId, CancellationToken ct = 
 233    {
 234        var item = await _db.ArmtekCartItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cartItemId && x.ProviderKey
 235            ?? throw new InvalidOperationException("Позиция корзины не найдена или уже оформляется.");
 236        if (string.IsNullOrWhiteSpace(item.BuyerKunnr) || string.IsNullOrWhiteSpace(item.DeliveryAddressKunnr))
 237            throw new InvalidOperationException("Сначала выберите клиента и адрес доставки Armtek для этой позиции.");
 238
 239        var (client, vkorg) = ResolveClient(providerKey);
 240        var response = await client.SearchAsync(item.BuyerKunnr, NormalizePin(item.Pin), item.Brand.Trim(), vkorg,
 241            queryType: 1, kunnrZa: item.DeliveryAddressKunnr, incotermsSelfPickup: false, ct: ct);
 242        var error = BuildError(response);
 243        if (!string.IsNullOrWhiteSpace(error)) throw new ArmtekApiException(error);
 244
 245        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
 249        var rawOffers = (response.Resp ?? [])
 250            .Where(x => string.Equals(NormalizePin(x.Pin), NormalizePin(item.Pin), StringComparison.OrdinalIgnoreCase)
 251                        && string.Equals(x.Brand, item.Brand, StringComparison.OrdinalIgnoreCase))
 252            .Select(x => new { Offer = x, Price = ParseDecimal(x.Price) })
 253            .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)
 256            .Take(8)
 257            .Select(x => new { x.Offer, Price = x.Price!.Value })
 258            .ToArray();
 259        var destination = new ArmtekDestination(item.BuyerKunnr, item.DeliveryAddressKunnr);
 260        var confirmed = await Task.WhenAll(rawOffers.Select(async x => new
 261        {
 262            x.Offer,
 263            x.Price,
 264            IsOrderable = await CheckRefreshedOfferAsync(client, vkorg, destination, item, x.Offer, ct) is not null
 265        }));
 266        var offers = confirmed
 267            .Select(x => new ArmtekCartRequoteOffer(x.Offer.Keyzak, x.Offer.ArtId, x.Offer.PartnerWarehouseCode,
 268                storeNames.GetValueOrDefault(x.Offer.PartnerWarehouseCode, "Склад Armtek"), x.Offer.Name,
 269                x.Price, string.IsNullOrWhiteSpace(x.Offer.Currency) ? item.CurrencyCode : x.Offer.Currency,
 270                ParseStock(x.Offer.Rvalue), ParseApiDate(x.Offer.DeliveryDate), x.IsOrderable))
 271            .ToArray();
 272        return new ArmtekCartRequoteResult(
 273            string.IsNullOrWhiteSpace(item.WarehouseName) ? item.PartnerWarehouseCode ?? "—" : $"{item.WarehouseName} ({
 274            item.DeliveryAddressName ?? item.DeliveryAddressKunnr ?? "—", offers);
 275    }
 276
 277    /// <summary>Заменяет отклонённое предложение на выбранный вариант свежей проценки и требует новую проверку.</summar
 278    public async Task<bool> ReplaceWithRequotedOfferAsync(string providerKey, long cartItemId, string keyzak, Cancellati
 279    {
 280        var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId && x.ProviderKey == providerKey
 281        if (item is null || item.Status == ArmtekCartItemStatus.Processing) return false;
 282        var requote = await RequoteAsync(providerKey, cartItemId, ct);
 283        var offer = requote.Offers.FirstOrDefault(x => string.Equals(x.Keyzak, keyzak, StringComparison.Ordinal) && x.Is
 284        if (offer is null) throw new InvalidOperationException("Выбранное предложение уже не актуально. Выполните процен
 285
 286        item.ArtId = offer.ArtId;
 287        item.Keyzak = offer.Keyzak;
 288        item.PartnerWarehouseCode = offer.WarehouseCode;
 289        item.ProductName = string.IsNullOrWhiteSpace(offer.Name) ? item.ProductName : offer.Name;
 290        item.SourcePrice = offer.Price;
 291        item.CurrencyCode = offer.CurrencyCode;
 292        item.DeliveryAtUtc = offer.DeliveryAtUtc;
 293        item.Status = ArmtekCartItemStatus.Pending;
 294        item.TestedAtUtc = null;
 295        item.TestResultJson = null;
 296        item.LastError = "Выбрано актуальное предложение из новой проценки. Перед оформлением выполните проверку.";
 297        item.UpdatedAtUtc = DateTime.UtcNow;
 298        await _db.SaveChangesAsync(ct);
 299        return true;
 300    }
 301
 302    public Task<List<ArmtekOrder>> GetOrdersAsync(string providerKey, CancellationToken ct = default) =>
 303        _db.ArmtekOrders.AsNoTracking().Include(x => x.Items).ThenInclude(x => x.StudioOrder)
 304            .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    {
 310        var prepared = await PrepareDirectOrderItemsAsync(queue, ct);
 311        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())
 316            .ToArray(), []);
 317    }
 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    {
 329        var alreadyOrdered = await FindActiveSupplierOrdersAsync(queue, ct);
 330        if (confirmedReorderOrderIds is { Count: > 0 })
 331        {
 332            foreach (var key in alreadyOrdered.Keys.Where(x => confirmedReorderOrderIds.Contains(x.OrderId)).ToList())
 333                alreadyOrdered.Remove(key);
 334        }
 335        var messages = new List<string>();
 336        if (alreadyOrdered.Count > 0)
 337        {
 338            messages.AddRange(queue
 339                .Where(x => alreadyOrdered.ContainsKey((x.ProviderKey, x.OrderId)))
 340                .Select(x => $"{x.Pin}: заказ Армтек {alreadyOrdered[(x.ProviderKey, x.OrderId)]} уже оформлен ранее — п
 341                             "Если заказ нужно оформить заново, сначала отмените его у поставщика."));
 342            queue = queue.Where(x => !alreadyOrdered.ContainsKey((x.ProviderKey, x.OrderId))).ToArray();
 343            if (queue.Count == 0) return new(true, messages);
 344        }
 345
 346        var prepared = await PrepareDirectOrderItemsAsync(queue, ct);
 347        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();
 350        foreach (var group in groups)
 351        {
 352            var requestItems = group.Select(x => ToRequestItem(x.Item)).ToArray();
 353            var response = await ResolveClient(group.Key.ProviderKey).Client.CreateTestOrderAsync(group.Key.Destination.
 354                requestItems, group.Key.Vkorg, group.Key.Destination.Kunza, false, ct);
 355            LogApiExchange(group.Key.ProviderKey, "createTestOrder/direct", group.Key.Vkorg, group.Key.Destination, requ
 356            var error = BuildError(response);
 357            foreach (var (preparedItem, index) in group.Select((item, index) => (item, index)))
 358            {
 359                error ??= GetItemError(FindResponseItem(response, preparedItem.Item, index));
 360                if (!string.IsNullOrWhiteSpace(error))
 361                    return new(false, [$"{preparedItem.Item.Pin}: {error}"]);
 362            }
 363        }
 364
 365        foreach (var group in groups)
 366        {
 367            var requestItems = group.Select(x => ToRequestItem(x.Item)).ToArray();
 368            var response = await ResolveClient(group.Key.ProviderKey).Client.CreateOrderAsync(group.Key.Destination.Kunr
 369                requestItems, group.Key.Vkorg, group.Key.Destination.Kunza, false, ct,
 370                OrderComment(group.Select(x => x.Item.OrderId)));
 371            LogApiExchange(group.Key.ProviderKey, "createOrder/direct", group.Key.Vkorg, group.Key.Destination, requestI
 372            var responseError = BuildError(response);
 373            if (!string.IsNullOrWhiteSpace(responseError)) return new(false, [responseError]);
 374
 375            // Заказ у Armtek уже создан: отмена запроса не должна оборвать запись его номера в БД,
 376            // иначе повтор не увидит первый заказ и оформит второй.
 377            var persistCt = CancellationToken.None;
 378            var requestJson = JsonSerializer.Serialize(requestItems);
 379            var responseJson = JsonSerializer.Serialize(response);
 380            foreach (var (preparedItem, index) in group.Select((item, index) => (item, index)))
 381            {
 382                var apiItem = FindResponseItem(response, preparedItem.Item, index);
 383                var itemError = GetItemError(apiItem);
 384                var allocations = apiItem?.Result.Where(x => !string.IsNullOrWhiteSpace(x.Vbeln) && !HasApiItemError(x.E
 385                if (!string.IsNullOrWhiteSpace(itemError) || allocations.Count == 0)
 386                    return new(false, [$"{preparedItem.Item.Pin}: {itemError ?? "Армтек не вернул номер созданного заказ
 387
 388                foreach (var allocation in allocations)
 389                {
 390                    var order = await GetOrCreateOrderAsync(preparedItem.Item.ProviderKey, group.Key.Vkorg, group.Key.De
 391                        allocation.Vbeln!, changedBy, requestJson, responseJson, persistCt);
 392                    _db.ArmtekOrderItems.Add(new OzonDomains.Models.ArmtekOrderItem
 393                    {
 394                        ArmtekOrderId = order.Id, StudioOrderId = preparedItem.Item.OrderId, Posnr = allocation.Posnr,
 395                        Pin = preparedItem.Item.Pin, Brand = preparedItem.Item.Brand,
 396                        Quantity = ParseDecimal(allocation.Quantity) ?? preparedItem.Item.Quantity,
 397                        Price = ParseDecimal(allocation.Price), CurrencyCode = allocation.Currency,
 398                        DeliveryAtUtc = ParseDate(allocation.DeliveryDate), Error = allocation.Error
 399                    });
 400                }
 401                var supplierOrderNumbers = string.Join(", ", allocations.Select(x => x.Vbeln).Distinct());
 402                // Прямое оформление обходит локальную корзину, поэтому фиксируем состояние
 403                // сразу здесь, а не ждём фонового опроса поставщика.
 404                await TrackOrderedAsync(preparedItem.Item.OrderId, preparedItem.Item.ProviderKey,
 405                    supplierOrderNumbers, changedBy, persistCt,
 406                    preparedItem.Item.DeliveryDaysMin, preparedItem.Item.DeliveryDaysMax, preparedItem.Item.Direction);
 407                messages.Add($"{preparedItem.Item.Pin}: заказ Армтек {supplierOrderNumbers} создан.");
 408            }
 409            await _db.SaveChangesAsync(persistCt);
 410        }
 411        return new(true, messages);
 412    }
 413
 414    public async Task<ArmtekCartOperationResult> TestAsync(string providerKey, string changedBy, IReadOnlyCollection<lon
 415    {
 416        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 417        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 418        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 419        if (selectedIds is not null && items.Count != selectedIds.Count)
 420            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите проверку."]);
 421        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 422        var destinationError = BuildDestinationError(items);
 423        if (destinationError is not null) return new(0, 0, items.Count, [destinationError]);
 424
 425        var (client, vkorg) = ResolveClient(providerKey);
 426        var ready = 0;
 427        var failed = 0;
 428        var messages = new List<string>();
 429        // KUNRG/KUNZA — параметры шапки заказа, поэтому на каждый адрес доставки уходит отдельный запрос.
 430        foreach (var group in GroupByDestination(items))
 431        {
 432            var groupItems = group.ToList();
 433            var requestItems = ToRequestItems(groupItems);
 434            var response = await client.CreateTestOrderAsync(group.Key.Kunrg, requestItems, vkorg, group.Key.Kunza, fals
 435            LogApiExchange(providerKey, "createTestOrder", vkorg, group.Key, requestItems, response);
 436            var responseError = BuildError(response);
 437            var resultJson = JsonSerializer.Serialize(response);
 438            foreach (var (item, index) in groupItems.Select((item, index) => (item, index)))
 439            {
 440                var apiItem = FindResponseItem(response, item, index);
 441                var error = responseError ?? GetItemError(apiItem);
 442                item.TestedAtUtc = DateTime.UtcNow; item.TestResultJson = resultJson; item.UpdatedAtUtc = DateTime.UtcNo
 443                if (string.IsNullOrWhiteSpace(error))
 444                {
 445                    item.Status = ArmtekCartItemStatus.Ready;
 446                    item.LastError = null;
 447                    ready++;
 448                    messages.Add($"{item.Pin}: проверено у поставщика ({DescribeDestination(item)}). Можно оформлять зак
 449                }
 450                else
 451                {
 452                    var refresh = await TryRefreshOfferAsync(client, vkorg, group.Key, item, ct);
 453                    if (refresh is not null)
 454                    {
 455                        var refreshMessage = ApplyRefreshedOffer(item, refresh.Offer, error);
 456                        item.Status = ArmtekCartItemStatus.Ready;
 457                        item.TestedAtUtc = DateTime.UtcNow;
 458                        item.TestResultJson = JsonSerializer.Serialize(refresh.TestResponse);
 459                        item.LastError = refreshMessage;
 460                        ready++;
 461                        messages.Add($"{item.Pin}: {refreshMessage}");
 462                        await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), true, refreshMessage,
 463                            ApiSupplierBasketCartStatus.Ready, supplierOrderNumber: null, item.BuyerKunnr,
 464                            item.DeliveryAddressName ?? item.DeliveryAddressKunnr, changedBy, ct);
 465                        continue;
 466                    }
 467
 468                    item.Status = ArmtekCartItemStatus.Failed;
 469                    item.LastError = $"{error} для Арт. {item.Pin} · {item.Brand}.";
 470                    failed++;
 471                    messages.Add(item.LastError);
 472                }
 473            }
 474        }
 475        await _db.SaveChangesAsync(ct);
 476        return new(ready, 0, failed, messages);
 477    }
 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    {
 483        if (!IsProvider(providerKey) || string.IsNullOrWhiteSpace(buyerKunnr) || string.IsNullOrWhiteSpace(deliveryAddre
 484            return false;
 485        var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(
 486            x => x.Id == id && x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(x.Status), ct);
 487        if (item is null || item.Status == ArmtekCartItemStatus.Processing) return false;
 488
 489        item.BuyerKunnr = buyerKunnr.Trim();
 490        item.DeliveryAddressKunnr = deliveryAddressKunnr.Trim();
 491        item.DeliveryAddressName = string.IsNullOrWhiteSpace(deliveryAddressName) ? null : deliveryAddressName.Trim();
 492        // Адрес влияет на цену и склад: подтверждение Armtek больше не действительно.
 493        item.Status = ArmtekCartItemStatus.Pending;
 494        item.TestedAtUtc = null;
 495        item.TestResultJson = null;
 496        item.LastError = null;
 497        item.UpdatedAtUtc = DateTime.UtcNow;
 498        await _db.SaveChangesAsync(ct);
 499        return true;
 500    }
 501
 502    public async Task<ArmtekCartOperationResult> CheckoutAsync(string providerKey, string changedBy, IReadOnlyCollection
 503    {
 504        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 505        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 506        var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct);
 507        if (selectedIds is not null && items.Count != selectedIds.Count)
 508            return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите оформление."])
 509        if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]);
 510        if (items.Any(x => x.Status != ArmtekCartItemStatus.Ready || x.TestedAtUtc < DateTime.UtcNow.AddMinutes(-5)))
 511            return new(0, 0, items.Count, ["Перед оформлением выполните свежую проверку Armtek."]);
 512        var destinationError = BuildDestinationError(items);
 513        if (destinationError is not null) return new(0, 0, items.Count, [destinationError]);
 514
 515        var ids = items.Select(x => x.Id).ToArray();
 516        var locked = await _db.ArmtekCartItems.Where(x => ids.Contains(x.Id) && x.Status == ArmtekCartItemStatus.Ready)
 517            .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ArmtekCartItemStatus.Processing).SetProperty(x => x.Up
 518        if (locked != items.Count) return new(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]);
 519
 520        try
 521        {
 522            var (client, vkorg) = ResolveClient(providerKey);
 523            var success = 0; var failed = 0; var messages = new List<string>();
 524            foreach (var group in GroupByDestination(items))
 525            {
 526                var groupItems = group.ToList();
 527                var requestItems = ToRequestItems(groupItems);
 528                var response = await client.CreateOrderAsync(group.Key.Kunrg, requestItems, vkorg, group.Key.Kunza, fals
 529                    OrderComment(groupItems.Select(x => x.StudioOrderId)));
 530                LogApiExchange(providerKey, "createOrder", vkorg, group.Key, requestItems, response);
 531                var responseJson = JsonSerializer.Serialize(response);
 532                var requestJson = JsonSerializer.Serialize(requestItems);
 533                var apiError = BuildError(response);
 534                for (var index = 0; index < groupItems.Count; index++)
 535                {
 536                    var cartItem = groupItems[index];
 537                    var apiItem = FindResponseItem(response, cartItem, index);
 538                    var allocations = apiItem?.Result.Where(x => !string.IsNullOrWhiteSpace(x.Vbeln) && !HasApiItemError
 539                    var itemError = apiError ?? GetItemError(apiItem);
 540                    if (allocations.Count == 0 || !string.IsNullOrWhiteSpace(itemError))
 541                    {
 542                        cartItem.Status = ArmtekCartItemStatus.Failed;
 543                        cartItem.LastError = itemError ?? "Армтек не вернул номер созданного заказа.";
 544                        failed++; messages.Add($"{cartItem.Pin}: {cartItem.LastError}");
 545                        continue;
 546                    }
 547                    foreach (var allocation in allocations)
 548                    {
 549                        var order = await GetOrCreateOrderAsync(providerKey, vkorg, group.Key, allocation.Vbeln!, change
 550                        _db.ArmtekOrderItems.Add(new OzonDomains.Models.ArmtekOrderItem
 551                        {
 552                            ArmtekOrderId = order.Id, ArmtekCartItemId = cartItem.Id, StudioOrderId = cartItem.StudioOrd
 553                            Posnr = allocation.Posnr, Pin = cartItem.Pin, Brand = cartItem.Brand,
 554                            Quantity = ParseDecimal(allocation.Quantity) ?? cartItem.Quantity, Price = ParseDecimal(allo
 555                            CurrencyCode = allocation.Currency, DeliveryAtUtc = ParseDate(allocation.DeliveryDate), Erro
 556                        });
 557                    }
 558                    cartItem.Status = ArmtekCartItemStatus.Ordered; cartItem.ActiveStudioOrderId = null; cartItem.LastEr
 559                    var orderNumbers = string.Join(", ", allocations.Select(x => x.Vbeln).Distinct());
 560                    success++; messages.Add($"{cartItem.Pin}: заказ Армтек {orderNumbers} создан ({DescribeDestination(c
 561                    await RecordHistoryAsync(cartItem.StudioOrderId, ToQueueItem(cartItem), true, null,
 562                        ApiSupplierBasketCartStatus.Ordered, orderNumbers, cartItem.BuyerKunnr,
 563                        cartItem.DeliveryAddressName ?? cartItem.DeliveryAddressKunnr, changedBy, ct);
 564                    await TrackOrderedAsync(cartItem.StudioOrderId, providerKey, orderNumbers, changedBy, ct);
 565                }
 566            }
 567            await _db.SaveChangesAsync(ct);
 568            InvalidateCount();
 569            return new(success, 0, failed, messages);
 570        }
 571        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 572        {
 573            // Обрыв соединения мог случиться уже после того, как Армтек принял заказ — исход неизвестен.
 574            if (ex is HttpRequestException) _guard?.CurrentDispatch?.MarkUncertain(providerKey);
 575            await _db.ArmtekCartItems.Where(x => ids.Contains(x.Id) && x.Status == ArmtekCartItemStatus.Processing)
 576                .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ArmtekCartItemStatus.Failed).SetProperty(x => x.La
 577            return new(0, 0, items.Count, [$"Армтек: {ex.Message}"]);
 578        }
 579    }
 580
 581    public async Task<bool> RemoveAsync(long id, string providerKey, CancellationToken ct = default)
 582    {
 583        var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(x => x.Id == id && x.ProviderKey == providerKey && Armt
 584        if (item is null) return false;
 585        item.Status = ArmtekCartItemStatus.Removed; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.UtcNow
 586        await _db.SaveChangesAsync(ct);
 587        await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 588        InvalidateCount(); return true;
 589    }
 590
 591    /// <summary>Убирает из корзины выбранные строки (или всю корзину провайдера, если ids не заданы).</summary>
 592    public async Task<ArmtekCartOperationResult> RemoveManyAsync(string providerKey, IReadOnlyCollection<long>? cartItem
 593    {
 594        if (!IsProvider(providerKey)) return new(0, 0, 0, ["Неизвестный поставщик Армтек."]);
 595        var selectionError = ValidateSelection(cartItemIds, out var selectedIds);
 596        if (selectionError is not null) return new(0, 0, 0, [selectionError]);
 597
 598        // Оформляемые строки не трогаем: заказ уже ушёл в Armtek и его нельзя отменить удалением из корзины.
 599        var items = (await GetTrackedActiveAsync(providerKey, selectedIds, ct))
 600            .Where(x => x.Status != ArmtekCartItemStatus.Processing)
 601            .ToList();
 602        foreach (var item in items)
 603        {
 604            item.Status = ArmtekCartItemStatus.Removed;
 605            item.ActiveStudioOrderId = null;
 606            item.UpdatedAtUtc = DateTime.UtcNow;
 607        }
 608        await _db.SaveChangesAsync(ct);
 609        foreach (var item in items)
 610        {
 611            await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct);
 612        }
 613        var removed = items.Count;
 614        InvalidateCount();
 615        if (removed == 0)
 616            return new(0, 0, 0, ["Нечего удалять: строки уже удалены или сейчас оформляются."]);
 617        var skipped = selectedIds is null ? 0 : selectedIds.Count - removed;
 618        var messages = new List<string> { $"Удалено из корзины {ProviderName(providerKey)}: {removed}." };
 619        if (skipped > 0) messages.Add($"Пропущено (оформляются или уже удалены): {skipped}.");
 620        return new(removed, skipped, 0, messages);
 621    }
 622
 623    public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.ArmtekCartItems.CountAsync(x => ArmtekCartI
 624    private Task<List<ArmtekCartItem>> GetTrackedActiveAsync(string providerKey, IReadOnlyCollection<long>? cartItemIds,
 625    {
 626        var query = _db.ArmtekCartItems.Where(x => x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(
 627        if (cartItemIds is not null)
 628            query = query.Where(x => cartItemIds.Contains(x.Id));
 629        return query.OrderBy(x => x.Id).ToListAsync(ct);
 630    }
 631    private (ArmtekDataManager Client, string Vkorg) ResolveClient(string providerKey)
 632    {
 633        if (!IsProvider(providerKey)) throw new ArgumentException("Неизвестный Armtek provider.", nameof(providerKey));
 634        var client = providerKey == "ArmtekBy" ? (ArmtekDataManager)_armtekBy : _armtek;
 635        return (client, providerKey == "ArmtekBy" ? "2000" : "4000");
 636    }
 637    private static string? BuildDestinationError(IReadOnlyCollection<ArmtekCartItem> items)
 638    {
 639        var unlinked = items
 640            .Where(x => string.IsNullOrWhiteSpace(x.BuyerKunnr) || string.IsNullOrWhiteSpace(x.DeliveryAddressKunnr))
 641            .Select(x => x.Pin).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
 642        return unlinked.Length == 0
 643            ? null
 644            : $"Не указан клиент и адрес доставки Armtek: {string.Join(", ", unlinked)}. Выберите адрес в корзине либо с
 645    }
 646    private static IEnumerable<IGrouping<ArmtekDestination, ArmtekCartItem>> GroupByDestination(IEnumerable<ArmtekCartIt
 647        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    {
 657        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        {
 660            var providerKey = group.Key;
 661            var orderIds = group.Select(x => x.OrderId).Distinct().ToList();
 662
 663            var states = await _db.OrderSupplierStates.AsNoTracking()
 664                .Where(x => x.ProviderKey == providerKey && orderIds.Contains(x.OrderId))
 665                .Select(x => new { x.OrderId, x.Stage, x.ActiveOrderId, x.ExternalOrderNumber })
 666                .ToListAsync(ct);
 667            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
 672            var untrackedSince = DateTime.UtcNow - UntrackedOrderWindow;
 673            var placed = await _db.ArmtekOrderItems.AsNoTracking()
 674                .Where(x => orderIds.Contains(x.StudioOrderId) && x.ArmtekOrder!.ProviderKey == providerKey
 675                            && x.ArmtekOrder.CreatedAtUtc >= untrackedSince
 676                            && (x.Error == null || x.Error == "" || x.Error == "0"))
 677                .Select(x => new { x.StudioOrderId, x.ArmtekOrder!.Vbeln })
 678                .ToListAsync(ct);
 679            var placedNumbers = placed
 680                .GroupBy(x => x.StudioOrderId)
 681                .ToDictionary(x => x.Key, x => string.Join(", ", x.Select(i => i.Vbeln).Distinct()));
 682
 683            foreach (var orderId in orderIds)
 684            {
 685                if (ordered.TryGetValue(orderId, out var stateNumber))
 686                {
 687                    result[(providerKey, orderId)] = !string.IsNullOrWhiteSpace(stateNumber)
 688                        ? stateNumber
 689                        : placedNumbers.GetValueOrDefault(orderId) ?? "(номер не записан)";
 690                }
 691                else if (placedNumbers.TryGetValue(orderId, out var numbers) && !closed.Contains(orderId))
 692                {
 693                    result[(providerKey, orderId)] = numbers;
 694                }
 695            }
 696        }
 697        return result;
 698    }
 699
 700    private async Task<(List<ArmtekPreparedDirectItem> Items, List<string> Errors)> PrepareDirectOrderItemsAsync(
 701        IReadOnlyCollection<ArmtekCartQueueItem> queue, CancellationToken ct)
 702    {
 703        var items = new List<ArmtekPreparedDirectItem>();
 704        var errors = new List<string>();
 705        foreach (var item in queue)
 706        {
 707            if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 ||
 708                string.IsNullOrWhiteSpace(item.ArtId) || string.IsNullOrWhiteSpace(item.Pin) || string.IsNullOrWhiteSpac
 709                string.IsNullOrWhiteSpace(item.Keyzak))
 710            {
 711                errors.Add($"{Describe(item)}: неполные данные предложения Armtek.");
 712                continue;
 713            }
 714            var destination = await _deliveryAddressMappings.ResolveAsync(item.ProviderKey, item.OrderId, item.ZasylWare
 715            if (destination is null || string.IsNullOrWhiteSpace(destination.BuyerKunnr) || string.IsNullOrWhiteSpace(de
 716            {
 717                errors.Add($"{item.Pin}: не указан клиент и адрес доставки Armtek для склада отгрузки.");
 718                continue;
 719            }
 720            var (_, vkorg) = ResolveClient(item.ProviderKey);
 721            items.Add(new ArmtekPreparedDirectItem(item with { Pin = NormalizePin(item.Pin), Brand = item.Brand.Trim() }
 722                new ArmtekDestination(destination.BuyerKunnr, destination.DeliveryAddressKunnr)));
 723        }
 724        if (queue.GroupBy(x => new { x.ProviderKey, x.OrderId }).Any(group => group.Count() > 1))
 725            errors.Add("Для одного заказа выбрано несколько предложений Armtek одного поставщика.");
 726        return (items, errors);
 727    }
 728
 729    private sealed record ArmtekPreparedDirectItem(ArmtekCartQueueItem Item, string Vkorg, ArmtekDestination Destination
 730    private static ArmtekOrderRequestItem ToRequestItem(ArmtekCartQueueItem item) => new(
 731        item.Pin, item.Brand, item.Quantity, item.Keyzak, item.SourcePrice, FormatDateMax(item.DeliveryAtUtc),
 732        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) =>
 736        StudioOrderCommentLink.BuildBatch(studioOrderIds, StudioOrderCommentLink.MaxLengthFor("Armtek") ?? 100);
 737    private static string? FormatDateMax(string? value) => ParseApiDate(value)?.ToString("yyyyMMddHHmmss", CultureInfo.I
 738    private static string DescribeDestination(ArmtekCartItem item) =>
 739        $"клиент {item.BuyerKunnr}, адрес {item.DeliveryAddressName ?? item.DeliveryAddressKunnr}";
 740    private async Task<ArmtekOrder> GetOrCreateOrderAsync(string providerKey, string vkorg, ArmtekDestination destinatio
 741    {
 742        var existing = await _db.ArmtekOrders.FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Vbeln == vbeln,
 743        if (existing is not null) return existing;
 744        var order = new ArmtekOrder { ProviderKey = providerKey, Vkorg = vkorg, Kunrg = destination.Kunrg, Kunza = desti
 745        _db.ArmtekOrders.Add(order); await _db.SaveChangesAsync(ct); return order;
 746    }
 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        {
 755            var displayName = ProviderName(item.ProviderKey);
 756            var entry = new ApiSupplierBasketHistoryEntry(
 757                item.ProviderKey,
 758                displayName,
 759                ApiSupplierBasketHistoryEntry.ResolveLogoUrl(item.ProviderKey),
 760                success,
 761                error,
 762                ApiItemId: null,
 763                Article: item.Pin,
 764                ProductName: item.ProductName ?? item.Brand,
 765                Price: item.SourcePrice,
 766                CurrencyCode: item.CurrencyCode,
 767                Quantity: item.Quantity,
 768                ShipmentNumber: item.ShipmentNumber,
 769                ClientName: item.ClientName,
 770                WarehouseName: item.WarehouseName,
 771                BuyerKunnr: buyerKunnr,
 772                DeliveryAddressName: deliveryAddressName,
 773                CartStatus: cartStatus,
 774                SupplierOrderNumber: supplierOrderNumber,
 775                SupplierCartUrl: CartUrl(item.ProviderKey));
 776
 777            await _history.AddOrderHistory(new OrderHistory
 778            {
 779                OrderId = orderId,
 780                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 781                ColumnDisplayName = $"Корзина {displayName}",
 782                OldValue = null,
 783                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 784                ChangedAt = DateTime.Now,
 785                ChangedBy = changedBy
 786            });
 787        }
 788        catch
 789        {
 790            // Не ломаем размещение в корзине из-за журнала истории.
 791        }
 792    }
 793
 794    private static string CartUrl(string providerKey) =>
 795        providerKey == "ArmtekBy" ? "/SupplierCart?provider=armtekby&supplierTab=basket" : "/SupplierCart?provider=armte
 796    private static IReadOnlyCollection<ArmtekOrderRequestItem> ToRequestItems(IEnumerable<ArmtekCartItem> items) => item
 797        NormalizePin(x.Pin), x.Brand.Trim(), x.Quantity, x.Keyzak, x.SourcePrice,
 798        x.DeliveryAtUtc?.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture),
 799        StudioOrderCommentLink.BuildFor(x.ProviderKey, "заказ", x.StudioOrderId))).ToArray();
 800    private static ArmtekCreateOrderItemResult? FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekRe
 801        response.Resp?.Items.FirstOrDefault(x => string.Equals(x.ArtId, cartItem.ArtId, StringComparison.Ordinal))
 802        ?? response.Resp?.Items.FirstOrDefault(x =>
 803            string.Equals(NormalizePin(x.Pin), NormalizePin(cartItem.Pin), StringComparison.OrdinalIgnoreCase) &&
 804            string.Equals(x.Brand, cartItem.Brand, StringComparison.OrdinalIgnoreCase))
 805        ?? response.Resp?.Items.ElementAtOrDefault(fallbackIndex);
 806    private static ArmtekCreateOrderItemResult? FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekRe
 807        response.Resp?.Items.FirstOrDefault(x => string.Equals(x.ArtId, item.ArtId, StringComparison.Ordinal))
 808        ?? response.Resp?.Items.FirstOrDefault(x =>
 809            string.Equals(NormalizePin(x.Pin), NormalizePin(item.Pin), StringComparison.OrdinalIgnoreCase) &&
 810            string.Equals(x.Brand, item.Brand, StringComparison.OrdinalIgnoreCase))
 811        ?? response.Resp?.Items.ElementAtOrDefault(fallbackIndex);
 812    private static bool HasApiItemError(string? error) => !string.IsNullOrWhiteSpace(error) && error != "0";
 813    private static string? GetItemError(ArmtekCreateOrderItemResult? item)
 814    {
 815        if (item is null) return "Армтек не вернул результат проверки позиции.";
 816        if (HasApiItemError(item.Error)) return item.ErrorMessage ?? item.Error;
 817        if (item.Result.Count == 0) return "Армтек не подтвердил наличие позиции.";
 818        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    {
 827        if (!IsProvider(providerKey)) return SupplierOrderCancelResult.Failed($"Неизвестный провайдер Armtek: {providerK
 828        var links = await _db.ArmtekOrderItems.AsNoTracking()
 829            .Where(x => x.StudioOrderId == orderId && x.ArmtekOrder != null && x.ArmtekOrder.ProviderKey == providerKey 
 830            .Select(x => new { x.Posnr, x.ArmtekOrder!.Vbeln, x.ArmtekOrder.Kunrg, x.ArmtekOrder.Vkorg })
 831            .ToListAsync(ct);
 832        if (links.Count == 0) return SupplierOrderCancelResult.Failed("в Studio2 нет связки с заказом Армтек (номер и по
 833
 834        var (client, defaultVkorg) = ResolveClient(providerKey);
 835        var messages = new List<string>();
 836        var outcome = SupplierOrderCancelOutcome.Cancelled;
 837        foreach (var group in links.GroupBy(x => new { x.Vbeln, x.Kunrg, x.Vkorg }))
 838        {
 839            var vkorg = string.IsNullOrWhiteSpace(group.Key.Vkorg) ? defaultVkorg : group.Key.Vkorg;
 840            try
 841            {
 842                var order = await client.GetOrderAsync(group.Key.Kunrg, group.Key.Vbeln, vkorg, edit: true, ct);
 843                if (order.HasErrors || order.Resp is null)
 844                    return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ArmtekMessages(order.Messages) ?
 845
 846                var positions = group.Select(x => x.Posnr!).ToHashSet(StringComparer.OrdinalIgnoreCase);
 847                var items = order.Resp.Items.Where(x => x.Posnr != null && positions.Contains(x.Posnr)).ToList();
 848                if (items.Count == 0) return SupplierOrderCancelResult.Failed($"в заказе {group.Key.Vbeln} нет позиций {
 849                if (items.Any(x => ParseDecimal(x.KwmengR) is > 0))
 850                {
 851                    outcome = SupplierOrderCancelOutcome.ReturnNeeded;
 852                    messages.Add($"заказ {group.Key.Vbeln}: позиция уже отгружена — нужен возврат (createReturnInvoice)"
 853                    continue;
 854                }
 855                var toReject = items.Where(x => ParseDecimal(x.Kwmeng) is > 0 && ParseDecimal(x.KwmengRej) != ParseDecim
 856                if (toReject.Count == 0)
 857                {
 858                    messages.Add($"заказ {group.Key.Vbeln}: позиции уже отклонены");
 859                    continue;
 860                }
 861
 862                var abgru = order.Resp.AbgruItems.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Default))?.Abgru
 863                            ?? order.Resp.AbgruItems.FirstOrDefault()?.Abgru;
 864                if (string.IsNullOrWhiteSpace(abgru))
 865                    return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: Армтек не дал причин отклонения (
 866
 867                var edit = await client.EditOrderAsync(group.Key.Kunrg, group.Key.Vbeln, toReject.Select(x => (x.Posnr!,
 868                    abgru, vkorg, note, ct);
 869                if (edit.HasErrors)
 870                    return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ArmtekMessages(edit.Messages) ??
 871                messages.Add($"заказ {group.Key.Vbeln}, поз. {string.Join(", ", toReject.Select(x => x.Posnr))}: отклоне
 872            }
 873            catch (ArmtekApiException ex)
 874            {
 875                return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ex.Message}");
 876            }
 877        }
 878        var text = string.Join("; ", messages);
 879        return outcome == SupplierOrderCancelOutcome.ReturnNeeded ? SupplierOrderCancelResult.ReturnNeeded(text) : Suppl
 880    }
 881
 882    private static string? ArmtekMessages(IEnumerable<Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekMessage> mess
 883    {
 884        var text = string.Join("; ", messages.Where(x => x.Type is "E" or "A").Select(x => x.Text).Where(x => !string.Is
 885        return text.Length == 0 ? null : text;
 886    }
 887
 888    private static bool IsProvider(string key) => key is "Armtek" or "ArmtekBy";
 889    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    {
 892        if (ids is null)
 893        {
 894            normalizedIds = null;
 895            return null;
 896        }
 897
 898        normalizedIds = ids.Where(id => id > 0).Distinct().ToArray();
 899        return normalizedIds.Count == 0 ? "Выберите хотя бы одну позицию корзины." : null;
 900    }
 901    private static string ProviderName(string key) => key == "ArmtekBy" ? "Армтек BY" : "Армтек RU";
 902    private static string? BuildError<T>(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse<T> response) => r
 903    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        {
 911            var response = await client.SearchAsync(destination.Kunrg, NormalizePin(item.Pin), item.Brand.Trim(), vkorg,
 912                queryType: 1, kunnrZa: destination.Kunza, incotermsSelfPickup: false, ct: ct);
 913            if (response.HasErrors)
 914                return null;
 915
 916            var candidates = (response.Resp ?? [])
 917                .Where(offer => string.Equals(NormalizePin(offer.Pin), NormalizePin(item.Pin), StringComparison.OrdinalI
 918                                && string.Equals(offer.Brand, item.Brand, StringComparison.OrdinalIgnoreCase)
 919                                && (string.IsNullOrWhiteSpace(item.ArtId) || string.Equals(offer.ArtId, item.ArtId, Stri
 920                .Where(offer => ParseDecimal(offer.Price) is not null && HasEnoughStock(offer.Rvalue, item.Quantity))
 921                .OrderByDescending(offer => string.Equals(offer.PartnerWarehouseCode, item.PartnerWarehouseCode, StringC
 922                .ThenBy(offer => ParseApiDate(offer.DeliveryDate) ?? DateTime.MaxValue)
 923                .ThenBy(offer => ParseDecimal(offer.Price))
 924                .Take(8)
 925                .ToArray();
 926            var checkedOffers = await Task.WhenAll(candidates.Select(offer => CheckRefreshedOfferAsync(
 927                client, vkorg, destination, item, offer, ct)));
 928            return checkedOffers.FirstOrDefault(offer => offer is not null);
 929        }
 930        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 931        {
 932            return null;
 933        }
 934    }
 935    private async Task<ArmtekRefreshedOffer?> CheckRefreshedOfferAsync(ArmtekDataManager client, string vkorg,
 936        ArmtekDestination destination, ArmtekCartItem item, ArmtekSearchItem offer, CancellationToken ct)
 937    {
 938        await RefreshTestConcurrency.WaitAsync(ct);
 939        try
 940        {
 941            var requestItems = new[] { new ArmtekOrderRequestItem(NormalizePin(item.Pin), item.Brand.Trim(), item.Quanti
 942                offer.Keyzak, ParseDecimal(offer.Price), ParseApiDate(offer.DeliveryDate)?.ToString("yyyyMMddHHmmss", Cu
 943            var testResponse = await client.CreateTestOrderAsync(destination.Kunrg, requestItems, vkorg, destination.Kun
 944            LogApiExchange(item.ProviderKey, "refreshTestOrder", vkorg, destination, requestItems, testResponse);
 945            var testItem = FindResponseItem(testResponse, item);
 946            return string.IsNullOrWhiteSpace(BuildError(testResponse) ?? GetItemError(testItem))
 947                ? new ArmtekRefreshedOffer(offer, testResponse)
 948                : null;
 949        }
 950        finally
 951        {
 952            RefreshTestConcurrency.Release();
 953        }
 954    }
 955    private static bool HasEnoughStock(string? stock, decimal quantity)
 956    {
 957        var available = ParseStock(stock);
 958        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    {
 964        if (string.IsNullOrWhiteSpace(stock)) return null;
 965        var normalized = stock.Trim().Trim('>', '<', '~', '=', ' ').Replace(',', '.');
 966        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    {
 970        var oldKeyzak = item.Keyzak;
 971        var oldPrice = item.SourcePrice;
 972        item.ArtId = offer.ArtId;
 973        item.Keyzak = offer.Keyzak;
 974        item.WarehouseName = null;
 975        item.PartnerWarehouseCode = offer.PartnerWarehouseCode;
 976        item.SourcePrice = ParseDecimal(offer.Price);
 977        item.CurrencyCode = string.IsNullOrWhiteSpace(offer.Currency) ? item.CurrencyCode : offer.Currency;
 978        item.DeliveryAtUtc = ParseApiDate(offer.DeliveryDate);
 979        if (!string.IsNullOrWhiteSpace(offer.Name)) item.ProductName = offer.Name;
 980
 981        var price = item.SourcePrice.HasValue
 982            ? $"цена {oldPrice?.ToString("0.##", CultureInfo.InvariantCulture) ?? "—"} → {item.SourcePrice.Value.ToStrin
 983            : "цена не получена";
 984        var delivery = item.DeliveryAtUtc.HasValue
 985            ? $", срок {item.DeliveryAtUtc.Value:dd.MM.yyyy HH:mm}"
 986            : string.Empty;
 987        return $"проверка отклонена: {originalError}. Найдено и повторно подтверждено актуальное предложение: ключ {oldK
 988    }
 989
 990    private sealed record ArmtekRefreshedOffer(ArmtekSearchItem Offer,
 991        Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse<ArmtekCreateOrderResponse> TestResponse);
 992    private static DateTime? ParseDate(string? value) => DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.Inv
 993    private static DateTime? ParseApiDate(string? value) =>
 994        DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compa
 995        DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed) ? parse
 996    private static string Describe(ArmtekCartQueueItem item)
 997    {
 998        var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber;
 999        var product = string.IsNullOrWhiteSpace(item.ProductName) ? item.Pin : item.ProductName;
 1000        var client = string.IsNullOrWhiteSpace(item.ClientName) ? string.Empty : $", {item.ClientName}";
 1001        return $"{item.Pin}: заказ {order}, {product}{client}";
 1002    }
 1003    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
 1005        _logger.LogInformation("Armtek cart {Provider} {Operation}. Request: {Request}. Response: {Response}",
 1006            providerKey, operation,
 1007            JsonSerializer.Serialize(new
 1008            {
 1009                VKORG = vkorg,
 1010                KUNRG = destination.Kunrg,
 1011                KUNZA = destination.Kunza,
 1012                INCOTERMS = "0",
 1013                ITEMS = requestItems.Select(item => new
 1014                {
 1015                    item.Pin,
 1016                    item.Brand,
 1017                    Quantity = item.Quantity.ToString("0.####", CultureInfo.InvariantCulture),
 1018                    item.Keyzak,
 1019                    PriceMax = item.PriceMax?.ToString("0.####", CultureInfo.InvariantCulture),
 1020                    DateMax = item.DateMax
 1021                })
 1022            }),
 1023            JsonSerializer.Serialize(response));
 1024    private void InvalidateCount() => _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 1025}

Methods/Properties

get_Kunrg()