< Summary

Information
Class: Services.BitrixStockServices.BitrixStockUnarchiveJobService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockUnarchiveJobService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 193
Coverable lines: 193
Total lines: 338
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 90
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
CreateExecutionAsync()100%210%
ProcessUnarchiveAsync()0%3906620%
SendLog()0%620%
SendProgress()0%2040%
SendComplete()0%2040%
SendError()0%620%
GetExecutionsPageAsync(...)100%210%
GetExecutionDetailsAsync(...)100%210%
FormatUnarchiveError(...)0%620%
BuildReportHtml(...)100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockUnarchiveJobService.cs

#LineLine coverage
 1using System.Text;
 2using Hangfire;
 3using Hangfire.Storage;
 4using Microsoft.AspNetCore.SignalR;
 5using Microsoft.Extensions.Http;
 6using OzonDomains;
 7using OzonDomains.Models;
 8using OzonRepositories.Data;
 9using Services.ApiServices.OzonApi;
 10using Services.DataServices;
 11using Services.HangfireService;
 12using Services.SignalRServices;
 13using SlqStudio.Application.Services.EmailService;
 14
 15namespace Services.BitrixStockServices;
 16
 17public class BitrixStockUnarchiveJobService : IBitrixStockUnarchiveJobService
 18{
 19    public const string UnarchiveLockKey = "BitrixStock:UnarchiveOzonProducts";
 20    private const int BatchSize = 100;
 21    private const int DelayBetweenBatchesMs = 400;
 22
 23    private readonly IBitrixStockUnarchiveJobRepository _repository;
 24    private readonly OzonClientServices _ozonClientServices;
 25    private readonly IHttpClientFactory _httpClientFactory;
 26    private readonly IEmailService? _emailService;
 27    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 28
 029    public BitrixStockUnarchiveJobService(
 030        IBitrixStockUnarchiveJobRepository repository,
 031        OzonClientServices ozonClientServices,
 032        IHttpClientFactory httpClientFactory,
 033        IEmailService? emailService,
 034        IHubContext<JobProgressHub> jobProgressHub)
 35    {
 036        _repository = repository;
 037        _ozonClientServices = ozonClientServices;
 038        _httpClientFactory = httpClientFactory;
 039        _emailService = emailService;
 040        _jobProgressHub = jobProgressHub;
 041    }
 42
 43    public async Task<BitrixStockUnarchiveExecution> CreateExecutionAsync(
 44        string jobId,
 45        IReadOnlyList<BitrixStockUnarchiveItemDto> items,
 46        string? userId,
 47        string? userName,
 48        string triggerType = "Manual",
 49        CancellationToken cancellationToken = default)
 50    {
 051        var entities = items
 052            .Select((dto, index) => new BitrixStockUnarchiveItem
 053            {
 054                SortOrder = index,
 055                Article = dto.Article?.Trim(),
 056                OfferId = dto.OfferId?.Trim() ?? "",
 057                OzonProductId = dto.OzonProductId,
 058                OzonClient = dto.OzonClient?.Trim() ?? "",
 059                IsAutoArchived = dto.IsAutoArchived,
 060                IsManualArchived = dto.IsManualArchived,
 061                WasInArchive = dto.WasInArchive,
 062                Status = dto.WasInArchive
 063                    ? BitrixStockUnarchiveItemStatus.Pending
 064                    : BitrixStockUnarchiveItemStatus.Skipped
 065            })
 066            .ToList();
 67
 068        return await _repository.CreateExecutionAsync(jobId.Trim(), null, userId, userName, entities, triggerType,
 069            cancellationToken);
 070    }
 71
 72    [Queue(HangfireQueueNames.Main)]
 73    public async Task ProcessUnarchiveAsync(long executionId, string jobId, bool sendHubComplete = true,
 74        CancellationToken cancellationToken = default)
 75    {
 076        var hasHub = !string.IsNullOrWhiteSpace(jobId);
 077        IDisposable? distLock = null;
 078        IStorageConnection? lockConnection = null;
 79
 80        async Task SendLog(string message)
 81        {
 082            if (hasHub)
 83            {
 084                await _jobProgressHub.Clients.Group(jobId).SendAsync("Log", message, cancellationToken);
 85            }
 086        }
 87
 88        async Task SendProgress(int current, int total, string statusText)
 89        {
 090            if (hasHub)
 91            {
 092                await _jobProgressHub.Clients.Group(jobId)
 093                    .SendAsync("Progress", current, total, statusText ?? "", cancellationToken);
 94            }
 095        }
 96
 97        async Task SendComplete(bool success, string report)
 98        {
 099            if (hasHub)
 100            {
 0101                await _jobProgressHub.Clients.Group(jobId)
 0102                    .SendAsync("Complete", success, report ?? "", cancellationToken);
 103            }
 0104        }
 105
 106        async Task SendError(string message)
 107        {
 0108            if (hasHub)
 109            {
 0110                await _jobProgressHub.Clients.Group(jobId).SendAsync("Error", message, cancellationToken);
 111            }
 0112        }
 113
 114        try
 115        {
 116            try
 117            {
 0118                lockConnection = JobStorage.Current.GetConnection();
 0119                distLock = lockConnection.AcquireDistributedLock(UnarchiveLockKey, TimeSpan.FromMilliseconds(1));
 0120            }
 121            catch (DistributedLockTimeoutException)
 122            {
 123                const string msg = "Разархивация уже выполняется. Повторный запуск отменён.";
 0124                await SendLog(msg);
 0125                await _repository.CompleteExecutionAsync(executionId, BitrixStockUnarchiveExecutionStatus.Failed, msg,
 0126                    cancellationToken);
 0127                await SendComplete(false, msg);
 0128                return;
 129            }
 130
 0131            var execution = await _repository.GetExecutionByIdAsync(executionId, includeItems: true, cancellationToken);
 0132            if (execution == null)
 133            {
 0134                await SendError($"Задание {executionId} не найдено");
 0135                return;
 136            }
 137
 0138            var items = execution.Items.OrderBy(i => i.SortOrder).ToList();
 0139            var total = items.Count;
 0140            if (total == 0)
 141            {
 0142                await SendLog("Нет позиций для разархивации");
 0143                await _repository.CompleteExecutionAsync(executionId, BitrixStockUnarchiveExecutionStatus.Success, null,
 0144                    cancellationToken);
 0145                await SendComplete(true, "Нет позиций для обработки");
 0146                return;
 147            }
 148
 0149            await SendLog($"Старт разархивации: {total} позиций");
 0150            var ozonClients = (await _ozonClientServices.GetOzonClients())
 0151                .Where(c => c.ClientType == ClientType.OZON)
 0152                .ToList();
 153
 0154            var successCount = 0;
 0155            var failedCount = 0;
 0156            var skippedCount = 0;
 0157            var processed = 0;
 158
 0159            foreach (var item in items.Where(i => !i.WasInArchive))
 160            {
 0161                processed++;
 0162                skippedCount++;
 0163                await _repository.UpdateItemResultAsync(item.Id, BitrixStockUnarchiveItemStatus.Skipped,
 0164                    "Не в архиве", cancellationToken);
 0165                await SendLog($"⊘ {item.Article ?? item.OfferId} ({item.OzonClient}): не в архиве");
 0166                await SendProgress(processed, total, item.OfferId);
 0167            }
 168
 0169            var toUnarchive = items.Where(i => i.WasInArchive && i.OzonProductId > 0).ToList();
 0170            var invalid = items.Where(i => i.WasInArchive && i.OzonProductId <= 0).ToList();
 0171            foreach (var item in invalid)
 172            {
 0173                processed++;
 0174                failedCount++;
 175                const string err = "Не указан product_id Ozon";
 0176                await _repository.UpdateItemResultAsync(item.Id, BitrixStockUnarchiveItemStatus.Failed, err,
 0177                    cancellationToken);
 0178                await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0179                await SendProgress(processed, total, item.OfferId);
 0180            }
 181
 0182            var groups = toUnarchive.GroupBy(i => i.OzonClient, StringComparer.OrdinalIgnoreCase);
 0183            foreach (var clientGroup in groups)
 184            {
 0185                cancellationToken.ThrowIfCancellationRequested();
 186
 0187                var client = ozonClients.FirstOrDefault(c =>
 0188                    string.Equals(c.Name, clientGroup.Key, StringComparison.OrdinalIgnoreCase));
 189
 0190                if (client == null ||
 0191                    string.IsNullOrEmpty(client.DecryptClientId) ||
 0192                    string.IsNullOrEmpty(client.DecryptApiKey))
 193                {
 0194                    foreach (var item in clientGroup)
 195                    {
 0196                        processed++;
 0197                        failedCount++;
 0198                        var err = $"Клиент Ozon «{clientGroup.Key}» не найден или не настроен";
 0199                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockUnarchiveItemStatus.Failed, err,
 0200                            cancellationToken);
 0201                        await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0202                        await SendProgress(processed, total, item.OfferId);
 0203                    }
 204
 0205                    continue;
 206                }
 207
 0208                var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 209
 0210                var clientItems = clientGroup.ToList();
 0211                for (var offset = 0; offset < clientItems.Count; offset += BatchSize)
 212                {
 0213                    cancellationToken.ThrowIfCancellationRequested();
 214
 0215                    var batchItems = clientItems.Skip(offset).Take(BatchSize).ToList();
 0216                    var productIds = batchItems.Select(i => i.OzonProductId).Distinct().ToArray();
 0217                    var autoCount = batchItems.Count(i => i.IsAutoArchived);
 0218                    await SendLog(
 0219                        $"Клиент {client.Name}: батч {offset / BatchSize + 1}, product_id: {productIds.Length}" +
 0220                        (autoCount > 0 ? $", авто-архив: {autoCount} (лимит 100/сутки)" : ""));
 221
 222                    try
 223                    {
 0224                        var ok = await apiManager.UnarchiveProductsAsync(productIds);
 0225                        if (ok)
 226                        {
 0227                            foreach (var item in batchItems)
 228                            {
 0229                                processed++;
 0230                                successCount++;
 0231                                await _repository.UpdateItemResultAsync(item.Id, BitrixStockUnarchiveItemStatus.Success,
 0232                                    null, cancellationToken);
 0233                                var typeLabel = item.IsAutoArchived ? "авто" : item.IsManualArchived ? "вручную" : "";
 0234                                await SendLog(
 0235                                    $"✓ {item.Article ?? item.OfferId} ← из архива ({item.OzonProductId}, {client.Name}{
 0236                                await SendProgress(processed, total, item.OfferId);
 0237                            }
 238                        }
 239                        else
 240                        {
 241                            const string err = "Ozon вернул result=false";
 0242                            foreach (var item in batchItems)
 243                            {
 0244                                processed++;
 0245                                failedCount++;
 0246                                await _repository.UpdateItemResultAsync(item.Id, BitrixStockUnarchiveItemStatus.Failed,
 0247                                    err, cancellationToken);
 0248                                await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0249                                await SendProgress(processed, total, item.OfferId);
 0250                            }
 251                        }
 0252                    }
 0253                    catch (Exception ex)
 254                    {
 0255                        var err = FormatUnarchiveError(ex);
 0256                        foreach (var item in batchItems)
 257                        {
 0258                            processed++;
 0259                            failedCount++;
 0260                            await _repository.UpdateItemResultAsync(item.Id, BitrixStockUnarchiveItemStatus.Failed,
 0261                                err, cancellationToken);
 0262                            await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0263                            await SendProgress(processed, total, item.OfferId);
 0264                        }
 0265                    }
 266
 0267                    await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount,
 0268                        cancellationToken);
 269
 0270                    if (offset + BatchSize < clientItems.Count)
 271                    {
 0272                        await Task.Delay(DelayBetweenBatchesMs, cancellationToken);
 273                    }
 0274                }
 0275            }
 276
 0277            var finalStatus = failedCount == 0
 0278                ? BitrixStockUnarchiveExecutionStatus.Success
 0279                : successCount == 0 && skippedCount == total
 0280                    ? BitrixStockUnarchiveExecutionStatus.Success
 0281                    : successCount == 0
 0282                        ? BitrixStockUnarchiveExecutionStatus.Failed
 0283                        : BitrixStockUnarchiveExecutionStatus.PartialSuccess;
 284
 0285            await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount, cancellationToken);
 0286            await _repository.CompleteExecutionAsync(executionId, finalStatus, null, cancellationToken);
 287
 0288            var report = BuildReportHtml(executionId, total, successCount, failedCount, skippedCount, finalStatus);
 0289            await SendLog($"Готово: успешно {successCount}, пропущено {skippedCount}, ошибок {failedCount}");
 0290            if (sendHubComplete)
 291            {
 0292                await SendComplete(failedCount == 0, report);
 293            }
 0294        }
 0295        catch (Exception ex)
 296        {
 0297            await _repository.CompleteExecutionAsync(executionId, BitrixStockUnarchiveExecutionStatus.Failed, ex.Message
 0298                cancellationToken);
 0299            if (sendHubComplete)
 300            {
 0301                await SendError(ex.Message);
 0302                await SendComplete(false, ex.Message);
 303            }
 0304        }
 305        finally
 306        {
 0307            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 308        }
 0309    }
 310
 311    public Task<(List<BitrixStockUnarchiveExecution> Items, int Total)> GetExecutionsPageAsync(int page, int pageSize,
 312        CancellationToken cancellationToken = default) =>
 0313        _repository.GetExecutionsPageAsync(page, pageSize, cancellationToken);
 314
 315    public Task<BitrixStockUnarchiveExecution?> GetExecutionDetailsAsync(long id,
 316        CancellationToken cancellationToken = default) =>
 0317        _repository.GetExecutionByIdAsync(id, includeItems: true, cancellationToken);
 318
 319    private static string FormatUnarchiveError(Exception ex)
 320    {
 0321        var msg = ex.Message;
 0322        if (msg.Contains("autoarchive_restore_failed", StringComparison.OrdinalIgnoreCase))
 323        {
 0324            return "Превышен суточный лимит восстановления авто-архивных товаров (100 шт., обновление в 03:00 МСК)";
 325        }
 326
 0327        return msg;
 328    }
 329
 330    private static string BuildReportHtml(long executionId, int total, int success, int failed, int skipped,
 331        string status)
 332    {
 0333        var sb = new StringBuilder();
 0334        sb.Append($"<strong>Задание #{executionId}</strong> — {status}<br>");
 0335        sb.Append($"Всего: {total}, успешно: {success}, пропущено: {skipped}, ошибок: {failed}");
 0336        return sb.ToString();
 337    }
 338}