< Summary

Information
Class: Services.ApiServices.YandexApi.YandexDataManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/YandexApi/YandexDataManager.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 412
Coverable lines: 412
Total lines: 854
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 330
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
SetClient(...)100%210%
SetClient(...)100%210%
get_LastError()0%620%
CreateApiClient(...)100%210%
GetApiKey()100%210%
GetTestRequest()0%342180%
GetAccountCampaigns()0%272160%
GetCampaignInfo()0%156120%
GetOrders()0%155001240%
<GetOrders()100%210%
<GetOrders()0%1482380%
GetUpdatedOrderIds()0%342180%
ToApiDate(...)100%210%
Chunk()0%620%
GetCampaignIdsByBusinessId()0%600240%
GetFirstMileShipmentsOrderIds()0%342180%
GetCampaignIdForOrderAsync()0%420200%
GetOrderLabelPdfAsync()0%156120%
WaitForRateLimitAsync()0%110100%
GetCampaignInfoById()0%620%
GetOfferMappings()0%7280%
GetTariffs()0%4260%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/YandexApi/YandexDataManager.cs

#LineLine coverage
 1using Newtonsoft.Json.Linq;
 2using OzonOrdersWeb.ViewModels.OzonClientViewModels;
 3using System.Collections.Concurrent;
 4using System.Collections.Generic;
 5using System.Linq;
 6using System;
 7using System.Net.Http;
 8using System.Threading;
 9using SlqStudio.Application.Services.EmailService;
 10
 11
 12namespace Services.ApiServices.YandexApi
 13{
 14    public class YandexDataManager : IApiDataManager<YandexDataManager>
 15    {
 16        private readonly IHttpClientFactory? _httpClientFactory;
 17        private readonly IEmailService? _emailService;
 18
 19        private YandexApiClient _apiClient = null!;
 20        private string _campaignId = null!;
 21        private string _apiKey = null!;
 22
 23        // campaignId заказа неизменен — держим его в памяти, чтобы повторная печать
 24        // (например в другом формате) не ходила за ним к API заново.
 025        private static readonly ConcurrentDictionary<(string BusinessId, long OrderId), long?> CampaignIdCache = new();
 26
 27        // Rate limiting для Yandex API: максимум 100 запросов в минуту (скользящее окно).
 028        private static readonly SemaphoreSlim _rateLimitGate = new(1, 1);
 029        private static readonly Queue<DateTime> _requestTimes = new Queue<DateTime>();
 30        private const int MaxRequestsPerMinute = 100;
 31
 32        // Partner API разрешает не более четырёх одновременных запросов на бизнес.
 33        // Оставляем один слот запасом для ручных операций и других эндпоинтов Маркета.
 34        // Это поле static: менеджер создаётся transient, а ограничение должно работать
 35        // между всеми его экземплярами внутри приложения.
 036        private static readonly SemaphoreSlim _tariffRequestsGate = new(3, 3);
 37
 38        // Предел фильтра по дате обновления в Partner API — 30 дней
 39        private const int MaxWindowDays = 29;
 40
 41        // limit у обоих эндпоинтов заказов ограничен 50
 42        private const int PageLimit = 50;
 43        private const int OrderIdsChunkSize = 50;
 44
 45        // Даты окна трактуем как московское время: джоб формирует их с поправкой ReleaseManager.GetTimeZone()
 46        private const string MoscowOffset = "+03:00";
 47
 048        public YandexDataManager(IHttpClientFactory? httpClientFactory = null, IEmailService? emailService = null)
 49        {
 050            _httpClientFactory = httpClientFactory;
 051            _emailService = emailService;
 052        }
 53
 54        public YandexDataManager SetClient(string apiKey)
 55        {
 056            _apiClient = CreateApiClient(apiKey);
 057            _apiKey = apiKey;
 058            return this;
 59        }
 60
 61        public YandexDataManager SetClient(string clientId, string apiKey)
 62        {
 063            _apiClient = CreateApiClient(apiKey);
 064            _apiClient.RequestContext = $"businessId={clientId}";
 065            _campaignId = clientId;
 066            _apiKey = apiKey;
 067            return this;
 68        }
 69
 70        /// <summary>
 71        /// Текст последней ошибки запроса или <c>null</c>, если последний вызов прошёл успешно.
 72        /// Нужен, чтобы отчёт о загрузке не рапортовал об успехе, когда Маркет отклонил ключ.
 73        /// </summary>
 074        public string? LastError => _apiClient?.LastError;
 75
 76        /// <summary>
 77        /// Создаёт клиент поверх named-клиента "YandexApi" из фабрики (Retry + Circuit Breaker).
 78        /// </summary>
 79        private YandexApiClient CreateApiClient(string apiKey) =>
 080            new YandexApiClient(_httpClientFactory, _emailService, apiKey);
 81
 82        public async Task<string> GetApiKey()
 83        {
 084            return await _apiClient.GetApiKey();
 085        }
 86
 87        public async Task<bool> GetTestRequest(string clientId, string apiKey)
 88        {
 89            // clientId теперь ожидается просто как businessId (например, "917786")
 090            if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(apiKey))
 91            {
 092                return false;
 93            }
 94
 95            // Проверяем, что clientId является числом (businessId)
 096            if (!long.TryParse(clientId, out var businessId))
 97            {
 098                return false;
 99            }
 100
 0101            var requestUri = string.Format(YandexApiUrl.BUSINESS_SETTINGS, businessId);
 102
 103            // Проверка может выполняться до SetClient — ключ приходит из формы клиента.
 0104            var apiClient = CreateApiClient(apiKey);
 105
 106            // Применяем rate limiting
 0107            await WaitForRateLimitAsync();
 108
 0109            var jObject = await apiClient.SendJsonAsync(HttpMethod.Post, requestUri, new { });
 0110            if (jObject == null)
 111            {
 0112                return false;
 113            }
 114
 115            // Проверяем, что ответ содержит статус "OK" и результат с информацией о бизнесе
 0116            var status = jObject["status"]?.ToString();
 0117            var result = jObject["result"] as JObject;
 0118            var info = result?["info"] as JObject;
 0119            var infoId = info?["id"];
 120
 0121            return status == "OK" && infoId != null && infoId.Value<long>() == businessId;
 0122        }
 123
 124        public async Task<List<YandexClient>> GetAccountCampaigns()
 125        {
 0126            await WaitForRateLimitAsync();
 0127            var campaignsResponse = await _apiClient.SendJsonAsync(HttpMethod.Get, YandexApiUrl.CAMPAIGNS);
 128
 0129            var campaignsList = campaignsResponse?["campaigns"] as JArray;
 0130            var yandexClients = new List<YandexClient>();
 0131            if (campaignsList == null)
 0132                return yandexClients;
 133
 0134            foreach (var campaign in campaignsList)
 135            {
 0136                var yandexClient = new YandexClient
 0137                {
 0138                    WarehouseName = campaign["domain"]?.ToString() ?? string.Empty,
 0139                    CompanyId = (int)campaign["id"]!,
 0140                    BusinessId = (int)campaign["business"]!["id"]!,
 0141                    BusinessName = campaign["business"]?["name"]?.ToString() ?? string.Empty
 0142                };
 0143                yandexClients.Add(yandexClient);
 144            }
 145
 0146            return yandexClients.OrderBy(ya => ya.BusinessName).ToList();
 0147        }
 148
 149        public async Task<YandexClient> GetCampaignInfo()
 150        {
 0151            await WaitForRateLimitAsync();
 0152            var campaignInfoResponse = await _apiClient.SendJsonAsync(
 0153                HttpMethod.Get,
 0154                string.Format(YandexApiUrl.CAMPAIGN, _campaignId)
 0155            );
 156
 0157            var campaign = campaignInfoResponse?["campaign"] as JObject;
 0158            if (campaign == null)
 0159                return new YandexClient();
 160
 0161            var yandexClient = new YandexClient
 0162            {
 0163                WarehouseName = campaign["domain"]?.ToString() ?? string.Empty,
 0164                CompanyId = (int)campaign["id"]!,
 0165                BusinessId = (int)campaign["business"]!["id"]!,
 0166                BusinessName = campaign["business"]?["name"]?.ToString()
 0167            };
 168
 0169            return yandexClient;
 0170        }
 171
 172
 173        /// <summary>
 174        /// Возвращает заказы бизнеса, <b>изменённые</b> в интервале [<paramref name="start"/>; <paramref name="end"/>].
 175        /// </summary>
 176        /// <remarks>
 177        /// Бизнес-эндпоинт <c>POST /v1/businesses/{id}/orders</c> фильтрует только по дате создания:
 178        /// отмены и смены статуса уже созданных заказов в выгрузку не попадали, и заказ старше окна
 179        /// навсегда оставался в системе со старым статусом. Фильтр по дате обновления есть только
 180        /// у <c>GET /campaigns/{id}/orders</c>, поэтому работаем в два шага: сначала собираем id
 181        /// изменённых заказов по всем кампаниям бизнеса, затем добираем полные карточки бизнес-эндпоинтом
 182        /// по <c>orderIds</c> — формат ответа и обогащение (кампания, offer-mappings, тарифы) не меняются.
 183        /// </remarks>
 184        public async Task<JArray> GetOrders(DateTime start, DateTime end)
 185        {
 186            // Ожидаем, что _campaignId содержит только businessId (например, "917786")
 0187            if (string.IsNullOrWhiteSpace(_campaignId))
 188            {
 0189                return new JArray();
 190            }
 191
 192            // Используем _campaignId напрямую как businessId
 0193            var businessId = _campaignId;
 194
 195            // Без SetClient запрашивать нечего
 0196            if (_apiClient == null)
 197            {
 0198                return new JArray();
 199            }
 200
 201            // При необходимости подтягиваем ApiKey из клиента
 0202            if (string.IsNullOrWhiteSpace(_apiKey))
 203            {
 0204                _apiKey = await _apiClient.GetApiKey();
 205            }
 206
 0207            if (string.IsNullOrWhiteSpace(_apiKey))
 208            {
 0209                return new JArray();
 210            }
 211
 212            // Ограничиваем окно до 29 дней: у фильтра по дате обновления предел 30 дней
 0213            var daysDifference = (end - start).Days;
 0214            if (daysDifference > MaxWindowDays)
 215            {
 0216                end = start.AddDays(MaxWindowDays);
 217            }
 218
 219            // Шаг 1: id заказов, изменённых в окне (по всем кампаниям бизнеса)
 0220            var campaignIds = await GetCampaignIdsByBusinessId(businessId);
 0221            if (campaignIds.Count == 0)
 222            {
 0223                return new JArray();
 224            }
 225
 0226            var updatedOrderIds = new HashSet<long>();
 0227            foreach (var campaignId in campaignIds)
 228            {
 0229                foreach (var orderId in await GetUpdatedOrderIds(campaignId, start, end))
 230                {
 0231                    updatedOrderIds.Add(orderId);
 232                }
 233            }
 234
 0235            if (updatedOrderIds.Count == 0)
 236            {
 0237                return new JArray();
 238            }
 239
 240            // Шаг 2: полные карточки этих заказов через бизнес-эндпоинт (формат, который ждёт OrderCaster)
 0241            var allOrders = new JArray();
 242
 0243            foreach (var chunk in Chunk(updatedOrderIds.ToList(), OrderIdsChunkSize))
 244            {
 0245                string? pageToken = null;
 246
 247                do
 248                {
 0249                    var requestUri = string.Format(YandexApiUrl.BUSINESS_ORDERS, businessId) + $"?limit={PageLimit}";
 0250                    if (!string.IsNullOrEmpty(pageToken))
 251                    {
 0252                        requestUri += $"&page_token={Uri.EscapeDataString(pageToken)}";
 253                    }
 254
 0255                    var bodyObject = new { orderIds = chunk };
 256
 0257                    await WaitForRateLimitAsync();
 258
 0259                    var response = await _apiClient.SendJsonAsync(HttpMethod.Post, requestUri, bodyObject);
 0260                    if (response == null)
 261                    {
 262                        // Ошибка уже залогирована клиентом; прерываем пагинацию с тем, что успели забрать.
 263                        break;
 264                    }
 265
 0266                    var ordersPage = response["orders"]?.ToObject<JArray>()
 0267                                     ?? response["result"]?["orders"]?.ToObject<JArray>();
 268
 0269                    if (ordersPage != null)
 270                    {
 0271                        foreach (var order in ordersPage)
 272                        {
 0273                            allOrders.Add(order);
 274                        }
 275                    }
 276
 0277                    pageToken = response["paging"]?["nextPageToken"]?.ToString();
 0278                } while (!string.IsNullOrEmpty(pageToken));
 0279            }
 280
 0281            JArray orders = allOrders;
 282
 0283            if (orders == null || orders.Count == 0)
 284            {
 0285                return orders ?? new JArray();
 286            }
 287
 288            // Собираем уникальные campaignId из заказов
 0289            var orderCampaignIds = new HashSet<long>();
 0290            foreach (var order in orders.OfType<JObject>())
 291            {
 0292                var campaignIdToken = order["campaignId"];
 0293                if (campaignIdToken != null && long.TryParse(campaignIdToken.ToString(), out var campaignId))
 294                {
 0295                    orderCampaignIds.Add(campaignId);
 296                }
 297            }
 298
 299            // Получаем информацию о кампаниях для всех уникальных campaignId параллельно
 0300            var campaignInfoCache = new Dictionary<long, JObject>();
 0301            var campaignTasks = orderCampaignIds.Select(async campaignId =>
 0302            {
 0303                try
 0304                {
 0305                    var campaignInfo = await GetCampaignInfoById(campaignId);
 0306                    return (CampaignId: campaignId, Info: (JObject?)campaignInfo);
 0307                }
 0308                catch
 0309                {
 0310                    return (CampaignId: campaignId, Info: (JObject?)null);
 0311                }
 0312            });
 313
 0314            var campaignResults = await Task.WhenAll(campaignTasks);
 0315            foreach (var result in campaignResults)
 316            {
 0317                if (result.Info != null)
 318                {
 0319                    campaignInfoCache[result.CampaignId] = result.Info;
 320                }
 321            }
 322
 323            // Добавляем информацию о кампании в каждый заказ
 0324            foreach (var order in orders.OfType<JObject>())
 325            {
 0326                var campaignIdToken = order["campaignId"];
 0327                if (campaignIdToken != null && long.TryParse(campaignIdToken.ToString(), out var campaignId))
 328                {
 0329                    if (campaignInfoCache.TryGetValue(campaignId, out var campaignInfo))
 330                    {
 331                        // Добавляем объект campaign в заказ
 0332                        order["campaign"] = campaignInfo["campaign"];
 333                    }
 334                }
 335            }
 336
 337            // Собираем уникальные offerId из всех items всех заказов
 0338            var offerIds = new HashSet<string>();
 0339            foreach (var order in orders.OfType<JObject>())
 340            {
 0341                var items = order["items"] as JArray;
 0342                if (items != null)
 343                {
 0344                    foreach (var item in items.OfType<JObject>())
 345                    {
 0346                        var offerIdToken = item["offerId"];
 0347                        if (offerIdToken != null && !string.IsNullOrWhiteSpace(offerIdToken.ToString()))
 348                        {
 0349                            offerIds.Add(offerIdToken.ToString());
 350                        }
 351                    }
 352                }
 353            }
 354
 355            // Получаем информацию о продуктах (offer-mappings)
 0356            var offerMappingsCache = new Dictionary<string, JToken>();
 0357            if (offerIds.Count > 0)
 358            {
 359                try
 360                {
 0361                    var offerMappings = await GetOfferMappings(businessId, offerIds.ToList());
 0362                    if (offerMappings != null)
 363                    {
 0364                        var result = offerMappings["result"] as JObject;
 0365                        if (result != null)
 366                        {
 0367                            var offerMappingEntries = result["offerMappings"] as JArray;
 0368                            if (offerMappingEntries != null)
 369                            {
 0370                                foreach (var entry in offerMappingEntries.OfType<JObject>())
 371                                {
 0372                                    var offerId = entry["offer"]?["offerId"]?.ToString();
 0373                                    if (!string.IsNullOrWhiteSpace(offerId))
 374                                    {
 0375                                        offerMappingsCache[offerId] = entry;
 376                                    }
 377                                }
 378                            }
 379                        }
 380                    }
 0381                }
 0382                catch
 383                {
 384                    // Игнорируем ошибки получения информации о продуктах
 0385                }
 386            }
 387
 388            // Добавляем информацию о продуктах в каждый item заказа
 0389            foreach (var order in orders.OfType<JObject>())
 390            {
 0391                var items = order["items"] as JArray;
 0392                if (items != null)
 393                {
 0394                    foreach (var item in items.OfType<JObject>())
 395                    {
 0396                        var offerIdToken = item["offerId"];
 0397                        if (offerIdToken != null && !string.IsNullOrWhiteSpace(offerIdToken.ToString()))
 398                        {
 0399                            var offerId = offerIdToken.ToString();
 0400                            if (offerMappingsCache.TryGetValue(offerId, out var offerMapping))
 401                            {
 402                                // Добавляем информацию о продукте в item
 0403                                item["offerMapping"] = offerMapping;
 404                            }
 405                        }
 406                    }
 407                }
 408            }
 409
 410            // Получаем информацию о тарифах (комиссиях) для каждого заказа
 0411            var tariffTasks = orders.OfType<JObject>().Select(async order =>
 0412            {
 0413                try
 0414                {
 0415                    var campaignIdToken = order["campaignId"];
 0416                    if (campaignIdToken == null || !long.TryParse(campaignIdToken.ToString(), out var campaignId))
 0417                    {
 0418                        return (Order: order, Tariffs: (JObject?)null, OfferItems: (List<JObject>?)null);
 0419                    }
 0420
 0421                    var items = order["items"] as JArray;
 0422                    if (items == null || items.Count == 0)
 0423                    {
 0424                        return (Order: order, Tariffs: (JObject?)null, OfferItems: (List<JObject>?)null);
 0425                    }
 0426
 0427                    // Формируем массив offers для запроса тарифов
 0428                    var offersList = new List<object>();
 0429                    var offerItems = new List<JObject>();
 0430                    foreach (var item in items.OfType<JObject>())
 0431                    {
 0432                        var offerMapping = item["offerMapping"] as JObject;
 0433                        if (offerMapping == null)
 0434                        {
 0435                            continue;
 0436                        }
 0437
 0438                        var mapping = offerMapping["mapping"] as JObject;
 0439                        var offer = offerMapping["offer"] as JObject;
 0440                        if (mapping == null || offer == null)
 0441                        {
 0442                            continue;
 0443                        }
 0444
 0445                        var categoryIdToken = mapping["marketCategoryId"];
 0446                        var basicPrice = offer["basicPrice"] as JObject;
 0447                        var weightDimensions = offer["weightDimensions"] as JObject;
 0448                        var countToken = item["count"];
 0449
 0450                        if (categoryIdToken == null || basicPrice == null || weightDimensions == null ||
 0451                            countToken == null)
 0452                        {
 0453                            continue;
 0454                        }
 0455
 0456                        var categoryId = categoryIdToken.Value<long>();
 0457                        var price = basicPrice["value"]?.Value<decimal>() ?? 0;
 0458                        var length = weightDimensions["length"]?.Value<decimal>() ?? 0;
 0459                        var width = weightDimensions["width"]?.Value<decimal>() ?? 0;
 0460                        var height = weightDimensions["height"]?.Value<decimal>() ?? 0;
 0461                        var weight = weightDimensions["weight"]?.Value<decimal>() ?? 0;
 0462                        var quantity = countToken.Value<int>();
 0463
 0464                        // Яндекс отклоняет quantity < 1 (позиции с нулевым количеством, например удалённые из заказа)
 0465                        if (quantity < 1)
 0466                        {
 0467                            continue;
 0468                        }
 0469
 0470                        offerItems.Add(item);
 0471                        offersList.Add(new
 0472                        {
 0473                            categoryId = categoryId,
 0474                            price = price,
 0475                            length = length,
 0476                            width = width,
 0477                            height = height,
 0478                            weight = weight,
 0479                            quantity = quantity
 0480                        });
 0481                    }
 0482
 0483                    if (offersList.Count == 0)
 0484                    {
 0485                        return (Order: order, Tariffs: (JObject?)null, OfferItems: (List<JObject>?)null);
 0486                    }
 0487
 0488                    // Запрашиваем тарифы
 0489                    var tariffs = await GetTariffs(campaignId, offersList);
 0490                    return (Order: order, Tariffs: (JObject?)tariffs, OfferItems: (List<JObject>?)offerItems);
 0491                }
 0492                catch
 0493                {
 0494                    return (Order: order, Tariffs: (JObject?)null, OfferItems: (List<JObject>?)null);
 0495                }
 0496            });
 497
 0498            var tariffResults = await Task.WhenAll(tariffTasks);
 499
 500            // Добавляем информацию о тарифах в каждый item заказа
 0501            foreach (var result in tariffResults)
 502            {
 0503                if (result.Tariffs == null)
 504                {
 505                    continue;
 506                }
 507
 508                var order = result.Order;
 0509                var items = result.OfferItems;
 0510                if (items == null)
 511                {
 512                    continue;
 513                }
 514
 0515                var resultOffers = result.Tariffs["result"]?["offers"] as JArray;
 0516                if (resultOffers == null)
 517                {
 518                    continue;
 519                }
 520
 521                // Сопоставляем тарифы с items по индексу (только позиции, ушедшие в запрос)
 0522                for (int i = 0; i < items.Count && i < resultOffers.Count; i++)
 523                {
 0524                    var item = items[i];
 0525                    var tariffOffer = resultOffers[i] as JObject;
 0526                    if (item != null && tariffOffer != null)
 527                    {
 0528                        var tariffs = tariffOffer["tariffs"] as JArray;
 0529                        if (tariffs != null)
 530                        {
 0531                            item["tariffs"] = tariffs;
 532                        }
 533                    }
 534                }
 535            }
 536
 0537            return orders;
 0538        }
 539
 540        /// <summary>
 541        /// Возвращает id заказов кампании, изменённых в интервале — единственный фильтр по дате
 542        /// обновления в Partner API (<c>updatedAtFrom</c>/<c>updatedAtTo</c>, ISO 8601 со смещением).
 543        /// </summary>
 544        private async Task<List<long>> GetUpdatedOrderIds(long campaignId, DateTime start, DateTime end)
 545        {
 0546            var orderIds = new List<long>();
 0547            var baseUrl = string.Format(YandexApiUrl.CAMPAIGN_ORDERS, campaignId);
 0548            var from = Uri.EscapeDataString(ToApiDate(start));
 0549            var to = Uri.EscapeDataString(ToApiDate(end));
 0550            string? pageToken = null;
 551
 552            do
 553            {
 0554                var requestUri = $"{baseUrl}?updatedAtFrom={from}&updatedAtTo={to}&limit={PageLimit}";
 0555                if (!string.IsNullOrEmpty(pageToken))
 556                {
 0557                    requestUri += $"&page_token={Uri.EscapeDataString(pageToken)}";
 558                }
 559
 0560                await WaitForRateLimitAsync();
 561
 0562                var response = await _apiClient.SendJsonAsync(HttpMethod.Get, requestUri);
 0563                if (response == null)
 564                {
 565                    break;
 566                }
 567
 0568                var ordersPage = response["orders"] as JArray;
 0569                if (ordersPage != null)
 570                {
 0571                    foreach (var order in ordersPage.OfType<JObject>())
 572                    {
 573                        // В ответе кампании идентификатор заказа лежит в "id", а не в "orderId"
 0574                        var id = order["id"]?.Value<long?>();
 0575                        if (id.HasValue)
 576                        {
 0577                            orderIds.Add(id.Value);
 578                        }
 579                    }
 580                }
 581
 0582                pageToken = response["paging"]?["nextPageToken"]?.ToString();
 0583            } while (!string.IsNullOrEmpty(pageToken));
 584
 0585            return orderIds;
 0586        }
 587
 588        private static string ToApiDate(DateTime value) =>
 0589            value.ToString("yyyy-MM-ddTHH:mm:ss") + MoscowOffset;
 590
 591        private static IEnumerable<List<T>> Chunk<T>(List<T> source, int size)
 592        {
 0593            for (var i = 0; i < source.Count; i += size)
 594            {
 0595                yield return source.GetRange(i, Math.Min(size, source.Count - i));
 596            }
 0597        }
 598
 599        /// <summary>
 600        /// Получает список campaignId для указанного businessId.
 601        /// </summary>
 602        private async Task<List<long>> GetCampaignIdsByBusinessId(string businessId)
 603        {
 0604            if (string.IsNullOrWhiteSpace(_apiKey) || string.IsNullOrWhiteSpace(businessId) ||
 0605                !long.TryParse(businessId, out var businessIdNum))
 606            {
 0607                return new List<long>();
 608            }
 609
 0610            await WaitForRateLimitAsync();
 611
 0612            var requestUri = $"{YandexApiUrl.CAMPAIGNS_V2}?page=&pageSize=200";
 613
 0614            var j = await _apiClient.SendJsonAsync(HttpMethod.Get, requestUri);
 0615            var campaigns = j?["campaigns"] as JArray;
 0616            if (campaigns == null) return new List<long>();
 617
 0618            var list = new List<long>();
 0619            foreach (var c in campaigns.OfType<JObject>())
 620            {
 0621                var bid = c["business"]?["id"]?.Value<long?>();
 0622                if (bid.HasValue && bid.Value == businessIdNum)
 623                {
 0624                    var cid = c["id"]?.Value<long?>();
 0625                    if (cid.HasValue) list.Add(cid.Value);
 626                }
 627            }
 628
 0629            return list;
 0630        }
 631
 632        /// <summary>
 633        /// Запрашивает first-mile shipments для кампании за период и возвращает orderIds из всех отгрузок.
 634        /// </summary>
 635        private async Task<List<long>> GetFirstMileShipmentsOrderIds(long campaignId, DateTime start, DateTime end)
 636        {
 0637            if (string.IsNullOrWhiteSpace(_apiKey)) return new List<long>();
 638
 0639            await WaitForRateLimitAsync();
 640
 0641            var baseUrl = string.Format(YandexApiUrl.FIRST_MILE_SHIPMENTS, campaignId);
 0642            var requestUri = $"{baseUrl}?page_token=&limit=20";
 643
 0644            var bodyObject = new
 0645            {
 0646                dateFrom = start.ToString("yyyy-MM-dd"),
 0647                dateTo = end.ToString("yyyy-MM-dd")
 0648            };
 649
 0650            var j = await _apiClient.SendJsonAsync(HttpMethod.Put, requestUri, bodyObject);
 0651            var shipments = j?["result"]?["shipments"] as JArray;
 0652            if (shipments == null) return new List<long>();
 653
 0654            var orderIds = new List<long>();
 0655            foreach (var s in shipments.OfType<JObject>())
 656            {
 0657                var ids = s["orderIds"] as JArray;
 0658                if (ids == null) continue;
 0659                foreach (var t in ids)
 660                {
 0661                    if (t != null && long.TryParse(t.ToString(), out var id))
 0662                        orderIds.Add(id);
 663                }
 664            }
 665
 0666            return orderIds;
 0667        }
 668
 669        /// <summary>
 670        /// Получает campaignId для заказа по businessId и orderId (ShipmentNumber).
 671        /// Вызов GET businesses/{businessId}/orders с orderIds = [orderId], парсит campaignId из первого заказа.
 672        /// </summary>
 673        public async Task<long?> GetCampaignIdForOrderAsync(string businessId, long orderId)
 674        {
 0675            if (string.IsNullOrWhiteSpace(_apiKey) || string.IsNullOrWhiteSpace(businessId))
 0676                return null;
 677
 678            // Кампания заказа не меняется, а печать этикетки платит за этот запрос отдельным
 679            // round-trip к Яндексу перед каждым PDF — при перепечати в другом формате он
 680            // повторяется впустую.
 0681            var cacheKey = (businessId, orderId);
 0682            if (CampaignIdCache.TryGetValue(cacheKey, out var cached))
 683            {
 0684                return cached;
 685            }
 686
 0687            await WaitForRateLimitAsync();
 688
 0689            var requestUri = string.Format(YandexApiUrl.BUSINESS_ORDERS, businessId) + "?limit=1";
 0690            var bodyObject = new { orderIds = new[] { orderId } };
 691
 0692            var j = await _apiClient.SendJsonAsync(HttpMethod.Post, requestUri, bodyObject);
 0693            if (j == null)
 694            {
 0695                return null;
 696            }
 697
 0698            var orders = j["orders"] as JArray ?? j["result"]?["orders"] as JArray;
 0699            var first = orders?.FirstOrDefault() as JObject;
 0700            var campaignId = first?["campaignId"]?.Value<long?>();
 701
 0702            if (campaignId.HasValue)
 703            {
 0704                CampaignIdCache[cacheKey] = campaignId;
 705            }
 706
 0707            return campaignId;
 0708        }
 709
 710        /// <summary>
 711        /// Скачивает PDF этикетки доставки для заказа Яндекс.Маркета.
 712        /// format: A9_HORIZONTALLY | A9 | A7 | A4.
 713        /// </summary>
 714        public async Task<byte[]> GetOrderLabelPdfAsync(long campaignId, long orderId, string format)
 715        {
 0716            if (string.IsNullOrWhiteSpace(_apiKey))
 0717                throw new InvalidOperationException("Api-Key не задан.");
 718
 0719            var f = (format ?? "A7").Trim().ToUpperInvariant();
 0720            if (f != "A9_HORIZONTALLY" && f != "A9" && f != "A7" && f != "A4")
 0721                f = "A7";
 722
 0723            await WaitForRateLimitAsync();
 724
 0725            var url = string.Format(YandexApiUrl.DELIVERY_LABELS, campaignId, orderId) + "?format=" +
 0726                      Uri.EscapeDataString(f);
 727
 0728            return await _apiClient.GetBytesAsync(url);
 0729        }
 730
 731        /// <summary>
 732        /// Удерживает лимит Partner API (100 запросов в минуту) скользящим окном.
 733        /// Раньше здесь вдобавок стояла безусловная пауза 600 мс перед КАЖДЫМ запросом, и ждали её
 734        /// через Thread.Sleep внутри lock: печать одной этикетки (campaignId + PDF) платила 1.2 с
 735        /// на пустом месте, а параллельные вызовы вставали в очередь друг за другом и занимали
 736        /// потоки пула. Окна достаточно: пока за последнюю минуту меньше лимита, запрос уходит
 737        /// сразу, а при исчерпании ждём ровно до освобождения слота — и ждём асинхронно, вне лока.
 738        /// </summary>
 739        private static async Task WaitForRateLimitAsync(CancellationToken cancellationToken = default)
 740        {
 741            TimeSpan wait;
 742
 0743            await _rateLimitGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 744            try
 745            {
 0746                var now = DateTime.UtcNow;
 0747                var windowStart = now.AddMinutes(-1);
 748
 0749                while (_requestTimes.Count > 0 && _requestTimes.Peek() <= windowStart)
 750                {
 0751                    _requestTimes.Dequeue();
 752                }
 753
 0754                if (_requestTimes.Count < MaxRequestsPerMinute)
 755                {
 0756                    wait = TimeSpan.Zero;
 757                }
 758                else
 759                {
 760                    // Слот освободится, когда самый старый запрос окна выйдет за его границу.
 0761                    var slotFreeAt = _requestTimes.Peek().AddMinutes(1);
 0762                    wait = slotFreeAt > now ? slotFreeAt - now : TimeSpan.Zero;
 763                }
 764
 765                // Ставим в очередь момент, когда запрос реально уйдёт — иначе параллельные
 766                // вызывающие рассчитают одно и то же время ожидания и стартуют разом.
 0767                _requestTimes.Enqueue(now + wait);
 0768            }
 769            finally
 770            {
 0771                _rateLimitGate.Release();
 772            }
 773
 0774            if (wait > TimeSpan.Zero)
 775            {
 0776                await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
 777            }
 0778        }
 779
 780        // Вспомогательный метод для получения информации о кампании по ID
 781        private async Task<JObject?> GetCampaignInfoById(long campaignId)
 782        {
 0783            if (string.IsNullOrWhiteSpace(_apiKey))
 784            {
 0785                return null;
 786            }
 787
 788            // Применяем rate limiting
 0789            await WaitForRateLimitAsync();
 790
 0791            var requestUri = string.Format(YandexApiUrl.CAMPAIGN, campaignId);
 792
 0793            return await _apiClient.SendJsonAsync(HttpMethod.Get, requestUri);
 0794        }
 795
 796        // Вспомогательный метод для получения информации о продуктах (offer-mappings)
 797        private async Task<JObject?> GetOfferMappings(string businessId, List<string> offerIds)
 798        {
 0799            if (string.IsNullOrWhiteSpace(_apiKey) || string.IsNullOrWhiteSpace(businessId) || offerIds == null ||
 0800                offerIds.Count == 0)
 801            {
 0802                return null;
 803            }
 804
 805            // Применяем rate limiting
 0806            await WaitForRateLimitAsync();
 807
 0808            var requestUri = string.Format(YandexApiUrl.BUSINESS_OFFER_MAPPINGS, businessId);
 809
 810            // Формируем тело запроса
 0811            var bodyObject = new
 0812            {
 0813                offerIds = offerIds
 0814            };
 815
 0816            return await _apiClient.SendJsonAsync(HttpMethod.Post, requestUri, bodyObject);
 0817        }
 818
 819        // Вспомогательный метод для получения информации о тарифах (комиссиях)
 820        private async Task<JObject?> GetTariffs(long campaignId, List<object> offers)
 821        {
 0822            if (string.IsNullOrWhiteSpace(_apiKey) || offers == null || offers.Count == 0)
 823            {
 0824                return null;
 825            }
 826
 827            // Применяем rate limiting
 0828            await WaitForRateLimitAsync();
 829
 0830            var requestUri = YandexApiUrl.TARIFFS_CALCULATE;
 831
 832            // Формируем тело запроса
 0833            var bodyObject = new
 0834            {
 0835                parameters = new
 0836                {
 0837                    campaignId = campaignId,
 0838                    frequency = "DAILY"
 0839                },
 0840                offers = offers
 0841            };
 842
 0843            await _tariffRequestsGate.WaitAsync();
 844            try
 845            {
 0846                return await _apiClient.SendJsonAsync(HttpMethod.Post, requestUri, bodyObject);
 847            }
 848            finally
 849            {
 0850                _tariffRequestsGate.Release();
 851            }
 0852        }
 853    }
 854}