< Summary

Information
Class: OzonOrdersWeb.Controllers.OzonProductCountrySelection
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OrdersController.OzonLabels.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 3
Coverable lines: 3
Total lines: 1632
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_OrderId()100%210%
get_ProductId()100%210%
get_CountryIsoCode()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OrdersController.OzonLabels.cs

#LineLine coverage
 1using System.IO.Compression;
 2using System.Security.Claims;
 3using iText.Kernel.Pdf;
 4using Microsoft.AspNetCore.Mvc;
 5using Newtonsoft.Json;
 6using OzonDomains;
 7using OzonDomains.Models;
 8using OzonOrdersWeb.Authorization;
 9using Services.ApiServices.DropBoxApi;
 10using Services.ApiServices.OzonApi;
 11using Services.ApiServices.YandexApi;
 12using Services.LabelServices;
 13using Services.ParserServices.HelpDictEnum;
 14using Services.SignalRServices;
 15
 16namespace OzonOrdersWeb.Controllers;
 17
 18public partial class OrdersController
 19{
 20        public async Task<IActionResult> PrintOrdersLable(string ids, int page)
 21
 22        {
 23            List<OzonClient> clients = await _ozonClientServices.GetOzonClients();
 24
 25            int[] idArray = ids.Split(',').Select(int.Parse).ToArray();
 26
 27            var ordersLables = await _orderServices.GetOrdersNoTracking(idArray);
 28
 29            Dictionary<string, byte[]> lables = new Dictionary<string, byte[]>();
 30
 31            int continueLablesCuont = 0;
 32
 33            var failedShipments = new List<string>();
 34
 35
 36            // Экран использует устаревший прямой путь без нового JS-пайплайна. Сгруппируем
 37            // заказы по клиенту: экземпляр OzonJsonDataBuilder переключается только между
 38            // группами, а запросы этикеток внутри одной группы независимы и идут параллельно.
 39            const int labelParallelism = 8;
 40            var resultLock = new object();
 41
 42            foreach (var clientGroup in ordersLables.GroupBy(order => order.OzonClient?.ClientId))
 43            {
 44                var client = clients.FirstOrDefault(c => c.ClientId == clientGroup.Key);
 45                if (client == null || string.IsNullOrEmpty(client.DecryptClientId) ||
 46                    string.IsNullOrEmpty(client.DecryptApiKey))
 47                {
 48                    lock (resultLock)
 49                    {
 50                        foreach (var order in clientGroup)
 51                        {
 52                            continueLablesCuont++;
 53                            failedShipments.Add($"{order.ShipmentNumber}: Ozon клиент не найден");
 54                        }
 55                    }
 56
 57                    continue;
 58                }
 59
 60                _jsonDataBuilder.SetClient(client.DecryptClientId, client.DecryptApiKey);
 61                using var semaphore = new SemaphoreSlim(labelParallelism);
 62
 63                await Task.WhenAll(clientGroup.Select(async order =>
 64                {
 65                    await semaphore.WaitAsync();
 66                    try
 67                    {
 68                        var isRussianPost = order.Delivery?.Provider?.Name?.Contains(
 69                            "Почта России", StringComparison.OrdinalIgnoreCase) == true;
 70                        var maxAttempts = isRussianPost ? 5 : 3;
 71                        var delayMs = isRussianPost ? 1500 : 500;
 72
 73                        byte[]? labelBytes = null;
 74                        Exception? lastError = null;
 75
 76                        for (var attempt = 1; attempt <= maxAttempts; attempt++)
 77                        {
 78                            try
 79                            {
 80                                labelBytes = await _jsonDataBuilder.GetOrderLabel(order.ShipmentNumber!);
 81                                if (labelBytes is { Length: > 0 })
 82                                    break;
 83                            }
 84                            catch (Exception ex)
 85                            {
 86                                lastError = ex;
 87                            }
 88
 89                            if (attempt < maxAttempts)
 90                                await Task.Delay(delayMs);
 91                        }
 92
 93                        lock (resultLock)
 94                        {
 95                            if (labelBytes is { Length: > 0 })
 96                            {
 97                                lables[order.ShipmentNumber!] = labelBytes;
 98                            }
 99                            else
 100                            {
 101                                continueLablesCuont++;
 102                                var reason = lastError?.Message ?? "этикетка не сформирована (пустой ответ)";
 103                                failedShipments.Add($"{order.ShipmentNumber}: {reason}");
 104                            }
 105                        }
 106                    }
 107                    catch (Exception ex)
 108                    {
 109                        lock (resultLock)
 110                        {
 111                            continueLablesCuont++;
 112                            failedShipments.Add($"{order.ShipmentNumber}: {ex.Message}");
 113                        }
 114                    }
 115                    finally
 116                    {
 117                        semaphore.Release();
 118                    }
 119                }));
 120            }
 121
 122
 123            var result = SendFilesToUser(lables);
 124
 125            if (result == null)
 126
 127            {
 128                if (Request.Cookies["selectedIds"] != null)
 129
 130                {
 131                    Response.Cookies.Delete("selectedIds");
 132                }
 133
 134
 135                TempData["ErorrResult"] =
 136                    $"Не удалось распечатать этикетку для заказов: {string.Join(", ", ordersLables.Select(o => o.Shipmen
 137                    $"Печать этикетки возможна только для заказов со статусом <b>'Ожидает отгрузки'</b>";
 138
 139
 140                var errorMsg =
 141                    $"Не удалось распечатать этикетки. Печать возможна только для заказов со статусом 'Ожидает отгрузки'
 142
 143                if (failedShipments.Any())
 144
 145                {
 146                    errorMsg += $" Ошибки по заказам: {string.Join("; ", failedShipments.Take(5))}" +
 147                                (failedShipments.Count > 5 ? " ..." : "");
 148                }
 149
 150
 151                await NotificationService.NotifyAllAsync(errorMsg);
 152
 153                return RedirectToAction(nameof(System.Index), new { sortOrder = GetSortStateCookie(), page = page });
 154            }
 155
 156
 157            var successCount = lables.Count;
 158
 159            if (successCount > 0)
 160
 161            {
 162                var msg = $"<div class='alert alert-success'>" +
 163                          $"<strong>Этикетки сформированы</strong><br/>" +
 164                          $"Успешно: <b>{successCount}</b>, с ошибками: <b>{continueLablesCuont}</b>." +
 165                          $"</div>";
 166
 167                await NotificationService.NotifyAllAsync(msg);
 168
 169
 170                if (continueLablesCuont > 0 && failedShipments.Any())
 171
 172                {
 173                    var warn = $"<div class='alert alert-warning'>" +
 174                               $"<strong>Часть этикеток не сформирована</strong><br/>" +
 175                               $"Ошибки по заказам: {string.Join("; ", failedShipments.Take(5))}" +
 176                               (failedShipments.Count > 5 ? " ..." : "") +
 177                               $"</div>";
 178
 179                    await NotificationService.NotifyAllAsync(warn);
 180                }
 181            }
 182
 183
 184            ClearSelectedIdsSession();
 185
 186            return result;
 187        }
 188
 189
 190        [ServiceEndpoint]
 191        [HttpGet]
 192        public async Task<IActionResult> GetOzonLabelEligibleOrders(string ids)
 193        {
 194            if (string.IsNullOrWhiteSpace(ids))
 195            {
 196                return Json(new { eligibleOrderIds = Array.Empty<int>(), skippedOrders = Array.Empty<object>() });
 197            }
 198
 199            var orderIds = ids.Split(',', StringSplitOptions.RemoveEmptyEntries)
 200                .Select(value => int.TryParse(value.Trim(), out var id) ? id : (int?)null)
 201                .OfType<int>()
 202                .Distinct()
 203                .ToArray();
 204
 205            if (orderIds.Length == 0)
 206            {
 207                return Json(new { eligibleOrderIds = Array.Empty<int>(), skippedOrders = Array.Empty<object>() });
 208            }
 209
 210            var orders = await _orderServices.GetOrdersNoTracking(orderIds);
 211            var eligibleOrderIds = orders
 212                .Where(order => OzonLabelOrderEligibility.IsOzonOrder(
 213                    order.OzonClient?.ClientType,
 214                    order.ShipmentNumber,
 215                    order.OzonClient?.Id))
 216                .Select(order => order.Id)
 217                .ToArray();
 218            var skippedOrders = orders
 219                .Where(order => !eligibleOrderIds.Contains(order.Id))
 220                .Select(order => new
 221                {
 222                    orderId = order.Id,
 223                    shipmentNumber = order.ShipmentNumber,
 224                    clientType = order.OzonClient?.ClientType.ToString(),
 225                    clientName = order.OzonClient?.Name
 226                })
 227                .ToArray();
 228
 229            return Json(new { eligibleOrderIds, skippedOrders });
 230        }
 231
 232        [ServiceEndpoint]
 233        [HttpGet]
 234        public async Task<IActionResult> GetOrderProducts(string ids)
 235
 236        {
 237            try
 238
 239            {
 240                if (string.IsNullOrWhiteSpace(ids))
 241
 242                    return Json(new { orderProducts = Array.Empty<object>() });
 243
 244
 245                var idArray = ids.Split(',', StringSplitOptions.RemoveEmptyEntries)
 246                    .Select(s => int.TryParse(s.Trim(), out var id) ? id : (int?)null)
 247                    .Where(id => id.HasValue)
 248                    .Select(id => id!.Value)
 249                    .ToArray();
 250
 251
 252                if (idArray.Length == 0)
 253
 254                    return Json(new { orderProducts = Array.Empty<object>() });
 255
 256
 257                var clients = await _ozonClientServices.GetOzonClients();
 258
 259                var result = new List<object>();
 260
 261                // Строки одного отправления (несколько товаров в posting) — один запрос к Ozon.
 262                var loadedPostings = new HashSet<string>(StringComparer.Ordinal);
 263
 264                foreach (var orderId in idArray)
 265                {
 266                    var order = await _orderServices.GetOrder(orderId);
 267                    if (order?.OzonClient == null || string.IsNullOrEmpty(order.ShipmentNumber))
 268                        continue;
 269
 270                    var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber);
 271                    if (!loadedPostings.Add(apiPosting))
 272                        continue;
 273
 274                    try
 275                    {
 276                        var client = clients.FirstOrDefault(c => c.ClientId == order.OzonClient.ClientId);
 277                        if (client == null || string.IsNullOrEmpty(client.DecryptClientId) ||
 278                            string.IsNullOrEmpty(client.DecryptApiKey))
 279                            continue;
 280
 281                        _jsonDataBuilder.SetClient(client.DecryptClientId, client.DecryptApiKey);
 282                        var posting = await _jsonDataBuilder.GetPostingProductsWithDeliveryAsync(apiPosting);
 283                        var skus = posting.Products
 284                            .Select(p => p["sku"]?.ToObject<long?>() ?? 0)
 285                            .Where(sku => sku > 0)
 286                            .Distinct()
 287                            .ToArray();
 288                        var images = await _jsonDataBuilder.GetPrimaryImagesBySkuAsync(skus);
 289
 290                        result.Add(new
 291                        {
 292                            orderId = order.Id,
 293                            shipmentNumber = apiPosting,
 294                            deliveryMethod = posting.DeliveryMethodName,
 295                            isRussianPost = OzonDeliveryMethodHelper.IsRussianPost(
 296                                posting.DeliveryMethodName, posting.TplProvider),
 297                            products = posting.Products.Select(p => new
 298                            {
 299                                offer_id = p["offer_id"]?.ToString(),
 300                                name = p["name"]?.ToString(),
 301                                sku = p["sku"]?.ToObject<long?>(),
 302                                imageUrl = ResolvePostingProductImage(p, images, order),
 303                                quantity = p["quantity"]?.ToObject<int?>(),
 304                                price = p["price"]?.ToString(),
 305                                currency_code = p["currency_code"]?.ToString()
 306                            }).ToList()
 307                        });
 308                    }
 309                    catch
 310                    {
 311                        // Пропускаем заказ при ошибке
 312                    }
 313                }
 314
 315                return Json(new { orderProducts = result });
 316            }
 317
 318            catch
 319
 320            {
 321                return Json(new { orderProducts = Array.Empty<object>() });
 322            }
 323        }
 324
 325        /// <summary>
 326        /// Картинка товара отправления: главная картинка Ozon по SKU/offer_id, иначе — картинка
 327        /// карточки товара заказа Studio (как в основной таблице), если это тот же SKU.
 328        /// </summary>
 329        private static string? ResolvePostingProductImage(
 330            Newtonsoft.Json.Linq.JObject product,
 331            IReadOnlyDictionary<string, string> images,
 332            Order order)
 333        {
 334            var sku = product["sku"]?.ToString();
 335            if (!string.IsNullOrEmpty(sku) && images.TryGetValue(sku, out var bySku))
 336                return bySku;
 337
 338            var offerId = product["offer_id"]?.ToString();
 339            if (!string.IsNullOrEmpty(offerId) && images.TryGetValue(offerId, out var byOffer))
 340                return byOffer;
 341
 342            return !string.IsNullOrEmpty(sku) && order.ProductInfo?.FbsOzonSkuId == sku
 343                ? order.ProductInfo.ImageUrl
 344                : null;
 345        }
 346
 347
 348        [ServiceEndpoint]
 349        [HttpPost]
 350        public async Task<IActionResult> GetOzonCountryRequirements(string ids)
 351        {
 352            var orderIds = ParseOrderIds(ids);
 353            if (orderIds.Length == 0)
 354                return Json(new { success = true, requirements = Array.Empty<object>() });
 355
 356            var clients = await _ozonClientServices.GetOzonClients();
 357            var orders = await _orderServices.GetOrdersNoTracking(orderIds);
 358            var requirements = new List<object>();
 359
 360            foreach (var order in orders.Where(order =>
 361                         OzonLabelOrderEligibility.IsOzonOrder(order.OzonClient?.ClientType, order.ShipmentNumber,
 362                             order.OzonClient?.Id) && !string.IsNullOrWhiteSpace(order.ShipmentNumber)))
 363            {
 364                var client = clients.FirstOrDefault(item => item.ClientId == order.OzonClient?.ClientId);
 365                if (client == null || string.IsNullOrWhiteSpace(client.DecryptClientId) ||
 366                    string.IsNullOrWhiteSpace(client.DecryptApiKey))
 367                {
 368                    continue;
 369                }
 370
 371                try
 372                {
 373                    _ozonApiDataManager.SetClient(client.DecryptClientId, client.DecryptApiKey);
 374                    var postingNumber = OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber!);
 375                    var products = await _ozonApiDataManager.GetFbsProductsRequiringCountryAsync(postingNumber);
 376                    requirements.AddRange(products.Select(product => new
 377                    {
 378                        orderId = order.Id,
 379                        shipmentNumber = order.ShipmentNumber,
 380                        productId = product["sku"]?.ToObject<long>() ?? 0,
 381                        productName = product["name"]?.ToObject<string>() ?? product["offer_id"]?.ToObject<string>() ?? 
 382                        offerId = product["offer_id"]?.ToObject<string>() ?? string.Empty
 383                    }).Where(item => item.productId > 0));
 384                }
 385                catch
 386                {
 387                    // Ошибка Ozon по одному отправлению не должна ронять проверку остальных заказов
 388                    // и всю печать — недостающую страну пользователь увидит уже как ошибку отгрузки.
 389                }
 390            }
 391
 392            return Json(new { success = true, requirements });
 393        }
 394
 395        /// <summary>
 396        /// Требования Ozon к данным экземпляров (Честный ЗНАК, ГТД, РНПТ) перед сборкой отправления.
 397        /// Без них /v4/posting/fbs/ship отвечает EXEMPLAR_INFO_NOT_FILLED_COMPLETELY.
 398        /// </summary>
 399        [ServiceEndpoint]
 400        [HttpPost]
 401        public async Task<IActionResult> GetOzonExemplarRequirements(string ids)
 402        {
 403            var orderIds = ParseOrderIds(ids);
 404            if (orderIds.Length == 0)
 405                return Json(new { success = true, requirements = Array.Empty<object>() });
 406
 407            var clients = await _ozonClientServices.GetOzonClients();
 408            var orders = await _orderServices.GetOrdersNoTracking(orderIds);
 409            var requirements = new List<object>();
 410
 411            foreach (var ordersByClient in orders.GroupBy(order => order.OzonClient?.ClientId))
 412            {
 413                var client = clients.FirstOrDefault(item => item.ClientId == ordersByClient.Key);
 414                if (client == null || string.IsNullOrWhiteSpace(client.DecryptClientId) ||
 415                    string.IsNullOrWhiteSpace(client.DecryptApiKey))
 416                {
 417                    continue;
 418                }
 419
 420                _ozonExemplarService.SetClient(client.DecryptClientId, client.DecryptApiKey);
 421                var clientRequirements = await _ozonExemplarService.GetRequirementsAsync(ordersByClient);
 422
 423                requirements.AddRange(clientRequirements.Select(item => new
 424                {
 425                    orderId = item.OrderId,
 426                    shipmentNumber = item.ShipmentNumber,
 427                    productId = item.Requirement.ProductId,
 428                    productName = item.ProductName,
 429                    offerId = item.OfferId,
 430                    exemplarId = item.Requirement.ExemplarId,
 431                    index = item.Requirement.Index,
 432                    quantity = item.Requirement.Quantity,
 433                    needsMandatoryMark = item.Requirement.NeedsMandatoryMark,
 434                    needsGtd = item.Requirement.NeedsGtd,
 435                    needsRnpt = item.Requirement.NeedsRnpt,
 436                    unsupported = item.Requirement.Unsupported
 437                }));
 438            }
 439
 440            return Json(new { success = true, requirements });
 441        }
 442
 443        /// <summary>
 444        /// Сохраняет данные экземпляров в Ozon и ждёт вердикта о готовности отправления к сборке.
 445        /// Печать продолжается только при ship_available.
 446        /// </summary>
 447        [ServiceEndpoint]
 448        [HttpPost]
 449        public async Task<IActionResult> SetOzonExemplars([FromBody] List<OzonExemplarSubmission>? submissions)
 450        {
 451            if (submissions == null || submissions.Count == 0)
 452            {
 453                return BadRequest(new { success = false, message = "Не переданы данные экземпляров" });
 454            }
 455
 456            var orderIds = submissions.Select(item => item.OrderId).Distinct().ToArray();
 457            var orders = await _orderServices.GetOrdersNoTracking(orderIds);
 458            var clients = await _ozonClientServices.GetOzonClients();
 459            var ordersById = orders.ToDictionary(order => order.Id);
 460
 461            var reasons = new List<string>();
 462
 463            foreach (var submission in submissions)
 464            {
 465                if (!ordersById.TryGetValue(submission.OrderId, out var order) ||
 466                    string.IsNullOrWhiteSpace(order.ShipmentNumber))
 467                {
 468                    return BadRequest(new { success = false, message = "Заказ для сохранения экземпляров не найден" });
 469                }
 470
 471                var client = clients.FirstOrDefault(item => item.ClientId == order.OzonClient?.ClientId);
 472                if (client == null || string.IsNullOrWhiteSpace(client.DecryptClientId) ||
 473                    string.IsNullOrWhiteSpace(client.DecryptApiKey))
 474                {
 475                    return BadRequest(new { success = false, message = $"{order.ShipmentNumber}: не найден клиент Ozon" 
 476                }
 477
 478                _ozonExemplarService.SetClient(client.DecryptClientId, client.DecryptApiKey);
 479
 480                OzonExemplarSaveResult result;
 481                try
 482                {
 483                    result = await _ozonExemplarService.SaveAsync(order.ShipmentNumber, submission.Exemplars);
 484                }
 485                catch (Exception ex)
 486                {
 487                    return BadRequest(new
 488                    {
 489                        success = false,
 490                        message = $"{order.ShipmentNumber}: Ozon отклонил данные экземпляров. {ex.Message}"
 491                    });
 492                }
 493
 494                if (!result.ShipAvailable)
 495                {
 496                    reasons.AddRange(result.Reasons.Select(reason => $"{order.ShipmentNumber}: {reason}"));
 497                }
 498            }
 499
 500            if (reasons.Count > 0)
 501            {
 502                return Json(new
 503                {
 504                    success = false,
 505                    message = "Ozon не подтвердил готовность отправления к сборке.",
 506                    reasons
 507                });
 508            }
 509
 510            return Json(new { success = true });
 511        }
 512
 513        [ServiceEndpoint]
 514        [HttpGet]
 515        public async Task<IActionResult> GetOzonProductCountries(int orderId, string? search = null)
 516        {
 517            var order = await _orderServices.GetOrder(orderId);
 518            var client = (await _ozonClientServices.GetOzonClients())
 519                .FirstOrDefault(item => item.ClientId == order?.OzonClient?.ClientId);
 520            if (client == null || string.IsNullOrWhiteSpace(client.DecryptClientId) ||
 521                string.IsNullOrWhiteSpace(client.DecryptApiKey))
 522            {
 523                return BadRequest(new { success = false, message = "Не найден клиент Ozon для заказа" });
 524            }
 525
 526            _ozonApiDataManager.SetClient(client.DecryptClientId, client.DecryptApiKey);
 527            var countries = await _ozonApiDataManager.GetFbsProductCountriesAsync(search);
 528            return Json(new
 529            {
 530                success = true,
 531                countries = countries.Select(country => new
 532                {
 533                    name = country["name"]?.ToObject<string>() ?? string.Empty,
 534                    countryIsoCode = country["country_iso_code"]?.ToObject<string>() ?? string.Empty
 535                }).Where(country => !string.IsNullOrWhiteSpace(country.countryIsoCode))
 536                .OrderBy(country => country.name, StringComparer.Create(
 537                    System.Globalization.CultureInfo.GetCultureInfo("ru-RU"), ignoreCase: true))
 538            });
 539        }
 540
 541        [ServiceEndpoint]
 542        [HttpPost]
 543        public async Task<IActionResult> SetOzonProductCountries([FromBody] List<OzonProductCountrySelection>? selection
 544        {
 545            if (selections == null || selections.Count == 0 || selections.Any(selection =>
 546                    selection.OrderId <= 0 || selection.ProductId <= 0 || string.IsNullOrWhiteSpace(selection.CountryIso
 547            {
 548                return BadRequest(new { success = false, message = "Не заполнены страны-изготовители" });
 549            }
 550
 551            var orders = await _orderServices.GetOrdersNoTracking(selections.Select(selection => selection.OrderId)
 552                .Distinct().ToArray());
 553            var clients = await _ozonClientServices.GetOzonClients();
 554            var ordersById = orders.ToDictionary(order => order.Id);
 555
 556            foreach (var selection in selections)
 557            {
 558                if (!ordersById.TryGetValue(selection.OrderId, out var order) ||
 559                    string.IsNullOrWhiteSpace(order.ShipmentNumber))
 560                {
 561                    return BadRequest(new { success = false, message = "Заказ для сохранения страны не найден" });
 562                }
 563
 564                var client = clients.FirstOrDefault(item => item.ClientId == order.OzonClient?.ClientId);
 565                if (client == null || string.IsNullOrWhiteSpace(client.DecryptClientId) ||
 566                    string.IsNullOrWhiteSpace(client.DecryptApiKey))
 567                {
 568                    return BadRequest(new { success = false, message = $"{order.ShipmentNumber}: не найден клиент Ozon" 
 569                }
 570
 571                _ozonApiDataManager.SetClient(client.DecryptClientId, client.DecryptApiKey);
 572                await _ozonApiDataManager.SetFbsProductCountryAsync(
 573                    OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber), selection.ProductId,
 574                    selection.CountryIsoCode);
 575            }
 576
 577            return Json(new { success = true });
 578        }
 579
 580        private static int[] ParseOrderIds(string? ids)
 581        {
 582            return (ids ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries)
 583                .Select(value => int.TryParse(value.Trim(), out var id) ? id : (int?)null)
 584                .OfType<int>()
 585                .Distinct()
 586                .ToArray();
 587        }
 588
 589        [ServiceEndpoint]
 590        [HttpPost]
 591        public async Task<IActionResult> CheckLabelsInDropbox(string ids, string? sessionKey = null)
 592
 593        {
 594            try
 595
 596            {
 597                int[] idArray = ids.Split(',').Select(int.Parse).ToArray();
 598
 599                var ordersLables = await _orderServices.GetOrdersNoTracking(idArray);
 600
 601                Dictionary<string, byte[]> labels = new Dictionary<string, byte[]>();
 602
 603                var failedShipments = new List<string>();
 604
 605
 606                // Проверяем наличие этикеток в хранилище
 607
 608                bool allLabelsExist = true;
 609
 610                foreach (var order in ordersLables)
 611
 612                {
 613                    try
 614
 615                    {
 616                        var matchedFileName = await _labelFileStorage.FindExistingFileNameAsync(
 617                            OzonPostingNumberHelper.GetLabelFileNameCandidates(order.ShipmentNumber));
 618
 619                        if (matchedFileName != null)
 620
 621                        {
 622                            // Загружаем файл из хранилища
 623
 624                            try
 625
 626                            {
 627                                var labelBytes = await _labelFileStorage.TryDownloadAsync(matchedFileName);
 628
 629                                if (labelBytes != null && labelBytes.Length > 0)
 630
 631                                {
 632                                    labels.Add(order.ShipmentNumber!, labelBytes);
 633                                }
 634                                else
 635                                {
 636                                    allLabelsExist = false;
 637                                }
 638                            }
 639
 640                            catch (Exception storageEx)
 641
 642                            {
 643                                allLabelsExist = false;
 644
 645                                failedShipments.Add(
 646                                    $"{order.ShipmentNumber}: Не удалось загрузить этикетку - {storageEx.Message}");
 647                            }
 648                        }
 649
 650                        else
 651
 652                        {
 653                            allLabelsExist = false;
 654                        }
 655                    }
 656
 657                    catch (Exception ex)
 658                    {
 659                        allLabelsExist = false;
 660
 661                        System.Diagnostics.Debug.WriteLine(
 662                            $"Ошибка при проверке этикетки {order.ShipmentNumber} в хранилище: {ex.Message}");
 663                    }
 664                }
 665
 666
 667                // Если все этикетки существуют в хранилище, сохраняем их в сессию и возвращаем sessionKey
 668
 669                if (allLabelsExist && labels.Count > 0)
 670
 671                {
 672                    var finalSessionKey = _ozonLabelSessionStore.SaveLabels(HttpContext.Session, sessionKey, labels, app
 673
 674
 675                    // Обновляем кэш для всех найденных этикеток
 676
 677                    foreach (var shipmentNumber in labels.Keys)
 678
 679                    {
 680                        UpdateLabelCache(shipmentNumber, true);
 681                    }
 682
 683
 684                    return Json(new
 685
 686                    {
 687                        success = true,
 688
 689                        message = $"Этикетки для всех заказов найдены в системе ({labels.Count} шт.)",
 690
 691                        sessionKey = finalSessionKey,
 692
 693                        generatedLabels = labels.Keys.ToList(),
 694
 695                        failedShipments = failedShipments,
 696
 697                        totalCount = labels.Count,
 698
 699                        labelsAlreadyExist = true,
 700
 701                        skipFurtherSteps = true // Флаг для фронтенда, чтобы пропустить дальнейшие шаги
 702                    });
 703                }
 704
 705
 706                string? accumulatedSessionKey = null;
 707                if (labels.Count > 0 && !string.IsNullOrEmpty(sessionKey))
 708                {
 709                    accumulatedSessionKey = _ozonLabelSessionStore.SaveLabels(HttpContext.Session, sessionKey, labels, a
 710                }
 711
 712                // Если этикетки не найдены или не все найдены, возвращаем информацию для продолжения обычного процесса
 713
 714                return Json(new
 715
 716                {
 717                    success = true,
 718
 719                    message = labels.Count > 0
 720                        ? $"Найдено {labels.Count} из {ordersLables.Count} этикеток в системе. Продолжаем генерацию оста
 721                        : "Этикетки не найдены в системе. Продолжаем генерацию.",
 722
 723                    labelsFound = labels.Count,
 724
 725                    totalOrders = ordersLables.Count,
 726
 727                    skipFurtherSteps = false,
 728
 729                    foundLabels = labels.Keys.ToList(),
 730
 731                    sessionKey = accumulatedSessionKey
 732                });
 733            }
 734
 735            catch (Exception ex)
 736            {
 737                return Json(new
 738
 739                {
 740                    success = false,
 741
 742                    message = $"Ошибка при проверке этикеток в системе: {ex.Message}",
 743
 744                    skipFurtherSteps = false
 745                });
 746            }
 747        }
 748
 749
 750        /// <summary>
 751        /// Батч-запрос статусов Ozon для всех отправлений пачки: группируем по клиенту Ozon
 752        /// (у каждого свои креды) и опрашиваем отправления группы параллельно. Ошибку запроса
 753        /// не подменяем пустым статусом — такое отправление просто не попадает в словарь и
 754        /// вызывающий код запрашивает статус для него обычным путём.
 755        /// </summary>
 756        private async Task<Dictionary<string, string>> LoadOzonStatusesAsync(
 757            IEnumerable<Order> orders,
 758            List<OzonClient> clients)
 759        {
 760            var result = new Dictionary<string, string>(StringComparer.Ordinal);
 761
 762            foreach (var clientGroup in orders.GroupBy(o => o.OzonClient?.ClientId))
 763            {
 764                var client = clients.FirstOrDefault(c => c.ClientId == clientGroup.Key);
 765                if (client == null || string.IsNullOrEmpty(client.DecryptClientId) ||
 766                    string.IsNullOrEmpty(client.DecryptApiKey))
 767                {
 768                    continue;
 769                }
 770
 771                var postings = clientGroup
 772                    .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber))
 773                    .Select(o => OzonPostingNumberHelper.GetOzonApiPostingNumber(o.ShipmentNumber))
 774                    .Distinct(StringComparer.Ordinal)
 775                    .ToList();
 776
 777                if (postings.Count == 0)
 778                {
 779                    continue;
 780                }
 781
 782                _jsonDataBuilder.SetClient(client.DecryptClientId, client.DecryptApiKey);
 783
 784                try
 785                {
 786                    var statuses = await _jsonDataBuilder.GetProductStatusBatch(postings);
 787                    foreach (var (posting, value) in statuses)
 788                    {
 789                        if (value.Success && !string.IsNullOrEmpty(value.Status))
 790                        {
 791                            result[posting] = value.Status;
 792                        }
 793                    }
 794                }
 795                catch
 796                {
 797                    // Статусы — оптимизация, не обязательное условие: при сбое каждый заказ
 798                    // запросит свой статус сам.
 799                }
 800            }
 801
 802            return result;
 803        }
 804
 805        [ServiceEndpoint]
 806        public async Task<IActionResult> UpdateOrdersStatus(string ids, string? splitOption = null)
 807
 808        {
 809            try
 810
 811            {
 812                List<OzonClient> clients = await _ozonClientServices.GetOzonClients();
 813
 814                int[] idArray = ids.Split(',').Select(int.Parse).ToArray();
 815
 816                var ordersLables = await _orderServices.GetOrdersNoTracking(idArray);
 817
 818                var failedShipments = new List<string>();
 819
 820                string updatedOrdersResult = "";
 821
 822                var studioParentGroups = OzonPostingNumberHelper.GroupStudioLocalSplitSiblings(ordersLables);
 823
 824                var shippedStudioParents = new HashSet<string>(StringComparer.Ordinal);
 825
 826                // Отгрузка собирает отправление целиком (все товары), поэтому остальные строки того же
 827                // отправления (несколько разных товаров в одном posting) повторно не отгружаем — иначе они
 828                // пошли бы со статусом из батча ниже, уже устаревшим после первой отгрузки.
 829                var processedPostings = new HashSet<string>(StringComparer.Ordinal);
 830
 831                // Статусы всех отправлений — заранее и параллельно, по одному батчу на клиента Ozon.
 832                // Иначе каждый заказ в цикле ниже начинался бы с собственного последовательного
 833                // запроса статуса (~0.4 c на заказ впустую, пока Ozon уже всё отдал).
 834                var knownStatuses = await LoadOzonStatusesAsync(ordersLables, clients);
 835
 836                foreach (var order in ordersLables)
 837
 838                {
 839                    try
 840
 841                    {
 842                        var client = clients.FirstOrDefault(c => c.ClientId == order.OzonClient?.ClientId);
 843                        if (client == null || string.IsNullOrEmpty(client.DecryptClientId) ||
 844                            string.IsNullOrEmpty(client.DecryptApiKey))
 845                        {
 846                            failedShipments.Add($"{order.ShipmentNumber}: Ozon клиент не найден");
 847                            continue;
 848                        }
 849
 850                        _jsonDataBuilder.SetClient(client.DecryptClientId, client.DecryptApiKey);
 851                        // _ozonShipService держит собственный (DI, transient) OzonJsonDataBuilder —
 852                        // SetClient выше на него не влияет, клиента нужно выставлять отдельно здесь же.
 853                        _ozonShipService.SetClient(client.DecryptClientId, client.DecryptApiKey);
 854
 855                        var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber);
 856                        if (studioParentGroups.TryGetValue(apiPosting, out var siblings))
 857                        {
 858                            if (shippedStudioParents.Contains(apiPosting))
 859                            {
 860                                updatedOrdersResult =
 861                                    $"Отгрузка для {order.ShipmentNumber} выполнена в составе группы {apiPosting}.";
 862                                continue;
 863                            }
 864
 865                            updatedOrdersResult = await _ozonLabelPrintService.ProcessStudioLocalSplitGroupShipAsync(
 866                                siblings,
 867                                apiPosting,
 868                                _ozonShipService.ProcessOrderStatusAsync);
 869                            shippedStudioParents.Add(apiPosting);
 870                            continue;
 871                        }
 872
 873                        if (!processedPostings.Add(apiPosting))
 874                        {
 875                            updatedOrdersResult =
 876                                $"Отгрузка для {order.ShipmentNumber} выполнена в составе отправления {apiPosting}.";
 877                            continue;
 878                        }
 879
 880                        updatedOrdersResult = knownStatuses.TryGetValue(apiPosting, out var knownStatus)
 881                            ? await _ozonShipService.ProcessOrderStatusWithKnownStatusAsync(
 882                                order, splitOption, knownStatus)
 883                            : await _ozonShipService.ProcessOrderStatusAsync(order, splitOption);
 884                    }
 885
 886                    catch (Exception ex)
 887                    {
 888                        await _ozonShipService.RecordFailedShipmentAsync(order, ex, failedShipments);
 889                    }
 890                }
 891
 892
 893                await _clientStatusSyncService.SyncOrderStatusesAsync(idArray);
 894
 895                var labelBlockReasons = new List<string>();
 896                string? primaryOzonStatus = null;
 897                foreach (var order in ordersLables)
 898                {
 899                    if (failedShipments.Exists(f => f.StartsWith(order.ShipmentNumber + ":", StringComparison.Ordinal)))
 900                    {
 901                        continue;
 902                    }
 903
 904                    if (order.OzonClient == null)
 905                    {
 906                        continue;
 907                    }
 908
 909                    var client = clients.FirstOrDefault(c => c.ClientId == order.OzonClient.ClientId);
 910                    if (client == null || string.IsNullOrEmpty(client.DecryptClientId) ||
 911                        string.IsNullOrEmpty(client.DecryptApiKey))
 912                    {
 913                        continue;
 914                    }
 915
 916                    _jsonDataBuilder.SetClient(client.DecryptClientId, client.DecryptApiKey);
 917                    var ozonStatus = await _jsonDataBuilder.GetProductSatatus(
 918                        OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber!));
 919                    primaryOzonStatus ??= ozonStatus;
 920
 921                    if (!OzonLabelStatusHelper.IsOzonStatusLabelGenerationBlocked(ozonStatus))
 922                    {
 923                        continue;
 924                    }
 925
 926                    var statusName = OzonLabelStatusHelper.GetOzonStatusDisplayName(ozonStatus);
 927                    labelBlockReasons.Add(
 928                        $"{order.ShipmentNumber}: статус «{statusName}» — Ozon API не выдаёт этикетки для завершённых от
 929                }
 930
 931                var allFailed = ordersLables.Count > 0 && failedShipments.Count >= ordersLables.Count;
 932                var anyFailed = failedShipments.Any();
 933                var labelGenerationBlocked = labelBlockReasons.Count > 0;
 934
 935                if (anyFailed)
 936                {
 937                }
 938
 939                var resultMessage = anyFailed
 940                    ? string.Join("; ", failedShipments)
 941                    : updatedOrdersResult;
 942
 943                if (!anyFailed && !string.IsNullOrWhiteSpace(updatedOrdersResult))
 944                {
 945                    resultMessage = updatedOrdersResult;
 946                }
 947
 948                if (labelGenerationBlocked)
 949                {
 950                    resultMessage = string.Join("; ", labelBlockReasons);
 951                }
 952
 953                var result = new
 954
 955                {
 956                    success = !allFailed && !labelGenerationBlocked,
 957
 958                    message = resultMessage,
 959
 960                    updatedOrders = ordersLables.Count > 0 ? ordersLables[0].ShipmentNumber : "",
 961
 962                    failedShipments = failedShipments,
 963
 964                    totalOrders = ordersLables.Count,
 965
 966                    ozonStatus = primaryOzonStatus,
 967
 968                    labelGenerationBlocked,
 969
 970                    labelBlockReasons,
 971
 972                    step = allFailed || labelGenerationBlocked ? "UpdateOrdersStatus" : null
 973                };
 974
 975
 976                return Json(result);
 977            }
 978
 979            catch (Exception ex)
 980
 981            {
 982                return Json(new
 983
 984                {
 985                    success = false,
 986
 987                    message = $"Ошибка при обновлении статусов: {ex.Message}",
 988
 989                    step = "UpdateOrdersStatus"
 990                });
 991            }
 992        }
 993
 994
 995        [ServiceEndpoint]
 996        [HttpPost]
 997        public async Task<IActionResult> GenerateLabels(string ids, string? sessionKey = null)
 998
 999        {
 1000            try
 1001
 1002            {
 1003                List<OzonClient> clients = await _ozonClientServices.GetOzonClients();
 1004
 1005                int[] idArray = ids.Split(',').Select(int.Parse).ToArray();
 1006
 1007                var ordersLables = await _orderServices.GetOrdersNoTracking(idArray);
 1008
 1009                Dictionary<string, byte[]> labels = new Dictionary<string, byte[]>();
 1010
 1011                var failedShipments = new List<string>();
 1012
 1013                var labelsFromDropbox = new List<string>();
 1014
 1015                var labelsGenerated = new List<string>();
 1016
 1017                var ordersNeedingGeneration = new List<Order>();
 1018
 1019                var autoShippedShipments = new List<string>();
 1020
 1021                var labelsStored = new List<string>();
 1022
 1023                foreach (var order in ordersLables)
 1024
 1025                {
 1026                    try
 1027
 1028                    {
 1029                        var matchedFileName = await _labelFileStorage.FindExistingFileNameAsync(
 1030                            OzonPostingNumberHelper.GetLabelFileNameCandidates(order.ShipmentNumber));
 1031
 1032
 1033                        if (matchedFileName != null)
 1034
 1035                        {
 1036                            try
 1037
 1038                            {
 1039                                var labelBytes = await _labelFileStorage.TryDownloadAsync(matchedFileName);
 1040
 1041                                if (labelBytes != null && labelBytes.Length > 0)
 1042
 1043                                {
 1044                                    labels.Add(order.ShipmentNumber!, labelBytes);
 1045
 1046                                    labelsFromDropbox.Add(order.ShipmentNumber!);
 1047                                }
 1048                                else
 1049                                {
 1050                                    ordersNeedingGeneration.Add(order);
 1051                                }
 1052                            }
 1053
 1054                            catch (Exception storageEx)
 1055
 1056                            {
 1057                                ordersNeedingGeneration.Add(order);
 1058                                failedShipments.Add(
 1059                                    $"{order.ShipmentNumber}: Не удалось загрузить этикетку - {storageEx.Message}");
 1060                            }
 1061                        }
 1062
 1063                        else
 1064
 1065                        {
 1066                            ordersNeedingGeneration.Add(order);
 1067                        }
 1068                    }
 1069
 1070                    catch (Exception ex)
 1071                    {
 1072                        failedShipments.Add($"{order.ShipmentNumber}: {ex.Message}");
 1073                    }
 1074                }
 1075
 1076                await _ozonLabelPrintService.GenerateOzonLabelsForOrdersAsync(
 1077                    ordersNeedingGeneration,
 1078                    clients,
 1079                    labels,
 1080                    labelsGenerated,
 1081                    failedShipments,
 1082                    autoShippedShipments,
 1083                    labelsStored);
 1084
 1085                if (labelsStored.Count > 0)
 1086                {
 1087                    var stored = labelsStored.ToHashSet(StringComparer.OrdinalIgnoreCase);
 1088                    await _labelPrintLogService.RecordAsync(ordersLables
 1089                        .Where(order => order.ShipmentNumber is { } shipment && stored.Contains(shipment))
 1090                        .Select(order => CreateStudioLabelLog($"{order.ShipmentNumber}.pdf", order.Id)));
 1091                }
 1092
 1093                // ScanIt связывает этикетку с заказом: если его ещё нет в базе, берём у Ozon при печати.
 1094                try
 1095                {
 1096                    await _ozonLabelPrintService.EnsureScanitAsync(ordersLables.ToList(), clients);
 1097                }
 1098                catch
 1099                {
 1100                    // Не мешаем печати: ScanIt подтянется при следующей печати или загрузке заказа.
 1101                }
 1102
 1103                if (labels.Count == 0)
 1104
 1105                {
 1106
 1107                    return Json(new
 1108
 1109                    {
 1110                        success = false,
 1111
 1112                        message = "Не удалось получить или сгенерировать ни одной этикетки",
 1113
 1114                        failedShipments = failedShipments,
 1115
 1116                        autoShippedShipments = autoShippedShipments,
 1117
 1118                        step = "GenerateLabels"
 1119                    });
 1120                }
 1121
 1122
 1123                var finalSessionKey = _ozonLabelSessionStore.SaveLabels(HttpContext.Session, sessionKey, labels, append:
 1124
 1125
 1126                var message = $"Обработано {labels.Count} этикеток";
 1127
 1128                if (labelsFromDropbox.Count > 0 && labelsGenerated.Count > 0)
 1129
 1130                {
 1131                    message +=
 1132                        $" ({labelsFromDropbox.Count} загружено из системы, {labelsGenerated.Count} сгенерировано и сохр
 1133                }
 1134
 1135                else if (labelsFromDropbox.Count > 0)
 1136
 1137                {
 1138                    message += $" (все загружены из системы)";
 1139                }
 1140
 1141                else if (labelsGenerated.Count > 0)
 1142
 1143                {
 1144                    message += $" (все сгенерированы и успешно сохранены в системе)";
 1145                }
 1146
 1147                if (autoShippedShipments.Count > 0)
 1148
 1149                {
 1150                    message += $". Статус {autoShippedShipments.Count} заказ(ов) автоматически обновлён (отгружен) перед
 1151                }
 1152
 1153                // Обновляем кэш для всех успешно обработанных этикеток
 1154
 1155                foreach (var shipmentNumber in labels.Keys)
 1156
 1157                {
 1158                    UpdateLabelCache(shipmentNumber, true);
 1159                }
 1160
 1161
 1162                return Json(new
 1163
 1164                {
 1165                    success = true,
 1166
 1167                    message = message,
 1168
 1169                    sessionKey = finalSessionKey,
 1170
 1171                    generatedLabels = labels.Keys.ToList(),
 1172
 1173                    failedShipments = failedShipments,
 1174
 1175                    totalCount = labels.Count,
 1176
 1177                    fromDropbox = labelsFromDropbox,
 1178
 1179                    autoShippedShipments = autoShippedShipments,
 1180
 1181                    newlyGenerated = labelsGenerated,
 1182
 1183                    labelsFromDropbox = labelsFromDropbox
 1184                });
 1185            }
 1186
 1187            catch (Exception ex)
 1188
 1189            {
 1190                return Json(new
 1191
 1192                {
 1193                    success = false,
 1194
 1195                    message = $"Ошибка при генерации этикеток: {ex.Message}",
 1196
 1197                    step = "GenerateLabels"
 1198                });
 1199            }
 1200        }
 1201
 1202
 1203        [HttpPost]
 1204        /// <summary>
 1205        /// Этикетки Яндекс.Маркета для выбранных заказов.
 1206        /// <paramref name="force"/> = перепечать: игнорирует сохранённый PDF и запрашивает у API
 1207        /// новый в формате <paramref name="format"/>, перезаписывая файл в хранилище.
 1208        /// </summary>
 1209        public async Task<IActionResult> GenerateYandexLabels(string ids, string format = "A7", bool force = false)
 1210
 1211        {
 1212            try
 1213
 1214            {
 1215                var labels = new Dictionary<string, byte[]>();
 1216
 1217                var failedShipments = new List<string>();
 1218
 1219                var labelsFromDropbox = new List<string>();
 1220
 1221                var labelsGenerated = new List<string>();
 1222                var storedLabelLogs = new List<LabelPrintLogEntry>();
 1223
 1224
 1225                var f = (format ?? "A7").Trim().ToUpperInvariant();
 1226
 1227                if (f != "A9_HORIZONTALLY" && f != "A9" && f != "A7" && f != "A4")
 1228
 1229                    f = "A7";
 1230
 1231
 1232                var clients = await _ozonClientServices.GetOzonClients();
 1233
 1234                var idArray = ids.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
 1235                    .Select(s => int.TryParse(s.Trim(), out var n) ? n : 0).Where(i => i > 0).ToArray();
 1236
 1237                var ordersToProcess = new List<Order>();
 1238
 1239
 1240                foreach (var orderId in idArray)
 1241
 1242                {
 1243                    var order = await _orderServices.GetOrder(orderId);
 1244
 1245                    if (order == null) continue;
 1246
 1247                    if (order.OzonClient?.ClientType != ClientType.YANDEX)
 1248
 1249                    {
 1250                        failedShipments.Add($"{order.ShipmentNumber}: не заказ Яндекс.Маркета");
 1251
 1252                        continue;
 1253                    }
 1254
 1255
 1256                    ordersToProcess.Add(order);
 1257                }
 1258
 1259
 1260                // YandexDataManager хранит текущего клиента. Поэтому клиентов обрабатываем
 1261                // последовательно, а независимые заказы одного клиента — параллельно.
 1262                // Внутренний rate limiter YandexDataManager по-прежнему ограничивает API до 100 rpm.
 1263                // Partner API допускает не более 4 параллельных запросов на кампанию/бизнес (иначе HTTP 420).
 1264                const int labelParallelism = 4;
 1265                var resultLock = new object();
 1266
 1267                foreach (var clientGroup in ordersToProcess.GroupBy(order => order.OzonClient?.ClientId))
 1268                {
 1269                    var client = clients.FirstOrDefault(c => c.ClientId == clientGroup.Key);
 1270                    if (client == null || string.IsNullOrEmpty(client.DecryptClientId) ||
 1271                        string.IsNullOrEmpty(client.DecryptApiKey))
 1272                    {
 1273                        lock (resultLock)
 1274                        {
 1275                            failedShipments.AddRange(clientGroup.Select(order =>
 1276                                $"{order.ShipmentNumber}: нет данных клиента Яндекс"));
 1277                        }
 1278
 1279                        continue;
 1280                    }
 1281
 1282                    _yandexDataManager.SetClient(client.DecryptClientId, client.DecryptApiKey);
 1283                    using var semaphore = new SemaphoreSlim(labelParallelism);
 1284
 1285                    await Task.WhenAll(clientGroup.Select(async order =>
 1286                    {
 1287                        await semaphore.WaitAsync();
 1288                        try
 1289                        {
 1290                            var shipmentNumber = order.ShipmentNumber;
 1291                            if (string.IsNullOrWhiteSpace(shipmentNumber))
 1292                            {
 1293                                lock (resultLock) failedShipments.Add("Заказ без номера отправления");
 1294                                return;
 1295                            }
 1296
 1297                            var fileName = $"{shipmentNumber}.pdf";
 1298                            if (!force && await _labelFileStorage.ExistsAsync(fileName))
 1299                            {
 1300                                var storedBytes = await _labelFileStorage.TryDownloadAsync(fileName);
 1301                                if (storedBytes is not { Length: > 0 })
 1302                                    throw new Exception("Пустой файл этикетки в хранилище");
 1303
 1304                                lock (resultLock)
 1305                                {
 1306                                    labels[shipmentNumber] = storedBytes;
 1307                                    labelsFromDropbox.Add(shipmentNumber);
 1308                                }
 1309
 1310                                return;
 1311                            }
 1312
 1313                            if (!long.TryParse(shipmentNumber, out var yandexOrderId))
 1314                            {
 1315                                lock (resultLock) failedShipments.Add($"{shipmentNumber}: некорректный номер заказа");
 1316                                return;
 1317                            }
 1318
 1319                            var campaignId = await _yandexDataManager.GetCampaignIdForOrderAsync(
 1320                                client.DecryptClientId, yandexOrderId);
 1321                            if (!campaignId.HasValue)
 1322                            {
 1323                                lock (resultLock) failedShipments.Add($"{shipmentNumber}: не удалось получить campaignId
 1324                                return;
 1325                            }
 1326
 1327                            var labelBytes = await _yandexDataManager.GetOrderLabelPdfAsync(
 1328                                campaignId.Value, yandexOrderId, f);
 1329                            if (labelBytes is not { Length: > 0 })
 1330                            {
 1331                                lock (resultLock) failedShipments.Add($"{shipmentNumber}: пустой ответ API этикеток");
 1332                                return;
 1333                            }
 1334
 1335                            await _labelFileStorage.UploadAsync(labelBytes, fileName, overwrite: true);
 1336                            lock (resultLock)
 1337                            {
 1338                                labels[shipmentNumber] = labelBytes;
 1339                                labelsGenerated.Add(shipmentNumber);
 1340                                storedLabelLogs.Add(CreateStudioLabelLog(fileName, order.Id));
 1341                            }
 1342                        }
 1343                        catch (Exception ex)
 1344                        {
 1345                            lock (resultLock) failedShipments.Add($"{order.ShipmentNumber}: {ex.Message}");
 1346                        }
 1347                        finally
 1348                        {
 1349                            semaphore.Release();
 1350                        }
 1351                    }));
 1352                }
 1353
 1354
 1355                if (labels.Count == 0)
 1356
 1357                {
 1358                    return Json(new
 1359
 1360                    {
 1361                        success = false,
 1362
 1363                        message = "Не удалось получить или сгенерировать ни одной этикетки Яндекс",
 1364
 1365                        failedShipments = failedShipments
 1366                    });
 1367                }
 1368
 1369                if (storedLabelLogs.Count > 0)
 1370                {
 1371                    await _labelPrintLogService.RecordAsync(storedLabelLogs);
 1372                }
 1373
 1374
 1375                var sessionKey = $"labels_{Guid.NewGuid()}";
 1376
 1377                HttpContext.Session.SetString(sessionKey, JsonConvert.SerializeObject(labels.Keys.ToList()));
 1378
 1379                foreach (var label in labels)
 1380
 1381                {
 1382                    var fileKey = $"{sessionKey}_{label.Key}";
 1383
 1384                    HttpContext.Session.Set(fileKey, label.Value);
 1385                }
 1386
 1387
 1388                var message = $"Обработано {labels.Count} этикеток Яндекс";
 1389
 1390                if (labelsFromDropbox.Count > 0 && labelsGenerated.Count > 0)
 1391
 1392                    message += $" ({labelsFromDropbox.Count} из системы, {labelsGenerated.Count} сгенерировано)";
 1393
 1394                else if (labelsFromDropbox.Count > 0)
 1395
 1396                    message += " (все из системы)";
 1397
 1398                else if (labelsGenerated.Count > 0)
 1399
 1400                    message += " (все сгенерированы)";
 1401
 1402
 1403                foreach (var shipmentNumber in labels.Keys)
 1404
 1405                    UpdateLabelCache(shipmentNumber, true);
 1406
 1407
 1408                return Json(new
 1409
 1410                {
 1411                    success = true,
 1412
 1413                    message = message,
 1414
 1415                    sessionKey = sessionKey,
 1416
 1417                    generatedLabels = labels.Keys.ToList(),
 1418
 1419                    failedShipments = failedShipments,
 1420
 1421                    totalCount = labels.Count,
 1422
 1423                    fromDropbox = labelsFromDropbox,
 1424
 1425                    newlyGenerated = labelsGenerated
 1426                });
 1427            }
 1428
 1429            catch (Exception ex)
 1430            {
 1431                return Json(new { success = false, message = $"Ошибка при генерации этикеток Яндекс: {ex.Message}" });
 1432            }
 1433        }
 1434
 1435        private LabelPrintLogEntry CreateStudioLabelLog(string fileName, int orderId) =>
 1436            new(
 1437                fileName,
 1438                orderId,
 1439                LabelPrintSource.Studio2,
 1440                LabelPrintActorKind.StudioUser,
 1441                User.FindFirstValue(ClaimTypes.NameIdentifier),
 1442                User.Identity?.Name ?? "Неизвестный пользователь");
 1443
 1444
 1445        [ServiceEndpoint]
 1446        [HttpGet]
 1447        public async Task<IActionResult> DownloadLabels(string sessionKey)
 1448
 1449        {
 1450            try
 1451
 1452            {
 1453                var labelKeysJson = HttpContext.Session.GetString(sessionKey);
 1454
 1455                if (string.IsNullOrEmpty(labelKeysJson))
 1456
 1457                {
 1458                    return NotFound("Файлы этикеток не найдены или срок их хранения истек");
 1459                }
 1460
 1461
 1462                var labelKeys = JsonConvert.DeserializeObject<List<string>>(labelKeysJson);
 1463                if (labelKeys == null || labelKeys.Count == 0)
 1464                {
 1465                    return NotFound("Файлы этикеток не найдены или срок их хранения истек");
 1466                }
 1467
 1468                Dictionary<string, byte[]> labels = new Dictionary<string, byte[]>();
 1469
 1470
 1471                foreach (var key in labelKeys)
 1472
 1473                {
 1474                    await Task.CompletedTask;
 1475                    var fileKey = $"{sessionKey}_{key}";
 1476
 1477                    var bytes = HttpContext.Session.Get(fileKey);
 1478
 1479                    if (bytes != null)
 1480
 1481                    {
 1482                        labels.Add(key, bytes);
 1483                    }
 1484                }
 1485
 1486
 1487                if (labels.Count == 0)
 1488
 1489                {
 1490                    return NotFound("Файлы этикеток не найдены");
 1491                }
 1492
 1493
 1494                // Очищаем сессию после использования
 1495
 1496                HttpContext.Session.Remove(sessionKey);
 1497
 1498                foreach (var key in labelKeys)
 1499
 1500                {
 1501                    var fileKey = $"{sessionKey}_{key}";
 1502
 1503                    HttpContext.Session.Remove(fileKey);
 1504                }
 1505
 1506
 1507                // Если только один файл - возвращаем его напрямую
 1508
 1509                if (labels.Count == 1)
 1510
 1511                {
 1512                    var label = labels.First();
 1513
 1514                    Response.Headers.Append("Content-Disposition", $"inline; filename=\"{label.Key}.pdf\"");
 1515                    return File(label.Value, "application/pdf");
 1516                }
 1517
 1518
 1519                // Несколько этикеток возвращаем единым многостраничным PDF.
 1520                await using var mergedStream = new MemoryStream();
 1521                using var mergedPdf = new PdfDocument(new PdfWriter(mergedStream));
 1522                foreach (var label in labels)
 1523                {
 1524                    await using var sourceStream = new MemoryStream(label.Value);
 1525                    using var sourcePdf = new PdfDocument(new PdfReader(sourceStream));
 1526                    sourcePdf.CopyPagesTo(1, sourcePdf.GetNumberOfPages(), mergedPdf);
 1527                }
 1528
 1529                mergedPdf.Close();
 1530                Response.Headers.Append("Content-Disposition",
 1531                    $"inline; filename=\"labels_{DateTime.Now:yyyyMMdd_HHmmss}.pdf\"");
 1532                return File(mergedStream.ToArray(), "application/pdf");
 1533            }
 1534
 1535            catch (Exception ex)
 1536            {
 1537                return StatusCode(500, $"Ошибка при скачивании этикеток: {ex.Message}");
 1538            }
 1539        }
 1540
 1541
 1542        [ServiceEndpoint]
 1543        [HttpPost]
 1544        public IActionResult ClearLabelsSession(string sessionKey)
 1545        {
 1546            _ozonLabelSessionStore.ClearSession(HttpContext.Session, sessionKey);
 1547            return Ok(new { success = true });
 1548        }
 1549
 1550
 1551        private IActionResult? SendFilesToUser(Dictionary<string, byte[]> files)
 1552
 1553        {
 1554            List<FileContentResult> fileResults = new List<FileContentResult>();
 1555
 1556
 1557            foreach (var file in files)
 1558
 1559            {
 1560                var fileResult = new FileContentResult(file.Value, "application/pdf")
 1561
 1562                {
 1563                    FileDownloadName = $"{file.Key}.pdf"
 1564                };
 1565
 1566
 1567                fileResults.Add(fileResult);
 1568            }
 1569
 1570
 1571            if (fileResults.Count == 0)
 1572
 1573            {
 1574                return null;
 1575            }
 1576
 1577
 1578            // Если только один файл — отдаем его напрямую, без ZIP
 1579
 1580            if (fileResults.Count == 1)
 1581
 1582            {
 1583                var single = fileResults[0];
 1584
 1585                return File(single.FileContents, single.ContentType, single.FileDownloadName);
 1586            }
 1587
 1588
 1589            string tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
 1590
 1591            Directory.CreateDirectory(tempDirectory);
 1592
 1593
 1594            List<string> tempFiles = new List<string>();
 1595
 1596            foreach (var fileResult in fileResults)
 1597
 1598            {
 1599                string tempFilePath = Path.Combine(tempDirectory, fileResult.FileDownloadName);
 1600
 1601                System.IO.File.WriteAllBytes(tempFilePath, fileResult.FileContents);
 1602
 1603                tempFiles.Add(tempFilePath);
 1604            }
 1605
 1606
 1607            string zipFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".zip");
 1608
 1609            ZipFile.CreateFromDirectory(tempDirectory, zipFilePath);
 1610
 1611
 1612            Directory.Delete(tempDirectory, true);
 1613
 1614
 1615            return PhysicalFile(zipFilePath, "application/zip", $"labels_{DateTime.Now:dd.MM.yyyy}.zip");
 1616        }
 1617}
 1618
 1619public sealed class OzonProductCountrySelection
 1620{
 01621    public int OrderId { get; set; }
 01622    public long ProductId { get; set; }
 01623    public string CountryIsoCode { get; set; } = string.Empty;
 1624}
 1625
 1626/// <summary>Данные экземпляров одного заказа, введённые в форме перед печатью этикетки.</summary>
 1627public sealed class OzonExemplarSubmission
 1628{
 1629    public int OrderId { get; set; }
 1630
 1631    public List<OzonExemplarInput> Exemplars { get; set; } = new();
 1632}