< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.SupplierCartController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/SupplierCartController.cs
Line coverage
2%
Covered lines: 33
Uncovered lines: 1404
Coverable lines: 1437
Total lines: 2555
Line coverage: 2.2%
Branch coverage
1%
Covered branches: 16
Total branches: 1253
Branch coverage: 1.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
get_UserName()100%210%
get_Provider()100%210%
.ctor(...)100%210%
IsForumAutoProvider(...)0%110100%
ResolveForumAutoClient(...)0%7280%
Index()100%210%
Grid()0%620%
Summary()100%210%
<Summary()100%210%
BuildSummaryAsync()100%210%
CountForumAutoCartAsync(...)100%210%
CountForumAutoOrdersAsync(...)100%210%
CountFavoritPartsCartAsync(...)100%210%
CountFavoritPartsOrdersAsync(...)100%210%
BuildProviderSectionAsync()0%113421060%
Count()100%1194.44%
<Count()100%214.16%
TestMlAutoOrder()0%2040%
CreateMlAutoOrder()0%2040%
RemoveMlAutoCartItems()100%210%
UpdateMlAutoDeliveryAddress()0%2040%
RequoteMlAutoCartItem()100%210%
ReplaceMlAutoCartItemOffer()0%2040%
ImportMlAutoOrder()0%4260%
MlAutoOrderDetails()0%620%
TestMlAutoRuOrder()0%2040%
CreateMlAutoRuOrder()0%2040%
RemoveMlAutoRuCartItems()100%210%
UpdateMlAutoRuDeliveryAddress()0%2040%
TestForumAutoOrder()0%4260%
CreateForumAutoOrder()0%4260%
TestMoskvorechieIstraOrder()100%210%
CreateMoskvorechieIstraOrder()0%2040%
RemoveMoskvorechieIstraCartItems()100%210%
TestProfitLigaOrder()0%4260%
CreateProfitLigaOrder()0%4260%
RemoveProfitLigaCartItems()0%4260%
CreateNikeiOrder()0%7280%
RemoveNikeiCartItems()0%4260%
NikeiDeliveryOptions()0%2040%
TestMikadoCartItems()0%2040%
RemoveMikadoCartItems()0%7280%
TestZZapMoscowOrder()0%620%
CreateZZapMoscowOrder()0%7280%
CancelZZapMoscowOrder()0%7280%
RemoveZZapMoscowCartItems()0%7280%
ClearProfitLigaApiCart()0%620%
TestBergOrder()0%4260%
CreateBergOrder()0%4260%
RemoveBergCartItems()0%2040%
CreateMotexOrder()0%2040%
RemoveMotexCartItems()0%7280%
ImportMoskvorechieIstraOrder()0%2040%
RemoveForumAutoCartItems()0%156120%
TestFavoritPartsOrder()0%620%
CreateFavoritPartsOrder()0%4260%
RemoveFavoritPartsCartItems()0%156120%
TestFavoritPartsIstraOrder()0%620%
CreateFavoritPartsIstraOrder()0%4260%
RemoveFavoritPartsIstraCartItems()0%156120%
TestFavoritPartsRostovOrder()0%620%
CreateFavoritPartsRostovOrder()0%4260%
RemoveFavoritPartsRostovCartItems()0%156120%
CreateAvdOrder()0%110100%
RemoveAvdBasketItems()0%210140%
RemoveArmtekCartItems()0%156120%
TestArmtekOrder()0%4260%
UpdateArmtekDeliveryAddress()0%156120%
RequoteArmtekCartItem()0%620%
ReplaceArmtekCartItemOffer()0%4260%
PrepareArmtekCheckout()0%7280%
CreateArmtekOrder()0%506220%
ArmtekCheckoutPreparationCacheKey(...)100%210%
NormalizeCartItemIds(...)0%620%
SameCartItemIds(...)0%2040%
RemoveShateMCartItems()100%210%
CheckoutShateMCartItems()0%110100%
TryCreateShateMCheckoutOptions(...)0%210140%
ImageProxy()0%110100%
IsArmtekProvider(...)0%2040%
RefreshLocalCartStatusesAsync()0%3306570%
get_OrderId()100%210%
TryExtractOrderId(...)100%210%
TrackAvdOrderedAsync()0%4260%
TrackAvdRemovedAsync()0%4260%
BuildJapartsSectionAsync()0%620%
BuildBergSectionAsync()0%4260%
BuildAvdSectionAsync()0%420200%
GetBasketItemSortKey(...)0%620%
BuildArmtekSectionAsync()100%210%
BuildMlAutoSectionAsync()0%600240%
BuildMlAutoRuSectionAsync()0%420200%
BuildForumAutoSectionAsync()100%210%
BuildFavoritPartsSectionAsync(...)100%210%
BuildFavoritPartsSectionAsync()0%620%
BuildMoskvorechieIstraSectionAsync()100%210%
BuildProfitLigaSectionAsync()0%620%
BuildNikeiSectionAsync()0%600240%
ParseNikeiDate(...)0%620%
BuildMikadoSectionAsync()100%210%
BuildZZapMoscowSectionAsync()0%342180%
ToRow()0%140421180%
BuildMotexSectionAsync()0%930300%
GetMotexOrderCountAsync()100%210%
BuildShateMSectionAsync()0%702260%
ReadDecimal(...)0%2040%
ParseMotexDate(...)0%620%
GetFavoritPartsApiOrdersAsync()0%272160%
<GetFavoritPartsApiOrdersAsync()100%210%
ToFavoritPartsApiOrderRow(...)0%506220%
GetForumAutoApiOrdersAsync()0%156120%
<GetForumAutoApiOrdersAsync()100%210%
ToForumAutoApiOrderRow(...)0%156120%
ParseForumAutoDate(...)0%2040%
LoadStudioOrderLinksAsync()0%620%
GetMlAutoRuApiOrdersAsync()0%620%
<GetMlAutoRuApiOrdersAsync()100%210%
<GetMlAutoRuApiOrdersAsync()0%620%
GetMlAutoApiOrdersAsync()0%620%
<GetMlAutoApiOrdersAsync()100%210%
<GetMlAutoApiOrdersAsync()100%210%
BuildMlAutoOrderRowAsync()0%620%
<BuildMlAutoOrderRowAsync()100%210%
ToMlAutoApiOrderRow(...)0%210140%
DescribeMlAutoItemStatus(...)0%4260%
DescribeMlAutoOrderStatus(...)0%156120%
DescribeMlAutoPayment(...)0%4260%
GetArmtekDeliveryOptionsAsync()0%620%
<GetArmtekDeliveryOptionsAsync()0%7280%
FirstNotEmpty(...)0%620%
GetArmtekApiOrdersAsync()0%620%
<GetArmtekApiOrdersAsync()0%7280%
ToArmtekApiOrderRow(...)0%620%
BuildArmtekSection(...)0%812280%
BuildArmtekCartComment(...)0%4260%
IsAllowedProductImageUrl(...)100%11100%
TryCreateProductImageUri(...)100%1212100%
IsAllowedProductImageHost(...)100%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/SupplierCartController.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using System.Security.Cryptography;
 3using System.Globalization;
 4using Microsoft.AspNetCore.Authorization;
 5using Microsoft.AspNetCore.Mvc;
 6using OzonOrdersWeb.Filters;
 7using Microsoft.EntityFrameworkCore;
 8using Microsoft.Extensions.Caching.Memory;
 9using OzonDomains.Models;
 10using OzonDomains.Models.SupplierStatuses;
 11using OzonOrdersWeb.Authorization;
 12using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart;
 13using OzonRepositories.Context;
 14using Services.ApiServices.AvdApi;
 15using Services.ApiServices.JapartsApi;
 16using Services.ApiServices.BergApi;
 17using Services.ApiServices.ArmtekApi;
 18using Services.ApiServices.ArmtekApi.Models.Response;
 19using Services.ApiServices.FavoritPartsApi;
 20using Services.ApiServices.MlAutoApi;
 21using Services.ApiServices.MoskvorechieApi;
 22using Services.ApiServices.MotexApi;
 23using Services.ApiServices.ProfitLigaApi;
 24using Services.ApiServices.NikeiApi;
 25using Services.ApiServices.ShateMApi;
 26using MikadoCartService = OzonOrdersWeb.TransactionServices.MikadoCartService;
 27using ZZapCartService = OzonOrdersWeb.TransactionServices.ZZapCartService;
 28using Services.ApiServices.ZZapApi;
 29using Newtonsoft.Json.Linq;
 30using OzonOrdersWeb.TransactionServices;
 31using OzonOrdersWeb.WebServices.SupplierCart;
 32using Services.SupplierStatusServices;
 33
 34namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 35
 36/// <summary>Общая корзина API-поставщиков и ручное оформление заказов.</summary>
 37[Area("Studio2")]
 38[Authorize(Roles = "User,Admin")]
 39public sealed class SupplierCartController : Controller
 40{
 41    // Новый формат комментария — "Studio2 {номер отправления} (ID {orderId})"; старый (позиции, добавленные
 42    // до этого формата) — "Studio2 заказ {orderId}". Пробуем новый, затем откатываемся на старый.
 143    private static readonly Regex StudioOrderIdPattern = new(@"^Studio2 .+ \(ID (\d+)\)$", RegexOptions.Compiled);
 144    private static readonly Regex LegacyStudioOrderCommentPattern = new(@"Studio2 заказ (\d+)", RegexOptions.Compiled);
 45
 46    /// <summary>Период заказов ML-Auto по умолчанию: API отдаёт весь список без фильтра по датам.</summary>
 47    private const int MlAutoDefaultPeriodDays = 30;
 48    /// <summary>Состав каждого заказа — отдельный запрос, поэтому число разворачиваемых заказов ограничено.</summary>
 49    private const int MlAutoMaxDetailedOrders = 60;
 50    private const int MlAutoDetailsConcurrency = 4;
 51
 52    /// <summary>Глубина истории заказов Japarts: одна и та же для вкладки «Заказы» и её счётчика.</summary>
 53    private const int JapartsOrdersIntervalDays = 60;
 54
 55    private readonly AvdDataManager _avd;
 56    private readonly JapartsDataManager _japarts;
 57    private readonly BergDataManager _berg;
 58    private readonly BergCartService _bergCartService;
 59    private readonly ApplicationDbContext _context;
 60    private readonly IHttpClientFactory _httpClientFactory;
 61    private readonly IMemoryCache _cache;
 62    private readonly ArmtekCartService _armtekCart;
 63    private readonly ArmtekDataManager _armtek;
 64    private readonly ArmtekByDataManager _armtekBy;
 65    private readonly MlAutoCartService _mlAutoCart;
 66    private readonly MlAutoDataManager _mlAuto;
 67    private readonly MlAutoRuCartService _mlAutoRuCart;
 68    private readonly MlAutoRuDataManager _mlAutoRu;
 69    private readonly ForumAutoCartService _forumAutoCart;
 70    private readonly Services.ApiServices.ForumAutoApi.ForumAutoDataManager _forumAuto;
 71    private readonly Services.ApiServices.ForumAutoApi.ForumAutoInterpartsDataManager _forumAutoInterparts;
 72    private readonly Services.ApiServices.ForumAutoApi.ForumAutoPiterDataManager _forumAutoPiter;
 73    private readonly Services.ApiServices.ForumAutoApi.ForumAutoRostovDataManager _forumAutoRostov;
 74    private readonly Services.ApiServices.ForumAutoApi.ForumAutoIstraDataManager _forumAutoIstra;
 75    private readonly FavoritPartsCartService _favoritPartsCart;
 76    private readonly FavoritPartsIstraCartService _favoritPartsIstraCart;
 77    private readonly FavoritPartsRostovCartService _favoritPartsRostovCart;
 78    private readonly MoskvorechieIstraCartService _moskvorechieIstraCart;
 79    private readonly MotexCartService _motexCart;
 80    private readonly ProfitLigaCartService _profitLigaCart;
 81    private readonly NikeiCartService _nikeiCart;
 82    private readonly ShateMCartService _shateMCart;
 83    private readonly MikadoCartService _mikadoCart;
 84    private readonly ZZapCartService _zzapCart;
 85    private readonly ShateMDataManager _shateM;
 86    private readonly MotexDataManager _motex;
 87    private readonly IOrderSupplierStateService _supplierStateService;
 88    private readonly ILogger<SupplierCartController> _logger;
 089    private sealed record ArmtekCheckoutPreparation(string UserName, string Provider, long[]? CartItemIds);
 090    private sealed record SupplierCartSummaryRow(string Provider, int CartCount, int OrderCount);
 91
 092    public SupplierCartController(
 093        AvdDataManager avd,
 094        JapartsDataManager japarts,
 095        BergDataManager berg,
 096        BergCartService bergCartService,
 097        ApplicationDbContext context,
 098        IHttpClientFactory httpClientFactory,
 099        IMemoryCache cache,
 0100        ArmtekCartService armtekCart,
 0101        ArmtekDataManager armtek,
 0102        ArmtekByDataManager armtekBy, MlAutoCartService mlAutoCart, MlAutoDataManager mlAuto, MlAutoRuCartService mlAuto
 0103        ForumAutoCartService forumAutoCart, Services.ApiServices.ForumAutoApi.ForumAutoDataManager forumAuto,
 0104        Services.ApiServices.ForumAutoApi.ForumAutoInterpartsDataManager forumAutoInterparts,
 0105        Services.ApiServices.ForumAutoApi.ForumAutoPiterDataManager forumAutoPiter,
 0106        Services.ApiServices.ForumAutoApi.ForumAutoRostovDataManager forumAutoRostov,
 0107        Services.ApiServices.ForumAutoApi.ForumAutoIstraDataManager forumAutoIstra,
 0108        FavoritPartsCartService favoritPartsCart, FavoritPartsIstraCartService favoritPartsIstraCart, FavoritPartsRostov
 0109        IOrderSupplierStateService supplierStateService,
 0110        ILogger<SupplierCartController> logger)
 111    {
 0112        _avd = avd;
 0113        _japarts = japarts;
 0114        _berg = berg;
 0115        _bergCartService = bergCartService;
 0116        _context = context;
 0117        _httpClientFactory = httpClientFactory;
 0118        _cache = cache;
 0119        _armtekCart = armtekCart;
 0120        _armtek = armtek;
 0121        _armtekBy = armtekBy;
 0122        _mlAutoCart = mlAutoCart;
 0123        _mlAuto = mlAuto;
 0124        _mlAutoRuCart = mlAutoRuCart;
 0125        _mlAutoRu = mlAutoRu;
 0126        _forumAutoCart = forumAutoCart;
 0127        _forumAuto = forumAuto;
 0128        _forumAutoInterparts = forumAutoInterparts;
 0129        _forumAutoPiter = forumAutoPiter;
 0130        _forumAutoRostov = forumAutoRostov;
 0131        _forumAutoIstra = forumAutoIstra;
 0132        _favoritPartsCart = favoritPartsCart;
 0133        _favoritPartsIstraCart = favoritPartsIstraCart;
 0134        _favoritPartsRostovCart = favoritPartsRostovCart;
 0135        _moskvorechieIstraCart = moskvorechieIstraCart;
 0136        _motexCart = motexCart;
 0137        _profitLigaCart = profitLigaCart;
 0138        _nikeiCart = nikeiCart;
 0139        _shateMCart = shateMCart;
 0140        _mikadoCart = mikadoCart;
 0141        _zzapCart = zzapCart;
 0142        _shateM = shateM;
 0143        _motex = motex;
 0144        _supplierStateService = supplierStateService;
 0145        _logger = logger;
 0146    }
 147
 0148    private static bool IsForumAutoProvider(string provider) => provider is ApiSupplierSettings.ForumAutoProviderKey or 
 0149    private Services.ApiServices.ForumAutoApi.ForumAutoDataManager ResolveForumAutoClient(string provider) => provider s
 0150    {
 0151        ApiSupplierSettings.ForumAutoInterpartsProviderKey => _forumAutoInterparts,
 0152        ApiSupplierSettings.ForumAutoPiterProviderKey => _forumAutoPiter,
 0153        ApiSupplierSettings.ForumAutoRostovProviderKey => _forumAutoRostov,
 0154        ApiSupplierSettings.ForumAutoIstraProviderKey => _forumAutoIstra,
 0155        _ => _forumAuto
 0156    };
 157
 158    [HttpGet]
 159    public IActionResult Index()
 160    {
 161        // Страница — только быстрый shell. Данные вкладки подгружаются запросом Grid
 162        // после выбора поставщика, чтобы открытие /SupplierCart не ждало все API поставщиков.
 0163        return View();
 164    }
 165
 166    /// <summary>
 167    /// Данные вкладки поставщика для гридов AG Grid «Корзина» и «Заказы» (строки собирает <see cref="SupplierCartGridRo
 168    /// dateBegin/dateEnd/number — параметры API поставщика (период заказов), остальное фильтрует грид.
 169    /// </summary>
 170    [HttpGet]
 171    public async Task<IActionResult> Grid(
 172        string? provider,
 173        string? dateBegin,
 174        string? dateEnd,
 175        string? number = null,
 176        CancellationToken ct = default)
 177    {
 0178        var route = SupplierCartGridRows.NormalizeRoute(provider);
 0179        var section = await BuildProviderSectionAsync(route, dateBegin, dateEnd, number, ct);
 0180        if (section is null) return BadRequest(new { error = "Неизвестный поставщик корзины." });
 0181        return Ok(SupplierCartGridRows.Build(route, section,
 0182            url => IsAllowedProductImageUrl(url)
 0183                ? Url.Action(nameof(ImageProxy), "SupplierCart", new { area = "Studio2", url }) ?? string.Empty
 0184                : string.Empty));
 0185    }
 186
 187    /// <summary>Лёгкая сводка для счётчиков вкладок; не строит их содержимое.</summary>
 188    [HttpGet]
 189    public async Task<IActionResult> Summary(CancellationToken ct = default)
 190    {
 0191        var summary = await _cache.GetOrCreateAsync("supplier-cart:summary", async entry =>
 0192        {
 0193            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
 0194            return await BuildSummaryAsync(ct);
 0195        });
 0196        return Ok(summary);
 0197    }
 198
 199    private async Task<IReadOnlyList<SupplierCartSummaryRow>> BuildSummaryAsync(CancellationToken ct)
 200    {
 0201        var avdCartCount = 0;
 0202        var avdOrderCount = 0;
 203        try
 204        {
 0205            avdCartCount = (await _avd.GetBasketItemsAsync(ct)).Count;
 0206            avdOrderCount = (await _avd.GetOrdersParsedAsync(null, null, 0, null, ct)).Count;
 0207        }
 0208        catch (AvdApiException)
 209        {
 210            // При временной недоступности АВД сохраняем остальные, локальные счётчики.
 0211        }
 212
 0213        var japartsCartCount = 0;
 0214        var japartsOrderCount = 0;
 215        try
 216        {
 0217            japartsCartCount = (await _japarts.GetBasketAsync(ct)).Count;
 0218            japartsOrderCount = (await _japarts.GetOrdersAsync(JapartsOrdersIntervalDays, ct)).Count;
 0219        }
 0220        catch (Exception exception) when (exception is JapartsApiException or HttpRequestException)
 221        {
 222            // Как и у АВД: недоступность API поставщика не должна гасить остальные счётчики.
 0223        }
 224
 225        // Вкладка Berg показывает активные заказы поставщика, а не строки локальной таблицы:
 226        // счётчик должен считать то же самое, иначе в шапке всегда ноль.
 0227        var bergOrderCount = 0;
 228        try
 229        {
 0230            bergOrderCount = BergDataManager.ParseOrders(await _berg.GetActiveOrderStatesAsync(ct)).Count;
 0231        }
 0232        catch (Exception exception) when (exception is BergApiException or HttpRequestException)
 233        {
 234            // Как и у АВД: недоступность API поставщика не должна гасить остальные счётчики.
 0235        }
 236
 0237        var rows = new List<SupplierCartSummaryRow>
 0238        {
 0239            new("avd", avdCartCount, avdOrderCount),
 0240            new("japarts", japartsCartCount, japartsOrderCount),
 0241            new("berg", await _bergCartService.CountActiveAsync(ct), bergOrderCount),
 0242            new("armtek", await _context.ArmtekCartItems.CountAsync(x => x.ProviderKey == "Armtek" && ArmtekCartItemStat
 0243            new("armtekby", await _context.ArmtekCartItems.CountAsync(x => x.ProviderKey == "ArmtekBy" && ArmtekCartItem
 0244            new("mlauto", await _context.MlAutoCartItems.CountAsync(x => MlAutoCartItemStatus.Active.Contains(x.Status),
 0245            new("mlautoru", await _context.MlAutoRuCartItems.CountAsync(x => MlAutoRuCartItemStatus.Active.Contains(x.St
 0246            new("forumauto", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoProviderKey, ct), await CountFor
 0247            new("forumautointerparts", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoInterpartsProviderKey,
 0248            new("forumautopiter", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoPiterProviderKey, ct), awai
 0249            new("forumautorostov", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoRostovProviderKey, ct), aw
 0250            new("forumautoistra", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoIstraProviderKey, ct), awai
 0251            new("favoritparts", await CountFavoritPartsCartAsync(ApiSupplierSettings.FavoritPartsProviderKey, ct), await
 0252            new("favoritpartsistra", await CountFavoritPartsCartAsync(ApiSupplierSettings.FavoritPartsIstraProviderKey, 
 0253            new("favoritpartsrostov", await CountFavoritPartsCartAsync(ApiSupplierSettings.FavoritPartsRostovProviderKey
 0254            new("moskvorechieistra", await _context.MoskvorechieIstraCartItems.CountAsync(x => x.ActiveStudioOrderId != 
 0255            new("motex", await _motexCart.CountActiveAsync(ct), await GetMotexOrderCountAsync(ct)),
 0256            new("profitliga", await _profitLigaCart.CountActiveAsync(ct), await _context.ProfitLigaOrders.CountAsync(ct)
 0257            new("nikei", await _nikeiCart.CountActiveAsync(ct), await _context.NikeiCartItems.CountAsync(x => x.Status =
 0258            new("mikadomskhod20", await _mikadoCart.CountActiveAsync(ct), 0),
 0259            new("zzapmoscow", await _zzapCart.CountActiveAsync(ct), await _zzapCart.CountOrderedAsync(ct)),
 0260            new("shatem", await _shateMCart.CountActiveAsync(ct), await _context.ShateMOrders.CountAsync(ct))
 0261        };
 0262        return rows;
 0263    }
 264
 265    private Task<int> CountForumAutoCartAsync(string providerKey, CancellationToken ct) =>
 0266        _context.ForumAutoCartItems.CountAsync(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Conta
 267
 268    private Task<int> CountForumAutoOrdersAsync(string providerKey, CancellationToken ct) =>
 0269        _context.ForumAutoOrders.CountAsync(x => x.ProviderKey == providerKey, ct);
 270
 271    private Task<int> CountFavoritPartsCartAsync(string providerKey, CancellationToken ct) =>
 0272        _context.FavoritPartsCartItems.CountAsync(x => x.ProviderKey == providerKey && FavoritPartsCartItemStatus.Active
 273
 274    private Task<int> CountFavoritPartsOrdersAsync(string providerKey, CancellationToken ct) =>
 0275        _context.FavoritPartsOrders.CountAsync(x => x.ProviderKey == providerKey, ct);
 276
 277    private async Task<SupplierCartSection?> BuildProviderSectionAsync(
 278        string? provider,
 279        string? dateBegin,
 280        string? dateEnd,
 281        string? number,
 282        CancellationToken ct)
 283    {
 0284        var route = provider?.Trim().ToLowerInvariant() ?? "avd";
 0285        var createdFrom = DateOnly.TryParse(dateBegin, out var parsedFrom) ? parsedFrom : DateOnly.FromDateTime(DateTime
 0286        var createdTo = DateOnly.TryParse(dateEnd, out var parsedTo) ? parsedTo : createdFrom;
 287
 0288        if (route == "avd")
 289        {
 290            SupplierCartSection avdSection;
 291            try
 292            {
 0293                var avdItems = await _avd.GetBasketItemsAsync(ct);
 0294                avdSection = await BuildAvdSectionAsync(avdItems, ct);
 0295            }
 0296            catch (AvdApiException ex)
 297            {
 0298                avdSection = SupplierCartSection.Failed(ApiSupplierSettings.AvdProviderKey, "АВД", "/images/avd-logo.svg
 0299            }
 300
 301            try
 302            {
 303                // Статус позиции фильтрует грид заказов (set-фильтр), API получает только период и номер.
 0304                var orders = await _avd.GetOrdersParsedAsync(dateBegin, dateEnd, 0, number, ct);
 0305                return avdSection with { Orders = orders };
 306            }
 0307            catch (AvdApiException ex)
 308            {
 0309                return avdSection with { OrdersError = ex.Message };
 310            }
 311        }
 312
 0313        if (route == "japarts")
 314        {
 0315            try { return await BuildJapartsSectionAsync(ct); }
 0316            catch (JapartsApiException ex) { return SupplierCartSection.Failed(ApiSupplierSettings.JapartsProviderKey, "
 317        }
 318
 0319        if (route == "berg")
 320        {
 0321            try { return await BuildBergSectionAsync(ct); }
 0322            catch (BergApiException ex) { return SupplierCartSection.Failed(ApiSupplierSettings.BergProviderKey, "Берг",
 323        }
 324
 0325        return route switch
 0326        {
 0327            "armtek" => await BuildArmtekSectionAsync("Armtek", "Армтек RU", "/images/armtek-logo.svg", createdFrom, cre
 0328            "armtekby" => await BuildArmtekSectionAsync("ArmtekBy", "Армтек BY", "/images/armtek-logo.svg", createdFrom,
 0329            "mlauto" => await BuildMlAutoSectionAsync(
 0330                DateOnly.TryParse(dateBegin, out var mlFrom) ? mlFrom : DateOnly.FromDateTime(DateTime.Today).AddDays(-M
 0331                DateOnly.TryParse(dateEnd, out var mlTo) ? mlTo : DateOnly.FromDateTime(DateTime.Today), ct),
 0332            "mlautoru" => await BuildMlAutoRuSectionAsync(ct),
 0333            "forumauto" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoProviderKey, "Форум-Авто (Союз)
 0334            "forumautointerparts" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoInterpartsProviderKey
 0335            "forumautopiter" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoPiterProviderKey, "Форум-А
 0336            "forumautorostov" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoRostovProviderKey, "Форум
 0337            "forumautoistra" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoIstraProviderKey, "Форум-А
 0338            "favoritparts" => await BuildFavoritPartsSectionAsync(ct),
 0339            "favoritpartsistra" => await BuildFavoritPartsSectionAsync(_favoritPartsIstraCart, ApiSupplierSettings.Favor
 0340            "favoritpartsrostov" => await BuildFavoritPartsSectionAsync(_favoritPartsRostovCart, ApiSupplierSettings.Fav
 0341            "moskvorechieistra" => await BuildMoskvorechieIstraSectionAsync(ct),
 0342            "motex" => await BuildMotexSectionAsync(
 0343                DateOnly.TryParse(dateBegin, out var motexFrom) ? motexFrom : DateOnly.FromDateTime(DateTime.Today).AddD
 0344                DateOnly.TryParse(dateEnd, out var motexTo) ? motexTo : DateOnly.FromDateTime(DateTime.Today), ct),
 0345            "shatem" => await BuildShateMSectionAsync(ct),
 0346            "profitliga" => await BuildProfitLigaSectionAsync(ct),
 0347            "nikei" => await BuildNikeiSectionAsync(ct),
 0348            "mikadomskhod20" => await BuildMikadoSectionAsync(ct),
 0349            "zzapmoscow" => await BuildZZapMoscowSectionAsync(ct),
 0350            _ => null
 0351        };
 0352    }
 353
 354    /// <summary>Количество позиций в корзинах поставщиков для индикатора в боковом меню.</summary>
 355    [HttpGet, ServiceEndpoint]
 356    public async Task<IActionResult> Count(CancellationToken ct)
 357    {
 358        try
 359        {
 2360            var count = await _cache.GetOrCreateAsync(SupplierCartCacheKeys.AvdBasketCount, async entry =>
 2361            {
 0362                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
 2363
 0364                var avdCount = 0;
 2365                try
 2366                {
 0367                    avdCount = (await _avd.GetBasketItemsAsync(ct)).Count;
 0368                }
 0369                catch (Exception)
 2370                {
 2371                    // Сбой или недоступность АВД не должны ронять общий счётчик в боковом меню
 0372                }
 2373
 0374                var favoritPartsCount = 0;
 0375                try { favoritPartsCount = await _favoritPartsCart.CountApiCartAsync(ct); }
 0376                catch (Exception) { /* один недоступный поставщик не должен ронять общий счётчик */ }
 2377
 0378                var favoritPartsIstraCount = 0;
 0379                try { favoritPartsIstraCount = await _favoritPartsIstraCart.CountApiCartAsync(ct); }
 0380                catch (Exception) { /* один недоступный поставщик не должен ронять общий счётчик */ }
 2381
 0382                var favoritPartsRostovCount = 0;
 0383                try { favoritPartsRostovCount = await _favoritPartsRostovCart.CountApiCartAsync(ct); }
 0384                catch (Exception) { /* один недоступный поставщик не должен ронять общий счётчик */ }
 2385
 0386                return avdCount
 0387                    + await _armtekCart.CountActiveAsync(ct)
 0388                    + await _mlAutoCart.CountActiveAsync(ct)
 0389                    + await _mlAutoRuCart.CountActiveAsync(ct)
 0390                    + await _forumAutoCart.CountActiveAsync(ct)
 0391                    + favoritPartsCount
 0392                    + favoritPartsIstraCount
 0393                    + favoritPartsRostovCount;
 2394            });
 0395            return Ok(new { count });
 396        }
 1397        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 398        {
 1399            throw;
 400        }
 1401        catch (Exception ex)
 402        {
 1403            _logger.LogWarning(ex, "Не удалось получить общий счётчик корзины поставщиков для бокового меню");
 1404            return Ok(new { count = 0 });
 405        }
 1406    }
 407
 408    [HttpPost, ValidateAntiForgeryToken]
 409    public async Task<IActionResult> TestMlAutoOrder(long[]? cartItemIds, CancellationToken ct)
 410    {
 0411        var result = await _mlAutoCart.TestAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct);
 0412        await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, cartItemIds, ct);
 0413        return Ok(result);
 0414    }
 415
 416    [HttpPost, ValidateAntiForgeryToken]
 417    [SupplierOrderGuard(ApiSupplierSettings.MlAutoProviderKey)]
 418    public async Task<IActionResult> CreateMlAutoOrder(long[]? cartItemIds, bool cashless, CancellationToken ct)
 419    {
 0420        var result = await _mlAutoCart.CheckoutAsync(cartItemIds, cashless, User.Identity?.Name ?? "unknown", ct);
 0421        await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, cartItemIds, ct);
 0422        return Ok(result);
 0423    }
 424
 425    [HttpPost, ValidateAntiForgeryToken]
 426    public async Task<IActionResult> RemoveMlAutoCartItems(long[]? cartItemIds, CancellationToken ct)
 0427        => Ok(new { success = true, removed = await _mlAutoCart.RemoveAsync(cartItemIds, ct) });
 428
 429    [HttpPost, ValidateAntiForgeryToken]
 430    public async Task<IActionResult> UpdateMlAutoDeliveryAddress(long cartItemId, string? deliveryAddressId, string? del
 431    {
 0432        if (string.IsNullOrWhiteSpace(deliveryAddressId)) return BadRequest(new { error = "Выберите адрес ML-Auto." });
 0433        if (!await _mlAutoCart.UpdateAddressAsync(cartItemId, deliveryAddressId, deliveryAddressName, ct))
 0434            return BadRequest(new { error = "Позиция не найдена или оформляется." });
 435
 0436        await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, [cartItemId], ct);
 0437        return Ok(new { success = true });
 0438    }
 439
 440    [HttpPost, ValidateAntiForgeryToken]
 441    public async Task<IActionResult> RequoteMlAutoCartItem(long cartItemId, CancellationToken ct)
 442    {
 443        try
 444        {
 0445            var quote = await _mlAutoCart.RequoteAsync(cartItemId, ct);
 0446            return Ok(new
 0447            {
 0448                success = true,
 0449                quote.OriginalWarehouse,
 0450                quote.DeliveryAddress,
 451                offers = quote.Offers.Select(x => new
 452                {
 453                    x.StorageCode, x.WarehouseName, x.Price, x.AvailableQuantity,
 454                    deliveryAt = x.DeliveryAtUtc?.ToString("yyyy-MM-ddTHH:mm:ss")
 455                })
 0456            });
 457        }
 0458        catch (Exception ex) when (ex is MlAutoApiException or HttpRequestException or InvalidOperationException)
 459        {
 0460            return BadRequest(new { error = ex.Message });
 461        }
 0462    }
 463
 464    [HttpPost, ValidateAntiForgeryToken]
 465    public async Task<IActionResult> ReplaceMlAutoCartItemOffer(long cartItemId, string? storageCode, CancellationToken 
 466    {
 0467        if (string.IsNullOrWhiteSpace(storageCode)) return BadRequest(new { error = "Выберите актуальное предложение ML-
 468        try
 469        {
 0470            if (!await _mlAutoCart.ReplaceWithRequotedOfferAsync(cartItemId, storageCode, ct))
 0471                return BadRequest(new { error = "Позиция не найдена или оформляется." });
 472
 0473            await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, [cartItemId], ct);
 0474            return Ok(new { success = true });
 475        }
 0476        catch (Exception ex) when (ex is MlAutoApiException or HttpRequestException or InvalidOperationException)
 477        {
 0478            return BadRequest(new { error = ex.Message });
 479        }
 0480    }
 481
 482    [HttpPost, ValidateAntiForgeryToken]
 483    public async Task<IActionResult> ImportMlAutoOrder(string? orderNumber, CancellationToken ct)
 484    {
 0485        if (string.IsNullOrWhiteSpace(orderNumber)) return BadRequest(new { error = "Укажите номер заказа ML-Auto." });
 0486        try { return Ok(await _mlAutoCart.ImportOrderAsync(orderNumber.Trim(), User.Identity?.Name ?? "unknown", ct)); }
 0487        catch (MlAutoApiException ex) { return BadRequest(new { error = ex.Message }); }
 0488    }
 489
 490    [HttpGet]
 491    public async Task<IActionResult> MlAutoOrderDetails(string orderNumber, CancellationToken ct)
 492    {
 0493        if (string.IsNullOrWhiteSpace(orderNumber)) return BadRequest(new { error = "Не указан номер заказа." });
 0494        try { return Content((await _mlAuto.GetOrderByNumberAsync(orderNumber.Trim(), ct)).ToString(Newtonsoft.Json.Form
 0495        catch (MlAutoApiException ex) { return BadRequest(new { error = ex.Message }); }
 0496    }
 497
 498    [HttpPost, ValidateAntiForgeryToken]
 499    public async Task<IActionResult> TestMlAutoRuOrder(long[]? cartItemIds, CancellationToken ct)
 500    {
 0501        var result = await _mlAutoRuCart.TestAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct);
 0502        await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoRuProviderKey, cartItemIds, ct);
 0503        return Ok(result);
 0504    }
 505
 506    [HttpPost, ValidateAntiForgeryToken]
 507    [SupplierOrderGuard(ApiSupplierSettings.MlAutoRuProviderKey)]
 508    public async Task<IActionResult> CreateMlAutoRuOrder(long[]? cartItemIds, bool cashless, CancellationToken ct)
 509    {
 0510        var result = await _mlAutoRuCart.CheckoutAsync(cartItemIds, cashless, User.Identity?.Name ?? "unknown", ct);
 0511        await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoRuProviderKey, cartItemIds, ct);
 0512        return Ok(result);
 0513    }
 514
 515    [HttpPost, ValidateAntiForgeryToken]
 0516    public async Task<IActionResult> RemoveMlAutoRuCartItems(long[]? cartItemIds, CancellationToken ct) => Ok(new { succ
 517
 518    [HttpPost, ValidateAntiForgeryToken]
 519    public async Task<IActionResult> UpdateMlAutoRuDeliveryAddress(long cartItemId, string? deliveryAddressId, string? d
 520    {
 0521        if (string.IsNullOrWhiteSpace(deliveryAddressId)) return BadRequest(new { error = "Выберите адрес ML-Auto RU." }
 0522        if (!await _mlAutoRuCart.UpdateAddressAsync(cartItemId, deliveryAddressId, deliveryAddressName, ct))
 0523            return BadRequest(new { error = "Позиция не найдена или оформляется." });
 524
 0525        await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoRuProviderKey, [cartItemId], ct);
 0526        return Ok(new { success = true });
 0527    }
 528
 529    /// <summary>Безопасно проверяет активные позиции локальной корзины Форум-Авто (репрайсинг, не заказ).</summary>
 530    [HttpPost, ValidateAntiForgeryToken]
 531    public async Task<IActionResult> TestForumAutoOrder(string provider, long[]? cartItemIds, CancellationToken ct)
 532    {
 0533        if (!IsForumAutoProvider(provider))
 0534            return BadRequest(new { error = "Неизвестный аккаунт Форум-Авто." });
 535
 0536        var result = await _forumAutoCart.TestAsync(provider, User.Identity?.Name ?? "unknown", cartItemIds, ct);
 0537        await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct);
 0538        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0539    }
 540
 541    [HttpPost, ValidateAntiForgeryToken]
 542    [SupplierOrderGuard]
 543    public async Task<IActionResult> CreateForumAutoOrder(string provider, long[]? cartItemIds, CancellationToken ct)
 544    {
 0545        if (!IsForumAutoProvider(provider))
 0546            return BadRequest(new { error = "Неизвестный аккаунт Форум-Авто." });
 547
 0548        var result = await _forumAutoCart.CheckoutAsync(provider, User.Identity?.Name ?? "unknown", cartItemIds, ct);
 0549        await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct);
 0550        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0551    }
 552
 553    [HttpPost, ValidateAntiForgeryToken]
 554    public async Task<IActionResult> TestMoskvorechieIstraOrder(long[]? cartItemIds, CancellationToken ct)
 555    {
 0556        var result = await _moskvorechieIstraCart.TestAsync(cartItemIds, ct);
 0557        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0558    }
 559
 560    [HttpPost, ValidateAntiForgeryToken]
 561    [SupplierOrderGuard(ApiSupplierSettings.MoskvorechieIstraProviderKey)]
 562    public async Task<IActionResult> CreateMoskvorechieIstraOrder(long[]? cartItemIds, CancellationToken ct)
 563    {
 0564        var result = await _moskvorechieIstraCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct);
 0565        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0566    }
 567
 568    [HttpPost, ValidateAntiForgeryToken]
 569    public async Task<IActionResult> RemoveMoskvorechieIstraCartItems(long[]? cartItemIds, CancellationToken ct)
 570    {
 0571        var result = await _moskvorechieIstraCart.RemoveAsync(cartItemIds, ct);
 0572        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0573    }
 574
 575    [HttpPost, ValidateAntiForgeryToken]
 576    public async Task<IActionResult> TestProfitLigaOrder(long[]? cartItemIds, CancellationToken ct)
 577    {
 0578        var result = await _profitLigaCart.TestAsync(cartItemIds ?? [], User.Identity?.Name ?? "unknown", ct);
 0579        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0580    }
 581
 582    [HttpPost, ValidateAntiForgeryToken]
 583    [SupplierOrderGuard(ApiSupplierSettings.ProfitLigaProviderKey)]
 584    public async Task<IActionResult> CreateProfitLigaOrder(long[]? cartItemIds, string? methodId, string? paymentId,
 585        CancellationToken ct)
 586    {
 0587        var result = await _profitLigaCart.CheckoutAsync(cartItemIds ?? [], methodId, paymentId,
 0588            User.Identity?.Name ?? "unknown", ct);
 0589        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0590    }
 591
 592    [HttpPost, ValidateAntiForgeryToken]
 593    public async Task<IActionResult> RemoveProfitLigaCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct
 594    {
 0595        var result = await _profitLigaCart.RemoveManyAsync(cartItemIds ?? [], removeAll,
 0596            User.Identity?.Name ?? "unknown", ct);
 0597        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0598    }
 599
 600    [HttpPost, ValidateAntiForgeryToken]
 601    [SupplierOrderGuard(ApiSupplierSettings.NikeiProviderKey)]
 602    public async Task<IActionResult> CreateNikeiOrder(long[]? cartItemIds, int shippingType, int shippingAddress,
 603        string? orderComment, CancellationToken ct)
 604    {
 0605        var result = await _nikeiCart.CheckoutAsync(cartItemIds ?? [], shippingType, shippingAddress, orderComment ?? st
 0606            User.Identity?.Name ?? "unknown", ct);
 0607        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0608    }
 609
 610    [HttpPost, ValidateAntiForgeryToken]
 611    public async Task<IActionResult> RemoveNikeiCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct)
 612    {
 0613        var result = await _nikeiCart.RemoveAsync(cartItemIds ?? [], removeAll, User.Identity?.Name ?? "unknown", ct);
 0614        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0615    }
 616
 617    [HttpGet]
 618    public async Task<IActionResult> NikeiDeliveryOptions(CancellationToken ct)
 619    {
 620        try
 621        {
 0622            var offices = await _nikeiCart.GetOfficesAsync(ct);
 0623            var addresses = await _nikeiCart.GetClientAddressesAsync(ct);
 0624            return Ok(new
 0625            {
 626                offices = (offices as JArray ?? []).Select(x => new { id = x.Value<int?>("id"), name = x.Value<string>("
 627                addresses = (addresses["data"] as JArray ?? []).Where(x => x.Value<bool?>("is_active") != false)
 628                    .Select(x => new { id = x.Value<int?>("id"), name = x.Value<string>("address"), isDefault = x.Value<
 0629                addressesError = addresses.Value<string>("message")
 0630            });
 631        }
 0632        catch (NikeiApiException ex) { return BadRequest(new { error = ex.Message }); }
 0633    }
 634
 635    [HttpPost, ValidateAntiForgeryToken]
 636    public async Task<IActionResult> TestMikadoCartItems(long[]? cartItemIds, CancellationToken ct)
 637    {
 0638        var result = await _mikadoCart.TestAsync(cartItemIds ?? [], User.Identity?.Name, ct);
 0639        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0640    }
 641
 642    [HttpPost, ValidateAntiForgeryToken]
 643    public async Task<IActionResult> RemoveMikadoCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct)
 644    {
 0645        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо
 0646        var result = await _mikadoCart.RemoveAsync(cartItemIds, removeAll, User.Identity?.Name, ct);
 0647        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0648    }
 649
 650    [HttpPost, ValidateAntiForgeryToken]
 651    public async Task<IActionResult> TestZZapMoscowOrder(long[]? cartItemIds, CancellationToken ct)
 652    {
 0653        var result = await _zzapCart.TestAsync(cartItemIds, User.Identity?.Name, ct);
 0654        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0655    }
 656
 657    [HttpPost, ValidateAntiForgeryToken]
 658    [SupplierOrderGuard(ApiSupplierSettings.ZZapMoscowProviderKey)]
 659    public async Task<IActionResult> CreateZZapMoscowOrder(long[]? cartItemIds, CancellationToken ct)
 660    {
 0661        var result = await _zzapCart.CheckoutAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct);
 0662        if (result.Added == 0 && result.Failed == 0) return BadRequest(new { error = string.Join(" ", result.Messages) }
 0663        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0664    }
 665
 666    [HttpPost, ValidateAntiForgeryToken]
 667    public async Task<IActionResult> CancelZZapMoscowOrder(long codeOrder, string? reason, CancellationToken ct)
 668    {
 0669        if (codeOrder <= 0) return BadRequest(new { error = "Не указан номер заказа ZZap." });
 0670        var result = await _zzapCart.CancelOrderAsync(codeOrder, reason, User.Identity?.Name ?? "unknown", ct);
 0671        if (!result.IsSuccess) return BadRequest(new { error = string.Join(" ", result.Messages) });
 0672        return Ok(new { success = true, result.Messages });
 0673    }
 674
 675    [HttpPost, ValidateAntiForgeryToken]
 676    public async Task<IActionResult> RemoveZZapMoscowCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct
 677    {
 0678        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо
 0679        var result = await _zzapCart.RemoveAsync(cartItemIds, removeAll, User.Identity?.Name, ct);
 0680        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0681    }
 682
 683    /// <summary>Очистка корзины на стороне поставщика: /cart/order оформляет её целиком,
 684    /// поэтому перед заказом она должна быть пустой.</summary>
 685    [HttpPost, ValidateAntiForgeryToken]
 686    public async Task<IActionResult> ClearProfitLigaApiCart(string[]? keys, CancellationToken ct)
 687    {
 688        try
 689        {
 0690            var result = await _profitLigaCart.ClearApiCartAsync(keys ?? [], ct);
 0691            return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 692        }
 0693        catch (ProfitLigaApiException ex)
 694        {
 0695            return BadRequest(new { error = ex.Message });
 696        }
 0697    }
 698
 699    /// <summary>Проверка заказа Berg: order[is_test]=1 — заказ виден в истории поставщика,
 700    /// но не обрабатывается. Позиции остаются в корзине.</summary>
 701    [HttpPost, ValidateAntiForgeryToken]
 702    public async Task<IActionResult> TestBergOrder(long[]? cartItemIds, CancellationToken ct)
 703    {
 0704        var result = await _bergCartService.CheckoutAsync(cartItemIds ?? [], User.Identity?.Name ?? "unknown",
 0705            isTest: true, ct);
 0706        return Ok(new { success = result.IsSuccess, result.Messages });
 0707    }
 708
 709    [HttpPost, ValidateAntiForgeryToken]
 710    [SupplierOrderGuard(ApiSupplierSettings.BergProviderKey, nullMeansAll: false)]
 711    public async Task<IActionResult> CreateBergOrder(long[]? cartItemIds, CancellationToken ct)
 712    {
 0713        var result = await _bergCartService.CheckoutAsync(cartItemIds ?? [], User.Identity?.Name ?? "unknown",
 0714            isTest: false, ct);
 0715        return Ok(new { success = result.IsSuccess, result.Messages });
 0716    }
 717
 718    [HttpPost, ValidateAntiForgeryToken]
 719    public async Task<IActionResult> RemoveBergCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct)
 720    {
 0721        var ids = removeAll
 722            ? (await _bergCartService.GetActiveAsync(ct)).Select(x => x.Id).ToList()
 0723            : (cartItemIds ?? []).ToList();
 0724        var removed = await _bergCartService.RemoveAsync(ids, ct);
 0725        return Ok(new { success = true, removed });
 0726    }
 727
 728    [HttpPost, ValidateAntiForgeryToken]
 729    [SupplierOrderGuard(ApiSupplierSettings.MotexProviderKey)]
 730    public async Task<IActionResult> CreateMotexOrder(long[]? cartItemIds, CancellationToken ct)
 731    {
 0732        var result = await _motexCart.CheckoutAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct);
 0733        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0734    }
 735
 736    [HttpPost, ValidateAntiForgeryToken]
 737    public async Task<IActionResult> RemoveMotexCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct)
 738    {
 0739        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо
 0740        var result = await _motexCart.RemoveAsync(removeAll ? null : cartItemIds, ct);
 0741        return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages });
 0742    }
 743
 744    [HttpPost, ValidateAntiForgeryToken]
 745    public async Task<IActionResult> ImportMoskvorechieIstraOrder(string? externalOrderId, CancellationToken ct)
 746    {
 0747        var result = await _moskvorechieIstraCart.ImportOrderAsync(externalOrderId, User.Identity?.Name ?? "unknown", ct
 0748        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0749    }
 750
 751    /// <summary>Удаляет выбранные позиции локальной корзины Форум-Авто либо очищает её целиком.</summary>
 752    [HttpPost, ValidateAntiForgeryToken]
 753    public async Task<IActionResult> RemoveForumAutoCartItems(string provider, bool removeAll, long[]? cartItemIds, Canc
 754    {
 0755        if (!IsForumAutoProvider(provider))
 0756            return BadRequest(new { error = "Неизвестный аккаунт Форум-Авто." });
 0757        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0))
 0758            return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." });
 759
 0760        var result = await _forumAutoCart.RemoveManyAsync(provider, removeAll ? null : cartItemIds, ct);
 0761        if (result.Added == 0)
 0762            return BadRequest(new { error = string.Join(" ", result.Messages) });
 763
 0764        return Ok(new { success = true, result.Added, result.Skipped, result.Failed, result.Messages });
 0765    }
 766
 767    /// <summary>Свежая сверка выбранных позиций FavoritParts с реальной корзиной (репрайсинг, не заказ).
 768    /// Ключи выбора — составные "goods|warehouseGroup" (см. FavoritPartsApiCartLine.Key), не суррогатный Id:
 769    /// источник истины — сам API, локальная строка корзины не хранится.</summary>
 770    [HttpPost, ValidateAntiForgeryToken]
 771    public async Task<IActionResult> TestFavoritPartsOrder(string provider, string[]? cartItemIds, CancellationToken ct)
 772    {
 0773        if (provider != ApiSupplierSettings.FavoritPartsProviderKey)
 0774            return BadRequest(new { error = "Неизвестный аккаунт FavoritParts." });
 775
 0776        var result = await _favoritPartsCart.TestAsync(cartItemIds, ct);
 0777        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0778    }
 779
 780    [HttpPost, ValidateAntiForgeryToken]
 781    [SupplierOrderGuard]
 782    public async Task<IActionResult> CreateFavoritPartsOrder(string provider, string[]? cartItemIds, CancellationToken c
 783    {
 0784        if (provider != ApiSupplierSettings.FavoritPartsProviderKey)
 0785            return BadRequest(new { error = "Неизвестный аккаунт FavoritParts." });
 786
 0787        var result = await _favoritPartsCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct);
 0788        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0789    }
 790
 791    /// <summary>Удаляет выбранные позиции реальной корзины FavoritParts либо очищает её целиком (построчно, как у АВД).
 792    [HttpPost, ValidateAntiForgeryToken]
 793    public async Task<IActionResult> RemoveFavoritPartsCartItems(string provider, bool removeAll, string[]? cartItemIds,
 794    {
 0795        if (provider != ApiSupplierSettings.FavoritPartsProviderKey)
 0796            return BadRequest(new { error = "Неизвестный аккаунт FavoritParts." });
 0797        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0))
 0798            return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." });
 799
 0800        var result = await _favoritPartsCart.RemoveManyAsync(removeAll ? null : cartItemIds, removeAll, ct);
 0801        if (result.Added == 0)
 0802            return BadRequest(new { error = string.Join(" ", result.Messages) });
 803
 0804        return Ok(new { success = true, result.Added, result.Skipped, result.Failed, result.Messages });
 0805    }
 806
 807    [HttpPost, ValidateAntiForgeryToken]
 808    public async Task<IActionResult> TestFavoritPartsIstraOrder(string provider, string[]? cartItemIds, CancellationToke
 809    {
 0810        if (provider != ApiSupplierSettings.FavoritPartsIstraProviderKey) return BadRequest(new { error = "Неизвестный а
 0811        var result = await _favoritPartsIstraCart.TestAsync(cartItemIds, ct);
 0812        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0813    }
 814
 815    [HttpPost, ValidateAntiForgeryToken]
 816    [SupplierOrderGuard]
 817    public async Task<IActionResult> CreateFavoritPartsIstraOrder(string provider, string[]? cartItemIds, CancellationTo
 818    {
 0819        if (provider != ApiSupplierSettings.FavoritPartsIstraProviderKey) return BadRequest(new { error = "Неизвестный а
 0820        var result = await _favoritPartsIstraCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct);
 0821        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0822    }
 823
 824    [HttpPost, ValidateAntiForgeryToken]
 825    public async Task<IActionResult> RemoveFavoritPartsIstraCartItems(string provider, bool removeAll, string[]? cartIte
 826    {
 0827        if (provider != ApiSupplierSettings.FavoritPartsIstraProviderKey) return BadRequest(new { error = "Неизвестный а
 0828        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо
 0829        var result = await _favoritPartsIstraCart.RemoveManyAsync(removeAll ? null : cartItemIds, removeAll, ct);
 0830        return result.Added == 0 ? BadRequest(new { error = string.Join(" ", result.Messages) }) : Ok(new { success = tr
 0831    }
 832
 833    [HttpPost, ValidateAntiForgeryToken]
 834    public async Task<IActionResult> TestFavoritPartsRostovOrder(string provider, string[]? cartItemIds, CancellationTok
 835    {
 0836        if (provider != ApiSupplierSettings.FavoritPartsRostovProviderKey) return BadRequest(new { error = "Неизвестный 
 0837        var result = await _favoritPartsRostovCart.TestAsync(cartItemIds, ct);
 0838        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0839    }
 840
 841    [HttpPost, ValidateAntiForgeryToken]
 842    [SupplierOrderGuard]
 843    public async Task<IActionResult> CreateFavoritPartsRostovOrder(string provider, string[]? cartItemIds, CancellationT
 844    {
 0845        if (provider != ApiSupplierSettings.FavoritPartsRostovProviderKey) return BadRequest(new { error = "Неизвестный 
 0846        var result = await _favoritPartsRostovCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct);
 0847        return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages });
 0848    }
 849
 850    [HttpPost, ValidateAntiForgeryToken]
 851    public async Task<IActionResult> RemoveFavoritPartsRostovCartItems(string provider, bool removeAll, string[]? cartIt
 852    {
 0853        if (provider != ApiSupplierSettings.FavoritPartsRostovProviderKey) return BadRequest(new { error = "Неизвестный 
 0854        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо
 0855        var result = await _favoritPartsRostovCart.RemoveManyAsync(removeAll ? null : cartItemIds, removeAll, ct);
 0856        return result.Added == 0 ? BadRequest(new { error = string.Join(" ", result.Messages) }) : Ok(new { success = tr
 0857    }
 858
 859    /// <summary>Оформляет всю корзину АВД либо явно выбранные строки после подтверждения в UI.</summary>
 860    [HttpPost, ValidateAntiForgeryToken]
 861    [SupplierOrderGuard(ApiSupplierSettings.AvdProviderKey)]
 862    public async Task<IActionResult> CreateAvdOrder(
 863        bool createAll,
 864        long[]? basketItemIds,
 865        string? comment,
 866        CancellationToken ct)
 867    {
 868        try
 869        {
 0870            var currentItems = await _avd.GetBasketItemsAsync(ct);
 0871            if (currentItems.Count == 0)
 0872                return BadRequest(new { error = "Корзина АВД пуста. Обновите страницу и повторите попытку." });
 873
 874            long externalOrderId;
 875            int itemsCount;
 876            // Строки, ушедшие в заказ: по ним привязываем заказы Studio2 к номеру заказа АВД.
 877            // Комментарий позиции уже содержит Id заказа, поэтому повторный запрос к API не нужен.
 878            IReadOnlyList<AvdBasketItem> orderedItems;
 0879            if (createAll)
 880            {
 0881                externalOrderId = await _avd.CreateOrderAsync(comment, ct);
 0882                itemsCount = currentItems.Count;
 0883                orderedItems = currentItems;
 884            }
 885            else
 886            {
 887                var requestedIds = (basketItemIds ?? Array.Empty<long>()).Where(id => id > 0).Distinct().ToArray();
 0888                if (requestedIds.Length == 0)
 0889                    return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." });
 890
 0891                var actualIds = currentItems
 892                    .Select(item => long.TryParse(item.Id, out var id) ? id : (long?)null)
 0893                    .OfType<long>()
 0894                    .ToHashSet();
 0895                if (requestedIds.Any(id => !actualIds.Contains(id)))
 0896                    return Conflict(new { error = "Корзина АВД изменилась. Обновите страницу перед оформлением." });
 897
 0898                externalOrderId = await _avd.CreateOrderByBasketIdsAsync(requestedIds, comment, ct);
 0899                itemsCount = requestedIds.Length;
 0900                orderedItems = currentItems
 0901                    .Where(item => long.TryParse(item.Id, out var id) && requestedIds.Contains(id))
 0902                    .ToList();
 0903            }
 904
 0905            await TrackAvdOrderedAsync(orderedItems, externalOrderId, ct);
 906
 0907            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 0908            return Ok(new { success = true, orderId = externalOrderId, itemsCount });
 909        }
 0910        catch (AvdApiException ex)
 911        {
 0912            return BadRequest(new { error = ex.Message });
 913        }
 0914    }
 915
 916    /// <summary>Удаляет позиции корзины АВД: у API нет операции «очистить», удаление идёт построчно.</summary>
 917    [HttpPost, ValidateAntiForgeryToken]
 918    public async Task<IActionResult> RemoveAvdBasketItems(bool removeAll, long[]? basketItemIds, CancellationToken ct)
 919    {
 920        try
 921        {
 0922            var currentItems = await _avd.GetBasketItemsAsync(ct);
 0923            var currentIds = currentItems
 924                .Select(item => long.TryParse(item.Id, out var id) ? id : (long?)null)
 0925                .OfType<long>()
 0926                .ToArray();
 0927            if (currentIds.Length == 0)
 0928                return BadRequest(new { error = "Корзина АВД пуста." });
 929
 930            long[] targetIds;
 0931            if (removeAll)
 932            {
 0933                targetIds = currentIds;
 934            }
 935            else
 936            {
 937                targetIds = (basketItemIds ?? []).Where(id => id > 0).Distinct().ToArray();
 0938                if (targetIds.Length == 0)
 0939                    return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." });
 0940                if (targetIds.Any(id => !currentIds.Contains(id)))
 0941                    return Conflict(new { error = "Корзина АВД изменилась. Обновите страницу перед удалением." });
 942            }
 943
 0944            var removed = 0;
 0945            var messages = new List<string>();
 0946            var itemsById = currentItems
 947                .Where(item => long.TryParse(item.Id, out _))
 948                .ToDictionary(item => long.Parse(item.Id), item => item);
 0949            var removedItems = new List<AvdBasketItem>();
 0950            foreach (var id in targetIds)
 951            {
 952                try
 953                {
 0954                    await _avd.DeleteItemInBasketAsync(id.ToString(), ct);
 0955                    removed++;
 0956                    if (itemsById.TryGetValue(id, out var removedItem))
 0957                        removedItems.Add(removedItem);
 0958                }
 0959                catch (AvdApiException ex)
 960                {
 0961                    messages.Add($"Позиция {id}: {ex.Message}");
 0962                }
 963            }
 964
 0965            await TrackAvdRemovedAsync(removedItems, ct);
 0966            _cache.Remove(SupplierCartCacheKeys.AvdBasketCount);
 0967            messages.Insert(0, $"Удалено из корзины АВД: {removed} из {targetIds.Length}.");
 0968            return Ok(new { success = removed == targetIds.Length, added = removed, failed = targetIds.Length - removed,
 969        }
 0970        catch (AvdApiException ex)
 971        {
 0972            return BadRequest(new { error = ex.Message });
 973        }
 0974    }
 975
 976    /// <summary>Удаляет выбранные позиции локальной корзины Armtek либо очищает её целиком.</summary>
 977    [HttpPost, ValidateAntiForgeryToken]
 978    public async Task<IActionResult> RemoveArmtekCartItems(string provider, bool removeAll, long[]? cartItemIds, Cancell
 979    {
 0980        if (!IsArmtekProvider(provider))
 0981            return BadRequest(new { error = "Неизвестный поставщик Армтек." });
 982        // Пустой список без removeAll не должен молча очищать всю корзину.
 0983        if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0))
 0984            return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." });
 985
 0986        var result = await _armtekCart.RemoveManyAsync(provider, removeAll ? null : cartItemIds, ct);
 0987        if (result.Added == 0)
 0988            return BadRequest(new { error = string.Join(" ", result.Messages) });
 989
 0990        return Ok(new { success = true, result.Added, result.Skipped, result.Failed, result.Messages });
 0991    }
 992
 993    /// <summary>Безопасно проверяет все активные позиции локальной корзины Armtek.</summary>
 994    [HttpPost, ValidateAntiForgeryToken]
 995    public async Task<IActionResult> TestArmtekOrder(string provider, long[]? cartItemIds, CancellationToken ct)
 996    {
 0997        if (!IsArmtekProvider(provider))
 0998            return BadRequest(new { error = "Неизвестный поставщик Армтек." });
 999
 1000        try
 1001        {
 01002            var result = await _armtekCart.TestAsync(provider, User.Identity?.Name ?? "unknown", cartItemIds, ct);
 01003            await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct);
 01004            return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }
 1005        }
 01006        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 1007        {
 01008            return BadRequest(new { error = ex.Message });
 1009        }
 01010    }
 1011
 1012    /// <summary>Меняет клиента и адрес доставки строки корзины Armtek: подтверждение проверки при этом сбрасывается.</s
 1013    [HttpPost, ValidateAntiForgeryToken]
 1014    public async Task<IActionResult> UpdateArmtekDeliveryAddress(string provider, long cartItemId, string? deliveryOptio
 1015    {
 01016        if (!IsArmtekProvider(provider))
 01017            return BadRequest(new { error = "Неизвестный поставщик Армтек." });
 1018
 01019        var parts = (deliveryOption ?? string.Empty).Split('|');
 01020        if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace))
 01021            return BadRequest(new { error = "Выберите клиента и адрес доставки Armtek." });
 1022
 1023        IReadOnlyList<ArmtekDeliveryOption> options;
 1024        try
 1025        {
 01026            options = await GetArmtekDeliveryOptionsAsync(provider, ct);
 01027        }
 01028        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 1029        {
 01030            return BadRequest(new { error = $"Armtek не вернул список адресов: {ex.Message}" });
 1031        }
 1032
 01033        var option = options.FirstOrDefault(x => x.BuyerKunnr == parts[0] && x.AddressKunnr == parts[1]);
 01034        if (option is null)
 01035            return BadRequest(new { error = "Такой пары «клиент + адрес» нет в справочнике Armtek." });
 1036
 01037        var updated = await _armtekCart.UpdateDeliveryAddressAsync(cartItemId, provider, option.BuyerKunnr, option.Addre
 01038        if (!updated)
 01039            return BadRequest(new { error = "Позиция не найдена или уже оформляется." });
 1040
 01041        await RefreshLocalCartStatusesAsync(provider, [cartItemId], ct);
 01042        return Ok(new { success = true, address = option.DisplayName });
 01043    }
 1044
 1045    /// <summary>Новая проценка одной отклонённой позиции для её сохранённого адреса доставки.</summary>
 1046    [HttpPost, ValidateAntiForgeryToken]
 1047    public async Task<IActionResult> RequoteArmtekCartItem(string provider, long cartItemId, CancellationToken ct)
 1048    {
 01049        if (!IsArmtekProvider(provider)) return BadRequest(new { error = "Неизвестный поставщик Армтек." });
 1050        try
 1051        {
 01052            var offers = await _armtekCart.RequoteAsync(provider, cartItemId, ct);
 01053            return Ok(new
 01054            {
 01055                success = true,
 01056                originalWarehouse = offers.OriginalWarehouse,
 01057                deliveryAddress = offers.DeliveryAddress,
 1058                offers = offers.Offers.Select(x => new
 1059                {
 1060                    x.Keyzak, x.WarehouseCode, x.WarehouseName, x.Name, x.Price, x.CurrencyCode, x.IsOrderable,
 1061                    availableQuantity = x.AvailableQuantity,
 1062                    deliveryAt = x.DeliveryAtUtc?.ToString("yyyy-MM-ddTHH:mm:ss")
 1063                })
 01064            });
 1065        }
 01066        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException or InvalidOperationException)
 1067        {
 01068            return BadRequest(new { error = ex.Message });
 1069        }
 01070    }
 1071
 1072    /// <summary>Фиксирует выбранный вариант свежей проценки и сбрасывает подтверждение заказа.</summary>
 1073    [HttpPost, ValidateAntiForgeryToken]
 1074    public async Task<IActionResult> ReplaceArmtekCartItemOffer(string provider, long cartItemId, string? keyzak, Cancel
 1075    {
 01076        if (!IsArmtekProvider(provider)) return BadRequest(new { error = "Неизвестный поставщик Армтек." });
 01077        if (string.IsNullOrWhiteSpace(keyzak)) return BadRequest(new { error = "Выберите актуальное предложение." });
 1078        try
 1079        {
 01080            var replaced = await _armtekCart.ReplaceWithRequotedOfferAsync(provider, cartItemId, keyzak, ct);
 01081            if (!replaced)
 01082                return BadRequest(new { error = "Позиция не найдена или уже оформляется." });
 1083
 01084            await RefreshLocalCartStatusesAsync(provider, [cartItemId], ct);
 01085            return Ok(new { success = true });
 1086        }
 01087        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException or InvalidOperationException)
 1088        {
 01089            return BadRequest(new { error = ex.Message });
 1090        }
 01091    }
 1092
 1093    /// <summary>Создаёт реальный заказ Armtek по ранее успешно проверенным позициям.</summary>
 1094    [HttpPost, ValidateAntiForgeryToken]
 1095    public async Task<IActionResult> PrepareArmtekCheckout(string provider, long[]? cartItemIds, CancellationToken ct)
 1096    {
 01097        if (!IsArmtekProvider(provider))
 01098            return BadRequest(new { error = "Неизвестный поставщик Армтек." });
 1099
 1100        try
 1101        {
 01102            var userName = User.Identity?.Name ?? "unknown";
 01103            var result = await _armtekCart.TestAsync(provider, userName, cartItemIds, ct);
 01104            await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct);
 01105            if (result.Failed > 0)
 01106                return Ok(new { success = false, result.Added, result.Skipped, result.Failed, result.Messages });
 1107
 01108            var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
 01109            _cache.Set(ArmtekCheckoutPreparationCacheKey(token), new ArmtekCheckoutPreparation(userName, provider, Norma
 01110            return Ok(new { success = true, checkoutToken = token, result.Added, result.Skipped, result.Failed, result.M
 1111        }
 01112        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 1113        {
 01114            return BadRequest(new { error = ex.Message });
 1115        }
 01116    }
 1117
 1118    /// <summary>Создаёт реальный заказ только после свежей проверки, подготовленной отдельным запросом.</summary>
 1119    [HttpPost, ValidateAntiForgeryToken]
 1120    [SupplierOrderGuard]
 1121    public async Task<IActionResult> CreateArmtekOrder(string provider, long[]? cartItemIds, string? checkoutToken, Canc
 1122    {
 01123        if (!IsArmtekProvider(provider))
 01124            return BadRequest(new { error = "Неизвестный поставщик Армтек." });
 1125
 01126        var userName = User.Identity?.Name ?? "unknown";
 01127        if (string.IsNullOrWhiteSpace(checkoutToken) ||
 01128            !_cache.TryGetValue(ArmtekCheckoutPreparationCacheKey(checkoutToken), out ArmtekCheckoutPreparation? prepara
 01129            preparation is null || preparation.UserName != userName || preparation.Provider != provider ||
 01130            !SameCartItemIds(preparation.CartItemIds, cartItemIds))
 01131            return BadRequest(new { error = "Перед оформлением выполните свежую проверку корзины." });
 1132
 01133        _cache.Remove(ArmtekCheckoutPreparationCacheKey(checkoutToken));
 1134
 1135        try
 1136        {
 01137            var result = await _armtekCart.CheckoutAsync(provider, userName, cartItemIds, ct);
 01138            await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct);
 01139            if (result.Added == 0 && result.Failed == 0)
 01140                return BadRequest(new { error = string.Join(" ", result.Messages) });
 1141
 01142            return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }
 1143        }
 01144        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 1145        {
 01146            return BadRequest(new { error = ex.Message });
 1147        }
 01148    }
 1149
 01150    private static string ArmtekCheckoutPreparationCacheKey(string token) => $"supplier-cart:armtek-checkout:{token}";
 01151    private static long[]? NormalizeCartItemIds(long[]? ids) => ids is null ? null : ids.Where(id => id > 0).Distinct().
 1152    private static bool SameCartItemIds(long[]? expected, long[]? actual) =>
 01153        expected is null ? actual is null : actual is not null && expected.SequenceEqual(NormalizeCartItemIds(actual)!);
 1154
 1155    [HttpPost, ValidateAntiForgeryToken]
 1156    public async Task<IActionResult> RemoveShateMCartItems(string[]? cartItemIds, bool removeAll, CancellationToken ct)
 1157    {
 1158        try
 1159        {
 01160            var removed = await _shateMCart.RemoveExternalAsync(cartItemIds, removeAll, ct);
 01161            return Ok(new { success = true, messages = new[] { $"Шате‑М: удалено позиций {removed}." } });
 1162        }
 01163        catch (Exception ex) when (ex is ShateMApiException or HttpRequestException)
 1164        {
 01165            return BadRequest(new { error = ex.Message });
 1166        }
 01167    }
 1168
 1169    [HttpPost, ValidateAntiForgeryToken]
 1170    [SupplierOrderGuard(ApiSupplierSettings.ShateMProviderKey)]
 1171    public async Task<IActionResult> CheckoutShateMCartItems(
 1172        string[]? cartItemIds, bool checkoutAll, string? agreementCode, string? deliveryAddressCode,
 1173        string? deliveryType, string? deliveryAtUtc, bool agreeWithTermsOfDelivery,
 1174        bool agreeWithPersonalDataProcessingPolicyAndUserAgreement, CancellationToken ct)
 1175    {
 01176        if (!TryCreateShateMCheckoutOptions(agreementCode, deliveryAddressCode, deliveryType, deliveryAtUtc,
 01177                agreeWithTermsOfDelivery, agreeWithPersonalDataProcessingPolicyAndUserAgreement, out var options, out va
 01178            return BadRequest(new { error });
 1179        try
 1180        {
 01181            var result = await _shateMCart.CheckoutExternalAsync(cartItemIds, checkoutAll, options!, User.Identity?.Name
 01182            if (result.Added == 0 && result.Failed == 0)
 01183                return BadRequest(new { error = string.Join(" ", result.Messages) });
 01184            return Ok(new { success = result.Failed == 0, result.Added, result.Failed, result.Messages });
 1185        }
 01186        catch (Exception ex) when (ex is ShateMApiException or HttpRequestException or InvalidOperationException)
 1187        {
 01188            return BadRequest(new { error = ex.Message });
 1189        }
 01190    }
 1191
 1192    private static bool TryCreateShateMCheckoutOptions(string? agreementCode, string? addressCode, string? deliveryType,
 1193        string? deliveryAtUtc, bool terms, bool personalData, out ShateMCheckoutOptions? options, out string error)
 1194    {
 01195        options = null;
 01196        error = "Выберите договор, адрес, дату доставки и подтвердите согласия Шате‑М.";
 01197        if (string.IsNullOrWhiteSpace(agreementCode) || string.IsNullOrWhiteSpace(addressCode) || string.IsNullOrWhiteSp
 01198            !DateTime.TryParse(deliveryAtUtc, CultureInfo.GetCultureInfo("ru-RU"), DateTimeStyles.AllowWhiteSpaces, out 
 01199            return false;
 01200        options = new ShateMCheckoutOptions(agreementCode, addressCode, string.IsNullOrWhiteSpace(deliveryType) ? "OneDe
 01201        return true;
 1202    }
 1203
 1204    /// <summary>Проксирует миниатюры Ozon, чтобы CDN не скрывал их при прямой загрузке из браузера.</summary>
 1205    [HttpGet]
 1206    public async Task<IActionResult> ImageProxy(string? url, CancellationToken ct)
 1207    {
 01208        if (!TryCreateProductImageUri(url, out var uri))
 01209            return BadRequest();
 1210
 1211        try
 1212        {
 01213            using var response = await _httpClientFactory.CreateClient().GetAsync(uri, ct);
 01214            if (!response.IsSuccessStatusCode)
 01215                return NotFound();
 1216
 01217            var contentType = response.Content.Headers.ContentType?.MediaType;
 01218            if (string.IsNullOrWhiteSpace(contentType) || !contentType.StartsWith("image/", StringComparison.OrdinalIgno
 01219                return NotFound();
 1220
 01221            var bytes = await response.Content.ReadAsByteArrayAsync(ct);
 01222            return File(bytes, contentType);
 1223        }
 01224        catch (HttpRequestException)
 1225        {
 01226            return NotFound();
 1227        }
 01228    }
 1229
 01230    private static bool IsArmtekProvider(string provider) => provider is "Armtek" or "ArmtekBy";
 1231
 1232    /// <summary>
 1233    /// Сразу переносит результат действия в локальной корзине в состояние заказа.
 1234    /// Не ждём recurring job: пользователь видит Ready/Pending/Failed в основной таблице
 1235    /// сразу после проверки, смены адреса или выбора свежего предложения.
 1236    /// </summary>
 1237    private async Task RefreshLocalCartStatusesAsync(
 1238        string providerKey,
 1239        IReadOnlyCollection<long>? cartItemIds,
 1240        CancellationToken ct)
 1241    {
 1242        var ids = cartItemIds?.Where(id => id > 0).Distinct().ToArray();
 01243        var rows = providerKey switch
 01244        {
 01245            "Armtek" or "ArmtekBy" => await _context.ArmtekCartItems
 01246                .AsNoTracking()
 01247                .Where(item => item.ProviderKey == providerKey && item.ActiveStudioOrderId != null &&
 01248                               (ids == null || ids.Contains(item.Id)))
 01249                .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status))
 01250                .ToListAsync(ct),
 01251            "MlAuto" => await _context.MlAutoCartItems
 01252                .AsNoTracking()
 01253                .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id)))
 01254                .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status))
 01255                .ToListAsync(ct),
 01256            "MlAutoRu" => await _context.MlAutoRuCartItems
 01257                .AsNoTracking()
 01258                .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id)))
 01259                .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status))
 01260                .ToListAsync(ct),
 01261            "ForumAuto" or "ForumAutoInterparts" or "ForumAutoPiter" or "ForumAutoRostov" or "ForumAutoIstra" => await _
 01262                .AsNoTracking()
 01263                .Where(item => item.ProviderKey == providerKey && item.ActiveStudioOrderId != null &&
 01264                               (ids == null || ids.Contains(item.Id)))
 01265                .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status))
 01266                .ToListAsync(ct),
 01267            "FavoritParts" => await _context.FavoritPartsCartItems
 01268                .AsNoTracking()
 01269                .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id)))
 01270                .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status))
 01271                .ToListAsync(ct),
 01272            "ProfitLiga" => await _context.ProfitLigaCartItems
 01273                .AsNoTracking()
 01274                .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id)))
 01275                .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status))
 01276                .ToListAsync(ct),
 01277            _ => []
 01278        };
 1279
 01280        foreach (var row in rows)
 1281        {
 1282            try
 1283            {
 01284                await _supplierStateService.MarkInCartAsync(
 01285                    row.OrderId, providerKey, row.Status, row.CartItemId,
 01286                    user: User.Identity?.Name, ct: ct);
 01287            }
 01288            catch (Exception ex)
 1289            {
 01290                _logger.LogWarning(ex,
 01291                    "{Provider}: не удалось сразу обновить статус корзины для заказа {OrderId}",
 01292                    providerKey, row.OrderId);
 01293            }
 01294        }
 01295    }
 1296
 01297    private sealed record LocalCartStatusRow(int OrderId, long CartItemId, string Status);
 1298
 1299    private static bool TryExtractOrderId(string? comment, out int orderId) =>
 01300        StudioOrderCommentLink.TryExtractOrderId(comment, out orderId);
 1301
 1302    /// <summary>
 1303    /// Привязывает заказы Studio2 к созданному заказу АВД. Раньше номер заказа терялся,
 1304    /// и отследить его состояние у поставщика было нельзя.
 1305    /// </summary>
 1306    private async Task TrackAvdOrderedAsync(
 1307        IReadOnlyList<AvdBasketItem> orderedItems, long externalOrderId, CancellationToken ct)
 1308    {
 01309        var user = User.Identity?.Name;
 01310        foreach (var item in orderedItems)
 1311        {
 01312            if (!TryExtractOrderId(item.Comment, out var studioOrderId)) continue;
 1313
 1314            try
 1315            {
 01316                await _supplierStateService.MarkOrderedAsync(
 01317                    studioOrderId, ApiSupplierSettings.AvdProviderKey,
 01318                    externalOrderNumber: externalOrderId.ToString(),
 01319                    externalOrderId: externalOrderId.ToString(),
 01320                    externalLineId: item.Id,
 01321                    user: user, ct: ct);
 01322            }
 01323            catch (Exception ex)
 1324            {
 1325                // Заказ у поставщика уже создан — сбой журнала не повод отдавать пользователю ошибку.
 01326                _logger.LogWarning(ex,
 01327                    "АВД: не удалось привязать заказ {OrderId} к заказу поставщика {ExternalOrderId}",
 01328                    studioOrderId, externalOrderId);
 01329            }
 1330        }
 01331    }
 1332
 1333    private async Task TrackAvdRemovedAsync(IReadOnlyList<AvdBasketItem> removedItems, CancellationToken ct)
 1334    {
 01335        var user = User.Identity?.Name;
 01336        foreach (var item in removedItems)
 1337        {
 01338            if (!TryExtractOrderId(item.Comment, out var studioOrderId)) continue;
 1339
 1340            try
 1341            {
 01342                await _supplierStateService.RemoveFromCartAsync(
 01343                    studioOrderId, ApiSupplierSettings.AvdProviderKey, user, ct);
 01344            }
 01345            catch (Exception ex)
 1346            {
 01347                _logger.LogWarning(ex, "АВД: не удалось отметить удаление заказа {OrderId} из корзины", studioOrderId);
 01348            }
 1349        }
 01350    }
 1351
 1352    private async Task<SupplierCartSection> BuildJapartsSectionAsync(CancellationToken ct)
 1353    {
 01354        var items = await _japarts.GetBasketAsync(ct);
 01355        var apiOrders = await _japarts.GetOrdersAsync(JapartsOrdersIntervalDays, ct);
 1356        var orderIds = items.Select(x => StudioOrderCommentLink.ExtractOrderId(x.Comment)).OfType<int>().Distinct().ToLi
 01357        var orders = orderIds.Count == 0 ? new Dictionary<int, Order>() : await _context.Orders.AsNoTracking()
 01358            .Include(x => x.ProductInfo).Include(x => x.Manufacturer).Include(x => x.OzonClient)
 1359            .Where(x => orderIds.Contains(x.Id)).ToDictionaryAsync(x => x.Id, ct);
 01360        return new SupplierCartSection
 01361        {
 01362            ProviderKey = ApiSupplierSettings.JapartsProviderKey, DisplayName = "Japarts", LogoUrl = "/images/japarts-lo
 01363            Rows = items.Select(x =>
 01364            {
 01365                orders.TryGetValue(StudioOrderCommentLink.ExtractOrderId(x.Comment) ?? 0, out var order);
 01366                return new SupplierCartRow { ApiItemId = x.BasketId, ApiCatalogName = x.MakeName, ApiItemName = x.Detail
 01367                    ApiItemNumber = x.DetailNum, ApiPrice = x.PriceRub, ApiQuantity = x.Quantity, ApiRegion = x.Country,
 01368                    ApiSupplierName = x.SupplierCode, ApiPeriod = x.DeliveryDays, ApiComment = x.Comment, StudioOrderId 
 01369                    StudioOrderNumber = order?.ShipmentNumber, StudioProductName = order?.ProductName, StudioArticle = o
 01370                    StudioManufacturer = order?.Manufacturer?.Name, StudioProductImageUrl = order?.ProductInfo?.ImageUrl
 01371            }).ToList(),
 1372            JapartsOrders = apiOrders.Select(x => new JapartsApiOrderRow(x.GlobalId, x.OrderNumber, x.MakeName, x.Detail
 1373                x.DetailName, x.QuantityOrdered, x.QuantityBought, x.QuantityArrived, x.QuantityShipped, x.OrderPrice,
 1374                x.Status, string.Empty, x.LastUpdated, x.Comment)).ToList()
 01375        };
 01376    }
 1377
 1378    /// <summary>
 1379    /// Вкладка Berg: слева локальная корзина (у Berg нет API корзины), справа активные заказы,
 1380    /// которые тянутся прямо из ordering/states/active.json.
 1381    /// </summary>
 1382    private async Task<SupplierCartSection> BuildBergSectionAsync(CancellationToken ct)
 1383    {
 01384        var cartItems = await _bergCartService.GetActiveAsync(ct);
 1385        var cartOrderIds = cartItems.Select(x => x.StudioOrderId).Distinct().ToList();
 01386        var orders = cartOrderIds.Count == 0 ? new Dictionary<int, Order>() : await _context.Orders.AsNoTracking()
 01387            .Include(x => x.ProductInfo).Include(x => x.Manufacturer).Include(x => x.OzonClient)
 1388            .Where(x => cartOrderIds.Contains(x.Id)).ToDictionaryAsync(x => x.Id, ct);
 1389
 01390        var rows = cartItems.Select(x =>
 01391        {
 01392            orders.TryGetValue(x.StudioOrderId, out var order);
 01393            return new SupplierCartRow
 01394            {
 01395                LocalCartItemId = x.Id,
 01396                ApiItemId = x.Id.ToString(CultureInfo.InvariantCulture),
 01397                ApiCatalogName = x.Brand,
 01398                ApiItemName = x.ProductName ?? string.Empty,
 01399                ApiItemNumber = x.Article,
 01400                ApiPrice = x.SourcePrice,
 01401                ApiQuantity = x.Quantity,
 01402                ApiSupplierName = x.WarehouseName ?? string.Empty,
 01403                ApiComment = x.LastError ?? string.Empty,
 01404                AddedAt = x.CreatedAtUtc,
 01405                StudioOrderId = order?.Id ?? x.StudioOrderId,
 01406                StudioOrderNumber = order?.ShipmentNumber,
 01407                StudioProductName = order?.ProductName,
 01408                StudioArticle = order?.Article,
 01409                StudioManufacturer = order?.Manufacturer?.Name,
 01410                StudioProductImageUrl = order?.ProductInfo?.ImageUrl,
 01411                StudioClientName = order?.OzonClient?.Name
 01412            };
 01413        }).ToList();
 1414
 01415        var section = new SupplierCartSection
 01416        {
 01417            ProviderKey = ApiSupplierSettings.BergProviderKey,
 01418            DisplayName = "Берг",
 01419            LogoUrl = "/images/berg-logo-2026.png",
 01420            Rows = rows
 01421        };
 1422
 1423        try
 1424        {
 01425            var apiOrders = BergDataManager.ParseOrders(await _berg.GetActiveOrderStatesAsync(ct));
 1426            var externalIds = apiOrders.Select(x => x.Id).ToList();
 1427            // Связь строки Berg с заказом Studio2 хранится у нас: у поставщика её носит только комментарий.
 01428            var links = externalIds.Count == 0
 01429                ? []
 01430                : await _context.BergOrderItems.AsNoTracking()
 01431                    .Where(x => externalIds.Contains(x.BergOrder!.ExternalOrderId))
 01432                    .Select(x => new { x.BergOrder!.ExternalOrderId, x.Sequence, x.StudioOrderId })
 01433                    .ToListAsync(ct);
 1434            var linkedOrderIds = links.Select(x => x.StudioOrderId).Distinct().ToList();
 01435            var shipmentNumbers = linkedOrderIds.Count == 0
 01436                ? new Dictionary<int, string?>()
 01437                : await _context.Orders.AsNoTracking().Where(x => linkedOrderIds.Contains(x.Id))
 1438                    .ToDictionaryAsync(x => x.Id, x => x.ShipmentNumber, ct);
 1439
 01440            section = section with
 01441            {
 01442                BergOrders = apiOrders.Select(order => new BergApiOrderRow(order.Id, order.Reference, order.IsTest,
 01443                    order.CreatedAt, order.DispatchAt, order.ShipmentAddress, order.Comment,
 01444                    order.Items.Select(line =>
 01445                    {
 01446                        var link = links.FirstOrDefault(x => x.ExternalOrderId == order.Id && x.Sequence == line.Sequenc
 01447                        shipmentNumbers.TryGetValue(link?.StudioOrderId ?? 0, out var shipmentNumber);
 01448                        return new BergApiOrderItemRow(line.Sequence, line.Article, line.Brand, line.Name,
 01449                            line.WarehouseName, line.Quantity, line.Price, line.StateId, line.StateName,
 01450                            line.AssuredTime, line.Comment, link?.StudioOrderId, shipmentNumber);
 1451                    }).ToList())).OrderByDescending(x => x.CreatedAt).ToList()
 01452            };
 01453        }
 01454        catch (Exception ex) when (ex is BergApiException or HttpRequestException)
 1455        {
 1456            // Недоступность Berg гасит только вкладку заказов: локальная корзина остаётся видимой.
 01457            section = section with { OrdersError = ex.Message };
 01458        }
 1459
 01460        return section;
 01461    }
 1462
 1463    private async Task<SupplierCartSection> BuildAvdSectionAsync(IReadOnlyList<AvdBasketItem> items, CancellationToken c
 1464    {
 01465        var orderIds = items
 1466            .Select(x => TryExtractOrderId(x.Comment, out var id) ? id : (int?)null)
 1467            .Where(id => id.HasValue)
 1468            .Select(id => id!.Value)
 01469            .Distinct()
 01470            .ToList();
 1471
 01472        var ordersById = orderIds.Count == 0
 01473            ? new Dictionary<int, Order>()
 01474            : await _context.Orders.AsNoTracking()
 01475                .Include(o => o.ProductInfo)
 01476                .Include(o => o.Manufacturer)
 01477                .Include(o => o.OzonClient)
 01478                .Where(o => orderIds.Contains(o.Id))
 1479                .ToDictionaryAsync(o => o.Id, ct);
 01480        var addedAtByBasketId = new Dictionary<string, DateTime>(StringComparer.Ordinal);
 01481        if (orderIds.Count > 0)
 1482        {
 01483            var history = await _context.OrderHistories.AsNoTracking()
 01484                .Where(x => orderIds.Contains(x.OrderId) && x.ColumnName == ApiSupplierBasketHistoryEntry.HistoryColumnN
 01485                .Select(x => new { x.ChangedAt, x.NewValue })
 01486                .ToListAsync(ct);
 01487            foreach (var item in history)
 1488            {
 01489                var placement = ApiSupplierBasketHistoryEntry.TryParse(item.NewValue);
 01490                if (placement is not { Success: true, ProviderKey: "Avd", ApiItemId: { Length: > 0 } basketItemId })
 1491                    continue;
 01492                if (!addedAtByBasketId.TryGetValue(basketItemId, out var addedAt) || item.ChangedAt < addedAt)
 01493                    addedAtByBasketId[basketItemId] = item.ChangedAt;
 1494            }
 1495        }
 1496
 01497        var rows = items.Select(item =>
 01498        {
 01499            Order? order = null;
 01500            if (TryExtractOrderId(item.Comment, out var orderId))
 01501                ordersById.TryGetValue(orderId, out order);
 01502
 01503            return new SupplierCartRow
 01504            {
 01505                ApiItemId = item.Id,
 01506                ApiCatalogName = item.CatalogName,
 01507                ApiItemName = item.ItemName,
 01508                ApiItemNumber = item.ItemNumber,
 01509                ApiPrice = item.Price,
 01510                ApiQuantity = item.Quantity,
 01511                ApiRegion = item.Region,
 01512                ApiSupplierName = item.SupplierName,
 01513                ApiPeriod = item.Period,
 01514                ApiComment = item.Comment,
 01515                AddedAt = addedAtByBasketId.TryGetValue(item.Id, out var addedAt) ? addedAt : null,
 01516                StudioOrderId = order?.Id,
 01517                StudioOrderNumber = order?.ShipmentNumber,
 01518                StudioProductName = order?.ProductName,
 01519                StudioArticle = order?.Article,
 01520                StudioManufacturer = order?.Manufacturer?.Name,
 01521                StudioProductImageUrl = order?.ProductInfo?.ImageUrl,
 01522                StudioClientName = order?.OzonClient?.Name
 01523            };
 01524        })
 01525            // АВД выдаёт монотонно растущий идентификатор позиции корзины: новые позиции — сверху.
 1526            .OrderByDescending(row => GetBasketItemSortKey(row.ApiItemId))
 1527            .ThenByDescending(row => row.ApiItemId, StringComparer.Ordinal)
 01528            .ToList();
 1529
 01530        return new SupplierCartSection
 01531        {
 01532            ProviderKey = ApiSupplierSettings.AvdProviderKey,
 01533            DisplayName = "АВД",
 01534            LogoUrl = "/images/avd-logo.svg",
 01535            Rows = rows
 01536        };
 01537    }
 1538
 1539    private static long GetBasketItemSortKey(string itemId) =>
 01540        long.TryParse(itemId, out var numericId) ? numericId : long.MinValue;
 1541
 1542    private async Task<SupplierCartSection> BuildArmtekSectionAsync(string providerKey, string displayName, string logoU
 1543    {
 01544        var items = await _armtekCart.GetActiveAsync(providerKey, ct);
 01545        var orders = await _armtekCart.GetOrdersAsync(providerKey, ct);
 01546        var orderWarehouseIds = await _context.Orders.AsNoTracking()
 01547            .Where(x => items.Select(item => item.StudioOrderId).Contains(x.Id))
 01548            .Select(x => new { x.Id, x.ShipmentWarehouseId })
 1549            .ToDictionaryAsync(x => x.Id, x => x.ShipmentWarehouseId, ct);
 01550        var mappings = await _context.ArmtekDeliveryAddressMappings.AsNoTracking()
 01551            .Where(x => x.ProviderKey == providerKey)
 1552            .ToDictionaryAsync(x => x.ShipmentWarehouseId, ct);
 01553        var section = BuildArmtekSection(providerKey, displayName, logoUrl, items, orders, item =>
 01554            orderWarehouseIds.TryGetValue(item.StudioOrderId, out var warehouseId) && warehouseId.HasValue &&
 01555            mappings.TryGetValue(warehouseId.Value, out var mapping) &&
 01556            mapping.BuyerKunnr == item.BuyerKunnr && mapping.DeliveryAddressKunnr == item.DeliveryAddressKunnr);
 1557        try
 1558        {
 01559            section = section with { ArmtekDeliveryOptions = await GetArmtekDeliveryOptionsAsync(providerKey, ct) };
 01560        }
 01561        catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException)
 1562        {
 01563            section = section with { ArmtekDeliveryOptionsError = "Armtek не вернул список адресов доставки. Сохранённый
 01564        }
 1565
 1566        try
 1567        {
 01568            var apiOrders = await GetArmtekApiOrdersAsync(providerKey, createdFrom, createdTo, ct);
 01569            var links = await LoadStudioOrderLinksAsync(
 1570                orders.SelectMany(order => order.Items.Select(item => (order.Vbeln, item.StudioOrderId))), ct);
 01571            return section with { ArmtekApiOrders = apiOrders, StudioOrderLinksBySupplierOrder = links };
 1572        }
 01573        catch (ArmtekApiException ex)
 1574        {
 01575            return section with { OrdersError = ex.Message };
 1576        }
 01577    }
 1578
 1579    private async Task<SupplierCartSection> BuildMlAutoSectionAsync(DateOnly createdFrom, DateOnly createdTo, Cancellati
 1580    {
 01581        var items = await _mlAutoCart.GetActiveAsync(ct);
 01582        var orders = await _mlAutoCart.GetOrdersAsync(ct);
 01583        var mappedWarehouseIds = (await _context.MlAutoDeliveryAddressMappings.AsNoTracking()
 01584            .Select(x => x.ShipmentWarehouseId).ToListAsync(ct)).ToHashSet();
 01585        var options = new List<MlAutoDeliveryOption>();
 01586        var apiOrders = new List<MlAutoApiOrderRow>();
 01587        string? error = null;
 01588        string? ordersError = null;
 1589        try
 1590        {
 01591            var response = await _mlAuto.GetDeliveryAddressesAsync(ct);
 01592            foreach (var item in (response is JContainer container ? container.DescendantsAndSelf() : [response]).OfType
 1593            {
 01594                var id = item.GetValue("ADDRESS_ID", StringComparison.OrdinalIgnoreCase)?.ToString() ?? item.GetValue("I
 01595                if (!string.IsNullOrWhiteSpace(id)) options.Add(new MlAutoDeliveryOption(id, item.GetValue("ADDRESS_NAME
 1596            }
 1597            options = options.DistinctBy(x => x.Id).ToList();
 01598        }
 01599        catch (MlAutoApiException ex) { error = ex.Message; }
 1600
 1601        try
 1602        {
 01603            apiOrders = (await GetMlAutoApiOrdersAsync(createdFrom, createdTo, ct)).ToList();
 01604        }
 01605        catch (MlAutoApiException ex)
 1606        {
 01607            ordersError = ex.Message;
 01608        }
 1609
 01610        var studioOrderLinks = await LoadStudioOrderLinksAsync(
 1611            orders.SelectMany(order => order.Items.Select(item => (order.OrderNumber, item.StudioOrderId))), ct);
 01612        return new SupplierCartSection { ProviderKey = "MlAuto", DisplayName = "ML-Auto", LogoUrl = "https://www.ml-auto
 01613            Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.Pin, ApiItemNumber = x.
 01614    }
 1615
 1616    private async Task<SupplierCartSection> BuildMlAutoRuSectionAsync(CancellationToken ct)
 1617    {
 01618        var items = await _mlAutoRuCart.GetActiveAsync(ct);
 01619        var orders = await _mlAutoRuCart.GetOrdersAsync(ct);
 01620        var mappedWarehouseIds = (await _context.MlAutoRuDeliveryAddressMappings.AsNoTracking().Select(x => x.ShipmentWa
 01621        var options = new List<MlAutoDeliveryOption>();
 01622        var apiOrders = new List<MlAutoApiOrderRow>();
 01623        string? error = null;
 01624        string? ordersError = null;
 1625        try
 1626        {
 01627            var response = await _mlAutoRu.GetDeliveryAddressesAsync(ct);
 01628            foreach (var address in (response is JContainer container ? container.DescendantsAndSelf() : [response]).OfT
 1629            {
 01630                var id = address.GetValue("ADDRESS_ID", StringComparison.OrdinalIgnoreCase)?.ToString() ?? address.GetVa
 01631                if (!string.IsNullOrWhiteSpace(id)) options.Add(new MlAutoDeliveryOption(id, address.GetValue("ADDRESS_N
 1632            }
 01633        }
 01634        catch (MlAutoApiException ex) { error = ex.Message; }
 01635        try { apiOrders = (await GetMlAutoRuApiOrdersAsync(ct)).ToList(); }
 01636        catch (MlAutoApiException ex) { ordersError = ex.Message; }
 01637        var studioOrderLinks = await LoadStudioOrderLinksAsync(
 1638            orders.SelectMany(order => order.Items.Select(item => (order.OrderNumber, item.StudioOrderId))), ct);
 01639        return new SupplierCartSection
 01640        {
 1641            ProviderKey = "MlAutoRu", DisplayName = "ML-Auto RU", LogoUrl = "https://ml-auto.ru/media/files/settings/ins
 01642            Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.Pin, ApiItemNumber = x.
 01643        };
 01644    }
 1645
 1646    /// <summary>У Forum-Auto нет отдельного запроса за деталями — listOrders сразу возвращает состав заказа.</summary>
 1647    private async Task<SupplierCartSection> BuildForumAutoSectionAsync(string providerKey, string displayName,
 1648        Services.ApiServices.ForumAutoApi.ForumAutoDataManager client, CancellationToken ct)
 1649    {
 01650        var items = await _forumAutoCart.GetActiveAsync(providerKey, ct);
 01651        var orders = await _forumAutoCart.GetOrdersAsync(providerKey, ct);
 01652        var (apiOrders, ordersError) = await GetForumAutoApiOrdersAsync(providerKey, client, orders, ct);
 01653        var studioOrderLinks = await LoadStudioOrderLinksAsync(
 1654            orders.Where(order => order.Did.HasValue)
 1655                .SelectMany(order => order.Items.Select(item => (order.Did!.Value.ToString(CultureInfo.InvariantCulture)
 01656        return new SupplierCartSection
 01657        {
 01658            ProviderKey = providerKey, DisplayName = displayName, LogoUrl = "/images/forum-auto-logo.svg", CurrencyCode 
 01659            ForumAutoOrders = orders, ForumAutoApiOrders = apiOrders, StudioOrderLinksBySupplierOrder = studioOrderLinks
 1660            Rows = items.Select(x => new SupplierCartRow
 1661            {
 1662                LocalCartItemId = x.Id, ApiItemId = x.Tid, ApiItemNumber = x.Article, ApiCatalogName = x.Brand,
 1663                ApiItemName = x.ProductName ?? x.Article, ApiPrice = x.SourcePrice, ApiQuantity = x.Quantity,
 1664                ApiRegion = string.Empty, ApiSupplierName = x.Status,
 1665                ApiPeriod = x.DeliveryAtUtc.HasValue ? Math.Max(0, (int)Math.Ceiling((x.DeliveryAtUtc.Value - DateTime.U
 1666                AddedAt = x.CreatedAtUtc.ToLocalTime(), StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrd
 1667                StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article,
 1668                StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductIn
 1669                StudioClientName = x.StudioOrder?.OzonClient?.Name
 1670            }).ToList()
 01671        };
 01672    }
 1673
 1674    /// <summary>У FavoritParts реальная серверная корзина и реальные заказы — источник истины для
 1675    /// отображения сам API (GET cart/, GET order/), а не локальный кэш: он может разойтись с реальным
 1676    /// состоянием (ручное изменение на сайте поставщика, неудачный чекаут). StudioOrderId читается из
 1677    /// comment строки/позиции (см. StudioOrderCommentLink) — тем же каналом, что уже использует АВД.</summary>
 1678    private Task<SupplierCartSection> BuildFavoritPartsSectionAsync(CancellationToken ct) =>
 01679        BuildFavoritPartsSectionAsync(_favoritPartsCart, ApiSupplierSettings.FavoritPartsProviderKey, "Фаворит Союз", ct
 1680
 1681    private async Task<SupplierCartSection> BuildFavoritPartsSectionAsync(FavoritPartsCartService cartService, string pr
 1682    {
 01683        var rows = new List<SupplierCartRow>();
 01684        string? error = null;
 1685        try
 1686        {
 01687            var lines = await cartService.GetApiCartAsync(ct);
 1688            // Одна строка корзины может обслуживать несколько заказов Studio2 (cart/add не суммирует
 1689            // количество), поэтому привязка берётся из FavoritPartsCartItems, а не только из comment.
 01690            var links = await cartService.ResolveStudioOrdersAsync(lines, ct);
 1691            var orderIds = links.Values.SelectMany(x => x).Select(x => x.StudioOrderId).Distinct().ToList();
 01692            var ordersById = orderIds.Count == 0
 01693                ? new Dictionary<int, Order>()
 01694                : await _context.Orders.AsNoTracking()
 01695                    .Include(o => o.ProductInfo).Include(o => o.Manufacturer).Include(o => o.OzonClient)
 1696                    .Where(o => orderIds.Contains(o.Id)).ToDictionaryAsync(o => o.Id, ct);
 1697
 01698            rows = lines.Select(line =>
 01699            {
 01700                var lineLinks = links.TryGetValue(line.Key, out var l) ? l : [];
 01701                Order? order = lineLinks.Count > 0 && ordersById.TryGetValue(lineLinks[0].StudioOrderId, out var o) ? o 
 01702                var days = line.ShippingDate.HasValue
 01703                    ? Math.Max(0, line.ShippingDate.Value.DayNumber - FavoritPartsCartService.MoscowToday.DayNumber)
 01704                    : (int?)null;
 01705                var sharedNote = lineLinks.Count > 1
 1706                    ? $" Общая строка для заказов: {string.Join(", ", lineLinks.Select(x => x.StudioOrderId))}."
 01707                    : string.Empty;
 01708                return new SupplierCartRow
 01709                {
 01710                    LocalCartItemId = null,
 01711                    ApiItemId = line.Key,
 01712                    ApiCatalogName = line.Brand ?? string.Empty,
 01713                    ApiItemName = line.GoodsName ?? line.Number ?? string.Empty,
 01714                    ApiItemNumber = line.Number ?? string.Empty,
 01715                    ApiPrice = line.Price,
 01716                    ApiQuantity = line.Count,
 01717                    ApiRegion = line.WarehouseGroupCode ?? string.Empty,
 01718                    ApiSupplierName = FavoritPartsCartItemStatus.Pending,
 01719                    ApiPeriod = days,
 01720                    ApiComment = (line.Comment ?? string.Empty) + sharedNote,
 01721                    StudioOrderId = order?.Id,
 01722                    StudioOrderNumber = order?.ShipmentNumber,
 01723                    StudioProductName = order?.ProductName,
 01724                    StudioArticle = order?.Article,
 01725                    StudioManufacturer = order?.Manufacturer?.Name,
 01726                    StudioProductImageUrl = order?.ProductInfo?.ImageUrl,
 01727                    StudioClientName = order?.OzonClient?.Name
 01728                };
 01729            }).ToList();
 01730        }
 01731        catch (FavoritPartsApiException ex)
 1732        {
 01733            error = ex.Message;
 01734        }
 1735
 01736        var (apiOrders, ordersError) = await GetFavoritPartsApiOrdersAsync(cartService, providerKey, ct);
 1737        // Строка заказа поставщика может относиться к нескольким заказам Studio2, а comment хранит только
 1738        // первый из них — недостающие связи добираем из локальных FavoritPartsOrderItems.
 01739        var localOrderLinks = await _context.FavoritPartsOrderItems.AsNoTracking()
 01740            .Where(item => item.FavoritPartsOrder != null && item.FavoritPartsOrder.ProviderKey == providerKey && item.F
 01741            .Select(item => new { ExternalOrderId = item.FavoritPartsOrder!.ExternalOrderId!.Value, item.StudioOrderId }
 01742            .ToListAsync(ct);
 01743        var studioOrderLinks = await LoadStudioOrderLinksAsync(
 1744            apiOrders.SelectMany(order => order.Items
 1745                    .Where(item => item.StudioOrderId.HasValue)
 1746                    .Select(item => (order.Id.ToString(), item.StudioOrderId!.Value)))
 1747                .Concat(localOrderLinks.Select(link => (link.ExternalOrderId.ToString(), link.StudioOrderId)))
 01748                .Distinct(), ct);
 1749
 01750        return new SupplierCartSection
 01751        {
 01752            ProviderKey = providerKey, DisplayName = displayName,
 01753            LogoUrl = "/images/favorit-parts-logo.png", CurrencyCode = "RUB",
 01754            Rows = rows, Error = error,
 01755            FavoritPartsApiOrders = apiOrders, OrdersError = ordersError, StudioOrderLinksBySupplierOrder = studioOrderL
 01756        };
 01757    }
 1758
 1759    /// <summary>Портальная корзина общая: показываем лишь строки, которые были созданы Studio2.</summary>
 1760    private async Task<SupplierCartSection> BuildMoskvorechieIstraSectionAsync(CancellationToken ct)
 1761    {
 1762        try
 1763        {
 01764            await _moskvorechieIstraCart.SynchronizeAsync(ct);
 01765            await _moskvorechieIstraCart.SynchronizeOrdersAsync(ct);
 01766        }
 01767        catch (MoskvorechieApiException ex)
 1768        {
 01769            return SupplierCartSection.Failed(ApiSupplierSettings.MoskvorechieIstraProviderKey,
 01770                "Москворечье (Истра)", "/images/moskvorechie-logo.gif", ex.Message);
 1771        }
 01772        var items = await _context.MoskvorechieIstraCartItems.AsNoTracking()
 01773            // Оформленные строки больше не являются корзиной: они показываются только на вкладке «Заказы».
 01774            .Where(x => x.ActiveStudioOrderId != null && x.Status == "Pending")
 01775            .Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo)
 01776            .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer)
 01777            .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 01778            .OrderByDescending(x => x.CreatedAtUtc)
 01779            .ToListAsync(ct);
 01780        var orders = await _context.MoskvorechieIstraOrders.AsNoTracking().Include(x => x.Items)
 01781            .OrderByDescending(x => x.CreatedAt)
 01782            .Select(x => new MoskvorechieIstraApiOrderRow(x.Id, x.ExternalOrderNumber, x.CreatedAt, x.Status,
 01783                x.Items.Count, x.Items.Sum(i => (i.Price ?? 0m) * i.Quantity)))
 01784            .ToListAsync(ct);
 01785        return new SupplierCartSection
 01786        {
 01787            ProviderKey = ApiSupplierSettings.MoskvorechieIstraProviderKey,
 01788            DisplayName = "Москворечье (Истра)", LogoUrl = "/images/moskvorechie-logo.gif", CurrencyCode = "RUB",
 1789            Rows = items.Select(x => new SupplierCartRow
 1790            {
 1791                LocalCartItemId = x.Id, ApiItemId = x.ExternalBasketItemId, ApiCatalogName = x.Brand,
 1792                ApiItemName = x.ProductName ?? x.Article, ApiItemNumber = x.Article, ApiPrice = x.SourcePrice,
 1793                ApiQuantity = x.Quantity, ApiRegion = x.Warehouse ?? string.Empty, ApiSupplierName = x.Status,
 1794                ApiPeriod = x.DeliveryDaysMax ?? x.DeliveryDaysMin, ApiComment = x.Comment ?? string.Empty,
 1795                AddedAt = x.CreatedAtUtc, StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrder?.ShipmentNu
 1796                StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article,
 1797                StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductIn
 1798                StudioClientName = x.StudioOrder?.OzonClient?.Name
 1799            }).ToList(), MoskvorechieIstraOrders = orders
 01800        };
 01801    }
 1802
 1803    /// <summary>Гибридная вкладка: рабочая корзина Studio2 плюс реальная корзина аккаунта у поставщика.</summary>
 1804    private async Task<SupplierCartSection> BuildProfitLigaSectionAsync(CancellationToken ct)
 1805    {
 01806        var items = await _profitLigaCart.GetActiveAsync(ct);
 01807        var localOrders = await _profitLigaCart.GetOrdersAsync(ct);
 1808
 01809        string? error = null;
 1810        try
 1811        {
 01812            var apiLines = await _profitLigaCart.GetApiCartAsync(ct);
 01813            var unknownCount = apiLines.Count(line => !items.Any(item =>
 01814                string.Equals(item.ArticleId, line.ArticleId, StringComparison.Ordinal) &&
 01815                string.Equals(item.WarehouseId, line.WarehouseId, StringComparison.Ordinal)));
 01816            if (unknownCount > 0)
 01817                error = $"В корзине Профит-Лиги есть {unknownCount} поз. не из Studio2. Удалите их у поставщика: они бло
 01818        }
 01819        catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException)
 1820        {
 01821            error = ex.Message;
 01822        }
 1823
 1824        var orderRows = localOrders.Select(x => new ProfitLigaApiOrderRow(
 1825            x.Id, x.OrderNumber, x.Status, x.CreatedAtUtc, x.PointName, null, x.Items.Count,
 1826            x.Items.Sum(i => (i.Price ?? 0m) * i.Quantity),
 1827            x.Items.Select(i => new ProfitLigaApiOrderItemRow(i.Article, i.Brand, null, i.Quantity, i.Price,
 1828                null, null, null)).ToList())).ToList();
 1829
 01830        return new SupplierCartSection
 01831        {
 01832            ProviderKey = ApiSupplierSettings.ProfitLigaProviderKey,
 01833            DisplayName = "Профит-Лига",
 01834            LogoUrl = "/images/profit-liga-logo.svg",
 01835            CurrencyCode = "RUB",
 01836            Error = error,
 1837            Rows = items.Select(x => new SupplierCartRow
 1838            {
 1839                LocalCartItemId = x.Id,
 1840                ApiItemId = x.ArticleId,
 1841                ApiCatalogName = x.Brand,
 1842                ApiItemName = x.ProductName ?? x.Article,
 1843                ApiItemNumber = x.Article,
 1844                ApiPrice = x.SourcePrice,
 1845                ApiQuantity = x.Quantity,
 1846                ApiRegion = x.WarehouseName ?? x.WarehouseId,
 1847                // Общая строка корзины несёт статус в ApiSupplierName, а текст ошибки — в ApiComment.
 1848                ApiSupplierName = x.Status,
 1849                ApiComment = x.LastError ?? string.Empty,
 1850                AddedAt = x.CreatedAtUtc,
 1851                ProfitLigaPointName = x.PointName,
 1852                StudioOrderId = x.StudioOrderId,
 1853                StudioOrderNumber = x.StudioOrder?.ShipmentNumber,
 1854                StudioProductName = x.StudioOrder?.ProductName,
 1855                StudioArticle = x.StudioOrder?.Article,
 1856                StudioClientName = x.StudioOrder?.OzonClient?.Name
 1857            }).ToList(),
 01858            ProfitLigaOrders = orderRows
 01859        };
 01860    }
 1861
 1862    private async Task<SupplierCartSection> BuildNikeiSectionAsync(CancellationToken ct)
 1863    {
 01864        var items = await _nikeiCart.GetActiveAsync(ct);
 01865        var linkedOrders = await _context.NikeiCartItems.AsNoTracking().Include(x => x.StudioOrder)
 01866            .Where(x => x.Status == NikeiCartItemStatus.Ordered && x.ExternalOrderNumber != null).ToListAsync(ct);
 01867        string? error = null;
 01868        var orderRows = new List<NikeiApiOrderRow>();
 01869        var foreignRows = new List<SupplierCartRow>();
 1870        try
 1871        {
 01872            var apiCart = await _nikeiCart.GetApiCartAsync(ct);
 01873            var contents = apiCart["contents"] as JArray ?? [];
 01874            var foreign = contents.Count(line => !items.Any(x => string.Equals(x.ToOrder, line.Value<string>("to_order")
 01875            if (foreign > 0) error = $"В корзине Nikei есть {foreign} поз., добавленных не из Studio2. Они не будут офор
 01876            foreignRows = contents.Where(line => !items.Any(x => string.Equals(x.ToOrder, line.Value<string>("to_order")
 1877                .Select(line => new SupplierCartRow
 1878                {
 1879                    ApiItemId = line.Value<string>("to_order") ?? string.Empty, ApiCatalogName = line.Value<string>("bra
 1880                    ApiItemName = line.Value<string>("name") ?? line.Value<string>("article") ?? string.Empty, ApiItemNu
 1881                    ApiPrice = line.Value<decimal?>("price"), ApiQuantity = line.Value<decimal?>("quantity"),
 1882                    ApiRegion = line.Value<string>("warehouse") ?? "—", ApiSupplierName = "ApiOnly",
 1883                    ApiPeriod = line["deliverytime"]?.Value<int?>("max") ?? line["deliverytime"]?.Value<int?>("min"),
 1884                    ApiComment = line.Value<string>("comment") ?? "Добавлено в корзину Nikei вне Studio2.", AddedAt = nu
 1885                }).ToList();
 01886        }
 01887        catch (NikeiApiException ex) { error = ex.Message; }
 1888
 1889        try
 1890        {
 01891            var apiOrders = await _nikeiCart.GetApiOrdersAsync(ct);
 01892            foreach (var line in (apiOrders["orders"] as JArray ?? []))
 1893            {
 01894                var invoice = line.Value<string>("invoice") ?? "—";
 01895                var linked = linkedOrders.FirstOrDefault(x => string.Equals(x.ExternalOrderNumber, invoice, StringCompar
 01896                orderRows.Add(new NikeiApiOrderRow(invoice, line.Value<int?>("id"), line.Value<string>("article") ?? str
 01897                    line.Value<string>("brand") ?? string.Empty, line.Value<string>("name"), line.Value<decimal?>("quant
 01898                    line.Value<decimal?>("price"), line.Value<string>("status_name") ?? "—", line.Value<string>("shippin
 01899                    line.Value<string>("shipping_address"), ParseNikeiDate(line.Value<string>("date_added")), linked?.St
 1900            }
 01901        }
 01902        catch (NikeiApiException ex) { error ??= ex.Message; }
 1903
 01904        return new SupplierCartSection
 01905        {
 01906            ProviderKey = ApiSupplierSettings.NikeiProviderKey, DisplayName = "Nikei", LogoUrl = "/images/nikei-logo.svg
 1907            Rows = items.Select(x => new SupplierCartRow
 1908            {
 1909                LocalCartItemId = x.Id, ApiItemId = x.ToOrder ?? string.Empty, ApiCatalogName = x.Brand,
 1910                ApiItemName = x.ProductName ?? x.Article, ApiItemNumber = x.Article, ApiPrice = x.SourcePrice,
 1911                ApiQuantity = x.Quantity, ApiRegion = x.WarehouseName ?? "—", ApiSupplierName = x.Status,
 1912                ApiPeriod = x.DeliveryDaysMax ?? x.DeliveryDaysMin, ApiComment = x.LastError ?? x.Comment ?? string.Empt
 1913                AddedAt = x.CreatedAtUtc, StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrder?.ShipmentNu
 1914                StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article,
 1915                StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductIn
 1916                StudioClientName = x.StudioOrder?.OzonClient?.Name
 1917            }).Concat(foreignRows).ToList(), NikeiOrders = orderRows
 01918        };
 01919    }
 1920
 01921    private static DateTime? ParseNikeiDate(string? value) => DateTime.TryParse(value, CultureInfo.InvariantCulture,
 01922        DateTimeStyles.AssumeLocal, out var parsed) ? parsed : null;
 1923
 1924    private async Task<SupplierCartSection> BuildMikadoSectionAsync(CancellationToken ct)
 1925    {
 01926        var items = await _mikadoCart.GetActiveAsync(ct);
 01927        return new SupplierCartSection
 01928        {
 01929            ProviderKey = ApiSupplierSettings.MikadoMskHod20ProviderKey, DisplayName = "Микадо МскХод20",
 01930            LogoUrl = "https://mikado-parts.ru/img/MikadoLogo.svg", CurrencyCode = "RUB",
 1931            Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.ApiBasketItemId.ToStrin
 1932                ApiCatalogName = x.Brand, ApiItemName = x.ProductName ?? x.Article, ApiPrice = x.SourcePrice, ApiQuantit
 1933                ApiPeriod = x.DeliveryDays, ApiSupplierName = x.Status, ApiComment = x.LastError ?? string.Empty, AddedA
 1934                StudioOrderNumber = x.StudioOrder?.ShipmentNumber, StudioProductName = x.StudioOrder?.ProductName, Studi
 01935        };
 01936    }
 1937
 1938    /// <summary>Вкладка ZZap: зеркало Studio2 плюс живые данные API. Корзина сверяется с GET /cart по code_order,
 1939    /// заказы берутся из GET /orders и связываются с заказами Studio2 по тому же коду.</summary>
 1940    private async Task<SupplierCartSection> BuildZZapMoscowSectionAsync(CancellationToken ct)
 1941    {
 01942        var items = await _zzapCart.GetActiveAsync(ct);
 01943        var ordered = await _zzapCart.GetOrderedAsync(ct: ct);
 01944        string? error = null;
 01945        IReadOnlyDictionary<long, TransactionServices.ZZapApiCartLine>? apiCart = null;
 01946        try { apiCart = await _zzapCart.GetApiCartAsync(ct); }
 01947        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException) { error = $"Корзина ZZap не прочитана
 1948
 1949        // Источник корзины — API ZZap: показываем всё, что лежит в корзине аккаунта, а зеркало Studio2
 1950        // добавляет связь с заказом и статус проверки. Зеркальные строки, которых у ZZap уже нет, — в хвосте с пометкой
 1951        var mirrorByCode = items.ToDictionary(x => x.CodeOrder);
 1952        SupplierCartRow ToRow(TransactionServices.ZZapApiCartLine? live, ZZapCartItem? local)
 1953        {
 01954            var order = local?.StudioOrder;
 01955            var seller = live?.SellerName ?? local?.SellerName;
 01956            var location = live?.Location ?? local?.SellerLocation;
 01957            return new SupplierCartRow
 01958            {
 01959                LocalCartItemId = local?.Id, ApiItemId = (live?.CodeOrder ?? local!.CodeOrder).ToString(CultureInfo.Inva
 01960                ApiItemNumber = live?.Article ?? local?.Article ?? string.Empty, ApiCatalogName = live?.Brand ?? local?.
 01961                ApiItemName = live?.ProductName ?? local?.ProductName ?? live?.Article ?? local?.Article ?? string.Empty
 01962                ApiPrice = live?.Price ?? local?.SourcePrice, ApiQuantity = live?.Quantity ?? local?.Quantity,
 01963                ApiRegion = string.IsNullOrWhiteSpace(location) ? seller ?? "—" : $"{seller} · {location}",
 01964                ApiPeriod = live?.DeliveryDays ?? local?.DeliveryDays, ApiSupplierName = local?.Status ?? "ApiOnly",
 01965                ApiComment = local?.LastError ?? string.Empty, AddedAt = live?.CreatedAt ?? local?.CreatedAtUtc,
 01966                StudioOrderId = local?.StudioOrderId, StudioOrderNumber = order?.ShipmentNumber, StudioProductName = ord
 01967                StudioArticle = order?.Article, StudioManufacturer = order?.Manufacturer?.Name, StudioProductImageUrl = 
 01968                StudioClientName = order?.OzonClient?.Name, ZZapCodeOrder = live?.CodeOrder ?? local?.CodeOrder,
 01969                ZZapInApiCart = apiCart is null ? null : live is not null, ZZapApiPrice = live?.Price, ZZapApiDeliveryTe
 01970            };
 1971        }
 01972        var rows = new List<SupplierCartRow>();
 01973        if (apiCart is not null)
 1974            rows.AddRange(apiCart.Values.OrderByDescending(x => x.CreatedAt).Select(line => ToRow(line, mirrorByCode.Get
 01975        rows.AddRange(items.Where(x => apiCart is null || !apiCart.ContainsKey(x.CodeOrder)).Select(local => ToRow(null,
 1976
 01977        string? ordersError = null;
 01978        var orderRows = new List<ZZapApiOrderRow>();
 1979        try
 1980        {
 1981            var localByCode = ordered.ToDictionary(x => x.CodeOrder);
 01982            var apiOrders = await _zzapCart.GetApiOrdersAsync(ct: ct);
 01983            var seen = new HashSet<long>();
 01984            foreach (var line in apiOrders)
 1985            {
 01986                seen.Add(line.CodeOrder);
 01987                localByCode.TryGetValue(line.CodeOrder, out var local);
 01988                orderRows.Add(new ZZapApiOrderRow(line.CodeOrder, line.CreatedAt, line.Article, line.Brand, line.Product
 01989                    line.SellerPhone, line.Price, line.QuantityOrdered, line.StatusCode, line.StatusName ?? "—", line.St
 01990                    local?.StudioOrderId, local?.StudioOrder?.ShipmentNumber, local?.StudioOrder?.OzonClient?.Name));
 1991            }
 1992            // Заказы Studio2, которых уже нет на первых страницах API, показываем из зеркала — без живого статуса.
 1993            orderRows.AddRange(ordered.Where(x => !seen.Contains(x.CodeOrder)).Select(x => new ZZapApiOrderRow(x.CodeOrd
 1994                x.ProductName, x.SellerName, null, x.SourcePrice, x.Quantity.ToString(CultureInfo.InvariantCulture), x.E
 1995                x.ExternalStatusName ?? "Заказан у поставщика", null, null, x.StudioOrderId, x.StudioOrder?.ShipmentNumb
 01996        }
 01997        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 1998        {
 01999            ordersError = ex.Message;
 2000            orderRows = ordered.Select(x => new ZZapApiOrderRow(x.CodeOrder, x.OrderedAtUtc, x.Article, x.Brand, x.Produ
 2001                x.SourcePrice, x.Quantity.ToString(CultureInfo.InvariantCulture), x.ExternalStatusCode, x.ExternalStatus
 2002                null, null, x.StudioOrderId, x.StudioOrder?.ShipmentNumber, x.StudioOrder?.OzonClient?.Name)).ToList();
 02003        }
 2004
 02005        return new SupplierCartSection
 02006        {
 02007            ProviderKey = ApiSupplierSettings.ZZapMoscowProviderKey, DisplayName = ZZapCartService.DisplayName, LogoUrl 
 02008            CurrencyCode = "RUB", Rows = rows, ZZapOrders = orderRows, Error = error, OrdersError = ordersError
 02009        };
 02010    }
 2011
 2012    private async Task<SupplierCartSection> BuildMotexSectionAsync(DateOnly from, DateOnly to, CancellationToken ct)
 2013    {
 02014        var items = await _motexCart.GetActiveAsync(ct);
 02015        var localOrders = await _motexCart.GetOrdersAsync(ct);
 02016        var apiOrderRows = new List<MotexApiOrderRow>();
 02017        string? ordersError = null;
 2018        try
 2019        {
 02020            var apiOrders = await _motex.GetOrdersByDateRangeAsync(from.ToDateTime(TimeOnly.MinValue).ToString("yyyyMMdd
 02021            foreach (var apiOrder in (apiOrders["orders"] as JArray ?? new JArray()).OfType<JObject>())
 2022            {
 02023                var externalId = apiOrder.Value<string>("id");
 02024                var local = localOrders.FirstOrDefault(x => string.Equals(x.ExternalOrderId, externalId, StringCompariso
 02025                var status = apiOrder.Value<string>("status") ?? local?.Status ?? "—";
 02026                if (local is not null) local.Status = status;
 02027                var apiItems = apiOrder["orderItems"] as JArray;
 2028                var amount = apiItems?.OfType<JObject>().Sum(item => (ReadDecimal(item, "price") ?? 0m) * (ReadDecimal(i
 02029                apiOrderRows.Add(new MotexApiOrderRow(local?.Id ?? 0, externalId, apiOrder.Value<string>("clientOrderNum
 02030                    status, ParseMotexDate(apiOrder.Value<string>("createdAt")) ?? local?.CreatedAtUtc ?? DateTime.UtcNo
 2031            }
 02032            await _context.SaveChangesAsync(ct);
 02033        }
 02034        catch (Exception ex) when (ex is MotexApiException or HttpRequestException) { ordersError = ex.Message; }
 02035        if (apiOrderRows.Count == 0)
 2036            apiOrderRows = localOrders.Select(x => new MotexApiOrderRow(x.Id, x.ExternalOrderId, x.ClientOrderNum, x.Sta
 02037        return new SupplierCartSection
 02038        {
 02039            ProviderKey = ApiSupplierSettings.MotexProviderKey, DisplayName = "МоТехС", LogoUrl = "/images/motex-logo.pn
 2040            Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.StoreCode ?? string.Emp
 02041            MotexOrders = apiOrderRows
 02042        };
 02043    }
 2044
 2045    private async Task<int> GetMotexOrderCountAsync(CancellationToken ct)
 2046    {
 2047        try
 2048        {
 02049            var today = DateOnly.FromDateTime(DateTime.Today);
 02050            var response = await _motex.GetOrdersByDateRangeAsync(
 02051                today.AddDays(-30).ToDateTime(TimeOnly.MinValue).ToString("yyyyMMddHHmmss"),
 02052                today.ToDateTime(TimeOnly.MaxValue).ToString("yyyyMMddHHmmss"), null, 1, 1, ct);
 02053            return response.Value<int?>("totalRecords") ?? 0;
 2054        }
 02055        catch (Exception ex) when (ex is MotexApiException or HttpRequestException)
 2056        {
 02057            return await _context.MotexOrders.CountAsync(ct);
 2058        }
 02059    }
 2060
 2061    private async Task<SupplierCartSection> BuildShateMSectionAsync(CancellationToken ct)
 2062    {
 02063        var localItems = await _shateMCart.GetActiveAsync(ct);
 02064        var localOrders = await _shateMCart.GetOrdersAsync(ct);
 2065        var rows = localItems.Select(x => new SupplierCartRow
 2066        {
 2067            LocalCartItemId = x.Id, ApiItemId = x.ExternalBasketItemId ?? x.PriceId, ApiItemNumber = x.Article,
 2068            ApiCatalogName = x.Brand, ApiItemName = x.ProductName ?? x.Article, ApiPrice = x.SourcePrice,
 2069            ApiQuantity = x.Quantity, ApiRegion = x.LocationCode ?? x.Direction ?? string.Empty,
 2070            ApiSupplierName = x.Status, AddedAt = x.CreatedAtUtc, StudioOrderId = x.StudioOrderId,
 2071            StudioOrderNumber = x.StudioOrder?.ShipmentNumber, StudioProductName = x.StudioOrder?.ProductName,
 2072            StudioArticle = x.StudioOrder?.Article, StudioManufacturer = x.StudioOrder?.Manufacturer?.Name,
 2073            StudioClientName = x.StudioOrder?.OzonClient?.Name
 2074        }).ToList();
 2075        var orders = localOrders.Select(x => new ShateMApiOrderRow(x.Id, x.ExternalOrderId, x.Status, x.CreatedAtUtc,
 2076            x.Items.Count, x.Items.Sum(i => (i.Price ?? 0m) * i.Quantity))).ToList();
 02077        string? error = null;
 2078        try
 2079        {
 02080            var cartResponse = await _shateM.GetAsync("/api/v1/cartitems", null, ct);
 02081            foreach (var item in (cartResponse as JArray ?? new JArray()).OfType<JObject>())
 2082            {
 02083                var externalId = item.Value<string>("id") ?? string.Empty;
 02084                if (rows.Any(x => string.Equals(x.ApiItemId, externalId, StringComparison.Ordinal))) continue;
 02085                var article = item["article"] as JObject;
 02086                rows.Add(new SupplierCartRow
 02087                {
 02088                    ApiItemId = externalId, ApiItemNumber = article?.Value<string>("code") ?? string.Empty,
 02089                    ApiCatalogName = article?.Value<string>("tradeMarkName") ?? string.Empty,
 02090                    ApiItemName = article?.Value<string>("name") ?? string.Empty,
 02091                    ApiPrice = ReadDecimal(item, "price", "price.value"), ApiQuantity = ReadDecimal(item, "quantity"),
 02092                    ApiRegion = item.Value<string>("locationCode") ?? string.Empty,
 02093                    ApiSupplierName = "Pending", AddedAt = item.Value<DateTime?>("createDateTime")
 02094                });
 2095            }
 2096
 02097            var ordersResponse = await _shateM.GetAsync("/api/v1/orders", new Dictionary<string, string?>
 02098            {
 02099                ["page"] = "1", ["pageSize"] = "100"
 02100            }, ct);
 02101            var statusResponse = await _shateM.GetAsync("/api/v1/orderItemStatusCodes", null, ct);
 02102            var statusNames = (statusResponse as JArray ?? new JArray()).OfType<JObject>()
 2103                .Where(x => !string.IsNullOrWhiteSpace(x.Value<string>("code")))
 2104                .ToDictionary(x => x.Value<string>("code")!, x => x.Value<string>("name") ?? x.Value<string>("descriptio
 02105            orders = (ordersResponse as JArray ?? new JArray()).OfType<JObject>().Select(order =>
 02106            {
 2107                var id = order.Value<string>("id") ?? string.Empty;
 2108                var local = localOrders.FirstOrDefault(x => string.Equals(x.ExternalOrderId, id, StringComparison.Ordina
 2109                var orderItems = order["orderItems"] as JArray ?? new JArray();
 2110                var status = orderItems.OfType<JObject>().Select(x => x.Value<string>("statusCode"))
 2111                    .Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => statusNames.GetValueOrDefault(x!, $"Статус AP
 2112                    .Distinct(StringComparer.Ordinal).ToList();
 2113                return new ShateMApiOrderRow(local?.Id ?? 0, id,
 2114                    status.Count == 0 ? local?.Status ?? "Создан" : string.Join(", ", status),
 2115                    order.Value<DateTime?>("createDateTime") ?? local?.CreatedAtUtc ?? DateTime.UtcNow,
 2116                    orderItems.Count, ReadDecimal(order, "totalSum") ?? 0m);
 2117            }).OrderByDescending(x => x.CreatedAt).ToList();
 02118        }
 02119        catch (Exception ex) when (ex is ShateMApiException or HttpRequestException or TaskCanceledException)
 2120        {
 02121            error = ex.Message;
 02122        }
 02123        return new SupplierCartSection
 02124        {
 02125            ProviderKey = ApiSupplierSettings.ShateMProviderKey, DisplayName = "Шате‑М", LogoUrl = "/images/shate-m-logo
 02126            Rows = rows, ShateMOrders = orders, OrdersError = error
 02127        };
 02128    }
 2129
 2130    private static decimal? ReadDecimal(JObject source, params string[] fields)
 2131    {
 02132        var raw = fields.Select(field => source.Value<string>(field)).FirstOrDefault(value => !string.IsNullOrWhiteSpace
 02133        return decimal.TryParse(raw?.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ?
 2134    }
 2135
 2136    /// <summary>Motex отдаёт createdAt уже в местном времени (Минск, UTC+3), а не в UTC — AssumeLocal без сдвига значен
 2137    /// чтобы .ToLocalTime() в представлении не прибавлял смещение повторно.</summary>
 2138    private static DateTime? ParseMotexDate(string? value) =>
 02139        DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out va
 2140
 2141    /// <summary>Активные заказы одним запросом (active=1&amp;rowsorder=1) + точечная догрузка локально
 2142    /// известных заказов, выпавших из активного списка (закрыты/отменены) — по образцу Forum-Auto.</summary>
 2143    private async Task<(IReadOnlyList<FavoritPartsApiOrderRow> Orders, string? Error)> GetFavoritPartsApiOrdersAsync(Fav
 2144    {
 02145        var rows = new Dictionary<Guid, FavoritPartsApiOrderRow>();
 02146        string? error = null;
 2147        try
 2148        {
 02149            var response = await cartService.GetApiOrdersAsync(active: true, rowsOrder: true, ct);
 02150            foreach (var order in (response as JArray ?? []).OfType<JObject>())
 2151            {
 02152                var row = ToFavoritPartsApiOrderRow(order);
 02153                if (row is not null) rows[row.Id] = row;
 2154            }
 02155        }
 02156        catch (FavoritPartsApiException ex)
 2157        {
 02158            error = ex.Message;
 02159        }
 2160
 02161        var localExternalIds = await _context.FavoritPartsOrders.AsNoTracking()
 02162            .Where(x => x.ProviderKey == providerKey && x.ExternalOrderId != null).Select(x => x.ExternalOrderId!.Value)
 02163            .Distinct().ToListAsync(ct);
 02164        var missing = localExternalIds.Where(id => !rows.ContainsKey(id)).ToList();
 02165        if (missing.Count > 0)
 2166        {
 02167            var gate = new SemaphoreSlim(4);
 02168            var tasks = missing.Select(async id =>
 02169            {
 02170                await gate.WaitAsync(ct);
 02171                try { return (Id: id, Order: await cartService.GetSingleApiOrderAsync(id, ct) as JObject, Error: (string
 02172                catch (FavoritPartsApiException ex) { return (Id: id, Order: (JObject?)null, Error: ex.Message); }
 02173                finally { gate.Release(); }
 02174            });
 02175            foreach (var (id, order, err) in await Task.WhenAll(tasks))
 2176            {
 02177                if (order is not null)
 2178                {
 02179                    var row = ToFavoritPartsApiOrderRow(order);
 02180                    if (row is not null) rows[row.Id] = row;
 2181                }
 02182                else if (err is not null)
 2183                {
 02184                    rows[id] = new FavoritPartsApiOrderRow(id, null, null, null, "Неизвестно", string.Empty, 0, Array.Em
 2185                }
 2186            }
 02187        }
 2188
 2189        return (rows.Values.OrderByDescending(x => x.CreatedAt).ToList(), error);
 02190    }
 2191
 2192    private static FavoritPartsApiOrderRow? ToFavoritPartsApiOrderRow(JObject order)
 2193    {
 02194        if (!Guid.TryParse(order.Value<string>("id"), out var id)) return null;
 02195        var items = (order["goodsList"] as JArray ?? new JArray()).OfType<JObject>().Select(item =>
 02196        {
 02197            var goods = item["goods"] as JObject;
 02198            return new FavoritPartsApiOrderItemRow(
 02199                goods?.Value<string>("brand") ?? string.Empty,
 02200                goods?.Value<string>("number") ?? string.Empty,
 02201                item.Value<decimal?>("count") ?? 0,
 02202                item.Value<decimal?>("price"),
 02203                StudioOrderCommentLink.ExtractOrderId(item.Value<string>("comment")),
 02204                item.Value<decimal?>("countCancel") ?? 0);
 02205        }).ToList();
 2206
 02207        return new FavoritPartsApiOrderRow(
 02208            id,
 02209            order.Value<string>("number"),
 02210            order.Value<DateTime?>("date"),
 02211            FavoritPartsPriceMapper.ToMoscowDate(FavoritPartsPriceMapper.ReadOffset(order, "shippingDate"))?.ToDateTime(
 02212            (order["status"] as JObject)?.Value<string>("name") ?? "Неизвестно",
 02213            (order["tradePoint"] as JObject)?.Value<string>("name") ?? string.Empty,
 02214            order.Value<decimal?>("sum") ?? 0,
 02215            items,
 02216            null,
 02217            order.Value<string>("postError"));
 2218    }
 2219
 2220    /// <summary>
 2221    /// listOrders без did отдаёт только активные (не выданные/не аннулированные) заказы — FaultCode 26 значит "пусто", 
 2222    /// Выданные/аннулированные заказы выпадают из этого списка, поэтому для каждого локально известного Did,
 2223    /// не попавшего в активный список, дополнительно запрашиваем его статус точечно по did.
 2224    /// </summary>
 2225    private async Task<(IReadOnlyList<ForumAutoApiOrderRow> Orders, string? Error)> GetForumAutoApiOrdersAsync(
 2226        string providerKey, Services.ApiServices.ForumAutoApi.ForumAutoDataManager client,
 2227        IReadOnlyList<OzonDomains.Models.ForumAutoOrder> localOrders, CancellationToken ct)
 2228    {
 02229        var rows = new Dictionary<int, ForumAutoApiOrderRow>();
 02230        string? generalError = null;
 2231        try
 2232        {
 02233            var response = await _cache.GetOrCreateAsync($"supplier-cart:forumauto-orders:{providerKey}", async entry =>
 02234            {
 02235                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
 02236                return await client.ListOrdersAsync(ct: ct);
 02237            });
 02238            foreach (var row in (response as JArray ?? []).OfType<JObject>().Select(ToForumAutoApiOrderRow))
 02239                rows[row.Did] = row;
 02240        }
 02241        catch (Services.ApiServices.ForumAutoApi.ForumAutoApiException ex) when (ex.FaultCode == 26)
 2242        {
 2243            // Активных заказов нет — нормальное пустое состояние.
 02244        }
 02245        catch (Services.ApiServices.ForumAutoApi.ForumAutoApiException ex)
 2246        {
 02247            generalError = ex.Message;
 02248        }
 2249
 02250        var missingDids = localOrders.Where(o => o.Did.HasValue && !rows.ContainsKey(o.Did.Value))
 2251            .Select(o => o.Did!.Value).Distinct();
 02252        foreach (var did in missingDids)
 2253        {
 2254            try
 2255            {
 02256                var response = await client.ListOrdersAsync(did, ct);
 02257                var order = (response as JArray)?.OfType<JObject>().FirstOrDefault() ?? response as JObject;
 02258                if (order is not null) rows[did] = ToForumAutoApiOrderRow(order);
 02259            }
 02260            catch (Services.ApiServices.ForumAutoApi.ForumAutoApiException ex)
 2261            {
 02262                rows[did] = new ForumAutoApiOrderRow(did, null, "Неизвестно", 0, null, Array.Empty<ForumAutoApiOrderItem
 02263            }
 2264        }
 2265        return (rows.Values.OrderByDescending(x => x.CreatedAt ?? DateTime.MinValue).ToList(), generalError);
 02266    }
 2267
 2268    private static ForumAutoApiOrderRow ToForumAutoApiOrderRow(JObject order)
 2269    {
 02270        var items = (order["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>())
 02271            .Select(x => new ForumAutoApiOrderItemRow(
 02272                x.Value<string>("gid") ?? string.Empty, x.Value<string>("nr") ?? string.Empty, x.Value<string>("brand") 
 02273                x.Value<decimal?>("k1") ?? 0, x.Value<decimal?>("k2") ?? 0, x.Value<decimal?>("k3") ?? 0,
 02274                x.Value<decimal?>("price"), x.Value<string>("gname")))
 02275            .ToList();
 02276        return new ForumAutoApiOrderRow(
 02277            order.Value<int?>("did") ?? 0,
 02278            ParseForumAutoDate(order.Value<string>("dtc")),
 02279            order.Value<string>("status") ?? string.Empty,
 02280            order.Value<int?>("statusId") ?? 0,
 02281            order.Value<string>("mdt"),
 02282            items,
 02283            null);
 2284    }
 2285
 2286    private static DateTime? ParseForumAutoDate(string? value) =>
 02287        DateTime.TryParseExact(value, "dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var 
 02288        : DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback) ? fallback : nul
 2289
 2290    private async Task<IReadOnlyDictionary<string, IReadOnlyList<SupplierCartStudioOrderLink>>> LoadStudioOrderLinksAsyn
 2291        IEnumerable<(string SupplierOrderNumber, int StudioOrderId)> source, CancellationToken ct)
 2292    {
 02293        var pairs = source
 2294            .Where(x => !string.IsNullOrWhiteSpace(x.SupplierOrderNumber) && x.StudioOrderId > 0)
 02295            .Distinct()
 02296            .ToArray();
 02297        if (pairs.Length == 0)
 02298            return new Dictionary<string, IReadOnlyList<SupplierCartStudioOrderLink>>(StringComparer.Ordinal);
 2299
 2300        var studioOrderIds = pairs.Select(x => x.StudioOrderId).Distinct().ToArray();
 02301        var studioOrders = await _context.Orders.AsNoTracking()
 02302            .Where(order => studioOrderIds.Contains(order.Id))
 02303            .Select(order => new SupplierCartStudioOrderLink(order.Id, order.ShipmentNumber, order.OzonClient!.Name))
 2304            .ToDictionaryAsync(order => order.OrderId, ct);
 2305
 02306        return pairs
 02307            .Where(pair => studioOrders.ContainsKey(pair.StudioOrderId))
 2308            .GroupBy(pair => pair.SupplierOrderNumber, StringComparer.Ordinal)
 02309            .ToDictionary(
 2310                group => group.Key,
 02311                group => (IReadOnlyList<SupplierCartStudioOrderLink>)group
 02312                    .Select(pair => studioOrders[pair.StudioOrderId])
 2313                    .DistinctBy(order => order.OrderId)
 2314                    .OrderBy(order => order.ShipmentNumber, StringComparer.Ordinal)
 02315                    .ToArray(),
 02316                StringComparer.Ordinal);
 02317    }
 2318
 2319    private async Task<IReadOnlyList<MlAutoApiOrderRow>> GetMlAutoRuApiOrdersAsync(CancellationToken ct)
 2320    {
 02321        var summaries = await _cache.GetOrCreateAsync("supplier-cart:mlautoru-orders", async entry =>
 02322        {
 02323            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
 02324            return await _mlAutoRu.GetOrderSummariesAsync(ct);
 02325        }) ?? [];
 02326        using var limiter = new SemaphoreSlim(MlAutoDetailsConcurrency);
 02327        return await Task.WhenAll(summaries.Take(MlAutoMaxDetailedOrders).Select(async summary =>
 02328        {
 02329            await limiter.WaitAsync(ct);
 02330            try
 02331            {
 02332                var items = await _cache.GetOrCreateAsync($"supplier-cart:mlautoru-order:{summary.Number}", async entry 
 02333                {
 02334                    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
 02335                    return await _mlAutoRu.GetOrderItemsAsync(summary.Number, ct);
 02336                }) ?? [];
 02337                return ToMlAutoApiOrderRow(summary, items, null);
 02338            }
 02339            catch (MlAutoApiException ex) { return ToMlAutoApiOrderRow(summary, [], ex.Message); }
 02340            finally { limiter.Release(); }
 02341        }));
 02342    }
 2343
 2344    /// <summary>
 2345    /// Заказы ML-Auto за период. GetMyOrders отдаёт только номер и дату, поэтому состав каждого заказа
 2346    /// дочитывается через GetOrderByNumber — оба запроса read-only и заказ не изменяют.
 2347    /// </summary>
 2348    private async Task<IReadOnlyList<MlAutoApiOrderRow>> GetMlAutoApiOrdersAsync(DateOnly createdFrom, DateOnly createdT
 2349    {
 02350        var summaries = await _cache.GetOrCreateAsync("supplier-cart:mlauto-orders", async entry =>
 02351        {
 02352            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
 02353            return await _mlAuto.GetOrderSummariesAsync(ct);
 02354        }) ?? [];
 2355
 02356        var from = createdFrom.ToDateTime(TimeOnly.MinValue);
 02357        var to = createdTo.ToDateTime(TimeOnly.MaxValue);
 02358        var selected = summaries
 02359            .Where(x => x.CreatedAt is null || (x.CreatedAt.Value >= from && x.CreatedAt.Value <= to))
 02360            .Take(MlAutoMaxDetailedOrders)
 02361            .ToArray();
 2362
 02363        using var limiter = new SemaphoreSlim(MlAutoDetailsConcurrency);
 02364        var rows = await Task.WhenAll(selected.Select(async summary =>
 02365        {
 02366            await limiter.WaitAsync(ct);
 02367            try { return await BuildMlAutoOrderRowAsync(summary, ct); }
 02368            finally { limiter.Release(); }
 02369        }));
 2370
 02371        return rows;
 02372    }
 2373
 2374    private async Task<MlAutoApiOrderRow> BuildMlAutoOrderRowAsync(MlAutoOrderSummary summary, CancellationToken ct)
 2375    {
 2376        try
 2377        {
 02378            var items = await _cache.GetOrCreateAsync($"supplier-cart:mlauto-order:{summary.Number}", async entry =>
 02379            {
 02380                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
 02381                return await _mlAuto.GetOrderItemsAsync(summary.Number, ct);
 02382            }) ?? [];
 02383            return ToMlAutoApiOrderRow(summary, items, null);
 2384        }
 02385        catch (MlAutoApiException ex)
 2386        {
 02387            return ToMlAutoApiOrderRow(summary, [], ex.Message);
 2388        }
 02389    }
 2390
 2391    private static MlAutoApiOrderRow ToMlAutoApiOrderRow(MlAutoOrderSummary summary, IReadOnlyList<MlAutoOrderItemInfo> 
 2392    {
 02393        var deliveryDates = items.Select(x => x.EstimatedDelivery).OfType<DateTime>().ToArray();
 02394        return new MlAutoApiOrderRow(
 02395            summary.Number,
 02396            summary.CreatedAt,
 02397            DescribeMlAutoOrderStatus(items),
 02398            DescribeMlAutoPayment(items),
 02399            items.Count,
 02400            items.Sum(x => x.Quantity),
 02401            items.Count == 0 ? null : items.Sum(x => x.Sum ?? (x.Price ?? 0m) * x.Quantity),
 02402            deliveryDates.Length == 0 ? null : deliveryDates.Min(),
 02403            deliveryDates.Length == 0 ? null : deliveryDates.Max(),
 02404            items.Select(x => x.OrderComment).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? string.Empty,
 02405            items.Select(x => new MlAutoApiOrderItemRow(
 02406                x.Pin, x.Brand, x.Quantity, x.Price, x.Sum ?? (x.Price ?? 0m) * x.Quantity,
 02407                DescribeMlAutoItemStatus(x), x.StatusCode, x.EstimatedDelivery,
 02408                string.IsNullOrWhiteSpace(x.ItemComment) ? x.OrderComment : x.ItemComment, x.Rejected)).ToArray(),
 02409            detailsError);
 2410    }
 2411
 2412    // Справочника числовых статусов ML-Auto нет, поэтому состояние выводится из флагов позиции.
 2413    private static string DescribeMlAutoItemStatus(MlAutoOrderItemInfo item) =>
 02414        item.Rejected ? "Отказ" : item.Shipped ? "Отгружен" : item.Confirmed ? "Подтверждён" : "В обработке";
 2415
 2416    private static string DescribeMlAutoOrderStatus(IReadOnlyList<MlAutoOrderItemInfo> items)
 2417    {
 02418        if (items.Count == 0) return "Нет данных";
 02419        if (items.All(x => x.Rejected)) return "Отказ";
 02420        var active = items.Where(x => !x.Rejected).ToArray();
 02421        if (active.All(x => x.Shipped)) return items.Any(x => x.Rejected) ? "Отгружен частично" : "Отгружен";
 02422        if (active.Any(x => x.Shipped)) return "Отгружен частично";
 02423        return active.All(x => x.Confirmed) ? "Подтверждён" : "В обработке";
 2424    }
 2425
 02426    private static string DescribeMlAutoPayment(IReadOnlyList<MlAutoOrderItemInfo> items) => items.Count switch
 02427    {
 02428        0 => "—",
 02429        _ when items.All(x => x.Cashless) => "Безнал",
 02430        _ when items.All(x => !x.Cashless) => "Наличные",
 02431        _ => "Смешанная"
 02432    };
 2433
 2434    /// <summary>Плоский список пар «покупатель + адрес» Armtek: шапка заказа задаётся именно ими.</summary>
 2435    private async Task<IReadOnlyList<ArmtekDeliveryOption>> GetArmtekDeliveryOptionsAsync(string providerKey, Cancellati
 2436    {
 02437        var key = $"supplier-cart:armtek-delivery-options:{providerKey}";
 02438        return await _cache.GetOrCreateAsync(key, async entry =>
 02439        {
 02440            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
 02441            var client = providerKey == "ArmtekBy" ? (ArmtekDataManager)_armtekBy : _armtek;
 02442            var userInfo = await client.GetUserInfoAsync(structure: true, ct: ct);
 02443            if (userInfo.HasErrors)
 2444                throw new ArmtekApiException(string.Join("; ", userInfo.Messages.Select(x => x.Text)));
 02445
 02446            return (userInfo.Resp?.Structure.RgTab ?? [])
 2447                .Where(buyer => !string.IsNullOrWhiteSpace(buyer.Kunnr))
 2448                .SelectMany(buyer => buyer.ZaTab
 2449                    .Where(address => !string.IsNullOrWhiteSpace(address.Kunnr))
 2450                    .Select(address => new ArmtekDeliveryOption(
 2451                        buyer.Kunnr,
 2452                        $"{buyer.Kunnr} — {FirstNotEmpty(buyer.FullName, buyer.ShortName, "Без названия")}",
 2453                        address.Kunnr,
 2454                        FirstNotEmpty(address.Address, address.ShortName, address.FullName, address.Kunnr))))
 02455                .ToArray();
 02456        }) ?? [];
 02457    }
 2458
 2459    private static string FirstNotEmpty(params string?[] values) =>
 02460        values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? string.Empty;
 2461
 2462    private async Task<IReadOnlyList<ArmtekApiOrderRow>> GetArmtekApiOrdersAsync(string providerKey, DateOnly createdFro
 2463    {
 02464        var key = $"supplier-cart:armtek-orders:{providerKey}:{createdFrom:yyyyMMdd}:{createdTo:yyyyMMdd}";
 02465        return await _cache.GetOrCreateAsync(key, async entry =>
 02466        {
 02467            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
 02468            var client = providerKey == "ArmtekBy" ? (ArmtekDataManager)_armtekBy : _armtek;
 02469            var userInfo = await client.GetUserInfoAsync(ct: ct);
 02470            if (userInfo.HasErrors)
 2471                throw new ArmtekApiException(string.Join("; ", userInfo.Messages.Select(x => x.Text)));
 02472
 02473            var buyers = (userInfo.Resp?.Structure.RgTab ?? [])
 2474                .Where(x => !string.IsNullOrWhiteSpace(x.Kunnr))
 2475                .GroupBy(x => x.Kunnr, StringComparer.Ordinal)
 2476                .Select(group => (Code: group.Key, Name: FirstNotEmpty(group.First().FullName, group.First().ShortName, 
 02477                .ToArray();
 02478            var reports = await Task.WhenAll(buyers.Select(async buyer =>
 02479            {
 02480                var response = await client.GetOrderReportByDateAsync(buyer.Code, createdFrom: createdFrom.ToDateTime(Ti
 02481                return (buyer, response);
 02482            }));
 02483
 02484            return reports
 2485                .Where(x => !x.response.HasErrors)
 2486                .SelectMany(x => (x.response.Resp?.Data ?? []).Select(order => ToArmtekApiOrderRow(x.buyer.Code, x.buyer
 2487                .GroupBy(x => x.OrderNumber, StringComparer.Ordinal)
 2488                .Select(x => x.First())
 2489                .OrderByDescending(x => x.OrderDate, StringComparer.Ordinal)
 2490                .ThenByDescending(x => x.OrderNumber, StringComparer.Ordinal)
 02491                .ToArray();
 02492        }) ?? [];
 02493    }
 2494
 02495    private static ArmtekApiOrderRow ToArmtekApiOrderRow(string buyerCode, string buyerName, ArmtekOrderReportItem order
 02496        buyerCode, buyerName, order.Order, order.OrderDate, order.OrderType, order.OrderStatus,
 02497        decimal.TryParse(order.OrderSum, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.Invaria
 02498        order.Currency, order.DeliveryFrom, order.DeliveryTo, order.IncotermsText, order.Invoice, order.ContractInfo,
 02499        order.CreditStatus, order.MinSumStatus, order.Comment, order.ExpeditionComment);
 2500
 02501    private static SupplierCartSection BuildArmtekSection(string providerKey, string displayName, string logoUrl, IReadO
 02502    {
 02503        ProviderKey = providerKey,
 02504        DisplayName = displayName,
 02505        LogoUrl = logoUrl,
 02506        CurrencyCode = items.Select(x => x.CurrencyCode).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "RUB",
 02507        ArmtekOrders = orders,
 02508        Rows = items.Select(x => new SupplierCartRow
 02509        {
 02510            LocalCartItemId = x.Id,
 02511            ApiItemId = x.ArtId, ApiCatalogName = x.Brand, ApiItemName = x.ProductName ?? x.Pin, ApiItemNumber = x.Pin,
 02512            ApiPrice = x.SourcePrice, ApiQuantity = x.Quantity, ApiRegion = x.WarehouseName ?? x.Keyzak ?? string.Empty,
 02513            ApiPeriod = x.DeliveryAtUtc.HasValue ? Math.Max(0, (int)Math.Ceiling((x.DeliveryAtUtc.Value - DateTime.UtcNo
 02514            AddedAt = x.CreatedAtUtc.ToLocalTime(),
 02515            ApiSupplierName = x.Status,
 02516            ApiComment = BuildArmtekCartComment(x),
 02517            StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrder?.ShipmentNumber,
 02518            StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article,
 02519            StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductInfo?.
 02520            StudioClientName = x.StudioOrder?.OzonClient?.Name,
 02521            ArmtekBuyerKunnr = x.BuyerKunnr, ArmtekDeliveryAddressKunnr = x.DeliveryAddressKunnr,
 02522            ArmtekDeliveryAddressName = x.DeliveryAddressName, ArmtekDeliveryMappingLinked = isDeliveryMappingLinked(x)
 02523        }).ToList()
 02524    };
 2525
 2526    private static string BuildArmtekCartComment(ArmtekCartItem item)
 2527    {
 02528        if (string.IsNullOrWhiteSpace(item.LastError)) return string.Empty;
 02529        return item.Status == ArmtekCartItemStatus.Failed && !item.LastError.Contains("для Арт.", StringComparison.Ordin
 02530            ? $"{item.LastError} для Арт. {item.Pin} · {item.Brand}."
 02531            : item.LastError;
 2532    }
 2533
 2534    internal static bool IsAllowedProductImageUrl(string? url) =>
 72535        TryCreateProductImageUri(url, out _);
 2536
 2537    private static bool TryCreateProductImageUri(string? url, out Uri? uri)
 2538    {
 72539        uri = null;
 72540        if (string.IsNullOrWhiteSpace(url) ||
 72541            !Uri.TryCreate(url, UriKind.Absolute, out var parsedUri) ||
 72542            parsedUri is null ||
 72543            (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps) ||
 72544            !IsAllowedProductImageHost(parsedUri.Host))
 52545            return false;
 2546
 22547        uri = parsedUri;
 22548        return true;
 2549    }
 2550
 2551    private static bool IsAllowedProductImageHost(string host) =>
 32552        host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) ||
 32553        host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) ||
 32554        host.EndsWith(".ozonusercontent.com", StringComparison.OrdinalIgnoreCase);
 2555}

Methods/Properties

.cctor()
get_UserName()
get_Provider()
.ctor(Services.ApiServices.AvdApi.AvdDataManager,Services.ApiServices.JapartsApi.JapartsDataManager,Services.ApiServices.BergApi.BergDataManager,OzonOrdersWeb.TransactionServices.BergCartService,OzonRepositories.Context.ApplicationDbContext,System.Net.Http.IHttpClientFactory,Microsoft.Extensions.Caching.Memory.IMemoryCache,OzonOrdersWeb.TransactionServices.ArmtekCartService,Services.ApiServices.ArmtekApi.ArmtekDataManager,Services.ApiServices.ArmtekApi.ArmtekByDataManager,OzonOrdersWeb.TransactionServices.MlAutoCartService,Services.ApiServices.MlAutoApi.MlAutoDataManager,OzonOrdersWeb.TransactionServices.MlAutoRuCartService,Services.ApiServices.MlAutoApi.MlAutoRuDataManager,OzonOrdersWeb.TransactionServices.ForumAutoCartService,Services.ApiServices.ForumAutoApi.ForumAutoDataManager,Services.ApiServices.ForumAutoApi.ForumAutoInterpartsDataManager,Services.ApiServices.ForumAutoApi.ForumAutoPiterDataManager,Services.ApiServices.ForumAutoApi.ForumAutoRostovDataManager,Services.ApiServices.ForumAutoApi.ForumAutoIstraDataManager,OzonOrdersWeb.TransactionServices.FavoritPartsCartService,OzonOrdersWeb.TransactionServices.FavoritPartsIstraCartService,OzonOrdersWeb.TransactionServices.FavoritPartsRostovCartService,OzonOrdersWeb.TransactionServices.MoskvorechieIstraCartService,OzonOrdersWeb.TransactionServices.MotexCartService,OzonOrdersWeb.TransactionServices.ProfitLigaCartService,OzonOrdersWeb.TransactionServices.NikeiCartService,OzonOrdersWeb.TransactionServices.ShateMCartService,OzonOrdersWeb.TransactionServices.MikadoCartService,OzonOrdersWeb.TransactionServices.ZZapCartService,Services.ApiServices.ShateMApi.ShateMDataManager,Services.ApiServices.MotexApi.MotexDataManager,Services.SupplierStatusServices.IOrderSupplierStateService,Microsoft.Extensions.Logging.ILogger`1<OzonOrdersWeb.Areas.Studio2.Controllers.SupplierCartController>)
IsForumAutoProvider(System.String)
ResolveForumAutoClient(System.String)
Index()
Grid()
Summary()
<Summary()
BuildSummaryAsync()
CountForumAutoCartAsync(System.String,System.Threading.CancellationToken)
CountForumAutoOrdersAsync(System.String,System.Threading.CancellationToken)
CountFavoritPartsCartAsync(System.String,System.Threading.CancellationToken)
CountFavoritPartsOrdersAsync(System.String,System.Threading.CancellationToken)
BuildProviderSectionAsync()
Count()
<Count()
TestMlAutoOrder()
CreateMlAutoOrder()
RemoveMlAutoCartItems()
UpdateMlAutoDeliveryAddress()
RequoteMlAutoCartItem()
ReplaceMlAutoCartItemOffer()
ImportMlAutoOrder()
MlAutoOrderDetails()
TestMlAutoRuOrder()
CreateMlAutoRuOrder()
RemoveMlAutoRuCartItems()
UpdateMlAutoRuDeliveryAddress()
TestForumAutoOrder()
CreateForumAutoOrder()
TestMoskvorechieIstraOrder()
CreateMoskvorechieIstraOrder()
RemoveMoskvorechieIstraCartItems()
TestProfitLigaOrder()
CreateProfitLigaOrder()
RemoveProfitLigaCartItems()
CreateNikeiOrder()
RemoveNikeiCartItems()
NikeiDeliveryOptions()
TestMikadoCartItems()
RemoveMikadoCartItems()
TestZZapMoscowOrder()
CreateZZapMoscowOrder()
CancelZZapMoscowOrder()
RemoveZZapMoscowCartItems()
ClearProfitLigaApiCart()
TestBergOrder()
CreateBergOrder()
RemoveBergCartItems()
CreateMotexOrder()
RemoveMotexCartItems()
ImportMoskvorechieIstraOrder()
RemoveForumAutoCartItems()
TestFavoritPartsOrder()
CreateFavoritPartsOrder()
RemoveFavoritPartsCartItems()
TestFavoritPartsIstraOrder()
CreateFavoritPartsIstraOrder()
RemoveFavoritPartsIstraCartItems()
TestFavoritPartsRostovOrder()
CreateFavoritPartsRostovOrder()
RemoveFavoritPartsRostovCartItems()
CreateAvdOrder()
RemoveAvdBasketItems()
RemoveArmtekCartItems()
TestArmtekOrder()
UpdateArmtekDeliveryAddress()
RequoteArmtekCartItem()
ReplaceArmtekCartItemOffer()
PrepareArmtekCheckout()
CreateArmtekOrder()
ArmtekCheckoutPreparationCacheKey(System.String)
NormalizeCartItemIds(System.Int64[])
SameCartItemIds(System.Int64[],System.Int64[])
RemoveShateMCartItems()
CheckoutShateMCartItems()
TryCreateShateMCheckoutOptions(System.String,System.String,System.String,System.String,System.Boolean,System.Boolean,OzonOrdersWeb.TransactionServices.ShateMCheckoutOptions&,System.String&)
ImageProxy()
IsArmtekProvider(System.String)
RefreshLocalCartStatusesAsync()
get_OrderId()
TryExtractOrderId(System.String,System.Int32&)
TrackAvdOrderedAsync()
TrackAvdRemovedAsync()
BuildJapartsSectionAsync()
BuildBergSectionAsync()
BuildAvdSectionAsync()
GetBasketItemSortKey(System.String)
BuildArmtekSectionAsync()
BuildMlAutoSectionAsync()
BuildMlAutoRuSectionAsync()
BuildForumAutoSectionAsync()
BuildFavoritPartsSectionAsync(System.Threading.CancellationToken)
BuildFavoritPartsSectionAsync()
BuildMoskvorechieIstraSectionAsync()
BuildProfitLigaSectionAsync()
BuildNikeiSectionAsync()
ParseNikeiDate(System.String)
BuildMikadoSectionAsync()
BuildZZapMoscowSectionAsync()
ToRow()
BuildMotexSectionAsync()
GetMotexOrderCountAsync()
BuildShateMSectionAsync()
ReadDecimal(Newtonsoft.Json.Linq.JObject,System.String[])
ParseMotexDate(System.String)
GetFavoritPartsApiOrdersAsync()
<GetFavoritPartsApiOrdersAsync()
ToFavoritPartsApiOrderRow(Newtonsoft.Json.Linq.JObject)
GetForumAutoApiOrdersAsync()
<GetForumAutoApiOrdersAsync()
ToForumAutoApiOrderRow(Newtonsoft.Json.Linq.JObject)
ParseForumAutoDate(System.String)
LoadStudioOrderLinksAsync()
GetMlAutoRuApiOrdersAsync()
<GetMlAutoRuApiOrdersAsync()
<GetMlAutoRuApiOrdersAsync()
GetMlAutoApiOrdersAsync()
<GetMlAutoApiOrdersAsync()
<GetMlAutoApiOrdersAsync()
BuildMlAutoOrderRowAsync()
<BuildMlAutoOrderRowAsync()
ToMlAutoApiOrderRow(Services.ApiServices.MlAutoApi.MlAutoOrderSummary,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.MlAutoApi.MlAutoOrderItemInfo>,System.String)
DescribeMlAutoItemStatus(Services.ApiServices.MlAutoApi.MlAutoOrderItemInfo)
DescribeMlAutoOrderStatus(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.MlAutoApi.MlAutoOrderItemInfo>)
DescribeMlAutoPayment(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.MlAutoApi.MlAutoOrderItemInfo>)
GetArmtekDeliveryOptionsAsync()
<GetArmtekDeliveryOptionsAsync()
FirstNotEmpty(System.String[])
GetArmtekApiOrdersAsync()
<GetArmtekApiOrdersAsync()
ToArmtekApiOrderRow(System.String,System.String,Services.ApiServices.ArmtekApi.Models.Response.ArmtekOrderReportItem)
BuildArmtekSection(System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<OzonDomains.Models.ArmtekCartItem>,System.Collections.Generic.IReadOnlyList`1<OzonDomains.Models.ArmtekOrder>,System.Func`2<OzonDomains.Models.ArmtekCartItem,System.Boolean>)
BuildArmtekCartComment(OzonDomains.Models.ArmtekCartItem)
IsAllowedProductImageUrl(System.String)
TryCreateProductImageUri(System.String,System.Uri&)
IsAllowedProductImageHost(System.String)