< Summary

Information
Class: Services.ApiServices.OzonApi.OzonJsonDataBuilder
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/OzonJsonDataBuilder.cs
Line coverage
21%
Covered lines: 99
Uncovered lines: 372
Coverable lines: 471
Total lines: 856
Line coverage: 21%
Branch coverage
9%
Covered branches: 28
Total branches: 306
Branch coverage: 9.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.Http;
 2using Newtonsoft.Json.Linq;
 3using Services.ApiServices.OzonApi.Filters;
 4using Services.LabelServices;
 5using System.Collections.Concurrent;
 6using SlqStudio.Application.Services.EmailService;
 7
 8namespace Services.ApiServices.OzonApi
 9{
 10    public class OzonJsonDataBuilder
 11    {
 12        private readonly IHttpClientFactory _httpClientFactory;
 13        private readonly IEmailService? _emailService;
 14        private OzonApiDataManager _apiDataManager = null!;
 15
 16        // Rate limiting: минимальная задержка между API запросами (мс)
 17        private const int MinDelayBetweenRequests = 100;
 18        // Максимальное количество параллельных запросов
 19        private const int MaxParallelRequests = 5;
 20
 2421        public OzonJsonDataBuilder(IHttpClientFactory httpClientFactory, IEmailService? emailService)
 22        {
 2423            _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
 2424            _emailService = emailService;
 2425        }
 26
 27        public void Init(string clientId, string apiKey)
 28        {
 1629            _apiDataManager = new OzonApiDataManager(_httpClientFactory, _emailService, clientId, apiKey);
 1630        }
 31
 32        public virtual void SetClient(string clientId, string apiKey)
 33        {
 034            _apiDataManager = _apiDataManager.SetClient(clientId, apiKey);
 035        }
 36
 37        /// <summary>Батч-запрос статусов отправлений для клиента, установленного через <see cref="SetClient"/>.</summar
 38        public Task<Dictionary<string, (bool Success, string Status)>> GetProductStatusBatch(
 39            IReadOnlyCollection<string> productNumbers, int maxParallelism = MaxParallelRequests) =>
 040            _apiDataManager.GetProductStatusBatch(productNumbers, maxParallelism);
 41
 42        public Task<Filters.OzonCancelReasonCatalogResponse> GetFbsCancelReasonCatalogAsync() =>
 443            _apiDataManager.GetFbsCancelReasonCatalogAsync();
 44
 45        public async Task<JArray> BuildData(DateTime start, DateTime end)
 46        {
 47            const int batchSize = 500;
 48
 49            // ШАГ 1: Получаем код отчета и ждем его готовности с polling
 050            var orderCode = await _apiDataManager.GetReportCode(start, end);
 051            var orderFile = await _apiDataManager.WaitForReportReady(orderCode);
 052            var oredersContent = await _apiDataManager.ReadFileByUrl(orderFile);
 53
 054            if (oredersContent == null || !oredersContent.Any())
 55            {
 056                return new JArray();
 57            }
 58
 59            // Извлекаем данные из заказов
 060            var articles = new List<string>();
 061            var ozonIds = new List<string>();
 062            var productNumbers = new List<string>();
 63
 064            foreach (var order in oredersContent)
 65            {
 066                articles.Add(order["Артикул"]?.ToString() ?? string.Empty);
 067                ozonIds.Add(order["SKU"]?.ToString() ?? string.Empty);
 068                productNumbers.Add(order["Номер отправления"]?.ToString() ?? string.Empty);
 69            }
 70
 71            // ШАГ 2: Получаем данные о продуктах (параллельно по батчам)
 072            var productsContent = await GetProductsContentParallel(articles, ozonIds, batchSize);
 073            NormalizeProductReportArticleColumn(productsContent);
 74
 75            // Извлекаем данные из продуктов
 076            var newArticles = new List<string>();
 077            var ozonProductIds = new List<string>();
 078            var fbsOzonSkuIds = new List<string>();
 79
 080            foreach (var order in productsContent)
 81            {
 082                newArticles.Add(order["Артикул"]?.ToString() ?? string.Empty);
 083                ozonProductIds.Add(order["Ozon Product ID"]?.ToString() ?? string.Empty);
 084                fbsOzonSkuIds.Add(order["SKU"]?.ToString() ?? string.Empty);
 85            }
 86
 87            // ШАГ 3: Параллельно получаем цены, склады и города
 088            var pricesTask = GetProductPricesParallel(newArticles, ozonProductIds, batchSize);
 089            var warehousesTask = GetProductWarehousesParallel(fbsOzonSkuIds, batchSize);
 090            var warehousesAndCitysTask = GetProductWarehousesAndCitysParallel(productNumbers.ToArray());
 091            var primaryImagesTask = GetProductPrimaryImagesParallel(ozonProductIds, batchSize);
 92
 93            // Ожидаем завершения всех параллельных задач
 94            try
 95            {
 096                await Task.WhenAll(pricesTask, warehousesTask, warehousesAndCitysTask, primaryImagesTask);
 97            }
 98            finally
 99            {
 100                try
 101                {
 0102                    await primaryImagesTask;
 0103                }
 0104                catch
 105                {
 106                    // Игнорируем ошибку картинок при падении основных задач
 0107                }
 108            }
 109
 0110            var productPrices = await pricesTask;
 0111            var allProductWarehouses = await warehousesTask;
 0112            var allProductWarehousesAndCitys = await warehousesAndCitysTask;
 0113            var primaryImagesByProductId = await primaryImagesTask;
 114
 115            // ШАГ 4: Собираем все данные
 0116            var collectDataResult = new JArray();
 117            try
 118            {
 0119                collectDataResult = await CollectData(oredersContent, productsContent, productPrices,
 0120                    allProductWarehouses, allProductWarehousesAndCitys, primaryImagesByProductId);
 0121            }
 0122            catch (Exception ex)
 123            {
 0124                Console.WriteLine($"Ошибка при сборке данных: {ex.Message}");
 0125            }
 126
 0127            return collectDataResult;
 0128        }
 129
 130        /// <summary>
 131        /// Получает primary_image для товаров по product_id через Ozon Seller API.
 132        /// Используется для последующего заполнения Product.ImageUrl в OrderCaster без обращения к API.
 133        /// </summary>
 134        internal async Task<Dictionary<string, string?>> GetProductPrimaryImagesParallel(List<string> ozonProductIds,
 135            int batchSize)
 136        {
 3137            var cleanedIds = ozonProductIds
 4138                .Where(id => !string.IsNullOrWhiteSpace(id))
 4139                .Select(id => id.Trim().TrimStart('\''))
 3140                .Distinct(StringComparer.OrdinalIgnoreCase)
 3141                .ToList();
 142
 3143            if (cleanedIds.Count == 0)
 144            {
 0145                return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
 146            }
 147
 3148            var imagesByProductId = new ConcurrentDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
 3149            int productBatches = GetBathesCount(batchSize, cleanedIds.Count);
 3150            var semaphore = new SemaphoreSlim(MaxParallelRequests);
 151
 152            try
 153            {
 3154                var tasks = Enumerable.Range(0, productBatches).Select(async i =>
 3155                {
 3156                    await semaphore.WaitAsync();
 3157                    try
 3158                    {
 3159                        var productIdsBatch = cleanedIds.Skip(i * batchSize).Take(batchSize).ToArray();
 3160                        if (productIdsBatch.Length == 0)
 3161                        {
 0162                            return;
 3163                        }
 3164
 3165                        var limit = Math.Min(productIdsBatch.Length, 1000);
 3166
 3167                        var response = await _apiDataManager.GetProductAttributesAsync(
 3168                            productIds: productIdsBatch,
 3169                            offerIds: null,
 3170                            skus: null,
 3171                            visibility: "ALL",
 3172                            lastId: "",
 3173                            limit: limit,
 3174                            sortBy: "id",
 3175                            sortDir: "ASC")
 3176                            .ConfigureAwait(false);
 3177
 2178                        if (response?.Result == null)
 3179                        {
 0180                            return;
 3181                        }
 3182
 6183                        foreach (var item in response.Result)
 3184                        {
 1185                            var key = item?.Id.ToString();
 1186                            if (string.IsNullOrWhiteSpace(key))
 3187                            {
 3188                                continue;
 3189                            }
 3190
 3191                            // primary_image может быть пустым — это нормальная ситуация.
 1192                            imagesByProductId.AddOrUpdate(
 1193                                key,
 1194                                item?.PrimaryImage,
 1195                                (_, existing) =>
 1196                                {
 1197                                    // Если уже есть непустое значение — не перетираем.
 0198                                    if (!string.IsNullOrWhiteSpace(existing))
 1199                                    {
 0200                                        return existing;
 1201                                    }
 1202
 1203                                    // Если новое значение непустое — обновляем.
 0204                                    return string.IsNullOrWhiteSpace(item?.PrimaryImage) ? existing : item?.PrimaryImage
 1205                                });
 3206                        }
 2207                    }
 1208                    catch (Exception ex)
 3209                    {
 3210                        // Загрузка изображений вспомогательная и не должна приводить к сбою остальных батчей
 3211                        // или выбрасывать исключение в фоновой задаче.
 1212                        Console.WriteLine($"GetProductPrimaryImagesParallel: ошибка загрузки картинок для батча {i}: {ex
 1213                    }
 3214                    finally
 3215                    {
 3216                        semaphore.Release();
 3217                        await Task.Delay(MinDelayBetweenRequests);
 3218                    }
 3219                }).ToArray();
 220
 3221                await Task.WhenAll(tasks);
 3222            }
 0223            catch (Exception ex)
 224            {
 225                // Гарантируем, что метод никогда не вернет faulted Task, вызывающий UnobservedTaskException на сборщике
 0226                Console.WriteLine($"GetProductPrimaryImagesParallel: ошибка параллельной загрузки картинок: {ex.Message}
 0227            }
 228
 5229            return imagesByProductId.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
 3230        }
 231
 232        /// <summary>
 233        /// Получает данные о продуктах параллельно по батчам
 234        /// </summary>
 235        private async Task<JArray> GetProductsContentParallel(List<string> articles, List<string> ozonIds, int batchSize
 236        {
 0237            var productsContent = new ConcurrentBag<JToken>();
 0238            int productBatches = GetBathesCount(batchSize, articles.Count);
 0239            var semaphore = new SemaphoreSlim(MaxParallelRequests);
 240
 0241            var tasks = Enumerable.Range(0, productBatches).Select(async i =>
 0242            {
 0243                await semaphore.WaitAsync();
 0244                try
 0245                {
 0246                    var articlesBatch = articles.Skip(i * batchSize).Take(batchSize).ToArray();
 0247                    var ozonIdsBatch = ozonIds.Skip(i * batchSize).Take(batchSize).ToArray();
 0248
 0249                    var productsCode = await _apiDataManager.GetProductCode(articlesBatch, ozonIdsBatch);
 0250                    var productsFile = await _apiDataManager.WaitForReportReady(productsCode);
 0251                    var productContent = await _apiDataManager.ReadFileByUrl(productsFile);
 0252
 0253                    foreach (JToken product in productContent)
 0254                    {
 0255                        productsContent.Add(product);
 0256                    }
 0257                }
 0258                finally
 0259                {
 0260                    semaphore.Release();
 0261                    await Task.Delay(MinDelayBetweenRequests); // Rate limiting
 0262                }
 0263            });
 264
 0265            await Task.WhenAll(tasks);
 0266            return new JArray(productsContent);
 0267        }
 268
 269        /// <summary>
 270        /// Получает цены продуктов параллельно по батчам
 271        /// </summary>
 272        private async Task<JObject> GetProductPricesParallel(List<string> newArticles, List<string> ozonProductIds, int 
 273        {
 0274            var productPrices = new JObject();
 0275            var lockObj = new object();
 0276            int pricesBatches = GetBathesCount(batchSize, newArticles.Count);
 0277            var semaphore = new SemaphoreSlim(MaxParallelRequests);
 278
 0279            var tasks = Enumerable.Range(0, pricesBatches).Select(async j =>
 0280            {
 0281                await semaphore.WaitAsync();
 0282                try
 0283                {
 0284                    var newArticlesBatch = newArticles.Skip(j * batchSize).Take(batchSize).ToArray();
 0285                    var ozonProductBatch = ozonProductIds.Skip(j * batchSize).Take(batchSize).ToArray();
 0286
 0287                    var productPrice = await _apiDataManager.GetProductPrices(newArticlesBatch, ozonProductBatch);
 0288
 0289                    lock (lockObj)
 0290                    {
 0291                        productPrices.Merge(productPrice, new JsonMergeSettings
 0292                        {
 0293                            MergeArrayHandling = MergeArrayHandling.Union
 0294                        });
 0295                    }
 0296                }
 0297                finally
 0298                {
 0299                    semaphore.Release();
 0300                    await Task.Delay(MinDelayBetweenRequests);
 0301                }
 0302            });
 303
 0304            await Task.WhenAll(tasks);
 0305            return productPrices;
 0306        }
 307
 308        /// <summary>
 309        /// Получает данные о складах параллельно по батчам
 310        /// </summary>
 311        private async Task<JObject> GetProductWarehousesParallel(List<string> fbsOzonSkuIds, int batchSize)
 312        {
 0313            var allProductWarehouses = new JObject();
 0314            var lockObj = new object();
 0315            int productWarehousesBatches = GetBathesCount(batchSize, fbsOzonSkuIds.Count);
 0316            var semaphore = new SemaphoreSlim(MaxParallelRequests);
 317
 0318            var tasks = Enumerable.Range(0, productWarehousesBatches).Select(async k =>
 0319            {
 0320                await semaphore.WaitAsync();
 0321                try
 0322                {
 0323                    var currentBatch = fbsOzonSkuIds.Skip(k * batchSize).Take(batchSize).ToArray();
 0324                    var productWarehouses = await _apiDataManager.GetProductWarehouses(currentBatch, currentBatch);
 0325
 0326                    lock (lockObj)
 0327                    {
 0328                        allProductWarehouses.Merge(productWarehouses, new JsonMergeSettings
 0329                        {
 0330                            MergeArrayHandling = MergeArrayHandling.Union
 0331                        });
 0332                    }
 0333                }
 0334                finally
 0335                {
 0336                    semaphore.Release();
 0337                    await Task.Delay(MinDelayBetweenRequests);
 0338                }
 0339            });
 340
 0341            await Task.WhenAll(tasks);
 0342            return allProductWarehouses;
 0343        }
 344
 345        /// <summary>
 346        /// Получает данные о складах и городах параллельно (это самый медленный запрос - по одному номеру)
 347        /// </summary>
 348        private async Task<JArray> GetProductWarehousesAndCitysParallel(string[] productNumbers)
 349        {
 0350            var allProductWarehousesAndCitys = new ConcurrentBag<JToken>();
 351
 352            // Используем batch-метод из OzonApiDataManager
 0353            var batchResults = await _apiDataManager.GetProductWarehousAndCityBatch(productNumbers, MaxParallelRequests 
 354
 0355            foreach (var kvp in batchResults)
 356            {
 0357                var productWarehousAndCity = kvp.Value;
 0358                if (productWarehousAndCity != null)
 359                {
 0360                    var properties = productWarehousAndCity.Properties();
 0361                    foreach (var property in properties)
 362                    {
 0363                        allProductWarehousesAndCitys.Add(property.Value);
 364                    }
 365                }
 366            }
 367
 0368            return new JArray(allProductWarehousesAndCitys);
 0369        }
 370
 371        private int GetBathesCount(int batchSize, int skuCount)
 372        {
 3373            return (int)Math.Ceiling((double)skuCount / batchSize);
 374        }
 375
 376        internal static void NormalizeProductReportArticleColumn(JArray productsContent)
 377        {
 4378            foreach (var product in productsContent.OfType<JObject>())
 379            {
 1380                if (!string.IsNullOrWhiteSpace(product["Артикул"]?.ToString()))
 381                    continue;
 382
 1383                var article = product.Properties()
 1384                    .FirstOrDefault(property => IsOzonArticleColumn(property.Name))?
 1385                    .Value?
 1386                    .ToString();
 1387                if (!string.IsNullOrWhiteSpace(article))
 1388                    product["Артикул"] = article;
 389            }
 1390        }
 391
 392        private static bool IsOzonArticleColumn(string columnName)
 393        {
 1394            var normalized = columnName.Trim();
 1395            if (normalized.StartsWith("{{{", StringComparison.Ordinal) &&
 1396                normalized.EndsWith("}}}", StringComparison.Ordinal))
 397            {
 1398                normalized = normalized[3..^3];
 399            }
 400
 1401            var formatSeparator = normalized.IndexOf('|');
 1402            if (formatSeparator >= 0)
 1403                normalized = normalized[..formatSeparator];
 404
 1405            return string.Equals(normalized, "Артикул", StringComparison.OrdinalIgnoreCase);
 406        }
 407
 408        private async Task<JArray> CollectData(JArray oredersContent,
 409            JArray productsContent,
 410            JObject productPrices,
 411            JObject allProductWarehouses,
 412            JArray allProductWarehousesAndCitys,
 413            Dictionary<string, string?> primaryImagesByProductId)
 414        {
 0415            await Task.CompletedTask;
 416            // Создаем словари для быстрого поиска O(1) вместо O(n)
 0417            var productsByArticle = new Dictionary<string, JToken>(StringComparer.OrdinalIgnoreCase);
 0418            foreach (var product in productsContent)
 419            {
 0420                var article = product["Артикул"]?.ToString()?.TrimStart('\'');
 0421                if (!string.IsNullOrEmpty(article) && !productsByArticle.ContainsKey(article))
 422                {
 0423                    productsByArticle[article] = product;
 424                }
 425            }
 426
 0427            var pricesByOfferId = new Dictionary<string, JToken>(StringComparer.OrdinalIgnoreCase);
 0428            if (productPrices?["items"] is JArray priceItems)
 429            {
 0430                foreach (var item in priceItems)
 431                {
 0432                    var offerId = item?["offer_id"]?.ToString();
 0433                    if (!string.IsNullOrEmpty(offerId) && !pricesByOfferId.ContainsKey(offerId))
 434                    {
 0435                        pricesByOfferId[offerId] = item!;
 436                    }
 437                }
 438            }
 439
 0440            var warehousesBySku = new Dictionary<string, JToken>(StringComparer.OrdinalIgnoreCase);
 0441            if (allProductWarehouses?["result"] is JArray warehouseItems)
 442            {
 0443                foreach (var item in warehouseItems)
 444                {
 0445                    var sku = item?["sku"]?.ToString();
 0446                    if (!string.IsNullOrEmpty(sku))
 447                    {
 0448                        warehousesBySku[sku] = item!;
 449                    }
 450                }
 451            }
 452
 0453            var warehousesAndCitysByNumber = new Dictionary<string, JToken>(StringComparer.OrdinalIgnoreCase);
 0454            foreach (var item in allProductWarehousesAndCitys)
 455            {
 0456                var postingNumber = item?["posting_number"]?.ToString();
 0457                if (!string.IsNullOrEmpty(postingNumber) && !warehousesAndCitysByNumber.ContainsKey(postingNumber))
 458                {
 0459                    warehousesAndCitysByNumber[postingNumber] = item!;
 460                }
 461            }
 462
 463            // Теперь обрабатываем заказы с использованием словарей
 0464            foreach (var order in oredersContent)
 465            {
 0466                var article = order["Артикул"]?.ToString()?.TrimStart('\'');
 0467                var shipmentNumber = order["Номер отправления"]?.ToString();
 468
 469                // Находим продукт по артикулу
 0470                productsByArticle.TryGetValue(article ?? string.Empty, out var productWithArticleSource);
 471
 472                // У каждого заказа должна быть своя копия объекта продукта.
 473                // Иначе один и тот же JToken перепривязывается к разным parent и теряет изменения.
 0474                var productWithArticle = productWithArticleSource?.DeepClone();
 475
 476                // Обогащаем token productWithArticle значением primary_image (для OrderCaster)
 0477                if (productWithArticle != null)
 478                {
 0479                    if (productWithArticle is JObject productObj)
 480                    {
 0481                        var ozonProductId = productObj["Ozon Product ID"]?.ToString()?.Trim().TrimStart('\'') ?? "";
 482                        // Кладём поле всегда, даже если ключ не найден или значение пустое.
 483                        // Это позволяет быстро дебажить факт вызова и сопоставления.
 0484                        var primaryImage = primaryImagesByProductId.TryGetValue(ozonProductId, out var img)
 0485                            ? img
 0486                            : "";
 0487                        productObj["primary_image"] = primaryImage ?? "";
 488                    }
 489                }
 490
 0491                order["productWithArticle"] = productWithArticle;
 492
 493                // Находим цены по артикулу
 0494                pricesByOfferId.TryGetValue(article ?? string.Empty, out var productPricesWithArticle);
 0495                order["productPricesWithArticle"] = productPricesWithArticle;
 496
 497                // Находим склады по SKU продукта
 0498                var productSku = productWithArticle?["SKU"]?.ToString();
 0499                warehousesBySku.TryGetValue(productSku ?? string.Empty, out var productWarehousesWithArticle);
 0500                order["productWarehousesWithArticle"] = productWarehousesWithArticle;
 501
 502                // Находим склады и города по номеру отправления
 0503                warehousesAndCitysByNumber.TryGetValue(shipmentNumber ?? string.Empty, out var productWarehousesAndCitys
 0504                order["productWarehousesAndCitysWithNumber"] = productWarehousesAndCitysWithNumber;
 505            }
 506
 0507            return oredersContent;
 0508        }
 509
 510        public async Task<bool> GetTestReques(string clientId, string apiKey)
 511        {
 0512            return await _apiDataManager.GetTestRequest(clientId, apiKey);
 0513        }
 514
 515        /// <summary>
 516        /// PDF этикетки одного отправления: задание /v3/posting/fbs/package-label/create, опрос
 517        /// /v2/posting/fbs/package-label/get и скачивание file_url. Синхронный /v2/posting/fbs/package-label
 518        /// Ozon отключает 2 ноября 2026.
 519        /// </summary>
 520        public async Task<byte[]> GetOrderLabel(string shipmentNumber)
 521        {
 0522            var postings = new[] { shipmentNumber };
 0523            var createResponse = await _apiDataManager.CreatePackageLabelTask(postings).ConfigureAwait(false);
 0524            var task = createResponse.PickTask()
 0525                       ?? throw new Exception("Не удалось создать задание на формирование этикетки (пустой ответ).");
 526
 527            // file_url ведёт на файловое хранилище Ozon, а не на api-seller — берём клиент из фабрики
 528            // (создавать HttpClient вручную нельзя: сокеты не переиспользуются).
 0529            var httpClient = _httpClientFactory.CreateClient(OzonLabelPrintService.LabelDownloadHttpClientName);
 0530            return await PackageLabelPollingHelper.PollSingleTaskForBytesAsync(
 0531                id => _apiDataManager.GetPackageLabelFile(id),
 0532                url => httpClient.GetByteArrayAsync(url),
 0533                task.TaskId,
 0534                postings).ConfigureAwait(false);
 0535        }
 536
 537        /// <summary>
 538        /// ScanIt отправления (/v3/posting/fbs/get). null — Ozon его ещё не присвоил
 539        /// (старый формат этикетки или отправление не собрано).
 540        /// </summary>
 541        public async Task<string?> GetPostingScanit(string postingNumber)
 542        {
 0543            var response = await _apiDataManager.GetProductWarehousAndCity(postingNumber).ConfigureAwait(false);
 0544            var scanit = response?["result"]?["scanit"]?.ToString();
 0545            return string.IsNullOrWhiteSpace(scanit) ? null : scanit.Trim();
 0546        }
 547
 548        /// <summary>
 549        /// Создать задание на формирование этикеток для отправлений
 550        /// </summary>
 551        public async Task<PackageLabelCreateResponse> CreatePackageLabelTask(string[] postingNumbers)
 552        {
 0553            return await _apiDataManager.CreatePackageLabelTask(postingNumbers);
 0554        }
 555
 556        /// <summary>
 557        /// Получить файл с этикетками по task_id
 558        /// </summary>
 559        public async Task<PackageLabelGetResponse> GetPackageLabelFile(long taskId)
 560        {
 0561            return await _apiDataManager.GetPackageLabelFile(taskId);
 0562        }
 563
 564        public async Task<bool> CheckOrderForExistence(string article, string ozonProductId)
 565        {
 0566            return await _apiDataManager.CheckOrderForExistence([article], [ozonProductId]);
 0567        }
 568
 569        public async Task<string> GetProductSatatus(string productNumber)
 570        {
 0571            return await _apiDataManager.GetProductSatatus(productNumber);
 0572        }
 573
 574        public async Task<string> GetProductSubSatatus(string productNumber)
 575        {
 0576            return await _apiDataManager.GetProductSubSatatus(productNumber);
 0577        }
 578
 579        public async Task<string> GetProductId(string productNumber)
 580        {
 0581            return await _apiDataManager.GetProductId(productNumber);
 0582        }
 583
 584        public async Task<List<JObject>> GetProducts(string productNumber)
 585        {
 0586            return await _apiDataManager.GetProducts(productNumber);
 0587        }
 588
 589        public Task<(List<JObject> Products, string? DeliveryMethodName, string? TplProvider)>
 590            GetPostingProductsWithDeliveryAsync(string postingNumber)
 591        {
 0592            return _apiDataManager.GetPostingProductsWithDeliveryAsync(postingNumber);
 593        }
 594
 595        public Task<Dictionary<string, string>> GetPrimaryImagesBySkuAsync(long[] skus)
 596        {
 0597            return _apiDataManager.GetPrimaryImagesBySkuAsync(skus);
 598        }
 599
 600        /// <summary>
 601        /// Обертка над OzonApiDataManager.ShipFbsPosting (собрать заказ /v4/posting/fbs/ship).
 602        /// </summary>
 603        public async Task<JObject> ShipFbsPosting(string postingNumber, List<FbsShipPackage> packages,
 604            bool withAdditionalData = true)
 605        {
 0606            return await _apiDataManager.ShipFbsPosting(postingNumber, packages, withAdditionalData);
 0607        }
 608
 609        public async Task<AwaitingDeliveryResponse> SetAwaitingDelivery(string[] postingNumbers)
 610        {
 0611            return await _apiDataManager.SetAwaitingDelivery(postingNumbers);
 0612        }
 613
 614        /// <summary>
 615        /// Загружает полные данные одного отправления по posting_number через Ozon API
 616        /// и собирает их в формат, совместимый с <see cref="CollectData"/> / OrderCaster.CastToModel.
 617        ///
 618        /// Используется обработчиком TYPE_NEW_POSTING для мгновенной загрузки заказа
 619        /// без ожидания периодического отчёта.
 620        ///
 621        /// Пайплайн повторяет оригинальный <see cref="BuildData"/>:
 622        /// 1. Данные отправления через /v3/posting/fbs/get
 623        /// 2. Отчёт по товарам через /v1/report/products/create (Barcode, Category, Volume)
 624        /// 3. Цены/комиссии через /v5/product/info/prices
 625        /// 4. Остатки по складам через /v2/product/info/stocks-by-warehouse/fbs
 626        /// 5. Картинки через /v4/product/info/attributes
 627        /// 6. Сборка через <see cref="CollectData"/>
 628        /// </summary>
 629        public virtual async Task<JArray> BuildDataForPosting(string postingNumber)
 630        {
 631            const int batchSize = 500;
 632
 633            // ШАГ 1: Получаем полные данные отправления через /v3/posting/fbs/get
 0634            var postingResponse = await _apiDataManager.GetProductWarehousAndCity(postingNumber);
 0635            var postingResult = postingResponse?["result"];
 636
 0637            if (postingResult == null)
 0638                return new JArray();
 639
 0640            var products = postingResult["products"] as JArray;
 0641            if (products == null || !products.Any())
 0642                return new JArray();
 643
 0644            var postingNum = postingResult["posting_number"]?.ToString() ?? postingNumber;
 0645            var status = postingResult["status"]?.ToString() ?? "";
 0646            var humanStatus = Services.ParserServices.HelpDictEnum.OzonStatus.OrderStatuses
 0647                .TryGetValue(status, out var statusName) ? statusName : status;
 648
 649            // Форматируем даты в ISO 8601 — culture-independent,
 650            // корректно парсятся CastToModel с любой локалью сервера (ru/en)
 0651            var inProcessAt = FormatJTokenDate(postingResult["in_process_at"]);
 0652            var shipmentDate = FormatJTokenDate(postingResult["shipment_date"]);
 0653            var currencyCode = postingResult["financial_data"]?["currency_code"]?.ToString() ?? "RUB";
 654
 655            // Сумма отправления (как в CSV — общая сумма по всем товарам)
 0656            decimal totalAmount = 0;
 0657            foreach (var p in products)
 658            {
 0659                if (decimal.TryParse(p["price"]?.ToString(),
 0660                        System.Globalization.NumberStyles.Any,
 0661                        System.Globalization.CultureInfo.InvariantCulture, out var pr) &&
 0662                    int.TryParse(p["quantity"]?.ToString(), out var q))
 663                {
 0664                    totalAmount += pr * q;
 665                }
 666            }
 0667            var shipmentAmountStr = totalAmount.ToString(System.Globalization.CultureInfo.InvariantCulture);
 668
 669            // ШАГ 2: Формируем oredersContent в формате CSV-отчёта (по одной строке на product)
 0670            var oredersContent = new JArray();
 0671            var articles = new List<string>();
 0672            var ozonIds = new List<string>();
 673
 0674            foreach (var product in products)
 675            {
 0676                var offerId = product["offer_id"]?.ToString() ?? "";
 0677                var sku = product["sku"]?.ToString() ?? "";
 678
 0679                oredersContent.Add(new JObject
 0680                {
 0681                    ["Номер отправления"] = postingNum,
 0682                    ["Артикул"] = offerId,
 0683                    ["Принят в обработку"] = inProcessAt,
 0684                    ["Дата отгрузки"] = shipmentDate,
 0685                    ["Статус"] = humanStatus,
 0686                    ["Сумма отправления"] = shipmentAmountStr,
 0687                    ["Название товара"] = product["name"]?.ToString() ?? "",
 0688                    ["Количество"] = product["quantity"]?.ToString() ?? "1",
 0689                    ["Код валюты отправления"] = currencyCode,
 0690                    ["SKU"] = sku
 0691                });
 692
 0693                articles.Add(offerId);
 0694                ozonIds.Add(sku);
 695            }
 696
 697            // ШАГ 3: Получаем отчёт по товарам (как в оригинальном BuildData)
 698            // Содержит: Ozon Product ID, SKU, Barcode, Категория, Объём, Вес и т.д.
 0699            var productsContent = new JArray();
 700            try
 701            {
 0702                productsContent = await GetProductsContentParallel(articles, ozonIds, batchSize);
 0703            }
 0704            catch (Exception ex)
 705            {
 0706                Console.WriteLine($"BuildDataForPosting: ошибка получения отчёта по товарам: {ex.Message}");
 0707            }
 708
 709            // Если отчёт по товарам пуст — создаём минимальные записи из данных отправления
 0710            if (productsContent == null || !productsContent.Any())
 711            {
 0712                productsContent = new JArray();
 0713                var financialProducts = postingResult["financial_data"]?["products"] as JArray;
 0714                for (int i = 0; i < products.Count; i++)
 715                {
 0716                    var productId = financialProducts != null && i < financialProducts.Count
 0717                        ? financialProducts[i]?["product_id"]?.ToString() ?? ""
 0718                        : "";
 0719                    productsContent.Add(new JObject
 0720                    {
 0721                        ["Артикул"] = products[i]["offer_id"]?.ToString() ?? "",
 0722                        ["Ozon Product ID"] = productId,
 0723                        ["SKU"] = products[i]["sku"]?.ToString() ?? "",
 0724                        ["Barcode"] = "",
 0725                        ["Категория"] = "",
 0726                        ["Объем товара, л"] = "",
 0727                        ["Объемный вес, кг"] = ""
 0728                    });
 729                }
 730            }
 731
 0732            NormalizeProductReportArticleColumn(productsContent);
 733
 734            // Извлекаем данные из отчёта по товарам (как в оригинальном BuildData)
 0735            var newArticles = new List<string>();
 0736            var ozonProductIds = new List<string>();
 0737            var fbsOzonSkuIds = new List<string>();
 738
 0739            foreach (var p in productsContent)
 740            {
 0741                newArticles.Add(p["Артикул"]?.ToString() ?? "");
 0742                ozonProductIds.Add(p["Ozon Product ID"]?.ToString() ?? "");
 0743                fbsOzonSkuIds.Add(p["SKU"]?.ToString() ?? "");
 744            }
 745
 746            // ШАГ 4: Параллельно получаем цены, склады, картинки (как в оригинальном BuildData)
 0747            var pricesTask = GetProductPricesParallel(newArticles, ozonProductIds, batchSize);
 0748            var warehousesTask = GetProductWarehousesParallel(fbsOzonSkuIds, batchSize);
 0749            var imagesTask = GetProductPrimaryImagesParallel(ozonProductIds, batchSize);
 750
 751            // Ожидаем цены и склады (при временном сбое цен или складов используем fallback из отправления)
 752            JObject productPrices;
 753            try
 754            {
 0755                productPrices = await pricesTask;
 0756            }
 0757            catch (Exception ex)
 758            {
 0759                Console.WriteLine($"BuildDataForPosting: ошибка получения цен ({ex.GetType().Name}): {ex.Message}");
 0760                productPrices = new JObject();
 0761            }
 762
 763            JObject allProductWarehouses;
 764            try
 765            {
 0766                allProductWarehouses = await warehousesTask;
 0767            }
 0768            catch (Exception ex)
 769            {
 0770                Console.WriteLine($"BuildDataForPosting: ошибка получения складов ({ex.GetType().Name}): {ex.Message}");
 0771                allProductWarehouses = new JObject();
 0772            }
 773
 774            // Картинки — опционально (могут не загрузиться для новых товаров).
 775            // imagesTask всегда наблюдается, чтобы исключить UnobservedTaskException на сборщике мусора.
 776            Dictionary<string, string?> primaryImages;
 777            try
 778            {
 0779                primaryImages = await imagesTask;
 0780            }
 0781            catch
 782            {
 0783                primaryImages = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
 0784            }
 785
 786            // Данные склад+город — ответ /v3/posting/fbs/get (уже получен в шаге 1)
 0787            var warehousesAndCitys = new JArray { postingResult.DeepClone() };
 788
 789            // ШАГ 5: Собираем данные через тот же CollectData что и оригинальный BuildData
 0790            var collectDataResult = new JArray();
 791            try
 792            {
 0793                collectDataResult = await CollectData(
 0794                    oredersContent, productsContent, productPrices,
 0795                    allProductWarehouses, warehousesAndCitys, primaryImages);
 0796            }
 0797            catch (Exception ex)
 798            {
 0799                Console.WriteLine($"BuildDataForPosting: ошибка при сборке данных: {ex.Message}");
 0800            }
 801
 802            // ШАГ 6: Гарантируем что productPricesWithArticle не null (для CalculateСommissions)
 0803            foreach (var orderToken in collectDataResult)
 804            {
 0805                if (orderToken is not JObject order)
 806                    continue;
 807
 0808                if (order["productPricesWithArticle"] == null ||
 0809                    order["productPricesWithArticle"]!.Type == JTokenType.Null)
 810                {
 0811                    var offerId = order["Артикул"]?.ToString() ?? "";
 0812                    var product = products.FirstOrDefault(p => p["offer_id"]?.ToString() == offerId);
 0813                    var price = product?["price"]?.ToString() ?? "0";
 814
 0815                    order["productPricesWithArticle"] = new JObject
 0816                    {
 0817                        ["commissions"] = new JObject
 0818                        {
 0819                            ["sales_percent_fbs"] = "0",
 0820                            ["fbs_deliv_to_customer_amount"] = "0",
 0821                            ["fbs_first_mile_max_amount"] = "0",
 0822                            ["fbs_direct_flow_trans_max_amount"] = "0",
 0823                            ["fbs_first_mile_min_amount"] = "0",
 0824                            ["fbs_direct_flow_trans_min_amount"] = "0"
 0825                        },
 0826                        ["price"] = new JObject
 0827                        {
 0828                            ["marketing_seller_price"] = price,
 0829                            ["price"] = price
 0830                        },
 0831                        ["acquiring"] = "0"
 0832                    };
 833                }
 834            }
 835
 0836            return collectDataResult;
 0837        }
 838
 839        /// <summary>
 840        /// Форматирует JToken-дату в ISO 8601 строку.
 841        /// Newtonsoft.Json автоматически конвертирует строки дат в DateTime при парсинге JSON.
 842        /// При вызове ToString() на таком JValue результат зависит от локали потока,
 843        /// что приводит к ошибкам парсинга в CastToModel (dd.MM ↔ MM.dd).
 844        /// ISO 8601 формат парсится корректно в любой культуре.
 845        /// </summary>
 846        private static string FormatJTokenDate(JToken? token)
 847        {
 0848            if (token == null || token.Type == JTokenType.Null)
 0849                return "";
 0850            if (token.Type == JTokenType.Date)
 0851                return token.Value<DateTime>()
 0852                    .ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
 0853            return token.ToString();
 854        }
 855    }
 856}