< Summary

Information
Class: Services.LabelServices.OzonLabelPrintService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/OzonLabelPrintService.cs
Line coverage
13%
Covered lines: 28
Uncovered lines: 174
Coverable lines: 202
Total lines: 467
Line coverage: 13.8%
Branch coverage
13%
Covered branches: 15
Total branches: 108
Branch coverage: 13.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/OzonLabelPrintService.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using OzonDomains.Models;
 3using Services.ApiServices.OzonApi;
 4
 5namespace Services.LabelServices;
 6
 7public class OzonLabelPrintService : IOzonLabelPrintService
 8{
 9    /// <summary>
 10    /// Сколько отправлений обрабатывается параллельно. Ozon держит такой параллелизм без 429:
 11    /// 10 одиночных синхронных запросов этикеток укладываются в те же ~0.6 c, что и один.
 12    /// </summary>
 13    private const int LabelParallelism = 8;
 14
 15    private const int StatusPollMaxAttempts = 6;
 016    private static readonly TimeSpan StatusPollDelay = TimeSpan.FromMilliseconds(400);
 17
 18    private readonly OzonJsonDataBuilder _jsonDataBuilder;
 19    private readonly ILabelFileStorage _labelFileStorage;
 20    private readonly IOrdersOzonShipService _shipService;
 21    private readonly IOrderScanitStore _scanitStore;
 22
 323    public OzonLabelPrintService(
 324        OzonJsonDataBuilder jsonDataBuilder,
 325        ILabelFileStorage labelFileStorage,
 326        IOrdersOzonShipService shipService,
 327        IOrderScanitStore scanitStore)
 28    {
 329        _jsonDataBuilder = jsonDataBuilder;
 330        _labelFileStorage = labelFileStorage;
 331        _shipService = shipService;
 332        _scanitStore = scanitStore;
 333    }
 34
 35    public async Task<int> EnsureScanitAsync(IReadOnlyCollection<Order> orders, List<OzonClient> clients)
 36    {
 037        var scanitByPosting = new Dictionary<string, string>(StringComparer.Ordinal);
 38
 039        var missing = orders
 040            .Where(o => string.IsNullOrWhiteSpace(o.Scanit) && !string.IsNullOrWhiteSpace(o.ShipmentNumber))
 041            .GroupBy(o => o.OzonClient?.ClientId);
 42
 043        foreach (var clientGroup in missing)
 44        {
 045            var client = clients.FirstOrDefault(c => c.ClientId == clientGroup.Key);
 046            if (client == null)
 47            {
 48                continue;
 49            }
 50
 051            _jsonDataBuilder.SetClient(client.DecryptClientId ?? string.Empty, client.DecryptApiKey ?? string.Empty);
 52
 053            var postings = clientGroup
 054                .Select(o => OzonPostingNumberHelper.GetOzonApiPostingNumber(o.ShipmentNumber))
 055                .Distinct(StringComparer.Ordinal)
 056                .ToList();
 57
 058            using var semaphore = new SemaphoreSlim(LabelParallelism);
 059            var fetched = await Task.WhenAll(postings.Select(async posting =>
 060            {
 061                await semaphore.WaitAsync().ConfigureAwait(false);
 062                try
 063                {
 064                    return (Posting: posting, Scanit: await _jsonDataBuilder.GetPostingScanit(posting).ConfigureAwait(fa
 065                }
 066                catch
 067                {
 068                    // ScanIt не критичен для печати: не получили сейчас — получим при следующей печати
 069                    // или загрузке заказа.
 070                    return (Posting: posting, Scanit: (string?)null);
 071                }
 072                finally
 073                {
 074                    semaphore.Release();
 075                }
 076            })).ConfigureAwait(false);
 77
 078            foreach (var (posting, scanit) in fetched)
 79            {
 080                if (!string.IsNullOrEmpty(scanit))
 81                {
 082                    scanitByPosting[posting] = scanit;
 83                }
 84            }
 085        }
 86
 87        // Одна транзакция на всё: либо ScanIt записан у всех заказов, либо ни у одного.
 088        return await _scanitStore.SaveAsync(scanitByPosting).ConfigureAwait(false);
 089    }
 90
 91    public Task<string> ProcessStudioLocalSplitGroupShipAsync(
 92        IReadOnlyList<Order> siblings,
 93        string apiPosting,
 94        OzonOrderStatusProcessor processOrderStatus)
 95    {
 296        var first = siblings[0];
 297        return processOrderStatus(
 298            first,
 299            splitOption: null,
 2100            ozonPostingOverride: apiPosting,
 2101            forcedPackageCount: siblings.Count);
 102    }
 103
 104    /// <summary>
 105    /// Генерация этикеток Ozon. Пайплайн на группу клиента: батч-запрос статусов → доотгрузка
 106    /// заказов, которые ещё в awaiting_packaging → параллельное получение PDF по одному
 107    /// синхронному запросу на posting → параллельная выгрузка в хранилище.
 108    /// Studio-части одного отправления (posting/1, posting/2) делят один PDF: у Ozon этикетка
 109    /// выдаётся на родительский posting.
 110    /// </summary>
 111    public async Task GenerateOzonLabelsForOrdersAsync(
 112        List<Order> ordersToGenerate,
 113        List<OzonClient> clients,
 114        Dictionary<string, byte[]> labels,
 115        List<string> labelsGenerated,
 116        List<string> failedShipments,
 117        List<string>? autoShippedShipments = null,
 118        List<string>? labelsStored = null)
 119    {
 1120        autoShippedShipments ??= new List<string>();
 1121        labelsStored ??= new List<string>();
 122
 5123        foreach (var clientGroup in ordersToGenerate.GroupBy(o => o.OzonClient?.ClientId))
 124        {
 1125            var client = clients.FirstOrDefault(c => c.ClientId == clientGroup.Key);
 1126            if (client == null)
 127            {
 4128                foreach (var order in clientGroup)
 129                {
 1130                    failedShipments.Add($"{order.ShipmentNumber}: Ozon клиент не найден");
 131                }
 132
 133                continue;
 134            }
 135
 0136            _jsonDataBuilder.SetClient(client.DecryptClientId ?? string.Empty, client.DecryptApiKey ?? string.Empty);
 137            // _shipService держит собственный (DI, transient) OzonJsonDataBuilder — SetClient
 138            // выше на него не влияет, клиента для отгрузки нужно выставлять отдельно здесь же.
 0139            _shipService.SetClient(client.DecryptClientId ?? string.Empty, client.DecryptApiKey ?? string.Empty);
 140
 0141            var orders = clientGroup.ToList();
 0142            var withoutShipmentNumber = orders.Count(o => string.IsNullOrWhiteSpace(o.ShipmentNumber));
 0143            for (var i = 0; i < withoutShipmentNumber; i++)
 144            {
 0145                failedShipments.Add("Заказ без номера отправления");
 146            }
 147
 148            // Studio-части одного отправления идут одной единицей работы: Ozon отдаёт этикетку
 149            // только по родительскому posting, и запрашивать её на каждую часть отдельно бессмысленно.
 0150            var postingGroups = orders
 0151                .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber))
 0152                .GroupBy(o => OzonPostingNumberHelper.GetOzonApiPostingNumber(o.ShipmentNumber), StringComparer.Ordinal)
 0153                .ToList();
 154
 0155            if (postingGroups.Count == 0)
 156            {
 157                continue;
 158            }
 159
 0160            var statuses = await ResolveStatusesAsync(postingGroups.Select(g => g.Key).ToList());
 0161            var printable = await ShipPendingPostingsAsync(postingGroups, statuses, failedShipments, autoShippedShipment
 162
 0163            await GenerateLabelsInParallelAsync(printable, labels, labelsGenerated, failedShipments, labelsStored);
 0164        }
 1165    }
 166
 167    /// <summary>
 168    /// Статусы всех отправлений группы одним батчем (внутри — параллельные запросы к Ozon).
 169    /// Раньше статус запрашивался по одному в цикле, последовательно.
 170    /// </summary>
 171    private async Task<Dictionary<string, string>> ResolveStatusesAsync(IReadOnlyCollection<string> postings)
 172    {
 0173        var batch = await _jsonDataBuilder.GetProductStatusBatch(postings, LabelParallelism);
 0174        return batch.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Status, StringComparer.Ordinal);
 0175    }
 176
 177    /// <summary>
 178    /// Отсекает отправления, для которых печать невозможна, и доотгружает те, что ещё в
 179    /// awaiting_packaging. Отгрузка мутирует состояние на Ozon, поэтому идёт последовательно —
 180    /// зато только для тех отправлений, которым это реально нужно.
 181    /// </summary>
 182    private async Task<List<PostingPrintUnit>> ShipPendingPostingsAsync(
 183        IEnumerable<IGrouping<string, Order>> postingGroups,
 184        IReadOnlyDictionary<string, string> statuses,
 185        List<string> failedShipments,
 186        List<string> autoShippedShipments)
 187    {
 0188        var printable = new List<PostingPrintUnit>();
 189
 0190        foreach (var group in postingGroups)
 191        {
 0192            var apiPosting = group.Key;
 0193            var groupOrders = group.ToList();
 0194            var status = statuses.TryGetValue(apiPosting, out var s) ? s : string.Empty;
 195
 0196            if (status == "awaiting_packaging")
 197            {
 0198                status = await TryAutoShipAndRecheckStatusAsync(groupOrders[0], apiPosting);
 0199                if (status != "awaiting_packaging")
 200                {
 0201                    foreach (var order in groupOrders)
 202                    {
 0203                        autoShippedShipments.Add(order.ShipmentNumber!);
 204                    }
 205                }
 206            }
 207
 0208            if (status == "awaiting_packaging")
 209            {
 0210                foreach (var order in groupOrders)
 211                {
 0212                    failedShipments.Add(
 0213                        $"{order.ShipmentNumber}: заказ ещё не отгружен на Ozon (статус «Ожидает упаковки»), " +
 0214                        "автоматическая отгрузка перед печатью не удалась. Проверьте заказ на Ozon и повторите печать.")
 215                }
 216
 217                continue;
 218            }
 219
 0220            if (OzonLabelStatusHelper.IsOzonStatusLabelGenerationBlocked(status))
 221            {
 0222                var statusName = OzonLabelStatusHelper.GetOzonStatusDisplayName(status);
 0223                foreach (var order in groupOrders)
 224                {
 0225                    failedShipments.Add($"{order.ShipmentNumber}: печать недоступна (статус «{statusName}»).");
 226                }
 227
 228                continue;
 229            }
 230
 0231            printable.Add(new PostingPrintUnit(apiPosting, groupOrders, status));
 0232        }
 233
 0234        return printable;
 0235    }
 236
 237    /// <summary>
 238    /// Параллельно получает PDF для каждого отправления и сразу выгружает его в хранилище.
 239    /// Общие коллекции результата заполняются после — они не потокобезопасны.
 240    /// </summary>
 241    private async Task GenerateLabelsInParallelAsync(
 242        IReadOnlyList<PostingPrintUnit> units,
 243        Dictionary<string, byte[]> labels,
 244        List<string> labelsGenerated,
 245        List<string> failedShipments,
 246        List<string> labelsStored)
 247    {
 0248        if (units.Count == 0)
 249        {
 0250            return;
 251        }
 252
 0253        var results = new ConcurrentDictionary<string, (byte[]? Bytes, string? Error)>(StringComparer.Ordinal);
 0254        using var semaphore = new SemaphoreSlim(LabelParallelism);
 255
 0256        await Task.WhenAll(units.Select(async unit =>
 0257        {
 0258            await semaphore.WaitAsync().ConfigureAwait(false);
 0259            try
 0260            {
 0261                var bytes = await GetLabelBytesAsync(unit).ConfigureAwait(false);
 0262                results[unit.ApiPosting] = (bytes, null);
 0263            }
 0264            catch (Exception ex)
 0265            {
 0266                results[unit.ApiPosting] = (null, ex.Message);
 0267            }
 0268            finally
 0269            {
 0270                semaphore.Release();
 0271            }
 0272        })).ConfigureAwait(false);
 273
 0274        var uploads = new List<(string ShipmentNumber, Task<string?> Upload)>();
 275
 0276        foreach (var unit in units)
 277        {
 0278            if (!results.TryGetValue(unit.ApiPosting, out var result))
 279            {
 280                continue;
 281            }
 282
 0283            if (result.Bytes is not { Length: > 0 })
 284            {
 0285                foreach (var order in unit.Orders)
 286                {
 0287                    failedShipments.Add(DescribeLabelFailure(unit, order.ShipmentNumber!, result.Error));
 288                }
 289
 290                continue;
 291            }
 292
 0293            foreach (var order in unit.Orders)
 294            {
 0295                var shipmentNumber = order.ShipmentNumber!;
 0296                labels[shipmentNumber] = result.Bytes;
 0297                labelsGenerated.Add(shipmentNumber);
 0298                uploads.Add((shipmentNumber, SaveLabelToStorageAsync(result.Bytes, shipmentNumber)));
 299            }
 300        }
 301
 0302        foreach (var (shipmentNumber, uploadError) in await Task.WhenAll(uploads.Select(async upload =>
 0303                     (upload.ShipmentNumber, await upload.Upload.ConfigureAwait(false)))).ConfigureAwait(false))
 304        {
 0305            if (uploadError != null)
 306            {
 0307                failedShipments.Add(uploadError);
 308            }
 309            else
 310            {
 0311                labelsStored.Add(shipmentNumber);
 312            }
 313        }
 0314    }
 315
 316    /// <summary>
 317    /// Получение PDF одного отправления: задание /v3/posting/fbs/package-label/create → опрос
 318    /// /v2/posting/fbs/package-label/get → скачивание file_url (внутри OzonJsonDataBuilder.GetOrderLabel).
 319    /// Синхронного метода у Ozon больше нет (отключён 2 ноября 2026).
 320    /// </summary>
 321    private async Task<byte[]> GetLabelBytesAsync(PostingPrintUnit unit)
 322    {
 0323        Exception? lastEx = null;
 324
 0325        for (var attempt = 0; attempt < LabelAttempts; attempt++)
 326        {
 0327            if (attempt > 0)
 328            {
 0329                await Task.Delay(LabelRetryDelay).ConfigureAwait(false);
 330            }
 331
 332            try
 333            {
 0334                var bytes = await TryGetOrderLabelWithCandidatesAsync(unit.Orders[0].ShipmentNumber!)
 0335                    .ConfigureAwait(false);
 0336                if (bytes is { Length: > 0 })
 337                {
 0338                    return bytes;
 339                }
 340
 0341                lastEx = new Exception("этикетка не сформирована (пустой ответ)");
 0342            }
 343            catch (Exception ex)
 344            {
 0345                lastEx = ex;
 0346                if (!IsTransientLabelError(ex.Message))
 347                {
 0348                    break;
 349                }
 0350            }
 351        }
 352
 0353        throw lastEx ?? new Exception("не удалось получить этикетку");
 0354    }
 355
 356    private const int LabelAttempts = 2;
 0357    private static readonly TimeSpan LabelRetryDelay = TimeSpan.FromMilliseconds(500);
 358
 359    /// <summary>
 360    /// Опрашивает статус отправления, пока он остаётся "awaiting_packaging" (отгрузка ещё не
 361    /// применилась на стороне Ozon) — до <see cref="StatusPollMaxAttempts"/> попыток с паузой
 362    /// <see cref="StatusPollDelay"/>. Как только статус меняется (или лимит исчерпан), возвращает
 363    /// последний известный статус.
 364    /// </summary>
 365    private async Task<string> WaitForPrintableStatusAsync(string apiPosting, string initialStatus)
 366    {
 0367        var status = initialStatus;
 0368        var attempt = 0;
 0369        while (status == "awaiting_packaging" && attempt < StatusPollMaxAttempts)
 370        {
 0371            await Task.Delay(StatusPollDelay).ConfigureAwait(false);
 0372            status = await _jsonDataBuilder.GetProductSatatus(apiPosting).ConfigureAwait(false);
 0373            attempt++;
 374        }
 375
 0376        return status;
 0377    }
 378
 379    /// <summary>
 380    /// Заказ ещё в статусе "awaiting_packaging" перед печатью — вместо немедленного отказа
 381    /// отгружаем его сейчас же (тот же путь, что и отдельный шаг UpdateOrdersStatus) и
 382    /// перепроверяем статус. Отгрузка может не выполниться штатно (например не хватает данных о
 383    /// товаре в Studio) — тогда возвращаем исходный статус, честная ошибка формируется выше.
 384    /// </summary>
 385    private async Task<string> TryAutoShipAndRecheckStatusAsync(Order order, string apiPosting)
 386    {
 387        try
 388        {
 0389            await _shipService.ProcessOrderStatusAsync(order, splitOption: null, ozonPostingOverride: apiPosting)
 0390                .ConfigureAwait(false);
 0391        }
 0392        catch
 393        {
 0394            return "awaiting_packaging";
 395        }
 396
 0397        return await WaitForPrintableStatusAsync(apiPosting, "awaiting_packaging").ConfigureAwait(false);
 0398    }
 399
 400    private async Task<byte[]> TryGetOrderLabelWithCandidatesAsync(string shipmentNumber)
 401    {
 0402        Exception? lastEx = null;
 0403        foreach (var candidate in OzonPostingNumberHelper.GetLabelPostingNumberCandidates(shipmentNumber)
 0404                     .Distinct(StringComparer.Ordinal))
 405        {
 406            try
 407            {
 0408                var bytes = await _jsonDataBuilder.GetOrderLabel(candidate).ConfigureAwait(false);
 0409                if (bytes is { Length: > 0 })
 410                {
 0411                    return bytes;
 412                }
 0413            }
 0414            catch (Exception ex)
 415            {
 0416                lastEx = ex;
 0417            }
 418        }
 419
 0420        throw lastEx ?? new Exception($"Не удалось получить этикетку для {shipmentNumber}");
 0421    }
 422
 423    /// <summary>Имя HttpClient для скачивания готового файла этикетки по file_url.</summary>
 424    public const string LabelDownloadHttpClientName = "OzonLabelDownload";
 425
 426    /// <summary>Возвращает текст ошибки выгрузки или null, если этикетка сохранена.</summary>
 427    private async Task<string?> SaveLabelToStorageAsync(byte[] labelBytes, string shipmentNumber)
 428    {
 429        try
 430        {
 0431            await _labelFileStorage.UploadAsync(labelBytes, $"{shipmentNumber}.pdf", overwrite: true)
 0432                .ConfigureAwait(false);
 0433            return null;
 434        }
 0435        catch (Exception uploadEx)
 436        {
 0437            return $"{shipmentNumber}: Ошибка сохранения этикетки - {uploadEx.Message}";
 438        }
 0439    }
 440
 441    internal static bool IsTransientLabelError(string msg) =>
 4442        msg.Contains("Unknown posting number", StringComparison.OrdinalIgnoreCase) ||
 4443        msg.Contains("NO_POSTINGS_FOR_BATCH_DOWNLOAD", StringComparison.OrdinalIgnoreCase) ||
 4444        msg.Contains("missing a required role", StringComparison.OrdinalIgnoreCase) ||
 4445        msg.Contains("aren't ready", StringComparison.OrdinalIgnoreCase);
 446
 447    /// <summary>
 448    /// Отправление, уже переданное в доставку, Ozon отказывается отдавать обоими способами
 449    /// (INVALID_ARGUMENT, NO_POSTINGS_FOR_BATCH_DOWNLOAD). Отдавать
 450    /// пользователю сырой код в этом случае бессмысленно — причина в статусе, а не в сбое.
 451    /// </summary>
 452    private static string DescribeLabelFailure(PostingPrintUnit unit, string shipmentNumber, string? rawError)
 453    {
 0454        if (OzonLabelStatusHelper.IsOzonStatusPastShipping(unit.Status))
 455        {
 0456            var statusName = OzonLabelStatusHelper.GetOzonStatusDisplayName(unit.Status);
 0457            return $"{shipmentNumber}: отправление уже передано в доставку (статус «{statusName}») — " +
 0458                   "Ozon больше не выдаёт для него этикетку. Доступна только ранее сохранённая копия.";
 459        }
 460
 0461        return OzonLabelPrintErrorHelper.FormatShipmentError(
 0462            shipmentNumber, rawError ?? "этикетка не сформирована (пустой ответ)");
 463    }
 464
 465    /// <summary>Одно отправление Ozon, его статус и все Studio-части, которые делят его этикетку.</summary>
 0466    private sealed record PostingPrintUnit(string ApiPosting, List<Order> Orders, string Status);
 467}