< Summary

Information
Class: Services.BitrixStockServices.BitrixStockAutoScheduleService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockAutoScheduleService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 349
Coverable lines: 349
Total lines: 645
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 156
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using Hangfire;
 2using Hangfire.Common;
 3using Hangfire.States;
 4using Hangfire.Storage;
 5using Microsoft.AspNetCore.SignalR;
 6using OzonDomains.Models;
 7using OzonRepositories.Data;
 8using Services.BackgroundJobsServices;
 9using Services.HangfireService;
 10using Services.SignalRServices;
 11
 12namespace Services.BitrixStockServices;
 13
 14public interface IBitrixStockAutoScheduleService
 15{
 16    Task<List<BitrixStockAutoSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
 17    Task<BitrixStockAutoSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
 18    Task<int> CreateAsync(BitrixStockAutoSchedule schedule, CancellationToken cancellationToken = default);
 19    Task UpdateAsync(BitrixStockAutoSchedule schedule, CancellationToken cancellationToken = default);
 20    Task DeleteAsync(int id, CancellationToken cancellationToken = default);
 21    Task RegisterAllActiveAsync(CancellationToken cancellationToken = default);
 22    Task RegisterInHangfireAsync(BitrixStockAutoSchedule schedule, CancellationToken cancellationToken = default);
 23    Task UnregisterFromHangfireAsync(int scheduleId, CancellationToken cancellationToken = default);
 24    Task TriggerManualAsync(int scheduleId, string jobId, string? userName = null,
 25        CancellationToken cancellationToken = default);
 26    Task ProcessScheduleAsync(int scheduleId, string? progressJobId = null, string? manualUserName = null,
 27        CancellationToken cancellationToken = default);
 28    Task<List<BitrixStockAutoScheduleRun>> GetRunsAsync(int scheduleId, CancellationToken cancellationToken = default);
 29}
 30
 31public class BitrixStockAutoScheduleService : IBitrixStockAutoScheduleService
 32{
 33    /// <summary>Защита от бесконечного цикла (пакетов по MaxItemsPerRun).</summary>
 34    private const int MaxBatchesPerScheduleRun = 2000;
 35
 36    /// <summary>Макс. строк с артикулами в RunLog (остальное — сводка).</summary>
 37    private const int MaxArticleLinesInRunLog = 400;
 38
 39    private const int MaxArticleLinesInHubLog = 25;
 40
 41    private readonly IBitrixStockAutoScheduleRepository _repository;
 42    private readonly IBitrixStockInventoryService _inventoryService;
 43    private readonly IBitrixStockClearJobService _clearJobService;
 44    private readonly IBitrixStockPushJobService _pushJobService;
 45    private readonly IBitrixStockClearJobRepository _clearJobRepository;
 46    private readonly IBitrixStockPushJobRepository _pushJobRepository;
 47    private readonly IRecurringJobManager _recurringJobManager;
 48    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 49    private readonly IBitrixStockRunFileLogger? _runFileLogger;
 50
 051    public BitrixStockAutoScheduleService(
 052        IBitrixStockAutoScheduleRepository repository,
 053        IBitrixStockInventoryService inventoryService,
 054        IBitrixStockClearJobService clearJobService,
 055        IBitrixStockPushJobService pushJobService,
 056        IBitrixStockClearJobRepository clearJobRepository,
 057        IBitrixStockPushJobRepository pushJobRepository,
 058        IRecurringJobManager recurringJobManager,
 059        IHubContext<JobProgressHub> jobProgressHub,
 060        IBitrixStockRunFileLogger? runFileLogger = null)
 61    {
 062        _repository = repository;
 063        _inventoryService = inventoryService;
 064        _clearJobService = clearJobService;
 065        _pushJobService = pushJobService;
 066        _clearJobRepository = clearJobRepository;
 067        _pushJobRepository = pushJobRepository;
 068        _recurringJobManager = recurringJobManager;
 069        _jobProgressHub = jobProgressHub;
 070        _runFileLogger = runFileLogger;
 071    }
 72
 73    public Task<List<BitrixStockAutoSchedule>> GetAllAsync(CancellationToken cancellationToken = default) =>
 074        _repository.GetAllAsync(includeClient: true, cancellationToken);
 75
 76    public Task<BitrixStockAutoSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
 077        _repository.GetByIdAsync(id, cancellationToken);
 78
 79    public async Task<int> CreateAsync(BitrixStockAutoSchedule schedule, CancellationToken cancellationToken = default)
 80    {
 081        var id = await _repository.AddAsync(schedule, cancellationToken);
 082        if (schedule.IsActive)
 83        {
 084            schedule.Id = id;
 085            await RegisterInHangfireAsync(schedule, cancellationToken);
 86        }
 87
 088        return id;
 089    }
 90
 91    public async Task UpdateAsync(BitrixStockAutoSchedule schedule, CancellationToken cancellationToken = default)
 92    {
 093        await _repository.UpdateAsync(schedule, cancellationToken);
 94
 095        await UnregisterFromHangfireAsync(schedule.Id, cancellationToken);
 096        if (schedule.IsActive)
 97        {
 098            await RegisterInHangfireAsync(schedule, cancellationToken);
 99        }
 0100    }
 101
 102    public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
 103    {
 0104        await UnregisterFromHangfireAsync(id, cancellationToken);
 0105        await _repository.DeleteAsync(id, cancellationToken);
 0106    }
 107
 108    public async Task RegisterAllActiveAsync(CancellationToken cancellationToken = default)
 109    {
 0110        var active = (await _repository.GetAllAsync(cancellationToken: cancellationToken))
 111            .Where(s => s.IsActive)
 0112            .ToList();
 0113        foreach (var schedule in active)
 114        {
 0115            await RegisterInHangfireAsync(schedule, cancellationToken);
 116        }
 0117    }
 118
 119    public async Task RegisterInHangfireAsync(BitrixStockAutoSchedule schedule, CancellationToken cancellationToken = de
 120    {
 0121        var jobId = HangfireJobId(schedule.Id);
 0122        _recurringJobManager.RemoveIfExists(jobId);
 0123        _recurringJobManager.AddOrUpdate<IBitrixStockAutoScheduleService>(
 0124            jobId,
 0125            s => s.ProcessScheduleAsync(schedule.Id, null, null, CancellationToken.None),
 0126            schedule.CronExpression,
 0127            new RecurringJobOptions
 0128            {
 0129                TimeZone = RussianTimeZoneHelper.Zone
 0130            });
 131
 0132        schedule.NextRunAt = GetNextRunUtc(schedule.CronExpression);
 0133        schedule.UpdatedAt = DateTime.UtcNow;
 0134        await _repository.UpdateAsync(schedule, cancellationToken);
 0135    }
 136
 137    public async Task UnregisterFromHangfireAsync(int scheduleId, CancellationToken cancellationToken = default)
 138    {
 0139        _recurringJobManager.RemoveIfExists(HangfireJobId(scheduleId));
 0140        var schedule = await _repository.GetByIdAsync(scheduleId, cancellationToken);
 0141        if (schedule != null)
 142        {
 0143            schedule.NextRunAt = null;
 0144            await _repository.UpdateAsync(schedule, cancellationToken);
 145        }
 0146    }
 147
 148    public Task TriggerManualAsync(int scheduleId, string jobId, string? userName = null,
 149        CancellationToken cancellationToken = default)
 150    {
 0151        BackgroundJob.Enqueue<IBitrixStockAutoScheduleService>(
 0152            s => s.ProcessScheduleAsync(scheduleId, jobId, userName, CancellationToken.None));
 0153        return Task.CompletedTask;
 154    }
 155
 156    public async Task<List<BitrixStockAutoScheduleRun>> GetRunsAsync(int scheduleId,
 157        CancellationToken cancellationToken = default) =>
 0158        await _repository.GetRunsAsync(scheduleId, 30, cancellationToken);
 159
 160    [Queue(HangfireQueueNames.Main)]
 161    public async Task ProcessScheduleAsync(int scheduleId, string? progressJobId = null, string? manualUserName = null,
 162        CancellationToken cancellationToken = default)
 163    {
 0164        var isManual = !string.IsNullOrWhiteSpace(progressJobId);
 0165        var hubJobId = progressJobId?.Trim() ?? string.Empty;
 0166        var hasHub = isManual;
 167
 168        async Task SendLog(string message)
 169        {
 0170            if (_runFileLogger != null) await _runFileLogger.ProcessAsync(message, cancellationToken: cancellationToken)
 0171            if (hasHub)
 172            {
 0173                await _jobProgressHub.Clients.Group(hubJobId).SendAsync("Log", message, cancellationToken);
 174            }
 0175        }
 176
 177        async Task SendProgress(int current, int total, string statusText)
 178        {
 0179            if (hasHub)
 180            {
 0181                await _jobProgressHub.Clients.Group(hubJobId)
 0182                    .SendAsync("Progress", current, total, statusText ?? "", cancellationToken);
 183            }
 0184        }
 185
 186        async Task SendComplete(bool success, string report)
 187        {
 0188            if (hasHub)
 189            {
 0190                await _jobProgressHub.Clients.Group(hubJobId)
 0191                    .SendAsync("Complete", success, report ?? "", cancellationToken);
 192            }
 0193        }
 194
 195        async Task SendError(string message)
 196        {
 0197            if (hasHub)
 198            {
 0199                await _jobProgressHub.Clients.Group(hubJobId).SendAsync("Error", message, cancellationToken);
 200            }
 0201        }
 202
 0203        var schedule = await _repository.GetByIdAsync(scheduleId, cancellationToken);
 0204        if (schedule == null)
 205        {
 0206            await SendLog("Расписание не найдено");
 0207            await SendComplete(false, "Расписание не найдено");
 0208            return;
 209        }
 210
 0211        if (!schedule.IsActive && !isManual)
 212        {
 0213            return;
 214        }
 215
 0216        var isClear = string.Equals(schedule.ActionType, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgnor
 0217        var actionLabel = isClear ? "обнуление" : "накат";
 218
 0219        if (isManual)
 220        {
 0221            await SendLog($"Ручной запуск задачи «{schedule.Name}» ({actionLabel})");
 222        }
 223
 0224        var run = new BitrixStockAutoScheduleRun
 0225        {
 0226            ScheduleId = scheduleId,
 0227            Status = BitrixStockAutoScheduleRunStatus.Running,
 0228            StartedAt = DateTime.UtcNow
 0229        };
 0230        await _repository.AddRunAsync(run, cancellationToken);
 231
 0232        schedule.LastRunAt = DateTime.UtcNow;
 0233        schedule.NextRunAt = GetNextRunUtc(schedule.CronExpression);
 0234        await _repository.UpdateAsync(schedule, cancellationToken);
 235
 0236        using var fileRun = _runFileLogger?.BeginRun(new BitrixStockRunContext(
 0237            run.Id.ToString(), isManual ? "AutoScheduleManual" : "AutoSchedule", HangfireJobId: progressJobId,
 0238            UserName: manualUserName, ClientName: schedule.Name));
 239
 0240        var triggerType = isManual ? "Manual" : "Scheduled";
 0241        var triggeredBy = isManual && !string.IsNullOrWhiteSpace(manualUserName)
 0242            ? $"{manualUserName} (задача #{schedule.Id})"
 0243            : $"Расписание #{schedule.Id}: {schedule.Name}";
 244
 245        try
 246        {
 0247            var lockKey = isClear ? BitrixStockClearJobService.ClearLockKey : BitrixStockPushJobService.PushLockKey;
 248
 249            try
 250            {
 0251                var lockConnection = JobStorage.Current.GetConnection();
 0252                using var distLock = lockConnection.AcquireDistributedLock(lockKey, TimeSpan.FromMilliseconds(1));
 0253            }
 254            catch (DistributedLockTimeoutException)
 255            {
 256                const string lockMsg = "Другая операция Ozon уже выполняется";
 0257                var lockJobId = isManual ? hubJobId : Guid.NewGuid().ToString();
 0258                await RecordSkippedExecutionAsync(
 0259                    isClear, lockJobId, triggeredBy, triggerType, lockMsg, run, cancellationToken);
 0260                run.Status = BitrixStockAutoScheduleRunStatus.Skipped;
 0261                run.ErrorMessage = lockMsg;
 0262                run.CompletedAt = DateTime.UtcNow;
 0263                await _repository.UpdateRunAsync(run, cancellationToken);
 0264                await SendError(lockMsg);
 0265                await SendComplete(false, lockMsg);
 0266                return;
 267            }
 268
 0269            var resolvedFilter = BitrixStockAutoFilterFactory.ResolveForSchedule(schedule, null);
 0270            await SendLog($"Фильтр задачи: {BitrixStockAutoFilterFactory.DescribeFilter(resolvedFilter, schedule.ActionT
 271
 0272            var batchSize = schedule.MaxItemsPerRun > 0 ? schedule.MaxItemsPerRun : 500;
 0273            await SendLog($"Размер пакета: {batchSize}. Позиции обрабатываются пакетами до исчерпания фильтра.");
 274
 0275            var runLog = new List<string>();
 0276            void AddRunLog(string line) => runLog.Add(line);
 277
 0278            var totalQueued = 0;
 0279            var batchNumber = 0;
 0280            var lastWarning = (string?)null;
 0281            var mappingsLogged = false;
 282
 0283            while (batchNumber < MaxBatchesPerScheduleRun)
 284            {
 0285                cancellationToken.ThrowIfCancellationRequested();
 0286                batchNumber++;
 287
 0288                await SendLog($"Пакет {batchNumber}: поиск позиций по фильтру…");
 0289                await SendProgress(totalQueued, Math.Max(totalQueued, 1),
 0290                    $"Пакет {batchNumber}: загрузка Bitrix и Ozon…");
 291
 0292                var collected = await _inventoryService.CollectForScheduleAsync(schedule, cancellationToken);
 0293                if (batchNumber == 1)
 294                {
 0295                    run.CandidatesFound = collected.ProductsScanned;
 296                }
 297
 0298                var batchItems = isClear ? collected.ClearItems.Count : collected.PushItems.Count;
 0299                if (!string.IsNullOrEmpty(collected.Warning))
 300                {
 0301                    lastWarning = collected.Warning;
 302                }
 303
 0304                if (!mappingsLogged &&
 0305                    (collected.MappingsCreated > 0 || collected.MappingsUpdated > 0))
 306                {
 0307                    mappingsLogged = true;
 0308                    var mapMsg =
 0309                        $"Связи складов: создано {collected.MappingsCreated}, обновлено id {collected.MappingsUpdated}";
 0310                    AddRunLog(mapMsg);
 0311                    await SendLog(mapMsg);
 312                }
 313
 0314                if (batchNumber == 1)
 315                {
 0316                    foreach (var line in collected.LogLines)
 317                    {
 0318                        AddRunLog(line);
 0319                        await SendLog(line);
 320                    }
 321                }
 322
 0323                if (batchItems == 0)
 324                {
 0325                    if (batchNumber == 1)
 326                    {
 0327                        var emptyMsg = string.IsNullOrEmpty(collected.Warning)
 0328                            ? "Нет позиций по фильтру"
 0329                            : collected.Warning;
 0330                        AddRunLog(emptyMsg ?? "Нет позиций по фильтру");
 0331                        run.RunLog = string.Join(Environment.NewLine, runLog);
 0332                        var emptyJobId = isManual ? hubJobId : Guid.NewGuid().ToString();
 0333                        await RecordSkippedExecutionAsync(
 0334                            isClear, emptyJobId, triggeredBy, triggerType, emptyMsg, run, cancellationToken);
 0335                        run.Status = BitrixStockAutoScheduleRunStatus.Skipped;
 0336                        run.ErrorMessage = emptyMsg;
 0337                        run.CompletedAt = DateTime.UtcNow;
 0338                        await _repository.UpdateRunAsync(run, cancellationToken);
 0339                        await SendComplete(false, emptyMsg ?? "Нет позиций");
 0340                        return;
 341                    }
 342
 0343                    AddRunLog($"Пакет {batchNumber}: новых позиций нет — обработка завершена.");
 0344                    await SendLog("Все позиции по фильтру обработаны.");
 0345                    break;
 346                }
 347
 0348                totalQueued += batchItems;
 0349                run.ItemsQueued = totalQueued;
 0350                await SendLog(
 0351                    $"Пакет {batchNumber}: проверено товаров {collected.ProductsScanned}, к {actionLabel} {batchItems} (
 352
 0353                AppendBatchArticlesRunLog(runLog, batchNumber, actionLabel, isClear, collected);
 0354                foreach (var previewLine in BuildBatchArticlesHubPreview(batchNumber, isClear, collected))
 355                {
 0356                    await SendLog(previewLine);
 357                }
 358
 0359                var executionJobId = isManual ? hubJobId : Guid.NewGuid().ToString();
 0360                var isPartialBatch = batchItems < batchSize;
 361
 0362                if (isClear)
 363                {
 0364                    await SendLog($"Пакет {batchNumber}: задание обнуления ({batchItems} поз.)…");
 0365                    var execution = await _clearJobService.CreateExecutionAsync(
 0366                        executionJobId,
 0367                        collected.ClearItems,
 0368                        null,
 0369                        triggeredBy,
 0370                        triggerType);
 371
 0372                    run.ClearExecutionId = execution.Id;
 0373                    await _repository.UpdateRunAsync(run, cancellationToken);
 374
 0375                    await _clearJobService.ProcessClearAsync(
 0376                        execution.Id,
 0377                        executionJobId,
 0378                        sendHubComplete: false,
 0379                        cancellationToken);
 0380                }
 381                else
 382                {
 0383                    await SendLog($"Пакет {batchNumber}: задание наката ({batchItems} поз.)…");
 0384                    var execution = await _pushJobService.CreateExecutionAsync(
 0385                        executionJobId,
 0386                        collected.PushItems,
 0387                        null,
 0388                        triggeredBy,
 0389                        triggerType);
 390
 0391                    run.PushExecutionId = execution.Id;
 0392                    await _repository.UpdateRunAsync(run, cancellationToken);
 393
 0394                    await _pushJobService.ProcessPushAsync(
 0395                        execution.Id,
 0396                        executionJobId,
 0397                        sendHubComplete: false,
 0398                        cancellationToken);
 0399                }
 400
 0401                AddRunLog(isClear
 0402                    ? $"Пакет {batchNumber}: обнуление #{run.ClearExecutionId} — {batchItems} поз."
 0403                    : $"Пакет {batchNumber}: накат #{run.PushExecutionId} — {batchItems} поз.");
 404
 0405                if (isPartialBatch)
 406                {
 0407                    AddRunLog($"Последний пакет ({batchNumber}): меньше {batchSize} позиций.");
 0408                    break;
 409                }
 0410            }
 411
 0412            if (batchNumber >= MaxBatchesPerScheduleRun)
 413            {
 0414                var limitMsg =
 0415                    $"Достигнут лимит {MaxBatchesPerScheduleRun} пакетов за один запуск. Обработано {totalQueued} поз.";
 0416                AddRunLog(limitMsg);
 0417                await SendLog(limitMsg);
 0418                lastWarning = limitMsg;
 0419            }
 420
 0421            run.Status = BitrixStockAutoScheduleRunStatus.Success;
 0422            if (!string.IsNullOrEmpty(lastWarning))
 423            {
 0424                run.ErrorMessage = lastWarning;
 425            }
 426
 0427            var summary = isClear
 0428                ? $"Обнуление завершено: {totalQueued} поз. в {batchNumber} пакет(ах). Последнее задание #{run.ClearExec
 0429                : $"Накат завершён: {totalQueued} поз. в {batchNumber} пакет(ах). Последнее задание #{run.PushExecutionI
 0430            AddRunLog(summary);
 0431            run.RunLog = string.Join(Environment.NewLine, runLog);
 432
 0433            run.CompletedAt = DateTime.UtcNow;
 0434            await _repository.UpdateRunAsync(run, cancellationToken);
 0435            if (_runFileLogger != null) await _runFileLogger.CompleteAsync(true, cancellationToken: cancellationToken);
 436
 0437            if (isManual)
 438            {
 0439                await SendProgress(totalQueued, totalQueued, summary);
 0440                await SendComplete(string.IsNullOrEmpty(lastWarning) || totalQueued > 0, summary);
 441            }
 0442        }
 0443        catch (Exception ex)
 444        {
 0445            if (_runFileLogger != null) await _runFileLogger.CompleteAsync(false, ex.Message, CancellationToken.None);
 0446            run.Status = BitrixStockAutoScheduleRunStatus.Failed;
 0447            run.ErrorMessage = ex.Message;
 0448            run.CompletedAt = DateTime.UtcNow;
 0449            await _repository.UpdateRunAsync(run, cancellationToken);
 0450            await SendLog($"Критическая ошибка: {ex.Message}");
 0451            await SendError(ex.Message);
 0452            await SendComplete(false, ex.Message);
 0453            if (!isManual)
 454            {
 0455                throw;
 456            }
 0457        }
 0458    }
 459
 460    private async Task RecordSkippedExecutionAsync(
 461        bool isClear,
 462        string jobId,
 463        string triggeredBy,
 464        string triggerType,
 465        string? message,
 466        BitrixStockAutoScheduleRun run,
 467        CancellationToken cancellationToken)
 468    {
 0469        if (isClear)
 470        {
 0471            var execution = await _clearJobService.CreateExecutionAsync(
 0472                jobId,
 0473                Array.Empty<BitrixStockClearItemDto>(),
 0474                null,
 0475                triggeredBy,
 0476                triggerType,
 0477                cancellationToken);
 0478            await _clearJobRepository.CompleteExecutionAsync(
 0479                execution.Id, BitrixStockClearExecutionStatus.Success, message, cancellationToken);
 0480            run.ClearExecutionId = execution.Id;
 0481        }
 482        else
 483        {
 0484            var execution = await _pushJobService.CreateExecutionAsync(
 0485                jobId,
 0486                Array.Empty<BitrixStockPushItemDto>(),
 0487                null,
 0488                triggeredBy,
 0489                triggerType,
 0490                cancellationToken);
 0491            await _pushJobRepository.CompleteExecutionAsync(
 0492                execution.Id, BitrixStockPushExecutionStatus.Success, message, cancellationToken);
 0493            run.PushExecutionId = execution.Id;
 0494        }
 0495    }
 496
 497    private static void AppendBatchArticlesRunLog(
 498        ICollection<string> runLog,
 499        int batchNumber,
 500        string actionLabel,
 501        bool isClear,
 502        BitrixStockAutoCollectResult collected)
 503    {
 0504        if (isClear)
 505        {
 0506            AppendClearItemsRunLog(runLog, batchNumber, actionLabel, collected.ClearItems);
 507        }
 508        else
 509        {
 0510            AppendPushItemsRunLog(runLog, batchNumber, actionLabel, collected.PushItems);
 511        }
 0512    }
 513
 514    private static IEnumerable<string> BuildBatchArticlesHubPreview(
 515        int batchNumber,
 516        bool isClear,
 517        BitrixStockAutoCollectResult collected)
 518    {
 0519        var lines = isClear
 0520            ? FormatClearItemLines(collected.ClearItems)
 0521            : FormatPushItemLines(collected.PushItems);
 522
 0523        if (lines.Count == 0)
 524        {
 0525            yield break;
 526        }
 527
 0528        var uniqueArticles = lines
 529            .Select(l => l.ArticleKey)
 0530            .Distinct(StringComparer.OrdinalIgnoreCase)
 0531            .Count();
 532
 0533        yield return $"Пакет {batchNumber}: артикулы ({uniqueArticles} уник., {lines.Count} поз.):";
 534
 0535        foreach (var line in lines.Take(MaxArticleLinesInHubLog))
 536        {
 0537            yield return "  " + line.Text;
 538        }
 539
 0540        if (lines.Count > MaxArticleLinesInHubLog)
 541        {
 0542            yield return $"  … ещё {lines.Count - MaxArticleLinesInHubLog} поз. (полный список в журнале запуска)";
 543        }
 0544    }
 545
 546    private static void AppendClearItemsRunLog(
 547        ICollection<string> runLog,
 548        int batchNumber,
 549        string actionLabel,
 550        IReadOnlyList<BitrixStockClearItemDto> items)
 551    {
 0552        var lines = FormatClearItemLines(items);
 0553        if (lines.Count == 0)
 554        {
 0555            return;
 556        }
 557
 0558        var uniqueArticles = lines.Select(l => l.ArticleKey).Distinct(StringComparer.OrdinalIgnoreCase).Count();
 0559        runLog.Add($"Пакет {batchNumber} — к {actionLabel} ({lines.Count} поз., {uniqueArticles} арт.):");
 0560        AppendFormattedLines(runLog, lines);
 0561    }
 562
 563    private static void AppendPushItemsRunLog(
 564        ICollection<string> runLog,
 565        int batchNumber,
 566        string actionLabel,
 567        IReadOnlyList<BitrixStockPushItemDto> items)
 568    {
 0569        var lines = FormatPushItemLines(items);
 0570        if (lines.Count == 0)
 571        {
 0572            return;
 573        }
 574
 0575        var uniqueArticles = lines.Select(l => l.ArticleKey).Distinct(StringComparer.OrdinalIgnoreCase).Count();
 0576        runLog.Add($"Пакет {batchNumber} — к {actionLabel} ({lines.Count} поз., {uniqueArticles} арт.):");
 0577        AppendFormattedLines(runLog, lines);
 0578    }
 579
 580    private static void AppendFormattedLines(ICollection<string> runLog, List<BatchArticleLogLine> lines)
 581    {
 0582        foreach (var line in lines.Take(MaxArticleLinesInRunLog))
 583        {
 0584            runLog.Add("  " + line.Text);
 585        }
 586
 0587        if (lines.Count > MaxArticleLinesInRunLog)
 588        {
 0589            runLog.Add($"  … ещё {lines.Count - MaxArticleLinesInRunLog} поз. (обрезано в журнале)");
 590        }
 0591    }
 592
 593    private static List<BatchArticleLogLine> FormatClearItemLines(IReadOnlyList<BitrixStockClearItemDto> items) =>
 0594        items
 0595            .OrderBy(i => i.Article ?? i.OfferId, StringComparer.OrdinalIgnoreCase)
 0596            .ThenBy(i => i.WarehouseName)
 0597            .Select(i => new BatchArticleLogLine(
 0598                ResolveArticleKey(i.Article, i.OfferId ?? ""),
 0599                $"{ResolveArticleKey(i.Article, i.OfferId ?? "")} | {i.WarehouseName ?? i.WarehouseId.ToString()} ({i.Pr
 0600            .ToList();
 601
 602    private static List<BatchArticleLogLine> FormatPushItemLines(IReadOnlyList<BitrixStockPushItemDto> items) =>
 0603        items
 0604            .OrderBy(i => i.Article ?? i.OfferId, StringComparer.OrdinalIgnoreCase)
 0605            .ThenBy(i => i.WarehouseName)
 0606            .Select(i => new BatchArticleLogLine(
 0607                ResolveArticleKey(i.Article, i.OfferId ?? ""),
 0608                $"{ResolveArticleKey(i.Article, i.OfferId ?? "")} | {i.WarehouseName ?? i.WarehouseId.ToString()} Bitrix
 0609            .ToList();
 610
 611    private static string ResolveArticleKey(string? article, string offerId)
 612    {
 0613        if (!string.IsNullOrWhiteSpace(article))
 614        {
 0615            return article.Trim();
 616        }
 617
 0618        if (!string.IsNullOrWhiteSpace(offerId) && offerId.Contains('='))
 619        {
 0620            return offerId.Split('=')[0].Trim();
 621        }
 622
 0623        return offerId?.Trim() ?? "—";
 624    }
 625
 0626    private sealed record BatchArticleLogLine(string ArticleKey, string Text);
 627
 0628    private static string HangfireJobId(int scheduleId) => $"bitrix-stock-auto-schedule-{scheduleId}";
 629
 630    private static DateTime? GetNextRunUtc(string cronExpression)
 631    {
 632        try
 633        {
 0634            var expr = Cronos.CronExpression.Parse(cronExpression);
 0635            var tz = RussianTimeZoneHelper.Zone;
 0636            var nowRu = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
 0637            var next = expr.GetNextOccurrence(nowRu, tz);
 0638            return next.HasValue ? TimeZoneInfo.ConvertTimeToUtc(next.Value, tz) : null;
 639        }
 0640        catch
 641        {
 0642            return null;
 643        }
 0644    }
 645}

Methods/Properties

.ctor(OzonRepositories.Data.IBitrixStockAutoScheduleRepository,Services.BitrixStockServices.IBitrixStockInventoryService,Services.BitrixStockServices.IBitrixStockClearJobService,Services.BitrixStockServices.IBitrixStockPushJobService,OzonRepositories.Data.IBitrixStockClearJobRepository,OzonRepositories.Data.IBitrixStockPushJobRepository,Hangfire.IRecurringJobManager,Microsoft.AspNetCore.SignalR.IHubContext`1<Services.SignalRServices.JobProgressHub>,Services.BitrixStockServices.IBitrixStockRunFileLogger)
GetAllAsync(System.Threading.CancellationToken)
GetByIdAsync(System.Int32,System.Threading.CancellationToken)
CreateAsync()
UpdateAsync()
DeleteAsync()
RegisterAllActiveAsync()
RegisterInHangfireAsync()
UnregisterFromHangfireAsync()
TriggerManualAsync(System.Int32,System.String,System.String,System.Threading.CancellationToken)
GetRunsAsync()
ProcessScheduleAsync()
SendLog()
SendProgress()
SendComplete()
SendError()
AddRunLog()
RecordSkippedExecutionAsync()
AppendBatchArticlesRunLog(System.Collections.Generic.ICollection`1<System.String>,System.Int32,System.String,System.Boolean,Services.BitrixStockServices.BitrixStockAutoCollectResult)
BuildBatchArticlesHubPreview()
AppendClearItemsRunLog(System.Collections.Generic.ICollection`1<System.String>,System.Int32,System.String,System.Collections.Generic.IReadOnlyList`1<Services.BitrixStockServices.BitrixStockClearItemDto>)
AppendPushItemsRunLog(System.Collections.Generic.ICollection`1<System.String>,System.Int32,System.String,System.Collections.Generic.IReadOnlyList`1<Services.BitrixStockServices.BitrixStockPushItemDto>)
AppendFormattedLines(System.Collections.Generic.ICollection`1<System.String>,System.Collections.Generic.List`1<Services.BitrixStockServices.BitrixStockAutoScheduleService/BatchArticleLogLine>)
FormatClearItemLines(System.Collections.Generic.IReadOnlyList`1<Services.BitrixStockServices.BitrixStockClearItemDto>)
FormatPushItemLines(System.Collections.Generic.IReadOnlyList`1<Services.BitrixStockServices.BitrixStockPushItemDto>)
ResolveArticleKey(System.String,System.String)
get_ArticleKey()
HangfireJobId(System.Int32)
GetNextRunUtc(System.String)