< Summary

Information
Class: Services.ApiServices.OzonApi.PackageLabelPollResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/PackageLabelPollingHelper.cs
Line coverage
100%
Covered lines: 5
Uncovered lines: 0
Coverable lines: 5
Total lines: 165
Line coverage: 100%
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_IsSuccess()100%11100%
get_FileUrl()100%11100%
get_Error()100%11100%
Completed(...)100%11100%
Failed(...)100%11100%

File(s)

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

#LineLine coverage
 1using Services.ApiServices.OzonApi.Filters;
 2
 3namespace Services.ApiServices.OzonApi;
 4
 5public sealed class PackageLabelPollResult
 6{
 87    public bool IsSuccess { get; init; }
 38    public string? FileUrl { get; init; }
 29    public string? Error { get; init; }
 10
 11    public static PackageLabelPollResult Completed(string fileUrl) =>
 312        new() { IsSuccess = true, FileUrl = fileUrl };
 13
 14    public static PackageLabelPollResult Failed(string error) =>
 115        new() { IsSuccess = false, Error = error };
 16}
 17
 18/// <summary>
 19/// Общая логика опроса заданий на этикетки Ozon (package-label/get).
 20/// </summary>
 21public static class PackageLabelPollingHelper
 22{
 23    public const int DefaultMaxAttempts = 20;
 24
 25    private static readonly int[] BackoffDelaysMs = [300, 500, 1000, 2000];
 26
 27    public static async Task<byte[]> PollSingleTaskForBytesAsync(
 28        Func<long, Task<PackageLabelGetResponse>> getFile,
 29        Func<string, Task<byte[]>> downloadFile,
 30        long taskId,
 31        IReadOnlyCollection<string> postingNumbers,
 32        int maxAttempts = DefaultMaxAttempts,
 33        CancellationToken cancellationToken = default)
 34    {
 35        var poll = await PollSingleTaskForFileUrlAsync(getFile, taskId, postingNumbers, maxAttempts, cancellationToken)
 36            .ConfigureAwait(false);
 37        if (!poll.IsSuccess || string.IsNullOrEmpty(poll.FileUrl))
 38            throw new Exception(poll.Error ?? "Не удалось получить этикетку");
 39
 40        var bytes = await downloadFile(poll.FileUrl).ConfigureAwait(false);
 41        if (bytes is not { Length: > 0 })
 42            throw new Exception("Этикетка не была напечатана (пустой файл)");
 43
 44        return bytes;
 45    }
 46
 47    public static async Task<PackageLabelPollResult> PollSingleTaskForFileUrlAsync(
 48        Func<long, Task<PackageLabelGetResponse>> getFile,
 49        long taskId,
 50        IReadOnlyCollection<string> postingNumbers,
 51        int maxAttempts = DefaultMaxAttempts,
 52        CancellationToken cancellationToken = default)
 53    {
 54        var postingSet = new HashSet<string>(postingNumbers, StringComparer.Ordinal);
 55        PackageLabelGetResponse? lastResponse = null;
 56
 57        for (var attempt = 0; attempt < maxAttempts; attempt++)
 58        {
 59            if (attempt > 0)
 60            {
 61                var delayMs = BackoffDelaysMs[Math.Min(attempt - 1, BackoffDelaysMs.Length - 1)];
 62                await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false);
 63            }
 64
 65            lastResponse = await getFile(taskId).ConfigureAwait(false);
 66            var outcome = EvaluateResponse(lastResponse, postingSet);
 67            if (outcome != null)
 68                return outcome;
 69        }
 70
 71        var status = lastResponse?.Status?.Code ?? "unknown";
 72        return PackageLabelPollResult.Failed($"Не удалось получить этикетку: время ожидания истекло. Статус: {status}");
 73    }
 74
 75    /// <summary>
 76    /// Параллельно опрашивает все незавершённые taskId на каждой итерации (общий backoff).
 77    /// </summary>
 78    public static async Task<Dictionary<long, PackageLabelPollResult>> PollTasksParallelForFileUrlsAsync(
 79        Func<long, Task<PackageLabelGetResponse>> getFile,
 80        IReadOnlyDictionary<long, string[]> taskIdToPostings,
 81        int maxAttempts = DefaultMaxAttempts,
 82        CancellationToken cancellationToken = default)
 83    {
 84        var results = new Dictionary<long, PackageLabelPollResult>();
 85        if (taskIdToPostings.Count == 0)
 86            return results;
 87
 88        var pending = taskIdToPostings.Keys.ToHashSet();
 89
 90        for (var attempt = 0; attempt < maxAttempts && pending.Count > 0; attempt++)
 91        {
 92            if (attempt > 0)
 93            {
 94                var delayMs = BackoffDelaysMs[Math.Min(attempt - 1, BackoffDelaysMs.Length - 1)];
 95                await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false);
 96            }
 97
 98            var pollTasks = pending.Select(async taskId =>
 99            {
 100                var response = await getFile(taskId).ConfigureAwait(false);
 101                return (taskId, response);
 102            });
 103
 104            var pollResponses = await Task.WhenAll(pollTasks).ConfigureAwait(false);
 105
 106            foreach (var (taskId, response) in pollResponses)
 107            {
 108                var postings = taskIdToPostings[taskId];
 109                var postingSet = new HashSet<string>(postings, StringComparer.Ordinal);
 110                var outcome = EvaluateResponse(response, postingSet);
 111                if (outcome == null)
 112                    continue;
 113
 114                results[taskId] = outcome;
 115                pending.Remove(taskId);
 116            }
 117        }
 118
 119        foreach (var taskId in pending)
 120        {
 121            results[taskId] = PackageLabelPollResult.Failed("Не удалось получить этикетку: время ожидания истекло");
 122        }
 123
 124        return results;
 125    }
 126
 127    private static PackageLabelPollResult? EvaluateResponse(
 128        PackageLabelGetResponse? response,
 129        HashSet<string> postingSet)
 130    {
 131        if (response == null)
 132            return null;
 133
 134        var status = response.Status;
 135
 136        if (status?.UnprintedPostings is { Count: > 0 })
 137        {
 138            var unprinted = status.UnprintedPostings
 139                .FirstOrDefault(p => !string.IsNullOrEmpty(p.PostingNumber) &&
 140                                     postingSet.Contains(p.PostingNumber));
 141            if (unprinted != null)
 142            {
 143                return PackageLabelPollResult.Failed(
 144                    $"Ошибка формирования этикетки для {unprinted.PostingNumber}: {unprinted.Message ?? "Неизвестная оши
 145            }
 146        }
 147
 148        if (status?.Code == "completed" && !string.IsNullOrEmpty(response.FileUrl))
 149        {
 150            if (status.PrintedPostingsCount > 0)
 151                return PackageLabelPollResult.Completed(response.FileUrl);
 152
 153            return PackageLabelPollResult.Failed(
 154                $"Ошибка формирования этикетки: {response.Error?.Message ?? "Этикетка не была напечатана"}");
 155        }
 156
 157        if (status?.Code == "error" || !string.IsNullOrEmpty(response.Error?.Message))
 158        {
 159            return PackageLabelPollResult.Failed(
 160                $"Ошибка формирования этикетки: {response.Error?.Message ?? "Неизвестная ошибка"}");
 161        }
 162
 163        return null;
 164    }
 165}