< Summary

Information
Class: Services.ApiServices.OzonApi.OzonApiDataManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/OzonApiDataManager.cs
Line coverage
30%
Covered lines: 356
Uncovered lines: 795
Coverable lines: 1151
Total lines: 2205
Line coverage: 30.9%
Branch coverage
20%
Covered branches: 146
Total branches: 708
Branch coverage: 20.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
GetTestRequest()100%210%
SetClient(...)100%210%
NormalizeOzonIds(...)85.71%141490.9%
GetReportCode()100%210%
GetReportCode()0%702260%
CreateSellerReturnsReportAsync()0%600240%
GetWarehouseStockReportCodeAsync()0%620%
GetReportInfo()0%2040%
GetReportFile()0%7280%
WaitForReportReady()0%1332360%
ValidatePostingsReportRequest(...)0%110100%
GetProductWarehousAndCityBatch()100%11100%
<GetProductWarehousAndCityBatch()100%22100%
ReadFileByUrl()100%210%
GetProductCode()0%4260%
GetProductPrices()100%88100%
GetProductPricesByArticlesModel()0%2040%
GetProductAttributesAsync()70%1010100%
IsProductAttributesNotFoundError(...)90%1010100%
GetProductPricesDetailsAsync()0%4260%
GetProductPricesByArticles()100%22100%
GetActivePromosAsync()0%2040%
GetPromoOfferIdsAsync()0%156120%
CheckArticlesInPromosAsync()0%156120%
GetAllBatchOfferIdsAsync()0%600240%
Normalize()0%156120%
GetProductInfoListAsync()100%210%
GetProductInfoListBySkuAsync()100%210%
GetPrimaryImagesBySkuAsync()0%702260%
FirstImageUrl(...)0%7280%
ArchiveProductsAsync()0%7280%
UnarchiveProductsAsync()0%7280%
ReadWarehouseStockPagesAsync()90.9%232288.88%
GetProductWarehouses()75%88100%
GetProductWarehousesForBitrix()75%88100%
GetWarehouseInfo()0%702260%
CombineWarehouseInfoJson()37.5%1042448.27%
GetProductStocks()75%171683.78%
GetProductWarehousAndCity()100%11100%
GetProductSatatus()100%1150%
GetProductStatusRaw()50%66100%
GetProductStatusBatch()100%11100%
<GetProductStatusBatch()50%5464.28%
GetProductSubSatatus()0%4260%
GetProductId()0%600240%
GetPostingProductsWithDeliveryAsync()0%342180%
GetProducts()0%2040%
GetFbsPostingRequirementsAsync()0%506220%
GetFbsProductsRequiringCountryAsync()0%620%
GetFbsExemplarsCreateOrGetAsync()0%620%
SetFbsExemplarsAsync()0%2040%
GetFbsExemplarStatusAsync()0%620%
GetFbsProductCountriesAsync()0%7280%
SetFbsProductCountryAsync()0%4260%
GetPostingShipProductsAsync()100%210%
GetPostingProductQuantityAsync()0%2040%
GetPostingProductSkuAsync()0%2040%
CheckOrderForExistence()0%7280%
UpdateProductStocks()0%620%
TryResolveProductIdByOfferIdAsync()0%342180%
GetWarehouseList()0%2040%
GetWarehouseListOrThrowAsync()0%2040%
GetApiRoles()0%620%
GetSellerInfo()0%620%
ImportProductPrices()70%111081.81%
ImportProductPricesChunk()9.09%1012245.45%
ShipFbsPosting()0%4260%
GetUnfulfilledPostingsAsync()0%2040%
CreatePackageLabelTask()0%4260%
GetPackageLabelFile()0%2040%
SetAwaitingDelivery()0%7280%
GetFinanceAccrualPostingsAsync()0%7280%
GetFinanceAccrualTypesAsync()0%620%
GetReturnsListAsync()0%7280%
SplitFbsPosting()0%7280%
GetFbsCancelReasonsAsync()0%7280%
GetFbsCancelReasonCatalogAsync()50%22100%
GetFboCancelReasonCatalogAsync()100%210%
CancelFbsPostingAsync()0%156120%
GetWarehouseInfoBatchedAsync()79.16%4848100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/OzonApiDataManager.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Microsoft.Extensions.Http;
 3using Newtonsoft.Json;
 4using Newtonsoft.Json.Linq;
 5using Services.ApiServices.OzonApi.Filters;
 6using Services.BitrixStockServices;
 7using Services.ParserServices;
 8using OzonDomains.Models;
 9using Services.DataServices;
 10using Services.LabelServices;
 11using SlqStudio.Application.Services.EmailService;
 12
 13namespace Services.ApiServices.OzonApi
 14{
 15    /// <summary>
 16    /// Менеджер работы с Ozon Seller API (docs.ozon.ru/api/seller).
 17    /// Отчёты, товары, цены, остатки, отправления FBS, этикетки, финансы.
 18    /// </summary>
 19    public class OzonApiDataManager : IApiDataManager<OzonApiDataManager>
 20    {
 21        private readonly IHttpClientFactory _httpClientFactory;
 22        private readonly IEmailService? _emailService;
 23        private OzonApiClient ozonApiClient = null!;
 24        private readonly CsvUrlParser csvUrlParser = null!;
 25
 4426        private readonly ConcurrentDictionary<string, JObject> _warehouseSkuCache =
 4427            new ConcurrentDictionary<string, JObject>();
 28
 4429        private readonly Dictionary<string, string> StatusDescriptions = new Dictionary<string, string>
 4430        {
 4431            { "waiting", "в очереди на обработку" },
 4432            { "processing", "обрабатывается" },
 4433            { "success", "отчёт успешно создан" },
 4434            { "failed", "ошибка при создании отчёта" }
 4435        };
 36
 4437        public OzonApiDataManager(
 4438            IHttpClientFactory httpClientFactory,
 4439            IEmailService? emailService,
 4440            string clientId,
 4441            string apiKey)
 42        {
 4443            _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
 4444            _emailService = emailService;
 4445            ozonApiClient = new OzonApiClient(_httpClientFactory, _emailService, clientId, apiKey);
 4446            csvUrlParser = new CsvUrlParser(_httpClientFactory);
 4447        }
 48
 49        /// <summary>Проверка доступа к API по Client-Id и Api-Key.</summary>
 50        public async Task<bool> GetTestRequest(string clientId, string apiKey)
 51        {
 052            return await ozonApiClient.GetTestRequest(clientId, apiKey).ConfigureAwait(false);
 053        }
 54
 55        /// <summary>Устанавливает Client-Id и Api-Key для последующих запросов.</summary>
 56        public OzonApiDataManager SetClient(string clientId, string apiKey)
 57        {
 058            ozonApiClient = new OzonApiClient(_httpClientFactory, _emailService, clientId, apiKey);
 059            return this;
 60        }
 61
 62        private static string[] NormalizeOzonIds(IEnumerable<string?>? ids)
 63        {
 3864            if (ids == null)
 065                return Array.Empty<string>();
 66
 3867            var normalized = new List<string>();
 16868            foreach (var rawId in ids)
 69            {
 4670                var id = rawId?.Trim();
 4671                if (string.IsNullOrEmpty(id))
 72                    continue;
 73
 2174                if (id[0] == '\'')
 475                    id = id[1..].Trim();
 76
 2177                if (!string.IsNullOrEmpty(id) && id.Length <= 1000)
 1978                    normalized.Add(id);
 79            }
 80
 3881            return normalized.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
 82        }
 83
 84        /// <summary>Создание отчёта по отправлениям. POST /v1/report/postings/create. Возвращает code отчёта.</summary>
 85        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.REPORT_CODE).</param>
 86        public async Task<string> GetReportCode(DateTime start, DateTime end,
 87            string uri = OzonApiUrl.REPORT_CODE,
 88            string deliverySchema = "fbs")
 89        {
 090            var postingsReportRequest = new PostingsReportRequest
 091            {
 092                Filter = new PostingsReportRequestFilter
 093                {
 094                    ProcessedAtFrom = start, // DateTime.Now.AddHours(-period * 24 + timeZone),
 095                    ProcessedAtTo = end, // DateTime.Now.AddHours(timeZone),
 096                    DeliverySchema = [deliverySchema],
 097                    Sku = [],
 098                    CancelReasonId = [],
 099                    OfferId = "",
 0100                    StatusAlias = [],
 0101                    Statuses = [],
 0102                    Title = ""
 0103                },
 0104                Language = "DEFAULT"
 0105            };
 106
 0107            return await GetReportCode(postingsReportRequest, uri).ConfigureAwait(false);
 0108        }
 109
 110        /// <summary>Создание отчёта по отправлениям с кастомными фильтрами. POST /v1/report/postings/create.</summary>
 111        public async Task<string> GetReportCode(PostingsReportRequest request,
 112            string uri = OzonApiUrl.REPORT_CODE)
 113        {
 0114            if (request == null)
 0115                throw new ArgumentNullException(nameof(request));
 116
 0117            ValidatePostingsReportRequest(request);
 118
 0119            Console.WriteLine($"[OzonApiDataManager/GetReportCode] request={JObject.FromObject(request)}");
 0120            var postingsResponse = await ozonApiClient.MakeRequestPostAsync(request, uri).ConfigureAwait(false);
 0121            Console.WriteLine($"[OzonApiDataManager/GetReportCode] response={postingsResponse}");
 122
 0123            var createResponse = JsonConvert.DeserializeObject<ReportCreateResponse>(postingsResponse.ToString());
 0124            var reportCode = createResponse?.Result?.Code
 0125                             ?? postingsResponse.SelectToken("result.code")?.Value<string>()
 0126                             ?? postingsResponse.SelectToken("code")?.Value<string>()
 0127                             ?? postingsResponse["result"]?["code"]?.ToString()
 0128                             ?? postingsResponse["code"]?.ToString();
 129
 0130            if (!string.IsNullOrWhiteSpace(reportCode))
 131            {
 0132                Console.WriteLine($"[OzonApiDataManager/GetReportCode] reportCode={reportCode}");
 0133                return reportCode;
 134            }
 135
 0136            throw new Exception($"Ozon не вернул код отчета. Ответ: {postingsResponse}");
 0137        }
 138
 139        /// <summary>Создание отчёта по возвратам FBS. POST /v2/report/returns/create. Возвращает code отчёта.</summary>
 140        public async Task<string> CreateSellerReturnsReportAsync(
 141            DateTime dateFromUtc,
 142            DateTime dateToUtc,
 143            string deliverySchema = "fbs",
 144            string status = "ReceivedBySeller",
 145            string uri = OzonApiUrl.REPORT_RETURNS_CREATE)
 146        {
 0147            var request = new SellerReturnsReportCreateRequest
 0148            {
 0149                Filter = new SellerReturnsReportFilter
 0150                {
 0151                    DeliverySchema = deliverySchema,
 0152                    DateFrom = DateTime.SpecifyKind(dateFromUtc, DateTimeKind.Utc),
 0153                    DateTo = DateTime.SpecifyKind(dateToUtc, DateTimeKind.Utc),
 0154                    Status = status
 0155                },
 0156                Language = "DEFAULT"
 0157            };
 158
 0159            var response = await ozonApiClient.MakeRequestPostAsync(request, uri).ConfigureAwait(false);
 0160            var createResponse = JsonConvert.DeserializeObject<ReportCreateResponse>(response.ToString());
 0161            var reportCode = createResponse?.Result?.Code
 0162                             ?? response.SelectToken("result.code")?.Value<string>()
 0163                             ?? response.SelectToken("code")?.Value<string>()
 0164                             ?? response["result"]?["code"]?.ToString()
 0165                             ?? response["code"]?.ToString();
 166
 0167            if (!string.IsNullOrWhiteSpace(reportCode))
 0168                return reportCode;
 169
 0170            throw new Exception($"Ozon не вернул код отчёта по возвратам. Ответ: {response}");
 0171        }
 172
 173        /// <summary>Запрашивает отчёт об остатках FBS по складу(ам). Возвращает code для GetReportInfo. POST /v1/report
 174        public async Task<string> GetWarehouseStockReportCodeAsync(
 175            IReadOnlyList<long> warehouseIds,
 176            string uri = OzonApiUrl.WAREHOUSE_STOCK_REPORT)
 177        {
 0178            var request = new WarehouseStockReportRequest
 0179            {
 0180                Language = "DEFAULT",
 0181                WarehouseId = warehouseIds.Select(id => id.ToString()).ToList()
 0182            };
 0183            var response = await ozonApiClient.MakeRequestPostAsync(request, uri).ConfigureAwait(false);
 0184            var code = OzonReportCodeParsing.ParseWarehouseStockCode(response);
 0185            if (string.IsNullOrWhiteSpace(code))
 0186                throw new Exception($"Ozon не вернул code для отчёта warehouse/stock. Ответ: {response}");
 0187            return code;
 0188        }
 189
 190        /// <summary>Получение полной информации об отчёте по коду. POST /v1/report/info.</summary>
 191        public async Task<ReportInfoResponseResult> GetReportInfo(string code, string uri = OzonApiUrl.REPORT_FILE)
 192        {
 0193            if (string.IsNullOrWhiteSpace(code))
 0194                throw new ArgumentException("Код отчёта не должен быть пустым.", nameof(code));
 195
 0196            var reportInfoRequest = new ReportInfoRequest { Code = code };
 0197            var infoResponse = await ozonApiClient.MakeRequestPostAsync(reportInfoRequest, uri).ConfigureAwait(false);
 0198            var reportInfo = OzonReportInfoParsing.ParseResult(infoResponse);
 199
 0200            if (reportInfo != null)
 0201                return reportInfo;
 202
 0203            throw new Exception($"Ozon не вернул информацию об отчете. Ответ: {infoResponse}");
 0204        }
 205
 206        /// <summary>Получение ссылки на файл отчёта по коду. POST /v1/report/info.</summary>
 207        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.REPORT_FILE).</param>
 208        public async Task<string> GetReportFile(string code, string uri = OzonApiUrl.REPORT_FILE)
 209        {
 0210            var reportInfo = await GetReportInfo(code, uri).ConfigureAwait(false);
 211
 0212            if (reportInfo.Status == "success" && !string.IsNullOrWhiteSpace(reportInfo.File))
 213            {
 0214                return reportInfo.File;
 215            }
 216
 0217            if (!string.IsNullOrWhiteSpace(reportInfo.Status) && StatusDescriptions.TryGetValue(reportInfo.Status, out v
 218            {
 0219                throw new Exception(message);
 220            }
 221
 0222            throw new Exception($"Не удалось получить ссылку на файл отчёта с кодом {code}. Статус: {reportInfo.Status},
 0223        }
 224
 225        /// <summary>
 226        /// Ожидает готовности отчёта: POST /v1/report/info каждые <paramref name="pollIntervalMs"/> мс.
 227        /// </summary>
 228        public async Task<string> WaitForReportReady(
 229            string code,
 230            int maxWaitTimeMs = 600_000,
 231            int pollIntervalMs = 5_000,
 232            string uri = OzonApiUrl.REPORT_FILE,
 233            Func<string, Task>? onStatusLog = null)
 234        {
 0235            var reportInfoRequest = new ReportInfoRequest { Code = code };
 0236            var startTime = DateTime.UtcNow;
 0237            var poll = 0;
 238
 239            // #region agent log
 0240            OzonReportDebugLog.Write("report.info.wait_start",
 0241                new { code, maxWaitTimeMs, pollIntervalMs, uri });
 242            // #endregion
 243
 0244            while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitTimeMs)
 245            {
 0246                poll++;
 0247                var elapsedSec = (DateTime.UtcNow - startTime).TotalSeconds;
 248                JToken infoResponse;
 249                try
 250                {
 0251                    infoResponse = await ozonApiClient.MakeRequestPostAsync(reportInfoRequest, uri).ConfigureAwait(false
 0252                }
 0253                catch (Exception ex)
 254                {
 255                    // #region agent log
 0256                    OzonReportDebugLog.Write("report.info.request_error",
 0257                        new { poll, code, elapsedSec, error = ex.Message });
 258                    // #endregion
 0259                    throw;
 260                }
 261
 0262                var reportInfo = OzonReportInfoParsing.ParseResult(infoResponse);
 0263                var status = reportInfo?.Status ?? string.Empty;
 0264                var statusRu = StatusDescriptions.GetValueOrDefault(status, string.IsNullOrWhiteSpace(status) ? "неизвес
 265
 266                // #region agent log
 0267                OzonReportDebugLog.Write("report.info.poll",
 0268                    new
 0269                    {
 0270                        poll,
 0271                        code,
 0272                        status,
 0273                        statusRu,
 0274                        elapsedSec = Math.Round(elapsedSec, 1),
 0275                        reportType = reportInfo?.ReportType,
 0276                        hasFile = !string.IsNullOrWhiteSpace(reportInfo?.File),
 0277                        error = reportInfo?.Error,
 0278                        expiresAt = reportInfo?.ExpiresAt,
 0279                        responsePreview = OzonReportDebugLog.Truncate(infoResponse.ToString())
 0280                    });
 281                // #endregion
 282
 0283                if (onStatusLog != null)
 0284                    await onStatusLog($"Статус отчёта Ozon: {statusRu} (проверка {poll}, прошло {elapsedSec:F0} с)").Con
 285
 286                switch (status)
 287                {
 288                    case "success":
 0289                        if (string.IsNullOrWhiteSpace(reportInfo?.File))
 0290                            throw new Exception($"Отчёт {code} имеет статус «готов», но не содержит ссылки на файл.");
 291
 292                        // #region agent log
 0293                        OzonReportDebugLog.Write("report.info.ready",
 0294                            new
 0295                            {
 0296                                poll,
 0297                                code,
 0298                                elapsedSec = Math.Round(elapsedSec, 1),
 0299                                reportType = reportInfo.ReportType,
 0300                                filePrefix = OzonReportDebugLog.Truncate(reportInfo.File, 120)
 0301                            });
 302                        // #endregion
 0303                        return reportInfo.File;
 304                    case "failed":
 0305                        var failMsg = string.IsNullOrWhiteSpace(reportInfo?.Error)
 0306                            ? statusRu
 0307                            : $"{statusRu}: {reportInfo.Error}";
 308                        // #region agent log
 0309                        OzonReportDebugLog.Write("report.info.failed",
 0310                            new { poll, code, elapsedSec, error = reportInfo?.Error });
 311                        // #endregion
 0312                        throw new Exception($"Ошибка при создании отчёта Ozon: {failMsg}");
 313                    case "waiting":
 314                    case "processing":
 0315                        await Task.Delay(pollIntervalMs).ConfigureAwait(false);
 0316                        break;
 317                    default:
 318                        // #region agent log
 0319                        OzonReportDebugLog.Write("report.info.unknown_status",
 0320                            new { poll, code, status, responsePreview = OzonReportDebugLog.Truncate(infoResponse.ToStrin
 321                        // #endregion
 0322                        throw new Exception($"Неизвестный статус отчёта Ozon: {status}");
 323                }
 0324            }
 325
 0326            var timeoutSec = maxWaitTimeMs / 1000;
 327            // #region agent log
 0328            OzonReportDebugLog.Write("report.info.timeout",
 0329                new { code, poll, timeoutSec, elapsedSec = Math.Round((DateTime.UtcNow - startTime).TotalSeconds, 1) });
 330            // #endregion
 0331            throw new TimeoutException(
 0332                $"Превышено время ожидания ({timeoutSec} с) готовности отчёта Ozon с кодом {code}. Выполнено проверок: {
 0333        }
 334
 335        private static void ValidatePostingsReportRequest(PostingsReportRequest request)
 336        {
 0337            if (request.Filter == null)
 0338                throw new ArgumentException("Для отчета по отправлениям обязателен filter.", nameof(request));
 339
 0340            if (request.With?.AnalyticsData == true)
 341            {
 0342                var hasFbsSchema = request.Filter.DeliverySchema?.Any(schema =>
 0343                    string.Equals(schema, "fbs", StringComparison.OrdinalIgnoreCase)) == true;
 344
 0345                if (!hasFbsSchema)
 0346                    throw new ArgumentException("Поле with.analytics_data доступно только при filter.delivery_schema = f
 347            }
 0348        }
 349
 350        /// <summary>
 351        /// Данные по нескольким отправлениям FBS параллельно. POST /v3/posting/fbs/get.
 352        /// </summary>
 353        /// <param name="productNumbers">Номера отправлений.</param>
 354        /// <param name="maxParallelism">Максимальное количество параллельных запросов.</param>
 355        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.WAERHOUS_AND_CITY).</param>
 356        /// <returns>Словарь: номер отправления → данные.</returns>
 357        public async Task<Dictionary<string, JObject>> GetProductWarehousAndCityBatch(
 358            string[] productNumbers,
 359            int maxParallelism = 10,
 360            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 361        {
 1362            var results = new ConcurrentDictionary<string, JObject>();
 1363            var semaphore = new SemaphoreSlim(maxParallelism);
 364
 1365            var groups = productNumbers
 2366                .Where(n => !string.IsNullOrEmpty(n))
 1367                .GroupBy(OzonPostingNumberHelper.GetOzonApiPostingNumber, StringComparer.Ordinal);
 368
 1369            var tasks = groups.Select(async group =>
 1370            {
 1371                var cleanPosting = group.Key;
 1372                await semaphore.WaitAsync();
 1373                try
 1374                {
 1375                    var result = await GetProductWarehousAndCity(cleanPosting, uri);
 6376                    foreach (var rawNumber in group)
 1377                    {
 2378                        results.TryAdd(rawNumber, result);
 1379                    }
 1380                    results.TryAdd(cleanPosting, result);
 1381                }
 1382                finally
 1383                {
 1384                    semaphore.Release();
 1385                }
 2386            });
 387
 1388            await Task.WhenAll(tasks);
 1389            return new Dictionary<string, JObject>(results);
 1390        }
 391
 392        /// <summary>Чтение файла по URL (вне Ozon API, например файл отчёта).</summary>
 393        public async Task<JArray> ReadFileByUrl(string url)
 394        {
 0395            var fileContent = await csvUrlParser.ReadFileFromUrl(url);
 0396            return fileContent;
 0397        }
 398
 399        /// <summary>Создание отчёта по товарам. POST /v1/report/products/create. Возвращает code отчёта.</summary>
 400        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_CODE).</param>
 401        public async Task<string> GetProductCode(string[] articles,
 402            string[] ozonIds,
 403            string uri = OzonApiUrl.PRODUCT_CODE)
 404        {
 0405            var productsReportRequest = new ProductsReportRequest
 0406            {
 0407                Language = "DEFAULT",
 0408                OfferId = articles,
 0409                Search = "",
 0410                Sku = ozonIds,
 0411                Visibility = "ALL"
 0412            };
 413
 0414            var productsReportResponse =
 0415                await ozonApiClient.MakeRequestPostAsync(productsReportRequest, uri).ConfigureAwait(false);
 0416            return productsReportResponse["result"]?["code"]?.ToString()
 0417                   ?? throw new InvalidOperationException("Ozon API не вернул code отчёта по товарам.");
 0418        }
 419
 420        /// <summary>Информация о ценах товаров по артикулам и product_id. POST /v5/product/info/prices.</summary>
 421        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_PRICES).</param>
 422        public async Task<JObject> GetProductPrices(string[] articles,
 423            string[] ozonProductIds,
 424            string uri = OzonApiUrl.PRODUCT_PRICES)
 425        {
 4426            var offerIds = NormalizeOzonIds(articles);
 4427            var productIds = NormalizeOzonIds(ozonProductIds);
 4428            if (offerIds.Length == 0 && productIds.Length == 0)
 1429                return new JObject();
 430
 3431            var productPricesRequest = new ProductPricesRequest
 3432            {
 3433                Filter = new ProductPricesRequestFilter
 3434                {
 3435                    OfferId = offerIds.Length > 0 ? offerIds : null,
 3436                    ProductId = productIds.Length > 0 ? productIds : null,
 3437                    Visibility = "ALL"
 3438                },
 3439                LastId = "",
 3440                Limit = 500
 3441            };
 3442            var productPricesResponse =
 3443                await ozonApiClient.MakeRequestPostAsync(productPricesRequest, uri).ConfigureAwait(false);
 3444            return productPricesResponse;
 4445        }
 446
 447        /// <summary>Цены товаров по артикулам (offer_id). POST /v5/product/info/prices. Возвращает типизированную модел
 448        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_PRICES).</param>
 449        public async Task<ProductPricesResponse> GetProductPricesByArticlesModel(
 450            string[] articles,
 451            string uri = OzonApiUrl.PRODUCT_PRICES)
 452        {
 0453            var offerIds = NormalizeOzonIds(articles);
 0454            if (offerIds.Length == 0)
 0455                return new ProductPricesResponse();
 456
 0457            var productPricesRequest = new ProductPricesRequest
 0458            {
 0459                Filter = new ProductPricesRequestFilter
 0460                {
 0461                    OfferId = offerIds,
 0462                    Visibility = "ALL"
 0463                },
 0464                LastId = "",
 0465                Limit = 500
 0466            };
 467
 0468            var productPricesResponse = await ozonApiClient.MakeRequestPostForBitrixAsync(productPricesRequest, uri)
 0469                .ConfigureAwait(false);
 470
 471            // Десериализация в модель
 0472            return productPricesResponse.ToObject<ProductPricesResponse>()
 0473                   ?? new ProductPricesResponse();
 0474        }
 475
 476        /// <summary>
 477        /// Характеристики товара. POST /v4/product/info/attributes.
 478        /// </summary>
 479        /// <param name="productIds">Ozon product_id (product_id в фильтре).</param>
 480        /// <param name="offerIds">Ozon offer_id (опционально).</param>
 481        /// <param name="skus">Ozon sku (опционально).</param>
 482        /// <param name="visibility">Видимость: ALL / ...</param>
 483        /// <param name="lastId">last_id для постраничной навигации (пусто для первого запроса).</param>
 484        /// <param name="limit">Количество элементов на странице (1..1000).</param>
 485        /// <param name="sortBy">Параметр сортировки: sku / offer_id / id / title.</param>
 486        /// <param name="sortDir">Направление сортировки: ASC / DESC.</param>
 487        /// <param name="uri">URL эндпоинта.</param>
 488        public async Task<ProductAttributesResponse> GetProductAttributesAsync(
 489            string[] productIds,
 490            string[]? offerIds = null,
 491            string[]? skus = null,
 492            string visibility = "ALL",
 493            string lastId = "",
 494            int limit = 100,
 495            string sortBy = "id",
 496            string sortDir = "ASC",
 497            string uri = OzonApiUrl.PRODUCT_ATTRIBUTES)
 498        {
 4499            offerIds ??= [];
 4500            skus ??= [];
 501
 4502            var requestModel = new ProductAttributesRequest
 4503            {
 4504                Filter = new ProductAttributesRequestFilter
 4505                {
 4506                    ProductId = productIds ?? [],
 4507                    OfferId = offerIds,
 4508                    Sku = skus,
 4509                    Visibility = visibility
 4510                },
 4511                LastId = lastId ?? "",
 4512                Limit = limit,
 4513                SortBy = sortBy,
 4514                SortDir = sortDir
 4515            };
 516
 517            try
 518            {
 4519                var productAttributesResponse = await ozonApiClient.MakeRequestPostForBitrixAsync(requestModel, uri)
 4520                    .ConfigureAwait(false);
 521
 1522                return productAttributesResponse.ToObject<ProductAttributesResponse>() ?? new ProductAttributesResponse(
 523            }
 3524            catch (Exception ex) when (IsProductAttributesNotFoundError(ex))
 525            {
 2526                return new ProductAttributesResponse();
 527            }
 3528        }
 529
 530        private static bool IsProductAttributesNotFoundError(Exception ex)
 531        {
 8532            for (var cur = ex; cur != null; cur = cur.InnerException)
 533            {
 3534                if (cur.Message.Contains("Status: NotFound", StringComparison.OrdinalIgnoreCase) ||
 3535                    cur.Message.Contains("Status: 404", StringComparison.OrdinalIgnoreCase) ||
 3536                    (cur.Message.Contains("\"code\":5", StringComparison.OrdinalIgnoreCase) &&
 3537                     cur.Message.Contains("item not found", StringComparison.OrdinalIgnoreCase)))
 538                {
 2539                    return true;
 540                }
 541            }
 542
 1543            return false;
 544        }
 545
 546        /// <summary>Детализированные цены товаров по sku. POST /v1/product/prices/details.</summary>
 547        /// <param name="skus">Список sku (1..1000).</param>
 548        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_PRICES_DETAILS).</param>
 549        public async Task<ProductPricesDetailsResponse> GetProductPricesDetailsAsync(
 550            long[] skus,
 551            string uri = OzonApiUrl.PRODUCT_PRICES_DETAILS)
 552        {
 0553            if (skus == null || skus.Length == 0)
 0554                return new ProductPricesDetailsResponse();
 555
 0556            var requestModel = new ProductPricesDetailsRequest
 0557            {
 0558                Skus = skus
 0559            };
 560
 0561            var response = await ozonApiClient.MakeRequestPostForBitrixAsync(requestModel, uri)
 0562                .ConfigureAwait(false);
 563
 0564            return response.ToObject<ProductPricesDetailsResponse>() ?? new ProductPricesDetailsResponse();
 0565        }
 566
 567        /// <summary>Цены товаров по артикулам (offer_id). POST /v5/product/info/prices. Возвращает JObject.</summary>
 568        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_PRICES).</param>
 569        public async Task<JObject> GetProductPricesByArticles(string[] articles,
 570            string uri = OzonApiUrl.PRODUCT_PRICES)
 571        {
 2572            var offerIds = NormalizeOzonIds(articles);
 2573            if (offerIds.Length == 0)
 1574                return new JObject();
 575
 1576            var productPricesRequest = new ProductPricesRequest
 1577            {
 1578                Filter = new ProductPricesRequestFilter
 1579                {
 1580                    OfferId = offerIds,
 1581                    Visibility = "ALL"
 1582                },
 1583                LastId = "",
 1584                Limit = 500
 1585            };
 586
 1587            var productPricesResponse = await ozonApiClient.MakeRequestPostForBitrixAsync(productPricesRequest, uri)
 1588                .ConfigureAwait(false);
 1589            return productPricesResponse;
 2590        }
 591
 592        /// <summary>Список активных акций продавца. POST /v1/actions.</summary>
 593        public async Task<List<OzonPromoDto>> GetActivePromosAsync(
 594            string uri = OzonApiUrl.ACTIONS)
 595        {
 0596            var response = await ozonApiClient.MakeRequestPostForBitrixAsync(new OzonActionsRequest(), uri)
 0597                .ConfigureAwait(false);
 0598            var parsed = response.ToObject<OzonActionsResponse>();
 0599            return parsed?.Result ?? new List<OzonPromoDto>();
 0600        }
 601
 602        /// <summary>offer_id товаров одной акции. POST /v1/actions/products (пагинация).</summary>
 603        public async Task<List<string>> GetPromoOfferIdsAsync(
 604            int actionId,
 605            string uri = OzonApiUrl.ACTIONS_PRODUCTS)
 606        {
 0607            var result = new List<string>();
 0608            int offset = 0;
 609            const int limit = 1000;
 0610            while (true)
 611            {
 0612                var req = new OzonActionsProductsRequest { ActionId = actionId, Offset = offset, Limit = limit };
 0613                var response = await ozonApiClient.MakeRequestPostForBitrixAsync(req, uri).ConfigureAwait(false);
 0614                var parsed = response.ToObject<OzonActionsProductsResponse>();
 0615                var products = parsed?.Result?.Products ?? new List<OzonPromoProductDto>();
 0616                foreach (var p in products)
 0617                    if (!string.IsNullOrEmpty(p.OfferId))
 0618                        result.Add(p.OfferId);
 0619                if (products.Count < limit) break;
 0620                offset += limit;
 621            }
 0622            return result;
 0623        }
 624
 625        /// <summary>
 626        /// Batch-проверка: участвует ли каждый артикул хотя бы в одной активной акции клиента.
 627        /// Возвращает словарь offer_id → OzonPromoStatusDto.
 628        /// </summary>
 629        public async Task<Dictionary<string, OzonPromoStatusDto>> CheckArticlesInPromosAsync(
 630            IEnumerable<string> articles)
 631        {
 0632            var articleSet = new HashSet<string>(articles, StringComparer.OrdinalIgnoreCase);
 0633            var result = new Dictionary<string, OzonPromoStatusDto>(StringComparer.OrdinalIgnoreCase);
 0634            if (articleSet.Count == 0)
 0635                return result;
 636
 637            List<OzonPromoDto> promos;
 0638            try { promos = await GetActivePromosAsync().ConfigureAwait(false); }
 0639            catch { return result; }
 640
 0641            foreach (var promo in promos)
 642            {
 643                List<string> offerIds;
 0644                try { offerIds = await GetPromoOfferIdsAsync(promo.ActionId).ConfigureAwait(false); }
 0645                catch { continue; }
 646
 0647                foreach (var offerId in offerIds)
 648                {
 0649                    if (!articleSet.Contains(offerId)) continue;
 0650                    if (!result.ContainsKey(offerId))
 651                    {
 0652                        result[offerId] = new OzonPromoStatusDto
 0653                        {
 0654                            InPromo = true,
 0655                            ActionId = promo.ActionId,
 0656                            PromoName = promo.Title ?? string.Empty,
 0657                            DateTo = promo.DateTo
 0658                        };
 659                    }
 660                }
 0661            }
 662
 0663            return result;
 0664        }
 665
 666        /// <summary>
 667        /// Все offer_id из каталога Ozon у которых мультипликатор [N] &gt; 1.
 668        /// Используется в Stage 5 для обнаружения batch-вариантов не попавших в FBS xlsx-отчёт.
 669        /// Использует /v4/product/info/stocks с пустым фильтром (курсорная пагинация) —
 670        /// легче чем /v4/product/info/attributes и заодно возвращает текущий FBS-остаток.
 671        /// </summary>
 672        public async Task<HashSet<string>> GetAllBatchOfferIdsAsync(CancellationToken ct = default)
 673        {
 0674            var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0675            var cursor = "";
 676            do
 677            {
 0678                ct.ThrowIfCancellationRequested();
 0679                var requestModel = new ProductStocksRequest
 0680                {
 0681                    Cursor = cursor,
 0682                    Filter = new ProductStocksRequestFilter { OfferId = [], Visibility = "ALL" },
 0683                    Limit = 1000
 0684                };
 0685                var page = await ozonApiClient.MakeRequestPostForBitrixAsync(requestModel, OzonApiUrl.PRODUCT_STOCKS)
 0686                    .ConfigureAwait(false);
 0687                var items = page?["items"] as JArray;
 0688                if (items == null || items.Count == 0)
 689                    break;
 0690                foreach (var item in items)
 691                {
 0692                    var offerId = item["offer_id"]?.ToString();
 0693                    if (!string.IsNullOrEmpty(offerId) &&
 0694                        BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(offerId) > 1)
 0695                        result.Add(offerId);
 696                }
 0697                cursor = page?["cursor"]?.ToString() ?? "";
 0698                if (items.Count < 1000)
 699                    break;
 0700            } while (!string.IsNullOrEmpty(cursor));
 0701            return result;
 0702        }
 703
 704        /// <summary>Список товаров по offer_id / product_id / sku. POST /v3/product/info/list.</summary>
 705        public async Task<JObject> GetProductInfoListAsync(
 706            string[]? offerIds = null,
 707            string[]? productIds = null,
 708            string[]? skus = null,
 709            string uri = OzonApiUrl.PRODUCT_INFO_LIST)
 710        {
 711            static string[] Normalize(string[]? ids)
 712            {
 0713                if (ids == null || ids.Length == 0)
 714                {
 0715                    return Array.Empty<string>();
 716                }
 717
 0718                var result = new string[ids.Length];
 0719                for (var i = 0; i < ids.Length; i++)
 720                {
 0721                    var id = ids[i]?.Trim() ?? string.Empty;
 0722                    if (id.StartsWith("'"))
 723                    {
 0724                        id = id[1..];
 725                    }
 726
 0727                    result[i] = id;
 728                }
 729
 0730                return result;
 731            }
 732
 0733            var request = new ProductInfoListRequest
 0734            {
 0735                OfferId = Normalize(offerIds),
 0736                ProductId = Normalize(productIds),
 0737                Sku = Normalize(skus)
 0738            };
 739
 0740            return await ozonApiClient.MakeRequestPostForBitrixAsync(request, uri).ConfigureAwait(false);
 0741        }
 742
 743        /// <summary>
 744        /// Информация о товарах по числовым SKU. POST /v3/product/info/list.
 745        /// Передаёт sku как long[], что соответствует ожиданиям Ozon API.
 746        /// </summary>
 747        public async Task<JObject> GetProductInfoListBySkuAsync(
 748            long[] skus,
 749            string uri = OzonApiUrl.PRODUCT_INFO_LIST)
 750        {
 0751            var request = new ProductInfoListBySkuRequest { Sku = skus };
 0752            return await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 0753        }
 754
 755        /// <summary>
 756        /// Главные картинки товаров по SKU. POST /v3/product/info/list.
 757        /// Ключи — offer_id и все SKU из sources (строкой), значение — URL primary_image
 758        /// (при его отсутствии — первая из images). Пустой словарь при ошибке: картинки необязательны.
 759        /// </summary>
 760        public async Task<Dictionary<string, string>> GetPrimaryImagesBySkuAsync(long[] skus)
 761        {
 0762            var result = new Dictionary<string, string>(StringComparer.Ordinal);
 0763            if (skus.Length == 0)
 764            {
 0765                return result;
 766            }
 767
 768            JObject response;
 769            try
 770            {
 0771                response = await GetProductInfoListBySkuAsync(skus).ConfigureAwait(false);
 0772            }
 0773            catch
 774            {
 0775                return result;
 776            }
 777
 0778            if (response["items"] is not JArray items)
 779            {
 0780                return result;
 781            }
 782
 0783            foreach (var item in items.OfType<JObject>())
 784            {
 0785                var url = FirstImageUrl(item["primary_image"]) ?? FirstImageUrl(item["images"]);
 0786                if (string.IsNullOrEmpty(url))
 787                {
 788                    continue;
 789                }
 790
 0791                var offerId = item["offer_id"]?.ToString();
 0792                if (!string.IsNullOrEmpty(offerId))
 793                {
 0794                    result[offerId] = url;
 795                }
 796
 0797                foreach (var source in (item["sources"] as JArray)?.OfType<JObject>() ?? Enumerable.Empty<JObject>())
 798                {
 0799                    var sku = source["sku"]?.ToString();
 0800                    if (!string.IsNullOrEmpty(sku) && sku != "0")
 801                    {
 0802                        result[sku] = url;
 803                    }
 804                }
 805            }
 806
 0807            return result;
 0808        }
 809
 810        private static string? FirstImageUrl(JToken? token)
 811        {
 0812            var url = token switch
 0813            {
 0814                JArray array => array.FirstOrDefault()?.ToString(),
 0815                JValue value => value.ToString(),
 0816                _ => null
 0817            };
 0818            return string.IsNullOrWhiteSpace(url) ? null : url;
 819        }
 820
 821        /// <summary>Архивация товаров по product_id. POST /v1/product/archive (до 100 id за запрос).</summary>
 822        public async Task<bool> ArchiveProductsAsync(
 823            long[] productIds,
 824            string uri = OzonApiUrl.PRODUCT_ARCHIVE)
 825        {
 0826            if (productIds == null || productIds.Length == 0)
 827            {
 0828                return true;
 829            }
 830
 0831            var request = new ProductArchiveRequest { ProductId = productIds };
 0832            var response = await ozonApiClient.MakeRequestPostForBitrixAsync(request, uri).ConfigureAwait(false);
 0833            return response?["result"]?.Value<bool>() ?? false;
 0834        }
 835
 836        /// <summary>Восстановление товаров из архива по product_id. POST /v1/product/unarchive (до 100 id за запрос).</
 837        public async Task<bool> UnarchiveProductsAsync(
 838            long[] productIds,
 839            string uri = OzonApiUrl.PRODUCT_UNARCHIVE)
 840        {
 0841            if (productIds == null || productIds.Length == 0)
 842            {
 0843                return true;
 844            }
 845
 0846            var request = new ProductArchiveRequest { ProductId = productIds };
 0847            var response = await ozonApiClient.MakeRequestPostForBitrixAsync(request, uri).ConfigureAwait(false);
 0848            return response?["result"]?.Value<bool>() ?? false;
 0849        }
 850
 851        /// <summary>Максимум записей в одной странице stocks-by-warehouse. Требование Ozon: (0, 1000].</summary>
 852        private const int WarehouseStocksPageLimit = 1000;
 853
 854        /// <summary>Предел страниц на один запрос stocks-by-warehouse — защита от «вечного» курсора.</summary>
 855        private const int WarehouseStocksMaxPages = 60;
 856
 857        /// <summary>Пауза между страницами stocks-by-warehouse, чтобы не ловить 429.</summary>
 858        private const int WarehouseStocksPageDelayMs = 200;
 859
 860        /// <summary>
 861        /// Читает все страницы /v2/product/info/stocks-by-warehouse/fbs.
 862        /// Ozon отдаёт по строке на каждую пару sku×склад и режет ответ по limit, выставляя
 863        /// has_next и cursor: без пагинации при пакете в 200 SKU до вызывающего доходит лишь
 864        /// треть товаров, а остальные молча считаются «без складов» и уходят в FBS-fallback.
 865        /// </summary>
 866        private async Task<JArray> ReadWarehouseStockPagesAsync(
 867            string[]? sku,
 868            string[]? fbsSku,
 869            string uri,
 870            Func<StocksByWarehouseRequest, string, Task<JObject>> send,
 871            Action<Exception>? onError)
 872        {
 12873            var products = new JArray();
 12874            string? cursor = null;
 875
 30876            for (var page = 1; page <= WarehouseStocksMaxPages; page++)
 877            {
 15878                var request = new StocksByWarehouseRequest
 15879                {
 15880                    Sku = sku,
 15881                    FbsSku = fbsSku,
 15882                    Limit = WarehouseStocksPageLimit,
 15883                    Cursor = cursor
 15884                };
 885
 886                JObject? response;
 887                try
 888                {
 15889                    response = await send(request, uri).ConfigureAwait(false);
 14890                }
 1891                catch (Exception ex) when (onError != null)
 892                {
 1893                    onError(ex);
 1894                    break;
 895                }
 896
 14897                if (response is null)
 898                {
 899                    break;
 900                }
 901
 14902                var pageItems = response["products"] as JArray ?? response["result"] as JArray;
 14903                if (pageItems != null)
 904                {
 52905                    foreach (var entry in pageItems)
 906                    {
 12907                        products.Add(entry);
 908                    }
 909                }
 910
 14911                if (response["has_next"]?.Value<bool>() != true)
 912                {
 913                    break;
 914                }
 915
 4916                var nextCursor = response["cursor"]?.ToString();
 4917                if (string.IsNullOrWhiteSpace(nextCursor) || string.Equals(nextCursor, cursor, StringComparison.Ordinal)
 918                {
 1919                    Console.WriteLine(
 1920                        "[OzonApiDataManager/stocks-by-warehouse] has_next=true, но курсор не продвинулся — " +
 1921                        $"пагинация остановлена на странице {page}, получено {products.Count} строк.");
 1922                    break;
 923                }
 924
 3925                if (page == WarehouseStocksMaxPages)
 926                {
 0927                    Console.WriteLine(
 0928                        $"[OzonApiDataManager/stocks-by-warehouse] достигнут предел в {WarehouseStocksMaxPages} " +
 0929                        $"страниц ({products.Count} строк), часть складов не прочитана — уменьшите размер пакета SKU.");
 0930                    break;
 931                }
 932
 3933                cursor = nextCursor;
 3934                await Task.Delay(WarehouseStocksPageDelayMs).ConfigureAwait(false);
 935            }
 936
 12937            return products;
 12938        }
 939
 940        /// <summary>Остатки товаров по складам FBS. POST /v1/product/info/stocks-by-warehouse/fbs.</summary>
 941        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_WAERHOUSES).</param>
 942        public async Task<JObject> GetProductWarehouses(string[] sku,
 943            string[] fbsSku,
 944            string uri = OzonApiUrl.PRODUCT_WAERHOUSES)
 945        {
 2946            var cleanSku = NormalizeOzonIds(sku);
 2947            var cleanFbsSku = NormalizeOzonIds(fbsSku);
 2948            if (cleanSku.Length == 0 && cleanFbsSku.Length == 0)
 1949                return new JObject { ["result"] = new JArray() };
 950
 1951            var products = await ReadWarehouseStockPagesAsync(
 1952                cleanSku.Length > 0 ? cleanSku : null,
 1953                cleanFbsSku.Length > 0 ? cleanFbsSku : null,
 1954                uri,
 1955                (request, url) => ozonApiClient.MakeRequestPostAsync(request, url),
 1956                onError: null).ConfigureAwait(false);
 957
 1958            return new JObject { ["result"] = products };
 2959        }
 960
 961        /// <summary>Остатки по складам FBS (вариант для Bitrix, с обработкой ошибок). POST /v1/product/info/stocks-by-w
 962        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_WAERHOUSES).</param>
 963        public async Task<JObject> GetProductWarehousesForBitrix(
 964            string[] sku,
 965            string[] fbsSku,
 966            string uri = OzonApiUrl.PRODUCT_WAERHOUSES)
 967        {
 12968            var cleanSku = NormalizeOzonIds(sku);
 12969            var cleanFbsSku = NormalizeOzonIds(fbsSku);
 12970            if (cleanSku.Length == 0 && cleanFbsSku.Length == 0)
 1971                return new JObject { ["result"] = new JArray() };
 972
 11973            var skuCount = cleanSku.Length + cleanFbsSku.Length;
 974
 975            // Ошибку не проглатываем молча: пустой ответ неотличим от «складов нет» и уводит
 976            // товар в FBS-fallback с суммарным остатком вместо реальной разбивки по складам.
 11977            var products = await ReadWarehouseStockPagesAsync(
 11978                cleanSku.Length > 0 ? cleanSku : null,
 11979                cleanFbsSku.Length > 0 ? cleanFbsSku : null,
 11980                uri,
 14981                (request, url) => ozonApiClient.MakeRequestPostForBitrixAsync(request, url),
 1982                onError: ex => Console.WriteLine(
 1983                    $"[OzonApiDataManager/GetProductWarehousesForBitrix] пакет из {skuCount} sku: {ex.Message}"))
 11984                .ConfigureAwait(false);
 985
 11986            return new JObject { ["result"] = products };
 12987        }
 988
 989        /// <summary>Собирает информацию по складам для товара, дополняя данными из GetProductWarehousesForBitrix.</summ
 990        public async Task<JToken?> GetWarehouseInfo(JObject stocks, string article, OzonClient client,
 991            List<WarehouseOzon> warehouseList)
 992        {
 0993            if (stocks["items"] is not JArray items || !items.HasValues)
 0994                return null;
 995
 0996            var skus = new HashSet<string>();
 997
 998            // Собираем все SKU из всех складов
 0999            foreach (var stockItem in items)
 1000            {
 01001                if (stockItem["stocks"] is not JArray itemStocks || !itemStocks.HasValues)
 1002                    continue;
 1003
 01004                foreach (var stock in itemStocks)
 1005                {
 01006                    var sku = stock["sku"]?.ToString();
 01007                    if (!string.IsNullOrEmpty(sku))
 01008                        skus.Add(sku);
 1009                }
 1010            }
 1011
 01012            if (!skus.Any()) return null;
 1013
 1014            // Получаем информацию о всех складах одним запросом
 01015            SetClient(client.DecryptClientId ?? string.Empty, client.DecryptApiKey ?? string.Empty);
 01016            var warehouseInfo = await GetProductWarehousesForBitrix(skus.ToArray(), Array.Empty<string>());
 1017
 01018            if (warehouseInfo != null && warehouseList != null)
 1019            {
 01020                var combined = await CombineWarehouseInfoJson(warehouseInfo, warehouseList, client.Name);
 01021                return combined;
 1022            }
 1023
 01024            return warehouseInfo;
 01025        }
 1026
 1027        private async Task<JObject?> CombineWarehouseInfoJson(JObject warehouseInfo, List<WarehouseOzon> warehouseList,
 1028            string client)
 1029        {
 51030            await Task.CompletedTask;
 1031            try
 1032            {
 51033                var sourceArray = warehouseInfo?["result"] as JArray ?? warehouseInfo?["products"] as JArray;
 51034                if (sourceArray == null)
 01035                    return warehouseInfo;
 1036
 51037                var existingWarehouses = sourceArray.ToObject<List<WarehouseStock>>() ?? new List<WarehouseStock>();
 1038
 51039                if (warehouseList == null || !warehouseList.Any())
 01040                    return warehouseInfo;
 1041
 51042                var missingWarehouses = new List<WarehouseStock>();
 201043                foreach (var wh in warehouseList)
 1044                {
 101045                    if (!existingWarehouses.Any(ew => ew.warehouse_name == wh.Name))
 1046                    {
 01047                        missingWarehouses.Add(new WarehouseStock
 01048                        {
 01049                            product_id = existingWarehouses.FirstOrDefault()?.product_id ?? 0,
 01050                            present = 0,
 01051                            reserved = 0,
 01052                            sku = existingWarehouses.FirstOrDefault()?.sku ?? 0,
 01053                            warehouse_id = wh.WarehouseId,
 01054                            warehouse_name = wh.Name,
 01055                            fbs_sku = existingWarehouses.FirstOrDefault()?.fbs_sku ?? 0
 01056                        });
 1057                    }
 1058                }
 1059
 51060                var allWarehousesCombined = existingWarehouses.Concat(missingWarehouses).ToList();
 51061                return new JObject
 51062                {
 51063                    // Нормализуем в result для дальнейшей обработки GetWarehousesArray
 51064                    ["result"] = JArray.FromObject(allWarehousesCombined)
 51065                };
 1066            }
 01067            catch (Exception ex)
 1068            {
 01069                Console.WriteLine($"Error combining warehouse data: {ex.Message}");
 01070                return warehouseInfo;
 1071            }
 51072        }
 1073
 1074        /// <summary>Остатки товаров (курсорная выборка по offer_id). POST /v4/product/info/stocks.</summary>
 1075        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_STOCKS).</param>
 1076        public async Task<JObject> GetProductStocks(
 1077            string[] offerIds,
 1078            string cursor = "",
 1079            int limit = 1000,
 1080            string uri = OzonApiUrl.PRODUCT_STOCKS,
 1081            int chunkSize = 200)
 1082        {
 1083            // Удаляем ведущие '
 321084            for (int i = 0; i < offerIds.Length; i++)
 1085            {
 81086                if (offerIds[i].StartsWith("'"))
 1087                {
 01088                    offerIds[i] = offerIds[i].Substring(1);
 1089                }
 1090            }
 1091
 81092            var safeChunkSize = Math.Clamp(chunkSize, 1, 1000);
 81093            var chunks = offerIds
 81094                .Select((id, index) => new { id, index })
 81095                .GroupBy(x => x.index / safeChunkSize)
 161096                .Select(g => g.Select(x => x.id).ToArray())
 81097                .ToList();
 1098
 81099            var result = new JObject();
 81100            var combinedItems = new JArray();
 1101
 321102            foreach (var chunk in chunks)
 1103            {
 1104                const int maxAttempts = 3;
 81105                Exception? lastEx = null;
 161106                for (var attempt = 1; attempt <= maxAttempts; attempt++)
 1107                {
 1108                    try
 1109                    {
 81110                        var requestModel = new ProductStocksRequest
 81111                        {
 81112                            Cursor = cursor,
 81113                            Filter = new ProductStocksRequestFilter
 81114                            {
 81115                                OfferId = chunk,
 81116                                Visibility = "ALL"
 81117                            },
 81118                            Limit = Math.Min(limit, 1000)
 81119                        };
 1120
 81121                        var response = await ozonApiClient.MakeRequestPostForBitrixAsync(requestModel, uri)
 81122                            .ConfigureAwait(false);
 81123                        if (response["items"] is JArray items)
 1124                        {
 401125                            foreach (var item in items) combinedItems.Add(item);
 1126                        }
 81127                        lastEx = null;
 81128                        break;
 1129                    }
 1130                    catch (Exception ex)
 1131                    {
 01132                        lastEx = ex;
 01133                        if (attempt < maxAttempts)
 01134                            await Task.Delay(attempt * 1200).ConfigureAwait(false);
 1135                    }
 1136                }
 81137                if (lastEx != null)
 01138                    Console.WriteLine(
 01139                        $"[OzonApiDataManager/GetProductStocks] chunk FAILED after {maxAttempts} attempts ({chunk.Length
 81140            }
 1141
 81142            result["items"] = combinedItems;
 81143            return result;
 81144        }
 1145
 1146        /// <summary>Данные по одному отправлению FBS (аналитика, финансы). POST /v3/posting/fbs/get.</summary>
 1147        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.WAERHOUS_AND_CITY).</param>
 1148        public async Task<JObject> GetProductWarehousAndCity(string productNumber,
 1149            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1150        {
 11151            var productAnalyticsRequest = new ProductAnalyticsRequest
 11152            {
 11153                PostingNumber = productNumber,
 11154
 11155                With = new ProductAnalyticsRequestFilter
 11156                {
 11157                    AnalyticsData = true,
 11158                    Barcodes = false,
 11159                    FinancialData = true,
 11160                    ProductExemplars = false,
 11161                    Translit = false
 11162                }
 11163            };
 1164
 11165            var productAnalyticsResponse =
 11166                await ozonApiClient.MakeRequestPostAsync(productAnalyticsRequest, uri).ConfigureAwait(false);
 11167            return productAnalyticsResponse;
 11168        }
 1169
 1170        /// <summary>Статус отправления FBS. POST /v3/posting/fbs/get. Возвращает result.status.</summary>
 1171        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.WAERHOUS_AND_CITY).</param>
 1172        public async Task<string> GetProductSatatus(string productNumber,
 1173            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1174        {
 1175            try
 1176            {
 11177                return await GetProductStatusRaw(productNumber, uri).ConfigureAwait(false);
 1178            }
 01179            catch
 1180            {
 01181                return string.Empty;
 1182            }
 11183        }
 1184
 1185        private async Task<string> GetProductStatusRaw(string productNumber, string uri)
 1186        {
 21187            var productAnalyticsRequest = new ProductAnalyticsRequest
 21188            {
 21189                PostingNumber = productNumber,
 21190
 21191                With = new ProductAnalyticsRequestFilter
 21192                {
 21193                    AnalyticsData = true,
 21194                    Barcodes = false,
 21195                    FinancialData = true,
 21196                    ProductExemplars = false,
 21197                    Translit = false
 21198                }
 21199            };
 1200
 21201            var productAnalyticsResponse = await ozonApiClient.MakeFastRequestAsync(productAnalyticsRequest, uri)
 21202                .ConfigureAwait(false);
 21203            return productAnalyticsResponse["result"]?["status"]?.ToString() ?? string.Empty;
 21204        }
 1205
 1206        /// <summary>
 1207        /// Батч-версия <see cref="GetProductSatatus"/> для одного и того же клиента Ozon (креды уже
 1208        /// установлены в этом экземпляре). В отличие от последовательного вызова по одному, различает
 1209        /// "Ozon ответил пустым статусом" от "запрос упал" — вызывающий код (SLA-очередь) должен
 1210        /// повторить попытку для Success=false на следующем цикле, а не считать заказ актуализированным.
 1211        /// </summary>
 1212        public async Task<Dictionary<string, (bool Success, string Status)>> GetProductStatusBatch(
 1213            IReadOnlyCollection<string> productNumbers,
 1214            int maxParallelism = 10,
 1215            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1216        {
 11217            var results = new ConcurrentDictionary<string, (bool, string)>();
 11218            var semaphore = new SemaphoreSlim(maxParallelism);
 1219
 11220            var groups = productNumbers
 31221                .Where(n => !string.IsNullOrEmpty(n))
 11222                .GroupBy(OzonPostingNumberHelper.GetOzonApiPostingNumber, StringComparer.Ordinal);
 1223
 11224            var tasks = groups.Select(async group =>
 11225            {
 11226                var cleanPosting = group.Key;
 11227                await semaphore.WaitAsync();
 11228                try
 11229                {
 11230                    var status = await GetProductStatusRaw(cleanPosting, uri).ConfigureAwait(false);
 81231                    foreach (var rawNumber in group)
 11232                    {
 31233                        results[rawNumber] = (true, status);
 11234                    }
 11235                    results[cleanPosting] = (true, status);
 11236                }
 01237                catch
 11238                {
 01239                    foreach (var rawNumber in group)
 11240                    {
 01241                        results[rawNumber] = (false, string.Empty);
 11242                    }
 01243                    results[cleanPosting] = (false, string.Empty);
 01244                }
 11245                finally
 11246                {
 11247                    semaphore.Release();
 11248                }
 21249            });
 1250
 11251            await Task.WhenAll(tasks);
 11252            return new Dictionary<string, (bool, string)>(results);
 11253        }
 1254
 1255        /// <summary>Подстатус отправления FBS. POST /v3/posting/fbs/get. Возвращает result.substatus.</summary>
 1256        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.WAERHOUS_AND_CITY).</param>
 1257        public async Task<string> GetProductSubSatatus(string productNumber,
 1258            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1259        {
 1260            try
 1261            {
 01262                var productAnalyticsRequest = new ProductAnalyticsRequest
 01263                {
 01264                    PostingNumber = productNumber,
 01265
 01266                    With = new ProductAnalyticsRequestFilter
 01267                    {
 01268                        AnalyticsData = true,
 01269                        Barcodes = false,
 01270                        FinancialData = true,
 01271                        ProductExemplars = false,
 01272                        Translit = false
 01273                    }
 01274                };
 1275
 01276                var productAnalyticsResponse = await ozonApiClient.MakeFastRequestAsync(productAnalyticsRequest, uri)
 01277                    .ConfigureAwait(false);
 01278                return productAnalyticsResponse["result"]?["substatus"]?.ToString() ?? string.Empty;
 1279            }
 01280            catch
 1281            {
 01282                return string.Empty;
 1283            }
 01284        }
 1285
 1286        /// <summary>product_id (SKU) первого товара в отправлении FBS. POST /v3/posting/fbs/get.
 1287        /// Пробует financial_data.products[0].product_id, при отсутствии или нуле — products[0].sku.
 1288        /// Ошибки самого запроса (сеть, авторизация, недостающая роль ключа и т.п.) не глушатся —
 1289        /// пробрасываются наверх, чтобы вызывающий код (OrdersOzonShipService/OrderToSupplierOzonShipService)
 1290        /// мог показать пользователю настоящую причину через OzonLabelPrintErrorHelper, а не общий
 1291        /// "Не удалось определить product_id".</summary>
 1292        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.WAERHOUS_AND_CITY).</param>
 1293        public async Task<string> GetProductId(string productNumber,
 1294            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1295        {
 01296            var productAnalyticsRequest = new ProductAnalyticsRequest
 01297            {
 01298                PostingNumber = productNumber,
 01299
 01300                With = new ProductAnalyticsRequestFilter
 01301                {
 01302                    AnalyticsData = true,
 01303                    Barcodes = false,
 01304                    FinancialData = true,
 01305                    ProductExemplars = false,
 01306                    Translit = false
 01307                }
 01308            };
 1309
 01310            var productAnalyticsResponse = await ozonApiClient.MakeFastRequestAsync(productAnalyticsRequest, uri)
 01311                .ConfigureAwait(false);
 1312
 01313            var financialProductId = productAnalyticsResponse["result"]?["financial_data"]?["products"]?[0]?["product_id
 01314            if (!string.IsNullOrEmpty(financialProductId) && financialProductId != "0")
 01315                return financialProductId;
 1316
 1317            // Fallback: financial_data может отсутствовать или содержать product_id=0 —
 1318            // берём sku из result.products[], который доступен всегда.
 01319            return productAnalyticsResponse["result"]?["products"]?[0]?["sku"]?.ToString()
 01320                   ?? string.Empty;
 01321        }
 1322
 1323        /// <summary>
 1324        /// Товары и способ доставки FBS-отправления одним запросом. POST /v3/posting/fbs/get.
 1325        /// Ошибки запроса пробрасываются.
 1326        /// </summary>
 1327        public async Task<(List<JObject> Products, string? DeliveryMethodName, string? TplProvider)>
 1328            GetPostingProductsWithDeliveryAsync(string postingNumber, string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1329        {
 01330            var request = new ProductAnalyticsRequest
 01331            {
 01332                PostingNumber = postingNumber,
 01333                With = new ProductAnalyticsRequestFilter
 01334                {
 01335                    AnalyticsData = false,
 01336                    Barcodes = false,
 01337                    FinancialData = false,
 01338                    ProductExemplars = false,
 01339                    Translit = false
 01340                }
 01341            };
 1342
 01343            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 01344            var result = response["result"];
 01345            var products = (result?["products"] as JArray)?.OfType<JObject>().ToList() ?? new List<JObject>();
 01346            return (products,
 01347                result?["delivery_method"]?["name"]?.ToString(),
 01348                result?["delivery_method"]?["tpl_provider"]?.ToString());
 01349        }
 1350
 1351        public async Task<List<JObject>> GetProducts(string productNumber,
 1352            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1353        {
 1354            try
 1355            {
 01356                var productAnalyticsRequest = new ProductAnalyticsRequest
 01357                {
 01358                    PostingNumber = productNumber,
 01359                    With = new ProductAnalyticsRequestFilter
 01360                    {
 01361                        AnalyticsData = true,
 01362                        Barcodes = false,
 01363                        FinancialData = true,
 01364                        ProductExemplars = false,
 01365                        Translit = false
 01366                    }
 01367                };
 1368
 01369                var productAnalyticsResponse = await ozonApiClient.MakeFastRequestAsync(productAnalyticsRequest, uri);
 01370                var products = productAnalyticsResponse["result"]?["products"] as JArray;
 01371                if (products == null)
 1372                {
 01373                    return new List<JObject>();
 1374                }
 1375
 01376                return products.Cast<JObject>().ToList();
 1377            }
 01378            catch
 1379            {
 01380                return new List<JObject>();
 1381            }
 01382        }
 1383
 1384        /// <summary>
 1385        /// Блок requirements FBS-отправления: какие данные Ozon требует заполнить до сборки.
 1386        /// Возвращает SKU → список типов требований (country, change_country, gtd, rnpt,
 1387        /// mandatory_mark, jw_uin, imei, weight) и сами товары отправления.
 1388        /// </summary>
 1389        public async Task<(Dictionary<long, List<string>> RequirementsBySku, List<JObject> Products)>
 1390            GetFbsPostingRequirementsAsync(string postingNumber, string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1391        {
 01392            var request = new ProductAnalyticsRequest
 01393            {
 01394                PostingNumber = postingNumber,
 01395                With = new ProductAnalyticsRequestFilter
 01396                {
 01397                    AnalyticsData = false,
 01398                    Barcodes = false,
 01399                    FinancialData = false,
 01400                    ProductExemplars = false,
 01401                    Translit = false
 01402                }
 01403            };
 1404
 01405            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 01406            var requirements = response["result"]?["requirements"] as JObject;
 01407            var products = (response["result"]?["products"] as JArray)?.OfType<JObject>().ToList() ?? [];
 1408
 01409            var bySku = new Dictionary<long, List<string>>();
 01410            if (requirements == null)
 01411                return (bySku, products);
 1412
 01413            foreach (var property in requirements.Properties())
 1414            {
 01415                if (!property.Name.StartsWith("products_requiring_", StringComparison.Ordinal) ||
 01416                    property.Value is not JArray skus)
 1417                {
 1418                    continue;
 1419                }
 1420
 01421                var requirementType = property.Name["products_requiring_".Length..];
 01422                foreach (var sku in skus.Values<long>())
 1423                {
 01424                    if (!bySku.TryGetValue(sku, out var types))
 1425                    {
 01426                        types = [];
 01427                        bySku[sku] = types;
 1428                    }
 1429
 01430                    if (!types.Contains(requirementType))
 01431                        types.Add(requirementType);
 1432                }
 1433            }
 1434
 01435            return (bySku, products);
 01436        }
 1437
 1438        /// <summary>
 1439        /// Товары FBS-отправления, для которых Ozon требует указать страну-изготовителя до сборки.
 1440        /// </summary>
 1441        public async Task<List<JObject>> GetFbsProductsRequiringCountryAsync(string postingNumber,
 1442            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1443        {
 01444            var (requirementsBySku, products) = await GetFbsPostingRequirementsAsync(postingNumber, uri)
 01445                .ConfigureAwait(false);
 1446
 01447            var requiredSkus = requirementsBySku
 01448                .Where(pair => pair.Value.Contains("country") || pair.Value.Contains("change_country"))
 01449                .Select(pair => pair.Key)
 01450                .ToHashSet();
 1451
 01452            if (requiredSkus.Count == 0)
 01453                return [];
 1454
 01455            return products
 01456                .Where(product => requiredSkus.Contains(product["sku"]?.Value<long>() ?? 0))
 01457                .ToList();
 01458        }
 1459
 1460        /// <summary>
 1461        /// Данные экземпляров отправления: exemplar_id и флаги требований (ЧЗ, ГТД, РНПТ, вес, УИН).
 1462        /// POST /v6/fbs/posting/product/exemplar/create-or-get.
 1463        /// </summary>
 1464        public async Task<JObject> GetFbsExemplarsCreateOrGetAsync(string postingNumber,
 1465            string uri = OzonApiUrl.FBS_EXEMPLAR_CREATE_OR_GET)
 1466        {
 01467            if (string.IsNullOrWhiteSpace(postingNumber))
 01468                throw new ArgumentException("Номер отправления не указан", nameof(postingNumber));
 1469
 01470            return await ozonApiClient.MakeFastRequestAsync(
 01471                new FbsExemplarPostingRequest { PostingNumber = postingNumber }, uri).ConfigureAwait(false);
 01472        }
 1473
 1474        /// <summary>
 1475        /// Сохраняет данные экземпляров отправления. Асинхронный метод: результат проверок
 1476        /// забирается через <see cref="GetFbsExemplarStatusAsync"/>.
 1477        /// POST /v6/fbs/posting/product/exemplar/set.
 1478        /// </summary>
 1479        public async Task<JObject> SetFbsExemplarsAsync(FbsExemplarSetRequest request,
 1480            string uri = OzonApiUrl.FBS_EXEMPLAR_SET)
 1481        {
 01482            if (string.IsNullOrWhiteSpace(request.PostingNumber))
 01483                throw new ArgumentException("Номер отправления не указан", nameof(request));
 01484            if (request.Products.Count == 0)
 01485                throw new ArgumentException("Не переданы товары с экземплярами", nameof(request));
 1486
 01487            return await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 01488        }
 1489
 1490        /// <summary>
 1491        /// Статус проверки экземпляров и готовность отправления к сборке.
 1492        /// POST /v5/fbs/posting/product/exemplar/status.
 1493        /// </summary>
 1494        public async Task<JObject> GetFbsExemplarStatusAsync(string postingNumber,
 1495            string uri = OzonApiUrl.FBS_EXEMPLAR_STATUS)
 1496        {
 01497            if (string.IsNullOrWhiteSpace(postingNumber))
 01498                throw new ArgumentException("Номер отправления не указан", nameof(postingNumber));
 1499
 01500            return await ozonApiClient.MakeFastRequestAsync(
 01501                new FbsExemplarPostingRequest { PostingNumber = postingNumber }, uri).ConfigureAwait(false);
 01502        }
 1503
 1504        /// <summary>Список стран-изготовителей, доступных в Ozon FBS.</summary>
 1505        public async Task<List<JObject>> GetFbsProductCountriesAsync(string? search = null,
 1506            string uri = OzonApiUrl.FBS_PRODUCT_COUNTRY_LIST)
 1507        {
 01508            var response = await ozonApiClient.MakeFastRequestAsync(new FbsProductCountryListRequest
 01509            {
 01510                NameSearch = search?.Trim() ?? string.Empty
 01511            }, uri).ConfigureAwait(false);
 1512
 01513            return (response["result"] as JArray)?.OfType<JObject>().ToList() ?? [];
 01514        }
 1515
 1516        /// <summary>Сохраняет страну-изготовителя товара в FBS-отправлении до его сборки.</summary>
 1517        public async Task SetFbsProductCountryAsync(string postingNumber, long productId, string countryIsoCode,
 1518            string uri = OzonApiUrl.FBS_PRODUCT_COUNTRY_SET)
 1519        {
 01520            if (string.IsNullOrWhiteSpace(postingNumber))
 01521                throw new ArgumentException("Номер отправления не указан", nameof(postingNumber));
 01522            if (productId <= 0)
 01523                throw new ArgumentOutOfRangeException(nameof(productId));
 01524            if (string.IsNullOrWhiteSpace(countryIsoCode))
 01525                throw new ArgumentException("Код страны не указан", nameof(countryIsoCode));
 1526
 01527            await ozonApiClient.MakeFastRequestAsync(new FbsProductCountrySetRequest
 01528            {
 01529                PostingNumber = postingNumber,
 01530                ProductId = productId,
 01531                CountryIsoCode = countryIsoCode.Trim().ToUpperInvariant()
 01532            }, uri).ConfigureAwait(false);
 01533        }
 1534
 1535        /// <summary>
 1536        /// Все товары отправления FBS для /v4/posting/fbs/ship: SKU и количество, сгруппированные по SKU.
 1537        /// POST /v3/posting/fbs/get. Пустой список при ошибке запроса или отсутствии товаров.
 1538        /// </summary>
 1539        public async Task<List<FbsShipProduct>> GetPostingShipProductsAsync(string postingNumber,
 1540            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1541        {
 01542            var products = await GetProducts(postingNumber, uri).ConfigureAwait(false);
 01543            return products
 01544                .Select(p => new
 01545                {
 01546                    Sku = p["sku"]?.Value<long?>() ?? 0,
 01547                    Quantity = p["quantity"]?.Value<int?>() ?? 0
 01548                })
 01549                .Where(p => p.Sku > 0 && p.Quantity > 0)
 01550                .GroupBy(p => p.Sku)
 01551                .Select(g => new FbsShipProduct { ProductId = g.Key, Quantity = g.Sum(p => p.Quantity) })
 01552                .ToList();
 01553        }
 1554
 1555        /// <summary>
 1556        /// Количество товара (sku) в отправлении FBS. POST /v3/posting/fbs/get.
 1557        /// </summary>
 1558        /// <param name="postingNumber">Номер отправления.</param>
 1559        /// <param name="sku">SKU товара в Ozon (product_id).</param>
 1560        /// <returns>Суммарное количество или 0 при ошибке/отсутствии.</returns>
 1561        public async Task<int> GetPostingProductQuantityAsync(string postingNumber, long sku,
 1562            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1563        {
 01564            var products = await GetProducts(postingNumber, uri);
 01565            var quantity = 0;
 01566            foreach (var p in products)
 1567            {
 01568                quantity += p["quantity"]?.Value<int>() ?? 0;
 1569            }
 01570            return quantity;
 01571        }
 1572
 1573        /// <summary>
 1574        /// Количество товара (sku) в отправлении FBS. POST /v3/posting/fbs/get.
 1575        /// </summary>
 1576        /// <param name="postingNumber">Номер отправления.</param>
 1577        /// <param name="sku">SKU товара в Ozon (product_id).</param>
 1578        /// <returns>Суммарное количество или 0 при ошибке/отсутствии.</returns>
 1579        public async Task<long?> GetPostingProductSkuAsync(string postingNumber, long sku,
 1580            string uri = OzonApiUrl.WAERHOUS_AND_CITY)
 1581        {
 01582            var products = await GetProducts(postingNumber, uri);
 01583            long? productSku = 0;
 01584            foreach (var p in products)
 1585            {
 01586                productSku = p["sku"]?.Value<long?>();
 1587            }
 01588            return productSku;
 01589        }
 1590
 1591        /// <summary>Проверка существования товаров по артикулам и product_id. POST /v5/product/info/prices.</summary>
 1592        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_PRICES).</param>
 1593        public async Task<bool> CheckOrderForExistence(string[] articles,
 1594            string[] ozonProductIds,
 1595            string uri = OzonApiUrl.PRODUCT_PRICES)
 1596        {
 01597            var offerIds = NormalizeOzonIds(articles);
 01598            var productIds = NormalizeOzonIds(ozonProductIds);
 01599            if (offerIds.Length == 0 && productIds.Length == 0)
 01600                return false;
 1601
 01602            var productPricesRequest = new ProductPricesRequest
 01603            {
 01604                Filter = new ProductPricesRequestFilter
 01605                {
 01606                    OfferId = offerIds.Length > 0 ? offerIds : null,
 01607                    ProductId = productIds.Length > 0 ? productIds : null,
 01608                    Visibility = "ALL"
 01609                },
 01610                LastId = "",
 01611                Limit = 500
 01612            };
 1613            try
 1614            {
 01615                var productPricesResponse =
 01616                    await ozonApiClient.MakeRequestPostAsync(productPricesRequest, uri).ConfigureAwait(false);
 01617                return true;
 1618            }
 01619            catch
 1620            {
 01621                return false;
 1622            }
 01623        }
 1624
 1625        /// <summary>Обновление остатков товаров. POST /v2/products/stocks.</summary>
 1626        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.UPDATE_PRODUCT_STOCKS).</param>
 1627        public async Task<UpdateStocksResponse> UpdateProductStocks(
 1628            List<StockItemForUpdate> stocks,
 1629            string uri = OzonApiUrl.UPDATE_PRODUCT_STOCKS)
 1630        {
 01631            var request = new UpdateStocksRequest
 01632            {
 01633                Stocks = stocks
 01634            };
 1635
 01636            var response = await ozonApiClient.MakeStocksUpdateRequestAsync(request, uri).ConfigureAwait(false);
 1637
 01638            return response.ToObject<UpdateStocksResponse>() ?? new UpdateStocksResponse();
 01639        }
 1640
 1641        /// <summary>Ищет Ozon product_id по offer_id (stocks, затем prices).</summary>
 1642        public async Task<long> TryResolveProductIdByOfferIdAsync(string offerId, string? article = null)
 1643        {
 01644            if (string.IsNullOrWhiteSpace(offerId))
 1645            {
 01646                return 0;
 1647            }
 1648
 01649            var candidates = Services.BitrixStockServices.BitrixOzonStockLinkage
 01650                .BuildOfferIdCandidates(offerId, article)
 01651                .Where(c => !string.IsNullOrWhiteSpace(c))
 01652                .ToArray();
 01653            if (candidates.Length == 0)
 1654            {
 01655                return 0;
 1656            }
 1657
 1658            try
 1659            {
 01660                var stocks = await GetProductStocks(candidates).ConfigureAwait(false);
 01661                if (stocks["items"] is JArray items)
 1662                {
 01663                    foreach (var item in items)
 1664                    {
 01665                        var productId = item["product_id"]?.Value<long>() ?? 0;
 01666                        if (productId > 0)
 1667                        {
 01668                            return productId;
 1669                        }
 1670                    }
 1671                }
 01672            }
 01673            catch (Exception ex)
 1674            {
 01675                Console.WriteLine(
 01676                    $"[OzonApiDataManager/TryResolveProductIdByOfferIdAsync] stocks lookup failed for {offerId}: {ex.Mes
 01677            }
 1678
 1679            try
 1680            {
 01681                var prices = await GetProductPricesByArticlesModel(candidates).ConfigureAwait(false);
 01682                var found = prices?.Items?.FirstOrDefault(i => i.ProductId > 0);
 01683                if (found != null)
 1684                {
 01685                    return found.ProductId;
 1686                }
 01687            }
 01688            catch (Exception ex)
 1689            {
 01690                Console.WriteLine(
 01691                    $"[OzonApiDataManager/TryResolveProductIdByOfferIdAsync] prices lookup failed for {offerId}: {ex.Mes
 01692            }
 1693
 01694            return 0;
 01695        }
 1696
 1697        /// <summary>Список складов продавца. POST /v1/warehouse/list.</summary>
 1698        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.WAREHOUSE_LIST).</param>
 1699        public async Task<List<WarehouseOzon>> GetWarehouseList(string uri = OzonApiUrl.WAREHOUSE_LIST)
 1700        {
 1701            try
 1702            {
 01703                var emptyRequest = new EmptyRequest();
 01704                var response = await ozonApiClient.MakeRequestPostAsync(emptyRequest, uri).ConfigureAwait(false);
 01705                var warehouseResponse = response.ToObject<WarehouseResponse>();
 1706
 01707                return warehouseResponse?.Warehouses ?? new List<WarehouseOzon>();
 1708            }
 01709            catch (Exception ex)
 1710            {
 01711                Console.WriteLine($"Error getting warehouse list: {ex.Message}");
 01712                return new List<WarehouseOzon>();
 1713            }
 01714        }
 1715
 1716        /// <summary>
 1717        /// Список складов для отчётов BitrixStock: с повторами при 429/таймауте (клиент OzonApiBitrix, 90 с)
 1718        /// и без проглатывания ошибки. Пустой список из-за сбоя неотличим от «складов нет» и приводит к
 1719        /// массовому FbsNoWarehouse и обнулению складов на этапе 3, поэтому сбой пробрасывается наружу.
 1720        /// </summary>
 1721        public async Task<List<WarehouseOzon>> GetWarehouseListOrThrowAsync(string uri = OzonApiUrl.WAREHOUSE_LIST)
 1722        {
 01723            var response = await ozonApiClient.MakeRequestPostForBitrixAsync(new EmptyRequest(), uri).ConfigureAwait(fal
 01724            return response.ToObject<WarehouseResponse>()?.Warehouses ?? new List<WarehouseOzon>();
 01725        }
 1726
 1727        /// <summary>Список ролей пользователей API. POST /v1/roles.</summary>
 1728        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.ROLES).</param>
 1729        public async Task<RolesResponse> GetApiRoles(string uri = OzonApiUrl.ROLES)
 1730        {
 1731            try
 1732            {
 01733                var emptyRequest = new EmptyRequest();
 01734                var response = await ozonApiClient.MakeRequestPostAsync(emptyRequest, uri).ConfigureAwait(false);
 01735                var rolesResponse = response.ToObject<RolesResponse>();
 01736                return rolesResponse ?? new RolesResponse { Roles = new List<Role>() };
 1737            }
 01738            catch (Exception ex)
 1739            {
 01740                Console.WriteLine($"Error getting API roles: {ex.Message}");
 01741                return new RolesResponse { Roles = new List<Role>() };
 1742            }
 01743        }
 1744
 1745        /// <summary>Информация о продавце. POST /v1/seller/info.</summary>
 1746        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.SELLER_INFO).</param>
 1747        public async Task<SellerInfoResponse> GetSellerInfo(string uri = OzonApiUrl.SELLER_INFO)
 1748        {
 1749            try
 1750            {
 01751                var emptyRequest = new EmptyRequest();
 01752                var response = await ozonApiClient.MakeRequestPostAsync(emptyRequest, uri).ConfigureAwait(false);
 01753                var sellerInfoResponse = response.ToObject<SellerInfoResponse>();
 01754                return sellerInfoResponse ?? new SellerInfoResponse
 01755                {
 01756                    Company = new Company(),
 01757                    Ratings = new List<Rating>(),
 01758                    Subscription = new Subscription()
 01759                };
 1760            }
 01761            catch (Exception ex)
 1762            {
 01763                Console.WriteLine($"Error getting seller info: {ex.Message}");
 01764                return new SellerInfoResponse
 01765                {
 01766                    Company = new Company(),
 01767                    Ratings = new List<Rating>(),
 01768                    Subscription = new Subscription()
 01769                };
 1770            }
 01771        }
 1772
 1773        /// <summary>Импорт цен товаров. POST /v1/product/import/prices (пакеты до 1000 позиций).</summary>
 1774        /// <param name="prices">Список позиций для обновления цен.</param>
 1775        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PRODUCT_IMPORT_PRICES).</param>
 1776        public async Task<ProductImportPricesResponse> ImportProductPrices(
 1777            List<ProductPriceItem> prices,
 1778            string uri = OzonApiUrl.PRODUCT_IMPORT_PRICES)
 1779        {
 11780            if (prices == null || prices.Count == 0)
 01781                throw new ArgumentException("Список цен для импорта пуст", nameof(prices));
 1782
 1783            // Ozon API допускает от 1 до 1000 элементов в одном запросе.
 1784            // При превышении 1000 разбиваем на порции и объединяем результат.
 1785            const int maxOzonBatchSize = 1000;
 11786            if (prices.Count > maxOzonBatchSize)
 1787            {
 11788                var combinedResult = new List<ProductImportPriceResult>();
 81789                foreach (var chunk in prices.Chunk(maxOzonBatchSize))
 1790                {
 31791                    var chunkResponse = await ImportProductPricesChunk(chunk.ToList(), uri).ConfigureAwait(false);
 31792                    if (chunkResponse.Result != null)
 31793                        combinedResult.AddRange(chunkResponse.Result);
 1794                }
 11795                return new ProductImportPricesResponse { Result = combinedResult };
 1796            }
 1797
 01798            return await ImportProductPricesChunk(prices, uri).ConfigureAwait(false);
 11799        }
 1800
 1801        private async Task<ProductImportPricesResponse> ImportProductPricesChunk(
 1802            List<ProductPriceItem> prices,
 1803            string uri)
 1804        {
 31805            var request = new ProductImportPricesRequest
 31806            {
 31807                Prices = prices
 31808            };
 1809
 1810            const int maxRetries = 3;
 31811            var delayMs = 700;
 31812            Exception? lastException = null;
 1813
 61814            for (var attempt = 1; attempt <= maxRetries; attempt++)
 1815            {
 1816                try
 1817                {
 31818                    var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 31819                    return response.ToObject<ProductImportPricesResponse>() ?? new ProductImportPricesResponse();
 1820                }
 1821                catch (Exception ex)
 1822                {
 01823                    lastException = ex;
 01824                    var message = ex.Message ?? string.Empty;
 01825                    var isTransient = message.Contains("429") ||
 01826                                      message.Contains("500") ||
 01827                                      message.Contains("502") ||
 01828                                      message.Contains("503") ||
 01829                                      message.Contains("504") ||
 01830                                      message.Contains("timeout", StringComparison.OrdinalIgnoreCase);
 01831                    if (!isTransient || attempt == maxRetries)
 1832                        break;
 1833
 01834                    await Task.Delay(delayMs).ConfigureAwait(false);
 01835                    delayMs *= 2;
 1836                }
 1837            }
 1838
 01839            throw new Exception($"ImportProductPrices failed after retries: {lastException?.Message}", lastException);
 31840        }
 1841
 1842        /// <summary>
 1843        /// Сборка заказа FBS. POST /v4/posting/fbs/ship.
 1844        /// Переводит отправление в статус awaiting_deliver. Результат проверять через /v3/posting/fbs/get.
 1845        /// </summary>
 1846        /// <param name="postingNumber">Номер отправления (posting_number).</param>
 1847        /// <param name="packages">Массив пакетов с товарами и количеством.</param>
 1848        /// <param name="withAdditionalData">Запрашивать additional_data в ответе.</param>
 1849        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.FBS_SHIP).</param>
 1850        public async Task<JObject> ShipFbsPosting(
 1851            string postingNumber,
 1852            List<FbsShipPackage> packages,
 1853            bool withAdditionalData = true,
 1854            string uri = OzonApiUrl.FBS_SHIP)
 1855        {
 01856            if (string.IsNullOrWhiteSpace(postingNumber))
 01857                throw new ArgumentException("postingNumber не может быть пустым", nameof(postingNumber));
 1858
 01859            if (packages == null || packages.Count == 0)
 01860                throw new ArgumentException("Необходимо передать хотя бы один пакет с товарами", nameof(packages));
 1861
 01862            var request = new FbsShipRequest
 01863            {
 01864                PostingNumber = postingNumber,
 01865                Packages = packages,
 01866                With = new FbsShipWith
 01867                {
 01868                    AdditionalData = withAdditionalData
 01869                }
 01870            };
 1871
 1872            // Ответ с кодом 200 не гарантирует успешную сборку заказа.
 1873            // После вызова этого метода рекомендуется вызвать v3/posting/fbs/get и проверить substatus.
 01874            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 01875            return response;
 01876        }
 1877
 1878        /// <summary>
 1879        /// Список неотгруженных FBS-отправлений с cursor-пагинацией. POST /v4/posting/fbs/unfulfilled/list.
 1880        /// Собирает все страницы автоматически.
 1881        /// </summary>
 1882        public async Task<List<UnfulfilledPosting>> GetUnfulfilledPostingsAsync(
 1883            DateTime cutoffFrom,
 1884            DateTime cutoffTo,
 1885            string status = "awaiting_deliver",
 1886            string uri = OzonApiUrl.FBS_UNFULFILLED_LIST)
 1887        {
 01888            var result = new List<UnfulfilledPosting>();
 01889            string? cursor = null;
 01890            var hasNext = true;
 1891
 01892            while (hasNext)
 1893            {
 01894                var req = new UnfulfilledListRequest
 01895                {
 01896                    Filter = new UnfulfilledListFilter
 01897                    {
 01898                        CutoffFrom = cutoffFrom.ToString("yyyy-MM-ddTHH:mm:sszzz"),
 01899                        CutoffTo = cutoffTo.ToString("yyyy-MM-ddTHH:mm:sszzz"),
 01900                        Status = status,
 01901                    },
 01902                    Limit = 50,
 01903                    Cursor = cursor,
 01904                };
 1905
 01906                var raw = await ozonApiClient.MakeFastRequestAsync(req, uri).ConfigureAwait(false);
 01907                var resp = raw.ToObject<UnfulfilledListResponse>() ?? new UnfulfilledListResponse();
 01908                result.AddRange(resp.Postings);
 01909                hasNext = resp.HasNext;
 01910                cursor = resp.Cursor;
 1911            }
 1912
 01913            return result;
 01914        }
 1915
 1916        /// <summary>
 1917        /// Создание задания на формирование этикеток. POST /v3/posting/fbs/package-label/create.
 1918        /// Для отправлений в статусе awaiting_deliver. Возвращает задания (task_id, task_type: big_label/small_label).
 1919        /// </summary>
 1920        /// <param name="postingNumbers">Номера отправлений.</param>
 1921        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PACKAGE_LABEL_CREATE).</param>
 1922        public async Task<PackageLabelCreateResponse> CreatePackageLabelTask(
 1923            string[] postingNumbers,
 1924            string uri = OzonApiUrl.PACKAGE_LABEL_CREATE)
 1925        {
 01926            if (postingNumbers == null || postingNumbers.Length == 0)
 01927                throw new ArgumentException("Необходимо передать хотя бы один номер отправления",
 01928                    nameof(postingNumbers));
 1929
 01930            var request = new PackageLabelCreateRequest
 01931            {
 01932                PostingNumbers = postingNumbers
 01933            };
 1934
 01935            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 01936            return response.ToObject<PackageLabelCreateResponse>() ?? new PackageLabelCreateResponse();
 01937        }
 1938
 1939        /// <summary>
 1940        /// Получение файла этикеток по task_id. POST /v2/posting/fbs/package-label/get.
 1941        /// Вызывать после CreatePackageLabelTask.
 1942        /// </summary>
 1943        /// <param name="taskId">Номер задания из ответа CreatePackageLabelTask.</param>
 1944        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.PACKAGE_LABEL_GET).</param>
 1945        public async Task<PackageLabelGetResponse> GetPackageLabelFile(
 1946            long taskId,
 1947            string uri = OzonApiUrl.PACKAGE_LABEL_GET)
 1948        {
 01949            if (taskId <= 0)
 01950                throw new ArgumentException("taskId должен быть больше нуля", nameof(taskId));
 1951
 01952            var request = new PackageLabelGetRequest
 01953            {
 01954                TaskId = taskId
 01955            };
 1956
 01957            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 01958            return response.ToObject<PackageLabelGetResponse>() ?? new PackageLabelGetResponse();
 01959        }
 1960
 1961        /// <summary>
 1962        /// Передача отправлений к отгрузке (статус awaiting_deliver). POST /v2/posting/fbs/awaiting-delivery.
 1963        /// До 100 номеров за запрос.
 1964        /// </summary>
 1965        /// <param name="postingNumbers">Номера отправлений.</param>
 1966        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.FBS_AWAITING_DELIVERY).</param>
 1967        public async Task<AwaitingDeliveryResponse> SetAwaitingDelivery(
 1968            string[] postingNumbers,
 1969            string uri = OzonApiUrl.FBS_AWAITING_DELIVERY)
 1970        {
 01971            if (postingNumbers == null || postingNumbers.Length == 0)
 01972                throw new ArgumentException(
 01973                    "Необходимо передать хотя бы один номер отправления",
 01974                    nameof(postingNumbers));
 1975
 01976            if (postingNumbers.Length > 100)
 01977                throw new ArgumentException(
 01978                    "Максимум 100 отправлений в одном запросе",
 01979                    nameof(postingNumbers));
 1980
 01981            var request = new AwaitingDeliveryRequest
 01982            {
 01983                PostingNumber = postingNumbers
 01984            };
 1985
 01986            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 01987            return response.ToObject<AwaitingDeliveryResponse>() ?? new AwaitingDeliveryResponse();
 01988        }
 1989
 1990        /// <summary>Начисления по отправлениям. POST /v1/finance/accrual/postings.</summary>
 1991        public async Task<FinanceAccrualPostingsResponse> GetFinanceAccrualPostingsAsync(
 1992            FinanceAccrualPostingsRequest request,
 1993            string uri = OzonApiUrl.FINANCE_ACCRUAL_POSTINGS)
 1994        {
 01995            ArgumentNullException.ThrowIfNull(request);
 01996            if (request.PostingNumbers.Count is < 1 or > 100)
 01997                throw new ArgumentOutOfRangeException(nameof(request), "Нужно передать от 1 до 100 номеров отправлений."
 1998
 01999            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 02000            return response.ToObject<FinanceAccrualPostingsResponse>() ?? new FinanceAccrualPostingsResponse();
 02001        }
 2002
 2003        /// <summary>Справочник типов начислений. POST /v1/finance/accrual/types.</summary>
 2004        public async Task<FinanceAccrualTypesResponse> GetFinanceAccrualTypesAsync(
 2005            string uri = OzonApiUrl.FINANCE_ACCRUAL_TYPES)
 2006        {
 02007            var response = await ozonApiClient.MakeFastRequestAsync(new FinanceAccrualTypesRequest(), uri).ConfigureAwai
 02008            return response.ToObject<FinanceAccrualTypesResponse>() ?? new FinanceAccrualTypesResponse();
 02009        }
 2010
 2011        /// <summary>
 2012        /// Информация о возвратах FBO и FBS. POST /v1/returns/list.
 2013        /// Используйте только один фильтр по дате: logistic_return_date,
 2014        /// storage_tariffication_start_date или visual_status_change_moment.
 2015        /// Для пагинации передавайте last_id из предыдущего ответа, пока has_next == true.
 2016        /// </summary>
 2017        /// <param name="request">Фильтр и limit (1–500), опционально last_id.</param>
 2018        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.RETURNS_LIST).</param>
 2019        /// <returns>Список возвратов и флаг has_next для пагинации.</returns>
 2020        public async Task<ReturnListResponse> GetReturnsListAsync(
 2021            ReturnListRequest request,
 2022            string uri = OzonApiUrl.RETURNS_LIST)
 2023        {
 02024            if (request == null)
 02025                throw new ArgumentNullException(nameof(request));
 02026            if (request.Limit < 1 || request.Limit > 500)
 02027                throw new ArgumentOutOfRangeException(nameof(request), "limit должен быть от 1 до 500");
 2028
 2029            // Используем OzonApiReturns (без Circuit Breaker), чтобы загрузка возвратов не блокировалась при открытом к
 02030            var response = await ozonApiClient.MakeFastRequestAsync(request, uri, "OzonApiReturns").ConfigureAwait(false
 02031            var result = response.ToObject<ReturnListResponse>();
 02032            return result ?? new ReturnListResponse();
 02033        }
 2034
 2035        /// <summary>
 2036        /// Разделить заказ на отправления без сборки. POST /v1/posting/fbs/split.
 2037        /// Позволяет разделить один заказ на несколько отправлений.
 2038        /// </summary>
 2039        /// <param name="postingNumber">Номер отправления для разделения.</param>
 2040        /// <param name="postings">Список отправлений, на которые поделится заказ.</param>
 2041        /// <param name="uri">URL эндпоинта (по умолчанию OzonApiUrl.FBS_SPLIT).</param>
 2042        /// <returns>Информация об изначальном отправлении и список новых отправлений.</returns>
 2043        public async Task<FbsSplitResponse> SplitFbsPosting(
 2044            string postingNumber,
 2045            List<FbsSplitPosting> postings,
 2046            string uri = OzonApiUrl.FBS_SPLIT)
 2047        {
 02048            if (string.IsNullOrWhiteSpace(postingNumber))
 02049                throw new ArgumentException("Номер отправления не может быть пустым", nameof(postingNumber));
 2050
 02051            if (postings == null || postings.Count == 0)
 02052                throw new ArgumentException("Необходимо передать хотя бы одно отправление для разделения",
 02053                    nameof(postings));
 2054
 02055            var request = new FbsSplitRequest
 02056            {
 02057                PostingNumber = postingNumber,
 02058                Postings = postings
 02059            };
 2060
 02061            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 02062            var result = response.ToObject<FbsSplitResponse>();
 02063            return result ?? new FbsSplitResponse();
 02064        }
 2065
 2066        /// <summary>Причины отмены для отправлений FBS. POST /v1/posting/fbs/cancel-reason.</summary>
 2067        public async Task<FbsCancelReasonListResponse> GetFbsCancelReasonsAsync(
 2068            IEnumerable<string> postingNumbers,
 2069            string uri = OzonApiUrl.FBS_CANCEL_REASON)
 2070        {
 02071            var numbers = postingNumbers?
 02072                .Where(n => !string.IsNullOrWhiteSpace(n))
 02073                .Select(n => n.Trim())
 02074                .Distinct(StringComparer.OrdinalIgnoreCase)
 02075                .ToList() ?? new List<string>();
 2076
 02077            if (numbers.Count == 0)
 02078                throw new ArgumentException("Не указаны номера отправлений", nameof(postingNumbers));
 2079
 02080            var request = new FbsCancelReasonListRequest { RelatedPostingNumbers = numbers };
 02081            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 02082            return response.ToObject<FbsCancelReasonListResponse>() ?? new FbsCancelReasonListResponse();
 02083        }
 2084
 2085        public async Task<OzonCancelReasonCatalogResponse> GetFbsCancelReasonCatalogAsync(
 2086            string uri = OzonApiUrl.FBS_CANCEL_REASON_LIST)
 2087        {
 42088            var response = await ozonApiClient.MakeFastRequestAsync(new OzonCancelReasonCatalogRequest(), uri).Configure
 32089            return response.ToObject<OzonCancelReasonCatalogResponse>() ?? new OzonCancelReasonCatalogResponse();
 32090        }
 2091
 2092        public Task<OzonCancelReasonCatalogResponse> GetFboCancelReasonCatalogAsync() =>
 02093            GetFbsCancelReasonCatalogAsync(OzonApiUrl.FBO_CANCEL_REASON_LIST);
 2094
 2095        /// <summary>Отменить отправление FBS. POST /v2/posting/fbs/cancel.</summary>
 2096        public async Task<bool> CancelFbsPostingAsync(
 2097            string postingNumber,
 2098            long cancelReasonId,
 2099            string? cancelReasonMessage = null,
 2100            string uri = OzonApiUrl.FBS_POSTING_CANCEL)
 2101        {
 02102            if (string.IsNullOrWhiteSpace(postingNumber))
 02103                throw new ArgumentException("Номер отправления не может быть пустым", nameof(postingNumber));
 2104
 02105            if (cancelReasonId <= 0)
 02106                throw new ArgumentException("Не указана причина отмены", nameof(cancelReasonId));
 2107
 02108            var request = new FbsPostingCancelRequest
 02109            {
 02110                PostingNumber = postingNumber.Trim(),
 02111                CancelReasonId = cancelReasonId,
 02112                CancelReasonMessage = string.IsNullOrWhiteSpace(cancelReasonMessage) ? null : cancelReasonMessage.Trim()
 02113            };
 2114
 02115            var response = await ozonApiClient.MakeFastRequestAsync(request, uri).ConfigureAwait(false);
 02116            var result = response.ToObject<FbsPostingCancelResponse>();
 02117            return result?.Result == true || response["result"]?.Value<bool>() == true;
 02118        }
 2119
 2120        /// <summary>Идентификатор причины «Другое (вина продавца)» — обязателен комментарий.</summary>
 2121        public const long FbsCancelReasonOtherSellerFaultId = 402;
 2122
 2123        /// <summary>
 2124        /// Батчевый аналог GetWarehouseInfo — собирает все SKU всех товаров страницы
 2125        /// и выполняет один (или несколько параллельных) API-вызов вместо N последовательных.
 2126        /// Возвращает словарь ArticleWithKey → warehouseInfo.
 2127        /// </summary>
 2128        public async Task<Dictionary<string, JToken?>> GetWarehouseInfoBatchedAsync(
 2129            IReadOnlyList<(string ArticleWithKey, JObject Stocks)> items,
 2130            List<WarehouseOzon> warehouseList)
 2131        {
 52132            var result = new Dictionary<string, JToken?>(StringComparer.OrdinalIgnoreCase);
 2133
 2134            // 1. Собрать все SKU с привязкой к артикулу
 52135            var skuToArticle = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 52136            var articleToSkus = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
 2137
 202138            foreach (var (article, stocks) in items)
 2139            {
 52140                if (stocks?["items"] is not JArray stockItems) continue;
 52141                var skusForArticle = new HashSet<string>();
 202142                foreach (var si in stockItems)
 222143                    foreach (var s in si["stocks"] ?? new JArray())
 2144                    {
 62145                        var sku = s["sku"]?.ToString();
 62146                        if (string.IsNullOrEmpty(sku)) continue;
 62147                        skusForArticle.Add(sku);
 62148                        skuToArticle.TryAdd(sku, article);
 2149                    }
 52150                if (skusForArticle.Count > 0)
 52151                    articleToSkus[article] = skusForArticle;
 2152            }
 2153
 52154            if (skuToArticle.Count == 0) return result;
 2155
 2156            // 2. Последовательные API-вызовы чанками по 200 SKU
 2157            // (параллельность вызывает rate-limit 429 у Ozon → часть товаров без данных).
 2158            // Ответ на каждый чанк читается постранично по cursor: Ozon возвращает строку на пару
 2159            // sku×склад и режет её по 1000, иначе бо́льшая часть чанка терялась бы молча.
 2160            const int skuChunkSize = 200;
 52161            var allSkus = skuToArticle.Keys.ToArray();
 52162            var chunks = allSkus.Chunk(skuChunkSize).ToList();
 2163
 52164            var chunkResults = new List<JObject?>(chunks.Count);
 202165            foreach (var chunk in chunks)
 2166            {
 52167                chunkResults.Add(await GetProductWarehousesForBitrix(chunk, Array.Empty<string>())
 52168                    .ConfigureAwait(false));
 2169            }
 2170
 2171            // 3. Индексировать результаты по SKU → все записи склада (один SKU = N складов)
 2172            // Ozon API возвращает данные в "result" или "products" — обрабатываем оба варианта
 52173            var entriesBySku = new Dictionary<string, List<JToken>>(StringComparer.OrdinalIgnoreCase);
 202174            foreach (var chunkResult in chunkResults)
 2175            {
 52176                var arr = (chunkResult?["result"] as JArray) ?? (chunkResult?["products"] as JArray);
 52177                if (arr == null) continue;
 202178                foreach (var entry in arr)
 2179                {
 52180                    var sku = entry["sku"]?.ToString();
 52181                    if (string.IsNullOrEmpty(sku)) continue;
 52182                    if (!entriesBySku.TryGetValue(sku, out var list))
 52183                        entriesBySku[sku] = list = new List<JToken>();
 52184                    list.Add(entry);
 2185                }
 2186            }
 2187
 2188            // 4. Сформировать per-article результат
 202189            foreach (var (article, skus) in articleToSkus)
 2190            {
 52191                var entries = new JArray(skus
 52192                    .Where(sku => entriesBySku.ContainsKey(sku))
 102193                    .SelectMany(sku => entriesBySku[sku]));
 2194
 52195                if (entries.Count == 0) { result[article] = null; continue; }
 2196
 52197                var perItemData = new JObject { ["result"] = entries };
 52198                result[article] = await CombineWarehouseInfoJson(perItemData, warehouseList, string.Empty)
 52199                    .ConfigureAwait(false);
 2200            }
 2201
 52202            return result;
 52203        }
 2204    }
 2205}

Methods/Properties

.ctor(System.Net.Http.IHttpClientFactory,SlqStudio.Application.Services.EmailService.IEmailService,System.String,System.String)
GetTestRequest()
SetClient(System.String,System.String)
NormalizeOzonIds(System.Collections.Generic.IEnumerable`1<System.String>)
GetReportCode()
GetReportCode()
CreateSellerReturnsReportAsync()
GetWarehouseStockReportCodeAsync()
GetReportInfo()
GetReportFile()
WaitForReportReady()
ValidatePostingsReportRequest(Services.ApiServices.OzonApi.Filters.PostingsReportRequest)
GetProductWarehousAndCityBatch()
<GetProductWarehousAndCityBatch()
ReadFileByUrl()
GetProductCode()
GetProductPrices()
GetProductPricesByArticlesModel()
GetProductAttributesAsync()
IsProductAttributesNotFoundError(System.Exception)
GetProductPricesDetailsAsync()
GetProductPricesByArticles()
GetActivePromosAsync()
GetPromoOfferIdsAsync()
CheckArticlesInPromosAsync()
GetAllBatchOfferIdsAsync()
Normalize()
GetProductInfoListAsync()
GetProductInfoListBySkuAsync()
GetPrimaryImagesBySkuAsync()
FirstImageUrl(Newtonsoft.Json.Linq.JToken)
ArchiveProductsAsync()
UnarchiveProductsAsync()
ReadWarehouseStockPagesAsync()
GetProductWarehouses()
GetProductWarehousesForBitrix()
GetWarehouseInfo()
CombineWarehouseInfoJson()
GetProductStocks()
GetProductWarehousAndCity()
GetProductSatatus()
GetProductStatusRaw()
GetProductStatusBatch()
<GetProductStatusBatch()
GetProductSubSatatus()
GetProductId()
GetPostingProductsWithDeliveryAsync()
GetProducts()
GetFbsPostingRequirementsAsync()
GetFbsProductsRequiringCountryAsync()
GetFbsExemplarsCreateOrGetAsync()
SetFbsExemplarsAsync()
GetFbsExemplarStatusAsync()
GetFbsProductCountriesAsync()
SetFbsProductCountryAsync()
GetPostingShipProductsAsync()
GetPostingProductQuantityAsync()
GetPostingProductSkuAsync()
CheckOrderForExistence()
UpdateProductStocks()
TryResolveProductIdByOfferIdAsync()
GetWarehouseList()
GetWarehouseListOrThrowAsync()
GetApiRoles()
GetSellerInfo()
ImportProductPrices()
ImportProductPricesChunk()
ShipFbsPosting()
GetUnfulfilledPostingsAsync()
CreatePackageLabelTask()
GetPackageLabelFile()
SetAwaitingDelivery()
GetFinanceAccrualPostingsAsync()
GetFinanceAccrualTypesAsync()
GetReturnsListAsync()
SplitFbsPosting()
GetFbsCancelReasonsAsync()
GetFbsCancelReasonCatalogAsync()
GetFboCancelReasonCatalogAsync()
CancelFbsPostingAsync()
GetWarehouseInfoBatchedAsync()