< Summary

Information
Class: Services.BitrixStockServices.BitrixStockArchiveJobService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockArchiveJobService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 183
Coverable lines: 183
Total lines: 321
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 80
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%
ProcessArchiveAsync()0%2970540%
SendLog()0%620%
SendProgress()0%2040%
SendComplete()0%2040%
SendError()0%620%
GetExecutionsPageAsync(...)100%210%
GetExecutionDetailsAsync(...)100%210%
BuildReportHtml(...)100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockArchiveJobService.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 BitrixStockArchiveJobService : IBitrixStockArchiveJobService
 18{
 19    public const string ArchiveLockKey = "BitrixStock:ArchiveOzonProducts";
 20    private const int BatchSize = 100;
 21    private const int DelayBetweenBatchesMs = 400;
 22
 23    private readonly IBitrixStockArchiveJobRepository _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 BitrixStockArchiveJobService(
 030        IBitrixStockArchiveJobRepository 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<BitrixStockArchiveExecution> CreateExecutionAsync(
 44        string jobId,
 45        IReadOnlyList<BitrixStockArchiveItemDto> 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 BitrixStockArchiveItem
 053            {
 054                SortOrder = index,
 055                Article = dto.Article?.Trim(),
 056                OfferId = dto.OfferId?.Trim() ?? "",
 057                OzonProductId = dto.OzonProductId,
 058                OzonClient = dto.OzonClient?.Trim() ?? "",
 059                WasArchived = dto.WasArchived,
 060                Status = dto.WasArchived
 061                    ? BitrixStockArchiveItemStatus.Skipped
 062                    : BitrixStockArchiveItemStatus.Pending
 063            })
 064            .ToList();
 65
 066        return await _repository.CreateExecutionAsync(jobId.Trim(), null, userId, userName, entities, triggerType,
 067            cancellationToken);
 068    }
 69
 70    [Queue(HangfireQueueNames.Main)]
 71    public async Task ProcessArchiveAsync(long executionId, string jobId, bool sendHubComplete = true,
 72        CancellationToken cancellationToken = default)
 73    {
 074        var hasHub = !string.IsNullOrWhiteSpace(jobId);
 075        IDisposable? distLock = null;
 076        IStorageConnection? lockConnection = null;
 77
 78        async Task SendLog(string message)
 79        {
 080            if (hasHub)
 81            {
 082                await _jobProgressHub.Clients.Group(jobId).SendAsync("Log", message, cancellationToken);
 83            }
 084        }
 85
 86        async Task SendProgress(int current, int total, string statusText)
 87        {
 088            if (hasHub)
 89            {
 090                await _jobProgressHub.Clients.Group(jobId)
 091                    .SendAsync("Progress", current, total, statusText ?? "", cancellationToken);
 92            }
 093        }
 94
 95        async Task SendComplete(bool success, string report)
 96        {
 097            if (hasHub)
 98            {
 099                await _jobProgressHub.Clients.Group(jobId)
 0100                    .SendAsync("Complete", success, report ?? "", cancellationToken);
 101            }
 0102        }
 103
 104        async Task SendError(string message)
 105        {
 0106            if (hasHub)
 107            {
 0108                await _jobProgressHub.Clients.Group(jobId).SendAsync("Error", message, cancellationToken);
 109            }
 0110        }
 111
 112        try
 113        {
 114            try
 115            {
 0116                lockConnection = JobStorage.Current.GetConnection();
 0117                distLock = lockConnection.AcquireDistributedLock(ArchiveLockKey, TimeSpan.FromMilliseconds(1));
 0118            }
 119            catch (DistributedLockTimeoutException)
 120            {
 121                const string msg = "Архивация уже выполняется. Повторный запуск отменён.";
 0122                await SendLog(msg);
 0123                await _repository.CompleteExecutionAsync(executionId, BitrixStockArchiveExecutionStatus.Failed, msg,
 0124                    cancellationToken);
 0125                await SendComplete(false, msg);
 0126                return;
 127            }
 128
 0129            var execution = await _repository.GetExecutionByIdAsync(executionId, includeItems: true, cancellationToken);
 0130            if (execution == null)
 131            {
 0132                await SendError($"Задание {executionId} не найдено");
 0133                return;
 134            }
 135
 0136            var items = execution.Items.OrderBy(i => i.SortOrder).ToList();
 0137            var total = items.Count;
 0138            if (total == 0)
 139            {
 0140                await SendLog("Нет позиций для архивации");
 0141                await _repository.CompleteExecutionAsync(executionId, BitrixStockArchiveExecutionStatus.Success, null,
 0142                    cancellationToken);
 0143                await SendComplete(true, "Нет позиций для обработки");
 0144                return;
 145            }
 146
 0147            await SendLog($"Старт архивации: {total} позиций");
 0148            var ozonClients = (await _ozonClientServices.GetOzonClients())
 0149                .Where(c => c.ClientType == ClientType.OZON)
 0150                .ToList();
 151
 0152            var successCount = 0;
 0153            var failedCount = 0;
 0154            var skippedCount = 0;
 0155            var processed = 0;
 156
 0157            foreach (var item in items.Where(i => i.WasArchived))
 158            {
 0159                processed++;
 0160                skippedCount++;
 0161                await _repository.UpdateItemResultAsync(item.Id, BitrixStockArchiveItemStatus.Skipped,
 0162                    "Уже в архиве", cancellationToken);
 0163                await SendLog($"⊘ {item.Article ?? item.OfferId} ({item.OzonClient}): уже в архиве");
 0164                await SendProgress(processed, total, item.OfferId);
 0165            }
 166
 0167            var toArchive = items.Where(i => !i.WasArchived && i.OzonProductId > 0).ToList();
 0168            var invalid = items.Where(i => !i.WasArchived && i.OzonProductId <= 0).ToList();
 0169            foreach (var item in invalid)
 170            {
 0171                processed++;
 0172                failedCount++;
 173                const string err = "Не указан product_id Ozon";
 0174                await _repository.UpdateItemResultAsync(item.Id, BitrixStockArchiveItemStatus.Failed, err,
 0175                    cancellationToken);
 0176                await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0177                await SendProgress(processed, total, item.OfferId);
 0178            }
 179
 0180            var groups = toArchive.GroupBy(i => i.OzonClient, StringComparer.OrdinalIgnoreCase);
 0181            foreach (var clientGroup in groups)
 182            {
 0183                cancellationToken.ThrowIfCancellationRequested();
 184
 0185                var client = ozonClients.FirstOrDefault(c =>
 0186                    string.Equals(c.Name, clientGroup.Key, StringComparison.OrdinalIgnoreCase));
 187
 0188                if (client == null ||
 0189                    string.IsNullOrEmpty(client.DecryptClientId) ||
 0190                    string.IsNullOrEmpty(client.DecryptApiKey))
 191                {
 0192                    foreach (var item in clientGroup)
 193                    {
 0194                        processed++;
 0195                        failedCount++;
 0196                        var err = $"Клиент Ozon «{clientGroup.Key}» не найден или не настроен";
 0197                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockArchiveItemStatus.Failed, err,
 0198                            cancellationToken);
 0199                        await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0200                        await SendProgress(processed, total, item.OfferId);
 0201                    }
 202
 0203                    continue;
 204                }
 205
 0206                var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 207
 0208                var clientItems = clientGroup.ToList();
 0209                for (var offset = 0; offset < clientItems.Count; offset += BatchSize)
 210                {
 0211                    cancellationToken.ThrowIfCancellationRequested();
 212
 0213                    var batchItems = clientItems.Skip(offset).Take(BatchSize).ToList();
 0214                    var productIds = batchItems.Select(i => i.OzonProductId).Distinct().ToArray();
 0215                    await SendLog(
 0216                        $"Клиент {client.Name}: батч {offset / BatchSize + 1}, product_id: {productIds.Length}");
 217
 218                    try
 219                    {
 0220                        var ok = await apiManager.ArchiveProductsAsync(productIds);
 0221                        if (ok)
 222                        {
 0223                            foreach (var item in batchItems)
 224                            {
 0225                                processed++;
 0226                                successCount++;
 0227                                await _repository.UpdateItemResultAsync(item.Id, BitrixStockArchiveItemStatus.Success,
 0228                                    null, cancellationToken);
 0229                                await SendLog(
 0230                                    $"✓ {item.Article ?? item.OfferId} → архив ({item.OzonProductId}, {client.Name})");
 0231                                await SendProgress(processed, total, item.OfferId);
 0232                            }
 233                        }
 234                        else
 235                        {
 236                            const string err = "Ozon вернул result=false";
 0237                            foreach (var item in batchItems)
 238                            {
 0239                                processed++;
 0240                                failedCount++;
 0241                                await _repository.UpdateItemResultAsync(item.Id, BitrixStockArchiveItemStatus.Failed,
 0242                                    err, cancellationToken);
 0243                                await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0244                                await SendProgress(processed, total, item.OfferId);
 0245                            }
 246                        }
 0247                    }
 0248                    catch (Exception ex)
 249                    {
 0250                        foreach (var item in batchItems)
 251                        {
 0252                            processed++;
 0253                            failedCount++;
 0254                            await _repository.UpdateItemResultAsync(item.Id, BitrixStockArchiveItemStatus.Failed,
 0255                                ex.Message, cancellationToken);
 0256                            await SendLog($"✗ {item.Article ?? item.OfferId}: {ex.Message}");
 0257                            await SendProgress(processed, total, item.OfferId);
 0258                        }
 0259                    }
 260
 0261                    await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount,
 0262                        cancellationToken);
 263
 0264                    if (offset + BatchSize < clientItems.Count)
 265                    {
 0266                        await Task.Delay(DelayBetweenBatchesMs, cancellationToken);
 267                    }
 0268                }
 0269            }
 270
 0271            var finalStatus = failedCount == 0
 0272                ? BitrixStockArchiveExecutionStatus.Success
 0273                : successCount == 0 && skippedCount == total
 0274                    ? BitrixStockArchiveExecutionStatus.Success
 0275                    : successCount == 0
 0276                        ? BitrixStockArchiveExecutionStatus.Failed
 0277                        : BitrixStockArchiveExecutionStatus.PartialSuccess;
 278
 0279            await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount, cancellationToken);
 0280            await _repository.CompleteExecutionAsync(executionId, finalStatus, null, cancellationToken);
 281
 0282            var report = BuildReportHtml(executionId, total, successCount, failedCount, skippedCount, finalStatus);
 0283            await SendLog($"Готово: успешно {successCount}, пропущено {skippedCount}, ошибок {failedCount}");
 0284            if (sendHubComplete)
 285            {
 0286                await SendComplete(failedCount == 0, report);
 287            }
 0288        }
 0289        catch (Exception ex)
 290        {
 0291            await _repository.CompleteExecutionAsync(executionId, BitrixStockArchiveExecutionStatus.Failed, ex.Message,
 0292                cancellationToken);
 0293            if (sendHubComplete)
 294            {
 0295                await SendError(ex.Message);
 0296                await SendComplete(false, ex.Message);
 297            }
 0298        }
 299        finally
 300        {
 0301            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 302        }
 0303    }
 304
 305    public Task<(List<BitrixStockArchiveExecution> Items, int Total)> GetExecutionsPageAsync(int page, int pageSize,
 306        CancellationToken cancellationToken = default) =>
 0307        _repository.GetExecutionsPageAsync(page, pageSize, cancellationToken);
 308
 309    public Task<BitrixStockArchiveExecution?> GetExecutionDetailsAsync(long id,
 310        CancellationToken cancellationToken = default) =>
 0311        _repository.GetExecutionByIdAsync(id, includeItems: true, cancellationToken);
 312
 313    private static string BuildReportHtml(long executionId, int total, int success, int failed, int skipped,
 314        string status)
 315    {
 0316        var sb = new StringBuilder();
 0317        sb.Append($"<strong>Задание #{executionId}</strong> — {status}<br>");
 0318        sb.Append($"Всего: {total}, успешно: {success}, пропущено: {skipped}, ошибок: {failed}");
 0319        return sb.ToString();
 320    }
 321}