< Summary

Information
Class: Services.BitrixStockServices.BitrixStockClearJobService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockClearJobService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 208
Coverable lines: 208
Total lines: 417
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 116
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%
ProcessClearAsync()0%3906620%
SendLog()0%2040%
SendProgress()0%2040%
SendComplete()0%2040%
SendError()0%620%
<ProcessClearAsync()0%2040%
<ProcessClearAsync()0%2040%
GetExecutionsPageAsync(...)100%210%
GetExecutionDetailsAsync(...)100%210%
TryUpdateBatchAsync()0%420200%
FormatStockErrors(...)0%210140%
BuildReportHtml(...)100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockClearJobService.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.ApiServices.OzonApi.Filters;
 11using Services.DataServices;
 12using Services.HangfireService;
 13using Services.SignalRServices;
 14using SlqStudio.Application.Services.EmailService;
 15
 16namespace Services.BitrixStockServices;
 17
 18public class BitrixStockClearJobService : IBitrixStockClearJobService
 19{
 20    public const string ClearLockKey = "BitrixStock:ClearOzonStock";
 21    private const int BatchSize = 100;
 22    private const int DelayBetweenBatchesMs = 400;
 23
 24    private readonly IBitrixStockClearJobRepository _repository;
 25    private readonly OzonClientServices _ozonClientServices;
 26    private readonly IHttpClientFactory _httpClientFactory;
 27    private readonly IEmailService? _emailService;
 28    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 29    private readonly IBitrixStockRunFileLogger? _runFileLogger;
 30
 031    public BitrixStockClearJobService(
 032        IBitrixStockClearJobRepository repository,
 033        OzonClientServices ozonClientServices,
 034        IHttpClientFactory httpClientFactory,
 035        IEmailService? emailService,
 036        IHubContext<JobProgressHub> jobProgressHub,
 037        IBitrixStockRunFileLogger? runFileLogger = null)
 38    {
 039        _repository = repository;
 040        _ozonClientServices = ozonClientServices;
 041        _httpClientFactory = httpClientFactory;
 042        _emailService = emailService;
 043        _jobProgressHub = jobProgressHub;
 044        _runFileLogger = runFileLogger;
 045    }
 46
 47    public async Task<BitrixStockClearExecution> CreateExecutionAsync(
 48        string jobId,
 49        IReadOnlyList<BitrixStockClearItemDto> items,
 50        string? userId,
 51        string? userName,
 52        string triggerType = "Manual",
 53        CancellationToken cancellationToken = default)
 54    {
 055        var entities = items
 56            .Select((dto, index) => new BitrixStockClearItem
 57            {
 58                SortOrder = index,
 59                Article = dto.Article?.Trim(),
 60                OfferId = dto.OfferId?.Trim() ?? "",
 61                ProductId = dto.ProductId,
 62                WarehouseId = dto.WarehouseId,
 63                WarehouseName = dto.WarehouseName?.Trim(),
 64                OzonClient = dto.OzonClient?.Trim() ?? "",
 65                PreviousStock = dto.PreviousStock,
 66                TargetStock = 0,
 67                Status = BitrixStockClearItemStatus.Pending
 68            })
 069            .ToList();
 70
 071        return await _repository.CreateExecutionAsync(jobId.Trim(), null, userId, userName, entities, triggerType,
 072            cancellationToken);
 073    }
 74
 75    [Queue(HangfireQueueNames.Main)]
 76    public async Task ProcessClearAsync(long executionId, string jobId, bool sendHubComplete = true,
 77        CancellationToken cancellationToken = default)
 78    {
 079        using var fileRun = _runFileLogger?.BeginRun(new BitrixStockRunContext(jobId, "LegacyClear", HangfireJobId: jobI
 080        var hasHub = !string.IsNullOrWhiteSpace(jobId);
 081        IDisposable? distLock = null;
 082        IStorageConnection? lockConnection = null;
 83
 84        async Task SendLog(string message)
 85        {
 086            if (_runFileLogger != null) await _runFileLogger.ProcessAsync(message, cancellationToken: cancellationToken)
 087            if (hasHub)
 88            {
 089                await _jobProgressHub.Clients.Group(jobId).SendAsync("Log", message, cancellationToken);
 90            }
 091        }
 92
 93        async Task SendProgress(int current, int total, string statusText)
 94        {
 095            if (hasHub)
 96            {
 097                await _jobProgressHub.Clients.Group(jobId)
 098                    .SendAsync("Progress", current, total, statusText ?? "", cancellationToken);
 99            }
 0100        }
 101
 102        async Task SendComplete(bool success, string report)
 103        {
 0104            if (hasHub)
 105            {
 0106                await _jobProgressHub.Clients.Group(jobId)
 0107                    .SendAsync("Complete", success, report ?? "", cancellationToken);
 108            }
 0109        }
 110
 111        async Task SendError(string message)
 112        {
 0113            if (hasHub)
 114            {
 0115                await _jobProgressHub.Clients.Group(jobId).SendAsync("Error", message, cancellationToken);
 116            }
 0117        }
 118
 119        try
 120        {
 121            try
 122            {
 0123                lockConnection = JobStorage.Current.GetConnection();
 0124                distLock = lockConnection.AcquireDistributedLock(ClearLockKey, TimeSpan.FromMilliseconds(1));
 0125            }
 126            catch (DistributedLockTimeoutException)
 127            {
 128                const string msg = "Обнуление уже выполняется. Повторный запуск отменён.";
 0129                await SendLog(msg);
 0130                await _repository.CompleteExecutionAsync(executionId, BitrixStockClearExecutionStatus.Failed, msg,
 0131                    cancellationToken);
 0132                await SendComplete(false, msg);
 0133                return;
 134            }
 135
 0136            var execution = await _repository.GetExecutionByIdAsync(executionId, includeItems: true, cancellationToken);
 0137            if (execution == null)
 138            {
 0139                await SendError($"Задание {executionId} не найдено");
 0140                return;
 141            }
 142
 143            var items = execution.Items.OrderBy(i => i.SortOrder).ToList();
 0144            var total = items.Count;
 0145            if (total == 0)
 146            {
 0147                await SendLog("Нет позиций для обнуления");
 0148                await _repository.CompleteExecutionAsync(executionId, BitrixStockClearExecutionStatus.Success, null,
 0149                    cancellationToken);
 0150                await SendComplete(true, "Нет позиций для обработки");
 0151                return;
 152            }
 153
 0154            await SendLog($"Старт обнуления: {total} позиций");
 0155            var ozonClients = (await _ozonClientServices.GetOzonClients())
 156                .Where(c => c.ClientType == ClientType.OZON)
 0157                .ToList();
 158
 0159            var successCount = 0;
 0160            var failedCount = 0;
 0161            var processed = 0;
 162
 163            var groups = items.GroupBy(i => i.OzonClient, StringComparer.OrdinalIgnoreCase);
 0164            foreach (var clientGroup in groups)
 165            {
 0166                cancellationToken.ThrowIfCancellationRequested();
 167
 0168                var client = ozonClients.FirstOrDefault(c =>
 0169                    string.Equals(c.Name, clientGroup.Key, StringComparison.OrdinalIgnoreCase));
 170
 0171                if (client == null ||
 0172                    string.IsNullOrEmpty(client.DecryptClientId) ||
 0173                    string.IsNullOrEmpty(client.DecryptApiKey))
 174                {
 0175                    foreach (var item in clientGroup)
 176                    {
 0177                        processed++;
 0178                        failedCount++;
 0179                        var err = $"Клиент Ozon «{clientGroup.Key}» не найден или не настроен";
 0180                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockClearItemStatus.Failed, err,
 0181                            cancellationToken);
 0182                        await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0183                        await SendProgress(processed, total, $"Ошибка клиента {clientGroup.Key}");
 0184                    }
 185
 0186                    continue;
 187                }
 188
 0189                var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 190
 0191                var clientItems = clientGroup.ToList();
 0192                for (var offset = 0; offset < clientItems.Count; offset += BatchSize)
 193                {
 0194                    cancellationToken.ThrowIfCancellationRequested();
 195
 0196                    var batchItems = clientItems.Skip(offset).Take(BatchSize).ToList();
 0197                    await SendLog(
 0198                        $"Клиент {client.Name}: батч {offset / BatchSize + 1}, позиций {batchItems.Count}");
 199
 200                    var stockPayload = batchItems.Select(i => new StockItemForUpdate
 201                    {
 202                        OfferId = i.OfferId,
 203                        ProductId = i.ProductId,
 204                        Stock = 0,
 205                        WarehouseId = i.WarehouseId
 206                    }).ToList();
 207
 0208                    var handledInBatch = new HashSet<long>();
 0209                    var batchOk = await TryUpdateBatchAsync(apiManager, stockPayload, batchItems, async (item, error) =>
 0210                    {
 0211                        handledInBatch.Add(item.Id);
 0212                        processed++;
 0213                        failedCount++;
 0214                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockClearItemStatus.Failed, error,
 0215                            cancellationToken);
 0216                        await SendLog($"✗ {item.Article ?? item.OfferId} ({item.WarehouseName ?? item.WarehouseId.ToStri
 0217                        await SendProgress(processed, total, item.OfferId);
 0218                    }, async (item) =>
 0219                    {
 0220                        handledInBatch.Add(item.Id);
 0221                        processed++;
 0222                        successCount++;
 0223                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockClearItemStatus.Success, null,
 0224                            cancellationToken);
 0225                        await SendLog($"✓ {item.Article ?? item.OfferId} → 0 ({item.WarehouseName ?? item.WarehouseId.To
 0226                        await SendProgress(processed, total, item.OfferId);
 0227                    }, cancellationToken);
 228
 0229                    if (!batchOk)
 230                    {
 0231                        foreach (var item in batchItems)
 232                        {
 0233                            if (handledInBatch.Contains(item.Id))
 234                            {
 235                                continue;
 236                            }
 237
 0238                            var single = new List<StockItemForUpdate>
 0239                            {
 0240                                new()
 0241                                {
 0242                                    OfferId = item.OfferId,
 0243                                    ProductId = item.ProductId,
 0244                                    Stock = 0,
 0245                                    WarehouseId = item.WarehouseId
 0246                                }
 0247                            };
 248
 249                            try
 250                            {
 0251                                var singleResult = await apiManager.UpdateProductStocks(single);
 0252                                var row = singleResult?.Result?.FirstOrDefault();
 0253                                if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any()))
 254                                {
 0255                                    processed++;
 0256                                    successCount++;
 0257                                    await _repository.UpdateItemResultAsync(item.Id, BitrixStockClearItemStatus.Success,
 0258                                        null, cancellationToken);
 0259                                    await SendLog(
 0260                                        $"✓ {item.Article ?? item.OfferId} → 0 (повтор по одной)");
 261                                }
 262                                else
 263                                {
 0264                                    var err = FormatStockErrors(row);
 0265                                    processed++;
 0266                                    failedCount++;
 0267                                    await _repository.UpdateItemResultAsync(item.Id, BitrixStockClearItemStatus.Failed,
 0268                                        err, cancellationToken);
 0269                                    await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0270                                }
 0271                            }
 0272                            catch (Exception ex)
 273                            {
 0274                                processed++;
 0275                                failedCount++;
 0276                                await _repository.UpdateItemResultAsync(item.Id, BitrixStockClearItemStatus.Failed,
 0277                                    ex.Message, cancellationToken);
 0278                                await SendLog($"✗ {item.Article ?? item.OfferId}: {ex.Message}");
 0279                            }
 280
 0281                            await SendProgress(processed, total, item.OfferId);
 0282                            await Task.Delay(200, cancellationToken);
 0283                        }
 284                    }
 285
 0286                    await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount,
 0287                        cancellationToken);
 288
 0289                    if (offset + BatchSize < clientItems.Count)
 290                    {
 0291                        await Task.Delay(DelayBetweenBatchesMs, cancellationToken);
 292                    }
 0293                }
 0294            }
 295
 0296            var finalStatus = failedCount == 0
 0297                ? BitrixStockClearExecutionStatus.Success
 0298                : successCount == 0
 0299                    ? BitrixStockClearExecutionStatus.Failed
 0300                    : BitrixStockClearExecutionStatus.PartialSuccess;
 301
 0302            await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount, cancellationToken);
 0303            await _repository.CompleteExecutionAsync(executionId, finalStatus, null, cancellationToken);
 304
 0305            var report = BuildReportHtml(executionId, total, successCount, failedCount, finalStatus);
 0306            await SendLog($"Готово: успешно {successCount}, ошибок {failedCount}");
 0307            if (_runFileLogger != null) await _runFileLogger.CompleteAsync(failedCount == 0, failedCount == 0 ? null : $
 0308            if (sendHubComplete)
 309            {
 0310                await SendComplete(failedCount == 0, report);
 311            }
 0312        }
 0313        catch (Exception ex)
 314        {
 0315            if (_runFileLogger != null) await _runFileLogger.CompleteAsync(false, ex.Message, CancellationToken.None);
 0316            await _repository.CompleteExecutionAsync(executionId, BitrixStockClearExecutionStatus.Failed, ex.Message,
 0317                cancellationToken);
 0318            if (sendHubComplete)
 319            {
 0320                await SendError(ex.Message);
 321            }
 322
 0323            await SendLog($"Критическая ошибка: {ex.Message}");
 0324            if (sendHubComplete)
 325            {
 0326                await SendComplete(false, ex.Message);
 327            }
 328
 0329            throw;
 0330        }
 331        finally
 332        {
 0333            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 334        }
 0335    }
 336
 337    public Task<(List<BitrixStockClearExecution> Items, int Total)> GetExecutionsPageAsync(int page, int pageSize,
 338        CancellationToken cancellationToken = default) =>
 0339        _repository.GetExecutionsPageAsync(page, pageSize, cancellationToken);
 340
 341    public Task<BitrixStockClearExecution?> GetExecutionDetailsAsync(long id,
 342        CancellationToken cancellationToken = default) =>
 0343        _repository.GetExecutionByIdAsync(id, includeItems: true, cancellationToken);
 344
 345    private static async Task<bool> TryUpdateBatchAsync(
 346        OzonApiDataManager apiManager,
 347        List<StockItemForUpdate> stockPayload,
 348        List<BitrixStockClearItem> batchItems,
 349        Func<BitrixStockClearItem, string, Task> onFailed,
 350        Func<BitrixStockClearItem, Task> onSuccess,
 351        CancellationToken cancellationToken)
 352    {
 353        try
 354        {
 0355            var result = await apiManager.UpdateProductStocks(stockPayload);
 0356            if (result?.Result == null || result.Result.Count == 0)
 357            {
 0358                return false;
 359            }
 360
 0361            var byKey = batchItems.ToDictionary(
 362                i => $"{i.ProductId}_{i.WarehouseId}",
 363                i => i);
 364
 0365            var allOk = true;
 0366            foreach (var row in result.Result)
 367            {
 0368                cancellationToken.ThrowIfCancellationRequested();
 0369                var key = $"{row.ProductId}_{row.WarehouseId}";
 0370                if (!byKey.TryGetValue(key, out var item))
 371                {
 372                    continue;
 373                }
 374
 0375                if (row.Updated && (row.Errors == null || !row.Errors.Any()))
 376                {
 0377                    await onSuccess(item);
 378                }
 379                else
 380                {
 0381                    allOk = false;
 0382                    await onFailed(item, FormatStockErrors(row));
 383                }
 384            }
 385
 0386            if (batchItems.Any(i => !byKey.ContainsKey($"{i.ProductId}_{i.WarehouseId}")))
 387            {
 0388                allOk = false;
 389            }
 390
 0391            return allOk;
 392        }
 0393        catch
 394        {
 0395            return false;
 396        }
 0397    }
 398
 399    private static string FormatStockErrors(UpdateStockResult? row)
 400    {
 0401        if (row?.Errors != null && row.Errors.Any())
 402        {
 0403            return string.Join("; ", row.Errors.Select(e => e.Message ?? e.Code ?? "error"));
 404        }
 405
 0406        return row?.Updated == true ? string.Empty : "Ozon не обновил остаток";
 407    }
 408
 409    private static string BuildReportHtml(long executionId, int total, int success, int failed, string status)
 410    {
 0411        var sb = new StringBuilder();
 0412        sb.Append("<div><strong>Итог обнуления</strong><br/>");
 0413        sb.Append($"Задание #{executionId}, статус: {status}<br/>");
 0414        sb.Append($"Всего: {total}, успешно: {success}, ошибок: {failed}</div>");
 0415        return sb.ToString();
 416    }
 417}