< Summary

Information
Class: Services.BitrixStockServices.BitrixStockSyncReportCleanupService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncReportCleanupService.cs
Line coverage
2%
Covered lines: 15
Uncovered lines: 651
Coverable lines: 666
Total lines: 1090
Line coverage: 2.2%
Branch coverage
3%
Covered branches: 13
Total branches: 360
Branch coverage: 3.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using Hangfire;
 2using Hangfire.Storage;
 3using Microsoft.AspNetCore.SignalR;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Caching.Memory;
 6using Microsoft.Extensions.Http;
 7using Newtonsoft.Json.Linq;
 8using OzonDomains;
 9using OzonDomains.Models;
 10using OzonRepositories.Context;
 11using Services.ApiServices.OzonApi;
 12using Services.ApiServices.OzonApi.Filters;
 13using Services.DataServices;
 14using Services.HangfireService;
 15using Services.SignalRServices;
 16using SlqStudio.Application.Services.EmailService;
 17
 18namespace Services.BitrixStockServices;
 19
 20public class BitrixStockSyncReportCleanupService : IBitrixStockSyncReportCleanupService
 21{
 22    private const int BatchSize = 100;
 23    private const int DelayBetweenBatchesMs = 400;
 24    private const int PreviewDefaultMaxProducts = 15;
 25    private const int PreviewMaxGroupsPerRequest = BitrixStockSyncCleanupPlanner.PreviewMaxGroupsPerRequest;
 26    private const int FbsStocksChunkSize = 200;
 27
 28    /// <summary>SKU на один запрос stocks-by-warehouse (ответ читается постранично по cursor).</summary>
 29    private const int FbsSkuChunkSize = 300;
 30    private const int ScanProgressLogEveryGroups = 50;
 31
 32    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
 33    private readonly OzonClientServices _ozonClientServices;
 34    private readonly IHttpClientFactory _httpClientFactory;
 35    private readonly IEmailService? _emailService;
 36    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 37    private readonly BitrixStockSyncApplyArticleLogService _articleLog;
 38    private readonly IMemoryCache _memoryCache;
 39    private readonly BitrixStockActiveStageStore _stageStore;
 40    private readonly IBitrixStockSyncReportPricePushService _pricePushService;
 41
 042    public BitrixStockSyncReportCleanupService(
 043        IDbContextFactory<ApplicationDbContext> dbFactory,
 044        OzonClientServices ozonClientServices,
 045        IHttpClientFactory httpClientFactory,
 046        IEmailService? emailService,
 047        IHubContext<JobProgressHub> jobProgressHub,
 048        BitrixStockSyncApplyArticleLogService articleLog,
 049        IMemoryCache memoryCache,
 050        BitrixStockActiveStageStore stageStore,
 051        IBitrixStockSyncReportPricePushService pricePushService)
 52    {
 053        _dbFactory = dbFactory;
 054        _ozonClientServices = ozonClientServices;
 055        _httpClientFactory = httpClientFactory;
 056        _emailService = emailService;
 057        _jobProgressHub = jobProgressHub;
 058        _articleLog = articleLog;
 059        _memoryCache = memoryCache;
 060        _stageStore = stageStore;
 061        _pricePushService = pricePushService;
 062    }
 63
 64    public Task<BitrixStockSyncCleanupPreviewDto> GetCleanupPreviewAsync(
 65        string reportId,
 66        bool onlyPending = true,
 67        int? maxProducts = null,
 68        IReadOnlyList<string>? articles = null,
 69        string? search = null,
 70        int skipGroups = 0,
 71        CancellationToken cancellationToken = default) =>
 072        BuildPreviewOrRunAsync(reportId, onlyPending, maxProducts, articles, search, skipGroups,
 073            dryRun: true, jobId: null, cancellationToken);
 74
 75    public async Task<BitrixStockSyncCleanupZeroResult> ZeroWarehouseAsync(
 76        string reportId,
 77        BitrixStockSyncCleanupZeroRequest request,
 78        CancellationToken cancellationToken = default)
 79    {
 080        if (string.IsNullOrWhiteSpace(reportId))
 081            return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "reportId обязателен" };
 082        if (string.IsNullOrWhiteSpace(request.Article))
 083            return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Артикул обязателен" };
 084        if (!BitrixStockSyncCleanupPlanner.TryParseWarehouseId(request.WarehouseId, out var warehouseId))
 085            return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Некорректный склад" };
 86
 087        IDisposable? distLock = null;
 088        IStorageConnection? lockConnection = null;
 89        try
 90        {
 91            try
 92            {
 093                lockConnection = JobStorage.Current.GetConnection();
 094                distLock = lockConnection.AcquireDistributedLock(
 095                    BitrixStockPushJobService.PushLockKey,
 096                    TimeSpan.FromMilliseconds(1));
 097            }
 098            catch (DistributedLockTimeoutException)
 99            {
 0100                return new BitrixStockSyncCleanupZeroResult
 0101                {
 0102                    Success = false,
 0103                    Error = "Обновление остатков Ozon уже выполняется. Повторите позже."
 0104                };
 105            }
 106
 0107            await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 108
 0109            var article = request.Article.Trim();
 0110            var lines = (await LoadCleanupReportLinesAsync(db, reportId.Trim(), new[] { article }, null, cancellationTok
 0111                .Where(l => l.OzonClientId == request.OzonClientId)
 0112                .ToList();
 0113            if (lines.Count == 0)
 0114                return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Артикул не найден в отчёте" };
 115
 0116            if (BitrixStockSyncCleanupPlanner.HasUnresolvedWarehouseLines(lines))
 0117                return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "В отчёте остаток Ozon без склада
 118
 0119            var protectedIds = BitrixStockSyncCleanupPlanner.BuildProtectedWarehouseIds(lines);
 0120            if (protectedIds.Contains(warehouseId))
 0121                return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Склад защищён (остаток > 0 в отч
 122
 0123            var first = lines[0];
 0124            var client = (await _ozonClientServices.GetOzonClients())
 0125                .FirstOrDefault(c => c.Id == request.OzonClientId && c.ClientType == ClientType.OZON);
 0126            if (client == null || string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApi
 0127                return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Клиент Ozon не найден" };
 128
 0129            var offerId = !string.IsNullOrWhiteSpace(request.OfferId) ? request.OfferId.Trim() : first.OfferId;
 0130            var productId = request.OzonProductId > 0 ? request.OzonProductId : first.OzonProductId ?? 0;
 0131            if (string.IsNullOrWhiteSpace(offerId) || productId <= 0)
 0132                return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Нет offerId/productId для Ozon" 
 133
 0134            var qtyBefore = request.Present > 0 ? request.Present : 0;
 0135            var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 136
 0137            var payload = new List<StockItemForUpdate>
 0138            {
 0139                new()
 0140                {
 0141                    OfferId = offerId,
 0142                    ProductId = productId,
 0143                    Stock = 0,
 0144                    WarehouseId = warehouseId
 0145                }
 0146            };
 147
 0148            var result = await apiManager.UpdateProductStocks(payload);
 0149            var row = result?.Result?.FirstOrDefault();
 0150            var ozonOk = row != null && row.Updated && (row.Errors == null || !row.Errors.Any());
 0151            var failError = ozonOk ? null : FormatStockErrors(row);
 152
 0153            var reportLineWritten = false;
 154            try
 155            {
 0156                await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync(
 0157                    db, reportId.Trim(), request.OzonClientId, article, warehouseId,
 0158                    request.WarehouseName, ozonOk, ozonOk ? "Обнулён из превью (этап 3)" : failError,
 0159                    qtyBefore, offerId, productId, cancellationToken);
 0160                await BitrixStockSyncReportService.RefreshReportOzonCleanupCountsAsync(db, reportId.Trim(), cancellation
 0161                reportLineWritten = true;
 0162            }
 0163            catch (Exception ex)
 164            {
 0165                if (!ozonOk)
 0166                    throw;
 167
 0168                return new BitrixStockSyncCleanupZeroResult
 0169                {
 0170                    Success = true,
 0171                    QtyBefore = qtyBefore,
 0172                    ReportLineWritten = false,
 0173                    Error = "Ozon обнулён, но строка отчёта не записана: " + ex.Message
 0174                };
 175            }
 176
 0177            if (!ozonOk)
 178            {
 0179                return new BitrixStockSyncCleanupZeroResult
 0180                {
 0181                    Success = false,
 0182                    Error = failError,
 0183                    QtyBefore = qtyBefore,
 0184                    ReportLineWritten = reportLineWritten
 0185                };
 186            }
 187
 0188            return new BitrixStockSyncCleanupZeroResult
 0189            {
 0190                Success = true,
 0191                QtyBefore = qtyBefore,
 0192                ReportLineWritten = reportLineWritten
 0193            };
 0194        }
 195        finally
 196        {
 0197            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 198        }
 0199    }
 200
 201    [Queue(HangfireQueueNames.Main)]
 202    public Task RunCleanupAsync(
 203        string reportId,
 204        BitrixStockSyncCleanupStartRequest request,
 205        string? progressJobId,
 206        CancellationToken cancellationToken = default) =>
 0207        BuildPreviewOrRunAsync(
 0208            reportId,
 0209            request.OnlyPending,
 0210            request.MaxProducts,
 0211            request.Articles,
 0212            search: null,
 0213            skipGroups: 0,
 0214            dryRun: false,
 0215            jobId: progressJobId ?? request.JobId,
 0216            cancellationToken: cancellationToken,
 0217            stageSettings: request.StageSettings,
 0218            chained: request.Chained);
 219
 220    private async Task<BitrixStockSyncCleanupPreviewDto> BuildPreviewOrRunAsync(
 221        string reportId,
 222        bool onlyPending,
 223        int? maxProducts,
 224        IReadOnlyList<string>? articles,
 225        string? search,
 226        int skipGroups,
 227        bool dryRun,
 228        string? jobId,
 229        CancellationToken cancellationToken,
 230        PresetStageSettings? stageSettings = null,
 231        bool chained = false)
 232    {
 0233        var hubId = jobId?.Trim() ?? string.Empty;
 0234        var hasHub = !string.IsNullOrWhiteSpace(hubId);
 235
 236        async Task Log(string message)
 237        {
 0238            if (hasHub)
 0239                await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken);
 0240        }
 241
 0242        var lastProgressSentAt = DateTime.MinValue;
 243        async Task Progress(int current, int total, string text)
 244        {
 0245            if (!hasHub) return;
 0246            var now = DateTime.UtcNow;
 0247            if (current < total && (now - lastProgressSentAt).TotalMilliseconds < 250) return;
 0248            lastProgressSentAt = now;
 0249            await _jobProgressHub.Clients.Group(hubId)
 0250                .SendAsync("Progress", current, total, text ?? "", cancellationToken);
 0251        }
 252
 253        async Task Complete(bool ok, object? payload)
 254        {
 0255            if (hasHub)
 0256                await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken);
 0257        }
 258
 0259        IDisposable? distLock = null;
 0260        IStorageConnection? lockConnection = null;
 261
 262        try
 263        {
 0264            if (!dryRun)
 265            {
 266                try
 267                {
 0268                    lockConnection = JobStorage.Current.GetConnection();
 0269                    distLock = lockConnection.AcquireDistributedLock(
 0270                        BitrixStockPushJobService.PushLockKey,
 0271                        TimeSpan.FromMilliseconds(1));
 0272                }
 273                catch (DistributedLockTimeoutException)
 274                {
 275                    const string msg = "Обновление остатков Ozon уже выполняется. Повторный запуск отменён.";
 0276                    await Log(msg);
 0277                    await Complete(false, msg);
 0278                    return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId };
 279                }
 280            }
 281
 0282            if (!dryRun && !chained)
 283            {
 0284                _stageStore.Set(reportId, 3);
 285                // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа)
 0286                if (hasHub) await Task.Delay(800, cancellationToken);
 287            }
 0288            await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 0289            var status = await LoadReportStatusAsync(db, reportId, cancellationToken);
 0290            if (status == null)
 291            {
 0292                await Log("Отчёт не найден");
 0293                await Complete(false, "Отчёт не найден");
 0294                return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId };
 295            }
 296
 0297            if (!string.Equals(status, "Completed", StringComparison.OrdinalIgnoreCase))
 298            {
 0299                await Log($"Отчёт в статусе «{status}» — дождитесь завершения формирования");
 0300                await Complete(false, "Отчёт ещё не готов");
 0301                return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId };
 302            }
 303
 304            var articleFilter = articles?.Where(a => !string.IsNullOrWhiteSpace(a))
 305                .Select(a => a.Trim())
 0306                .Distinct(StringComparer.OrdinalIgnoreCase)
 0307                .ToList();
 0308            var allLines = await LoadCleanupReportLinesAsync(db, reportId, articleFilter, search, cancellationToken);
 0309            if (allLines.Count == 0)
 310            {
 0311                await Log("Нет строк с остатком > 0 для этапа 3");
 0312                await Complete(true, new { reportId, total = 0 });
 0313                return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId };
 314            }
 315
 0316            var groups = allLines
 317                .GroupBy(l => $"{l.OzonClientId}||{l.Article}", StringComparer.OrdinalIgnoreCase)
 0318                .ToList();
 319
 0320            var preview = new BitrixStockSyncCleanupPreviewDto
 0321            {
 0322                ReportId = reportId,
 0323                TotalGroups = groups.Count
 0324            };
 0325            var allOzonClients = await _ozonClientServices.GetOzonClients();
 0326            var clientMap = allOzonClients
 327                .Where(c => c.ClientType == ClientType.OZON)
 328                .ToDictionary(c => c.Id);
 329
 0330            var totalGroups = groups.Count;
 0331            var successCount = 0;
 0332            var failedCount = 0;
 0333            var logJobId = (jobId ?? Guid.NewGuid().ToString("N")).Trim();
 0334            var previewMaxProducts = BitrixStockSyncCleanupPlanner.ResolvePreviewMaxProducts(
 0335                dryRun, maxProducts, articleFilter is { Count: > 0 }, PreviewDefaultMaxProducts);
 0336            var startOffset = dryRun ? Math.Max(0, skipGroups) : 0;
 0337            var scannedInRequest = 0;
 338
 0339            await Log(dryRun
 0340                ? $"Этап 3 — превью: до {previewMaxProducts} артикулов (группы {startOffset + 1}… из {totalGroups})"
 0341                : $"Этап 3 — очистка лишних складов: {totalGroups} групп");
 342
 0343            var groupsToScan = BitrixStockSyncCleanupPlanner.SliceGroupsToScan(
 0344                groups, startOffset, dryRun, PreviewMaxGroupsPerRequest).ToList();
 0345            var fbsCacheByClient = await LoadFbsWarehousesBatchByClientAsync(
 0346                groupsToScan,
 0347                clientMap,
 0348                dryRun,
 0349                Log,
 0350                Progress,
 0351                cancellationToken);
 352
 0353            var scannedGroupIndex = 0;
 0354            foreach (var group in groupsToScan)
 355            {
 0356                cancellationToken.ThrowIfCancellationRequested();
 0357                scannedGroupIndex++;
 0358                if (dryRun)
 359                {
 0360                    scannedInRequest++;
 0361                    if (scannedInRequest > PreviewMaxGroupsPerRequest)
 362                    {
 0363                        preview.HasMore = true;
 0364                        break;
 365                    }
 366                }
 0367                else if (scannedGroupIndex % ScanProgressLogEveryGroups == 0 || scannedGroupIndex == groupsToScan.Count)
 368                {
 0369                    await Log($"Сканирование групп: {startOffset + scannedGroupIndex}/{totalGroups}…");
 0370                    await Progress(startOffset + scannedGroupIndex, totalGroups, "Сканирование");
 371                }
 372
 0373                var lines = group.ToList();
 0374                var first = lines[0];
 0375                if (!clientMap.TryGetValue(first.OzonClientId, out var client) ||
 0376                    string.IsNullOrEmpty(client.DecryptClientId) ||
 0377                    string.IsNullOrEmpty(client.DecryptApiKey))
 378                {
 0379                    await Log($"✗ {first.Article}: клиент Ozon ID {first.OzonClientId} не найден");
 0380                    continue;
 381                }
 382
 0383                if (BitrixStockSyncCleanupPlanner.HasUnresolvedWarehouseLines(lines))
 384                {
 0385                    await Log($"⚠ {first.Article}: в отчёте остаток Ozon без склада (FbsNoWarehouse) — очистка пропущена
 0386                    continue;
 387                }
 388
 0389                var protectedIds = BitrixStockSyncCleanupPlanner.BuildProtectedWarehouseIds(lines);
 0390                var cleanupStatusByWarehouse = lines
 391                    .Where(l => l.OzonWarehouseId > 0)
 392                    .GroupBy(l => l.OzonWarehouseId)
 393                    .ToDictionary(g => g.Key, g => g.First().OzonCleanupStatus);
 394
 395                List<BitrixStockSyncCleanupFbsWarehouse> fbsWarehouses;
 396                try
 397                {
 0398                    fbsWarehouses = ResolveFbsWarehousesFromCache(
 0399                        fbsCacheByClient, first.OzonClientId, first.OfferId);
 0400                }
 401                catch (Exception ex)
 402                {
 0403                    await Log($"✗ {first.Article}: FBS API — {BitrixOzonStockErrorMapper.Map(ex.Message)}");
 0404                    continue;
 405                }
 406
 0407                var toZero = BitrixStockSyncCleanupPlanner.SelectWarehousesToZero(protectedIds, fbsWarehouses);
 0408                if (!BitrixStockSyncCleanupPlanner.IsCleanupCandidate(toZero))
 409                    continue;
 410
 0411                if (onlyPending && !BitrixStockSyncCleanupPlanner.IsGroupPending(toZero, cleanupStatusByWarehouse))
 412                    continue;
 413
 0414                var productDto = BuildProductDto(lines, client, toZero, protectedIds, fbsWarehouses);
 0415                preview.Products.Add(productDto);
 0416                preview.TotalWarehousesToZero += productDto.WarehousesToZero.Count;
 417
 0418                if (previewMaxProducts is > 0 && preview.Products.Count >= previewMaxProducts.Value)
 419                {
 0420                    if (dryRun)
 0421                        preview.HasMore = startOffset + scannedInRequest < totalGroups;
 0422                    break;
 423                }
 0424            }
 425
 0426            if (dryRun)
 427            {
 0428                preview.NextSkipGroups = startOffset + scannedInRequest;
 0429                preview.ScannedGroups = preview.NextSkipGroups;
 0430                if (!preview.HasMore)
 0431                    preview.HasMore = preview.NextSkipGroups < totalGroups;
 0432                preview.IsTruncated = preview.HasMore;
 433            }
 434            else
 435            {
 0436                preview.ScannedGroups = totalGroups;
 437            }
 438
 0439            preview.TotalProducts = preview.Products.Count;
 440
 0441            if (dryRun)
 442            {
 0443                await Log($"Превью: {preview.TotalProducts} артикулов, {preview.TotalWarehousesToZero} складов к обнулен
 0444                          (preview.HasMore ? $" (проверено {preview.ScannedGroups} из {totalGroups} групп, есть ещё)" : 
 0445                await Complete(true, preview);
 0446                return preview;
 447            }
 448
 0449            var runProducts = preview.Products;
 450            var totalOps = runProducts.Sum(p => p.WarehousesToZero.Count);
 0451            var processedOps = 0;
 452
 0453            foreach (var product in runProducts)
 454            {
 0455                cancellationToken.ThrowIfCancellationRequested();
 0456                if (!clientMap.TryGetValue(product.OzonClientId, out var client))
 457                    continue;
 458
 0459                var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 0460                var groupLines = allLines
 0461                    .Where(l => l.OzonClientId == product.OzonClientId &&
 0462                                string.Equals(l.Article, product.Article, StringComparison.OrdinalIgnoreCase))
 0463                    .ToList();
 464
 0465                await Log($"--- {product.Article} ({product.OzonClientName}): обнулить {product.WarehousesToZero.Count} 
 466
 0467                for (var offset = 0; offset < product.WarehousesToZero.Count; offset += BatchSize)
 468                {
 0469                    cancellationToken.ThrowIfCancellationRequested();
 0470                    var batch = product.WarehousesToZero.Skip(offset).Take(BatchSize).ToList();
 0471                    var payload = batch.Select(w => new StockItemForUpdate
 0472                    {
 0473                        OfferId = product.OfferId,
 0474                        ProductId = product.OzonProductId,
 0475                        Stock = 0,
 0476                        WarehouseId = w.WarehouseId
 0477                    }).ToList();
 478
 0479                    var pushItems = batch.Select(w => new BitrixStockPushItemDto
 0480                    {
 0481                        Article = product.Article,
 0482                        OfferId = product.OfferId,
 0483                        ProductId = product.OzonProductId,
 0484                        WarehouseId = w.WarehouseId,
 0485                        WarehouseName = w.WarehouseName,
 0486                        OzonClient = product.OzonClientName,
 0487                        PreviousStock = w.Present,
 0488                        TargetStock = 0
 0489                    }).ToList();
 490
 0491                    var handled = new HashSet<long>();
 0492                    var batchOk = await TryUpdateBatchAsync(apiManager, payload, pushItems,
 0493                        async (item, error) =>
 0494                        {
 0495                            var wh = batch.FirstOrDefault(b => b.WarehouseId == item.WarehouseId);
 0496                            if (wh == null) return;
 0497                            handled.Add(wh.WarehouseId);
 0498                            processedOps++;
 0499                            failedCount++;
 0500                            await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync(
 0501                                db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.WarehouseName,
 0502                                false, error, wh.Present, product.OfferId, product.OzonProductId, cancellationToken);
 0503                            await Log($"✗ {product.Article} ({wh.WarehouseName}): {error}");
 0504                            await WriteCleanupLogAsync(db, reportId, logJobId, product, wh, false, error, cancellationTo
 0505                            await Progress(processedOps, totalOps, product.Article ?? "");
 0506                        },
 0507                        async item =>
 0508                        {
 0509                            var wh = batch.FirstOrDefault(b => b.WarehouseId == item.WarehouseId);
 0510                            if (wh == null) return;
 0511                            handled.Add(wh.WarehouseId);
 0512                            processedOps++;
 0513                            successCount++;
 0514                            const string note = "Обнулён пакетной очисткой (этап 3)";
 0515                            await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync(
 0516                                db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.WarehouseName,
 0517                                true, note, wh.Present, product.OfferId, product.OzonProductId, cancellationToken);
 0518                            await Log($"⓪ {product.Article} ({wh.WarehouseName}): {wh.Present} → 0");
 0519                            await WriteCleanupLogAsync(db, reportId, logJobId, product, wh, true, note, cancellationToke
 0520                            await Progress(processedOps, totalOps, product.Article ?? "");
 0521                        },
 0522                        cancellationToken);
 523
 0524                    if (!batchOk)
 525                    {
 0526                        foreach (var wh in batch.Where(w => !handled.Contains(w.WarehouseId)))
 527                        {
 0528                            var single = new List<StockItemForUpdate>
 0529                            {
 0530                                new()
 0531                                {
 0532                                    OfferId = product.OfferId,
 0533                                    ProductId = product.OzonProductId,
 0534                                    Stock = 0,
 0535                                    WarehouseId = wh.WarehouseId
 0536                                }
 0537                            };
 538                            try
 539                            {
 0540                                var result = await apiManager.UpdateProductStocks(single);
 0541                                var row = result?.Result?.FirstOrDefault();
 0542                                if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any()))
 543                                {
 0544                                    processedOps++;
 0545                                    successCount++;
 0546                                    await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync(
 0547                                        db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.Warehous
 0548                                        true, "Обнулён пакетной очисткой (этап 3)", wh.Present,
 0549                                        product.OfferId, product.OzonProductId, cancellationToken);
 0550                                    await Log($"⓪ {product.Article} ({wh.WarehouseName}): повтор по одной");
 551                                }
 552                                else
 553                                {
 0554                                    var err = FormatStockErrors(row);
 0555                                    processedOps++;
 0556                                    failedCount++;
 0557                                    await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync(
 0558                                        db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.Warehous
 0559                                        false, err, wh.Present, product.OfferId, product.OzonProductId, cancellationToke
 0560                                    await Log($"✗ {product.Article}: {err}");
 0561                                }
 0562                            }
 0563                            catch (Exception ex)
 564                            {
 0565                                processedOps++;
 0566                                failedCount++;
 0567                                await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync(
 0568                                    db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.WarehouseNam
 0569                                    false, BitrixOzonStockErrorMapper.Map(ex.Message), wh.Present,
 0570                                    product.OfferId, product.OzonProductId, cancellationToken);
 0571                                await Log($"✗ {product.Article}: {BitrixOzonStockErrorMapper.Map(ex.Message)}");
 0572                            }
 573
 0574                            await Progress(processedOps, totalOps, product.Article ?? "");
 0575                            await Task.Delay(200, cancellationToken);
 0576                        }
 577                    }
 578
 0579                    if (offset + BatchSize < product.WarehousesToZero.Count)
 0580                        await Task.Delay(DelayBetweenBatchesMs, cancellationToken);
 0581                }
 582
 0583                foreach (var protectedWh in product.ProtectedWarehouses)
 584                {
 0585                    await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync(
 0586                        db, reportId, product.OzonClientId, product.Article, protectedWh.WarehouseId,
 0587                        protectedWh.WarehouseName, true, "Сохранён (этап 3)", protectedWh.Present,
 0588                        product.OfferId, product.OzonProductId, cancellationToken);
 589                }
 590
 0591            }
 592
 0593            await BitrixStockSyncReportService.RefreshReportOzonCleanupCountsAsync(db, reportId, cancellationToken);
 0594            await Log($"Этап 3 готово: успешно {successCount}, ошибок {failedCount} из {totalOps}");
 0595            await Progress(totalOps, totalOps, "Очистка завершена");
 0596            await Complete(true, new { reportId, successCount, failedCount, totalOps });
 597
 598            // Цепочкой этапов (4→5) управляет BitrixStockSyncPipelineOrchestrator.
 0599            return preview;
 0600        }
 0601        catch (Exception ex)
 602        {
 0603            await Log("Ошибка: " + ex.Message);
 0604            await Complete(false, ex.Message);
 0605            throw;
 0606        }
 607        finally
 608        {
 0609            if (!dryRun && !chained) _stageStore.Clear(reportId);
 0610            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 611        }
 0612    }
 613
 614    private static BitrixStockSyncCleanupProductDto BuildProductDto(
 615        List<BitrixStockSyncCleanupReportLine> lines,
 616        OzonClient client,
 617        IReadOnlyList<BitrixStockSyncCleanupWarehouseTarget> toZero,
 618        IReadOnlySet<long> protectedIds,
 619        IReadOnlyList<BitrixStockSyncCleanupFbsWarehouse> fbsWarehouses)
 620    {
 0621        var first = lines[0];
 0622        var lineByWarehouse = lines
 0623            .Where(l => l.OzonWarehouseId > 0)
 0624            .GroupBy(l => l.OzonWarehouseId)
 0625            .ToDictionary(g => g.Key, g => g.First());
 626
 0627        var protectedDtos = protectedIds
 0628            .Select(id =>
 0629            {
 0630                lineByWarehouse.TryGetValue(id, out var line);
 0631                var fbs = fbsWarehouses.FirstOrDefault(w => w.WarehouseId == id);
 0632                return new BitrixStockSyncCleanupWarehouseDto
 0633                {
 0634                    WarehouseId = id,
 0635                    WarehouseName = line?.OzonWarehouseName ?? fbs?.WarehouseName ?? $"#{id}",
 0636                    Present = line?.EffectiveQty ?? fbs?.Present ?? 0,
 0637                    ReportLineId = line?.LineId,
 0638                    CleanupStatus = line?.OzonCleanupStatus,
 0639                    CleanupError = null
 0640                };
 0641            })
 0642            .OrderBy(w => w.WarehouseName, StringComparer.OrdinalIgnoreCase)
 0643            .ToList();
 644
 0645        var toZeroDtos = toZero.Select(w =>
 0646        {
 0647            lineByWarehouse.TryGetValue(w.WarehouseId, out var line);
 0648            return new BitrixStockSyncCleanupWarehouseDto
 0649            {
 0650                WarehouseId = w.WarehouseId,
 0651                WarehouseName = w.WarehouseName,
 0652                Present = w.Present,
 0653                ReportLineId = line?.LineId,
 0654                CleanupStatus = line?.OzonCleanupStatus
 0655            };
 0656        }).ToList();
 657
 0658        return new BitrixStockSyncCleanupProductDto
 0659        {
 0660            OzonClientId = first.OzonClientId,
 0661            OzonClientName = client.Name ?? first.OzonClientId.ToString(),
 0662            Article = first.Article,
 0663            OfferId = first.OfferId ?? string.Empty,
 0664            OzonProductId = first.OzonProductId ?? 0,
 0665            ProtectedWarehouses = protectedDtos,
 0666            WarehousesToZero = toZeroDtos
 0667        };
 668    }
 669
 670    private async Task<Dictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>>> LoadFbsWarehousesB
 671        IReadOnlyList<IGrouping<string, BitrixStockSyncCleanupReportLine>> groupsToScan,
 672        IReadOnlyDictionary<int, OzonClient> clientMap,
 673        bool dryRun,
 674        Func<string, Task> log,
 675        Func<int, int, string, Task> progress,
 676        CancellationToken cancellationToken)
 677    {
 0678        var result = new Dictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>>();
 0679        var groupsByClient = groupsToScan
 680            .Select(g => g.ToList())
 681            .Where(lines => lines.Count > 0)
 682            .GroupBy(lines => lines[0].OzonClientId)
 0683            .ToList();
 684
 0685        foreach (var clientGroups in groupsByClient)
 686        {
 0687            cancellationToken.ThrowIfCancellationRequested();
 0688            if (!clientMap.TryGetValue(clientGroups.Key, out var client) ||
 0689                string.IsNullOrEmpty(client.DecryptClientId) ||
 0690                string.IsNullOrEmpty(client.DecryptApiKey))
 691            {
 692                continue;
 693            }
 694
 0695            var offerIds = clientGroups
 696                .Select(lines => lines[0].OfferId)
 697                .Where(offerId => !string.IsNullOrWhiteSpace(offerId))
 698                .Select(offerId => offerId!.Trim())
 0699                .Distinct(StringComparer.OrdinalIgnoreCase)
 0700                .ToList();
 0701            if (offerIds.Count == 0)
 702            {
 0703                result[clientGroups.Key] = new Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>(StringCompar
 0704                continue;
 705            }
 706
 0707            await log(dryRun
 0708                ? $"Загрузка FBS Ozon: {offerIds.Count} артикулов ({client.Name})…"
 0709                : $"Загрузка FBS Ozon пакетами: {offerIds.Count} артикулов ({client.Name})…");
 710
 0711            var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 0712            var cache = await LoadFbsWarehousesBatchAsync(apiManager, offerIds, log, cancellationToken);
 0713            result[clientGroups.Key] = cache;
 0714            await progress(offerIds.Count, offerIds.Count, "FBS загружен");
 0715        }
 716
 0717        return result;
 0718    }
 719
 720    private static List<BitrixStockSyncCleanupFbsWarehouse> ResolveFbsWarehousesFromCache(
 721        IReadOnlyDictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>> cacheByClient,
 722        int ozonClientId,
 723        string? offerId)
 724    {
 0725        if (string.IsNullOrWhiteSpace(offerId))
 0726            return new List<BitrixStockSyncCleanupFbsWarehouse>();
 727
 0728        if (!cacheByClient.TryGetValue(ozonClientId, out var cache) ||
 0729            !cache.TryGetValue(offerId.Trim(), out var warehouses))
 730        {
 0731            return new List<BitrixStockSyncCleanupFbsWarehouse>();
 732        }
 733
 0734        return warehouses;
 735    }
 736
 737    private static async Task<Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>> LoadFbsWarehousesBatchAsync(
 738        OzonApiDataManager apiManager,
 739        IReadOnlyList<string> offerIds,
 740        Func<string, Task> log,
 741        CancellationToken cancellationToken)
 742    {
 0743        var result = new Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>(StringComparer.OrdinalIgnoreCase);
 0744        if (offerIds.Count == 0)
 0745            return result;
 746
 0747        var distinctOfferIds = offerIds
 748            .Where(id => !string.IsNullOrWhiteSpace(id))
 749            .Select(id => id.Trim())
 0750            .Distinct(StringComparer.OrdinalIgnoreCase)
 0751            .ToList();
 752
 0753        var offerToSkus = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
 0754        var stockChunks = (distinctOfferIds.Count + FbsStocksChunkSize - 1) / FbsStocksChunkSize;
 0755        for (var chunkIndex = 0; chunkIndex < stockChunks; chunkIndex++)
 756        {
 0757            cancellationToken.ThrowIfCancellationRequested();
 0758            var chunk = distinctOfferIds
 0759                .Skip(chunkIndex * FbsStocksChunkSize)
 0760                .Take(FbsStocksChunkSize)
 0761                .ToArray();
 0762            var stocks = await apiManager.GetProductStocks(chunk);
 0763            MergeOfferSkusFromStocks(stocks, offerToSkus);
 0764            if (stockChunks > 1)
 0765                await log($"FBS stocks: {chunkIndex + 1}/{stockChunks} пакетов");
 0766            if (chunkIndex < stockChunks - 1)
 0767                await Task.Delay(600, cancellationToken);
 768        }
 769
 0770        var allSkus = offerToSkus.Values
 771            .SelectMany(set => set)
 0772            .Distinct(StringComparer.OrdinalIgnoreCase)
 0773            .ToList();
 0774        if (allSkus.Count == 0)
 0775            return result;
 776
 0777        var skuToWarehouses = new Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>(StringComparer.OrdinalIgn
 0778        var skuChunks = (allSkus.Count + FbsSkuChunkSize - 1) / FbsSkuChunkSize;
 0779        for (var chunkIndex = 0; chunkIndex < skuChunks; chunkIndex++)
 780        {
 0781            cancellationToken.ThrowIfCancellationRequested();
 0782            var chunk = allSkus
 0783                .Skip(chunkIndex * FbsSkuChunkSize)
 0784                .Take(FbsSkuChunkSize)
 0785                .ToArray();
 0786            var fbsResponse = await apiManager.GetProductWarehousesForBitrix(chunk, Array.Empty<string>());
 0787            foreach (var pair in GroupFbsWarehousesBySku(ParseFbsWarehouses(fbsResponse)))
 788            {
 0789                if (!skuToWarehouses.TryGetValue(pair.Key, out var list))
 790                {
 0791                    list = new List<BitrixStockSyncCleanupFbsWarehouse>();
 0792                    skuToWarehouses[pair.Key] = list;
 793                }
 794
 0795                list.AddRange(pair.Value);
 796            }
 797
 0798            if (skuChunks > 1)
 0799                await log($"FBS warehouses: {chunkIndex + 1}/{skuChunks} пакетов");
 0800            if (chunkIndex < skuChunks - 1)
 0801                await Task.Delay(600, cancellationToken);
 802        }
 803
 0804        foreach (var (offerId, skus) in offerToSkus)
 805        {
 0806            var merged = new Dictionary<long, BitrixStockSyncCleanupFbsWarehouse>();
 0807            foreach (var sku in skus)
 808            {
 0809                if (!skuToWarehouses.TryGetValue(sku, out var warehouses))
 810                    continue;
 811
 0812                foreach (var warehouse in warehouses)
 813                {
 0814                    merged[warehouse.WarehouseId] = warehouse;
 815                }
 816            }
 817
 0818            result[offerId] = merged.Values
 819                .OrderBy(w => w.WarehouseName, StringComparer.OrdinalIgnoreCase)
 0820                .ToList();
 821        }
 822
 0823        return result;
 0824    }
 825
 826    private async Task<List<BitrixStockSyncCleanupFbsWarehouse>> LoadFbsWarehousesAsync(
 827        OzonApiDataManager apiManager,
 828        string offerId,
 829        CancellationToken cancellationToken)
 830    {
 0831        var cache = await LoadFbsWarehousesBatchAsync(
 0832            apiManager,
 0833            new[] { offerId },
 834            _ => Task.CompletedTask,
 0835            cancellationToken);
 0836        return ResolveFbsWarehousesFromCache(
 0837            new Dictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>>
 0838            {
 0839                [0] = cache
 0840            },
 0841            0,
 0842            offerId);
 0843    }
 844
 845    private static void MergeOfferSkusFromStocks(
 846        JObject? stocks,
 847        IDictionary<string, HashSet<string>> offerToSkus)
 848    {
 0849        if (stocks?["items"] is not JArray items)
 0850            return;
 851
 0852        foreach (var stockItem in items)
 853        {
 0854            var offerId = stockItem["offer_id"]?.ToString();
 0855            if (string.IsNullOrWhiteSpace(offerId))
 856                continue;
 857
 0858            if (!offerToSkus.TryGetValue(offerId, out var skus))
 859            {
 0860                skus = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0861                offerToSkus[offerId] = skus;
 862            }
 863
 0864            if (stockItem["stocks"] is not JArray stockRows)
 865                continue;
 866
 0867            foreach (var s in stockRows)
 868            {
 0869                var sku = s["sku"]?.ToString();
 0870                if (!string.IsNullOrEmpty(sku))
 0871                    skus.Add(sku);
 872            }
 873        }
 0874    }
 875
 876    private static IEnumerable<KeyValuePair<string, List<BitrixStockSyncCleanupFbsWarehouse>>> GroupFbsWarehousesBySku(
 877        IEnumerable<BitrixStockSyncCleanupFbsWarehouse> warehouses)
 878    {
 0879        return warehouses
 0880            .Where(w => w.Sku > 0)
 0881            .GroupBy(w => w.Sku.ToString(), StringComparer.OrdinalIgnoreCase)
 0882            .Select(g => new KeyValuePair<string, List<BitrixStockSyncCleanupFbsWarehouse>>(g.Key, g.ToList()));
 883    }
 884
 885    internal static List<BitrixStockSyncCleanupFbsWarehouse> ParseFbsWarehouses(JObject? response)
 886    {
 3887        var list = new List<BitrixStockSyncCleanupFbsWarehouse>();
 3888        var arr = response?["result"] as JArray ?? response?["products"] as JArray;
 3889        if (arr == null) return list;
 890
 10891        foreach (var entry in arr)
 892        {
 2893            var warehouseId = entry["warehouse_id"]?.Value<long>() ?? 0;
 2894            if (warehouseId <= 0) continue;
 2895            list.Add(new BitrixStockSyncCleanupFbsWarehouse
 2896            {
 2897                WarehouseId = warehouseId,
 2898                WarehouseName = entry["warehouse_name"]?.ToString() ?? $"#{warehouseId}",
 2899                Present = entry["present"]?.Value<int>() ?? 0,
 2900                ProductId = entry["product_id"]?.Value<long>() ?? 0,
 2901                Sku = entry["sku"]?.Value<long>() ?? 0
 2902            });
 903        }
 904
 3905        return list;
 906    }
 907
 908    private static async Task<string?> LoadReportStatusAsync(
 909        ApplicationDbContext db,
 910        string reportId,
 911        CancellationToken cancellationToken)
 912    {
 0913        var rows = await db.Database.SqlQueryRaw<string>(
 0914            "SELECT Status FROM BitrixStockSyncReports WHERE ReportId = {0} LIMIT 1",
 0915            reportId.Trim()).ToListAsync(cancellationToken);
 0916        return rows.FirstOrDefault();
 0917    }
 918
 919    private static async Task<List<BitrixStockSyncCleanupReportLine>> LoadCleanupReportLinesAsync(
 920        ApplicationDbContext db,
 921        string reportId,
 922        IReadOnlyList<string>? articles,
 923        string? search,
 924        CancellationToken cancellationToken)
 925    {
 0926        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 0927        {
 0928            var sql = @"
 0929SELECT l.Id, COALESCE(l.OzonClientId, 0), l.Article, l.OfferId, l.OzonProductId,
 0930       l.OzonWarehouseId, l.OzonWarehouseName,
 0931       COALESCE(l.OzonQtyAfter, l.OzonQty) AS EffectiveQty,
 0932       l.OzonCleanupStatus
 0933FROM BitrixStockSyncReportLines l
 0934WHERE l.ReportId = @reportId
 0935  AND COALESCE(l.OzonQtyAfter, l.OzonQty) > 0
 0936  AND l.OzonProductId IS NOT NULL AND l.OzonProductId > 0
 0937  AND l.OfferId IS NOT NULL AND l.OfferId != ''";
 0938
 0939            if (articles is { Count: > 0 })
 0940            {
 941                var artIn = string.Join(", ", articles.Select((_, i) => $"@art{i}"));
 0942                sql += $" AND l.Article IN ({artIn})";
 0943            }
 0944
 0945            if (!string.IsNullOrWhiteSpace(search))
 0946                sql += " AND l.Article LIKE @search";
 0947
 0948            sql += " ORDER BY l.OzonClientId, l.Article, l.OzonWarehouseId";
 0949
 0950            var cmd = connection.CreateCommand();
 0951            cmd.CommandText = sql;
 0952            AddParam(cmd, "@reportId", reportId.Trim());
 0953            if (articles is { Count: > 0 })
 0954            {
 0955                for (var i = 0; i < articles.Count; i++)
 0956                    AddParam(cmd, $"@art{i}", articles[i]);
 0957            }
 0958
 0959            if (!string.IsNullOrWhiteSpace(search))
 0960                AddParam(cmd, "@search", $"%{search.Trim()}%");
 0961
 0962            var list = new List<BitrixStockSyncCleanupReportLine>();
 0963            await using var reader = await cmd.ExecuteReaderAsync(ct);
 0964            while (await reader.ReadAsync(ct))
 0965            {
 0966                list.Add(new BitrixStockSyncCleanupReportLine
 0967                {
 0968                    LineId = reader.GetInt64(0),
 0969                    OzonClientId = reader.GetInt32(1),
 0970                    Article = reader.GetString(2),
 0971                    OfferId = reader.IsDBNull(3) ? null : reader.GetString(3),
 0972                    OzonProductId = reader.IsDBNull(4) ? null : reader.GetInt64(4),
 0973                    OzonWarehouseId = reader.GetInt64(5),
 0974                    OzonWarehouseName = reader.IsDBNull(6) ? string.Empty : reader.GetString(6),
 0975                    EffectiveQty = reader.GetInt32(7),
 0976                    OzonCleanupStatus = reader.IsDBNull(8) ? null : reader.GetString(8)
 0977                });
 0978            }
 0979
 0980            return list;
 0981        }, cancellationToken);
 0982    }
 983
 984    private async Task WriteCleanupLogAsync(
 985        ApplicationDbContext db,
 986        string reportId,
 987        string logJobId,
 988        BitrixStockSyncCleanupProductDto product,
 989        BitrixStockSyncCleanupWarehouseDto wh,
 990        bool ok,
 991        string? error,
 992        CancellationToken cancellationToken)
 993    {
 994        try
 995        {
 0996            await _articleLog.WriteAsync(db, reportId, logJobId, new BitrixStockSyncApplyArticleLogEntry
 0997            {
 0998                Article = product.Article ?? "",
 0999                OneCStore = wh.WarehouseName ?? "",
 01000                Client1 = new ApplyArticleClientEntry
 01001                {
 01002                    ClientId = product.OzonClientId,
 01003                    ClientName = product.OzonClientName,
 01004                    QtyBefore = wh.Present,
 01005                    QtyAfter = ok ? 0 : null,
 01006                    ActionCode = ok ? ApplyArticleActionCodes.CleanupZero : "FAILED",
 01007                    ActionLabel = ok ? $"{wh.Present} → 0" : error
 01008                },
 01009                Summary = ok
 01010                    ? $"⓪ {product.Article} ({wh.WarehouseName}): {wh.Present} → 0 [этап 3]"
 01011                    : $"✗ {product.Article} ({wh.WarehouseName}): {error}"
 01012            }, cancellationToken);
 01013        }
 01014        catch
 1015        {
 1016            // журнал не должен прерывать cleanup
 01017        }
 01018    }
 1019
 1020    private static async Task<bool> TryUpdateBatchAsync(
 1021        OzonApiDataManager apiManager,
 1022        List<StockItemForUpdate> stockPayload,
 1023        List<BitrixStockPushItemDto> batchItems,
 1024        Func<BitrixStockPushItemDto, string, Task> onFailed,
 1025        Func<BitrixStockPushItemDto, Task> onSuccess,
 1026        CancellationToken cancellationToken)
 1027    {
 1028        try
 1029        {
 01030            var result = await apiManager.UpdateProductStocks(stockPayload);
 01031            if (result?.Result == null || result.Result.Count == 0)
 01032                return false;
 1033
 1034            var byKey = batchItems.ToDictionary(i => $"{i.ProductId}_{i.WarehouseId}", i => i);
 01035            var allOk = true;
 01036            foreach (var row in result.Result)
 1037            {
 01038                cancellationToken.ThrowIfCancellationRequested();
 01039                var key = $"{row.ProductId}_{row.WarehouseId}";
 01040                if (!byKey.TryGetValue(key, out var item))
 1041                    continue;
 1042
 01043                if (row.Updated && (row.Errors == null || !row.Errors.Any()))
 01044                    await onSuccess(item);
 1045                else
 1046                {
 01047                    allOk = false;
 01048                    await onFailed(item, FormatStockErrors(row));
 1049                }
 1050            }
 1051
 01052            return allOk;
 1053        }
 01054        catch
 1055        {
 01056            return false;
 1057        }
 01058    }
 1059
 1060    private static string FormatStockErrors(UpdateStockResult? row)
 1061    {
 01062        if (row?.Errors != null && row.Errors.Any())
 01063            return string.Join("; ", row.Errors.Select(e => BitrixOzonStockErrorMapper.Map(e.Message ?? e.Code)));
 01064        return row?.Updated == true ? string.Empty : "Ozon не обновил остаток";
 1065    }
 1066
 1067    private static async Task<T> WithOpenConnectionAsync<T>(
 1068        ApplicationDbContext db,
 1069        Func<System.Data.Common.DbConnection, CancellationToken, Task<T>> action,
 1070        CancellationToken cancellationToken)
 1071    {
 01072        await db.Database.OpenConnectionAsync(cancellationToken);
 1073        try
 1074        {
 01075            return await action(db.Database.GetDbConnection(), cancellationToken);
 1076        }
 1077        finally
 1078        {
 01079            await db.Database.CloseConnectionAsync();
 1080        }
 01081    }
 1082
 1083    private static void AddParam(System.Data.Common.DbCommand cmd, string name, object? value)
 1084    {
 01085        var p = cmd.CreateParameter();
 01086        p.ParameterName = name;
 01087        p.Value = value ?? DBNull.Value;
 01088        cmd.Parameters.Add(p);
 01089    }
 1090}

Methods/Properties

.ctor(Microsoft.EntityFrameworkCore.IDbContextFactory`1<OzonRepositories.Context.ApplicationDbContext>,Services.DataServices.OzonClientServices,System.Net.Http.IHttpClientFactory,SlqStudio.Application.Services.EmailService.IEmailService,Microsoft.AspNetCore.SignalR.IHubContext`1<Services.SignalRServices.JobProgressHub>,Services.BitrixStockServices.BitrixStockSyncApplyArticleLogService,Microsoft.Extensions.Caching.Memory.IMemoryCache,Services.BitrixStockServices.BitrixStockActiveStageStore,Services.BitrixStockServices.IBitrixStockSyncReportPricePushService)
GetCleanupPreviewAsync(System.String,System.Boolean,System.Nullable`1<System.Int32>,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.Int32,System.Threading.CancellationToken)
ZeroWarehouseAsync()
RunCleanupAsync(System.String,Services.BitrixStockServices.BitrixStockSyncCleanupStartRequest,System.String,System.Threading.CancellationToken)
BuildPreviewOrRunAsync()
Log()
Progress()
Complete()
<BuildPreviewOrRunAsync()
<BuildPreviewOrRunAsync()
BuildProductDto(System.Collections.Generic.List`1<Services.BitrixStockServices.BitrixStockSyncCleanupReportLine>,OzonDomains.Models.OzonClient,System.Collections.Generic.IReadOnlyList`1<Services.BitrixStockServices.BitrixStockSyncCleanupWarehouseTarget>,System.Collections.Generic.IReadOnlySet`1<System.Int64>,System.Collections.Generic.IReadOnlyList`1<Services.BitrixStockServices.BitrixStockSyncCleanupFbsWarehouse>)
LoadFbsWarehousesBatchByClientAsync()
ResolveFbsWarehousesFromCache(System.Collections.Generic.IReadOnlyDictionary`2<System.Int32,System.Collections.Generic.Dictionary`2<System.String,System.Collections.Generic.List`1<Services.BitrixStockServices.BitrixStockSyncCleanupFbsWarehouse>>>,System.Int32,System.String)
LoadFbsWarehousesBatchAsync()
LoadFbsWarehousesAsync()
MergeOfferSkusFromStocks(Newtonsoft.Json.Linq.JObject,System.Collections.Generic.IDictionary`2<System.String,System.Collections.Generic.HashSet`1<System.String>>)
GroupFbsWarehousesBySku(System.Collections.Generic.IEnumerable`1<Services.BitrixStockServices.BitrixStockSyncCleanupFbsWarehouse>)
ParseFbsWarehouses(Newtonsoft.Json.Linq.JObject)
LoadReportStatusAsync()
LoadCleanupReportLinesAsync()
<LoadCleanupReportLinesAsync()
WriteCleanupLogAsync()
TryUpdateBatchAsync()
FormatStockErrors(Services.ApiServices.OzonApi.Filters.UpdateStockResult)
WithOpenConnectionAsync()
AddParam(System.Data.Common.DbCommand,System.String,System.Object)