< Summary

Information
Class: Services.BitrixStockServices.BitrixStockPushJobService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockPushJobService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 210
Coverable lines: 210
Total lines: 420
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%
ProcessPushAsync()0%3906620%
SendLog()0%2040%
SendProgress()0%2040%
SendComplete()0%2040%
SendError()0%620%
<ProcessPushAsync()0%2040%
<ProcessPushAsync()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/BitrixStockPushJobService.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 BitrixStockPushJobService : IBitrixStockPushJobService
 19{
 20    public const string PushLockKey = "BitrixStock:PushOzonStock";
 21    private const int BatchSize = 100;
 22    private const int DelayBetweenBatchesMs = 400;
 23
 24    private readonly IBitrixStockPushJobRepository _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 BitrixStockPushJobService(
 032        IBitrixStockPushJobRepository 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<BitrixStockPushExecution> CreateExecutionAsync(
 48        string jobId,
 49        IReadOnlyList<BitrixStockPushItemDto> 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 BitrixStockPushItem
 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                LinkedStoreName = dto.LinkedStoreName?.Trim(),
 66                PreviousStock = dto.PreviousStock,
 67                TargetStock = dto.TargetStock,
 68                Status = BitrixStockPushItemStatus.Pending
 69            })
 070            .ToList();
 71
 072        return await _repository.CreateExecutionAsync(jobId.Trim(), null, userId, userName, entities, triggerType,
 073            cancellationToken);
 074    }
 75
 76    [Queue(HangfireQueueNames.Main)]
 77    public async Task ProcessPushAsync(long executionId, string jobId, bool sendHubComplete = true,
 78        CancellationToken cancellationToken = default)
 79    {
 080        using var fileRun = _runFileLogger?.BeginRun(new BitrixStockRunContext(jobId, "LegacyPush", HangfireJobId: jobId
 081        var hasHub = !string.IsNullOrWhiteSpace(jobId);
 082        IDisposable? distLock = null;
 083        IStorageConnection? lockConnection = null;
 84
 85        async Task SendLog(string message)
 86        {
 087            if (_runFileLogger != null) await _runFileLogger.ProcessAsync(message, cancellationToken: cancellationToken)
 088            if (hasHub)
 89            {
 090                await _jobProgressHub.Clients.Group(jobId).SendAsync("Log", message, cancellationToken);
 91            }
 092        }
 93
 94        async Task SendProgress(int current, int total, string statusText)
 95        {
 096            if (hasHub)
 97            {
 098                await _jobProgressHub.Clients.Group(jobId)
 099                    .SendAsync("Progress", current, total, statusText ?? "", cancellationToken);
 100            }
 0101        }
 102
 103        async Task SendComplete(bool success, string report)
 104        {
 0105            if (hasHub)
 106            {
 0107                await _jobProgressHub.Clients.Group(jobId)
 0108                    .SendAsync("Complete", success, report ?? "", cancellationToken);
 109            }
 0110        }
 111
 112        async Task SendError(string message)
 113        {
 0114            if (hasHub)
 115            {
 0116                await _jobProgressHub.Clients.Group(jobId).SendAsync("Error", message, cancellationToken);
 117            }
 0118        }
 119
 120        try
 121        {
 122            try
 123            {
 0124                lockConnection = JobStorage.Current.GetConnection();
 0125                distLock = lockConnection.AcquireDistributedLock(PushLockKey, TimeSpan.FromMilliseconds(1));
 0126            }
 127            catch (DistributedLockTimeoutException)
 128            {
 129                const string msg = "Накат остатков уже выполняется. Повторный запуск отменён.";
 0130                await SendLog(msg);
 0131                await _repository.CompleteExecutionAsync(executionId, BitrixStockPushExecutionStatus.Failed, msg,
 0132                    cancellationToken);
 0133                await SendComplete(false, msg);
 0134                return;
 135            }
 136
 0137            var execution = await _repository.GetExecutionByIdAsync(executionId, includeItems: true, cancellationToken);
 0138            if (execution == null)
 139            {
 0140                await SendError($"Задание {executionId} не найдено");
 0141                return;
 142            }
 143
 144            var items = execution.Items.OrderBy(i => i.SortOrder).ToList();
 0145            var total = items.Count;
 0146            if (total == 0)
 147            {
 0148                await SendLog("Нет позиций для наката");
 0149                await _repository.CompleteExecutionAsync(executionId, BitrixStockPushExecutionStatus.Success, null,
 0150                    cancellationToken);
 0151                await SendComplete(true, "Нет позиций для обработки");
 0152                return;
 153            }
 154
 0155            await SendLog($"Старт наката: {total} позиций");
 0156            var ozonClients = (await _ozonClientServices.GetOzonClients())
 157                .Where(c => c.ClientType == ClientType.OZON)
 0158                .ToList();
 159
 0160            var successCount = 0;
 0161            var failedCount = 0;
 0162            var processed = 0;
 163
 164            var groups = items.GroupBy(i => i.OzonClient, StringComparer.OrdinalIgnoreCase);
 0165            foreach (var clientGroup in groups)
 166            {
 0167                cancellationToken.ThrowIfCancellationRequested();
 168
 0169                var client = ozonClients.FirstOrDefault(c =>
 0170                    string.Equals(c.Name, clientGroup.Key, StringComparison.OrdinalIgnoreCase));
 171
 0172                if (client == null ||
 0173                    string.IsNullOrEmpty(client.DecryptClientId) ||
 0174                    string.IsNullOrEmpty(client.DecryptApiKey))
 175                {
 0176                    foreach (var item in clientGroup)
 177                    {
 0178                        processed++;
 0179                        failedCount++;
 0180                        var err = $"Клиент Ozon «{clientGroup.Key}» не найден или не настроен";
 0181                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockPushItemStatus.Failed, err,
 0182                            cancellationToken);
 0183                        await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0184                        await SendProgress(processed, total, $"Ошибка клиента {clientGroup.Key}");
 0185                    }
 186
 0187                    continue;
 188                }
 189
 0190                var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 191
 0192                var clientItems = clientGroup.ToList();
 0193                for (var offset = 0; offset < clientItems.Count; offset += BatchSize)
 194                {
 0195                    cancellationToken.ThrowIfCancellationRequested();
 196
 0197                    var batchItems = clientItems.Skip(offset).Take(BatchSize).ToList();
 0198                    await SendLog(
 0199                        $"Клиент {client.Name}: батч {offset / BatchSize + 1}, позиций {batchItems.Count}");
 200
 201                    var stockPayload = batchItems.Select(i => new StockItemForUpdate
 202                    {
 203                        OfferId = i.OfferId,
 204                        ProductId = i.ProductId,
 205                        Stock = i.TargetStock,
 206                        WarehouseId = i.WarehouseId
 207                    }).ToList();
 208
 0209                    var handledInBatch = new HashSet<long>();
 0210                    var batchOk = await TryUpdateBatchAsync(apiManager, stockPayload, batchItems, async (item, error) =>
 0211                    {
 0212                        handledInBatch.Add(item.Id);
 0213                        processed++;
 0214                        failedCount++;
 0215                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockPushItemStatus.Failed, error,
 0216                            cancellationToken);
 0217                        await SendLog(
 0218                            $"✗ {item.Article ?? item.OfferId} ({item.WarehouseName ?? item.WarehouseId.ToString()}): {e
 0219                        await SendProgress(processed, total, item.OfferId);
 0220                    }, async item =>
 0221                    {
 0222                        handledInBatch.Add(item.Id);
 0223                        processed++;
 0224                        successCount++;
 0225                        await _repository.UpdateItemResultAsync(item.Id, BitrixStockPushItemStatus.Success, null,
 0226                            cancellationToken);
 0227                        await SendLog(
 0228                            $"✓ {item.Article ?? item.OfferId}: {item.PreviousStock} → {item.TargetStock} ({item.Warehou
 0229                        await SendProgress(processed, total, item.OfferId);
 0230                    }, cancellationToken);
 231
 0232                    if (!batchOk)
 233                    {
 0234                        foreach (var item in batchItems)
 235                        {
 0236                            if (handledInBatch.Contains(item.Id))
 237                            {
 238                                continue;
 239                            }
 240
 0241                            var single = new List<StockItemForUpdate>
 0242                            {
 0243                                new()
 0244                                {
 0245                                    OfferId = item.OfferId,
 0246                                    ProductId = item.ProductId,
 0247                                    Stock = item.TargetStock,
 0248                                    WarehouseId = item.WarehouseId
 0249                                }
 0250                            };
 251
 252                            try
 253                            {
 0254                                var singleResult = await apiManager.UpdateProductStocks(single);
 0255                                var row = singleResult?.Result?.FirstOrDefault();
 0256                                if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any()))
 257                                {
 0258                                    processed++;
 0259                                    successCount++;
 0260                                    await _repository.UpdateItemResultAsync(item.Id, BitrixStockPushItemStatus.Success,
 0261                                        null, cancellationToken);
 0262                                    await SendLog(
 0263                                        $"✓ {item.Article ?? item.OfferId}: {item.PreviousStock} → {item.TargetStock} (п
 264                                }
 265                                else
 266                                {
 0267                                    var err = FormatStockErrors(row);
 0268                                    processed++;
 0269                                    failedCount++;
 0270                                    await _repository.UpdateItemResultAsync(item.Id, BitrixStockPushItemStatus.Failed,
 0271                                        err, cancellationToken);
 0272                                    await SendLog($"✗ {item.Article ?? item.OfferId}: {err}");
 0273                                }
 0274                            }
 0275                            catch (Exception ex)
 276                            {
 0277                                processed++;
 0278                                failedCount++;
 0279                                await _repository.UpdateItemResultAsync(item.Id, BitrixStockPushItemStatus.Failed,
 0280                                    ex.Message, cancellationToken);
 0281                                await SendLog($"✗ {item.Article ?? item.OfferId}: {ex.Message}");
 0282                            }
 283
 0284                            await SendProgress(processed, total, item.OfferId);
 0285                            await Task.Delay(200, cancellationToken);
 0286                        }
 287                    }
 288
 0289                    await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount,
 0290                        cancellationToken);
 291
 0292                    if (offset + BatchSize < clientItems.Count)
 293                    {
 0294                        await Task.Delay(DelayBetweenBatchesMs, cancellationToken);
 295                    }
 0296                }
 0297            }
 298
 0299            var finalStatus = failedCount == 0
 0300                ? BitrixStockPushExecutionStatus.Success
 0301                : successCount == 0
 0302                    ? BitrixStockPushExecutionStatus.Failed
 0303                    : BitrixStockPushExecutionStatus.PartialSuccess;
 304
 0305            await _repository.UpdateExecutionCountersAsync(executionId, successCount, failedCount, cancellationToken);
 0306            await _repository.CompleteExecutionAsync(executionId, finalStatus, null, cancellationToken);
 307
 0308            var report = BuildReportHtml(executionId, total, successCount, failedCount, finalStatus);
 0309            await SendLog($"Готово: успешно {successCount}, ошибок {failedCount}");
 0310            if (_runFileLogger != null) await _runFileLogger.CompleteAsync(failedCount == 0, failedCount == 0 ? null : $
 0311            if (sendHubComplete)
 312            {
 0313                await SendComplete(failedCount == 0, report);
 314            }
 0315        }
 0316        catch (Exception ex)
 317        {
 0318            if (_runFileLogger != null) await _runFileLogger.CompleteAsync(false, ex.Message, CancellationToken.None);
 0319            await _repository.CompleteExecutionAsync(executionId, BitrixStockPushExecutionStatus.Failed, ex.Message,
 0320                cancellationToken);
 0321            if (sendHubComplete)
 322            {
 0323                await SendError(ex.Message);
 324            }
 325
 0326            await SendLog($"Критическая ошибка: {ex.Message}");
 0327            if (sendHubComplete)
 328            {
 0329                await SendComplete(false, ex.Message);
 330            }
 331
 0332            throw;
 0333        }
 334        finally
 335        {
 0336            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 337        }
 0338    }
 339
 340    public Task<(List<BitrixStockPushExecution> Items, int Total)> GetExecutionsPageAsync(int page, int pageSize,
 341        CancellationToken cancellationToken = default) =>
 0342        _repository.GetExecutionsPageAsync(page, pageSize, cancellationToken);
 343
 344    public Task<BitrixStockPushExecution?> GetExecutionDetailsAsync(long id,
 345        CancellationToken cancellationToken = default) =>
 0346        _repository.GetExecutionByIdAsync(id, includeItems: true, cancellationToken);
 347
 348    private static async Task<bool> TryUpdateBatchAsync(
 349        OzonApiDataManager apiManager,
 350        List<StockItemForUpdate> stockPayload,
 351        List<BitrixStockPushItem> batchItems,
 352        Func<BitrixStockPushItem, string, Task> onFailed,
 353        Func<BitrixStockPushItem, Task> onSuccess,
 354        CancellationToken cancellationToken)
 355    {
 356        try
 357        {
 0358            var result = await apiManager.UpdateProductStocks(stockPayload);
 0359            if (result?.Result == null || result.Result.Count == 0)
 360            {
 0361                return false;
 362            }
 363
 0364            var byKey = batchItems.ToDictionary(
 365                i => $"{i.ProductId}_{i.WarehouseId}",
 366                i => i);
 367
 0368            var allOk = true;
 0369            foreach (var row in result.Result)
 370            {
 0371                cancellationToken.ThrowIfCancellationRequested();
 0372                var key = $"{row.ProductId}_{row.WarehouseId}";
 0373                if (!byKey.TryGetValue(key, out var item))
 374                {
 375                    continue;
 376                }
 377
 0378                if (row.Updated && (row.Errors == null || !row.Errors.Any()))
 379                {
 0380                    await onSuccess(item);
 381                }
 382                else
 383                {
 0384                    allOk = false;
 0385                    await onFailed(item, FormatStockErrors(row));
 386                }
 387            }
 388
 0389            if (batchItems.Any(i => !byKey.ContainsKey($"{i.ProductId}_{i.WarehouseId}")))
 390            {
 0391                allOk = false;
 392            }
 393
 0394            return allOk;
 395        }
 0396        catch
 397        {
 0398            return false;
 399        }
 0400    }
 401
 402    private static string FormatStockErrors(UpdateStockResult? row)
 403    {
 0404        if (row?.Errors != null && row.Errors.Any())
 405        {
 0406            return string.Join("; ", row.Errors.Select(e => e.Message ?? e.Code ?? "error"));
 407        }
 408
 0409        return row?.Updated == true ? string.Empty : "Ozon не обновил остаток";
 410    }
 411
 412    private static string BuildReportHtml(long executionId, int total, int success, int failed, string status)
 413    {
 0414        var sb = new StringBuilder();
 0415        sb.Append("<div><strong>Итог наката остатков</strong><br/>");
 0416        sb.Append($"Задание #{executionId}, статус: {status}<br/>");
 0417        sb.Append($"Всего: {total}, успешно: {success}, ошибок: {failed}</div>");
 0418        return sb.ToString();
 419    }
 420}