< Summary

Information
Class: Services.BitrixStockServices.BitrixStockSyncReportService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncReportService.cs
Line coverage
1%
Covered lines: 19
Uncovered lines: 1793
Coverable lines: 1812
Total lines: 2634
Line coverage: 1%
Branch coverage
0%
Covered branches: 8
Total branches: 1000
Branch coverage: 0.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
BuildReportAsync()0%160021260%
Log()0%2040%
Progress()0%7280%
Complete()0%620%
BuildFilter(...)0%4260%
LoadOzonForBatchAsync()0%3660600%
GetReportAsync()0%702260%
<GetReportAsync()0%203061420%
ListReportsAsync()0%4260%
<ListReportsAsync()0%4970700%
FillOrphanAndPricePushAggregatesAsync()0%506220%
GetReportOneCStoresAsync()0%2040%
<GetReportOneCStoresAsync()0%7280%
GetReportMappingCombosAsync()0%2040%
<GetReportMappingCombosAsync()0%7280%
GetReportLogTextAsync()0%2040%
<GetReportLogTextAsync()0%156120%
GetArchivedPreviewAsync()0%2040%
<GetArchivedPreviewAsync()0%1806420%
BuildExceptionSkipLines(...)0%272160%
BuildProductLines(...)0%2352480%
BuildNotFoundLines(...)0%1190340%
LoadOrderReservesAsync()0%156120%
GetOrderReserveMapAsync()0%7280%
ResolveReserveStatusIdsAsync()0%2040%
LoadDefaultReserveStatusNamesAsync()0%620%
GetReserveStatusOptionsAsync()0%620%
IncrementSummary(...)0%1332360%
LoadProductStocksChunkedParallelAsync()0%210140%
<LoadProductStocksChunkedParallelAsync()100%210%
ApplySupplierPrefixesAsync()0%7280%
ParseMultiClientIds(...)100%210%
ParseMultiClientNames(...)100%210%
LoadArchiveStatusAsync()100%210%
<LoadArchiveStatusAsync()0%210140%
GetOrCreateWarehouseCacheAsync()0%4260%
WithOpenConnectionAsync()100%210%
PrepareReportStorageAsync()0%4260%
InsertReportLinesAsync()0%7280%
UpdatePromoStatusAsync()0%620%
GetSaleArticlesAsync()100%210%
NormalizeArticleForIndex(...)100%210%
UpdateSaleFlagPromoStatusAsync()0%2040%
FinalizeReportStorageAsync()0%2040%
AddParam(...)0%620%
PurgeOldCompletedReportsAsync()100%210%
<PurgeOldCompletedReportsAsync()0%210140%
RefreshReportOzonApplyCountsAsync()0%620%
RefreshReportOzonCleanupCountsAsync()0%620%
BuildLinesWhereSql(...)100%88100%
AddLineFilterParams(...)0%110100%
LoadPreviousPricesAsync()0%156120%
GetInitialApplyStatus(...)0%2040%
get_PricePushStatus()100%210%
get_PricePushError()100%210%
GetOzonPriceInfoAsync()0%702260%
UpdateOzonPriceSnapshotAsync()0%4260%
RecordPricePushAsync()0%2040%
RecordPricePushStatusAsync()0%2040%
GetOzonPricedArticlesAsync()0%620%
GetReportAggregatesAsync()0%620%
<GetReportAggregatesAsync()0%342180%
GetOzonPriceSnapshotsAsync()0%2040%
SaveOzonPriceSnapshotsAsync()0%420200%
EnrichOzonPriceItem(...)0%506220%
InsertOzonPriceSnapshotsAsync()0%4260%
GetReportPromosAsync()0%620%
<GetReportPromosAsync()0%7280%
GetReportPriceSettingsAsync()0%620%
<GetReportPriceSettingsAsync()0%4260%
GetArticleC1PriceAsync()0%620%
<GetArticleC1PriceAsync()0%156120%
GetArticleSnapshotAsync()0%620%
<GetArticleSnapshotAsync()0%210140%

File(s)

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

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Text;
 3using System.Text.Json;
 4using Hangfire;
 5using Microsoft.AspNetCore.SignalR;
 6using Microsoft.Extensions.Caching.Memory;
 7using Microsoft.Extensions.Http;
 8using Microsoft.EntityFrameworkCore;
 9using MySqlConnector;
 10using Newtonsoft.Json.Linq;
 11using OzonDomains;
 12using OzonDomains.Models;
 13using OzonDomains.Models.BitrixModels;
 14using OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix;
 15using Services.ApiServices.OzonApi;
 16using Services.ApiServices.OzonApi.Filters;
 17using Services.DataServices;
 18using Services.FiltersServices.DataFilterManagers;
 19using Services.HangfireService;
 20using Services.SignalRServices;
 21using OzonRepositories.Data;
 22using OzonRepositories.Context;
 23using SlqStudio.Application.Services.EmailService;
 24
 25namespace Services.BitrixStockServices;
 26
 27public class BitrixStockSyncReportService : IBitrixStockSyncReportService
 28{
 29    private const int PageSize = 500;
 30    private const int OzonStocksChunkSize = 500;
 31    private const int OzonStocksChunkParallelism = 4;
 32    private const int OzonWarehouseInfoParallelism = 10;
 033    private static readonly string[] FallbackReserveStatuses = ["Не указан", "Заказан реализатору"];
 34
 35    private readonly IWarehouseStockBitrixViewService _warehouseView;
 36    private readonly OzonClientServices _ozonClientServices;
 37    private readonly IHttpClientFactory _httpClientFactory;
 38    private readonly IEmailService? _emailService;
 39    private readonly WarehouseMappingDataServices _warehouseMappingDataServices;
 40    private readonly EtProducerRepository _etProducerRepository;
 41    private readonly IMemoryCache _memoryCache;
 42    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
 43    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 44    private readonly BitrixStockActiveStageStore _stageStore;
 45    private readonly IBitrixStockExceptionService _exceptions;
 46    private readonly IBitrixStockLiveStockComparer _liveStockComparer;
 47
 048    public BitrixStockSyncReportService(
 049        IWarehouseStockBitrixViewService warehouseView,
 050        OzonClientServices ozonClientServices,
 051        IHttpClientFactory httpClientFactory,
 052        IEmailService? emailService,
 053        WarehouseMappingDataServices warehouseMappingDataServices,
 054        EtProducerRepository etProducerRepository,
 055        IMemoryCache memoryCache,
 056        IDbContextFactory<ApplicationDbContext> dbFactory,
 057        IHubContext<JobProgressHub> jobProgressHub,
 058        BitrixStockActiveStageStore stageStore,
 059        IBitrixStockExceptionService exceptions,
 060        IBitrixStockLiveStockComparer liveStockComparer)
 61    {
 062        _warehouseView = warehouseView;
 063        _ozonClientServices = ozonClientServices;
 064        _httpClientFactory = httpClientFactory;
 065        _emailService = emailService;
 066        _warehouseMappingDataServices = warehouseMappingDataServices;
 067        _etProducerRepository = etProducerRepository;
 068        _memoryCache = memoryCache;
 069        _dbFactory = dbFactory;
 070        _jobProgressHub = jobProgressHub;
 071        _stageStore = stageStore;
 072        _exceptions = exceptions;
 073        _liveStockComparer = liveStockComparer;
 074    }
 75
 76    [Queue(HangfireQueueNames.Main)]
 77    public async Task BuildReportAsync(
 78        string reportId,
 79        BitrixStockSyncReportStartRequest request,
 80        string? progressJobId,
 81        CancellationToken cancellationToken = default)
 82    {
 083        var hubId = progressJobId?.Trim() ?? string.Empty;
 084        var hasHub = !string.IsNullOrWhiteSpace(hubId);
 085        var logLines = new List<string>();
 86        const int MaxLogLines = 2000;
 87
 88        async Task Log(string message)
 89        {
 090            if (logLines.Count < MaxLogLines) logLines.Add(message);
 091            if (hasHub)
 92            {
 093                await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken);
 94            }
 095        }
 96
 097        var lastProgressSentAt = DateTime.MinValue;
 98        async Task Progress(int current, int total, string text)
 99        {
 0100            if (!hasHub) return;
 0101            var now = DateTime.UtcNow;
 0102            if (current < total && (now - lastProgressSentAt).TotalMilliseconds < 250) return;
 0103            lastProgressSentAt = now;
 0104            await _jobProgressHub.Clients.Group(hubId)
 0105                .SendAsync("Progress", current, total, text ?? "", cancellationToken);
 0106        }
 107
 108        async Task Complete(bool ok, object? payload)
 109        {
 0110            if (hasHub)
 111            {
 0112                await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken);
 113            }
 0114        }
 115
 116        // В режиме цепочки stage-store владеет оркестратор.
 0117        if (!request.Chained)
 0118            _stageStore.Set(reportId, 1);
 119        try
 120        {
 0121            var orderedClientIds = request.GetOrderedClientIds();
 0122            if (!orderedClientIds.Any())
 123            {
 0124                await Log("Ошибка: не выбран клиент Ozon");
 0125                await Complete(false, "Не выбран клиент Ozon");
 0126                return;
 127            }
 128
 0129            var allClients = await _ozonClientServices.GetOzonClients();
 0130            var clients = orderedClientIds
 0131                .Select(id => allClients.FirstOrDefault(c => c.Id == id && c.ClientType == ClientType.OZON))
 132                .Where(c => c != null && !string.IsNullOrEmpty(c.DecryptClientId) && !string.IsNullOrEmpty(c.DecryptApiK
 0133                .ToList();
 134
 0135            if (!clients.Any())
 136            {
 0137                await Log("Ошибка: ни один из выбранных клиентов Ozon не найден или не настроен");
 0138                await Complete(false, "Клиент Ozon не найден");
 0139                return;
 140            }
 141
 0142            var storeTitles = request.SelectedStoreTitles?
 143                .Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToList()
 0144                ?? BitrixStockClearFilterTemplate.StoreTitles.ToList();
 145            var disabledMappingIds = request.DisabledWarehouseMappingIds?.Where(id => id > 0).ToHashSet() ?? new HashSet
 0146            var allowedBitrixStores = storeTitles.ToHashSet(StringComparer.OrdinalIgnoreCase);
 147
 0148            await Log($"Мультиклиентный отчёт: {clients.Count} клиент(ов). Изменения в Ozon не выполняются.");
 149            await Log($"Клиенты (приоритет): {string.Join(", ", clients.Select((c, i) => $"{i + 1}. {c!.Name}"))}");
 0150            await Log($"Склады 1С: {string.Join(", ", storeTitles)}");
 0151            if (!string.IsNullOrWhiteSpace(request.Article))
 0152                await Log($"Фильтр по артикулу: {request.Article}");
 0153            if (disabledMappingIds.Count > 0)
 0154                await Log($"Отключено связей: {disabledMappingIds.Count}");
 155
 0156            await using var reportDb = await _dbFactory.CreateDbContextAsync(cancellationToken);
 157            await PrepareReportStorageAsync(reportDb, reportId, request, clients[0]!, clients.Select(c => c!).ToList(), 
 158
 0159            var prevPrices = await LoadPreviousPricesAsync(reportDb, reportId, cancellationToken);
 0160            if (prevPrices.Count > 0)
 0161                await Log($"Предыдущий отчёт: цены по {prevPrices.Count} артикулам загружены для сравнения Δ C0.");
 162
 0163            var summary = new BitrixStockSyncReportSummaryDto();
 0164            var productsInReport = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0165            var logLines2 = new List<string>();
 0166            var clientPromoData = new List<(OzonDomains.Models.OzonClient Client, HashSet<string> OfferIds)>();
 0167            var allReportArticles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 168
 0169            var skipStockArticles = await _exceptions.GetSkipStockArticlesSetAsync(cancellationToken);
 0170            var skipPriceArticles = await _exceptions.GetSkipPriceArticlesSetAsync(cancellationToken);
 0171            var priceExceptionsMap = await _exceptions.GetClientPricesMapAsync(cancellationToken);
 0172            if (skipStockArticles.Count > 0)
 0173                await Log($"Справочник исключений: {skipStockArticles.Count} арт. с пропуском остатков.");
 0174            if (skipPriceArticles.Count > 0 || priceExceptionsMap.Count > 0)
 0175                await Log($"Справочник исключений цен: {skipPriceArticles.Count} глоб. пропуск, {priceExceptionsMap.Coun
 176
 0177            var reservedStatusIds = await ResolveReserveStatusIdsAsync(
 0178                reportDb,
 0179                request.StageSettings?.ReserveStatusIds,
 0180                cancellationToken);
 0181            if (reservedStatusIds.Count > 0)
 182            {
 0183                var reserveStatusNames = await reportDb.AppStatuses
 0184                    .Where(s => reservedStatusIds.Contains(s.Id))
 0185                    .Select(s => s.Name ?? "")
 0186                    .ToListAsync(cancellationToken);
 0187                await Log($"Резерв заказов: найдено {reservedStatusIds.Count} статус(а/ов) ({string.Join(", ", reserveSt
 188            }
 189            else
 190            {
 0191                await Log("Резерв заказов: выбранные статусы не найдены в БД — резерв не применяется.");
 192            }
 193
 0194            var reserveMap = await LoadOrderReservesAsync(reportDb, reservedStatusIds, cancellationToken);
 0195            if (reserveMap.Count > 0)
 0196                await Log($"Резервы заказов: {reserveMap.Count} арт. с ненулевым резервом.");
 197
 0198            for (var clientIndex = 0; clientIndex < clients.Count; clientIndex++)
 199            {
 0200                var client = clients[clientIndex]!;
 0201                await Log($"--- Клиент {clientIndex + 1}/{clients.Count}: {client.Name} ---");
 202
 0203                var filter = BuildFilter(request, client.Id);
 0204                var clientMappings = await _warehouseMappingDataServices.GetByClientIdAsync(client.Id);
 0205                var linkageMappings = BitrixOzonStockLinkage.GetStockLinkageMappings(clientMappings);
 0206                await Log($"Связей складов Ozon↔1С: {linkageMappings.Count}");
 207
 0208                var mappedWarehouseIds = linkageMappings
 209                    .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out _))
 210                    .Select(m => long.Parse(m.OzonWarehouseId!.Trim()))
 0211                    .ToHashSet();
 212
 0213                var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 0214                var warehouseList = await GetOrCreateWarehouseCacheAsync(apiManager, client.DecryptClientId ?? string.Em
 215
 0216                var clientOfferIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 217
 218                // --- Snapshot loop for this client ---
 0219                filter.Page = 1;
 0220                filter.PageSize = PageSize;
 0221                filter.DisablePagination = false;
 0222                var totalInSnapshot = 0;
 0223                var processed = 0;
 224
 0225                while (true)
 226                {
 0227                    cancellationToken.ThrowIfCancellationRequested();
 0228                    await Progress(processed, Math.Max(totalInSnapshot, 1), $"[{client.Name}] Загрузка snapshot, стр. {f
 229
 0230                    var page = await _warehouseView.GetRemainingStockAsync(filter);
 0231                    if (filter.Page == 1)
 232                    {
 0233                        totalInSnapshot = page.TotalCount;
 0234                        if (totalInSnapshot > 5000)
 0235                            await Log($"В snapshot {totalInSnapshot} артикулов — загрузка Ozon пакетами по {OzonStocksCh
 236                    }
 237
 0238                    if (page.Items.Count == 0) break;
 239
 0240                    var batch = page.Items.ToList();
 241
 0242                    await ApplySupplierPrefixesAsync(batch, cancellationToken);
 0243                    await Log($"[{client.Name}] Стр. {filter.Page}: {batch.Count} товаров, загрузка Ozon…");
 0244                    var (foundInOzonApi, fbsFallbackArticles, fbsUnknownStockArticles) = await LoadOzonForBatchAsync(bat
 0245                        allowedBitrixStores, Log, cancellationToken);
 0246                    if (fbsFallbackArticles.Count > 0)
 0247                        await Log($"[{client.Name}] FBS-схема (SDS): {fbsFallbackArticles.Count} арт. обработаны через F
 0248                    if (fbsUnknownStockArticles.Count > 0)
 0249                        await Log($"[{client.Name}] FBS-fallback без разбивки по складам: {fbsUnknownStockArticles.Count
 0250                    var notFoundInBatch = batch
 0251                        .Where(i => foundInOzonApi.TryGetValue(i.ArticleWithKey, out var inf) && !inf.Found)
 252                        .Select(i => $"{i.Article}(key={i.ArticleWithKey})")
 0253                        .ToList();
 0254                    if (notFoundInBatch.Count > 0)
 0255                        await Log($"[{client.Name}] Не найдено в Ozon ({notFoundInBatch.Count}): {string.Join(", ", notF
 256
 0257                    var pageLineItems = new List<(string ProductKey, List<BitrixStockSyncReportWarehouseLineDto> Lines)>
 0258                    foreach (var item in batch)
 259                    {
 260                        // Проверка справочника исключений по остаткам
 0261                        var stockRule = !string.IsNullOrEmpty(item.Article)
 0262                            ? await _exceptions.FindStockRuleAsync(item.Article, client.Id, cancellationToken)
 0263                            : null;
 0264                        if (!string.IsNullOrEmpty(item.Article) &&
 0265                            BitrixStockExceptionReportAnnotator.HasStockSkip(item.Article, skipStockArticles, stockRule)
 266                        {
 0267                            var exceptionNote = BitrixStockExceptionReportAnnotator.FormatFullNote(
 0268                                item.Article, client.Id, skipStockArticles, skipPriceArticles, priceExceptionsMap, stock
 0269                            if (skipStockArticles.Contains(item.Article))
 0270                                await Log($"[{item.Article}] Пропуск остатков (глобальный справочник). {exceptionNote}")
 271                            else
 0272                                await Log($"[{item.Article}] Пропуск остатков (правило). {exceptionNote}");
 273
 0274                            var skipLines = BuildExceptionSkipLines(item, allowedBitrixStores, exceptionNote);
 0275                            foreach (var l in skipLines) { l.OzonClientId = client.Id; l.OzonClientName = client.Name ??
 0276                            summary.SkipCount += skipLines.Count;
 0277                            if (skipLines.Count > 0) pageLineItems.Add((item.ArticleWithKey, skipLines));
 278                            // Снимок цен Ozon нужен и при пропуске остатков — этап 4 решает отдельно по правилам цен.
 0279                            if (!string.IsNullOrEmpty(item.ArticleWithKey))
 0280                                clientOfferIds.Add(item.ArticleWithKey);
 0281                            processed++;
 0282                            continue;
 283                        }
 284
 0285                        var lines = BuildProductLines(item, client, linkageMappings, mappedWarehouseIds, disabledMapping
 0286                        if (lines.Count == 0)
 287                        {
 0288                            var ozonInfo = foundInOzonApi.GetValueOrDefault(item.ArticleWithKey, OzonApiFoundInfo.NotFou
 0289                            var notFoundLines = BuildNotFoundLines(item, allowedBitrixStores, ozonInfo);
 0290                            foreach (var nfl in notFoundLines) IncrementSummary(summary, nfl.Action);
 0291                            lines = notFoundLines;
 292                        }
 293                        // Проставляем клиента и подписи ценовых исключений на каждой строке
 0294                        foreach (var l in lines) { l.OzonClientId = client.Id; l.OzonClientName = client.Name ?? $"ID {c
 0295                        BitrixStockExceptionReportAnnotator.ApplyPriceNotesToLines(
 0296                            lines, client.Id, skipPriceArticles, priceExceptionsMap);
 0297                        if (lines.Count > 0)
 298                        {
 0299                            pageLineItems.Add((item.ArticleWithKey, lines));
 300                            foreach (var l in lines.Where(l => l.ReservedQty > 0))
 0301                                await Log($"[{client.Name}] [{item.Article}] {l.OneCStore}: 1С {l.OneCQty + l.ReservedQt
 302                        }
 0303                        processed++;
 0304                    }
 305
 306                    var allPageLines = pageLineItems.SelectMany(x => x.Lines).ToList();
 0307                    var offerIdsForArchive = allPageLines
 308                        .Where(l => !string.IsNullOrEmpty(l.OfferId)).Select(l => l.OfferId!)
 0309                        .Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
 0310                    if (offerIdsForArchive.Length > 0)
 311                    {
 0312                        var archiveMap = await LoadArchiveStatusAsync(apiManager, offerIdsForArchive, cancellationToken)
 0313                        foreach (var line in allPageLines)
 0314                            if (!string.IsNullOrEmpty(line.OfferId) && archiveMap.TryGetValue(line.OfferId, out var st))
 0315                            { line.IsArchived = st.IsArchived; line.IsAutoArchived = st.IsAutoArchived; }
 316                    }
 317
 0318                    foreach (var (productKey, lines) in pageLineItems)
 319                    {
 0320                        foreach (var line in lines)
 321                        {
 0322                            if (!string.IsNullOrEmpty(line.Article) && prevPrices.TryGetValue(line.Article, out var prev
 0323                                line.PrevOneCPrice = prev;
 0324                            if (!string.IsNullOrEmpty(line.OfferId))
 0325                                clientOfferIds.Add(line.OfferId);
 0326                            if (!string.IsNullOrEmpty(line.Article))
 0327                                allReportArticles.Add(line.Article);
 328                        }
 0329                        productsInReport.Add($"{client.Id}:{productKey}");
 0330                        await InsertReportLinesAsync(reportDb, reportId, lines, cancellationToken);
 331                    }
 332
 0333                    if (processed >= totalInSnapshot || page.Items.Count < filter.PageSize) break;
 0334                    filter.Page++;
 0335                }
 336
 0337                summary.ProductsScanned += processed;
 0338                await Log($"[{client.Name}] Проверено: {processed} товаров");
 339
 0340                if (clientOfferIds.Count > 0)
 341                {
 0342                    await Log($"[{client.Name}] Сохранение цен Ozon для {clientOfferIds.Count} артикулов…");
 343                    try
 344                    {
 0345                        await SaveOzonPriceSnapshotsAsync(reportDb, reportId, apiManager, client, clientOfferIds, cancel
 0346                        await Log($"[{client.Name}] Цены Ozon сохранены.");
 0347                    }
 0348                    catch (Exception ex)
 349                    {
 0350                        await Log($"[{client.Name}] Ошибка при сохранении цен Ozon: {ex.Message}");
 351                    }
 0352                    clientPromoData.Add((client, new HashSet<string>(clientOfferIds, StringComparer.OrdinalIgnoreCase)))
 353                }
 0354            } // end foreach client
 355
 356            // Проверка участия товаров в распродаже через WarehouseStockSnapshot.SaleFlag
 0357            var allArticles = allReportArticles;
 358
 0359            if (allArticles.Count > 0)
 360            {
 361                try
 362                {
 0363                    await Log($"Проверка распродажи (SaleFlag) для {allArticles.Count} артикулов…");
 0364                    var saleArticles = await GetSaleArticlesAsync(reportDb, allArticles, cancellationToken);
 0365                    if (saleArticles.Count > 0)
 366                    {
 0367                        await Log($"В распродаже участвует {saleArticles.Count} артикулов.");
 0368                        await UpdateSaleFlagPromoStatusAsync(reportDb, reportId, saleArticles, Log, cancellationToken);
 369                    }
 370                    else
 371                    {
 0372                        await Log("Ни один артикул не участвует в распродаже.");
 373                    }
 0374                }
 0375                catch (Exception ex)
 376                {
 0377                    await Log($"Предупреждение: ошибка проверки SaleFlag: {ex.Message}");
 378                }
 379            }
 380
 0381            summary.ProductsInReport = productsInReport.Count;
 0382            summary.WarehouseLinesTotal = summary.MatchCount + summary.PushCount + summary.DecreaseCount + summary.Clear
 0383                                          summary.SkipCount + summary.NotFoundCount;
 384            // Сверка с живыми остатками 1С: только сравнение, количества отчёта не меняются (флаг BitrixStock:LiveStock
 0385            await _liveStockComparer.CompareAsync(reportId, Log, cancellationToken);
 0386            await Log("Сохранение результатов отчёта…");
 0387            await FinalizeReportStorageAsync(reportDb, reportId, summary, string.Join("\n", logLines), null, cancellatio
 0388            await Log("Результаты сохранены.");
 389
 390            // Сохраняем настройки % ценообразования из пресета вместе с отчётом
 0391            if (request.StageSettings != null)
 392            {
 0393                var priceJson = System.Text.Json.JsonSerializer.Serialize(new
 0394                {
 0395                    NoPromoMarkupPercent = request.StageSettings.NoPromoMarkupPercent,
 0396                    PromoMarkupPercent   = request.StageSettings.PromoMarkupPercent
 0397                });
 398                try
 399                {
 0400                    reportDb.Database.SetCommandTimeout(30);
 0401                    await reportDb.Database.ExecuteSqlRawAsync(
 0402                        "UPDATE BitrixStockSyncReports SET PriceSettingsJson={0} WHERE ReportId={1}",
 0403                        EfSqlRawParameterGuard.Materialize(priceJson, reportId), cancellationToken);
 0404                }
 0405                catch (Exception ex)
 406                {
 0407                    await Log($"Предупреждение: не удалось сохранить настройки цен: {ex.Message}");
 408                }
 409            }
 410
 0411            try { await PurgeOldCompletedReportsAsync(reportDb, keepCount: 200, cancellationToken); }
 0412            catch (Exception ex) { await Log($"Purge old reports failed: {ex.Message}"); }
 413
 0414            await Progress(summary.ProductsScanned, Math.Max(summary.ProductsScanned, 1), "Формирование отчёта завершено
 0415            await Log(
 0416                $"Итого: {productsInReport.Count} товаров, {summary.WarehouseLinesTotal} строк. " +
 0417                $"↑{summary.PushCount} ↓{summary.DecreaseCount} ⊘{summary.ClearCount} ✓{summary.MatchCount} ?{summary.No
 418
 0419            await Complete(true, new { reportId, success = true });
 420
 421            // Цепочкой этапов (2→5) управляет BitrixStockSyncPipelineOrchestrator.
 0422        }
 0423        catch (Exception ex)
 424        {
 425            try
 426            {
 0427                await using var failDb = await _dbFactory.CreateDbContextAsync(cancellationToken);
 0428                await FinalizeReportStorageAsync(failDb, reportId, null, string.Join("\n", logLines), ex.Message, cancel
 0429            }
 0430            catch
 431            {
 432                // ignore storage errors on failure path
 0433            }
 0434            await Log($"Ошибка: {ex.Message}");
 0435            await Complete(false, ex.Message);
 0436            throw;
 0437        }
 438        finally
 439        {
 0440            if (!request.Chained)
 0441                _stageStore.Clear(reportId);
 442        }
 0443    }
 444
 445    private static RemainingStockFilter BuildFilter(BitrixStockSyncReportStartRequest request, int clientId)
 446    {
 0447        var stores = request.SelectedStoreTitles is { Count: > 0 }
 0448            ? request.SelectedStoreTitles
 0449            : BitrixStockClearFilterTemplate.StoreTitles.ToList();
 450
 0451        var filter = new RemainingStockFilter
 0452        {
 0453            SelectedClientId = clientId,
 0454            LoadOzonWarehouses = true,
 0455            SelectedStoreTitles = stores
 0456                .Where(s => !string.IsNullOrWhiteSpace(s))
 0457                .Select(s => s.Trim())
 0458                .Distinct(StringComparer.OrdinalIgnoreCase)
 0459                .ToList(),
 0460            Page = 1,
 0461            PageSize = PageSize,
 0462            DisablePagination = false
 0463        };
 464
 0465        if (!string.IsNullOrWhiteSpace(request.Article))
 466        {
 0467            filter.Article = request.Article.Trim();
 468        }
 469
 0470        return filter;
 471    }
 472
 473    private async Task<(Dictionary<string, OzonApiFoundInfo> Found, List<string> FbsFallbackArticles,
 474        List<string> FbsUnknownStockArticles)> LoadOzonForBatchAsync(
 475        List<RemainingStockBitrix> items,
 476        OzonClient client,
 477        OzonApiDataManager apiManager,
 478        List<WarehouseOzon> warehouseList,
 479        List<WarehouseMapping> clientMappings,
 480        HashSet<string> allowedBitrixStores,
 481        Func<string, Task> log,
 482        CancellationToken cancellationToken)
 483    {
 0484        var foundInOzonApi = new Dictionary<string, OzonApiFoundInfo>(StringComparer.OrdinalIgnoreCase);
 0485        var fbsFallbackArticles = new List<string>();
 0486        var fbsUnknownStockArticles = new List<string>();
 0487        foreach (var item in items)
 488        {
 0489            if (!string.IsNullOrWhiteSpace(item.ArticleWithKey))
 490            {
 0491                foundInOzonApi[item.ArticleWithKey] = OzonApiFoundInfo.NotFound;
 492            }
 493        }
 494
 495        // Включаем оба варианта: "0035310SX=SLX" и "0035310SX" — Ozon может хранить любой из них как offer_id
 0496        var articles = items
 497            .SelectMany(x => BitrixOzonStockLinkage.BuildOfferIdCandidates(x.ArticleWithKey, x.Article))
 0498            .Distinct(StringComparer.OrdinalIgnoreCase)
 0499            .ToArray();
 500
 0501        if (articles.Length == 0)
 502        {
 0503            return (foundInOzonApi, fbsFallbackArticles, fbsUnknownStockArticles);
 504        }
 505
 0506        var bitrixNames = allowedBitrixStores.ToList();
 0507        if (bitrixNames.Count > 0)
 508        {
 0509            await BitrixWarehouseMappingEnsure.EnsureAsync(
 0510                _warehouseMappingDataServices,
 0511                client,
 0512                bitrixNames,
 0513                warehouseList);
 514        }
 515
 0516        var allStocks = await LoadProductStocksChunkedParallelAsync(apiManager, articles, cancellationToken);
 0517        if (allStocks?["items"] == null)
 518        {
 0519            return (foundInOzonApi, fbsFallbackArticles, fbsUnknownStockArticles);
 520        }
 521
 0522        var warehouseInfoJobs = new List<(RemainingStockBitrix Item, JObject ItemStocks, long StocksProductId)>();
 0523        foreach (var item in items)
 524        {
 0525            cancellationToken.ThrowIfCancellationRequested();
 0526            var itemStocks = BitrixOzonStockLinkage.FindStocksForArticle(allStocks, item);
 0527            if (itemStocks != null)
 528            {
 0529                var (fbsTotal, rfbsTotal, fboTotal) = BitrixOzonStockLinkage.ExtractStocksByType(itemStocks);
 0530                var productId = BitrixOzonStockLinkage.ExtractProductIdFromStocks(itemStocks);
 0531                var foundOfferId = itemStocks["items"]?[0]?["offer_id"]?.ToString();
 0532                foundInOzonApi[item.ArticleWithKey] = new OzonApiFoundInfo(true, fbsTotal, rfbsTotal, fboTotal, foundOff
 0533                warehouseInfoJobs.Add((item, itemStocks, productId));
 534            }
 535        }
 536
 0537        var notFoundNullPrefix = items
 0538            .Where(i => !string.IsNullOrWhiteSpace(i.Article) &&
 0539                        string.IsNullOrWhiteSpace(i.MarketPrefix) &&
 0540                        foundInOzonApi.TryGetValue(i.ArticleWithKey, out var inf) && !inf.Found)
 0541            .ToList();
 0542        if (notFoundNullPrefix.Count > 0)
 543        {
 544            await log($"[{client.Name}] Не найдено без префикса ({notFoundNullPrefix.Count}): {string.Join(", ", notFoun
 545        }
 546
 547        // Проверка "не на продаже": для NotFound товаров спрашиваем /v3/product/info/list —
 548        // если товар там есть, значит он в каталоге, но убран из продажи / архив.
 0549        var stillNotFound = items
 0550            .Where(i => foundInOzonApi.TryGetValue(i.ArticleWithKey, out var inf) && !inf.Found)
 0551            .ToList();
 0552        if (stillNotFound.Count > 0)
 553        {
 0554            var offerIdCandidates = stillNotFound
 555                .SelectMany(i => BitrixOzonStockLinkage.BuildOfferIdCandidates(i.ArticleWithKey, i.Article))
 0556                .Distinct(StringComparer.OrdinalIgnoreCase)
 0557                .ToArray();
 558
 559            const int infoChunkSize = 100;
 0560            var infoChunks = offerIdCandidates
 561                .Select((id, idx) => new { id, idx })
 562                .GroupBy(x => x.idx / infoChunkSize)
 563                .Select(g => g.Select(x => x.id).ToArray())
 0564                .ToList();
 565
 0566            var catalogOfferIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0567            var catalogProductIds = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
 0568            foreach (var chunk in infoChunks)
 569            {
 570                try
 571                {
 0572                    var infoResp = await apiManager.GetProductInfoListAsync(offerIds: chunk).ConfigureAwait(false);
 0573                    if (infoResp?["items"] is Newtonsoft.Json.Linq.JArray infoItems)
 574                    {
 0575                        foreach (var infoItem in infoItems)
 576                        {
 0577                            var oid = infoItem["offer_id"]?.ToString();
 0578                            if (!string.IsNullOrWhiteSpace(oid))
 579                            {
 0580                                catalogOfferIds.Add(oid);
 0581                                var pid = infoItem["id"]?.Value<long>() ?? 0;
 0582                                if (pid > 0) catalogProductIds[oid] = pid;
 583                            }
 584                        }
 585                    }
 0586                }
 0587                catch (Exception ex)
 588                {
 0589                    await log($"[{client.Name}] GetProductInfoList chunk error: {ex.Message}");
 590                }
 591            }
 592
 0593            var notForSaleCount = 0;
 0594            foreach (var item in stillNotFound)
 595            {
 0596                var candidates = BitrixOzonStockLinkage.BuildOfferIdCandidates(item.ArticleWithKey, item.Article);
 0597                var matchedOfferId = candidates.FirstOrDefault(c => catalogOfferIds.Contains(c));
 0598                if (matchedOfferId != null)
 599                {
 0600                    catalogProductIds.TryGetValue(matchedOfferId, out var pid);
 0601                    foundInOzonApi[item.ArticleWithKey] = new OzonApiFoundInfo(false, 0, 0, 0, matchedOfferId, pid, NotF
 0602                    notForSaleCount++;
 603                }
 604            }
 0605            if (notForSaleCount > 0)
 0606                await log($"[{client.Name}] Убраны из продажи (есть в каталоге, нет в stocks): {notForSaleCount} арт.");
 0607        }
 608
 0609        if (warehouseInfoJobs.Count > 0)
 610        {
 611            // Один батчевый API-вызов вместо N параллельных (100× меньше запросов к Ozon)
 0612            var inputForBatch = warehouseInfoJobs
 613                .Select(j => (j.Item.ArticleWithKey, j.ItemStocks))
 0614                .ToList();
 0615            var batchedInfo = await apiManager.GetWarehouseInfoBatchedAsync(inputForBatch, warehouseList);
 616
 0617            foreach (var job in warehouseInfoJobs)
 618            {
 0619                batchedInfo.TryGetValue(job.Item.ArticleWithKey, out var warehouseInfo);
 0620                var ozonInfo = foundInOzonApi.GetValueOrDefault(job.Item.ArticleWithKey, OzonApiFoundInfo.NotFound);
 0621                var isFbsFallback = BitrixOzonStockLinkage.ApplyMappedStocks(
 0622                    job.Item,
 0623                    client,
 0624                    warehouseInfo,
 0625                    clientMappings,
 0626                    warehouseList,
 0627                    allowedBitrixStores,
 0628                    job.Item.ArticleWithKey,
 0629                    job.StocksProductId,
 0630                    ozonInfo);
 0631                if (isFbsFallback)
 632                {
 0633                    fbsFallbackArticles.Add(job.Item.ArticleWithKey);
 634
 635                    // Суммарный FBS-остаток при нескольких связанных складах разложить нельзя —
 636                    // строки идут пропуском, накат по товару не выполняется. Показываем это в логе.
 637                    if (job.Item.OzonStores.Any(s => s.IsFbsStockUnknown))
 0638                        fbsUnknownStockArticles.Add(job.Item.ArticleWithKey);
 639                }
 640            }
 641        }
 642
 0643        return (foundInOzonApi, fbsFallbackArticles, fbsUnknownStockArticles);
 0644    }
 645
 646    public async Task<BitrixStockSyncReportDto?> GetReportAsync(
 647        string reportId,
 648        BitrixStockSyncReportQuery query,
 649        CancellationToken cancellationToken = default)
 650    {
 0651        if (string.IsNullOrWhiteSpace(reportId))
 652        {
 0653            return null;
 654        }
 655
 0656        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 657
 0658        var page = Math.Max(1, query.Page);
 0659        var pageSize = Math.Clamp(query.PageSize <= 0 ? 100 : query.PageSize, 20, 500);
 0660        var articleFilter = string.IsNullOrWhiteSpace(query.Article) ? null : query.Article.Trim();
 661        // Actions: приоритет над Action (legacy)
 662        var actionsFilter = query.Actions?.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.Trim()).Distinct(Stri
 0663        if ((actionsFilter == null || actionsFilter.Count == 0) && !string.IsNullOrWhiteSpace(query.Action))
 0664            actionsFilter = new List<string> { query.Action.Trim() };
 0665        var ozonWarehouseFilter = string.IsNullOrWhiteSpace(query.OzonWarehouse) ? null : query.OzonWarehouse.Trim();
 0666        var oneCStores = query.OneCStores?
 667            .Where(s => !string.IsNullOrWhiteSpace(s))
 668            .Select(s => s.Trim())
 0669            .Distinct(StringComparer.OrdinalIgnoreCase)
 0670            .ToList() ?? new List<string>();
 671        var clientIds = query.ClientIds?.Where(id => id > 0).ToList() ?? new List<int>();
 672
 0673        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 0674        {
 0675        var metaCmd = connection.CreateCommand();
 0676        metaCmd.CommandText = @"SELECT ReportId, GeneratedAtUtc, CompletedAtUtc, OzonClientId, OzonClientName, Status, W
 0677SelectedStoresJson, ProductsScanned, ProductsInReport, WarehouseLinesTotal, MatchCount, PushCount, DecreaseCount, ClearC
 0678OzonAppliedSuccessCount, OzonApplyFailedCount, OzonApplyLastAtUtc,
 0679COALESCE(OzonCleanupSuccessCount, 0), COALESCE(OzonCleanupFailedCount, 0), OzonCleanupLastAtUtc,
 0680COALESCE(MultiClientIdsJson, '[]') AS MultiClientIdsJson
 0681FROM BitrixStockSyncReports
 0682WHERE ReportId = @reportId LIMIT 1;";
 0683        AddParam(metaCmd, "@reportId", reportId.Trim());
 0684
 0685        await using var metaReader = await metaCmd.ExecuteReaderAsync(ct);
 0686        if (!await metaReader.ReadAsync(ct))
 0687        {
 0688            return null;
 0689        }
 0690
 0691        var report = new BitrixStockSyncReportDto
 0692        {
 0693            ReportId = metaReader.GetString(0),
 0694            GeneratedAtUtc = metaReader.GetDateTime(1),
 0695            CompletedAtUtc = metaReader.IsDBNull(2) ? null : metaReader.GetDateTime(2),
 0696            OzonClientId = metaReader.GetInt32(3),
 0697            OzonClientName = metaReader.GetString(4),
 0698            Status = metaReader.IsDBNull(5) ? string.Empty : metaReader.GetString(5),
 0699            DryRun = true,
 0700            Warning = metaReader.IsDBNull(6) ? null : metaReader.GetString(6),
 0701            LogLines = (metaReader.IsDBNull(7) ? string.Empty : metaReader.GetString(7))
 0702                .Split('\n', StringSplitOptions.RemoveEmptyEntries)
 0703                .ToList(),
 0704            SelectedStoresJson = metaReader.IsDBNull(8) ? null : metaReader.GetString(8),
 0705            Summary = new BitrixStockSyncReportSummaryDto
 0706            {
 0707                ProductsScanned = metaReader.IsDBNull(9) ? 0 : metaReader.GetInt32(9),
 0708                ProductsInReport = metaReader.IsDBNull(10) ? 0 : metaReader.GetInt32(10),
 0709                WarehouseLinesTotal = metaReader.IsDBNull(11) ? 0 : metaReader.GetInt32(11),
 0710                MatchCount = metaReader.IsDBNull(12) ? 0 : metaReader.GetInt32(12),
 0711                PushCount = metaReader.IsDBNull(13) ? 0 : metaReader.GetInt32(13),
 0712                DecreaseCount = metaReader.IsDBNull(14) ? 0 : metaReader.GetInt32(14),
 0713                ClearCount = metaReader.IsDBNull(15) ? 0 : metaReader.GetInt32(15),
 0714                SkipCount = metaReader.IsDBNull(16) ? 0 : metaReader.GetInt32(16),
 0715                NotFoundCount = metaReader.IsDBNull(17) ? 0 : metaReader.GetInt32(17)
 0716            },
 0717            OzonAppliedSuccessCount = metaReader.IsDBNull(18) ? 0 : metaReader.GetInt32(18),
 0718            OzonApplyFailedCount = metaReader.IsDBNull(19) ? 0 : metaReader.GetInt32(19),
 0719            OzonApplyLastAtUtc = metaReader.IsDBNull(20) ? null : metaReader.GetDateTime(20),
 0720            OzonCleanupSuccessCount = metaReader.IsDBNull(21) ? 0 : metaReader.GetInt32(21),
 0721            OzonCleanupFailedCount = metaReader.IsDBNull(22) ? 0 : metaReader.GetInt32(22),
 0722            OzonCleanupLastAtUtc = metaReader.IsDBNull(23) ? null : metaReader.GetDateTime(23)
 0723        };
 0724        // MultiClientIds — поддерживаем старый формат [1,2] и новый [{id,name},...]
 0725        var multiJson = metaReader.IsDBNull(24) ? "[]" : metaReader.GetString(24);
 0726        report.MultiClientIds = ParseMultiClientIds(multiJson);
 0727        if (!report.MultiClientIds.Any()) report.MultiClientIds = new List<int> { report.OzonClientId };
 0728        await metaReader.CloseAsync();
 0729
 0730        // Имена клиентов для мультиклиентного отчёта
 0731        if (report.MultiClientIds.Count > 1)
 0732        {
 0733            var clientNamesCmd = connection.CreateCommand();
 0734            clientNamesCmd.CommandText = $"SELECT DISTINCT OzonClientId, OzonClientName FROM BitrixStockSyncReportLines 
 0735            AddParam(clientNamesCmd, "@rid", report.ReportId);
 0736            await using var cnReader = await clientNamesCmd.ExecuteReaderAsync(ct);
 0737            while (await cnReader.ReadAsync(ct))
 0738                report.MultiClientNames[cnReader.GetInt32(0)] = cnReader.IsDBNull(1) ? "" : cnReader.GetString(1);
 0739        }
 0740
 0741        report.ArticleFilter = articleFilter;
 0742        report.OneCStoresFilter = oneCStores.Count > 0 ? oneCStores : null;
 0743        report.OzonWarehouseFilter = ozonWarehouseFilter;
 0744        report.ActionFilter = actionsFilter is { Count: > 0 } ? string.Join(",", actionsFilter) : null;
 0745        report.OnlyOzonAppliedSuccess = query.OnlyOzonAppliedSuccess;
 0746        report.OnlyOzonAppliedFailed = query.OnlyOzonAppliedFailed;
 0747        report.OnlyArchived = query.OnlyArchived;
 0748
 0749        var pendingCmd = connection.CreateCommand();
 0750        pendingCmd.CommandText = @"SELECT COUNT(*) FROM BitrixStockSyncReportLines
 0751WHERE ReportId = @reportId
 0752AND Action IN ('Push','Decrease','Clear')
 0753AND (OzonApplyStatus IS NULL OR OzonApplyStatus = @pending);";
 0754        AddParam(pendingCmd, "@reportId", report.ReportId);
 0755        AddParam(pendingCmd, "@pending", BitrixStockSyncReportApplyStatuses.Pending);
 0756        report.OzonApplyPendingCount = Convert.ToInt32(await pendingCmd.ExecuteScalarAsync(ct) ?? 0);
 0757
 0758        var archiveCountCmd = connection.CreateCommand();
 0759        archiveCountCmd.CommandText = "SELECT COUNT(*), SUM(IsAutoArchived) FROM BitrixStockSyncReportLines WHERE Report
 0760        AddParam(archiveCountCmd, "@reportId", report.ReportId);
 0761        await using var archiveCountReader = await archiveCountCmd.ExecuteReaderAsync(ct);
 0762        if (await archiveCountReader.ReadAsync(ct))
 0763        {
 0764            report.ArchivedCount = archiveCountReader.IsDBNull(0) ? 0 : Convert.ToInt32(archiveCountReader.GetValue(0));
 0765            report.AutoArchivedCount = archiveCountReader.IsDBNull(1) ? 0 : Convert.ToInt32(archiveCountReader.GetValue(
 0766        }
 0767        await archiveCountReader.CloseAsync();
 0768
 0769        var whereSql = BuildLinesWhereSql(oneCStores, clientIds, actionsFilter);
 0770
 0771        var countCmd = connection.CreateCommand();
 0772        countCmd.CommandText = "SELECT COUNT(*) FROM BitrixStockSyncReportLines" + whereSql;
 0773        AddLineFilterParams(countCmd, report.ReportId, articleFilter, ozonWarehouseFilter, oneCStores, actionsFilter, qu
 0774        var totalRows = Convert.ToInt32(await countCmd.ExecuteScalarAsync(ct) ?? 0);
 0775
 0776        report.TotalRows = totalRows;
 0777        report.Page = page;
 0778        report.PageSize = pageSize;
 0779        report.TotalPages = totalRows == 0 ? 1 : (int)Math.Ceiling(totalRows / (double)pageSize);
 0780        var rowsPage = page;
 0781        if (rowsPage > report.TotalPages) rowsPage = report.TotalPages;
 0782
 0783        var rowsCmd = connection.CreateCommand();
 0784        rowsCmd.CommandText = @"SELECT l.Id, l.ProductId, l.Article, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId
 0785l.OneCQty, l.OzonQty, l.Delta, l.Action, l.ActionLabel, l.ActionIcon, l.RowClass, l.Note, l.OzonProductId, l.OfferId,
 0786l.OzonApplyStatus, l.OzonAppliedUtc, l.OzonApplyError, l.OzonQtyAfter,
 0787(SELECT s.Manufacturer FROM WarehouseStockSnapshot s WHERE s.Article = l.Article AND s.Manufacturer IS NOT NULL AND s.Ma
 0788l.OneCPrice, l.PrevOneCPrice,
 0789l.IsArchived, l.IsAutoArchived,
 0790COALESCE(l.OzonClientId, 0) AS OzonClientId, COALESCE(l.OzonClientName, '') AS OzonClientName,
 0791l.OzonCleanupStatus, l.OzonCleanupAtUtc, l.OzonCleanupError,
 0792COALESCE(l.InPromo, 0) AS InPromo, l.PromoName,
 0793COALESCE(l.ReservedQty, 0) AS ReservedQty,
 0794COALESCE(l.BatchMultiplier, 1) AS BatchMultiplier, l.BatchBaseArticle, l.BatchPushedPrice
 0795FROM BitrixStockSyncReportLines l" + whereSql + @"
 0796ORDER BY l.Article, l.OneCStore, l.OzonWarehouseName, l.OzonClientId
 0797LIMIT @take OFFSET @skip;";
 0798        AddLineFilterParams(rowsCmd, report.ReportId, articleFilter, ozonWarehouseFilter, oneCStores, actionsFilter, que
 0799        AddParam(rowsCmd, "@take", pageSize);
 0800        AddParam(rowsCmd, "@skip", (rowsPage - 1) * pageSize);
 0801
 0802        await using var rowsReader = await rowsCmd.ExecuteReaderAsync(ct);
 0803        while (await rowsReader.ReadAsync(ct))
 0804        {
 0805            report.Rows.Add(new BitrixStockSyncReportWarehouseLineDto
 0806            {
 0807                LineId = rowsReader.GetInt64(0),
 0808                ProductId = rowsReader.GetInt32(1),
 0809                Article = rowsReader.GetString(2),
 0810                OneCStore = rowsReader.GetString(3),
 0811                OzonWarehouseName = rowsReader.GetString(4),
 0812                OzonWarehouseId = rowsReader.GetInt64(5),
 0813                MappingDescription = rowsReader.GetString(6),
 0814                OneCQty = rowsReader.GetInt32(7),
 0815                OzonQty = rowsReader.GetInt32(8),
 0816                Delta = rowsReader.GetInt32(9),
 0817                Action = rowsReader.GetString(10),
 0818                ActionLabel = rowsReader.GetString(11),
 0819                ActionIcon = rowsReader.GetString(12),
 0820                RowClass = rowsReader.GetString(13),
 0821                Note = rowsReader.IsDBNull(14) ? null : rowsReader.GetString(14),
 0822                OzonProductId = rowsReader.IsDBNull(15) ? null : rowsReader.GetInt64(15),
 0823                OfferId = rowsReader.IsDBNull(16) ? null : rowsReader.GetString(16),
 0824                OzonApplyStatus = rowsReader.IsDBNull(17) ? null : rowsReader.GetString(17),
 0825                OzonAppliedUtc = rowsReader.IsDBNull(18) ? null : rowsReader.GetDateTime(18),
 0826                OzonApplyError = rowsReader.IsDBNull(19) ? null : rowsReader.GetString(19),
 0827                OzonQtyAfter = rowsReader.IsDBNull(20) ? null : rowsReader.GetInt32(20),
 0828                ManufacturerName = rowsReader.IsDBNull(21) ? null : rowsReader.GetString(21),
 0829                OneCPrice = rowsReader.IsDBNull(22) ? null : (decimal?)Convert.ToDecimal(rowsReader.GetValue(22)),
 0830                PrevOneCPrice = rowsReader.IsDBNull(23) ? null : (decimal?)Convert.ToDecimal(rowsReader.GetValue(23)),
 0831                IsArchived = !rowsReader.IsDBNull(24) && rowsReader.GetInt32(24) != 0,
 0832                IsAutoArchived = !rowsReader.IsDBNull(25) && rowsReader.GetInt32(25) != 0,
 0833                OzonClientId = rowsReader.IsDBNull(26) ? 0 : rowsReader.GetInt32(26),
 0834                OzonClientName = rowsReader.IsDBNull(27) ? "" : rowsReader.GetString(27),
 0835                OzonCleanupStatus = rowsReader.IsDBNull(28) ? null : rowsReader.GetString(28),
 0836                OzonCleanupAtUtc = rowsReader.IsDBNull(29) ? null : rowsReader.GetDateTime(29),
 0837                OzonCleanupError = rowsReader.IsDBNull(30) ? null : rowsReader.GetString(30),
 0838                InPromo = !rowsReader.IsDBNull(31) && rowsReader.GetInt32(31) != 0,
 0839                PromoName = rowsReader.IsDBNull(32) ? null : rowsReader.GetString(32),
 0840                ReservedQty = rowsReader.IsDBNull(33) ? 0 : rowsReader.GetInt32(33),
 0841                BatchMultiplier = rowsReader.IsDBNull(34) ? 1 : rowsReader.GetInt32(34),
 0842                BatchBaseArticle = rowsReader.IsDBNull(35) ? null : rowsReader.GetString(35),
 0843                BatchPushedPrice = rowsReader.IsDBNull(36) ? null : rowsReader.GetDecimal(36)
 0844            });
 0845        }
 0846
 0847        return report;
 0848        }, cancellationToken);
 0849    }
 850
 851    public async Task<BitrixStockSyncReportRunsPageDto> ListReportsAsync(
 852        BitrixStockSyncReportRunsQuery query,
 853        CancellationToken cancellationToken = default)
 854    {
 0855        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 856
 0857        var page = Math.Max(1, query.Page);
 0858        var pageSize = Math.Clamp(query.PageSize <= 0 ? 20 : query.PageSize, 5, query.MaxPageSize);
 0859        var statusFilter = string.IsNullOrWhiteSpace(query.Status) ? null : query.Status.Trim();
 860
 0861        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 0862        {
 0863        var where = " WHERE 1=1";
 0864        if (query.OzonClientId is > 0)
 0865        {
 0866            where += " AND r.OzonClientId = @ozonClientId";
 0867        }
 0868
 0869        if (statusFilter != null)
 0870        {
 0871            where += " AND r.Status = @status";
 0872        }
 0873
 0874        var countCmd = connection.CreateCommand();
 0875        countCmd.CommandTimeout = 30;
 0876        countCmd.CommandText = "SELECT COUNT(*) FROM BitrixStockSyncReports r" + where;
 0877        if (query.OzonClientId is > 0)
 0878        {
 0879            AddParam(countCmd, "@ozonClientId", query.OzonClientId.Value);
 0880        }
 0881
 0882        if (statusFilter != null)
 0883        {
 0884            AddParam(countCmd, "@status", statusFilter);
 0885        }
 0886
 0887        var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(ct) ?? 0);
 0888        var totalPages = totalCount == 0 ? 1 : (int)Math.Ceiling(totalCount / (double)pageSize);
 0889        var listPage = page;
 0890        if (listPage > totalPages)
 0891        {
 0892            listPage = totalPages;
 0893        }
 0894
 0895        // Только базовые поля отчёта — без 5 коррелированных подзапросов по дочерним таблицам
 0896        // (раньше выполнялись на каждую строку страницы, до ~500 full scan за запрос из-за
 0897        // COLLATE-приведения поверх индексируемого ReportId). Агрегаты orphan/price-push
 0898        // теперь считаются отдельно, одним GROUP BY-запросом на каждую таблицу для всей
 0899        // страницы разом — см. FillOrphanAndPricePushAggregatesAsync.
 0900        var listCmd = connection.CreateCommand();
 0901        listCmd.CommandTimeout = 30;
 0902        listCmd.CommandText = @"SELECT r.ReportId, r.GeneratedAtUtc, r.CompletedAtUtc, r.Status, r.OzonClientId, r.OzonC
 0903r.SelectedStoresJson, r.ProductsScanned, r.ProductsInReport, r.WarehouseLinesTotal, r.PushCount, r.DecreaseCount, r.Clea
 0904r.OzonAppliedSuccessCount, r.OzonApplyFailedCount, r.OzonApplyLastAtUtc, r.Warning, COALESCE(r.MultiClientIdsJson,'[]'),
 0905COALESCE(r.OzonCleanupSuccessCount, 0), COALESCE(r.OzonCleanupFailedCount, 0)
 0906FROM BitrixStockSyncReports r" + where + @"
 0907ORDER BY r.GeneratedAtUtc DESC
 0908LIMIT @take OFFSET @skip;";
 0909        if (query.OzonClientId is > 0)
 0910        {
 0911            AddParam(listCmd, "@ozonClientId", query.OzonClientId.Value);
 0912        }
 0913
 0914        if (statusFilter != null)
 0915        {
 0916            AddParam(listCmd, "@status", statusFilter);
 0917        }
 0918
 0919        AddParam(listCmd, "@take", pageSize);
 0920        AddParam(listCmd, "@skip", (listPage - 1) * pageSize);
 0921
 0922        var result = new BitrixStockSyncReportRunsPageDto
 0923        {
 0924            Page = listPage,
 0925            PageSize = pageSize,
 0926            TotalCount = totalCount,
 0927            TotalPages = totalPages
 0928        };
 0929
 0930        await using (var reader = await listCmd.ExecuteReaderAsync(ct))
 0931        {
 0932            while (await reader.ReadAsync(ct))
 0933            {
 0934                result.Items.Add(new BitrixStockSyncReportRunDto
 0935                {
 0936                    ReportId = reader.GetString(0),
 0937                    GeneratedAtUtc = reader.GetDateTime(1),
 0938                    CompletedAtUtc = reader.IsDBNull(2) ? null : reader.GetDateTime(2),
 0939                    Status = reader.IsDBNull(3) ? string.Empty : reader.GetString(3),
 0940                    OzonClientId = reader.GetInt32(4),
 0941                    OzonClientName = reader.GetString(5),
 0942                    SelectedStores = reader.IsDBNull(6) ? null : reader.GetString(6),
 0943                    ProductsScanned = reader.IsDBNull(7) ? 0 : reader.GetInt32(7),
 0944                    ProductsInReport = reader.IsDBNull(8) ? 0 : reader.GetInt32(8),
 0945                    WarehouseLinesTotal = reader.IsDBNull(9) ? 0 : reader.GetInt32(9),
 0946                    PushCount = reader.IsDBNull(10) ? 0 : reader.GetInt32(10),
 0947                    DecreaseCount = reader.IsDBNull(11) ? 0 : reader.GetInt32(11),
 0948                    ClearCount = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
 0949                    MatchCount = reader.IsDBNull(13) ? 0 : reader.GetInt32(13),
 0950                    SkipCount = reader.IsDBNull(14) ? 0 : reader.GetInt32(14),
 0951                    NotFoundCount = reader.IsDBNull(15) ? 0 : reader.GetInt32(15),
 0952                    OzonAppliedSuccessCount = reader.IsDBNull(16) ? 0 : reader.GetInt32(16),
 0953                    OzonApplyFailedCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17),
 0954                    OzonApplyLastAtUtc = reader.IsDBNull(18) ? null : reader.GetDateTime(18),
 0955                    Warning = reader.IsDBNull(19) ? null : reader.GetString(19),
 0956                    MultiClientNames = ParseMultiClientNames(reader.IsDBNull(20) ? "[]" : reader.GetString(20)),
 0957                    OzonCleanupSuccessCount = reader.IsDBNull(21) ? 0 : reader.GetInt32(21),
 0958                    OzonCleanupFailedCount = reader.IsDBNull(22) ? 0 : reader.GetInt32(22)
 0959                });
 0960            }
 0961        }
 0962
 0963        if (result.Items.Count > 0)
 0964        {
 0965            await FillOrphanAndPricePushAggregatesAsync(connection, result.Items, ct);
 0966        }
 0967
 0968        return result;
 0969        }, cancellationToken);
 0970    }
 971
 972    /// <summary>
 973    /// Догружает суммарные показатели orphan-очистки (этап 5, BitrixStockSyncReportOzonFiles) и
 974    /// наката цен (этап 4, BitrixStockSyncReportOzonPrices) одним GROUP BY-запросом на каждую
 975    /// таблицу для всей страницы разом, вместо 5 коррелированных подзапросов на каждую строку.
 976    /// </summary>
 977    private static async Task FillOrphanAndPricePushAggregatesAsync(
 978        System.Data.Common.DbConnection connection,
 979        List<BitrixStockSyncReportRunDto> items,
 980        CancellationToken ct)
 981    {
 0982        var idParamNames = new string[items.Count];
 0983        for (var i = 0; i < items.Count; i++)
 984        {
 0985            idParamNames[i] = $"@rid{i}";
 986        }
 0987        var inClause = string.Join(", ", idParamNames);
 988
 0989        var filesCmd = connection.CreateCommand();
 0990        filesCmd.CommandTimeout = 30;
 0991        filesCmd.CommandText = $@"SELECT f.ReportId, COALESCE(SUM(f.OrphansFound), 0), COALESCE(SUM(f.OrphansZeroed), 0)
 0992FROM BitrixStockSyncReportOzonFiles f
 0993WHERE f.ReportId IN ({inClause})
 0994GROUP BY f.ReportId";
 0995        for (var i = 0; i < items.Count; i++)
 996        {
 0997            AddParam(filesCmd, idParamNames[i], items[i].ReportId);
 998        }
 999
 01000        var orphans = new Dictionary<string, (int Found, int Zeroed)>(StringComparer.Ordinal);
 01001        await using (var reader = await filesCmd.ExecuteReaderAsync(ct))
 1002        {
 01003            while (await reader.ReadAsync(ct))
 1004            {
 01005                orphans[reader.GetString(0)] = (reader.GetInt32(1), reader.GetInt32(2));
 1006            }
 1007        }
 1008
 01009        var pricesCmd = connection.CreateCommand();
 01010        pricesCmd.CommandTimeout = 30;
 01011        pricesCmd.CommandText = $@"SELECT p.ReportId,
 01012    COUNT(CASE WHEN p.PricePushedNew IS NOT NULL THEN 1 END),
 01013    COUNT(CASE WHEN p.PricePushStatus = 'Unchanged' THEN 1 END),
 01014    COUNT(CASE WHEN p.PricePushStatus = 'Error' THEN 1 END)
 01015FROM BitrixStockSyncReportOzonPrices p
 01016WHERE p.ReportId IN ({inClause})
 01017GROUP BY p.ReportId";
 01018        for (var i = 0; i < items.Count; i++)
 1019        {
 01020            AddParam(pricesCmd, idParamNames[i], items[i].ReportId);
 1021        }
 1022
 01023        var pricePush = new Dictionary<string, (int Pushed, int Unchanged, int Error)>(StringComparer.Ordinal);
 01024        await using (var reader = await pricesCmd.ExecuteReaderAsync(ct))
 1025        {
 01026            while (await reader.ReadAsync(ct))
 1027            {
 01028                pricePush[reader.GetString(0)] = (reader.GetInt32(1), reader.GetInt32(2), reader.GetInt32(3));
 1029            }
 1030        }
 1031
 01032        foreach (var item in items)
 1033        {
 01034            if (item.ReportId == null)
 1035            {
 1036                continue;
 1037            }
 1038
 01039            if (orphans.TryGetValue(item.ReportId, out var o))
 1040            {
 01041                item.OrphansFound = o.Found;
 01042                item.OrphansZeroed = o.Zeroed;
 1043            }
 1044
 01045            if (pricePush.TryGetValue(item.ReportId, out var p))
 1046            {
 01047                item.PricePushCount = p.Pushed;
 01048                item.PricePushUnchangedCount = p.Unchanged;
 01049                item.PricePushErrorCount = p.Error;
 1050            }
 1051        }
 01052    }
 1053
 1054    public async Task<List<string>> GetReportOneCStoresAsync(
 1055        string reportId,
 1056        CancellationToken cancellationToken = default)
 1057    {
 01058        if (string.IsNullOrWhiteSpace(reportId))
 1059        {
 01060            return new List<string>();
 1061        }
 1062
 01063        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 01064        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01065        {
 01066            var cmd = connection.CreateCommand();
 01067            cmd.CommandText = @"SELECT DISTINCT OneCStore FROM BitrixStockSyncReportLines
 01068WHERE ReportId = @reportId
 01069ORDER BY OneCStore;";
 01070            AddParam(cmd, "@reportId", reportId.Trim());
 01071
 01072            var stores = new List<string>();
 01073            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01074            while (await reader.ReadAsync(ct))
 01075            {
 01076                stores.Add(reader.GetString(0));
 01077            }
 01078
 01079            return stores;
 01080        }, cancellationToken);
 01081    }
 1082
 1083    public async Task<List<(int ClientId, string OneCStore, string OzonWarehouse)>> GetReportMappingCombosAsync(
 1084        string reportId,
 1085        CancellationToken cancellationToken = default)
 1086    {
 01087        if (string.IsNullOrWhiteSpace(reportId))
 01088            return new List<(int, string, string)>();
 1089
 01090        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 01091        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01092        {
 01093            var cmd = connection.CreateCommand();
 01094            cmd.CommandText = @"SELECT DISTINCT OzonClientId, OneCStore, OzonWarehouseName
 01095FROM BitrixStockSyncReportLines
 01096WHERE ReportId = @reportId
 01097  AND OneCStore IS NOT NULL AND OneCStore != '' AND OneCStore != '—'
 01098  AND OzonWarehouseName IS NOT NULL AND OzonWarehouseName != '' AND OzonWarehouseName != '—'
 01099  AND OzonClientId > 0
 01100ORDER BY OzonClientId, OneCStore, OzonWarehouseName;";
 01101            AddParam(cmd, "@reportId", reportId.Trim());
 01102
 01103            var result = new List<(int, string, string)>();
 01104            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01105            while (await reader.ReadAsync(ct))
 01106            {
 01107                result.Add((
 01108                    Convert.ToInt32(reader.GetValue(0)),
 01109                    reader.GetString(1),
 01110                    reader.GetString(2)
 01111                ));
 01112            }
 01113            return result;
 01114        }, cancellationToken);
 01115    }
 1116
 1117    public async Task<string?> GetReportLogTextAsync(string reportId, CancellationToken cancellationToken = default)
 1118    {
 01119        if (string.IsNullOrWhiteSpace(reportId)) return null;
 01120        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 01121        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01122        {
 01123            var cmd = connection.CreateCommand();
 01124            cmd.CommandText = "SELECT LogText, GeneratedAtUtc, Status FROM BitrixStockSyncReports WHERE ReportId = @id L
 01125            AddParam(cmd, "@id", reportId.Trim());
 01126            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01127            if (!await reader.ReadAsync(ct)) return null;
 01128            var logText = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
 01129            var generatedAt = reader.GetDateTime(1);
 01130            var status = reader.IsDBNull(2) ? "" : reader.GetString(2);
 01131            var header = $"Отчёт синхронизации остатков\nID: {reportId}\nСформирован: {BitrixStockMskDateTime.Format(gen
 01132            return header + logText;
 01133        }, cancellationToken);
 01134    }
 1135
 1136    public async Task<BitrixStockArchivedPreviewDto> GetArchivedPreviewAsync(
 1137        string reportId,
 1138        CancellationToken cancellationToken = default)
 1139    {
 01140        var result = new BitrixStockArchivedPreviewDto();
 01141        if (string.IsNullOrWhiteSpace(reportId))
 01142            return result;
 1143
 01144        var allClients = await _ozonClientServices.GetOzonClients();
 1145        var clientNameMap = allClients.ToDictionary(c => c.Id, c => c.Name ?? string.Empty);
 1146
 01147        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 01148        await WithOpenConnectionAsync(db, async (connection, ct) =>
 01149        {
 01150            // Сводка по клиентам
 01151            var summaryCmd = connection.CreateCommand();
 01152            summaryCmd.CommandText = @"
 01153SELECT COALESCE(OzonClientId, 0) AS ClientId,
 01154       SUM(CASE WHEN IsAutoArchived = 1 THEN 1 ELSE 0 END) AS AutoCount,
 01155       SUM(CASE WHEN IsAutoArchived = 0 THEN 1 ELSE 0 END) AS ManualCount
 01156FROM BitrixStockSyncReportLines
 01157WHERE ReportId = @reportId AND IsArchived = 1
 01158GROUP BY OzonClientId
 01159ORDER BY OzonClientId;";
 01160            AddParam(summaryCmd, "@reportId", reportId.Trim());
 01161
 01162            await using var summaryReader = await summaryCmd.ExecuteReaderAsync(ct);
 01163            while (await summaryReader.ReadAsync(ct))
 01164            {
 01165                var clientId = summaryReader.IsDBNull(0) ? 0 : Convert.ToInt32(summaryReader.GetValue(0));
 01166                var autoCount = summaryReader.IsDBNull(1) ? 0 : Convert.ToInt32(summaryReader.GetValue(1));
 01167                var manualCount = summaryReader.IsDBNull(2) ? 0 : Convert.ToInt32(summaryReader.GetValue(2));
 01168                var clientName = clientId > 0 && clientNameMap.TryGetValue(clientId, out var name)
 01169                    ? name
 01170                    : clientId > 0 ? $"Клиент #{clientId}" : "Неизвестный клиент";
 01171
 01172                result.Clients.Add(new BitrixStockArchivedPreviewClientDto
 01173                {
 01174                    ClientId = clientId,
 01175                    ClientName = clientName,
 01176                    AutoCount = autoCount,
 01177                    ManualCount = manualCount
 01178                });
 01179                result.TotalAuto += autoCount;
 01180                result.TotalManual += manualCount;
 01181            }
 01182            result.TotalArchived = result.TotalAuto + result.TotalManual;
 01183            await summaryReader.CloseAsync();
 01184
 01185            // Детальные строки товаров в архиве (лимит 500)
 01186            var itemsCmd = connection.CreateCommand();
 01187            itemsCmd.CommandText = @"
 01188SELECT l.Article,
 01189       l.IsAutoArchived,
 01190       COALESCE(l.OzonClientName, '') AS ClientName,
 01191       COALESCE(l.OneCStore, '')      AS OneCStore,
 01192       l.OneCQty,
 01193       COALESCE(l.OzonWarehouseName, '') AS OzonWarehouse,
 01194       l.OzonQty
 01195FROM BitrixStockSyncReportLines l
 01196WHERE l.ReportId = @reportId AND l.IsArchived = 1
 01197ORDER BY l.Article, l.OzonClientId, l.OneCStore, l.OzonWarehouseName
 01198LIMIT 500;";
 01199            AddParam(itemsCmd, "@reportId", reportId.Trim());
 01200
 01201            await using var itemsReader = await itemsCmd.ExecuteReaderAsync(ct);
 01202            while (await itemsReader.ReadAsync(ct))
 01203            {
 01204                result.Items.Add(new BitrixStockArchivedPreviewLineDto
 01205                {
 01206                    Article = itemsReader.IsDBNull(0) ? "" : itemsReader.GetString(0),
 01207                    IsAutoArchived = !itemsReader.IsDBNull(1) && Convert.ToInt32(itemsReader.GetValue(1)) != 0,
 01208                    ClientName = itemsReader.IsDBNull(2) ? "" : itemsReader.GetString(2),
 01209                    OneCStore = itemsReader.IsDBNull(3) ? "" : itemsReader.GetString(3),
 01210                    OneCQty = itemsReader.IsDBNull(4) ? 0 : itemsReader.GetInt32(4),
 01211                    OzonWarehouse = itemsReader.IsDBNull(5) ? "" : itemsReader.GetString(5),
 01212                    OzonQty = itemsReader.IsDBNull(6) ? 0 : itemsReader.GetInt32(6),
 01213                });
 01214            }
 01215
 01216            return result;
 01217        }, cancellationToken);
 1218
 01219        return result;
 01220    }
 1221
 1222    private static List<BitrixStockSyncReportWarehouseLineDto> BuildExceptionSkipLines(
 1223        RemainingStockBitrix item,
 1224        HashSet<string> allowedBitrixStores,
 1225        string note)
 1226    {
 01227        var stores = item.Stores?
 01228            .Where(s => !string.IsNullOrWhiteSpace(s.Title) && allowedBitrixStores.Contains(s.Title.Trim()))
 01229            .ToList() ?? new List<StockByStore>();
 1230
 01231        if (stores.Count == 0)
 1232        {
 01233            return new List<BitrixStockSyncReportWarehouseLineDto>
 01234            {
 01235                new()
 01236                {
 01237                    Article = item.Article ?? string.Empty,
 01238                    OfferId = item.ArticleWithKey,
 01239                    ProductId = item.ProductId,
 01240                    OneCStore = "—",
 01241                    OzonWarehouseName = "—",
 01242                    OneCQty = 0,
 01243                    OzonQty = 0,
 01244                    Delta = 0,
 01245                    Action = BitrixStockSyncReportActions.Skip,
 01246                    ActionLabel = "Пропуск (исключение)",
 01247                    ActionIcon = "⊘",
 01248                    RowClass = "sync-row-exception",
 01249                    Note = note,
 01250                }
 01251            };
 1252        }
 1253
 01254        return stores.Select(s => new BitrixStockSyncReportWarehouseLineDto
 01255        {
 01256            Article = item.Article ?? string.Empty,
 01257            OfferId = item.ArticleWithKey,
 01258            ProductId = item.ProductId,
 01259            OneCStore = s.Title?.Trim() ?? "—",
 01260            OzonWarehouseName = "—",
 01261            OneCQty = (int)s.Amount,
 01262            OzonQty = 0,
 01263            Delta = 0,
 01264            Action = BitrixStockSyncReportActions.Skip,
 01265            ActionLabel = "Пропуск (исключение)",
 01266            ActionIcon = "⊘",
 01267            RowClass = "sync-row-exception",
 01268            Note = note,
 01269        }).ToList();
 1270    }
 1271
 1272    private static List<BitrixStockSyncReportWarehouseLineDto> BuildProductLines(
 1273        RemainingStockBitrix item,
 1274        OzonClient client,
 1275        List<WarehouseMapping> linkageMappings,
 1276        HashSet<long> mappedWarehouseIds,
 1277        HashSet<int> disabledMappingIds,
 1278        BitrixStockSyncReportSummaryDto summary,
 1279        IReadOnlyDictionary<string, Dictionary<string, int>> reserveMap)
 1280    {
 01281        var lines = new List<BitrixStockSyncReportWarehouseLineDto>();
 01282        var isInactive = BitrixOzonStockLinkage.IsInactiveProduct(item);
 01283        var clientName = client.Name ?? string.Empty;
 1284
 01285        var linkedOzonStores = item.OzonStores?
 01286            .Where(s => string.Equals(s.ClientName, clientName, StringComparison.OrdinalIgnoreCase))
 01287            .Where(s => s.StoreId > 0 && mappedWarehouseIds.Contains(s.StoreId))
 01288            .Where(s => s.LinkStores != null && s.LinkStores.Any())
 01289            .ToList() ?? new List<StockByStore>();
 1290
 01291        foreach (var oz in linkedOzonStores)
 1292        {
 01293            var mapping = linkageMappings.FirstOrDefault(m =>
 01294            {
 01295                if (!long.TryParse(m.OzonWarehouseId?.Trim(), out var wid) || wid != oz.StoreId)
 01296                {
 01297                    return false;
 01298                }
 01299
 01300                return oz.LinkStores!.Any(ls =>
 01301                    string.Equals(m.BitrixWarehouseName?.Trim(), ls, StringComparison.OrdinalIgnoreCase));
 01302            });
 1303
 01304            if (mapping != null && disabledMappingIds.Contains(mapping.Id))
 1305            {
 1306                continue;
 1307            }
 1308
 01309            var oneCName = BitrixOzonStockLinkage.GetLinkedOneCStoreLabel(oz.LinkStores);
 01310            var rawOneCQty = BitrixOzonStockLinkage.GetLinkedOneCQuantity(item, oz.LinkStores);
 01311            var reservedQty = BitrixOzonStockLinkage.GetLinkedReservedQuantity(item.Article, oz.LinkStores, reserveMap);
 01312            var actualReserved = Math.Min(reservedQty, rawOneCQty);
 01313            var oneCQty = rawOneCQty - actualReserved;
 01314            var hasLinkedOneCStore = BitrixOzonStockLinkage.HasLinkedOneCStore(item, oz.LinkStores);
 01315            var ozonQty = oz.OzonPresentOnOzon.HasValue
 01316                ? (int)Math.Round(oz.OzonPresentOnOzon.Value)
 01317                : (int)Math.Round(oz.Amount);
 1318
 01319            var hasProductId = BitrixOzonStockLinkage.HasValidOzonProductId(oz);
 01320            var mappingDesc = mapping != null
 01321                ? $"1С {mapping.BitrixWarehouseName} → Ozon {mapping.OzonWarehouseName} (id {mapping.OzonWarehouseId})"
 01322                : $"Связь: {string.Join(", ", oz.LinkStores!)} → {oz.Title}";
 1323
 01324            var (action, label, icon, rowClass, note) = BitrixStockSyncActionResolver.Resolve(
 01325                isInactive,
 01326                hasProductId,
 01327                hasLinkedOneCStore,
 01328                mapping != null,
 01329                oneCQty,
 01330                ozonQty);
 1331
 1332            // Остаток по складу неизвестен (несколько связей, а Ozon отдал только суммарный FBS) —
 1333            // сравнение с 1С бессмысленно, накат по такой строке не выполняется.
 01334            if (oz.IsFbsStockUnknown && action != BitrixStockSyncReportActions.Skip)
 1335            {
 01336                (action, label, icon, rowClass, note) = (
 01337                    BitrixStockSyncReportActions.Skip,
 01338                    "Нет остатка по складу",
 01339                    "—",
 01340                    "sync-row-skip",
 01341                    "Ozon не вернул разбивку по складам, связей несколько — остаток по складу неизвестен");
 1342            }
 1343
 01344            var fallbackNote = oz.IsFbsFallback
 01345                ? "FBS-схема (SDS): склад из WarehouseMapping, остаток с Ozon /v4/product/info/stocks"
 01346                : null;
 01347            var finalNote = string.Join(" | ", new[] { fallbackNote, note }.Where(s => !string.IsNullOrEmpty(s)));
 01348            if (string.IsNullOrEmpty(finalNote)) finalNote = note;
 1349
 01350            IncrementSummary(summary, action);
 1351
 01352            lines.Add(new BitrixStockSyncReportWarehouseLineDto
 01353            {
 01354                ProductId = item.ProductId,
 01355                Article = item.Article ?? string.Empty,
 01356                OneCStore = oneCName,
 01357                OzonWarehouseName = oz.Title ?? string.Empty,
 01358                OzonWarehouseId = oz.StoreId,
 01359                MappingDescription = mappingDesc,
 01360                OneCQty = oneCQty,
 01361                OzonQty = ozonQty,
 01362                Delta = oneCQty - ozonQty,
 01363                Action = action,
 01364                ActionLabel = label,
 01365                ActionIcon = icon,
 01366                RowClass = rowClass,
 01367                Note = finalNote,
 01368                OzonProductId = hasProductId && long.TryParse(oz.ProductId, out var pid) ? pid : null,
 01369                OfferId = oz.OfferId ?? item.ArticleWithKey,
 01370                OneCPrice = item.C0,
 01371                ReservedQty = actualReserved,
 01372                BatchMultiplier = item.BatchMultiplier,
 01373                BatchBaseArticle = item.BatchMultiplier > 1 ? item.BatchBaseOfferId : null
 01374            });
 1375        }
 1376
 01377        return lines.OrderBy(l => l.OneCStore, StringComparer.OrdinalIgnoreCase).ToList();
 1378    }
 1379
 1380    private static List<BitrixStockSyncReportWarehouseLineDto> BuildNotFoundLines(
 1381        RemainingStockBitrix item,
 1382        HashSet<string> allowedBitrixStores,
 1383        OzonApiFoundInfo ozonInfo)
 1384    {
 01385        var oneCStores = item.Stores?
 01386            .Where(s => !string.IsNullOrWhiteSpace(s.Title) &&
 01387                        allowedBitrixStores.Contains(s.Title.Trim()))
 01388            .OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
 01389            .ToList() ?? new List<StockByStore>();
 1390
 1391        string note;
 1392        int ozonQty;
 1393        string ozonWhName;
 1394        string action;
 1395        string actionLabel;
 1396        string actionIcon;
 1397        string rowClass;
 1398
 01399        if (ozonInfo.NotForSale)
 1400        {
 01401            note = "Товар есть в каталоге Ozon, но убран из продажи — нужно возобновить продажи в Ozon Seller";
 01402            ozonQty = 0;
 01403            ozonWhName = "—";
 01404            action = BitrixStockSyncReportActions.NotForSale;
 01405            actionLabel = "Не на продаже";
 01406            actionIcon = "⛔";
 01407            rowClass = "sync-row-not-for-sale";
 1408        }
 01409        else if (!ozonInfo.Found)
 1410        {
 01411            var hasPrefix = !string.IsNullOrWhiteSpace(item.MarketPrefix);
 01412            note = hasPrefix
 01413                ? "Товар не найден в Ozon API для выбранного клиента"
 01414                : $"Товар не найден в Ozon API — артикул «{item.Article}» без префикса (производитель «{item.Supplier ??
 01415            ozonQty = 0;
 01416            ozonWhName = "—";
 01417            action = BitrixStockSyncReportActions.NotFound;
 01418            actionLabel = "Не найден";
 01419            actionIcon = "?";
 01420            rowClass = "sync-row-not-found";
 1421        }
 01422        else if (ozonInfo.FbsFamilyTotal > 0)
 1423        {
 01424            note = $"Ozon FBS: {ozonInfo.FbsTotal}" +
 01425                   (ozonInfo.RfbsTotal > 0 ? $" + rFBS: {ozonInfo.RfbsTotal}" : string.Empty) +
 01426                   " — нет привязки к складу (настройте WarehouseMapping)";
 01427            ozonQty = ozonInfo.FbsFamilyTotal;
 01428            ozonWhName = "FBS";
 01429            action = BitrixStockSyncReportActions.FbsNoWarehouse;
 01430            actionLabel = "FBS: без склада";
 01431            actionIcon = "⚠";
 01432            rowClass = "sync-row-fbs-no-warehouse";
 1433        }
 1434        else
 1435        {
 01436            note = "Товар найден в Ozon (FBS=0), но нет связи в WarehouseMapping — настройте WarehouseMapping для накатк
 01437            ozonQty = 0;
 01438            ozonWhName = "FBS";
 01439            action = BitrixStockSyncReportActions.FbsNoWarehouse;
 01440            actionLabel = "FBS: без склада";
 01441            actionIcon = "⚠";
 01442            rowClass = "sync-row-fbs-no-warehouse";
 1443        }
 1444
 01445        var offerId = ozonInfo.FoundOfferId ?? item.ArticleWithKey;
 01446        var productId = ozonInfo.OzonProductId > 0 ? ozonInfo.OzonProductId : (long?)null;
 1447
 01448        if (oneCStores.Count == 0)
 1449        {
 01450            var totalQty = (int)Math.Max(0, Math.Round(item.Quantity));
 01451            return new List<BitrixStockSyncReportWarehouseLineDto>
 01452            {
 01453                new()
 01454                {
 01455                    ProductId = item.ProductId,
 01456                    Article = item.Article ?? string.Empty,
 01457                    OneCStore = "—",
 01458                    OzonWarehouseName = ozonWhName,
 01459                    OzonWarehouseId = 0,
 01460                    MappingDescription = ozonInfo.Found ? "Нет связи FBS-склада" : "Нет строк складов 1С в выбранных M2/
 01461                    OneCQty = totalQty,
 01462                    OzonQty = ozonQty,
 01463                    Delta = totalQty - ozonQty,
 01464                    Action = action,
 01465                    ActionLabel = actionLabel,
 01466                    ActionIcon = actionIcon,
 01467                    RowClass = rowClass,
 01468                    Note = note,
 01469                    OfferId = offerId,
 01470                    OzonProductId = productId,
 01471                    OneCPrice = item.C0
 01472                }
 01473            };
 1474        }
 1475
 01476        return oneCStores.Select(s =>
 01477        {
 01478            var qty = (int)Math.Max(0, Math.Round(s.Amount));
 01479            return new BitrixStockSyncReportWarehouseLineDto
 01480            {
 01481                ProductId = item.ProductId,
 01482                Article = item.Article ?? string.Empty,
 01483                OneCStore = s.Title ?? "—",
 01484                OzonWarehouseName = ozonWhName,
 01485                OzonWarehouseId = 0,
 01486                MappingDescription = ozonInfo.Found ? "Нет связи FBS-склада" : "Нет склада Ozon для клиента",
 01487                OneCQty = qty,
 01488                OzonQty = ozonQty,
 01489                Delta = qty - ozonQty,
 01490                Action = action,
 01491                ActionLabel = actionLabel,
 01492                ActionIcon = actionIcon,
 01493                RowClass = rowClass,
 01494                Note = note,
 01495                OfferId = offerId,
 01496                OzonProductId = productId,
 01497                OneCPrice = item.C0
 01498            };
 01499        }).ToList();
 1500    }
 1501
 1502    /// <summary>
 1503    /// Резерв заказов по статусам. Ключ результата — нормализованный артикул
 1504    /// (<see cref="OrderDataFilterManager.NormalizeArticleForSearch"/>): Orders.Article может
 1505    /// содержать дефисы/пробелы, которых нет в артикуле снэпшота 1С — точное сравнение их теряет.
 1506    /// Читатель (<see cref="BitrixOzonStockLinkage.GetLinkedReservedQuantity"/>) нормализует так же.
 1507    /// </summary>
 1508    internal static async Task<Dictionary<string, Dictionary<string, int>>> LoadOrderReservesAsync(
 1509        ApplicationDbContext db,
 1510        IReadOnlyList<int> reservedStatusIds,
 1511        CancellationToken ct)
 1512    {
 01513        var result = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
 01514        if (reservedStatusIds.Count == 0) return result;
 1515
 01516        var rows = await db.Orders
 01517            .Where(o => o.Article != null
 01518                     && o.OrderArchive == null
 01519                     && o.AppStatusId.HasValue
 01520                     && reservedStatusIds.Contains(o.AppStatusId.Value))
 01521            .Select(o => new {
 01522                o.Article,
 01523                o.ProductKey,
 01524                o.Quantity,
 01525                WarehouseName = o.ShipmentWarehouse != null ? o.ShipmentWarehouse.Name : null,
 01526                ZasylValue = o.OrderExtensions
 01527                    .Where(e => e.Key == "Засыл")
 01528                    .Select(e => e.Value)
 01529                    .FirstOrDefault()
 01530            })
 01531            .ToListAsync(ct);
 1532
 01533        foreach (var row in rows)
 1534        {
 01535            var normalizedArticle = OrderDataFilterManager.NormalizeArticleForSearch(row.Article);
 01536            if (string.IsNullOrEmpty(normalizedArticle)) continue;
 01537            var warehouseName = !string.IsNullOrWhiteSpace(row.WarehouseName)
 01538                ? row.WarehouseName
 01539                : row.ZasylValue;
 01540            if (string.IsNullOrWhiteSpace(warehouseName)) continue;
 1541
 01542            var qty = (row.Quantity ?? 1) * OzonDomains.Nomenclature.ProductKeyParser.BatchMultiplier(row.ProductKey);
 1543
 01544            if (!result.TryGetValue(normalizedArticle, out var storeMap))
 01545                result[normalizedArticle] = storeMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
 01546            storeMap.TryGetValue(warehouseName, out var cur);
 01547            storeMap[warehouseName] = cur + qty;
 1548        }
 01549        return result;
 01550    }
 1551
 1552    public async Task<OrderReserveMapDto> GetOrderReserveMapAsync(
 1553        IReadOnlyList<string>? selectedStores,
 1554        string? articleFilter,
 1555        IReadOnlyList<int>? reserveStatusIds = null,
 1556        CancellationToken cancellationToken = default)
 1557    {
 01558        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 01559        var reservedStatusIds = await ResolveReserveStatusIdsAsync(db, reserveStatusIds, cancellationToken);
 1560
 01561        if (reservedStatusIds.Count == 0)
 01562            return new OrderReserveMapDto();
 1563
 01564        var query = db.Orders
 01565            .Where(o => o.OrderArchive == null)
 01566            .Where(o => o.AppStatusId.HasValue && reservedStatusIds.Contains(o.AppStatusId.Value));
 1567
 01568        if (!string.IsNullOrWhiteSpace(articleFilter))
 01569            query = query.Where(o => o.Article != null && o.Article.Contains(articleFilter.Trim()));
 1570
 01571        var rows = await query
 01572            .Select(o => new OrderReserveSourceRow
 01573            {
 01574                Id = o.Id,
 01575                Article = o.Article,
 01576                ProductKey = o.ProductKey,
 01577                Quantity = o.Quantity,
 01578                ShipmentNumber = o.ShipmentNumber,
 01579                Key = o.Key,
 01580                ProcessingDate = o.ProcessingDate,
 01581                StatusName = o.AppStatus != null ? o.AppStatus.Name : null,
 01582                StatusColor = o.AppStatus != null && o.AppStatus.StatusColor != null
 01583                    ? o.AppStatus.StatusColor.ColorCode
 01584                    : null,
 01585                WarehouseName = o.ShipmentWarehouse != null ? o.ShipmentWarehouse.Name : null,
 01586                ZasylValue = o.OrderExtensions
 01587                    .Where(e => e.Key == "Засыл")
 01588                    .Select(e => e.Value)
 01589                    .FirstOrDefault()
 01590            })
 01591            .ToListAsync(cancellationToken);
 1592
 01593        var storeFilter = selectedStores?
 1594            .Where(s => !string.IsNullOrWhiteSpace(s))
 1595            .Select(s => s.Trim())
 01596            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 1597
 01598        var lines = OrderReserveAggregation.BuildLines(rows, storeFilter);
 1599
 01600        return new OrderReserveMapDto
 01601        {
 1602            TotalOrders = rows.Count(r => !string.IsNullOrWhiteSpace(r.Article)),
 1603            TotalArticles = lines.Select(l => l.Article).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
 01604            Lines = lines
 01605        };
 01606    }
 1607
 1608    internal static async Task<List<int>> ResolveReserveStatusIdsAsync(
 1609        ApplicationDbContext db,
 1610        IReadOnlyList<int>? explicitStatusIds,
 1611        CancellationToken cancellationToken)
 1612    {
 01613        if (explicitStatusIds is { Count: > 0 })
 1614        {
 01615            return await db.AppStatuses
 01616                .Where(s => explicitStatusIds.Contains(s.Id))
 01617                .Select(s => s.Id)
 01618                .ToListAsync(cancellationToken);
 1619        }
 1620
 01621        var defaultNames = await LoadDefaultReserveStatusNamesAsync(db, cancellationToken);
 01622        return await db.AppStatuses
 01623            .Where(s => s.Name != null && defaultNames.Contains(s.Name))
 01624            .Select(s => s.Id)
 01625            .ToListAsync(cancellationToken);
 01626    }
 1627
 1628    private static async Task<HashSet<string>> LoadDefaultReserveStatusNamesAsync(
 1629        ApplicationDbContext db,
 1630        CancellationToken cancellationToken)
 1631    {
 1632
 1633        try
 1634        {
 01635            var names = await db.Database
 01636                .SqlQueryRaw<string>(
 01637                    "SELECT StatusName FROM BitrixStockReserveStatusSettings WHERE IsDefault = 1 ORDER BY SortOrder, Id"
 01638                .ToListAsync(cancellationToken);
 1639
 01640            if (names.Count > 0)
 01641                return names.ToHashSet(StringComparer.OrdinalIgnoreCase);
 01642        }
 01643        catch
 1644        {
 1645            // fallback below
 01646        }
 1647
 01648        return FallbackReserveStatuses.ToHashSet(StringComparer.OrdinalIgnoreCase);
 01649    }
 1650
 1651    public async Task<List<ReserveStatusOptionDto>> GetReserveStatusOptionsAsync(
 1652        CancellationToken cancellationToken = default)
 1653    {
 01654        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 01655        var defaultNames = await LoadDefaultReserveStatusNamesAsync(db, cancellationToken);
 01656        var statuses = await db.AppStatuses
 01657            .Include(s => s.StatusColor)
 01658            .OrderBy(s => s.Name)
 01659            .ToListAsync(cancellationToken);
 1660
 01661        return statuses
 01662            .Select(s => new ReserveStatusOptionDto
 01663            {
 01664                Id = s.Id,
 01665                Name = s.Name ?? $"ID {s.Id}",
 01666                Color = s.GetStatusColor(),
 01667                IsDefault = !string.IsNullOrWhiteSpace(s.Name) && defaultNames.Contains(s.Name),
 01668            })
 01669            .ToList();
 01670    }
 1671
 1672    private static void IncrementSummary(BitrixStockSyncReportSummaryDto summary, string? action)
 1673    {
 01674        if (string.IsNullOrEmpty(action))
 01675            return;
 1676
 1677        switch (action)
 1678        {
 1679            case BitrixStockSyncReportActions.Push:
 01680                summary.PushCount++;
 01681                break;
 1682            case BitrixStockSyncReportActions.Decrease:
 01683                summary.DecreaseCount++;
 01684                break;
 1685            case BitrixStockSyncReportActions.Clear:
 01686                summary.ClearCount++;
 01687                break;
 1688            case BitrixStockSyncReportActions.Skip:
 01689                summary.SkipCount++;
 01690                break;
 1691            case BitrixStockSyncReportActions.NotFound:
 01692                summary.NotFoundCount++;
 01693                break;
 1694            case BitrixStockSyncReportActions.FbsNoWarehouse:
 01695                summary.NotFoundCount++;
 01696                break;
 1697            case BitrixStockSyncReportActions.CleanupExtra:
 1698                break;
 1699            default:
 01700                summary.MatchCount++;
 1701                break;
 1702        }
 01703    }
 1704
 1705    private static async Task<JObject?> LoadProductStocksChunkedParallelAsync(
 1706        OzonApiDataManager apiManager,
 1707        string[] articles,
 1708        CancellationToken cancellationToken)
 1709    {
 01710        if (articles.Length <= OzonStocksChunkSize)
 1711        {
 01712            return await apiManager.GetProductStocks(articles);
 1713        }
 1714
 01715        var chunks = new List<string[]>();
 01716        for (var offset = 0; offset < articles.Length; offset += OzonStocksChunkSize)
 1717        {
 01718            chunks.Add(articles.Skip(offset).Take(OzonStocksChunkSize).ToArray());
 1719        }
 1720
 01721        using var gate = new SemaphoreSlim(OzonStocksChunkParallelism);
 01722        var chunkTasks = chunks.Select(async chunk =>
 01723        {
 01724            await gate.WaitAsync(cancellationToken);
 01725            try
 01726            {
 01727                return await apiManager.GetProductStocks(chunk);
 01728            }
 01729            finally
 01730            {
 01731                gate.Release();
 01732            }
 01733        });
 1734
 01735        var parts = await Task.WhenAll(chunkTasks);
 01736        var mergedItems = new JArray();
 01737        foreach (var part in parts)
 1738        {
 01739            if (part?["items"] is JArray arr)
 1740            {
 01741                foreach (var item in arr)
 1742                {
 01743                    mergedItems.Add(item);
 1744                }
 1745            }
 1746        }
 1747
 01748        return mergedItems.Count == 0 ? null : new JObject { ["items"] = mergedItems };
 01749    }
 1750
 1751    private async Task ApplySupplierPrefixesAsync(
 1752        List<RemainingStockBitrix> items,
 1753        CancellationToken cancellationToken)
 1754    {
 01755        var suppliers = items
 1756            .Where(r => !string.IsNullOrWhiteSpace(r.Supplier))
 1757            .Select(r => r.Supplier!)
 01758            .Distinct()
 01759            .ToList();
 01760        if (suppliers.Count == 0)
 1761        {
 01762            return;
 1763        }
 1764
 01765        cancellationToken.ThrowIfCancellationRequested();
 01766        var prefixes = await _etProducerRepository.GetMarketPrefixesByNamesAsync(suppliers);
 01767        foreach (var item in items)
 1768        {
 01769            if (!string.IsNullOrWhiteSpace(item.Supplier) &&
 01770                prefixes.TryGetValue(item.Supplier, out var prefix))
 1771            {
 01772                item.MarketPrefix = prefix;
 1773            }
 1774        }
 01775    }
 1776
 1777    /// <summary>Парсит MultiClientIdsJson — поддерживает [1,2] и [{id,name},...].</summary>
 1778    private static List<int> ParseMultiClientIds(string json) =>
 01779        BitrixStockMultiClientParser.ParseMultiClientIds(json);
 1780
 1781    /// <summary>Парсит MultiClientIdsJson — возвращает имена клиентов (только для нового формата [{id,name},...]).</sum
 1782    private static List<string> ParseMultiClientNames(string json) =>
 01783        BitrixStockMultiClientParser.ParseMultiClientNames(json);
 1784
 1785    private static async Task<Dictionary<string, (bool IsArchived, bool IsAutoArchived)>> LoadArchiveStatusAsync(
 1786        OzonApiDataManager apiManager,
 1787        string[] offerIds,
 1788        CancellationToken cancellationToken)
 1789    {
 01790        var result = new ConcurrentDictionary<string, (bool, bool)>(StringComparer.OrdinalIgnoreCase);
 1791        try
 1792        {
 01793            using var gate = new SemaphoreSlim(4);
 01794            var tasks = offerIds.Chunk(100).Select(async chunk =>
 01795            {
 01796                cancellationToken.ThrowIfCancellationRequested();
 01797                await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
 01798                try
 01799                {
 01800                    var response = await apiManager.GetProductInfoListAsync(offerIds: chunk).ConfigureAwait(false);
 01801                    if (response?["items"] is not JArray items) return;
 01802                    foreach (var item in items)
 01803                    {
 01804                        var offerId = item["offer_id"]?.ToString();
 01805                        if (!string.IsNullOrEmpty(offerId))
 01806                            result[offerId] = (
 01807                                item["is_archived"]?.Value<bool>() ?? false,
 01808                                item["is_autoarchived"]?.Value<bool>() ?? false);
 01809                    }
 01810                }
 01811                finally { gate.Release(); }
 01812            });
 01813            await Task.WhenAll(tasks).ConfigureAwait(false);
 01814        }
 01815        catch
 1816        {
 1817            // если API недоступно — продолжаем без архивного статуса
 01818        }
 01819        return new Dictionary<string, (bool, bool)>(result, StringComparer.OrdinalIgnoreCase);
 01820    }
 1821
 1822    private async Task<List<WarehouseOzon>> GetOrCreateWarehouseCacheAsync(OzonApiDataManager apiManager, string clientI
 1823    {
 01824        var cacheKey = $"WarehouseList_{clientId}";
 01825        if (_memoryCache.TryGetValue(cacheKey, out List<WarehouseOzon>? cached) && cached != null)
 1826        {
 01827            return cached;
 1828        }
 1829
 01830        var list = await apiManager.GetWarehouseListOrThrowAsync();
 01831        if (list.Count == 0)
 01832            throw new InvalidOperationException(
 01833                "Ozon вернул пустой список складов — отчёт остановлен, чтобы не пометить все остатки как FbsNoWarehouse.
 1834
 01835        _memoryCache.Set(cacheKey, list, new MemoryCacheEntryOptions
 01836        {
 01837            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
 01838        });
 01839        return list;
 01840    }
 1841
 1842    private static async Task<T> WithOpenConnectionAsync<T>(
 1843        ApplicationDbContext db,
 1844        Func<System.Data.Common.DbConnection, CancellationToken, Task<T>> action,
 1845        CancellationToken cancellationToken)
 1846    {
 01847        await db.Database.OpenConnectionAsync(cancellationToken);
 1848        try
 1849        {
 01850            return await action(db.Database.GetDbConnection(), cancellationToken);
 1851        }
 1852        finally
 1853        {
 01854            await db.Database.CloseConnectionAsync();
 1855        }
 01856    }
 1857
 1858    private async Task PrepareReportStorageAsync(
 1859        ApplicationDbContext db,
 1860        string reportId,
 1861        BitrixStockSyncReportStartRequest request,
 1862        OzonClient primaryClient,
 1863        List<OzonClient> allClients,
 1864        CancellationToken cancellationToken)
 1865    {
 01866        var storesJson = JsonSerializer.Serialize(request.SelectedStoreTitles ?? new List<string>());
 01867        var sourceArticle = string.IsNullOrWhiteSpace(request.Article) ? null : request.Article.Trim();
 1868
 01869        await db.Database.ExecuteSqlRawAsync(
 01870            "DELETE FROM BitrixStockSyncReportLines WHERE ReportId = {0};",
 01871            EfSqlRawParameterGuard.Materialize(reportId),
 01872            cancellationToken);
 01873        await db.Database.ExecuteSqlRawAsync(
 01874            "DELETE FROM BitrixStockSyncReports WHERE ReportId = {0};",
 01875            EfSqlRawParameterGuard.Materialize(reportId),
 01876            cancellationToken);
 1877
 1878        // Сохраняем [{id,name},...] чтобы имена клиентов были доступны в списке отчётов без доп. запросов
 01879        var multiClientJson = System.Text.Json.JsonSerializer.Serialize(
 1880            allClients.Select(c => new { id = c.Id, name = c.Name ?? $"ID {c.Id}" }).ToList());
 01881        await db.Database.ExecuteSqlRawAsync(
 01882            @"INSERT INTO BitrixStockSyncReports
 01883(ReportId, GeneratedAtUtc, OzonClientId, OzonClientName, Status, SelectedStoresJson, SourceArticleFilter, MultiClientIds
 01884VALUES ({0}, UTC_TIMESTAMP(6), {1}, {2}, 'Running', {3}, {4}, {5});",
 01885            EfSqlRawParameterGuard.Materialize(reportId, primaryClient.Id, primaryClient.Name ?? $"ID {primaryClient.Id}
 01886            cancellationToken);
 01887    }
 1888
 1889    private static async Task InsertReportLinesAsync(
 1890        ApplicationDbContext db,
 1891        string reportId,
 1892        List<BitrixStockSyncReportWarehouseLineDto> lines,
 1893        CancellationToken cancellationToken)
 1894    {
 01895        if (lines.Count == 0) return;
 1896        const int chunkSize = 100;
 1897        const string header =
 1898            "INSERT INTO BitrixStockSyncReportLines" +
 1899            "(ReportId,ProductId,Article,OfferId,OneCStore,OzonWarehouseName,OzonWarehouseId,MappingDescription," +
 1900            "OneCQty,OzonQty,Delta,Action,ActionLabel,ActionIcon,RowClass,Note,OzonProductId,OzonApplyStatus," +
 1901            "IsArchived,IsAutoArchived,OzonClientId,OzonClientName,OneCPrice,PrevOneCPrice,ReservedQty," +
 1902            "BatchMultiplier,BatchBaseArticle,CreatedAtUtc) VALUES ";
 1903
 01904        foreach (var chunk in lines.Chunk(chunkSize))
 1905        {
 01906            cancellationToken.ThrowIfCancellationRequested();
 01907            var sb = new StringBuilder(header);
 01908            var parms = new List<object?>(chunk.Length * 27);
 01909            var rowIndex = 0;
 01910            foreach (var line in chunk)
 1911            {
 01912                if (rowIndex > 0) sb.Append(',');
 01913                var b = rowIndex * 27;
 01914                sb.Append($"({{{b}}},{{{b+1}}},{{{b+2}}},{{{b+3}}},{{{b+4}}},{{{b+5}}}," +
 01915                          $"{{{b+6}}},{{{b+7}}},{{{b+8}}},{{{b+9}}},{{{b+10}}},{{{b+11}}}," +
 01916                          $"{{{b+12}}},{{{b+13}}},{{{b+14}}},{{{b+15}}},{{{b+16}}},{{{b+17}}}," +
 01917                          $"{{{b+18}}},{{{b+19}}},{{{b+20}}},{{{b+21}}},{{{b+22}}},{{{b+23}}}," +
 01918                          $"{{{b+24}}},{{{b+25}}},{{{b+26}}},UTC_TIMESTAMP(6))");
 01919                parms.AddRange(new object?[] {
 01920                    reportId, line.ProductId, line.Article, line.OfferId, line.OneCStore,
 01921                    line.OzonWarehouseName, line.OzonWarehouseId, line.MappingDescription,
 01922                    line.OneCQty, line.OzonQty, line.Delta, line.Action, line.ActionLabel,
 01923                    line.ActionIcon, line.RowClass, line.Note, line.OzonProductId,
 01924                    GetInitialApplyStatus(line.Action),
 01925                    line.IsArchived ? 1 : 0, line.IsAutoArchived ? 1 : 0,
 01926                    line.OzonClientId, line.OzonClientName,
 01927                    (object?)line.OneCPrice, (object?)line.PrevOneCPrice,
 01928                    line.ReservedQty,
 01929                    line.BatchMultiplier,
 01930                    (object?)line.BatchBaseArticle
 01931                });
 01932                rowIndex++;
 1933            }
 01934            await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(parms), cancellationT
 1935        }
 01936    }
 1937
 1938    private static async Task UpdatePromoStatusAsync(
 1939        ApplicationDbContext db,
 1940        string reportId,
 1941        int clientId,
 1942        Dictionary<string, Services.ApiServices.OzonApi.Filters.OzonPromoStatusDto> promoResult,
 1943        CancellationToken ct)
 1944    {
 01945        foreach (var (offerId, status) in promoResult)
 1946        {
 1947            try
 1948            {
 01949                await db.Database.ExecuteSqlRawAsync(
 01950                    "UPDATE BitrixStockSyncReportLines SET InPromo=1, PromoName={0} WHERE ReportId={1} AND OfferId={2} A
 01951                    EfSqlRawParameterGuard.Materialize(status.PromoName, reportId, offerId, clientId),
 01952                    ct);
 01953            }
 01954            catch { /* ignore per-row errors */ }
 1955        }
 01956    }
 1957
 1958    private static async Task<HashSet<string>> GetSaleArticlesAsync(
 1959        ApplicationDbContext db, HashSet<string> articles, CancellationToken ct)
 1960    {
 01961        var result = await db.WarehouseStockSnapshots
 01962            .Where(s => s.Article != null
 01963                        && articles.Contains(s.Article)
 01964                        && s.SaleFlag != null
 01965                        && s.SaleFlag != "")
 01966            .Select(s => s.Article!)
 01967            .Distinct()
 01968            .ToListAsync(ct);
 01969        return new HashSet<string>(result, StringComparer.OrdinalIgnoreCase);
 01970    }
 1971
 1972    private const int SaleFlagUpdateChunkSize = 500;
 1973
 01974    private static string NormalizeArticleForIndex(string article) => article.Trim().ToLowerInvariant();
 1975
 1976    private static async Task UpdateSaleFlagPromoStatusAsync(
 1977        ApplicationDbContext db, string reportId, HashSet<string> saleArticles, Func<string, Task> log, CancellationToke
 1978    {
 01979        var articles = saleArticles.ToList();
 01980        for (var offset = 0; offset < articles.Count; offset += SaleFlagUpdateChunkSize)
 1981        {
 01982            var chunk = articles.Skip(offset).Take(SaleFlagUpdateChunkSize).ToList();
 1983            var placeholders = string.Join(", ", chunk.Select((_, i) => $"{{{i + 1}}}"));
 01984            var sql =
 01985                $"UPDATE BitrixStockSyncReportLines SET InPromo=1, PromoName='Распродажа' WHERE ReportId={{0}} AND Artic
 01986            var parameters = new object?[chunk.Count + 1];
 01987            parameters[0] = reportId;
 01988            for (var i = 0; i < chunk.Count; i++)
 01989                parameters[i + 1] = NormalizeArticleForIndex(chunk[i]);
 1990
 1991            try
 1992            {
 01993                await db.Database.ExecuteSqlRawAsync(sql, EfSqlRawParameterGuard.Materialize(parameters), ct);
 01994            }
 01995            catch (Exception ex)
 1996            {
 01997                await log($"Предупреждение: ошибка обновления SaleFlag-промо (чанк {offset / SaleFlagUpdateChunkSize + 1
 1998            }
 1999        }
 02000    }
 2001
 2002    private static async Task FinalizeReportStorageAsync(
 2003        ApplicationDbContext db,
 2004        string reportId,
 2005        BitrixStockSyncReportSummaryDto? summary,
 2006        string logText,
 2007        string? error,
 2008        CancellationToken cancellationToken)
 2009    {
 02010        db.Database.SetCommandTimeout(120);
 2011
 02012        if (summary == null)
 2013        {
 02014            await db.Database.ExecuteSqlRawAsync(
 02015                @"UPDATE BitrixStockSyncReports
 02016SET CompletedAtUtc = UTC_TIMESTAMP(6), Status = 'Failed', Warning = {1}, LogText = {2}
 02017WHERE ReportId = {0};",
 02018                EfSqlRawParameterGuard.Materialize(reportId, error ?? "Ошибка", logText),
 02019                cancellationToken);
 02020            return;
 2021        }
 2022
 02023        await db.Database.ExecuteSqlRawAsync(
 02024            @"UPDATE BitrixStockSyncReports
 02025SET CompletedAtUtc = UTC_TIMESTAMP(6), Status = 'Completed', Warning = {1}, LogText = {2},
 02026ProductsScanned = {3}, ProductsInReport = {4}, WarehouseLinesTotal = {5},
 02027MatchCount = {6}, PushCount = {7}, DecreaseCount = {8}, ClearCount = {9}, SkipCount = {10}, NotFoundCount = {11}
 02028WHERE ReportId = {0};",
 02029            EfSqlRawParameterGuard.Materialize(
 02030                reportId,
 02031                (object?)null,
 02032                logText,
 02033                summary.ProductsScanned,
 02034                summary.ProductsInReport,
 02035                summary.WarehouseLinesTotal,
 02036                summary.MatchCount,
 02037                summary.PushCount,
 02038                summary.DecreaseCount,
 02039                summary.ClearCount,
 02040                summary.SkipCount,
 02041                summary.NotFoundCount),
 02042            cancellationToken);
 02043    }
 2044
 2045    private static void AddParam(System.Data.Common.DbCommand command, string name, object? value)
 2046    {
 02047        var parameter = command.CreateParameter();
 02048        parameter.ParameterName = name;
 02049        parameter.Value = value ?? DBNull.Value;
 02050        command.Parameters.Add(parameter);
 02051    }
 2052
 2053    private static async Task PurgeOldCompletedReportsAsync(
 2054        ApplicationDbContext db,
 2055        int keepCount = 15,
 2056        CancellationToken cancellationToken = default)
 2057    {
 02058        await WithOpenConnectionAsync(db, async (conn, ct) =>
 02059        {
 02060            using var selectCmd = conn.CreateCommand();
 02061            selectCmd.CommandText = @"
 02062                SELECT ReportId FROM BitrixStockSyncReports
 02063                WHERE Status = 'Completed'
 02064                ORDER BY CompletedAtUtc DESC
 02065                LIMIT 99999 OFFSET @keep";
 02066            AddParam(selectCmd, "@keep", keepCount);
 02067
 02068            var toDelete = new List<string>();
 02069            await using var reader = await selectCmd.ExecuteReaderAsync(ct);
 02070            while (await reader.ReadAsync(ct))
 02071                toDelete.Add(reader.GetString(0));
 02072            await reader.CloseAsync();
 02073
 02074            if (toDelete.Count == 0) return 0;
 02075
 02076            // Удаляем по одному репорту — каждый DELETE маленький и не вызывает таймаут.
 02077            // Порядок: сначала дочерние таблицы (Lines, OzonPrices), потом заголовок.
 02078            var tables = new[] { "BitrixStockSyncReportLines", "BitrixStockSyncReportOzonPrices", "BitrixStockSyncReport
 02079            foreach (var reportId in toDelete)
 02080            {
 02081                foreach (var table in tables)
 02082                {
 02083                    using var delCmd = conn.CreateCommand();
 02084                    delCmd.CommandTimeout = 120;
 02085                    delCmd.CommandText = $"DELETE FROM `{table}` WHERE ReportId = @rid";
 02086                    AddParam(delCmd, "@rid", reportId);
 02087                    await delCmd.ExecuteNonQueryAsync(ct);
 02088                }
 02089            }
 02090            return toDelete.Count;
 02091        }, cancellationToken);
 02092    }
 2093
 2094    public static async Task RefreshReportOzonApplyCountsAsync(
 2095        ApplicationDbContext db,
 2096        string reportId,
 2097        CancellationToken cancellationToken = default)
 2098    {
 02099        if (string.IsNullOrWhiteSpace(reportId))
 2100        {
 02101            return;
 2102        }
 2103
 02104        db.Database.SetCommandTimeout(60);
 02105        await db.Database.ExecuteSqlRawAsync(
 02106            @"UPDATE BitrixStockSyncReports r
 02107SET OzonAppliedSuccessCount = (
 02108    SELECT COUNT(*) FROM BitrixStockSyncReportLines l
 02109    WHERE l.ReportId = r.ReportId AND l.OzonApplyStatus = {1}),
 02110    OzonApplyFailedCount = (
 02111    SELECT COUNT(*) FROM BitrixStockSyncReportLines l
 02112    WHERE l.ReportId = r.ReportId AND l.OzonApplyStatus = {2}),
 02113    OzonApplyLastAtUtc = UTC_TIMESTAMP(6)
 02114WHERE r.ReportId = {0};",
 02115            EfSqlRawParameterGuard.Materialize(
 02116                reportId.Trim(),
 02117                BitrixStockSyncReportApplyStatuses.Success,
 02118                BitrixStockSyncReportApplyStatuses.Failed),
 02119            cancellationToken);
 02120    }
 2121
 2122    public static async Task RefreshReportOzonCleanupCountsAsync(
 2123        ApplicationDbContext db,
 2124        string reportId,
 2125        CancellationToken cancellationToken = default)
 2126    {
 02127        if (string.IsNullOrWhiteSpace(reportId))
 02128            return;
 2129
 02130        db.Database.SetCommandTimeout(60);
 02131        await db.Database.ExecuteSqlRawAsync(
 02132            @"UPDATE BitrixStockSyncReports r
 02133SET OzonCleanupSuccessCount = (
 02134    SELECT COUNT(*) FROM BitrixStockSyncReportLines l
 02135    WHERE l.ReportId = r.ReportId AND l.OzonCleanupStatus = {1}),
 02136    OzonCleanupFailedCount = (
 02137    SELECT COUNT(*) FROM BitrixStockSyncReportLines l
 02138    WHERE l.ReportId = r.ReportId AND l.OzonCleanupStatus = {2}),
 02139    OzonCleanupLastAtUtc = UTC_TIMESTAMP(6)
 02140WHERE r.ReportId = {0};",
 02141            EfSqlRawParameterGuard.Materialize(
 02142                reportId.Trim(),
 02143                BitrixStockSyncCleanupStatuses.Success,
 02144                BitrixStockSyncCleanupStatuses.Failed),
 02145            cancellationToken);
 02146    }
 2147
 2148    private static string BuildLinesWhereSql(IReadOnlyList<string> oneCStores, IReadOnlyList<int> clientIds, IReadOnlyLi
 2149    {
 142150        var sql = @" WHERE ReportId = @reportId
 142151AND (@article IS NULL OR Article LIKE CONCAT('%', @article, '%'))
 142152AND (@ozonWarehouse IS NULL OR OzonWarehouseName LIKE CONCAT('%', @ozonWarehouse, '%'))
 142153AND (@article IS NOT NULL OR @onlyRequires = 0 OR Action IN ('Push','Decrease','Clear'))
 142154AND (@article IS NOT NULL OR @onlyOzonSuccess = 0 OR OzonApplyStatus = @ozonSuccessStatus)
 142155AND (@article IS NOT NULL OR @onlyOzonFailed = 0 OR OzonApplyStatus = @ozonFailedStatus)
 142156AND (@onlyArchived = 0 OR IsArchived = 1)
 142157AND (@onlyArchived = 1 OR @article IS NOT NULL OR @showZero = 1 OR Action = 'CleanupExtra' OR NOT (OneCQty = 0 AND OzonQ
 142158AND (@onlyOrphan = 0 OR IsOzonOrphan = 1)";
 142159        if (actions != null && actions.Count > 0)
 2160        {
 82161            var inClause = string.Join(", ", actions.Select((_, i) => $"@act{i}"));
 32162            sql += $" AND (@article IS NOT NULL OR Action IN ({inClause}))";
 2163        }
 142164        if (oneCStores.Count > 0)
 2165        {
 72166            var inClause = string.Join(", ", oneCStores.Select((_, i) => $"@oneC{i}"));
 32167            sql += $" AND OneCStore IN ({inClause})";
 2168        }
 142169        if (clientIds.Count > 0)
 2170        {
 62171            var inClause = string.Join(", ", clientIds.Select((_, i) => $"@cli{i}"));
 22172            sql += $" AND OzonClientId IN ({inClause})";
 2173        }
 142174        return sql;
 2175    }
 2176
 2177    private static void AddLineFilterParams(
 2178        System.Data.Common.DbCommand command,
 2179        string reportId,
 2180        string? articleFilter,
 2181        string? ozonWarehouseFilter,
 2182        IReadOnlyList<string> oneCStores,
 2183        IReadOnlyList<string>? actions,
 2184        bool onlyRequiresAction,
 2185        bool onlyOzonAppliedSuccess,
 2186        bool onlyOzonAppliedFailed = false,
 2187        bool onlyArchived = false,
 2188        IReadOnlyList<int>? clientIds = null,
 2189        bool showZeroRows = false,
 2190        bool onlyOzonOrphan = false)
 2191    {
 02192        AddParam(command, "@reportId", reportId);
 02193        AddParam(command, "@article", articleFilter);
 02194        AddParam(command, "@ozonWarehouse", ozonWarehouseFilter);
 02195        AddParam(command, "@onlyRequires", onlyRequiresAction ? 1 : 0);
 02196        AddParam(command, "@onlyOzonSuccess", onlyOzonAppliedSuccess ? 1 : 0);
 02197        AddParam(command, "@ozonSuccessStatus", BitrixStockSyncReportApplyStatuses.Success);
 02198        AddParam(command, "@onlyOzonFailed", onlyOzonAppliedFailed ? 1 : 0);
 02199        AddParam(command, "@ozonFailedStatus", BitrixStockSyncReportApplyStatuses.Failed);
 02200        AddParam(command, "@onlyArchived", onlyArchived ? 1 : 0);
 02201        AddParam(command, "@showZero", showZeroRows ? 1 : 0);
 02202        AddParam(command, "@onlyOrphan", onlyOzonOrphan ? 1 : 0);
 02203        if (actions != null)
 02204            for (var i = 0; i < actions.Count; i++)
 02205                AddParam(command, $"@act{i}", actions[i]);
 02206        for (var i = 0; i < oneCStores.Count; i++)
 02207            AddParam(command, $"@oneC{i}", oneCStores[i]);
 02208        if (clientIds != null)
 02209            for (var i = 0; i < clientIds.Count; i++)
 02210                AddParam(command, $"@cli{i}", clientIds[i]);
 02211    }
 2212
 2213    private static async Task<Dictionary<string, decimal>> LoadPreviousPricesAsync(
 2214        ApplicationDbContext db, string currentReportId, CancellationToken ct)
 2215    {
 02216        var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
 02217        var conn = db.Database.GetDbConnection();
 02218        var wasOpen = conn.State == System.Data.ConnectionState.Open;
 02219        if (!wasOpen) await conn.OpenAsync(ct);
 2220        try
 2221        {
 02222            await using var cmd = conn.CreateCommand();
 02223            cmd.CommandText = @"
 02224                SELECT l.Article, MAX(l.OneCPrice)
 02225                FROM BitrixStockSyncReportLines l
 02226                WHERE l.ReportId = (
 02227                    SELECT r.ReportId FROM BitrixStockSyncReports r
 02228                    WHERE r.Status = 'Completed' AND r.ReportId != @rid
 02229                    ORDER BY r.CompletedAtUtc DESC LIMIT 1
 02230                ) AND l.OneCPrice IS NOT NULL
 02231                GROUP BY l.Article";
 02232            var p = cmd.CreateParameter();
 02233            p.ParameterName = "@rid";
 02234            p.Value = currentReportId;
 02235            cmd.Parameters.Add(p);
 02236            await using var reader = await cmd.ExecuteReaderAsync(ct);
 02237            while (await reader.ReadAsync(ct))
 02238                result[reader.GetString(0)] = Convert.ToDecimal(reader.GetValue(1));
 02239        }
 2240        finally
 2241        {
 02242            if (!wasOpen) await conn.CloseAsync();
 2243        }
 02244        return result;
 02245    }
 2246
 2247    private static string? GetInitialApplyStatus(string? action) =>
 02248        !string.IsNullOrEmpty(action) && BitrixStockSyncReportActions.ApplicableToOzon.Contains(action)
 02249            ? BitrixStockSyncReportApplyStatuses.Pending
 02250            : null;
 2251
 2252    private sealed class OzonPriceInfoQueryRow : OzonPriceSnapshotRow
 2253    {
 02254        public string? PricePushStatus { get; set; }
 02255        public string? PricePushError  { get; set; }
 2256    }
 2257
 2258    public async Task<List<OzonPriceInfoDto>> GetOzonPriceInfoAsync(string reportId, CancellationToken cancellationToken
 2259    {
 02260        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02261        var rows = await db.Database
 02262            .SqlQueryRaw<OzonPriceInfoQueryRow>(
 02263                "SELECT Article, OfferId, ClientName, OzonClientId, PriceDataJson, PricePushedOld, PricePushedNew, Price
 02264                reportId)
 02265            .ToListAsync(cancellationToken);
 2266
 02267        var result = new List<OzonPriceInfoDto>(rows.Count);
 02268        foreach (var row in rows)
 2269        {
 2270            try
 2271            {
 02272                var item = JObject.Parse(row.PriceDataJson);
 2273                // поддержка старого формата (только price-объект) и нового (полный item)
 02274                var priceObj   = item["price"] as JObject ?? item;
 02275                var indexesObj = item["price_indexes"] as JObject;
 2276
 02277                var truePrice    = priceObj["price"]?.Value<decimal?>()
 02278                               ?? priceObj["customer_price"]?.Value<decimal?>()
 02279                               ?? priceObj["true_price"]?.Value<decimal?>();
 02280                var sellingPrice = priceObj["marketing_seller_price"]?.Value<decimal?>()
 02281                               ?? priceObj["price"]?.Value<decimal?>();
 02282                var colorIndex   = indexesObj?["color_index"]?.Value<string>();
 2283
 02284                result.Add(new OzonPriceInfoDto
 02285                {
 02286                    Article                = row.Article,
 02287                    OfferId                = row.OfferId,
 02288                    OzonClientId           = row.OzonClientId,
 02289                    TruePrice              = truePrice,
 02290                    SellingPrice           = sellingPrice,
 02291                    ColorIndex             = colorIndex,
 02292                    PricePushedOld         = row.PricePushedOld,
 02293                    PricePushedNew         = row.PricePushedNew,
 02294                    PricePushedAtUtc       = row.PricePushedAtUtc,
 02295                    PushedMarkupPct        = row.PushedMarkupPct,
 02296                    PushedColorIndexBefore = row.PushedColorIndexBefore,
 02297                    PushedColorIndexAfter  = row.PushedColorIndexAfter,
 02298                    PushedCompetitorPrice  = row.PushedCompetitorPrice,
 02299                    PricePushStatus        = row.PricePushStatus,
 02300                    PricePushError         = row.PricePushError,
 02301                });
 02302            }
 02303            catch { /* пропустить повреждённые записи */ }
 2304        }
 02305        return result;
 02306    }
 2307
 2308    public async Task UpdateOzonPriceSnapshotAsync(string reportId, string article, int ozonClientId, string freshPriceD
 2309    {
 02310        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02311        db.Database.SetCommandTimeout(30);
 02312        string? colorIndexAfter = null;
 2313        try
 2314        {
 02315            var item = Newtonsoft.Json.Linq.JObject.Parse(freshPriceDataJson);
 02316            colorIndexAfter = item["price_indexes"]?["color_index"]?.Value<string>();
 02317        }
 02318        catch { /* ignore parse errors */ }
 2319        // Сохраняем ColorIndexAfter только для строк, по которым уже был накат
 02320        await db.Database.ExecuteSqlRawAsync(
 02321            "UPDATE BitrixStockSyncReportOzonPrices SET PriceDataJson = {0}, PushedColorIndexAfter = CASE WHEN PricePush
 02322            EfSqlRawParameterGuard.Materialize(freshPriceDataJson, reportId, article, ozonClientId, colorIndexAfter));
 02323    }
 2324
 2325    public async Task RecordPricePushAsync(string reportId, string article, int ozonClientId, decimal oldPrice, decimal 
 2326        decimal? markupPct = null, string? colorIndexBefore = null, decimal? competitorPrice = null,
 2327        CancellationToken cancellationToken = default)
 2328    {
 02329        if (string.IsNullOrWhiteSpace(reportId)) return;
 02330        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02331        db.Database.SetCommandTimeout(30);
 02332        await db.Database.ExecuteSqlRawAsync(
 02333            "UPDATE BitrixStockSyncReportOzonPrices SET PricePushedOld = {0}, PricePushedNew = {1}, PricePushedAtUtc = U
 02334            EfSqlRawParameterGuard.Materialize(oldPrice, newPrice, reportId, article, ozonClientId, markupPct, colorInde
 02335    }
 2336
 2337    public async Task RecordPricePushStatusAsync(string reportId, string article, int ozonClientId,
 2338        string status, string? error = null, CancellationToken cancellationToken = default)
 2339    {
 02340        if (string.IsNullOrWhiteSpace(reportId)) return;
 02341        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02342        db.Database.SetCommandTimeout(30);
 02343        await db.Database.ExecuteSqlRawAsync(
 02344            "UPDATE BitrixStockSyncReportOzonPrices SET PricePushStatus = {0}, PricePushError = {4} WHERE ReportId = {1}
 02345            EfSqlRawParameterGuard.Materialize(status, reportId, article, ozonClientId, error));
 02346    }
 2347
 2348    public async Task<HashSet<string>> GetOzonPricedArticlesAsync(string reportId, CancellationToken cancellationToken =
 2349    {
 02350        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02351        var articles = await db.Database
 02352            .SqlQueryRaw<string>(
 02353                "SELECT DISTINCT Article FROM BitrixStockSyncReportOzonPrices WHERE ReportId={0}",
 02354                EfSqlRawParameterGuard.Materialize(reportId))
 02355            .ToListAsync(cancellationToken);
 02356        return new HashSet<string>(articles, StringComparer.OrdinalIgnoreCase);
 02357    }
 2358
 2359    public async Task<BitrixStockSyncReportAggregatesDto> GetReportAggregatesAsync(string reportId, CancellationToken ca
 2360    {
 02361        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02362        return await WithOpenConnectionAsync(db, async (conn, ct) =>
 02363        {
 02364            var result = new BitrixStockSyncReportAggregatesDto();
 02365
 02366            // Клиенты
 02367            using (var cmd = conn.CreateCommand())
 02368            {
 02369                cmd.CommandText = "SELECT COALESCE(OzonClientId,0), COALESCE(OzonClientName,''), COUNT(*) FROM BitrixSto
 02370                AddParam(cmd, "@rid", reportId);
 02371                await using var r = await cmd.ExecuteReaderAsync(ct);
 02372                while (await r.ReadAsync(ct))
 02373                {
 02374                    var key = r.GetInt32(0).ToString();
 02375                    result.ClientCounts[key] = r.GetInt32(2);
 02376                    result.ClientNames[key] = r.GetString(1);
 02377                }
 02378            }
 02379
 02380            // Δ C0 — повторяет логику getDeltaC0Category() из JS
 02381            using (var cmd = conn.CreateCommand())
 02382            {
 02383                cmd.CommandText = @"
 02384SELECT
 02385  CASE
 02386    WHEN OneCPrice IS NULL OR PrevOneCPrice IS NULL THEN 'none'
 02387    WHEN ROUND(OneCPrice) = ROUND(PrevOneCPrice) THEN 'equal'
 02388    WHEN PrevOneCPrice = 0 AND ROUND(OneCPrice) > 0 THEN 'up'
 02389    WHEN PrevOneCPrice = 0 AND ROUND(OneCPrice) < 0 THEN 'down'
 02390    WHEN ABS((ROUND(OneCPrice) - ROUND(PrevOneCPrice)) / PrevOneCPrice) < 0.05 THEN 'similar'
 02391    WHEN ROUND(OneCPrice) > ROUND(PrevOneCPrice) THEN 'up'
 02392    ELSE 'down'
 02393  END AS dc0cat,
 02394  COUNT(*) AS cnt
 02395FROM BitrixStockSyncReportLines
 02396WHERE ReportId = @rid
 02397GROUP BY dc0cat";
 02398                AddParam(cmd, "@rid", reportId);
 02399                await using var r = await cmd.ExecuteReaderAsync(ct);
 02400                while (await r.ReadAsync(ct))
 02401                    result.DeltaCounts[r.GetString(0)] = r.GetInt32(1);
 02402            }
 02403
 02404            // FbsNoWarehouse / NotForSale
 02405            using (var cmd = conn.CreateCommand())
 02406            {
 02407                cmd.CommandText = "SELECT Action, COUNT(*) FROM BitrixStockSyncReportLines WHERE ReportId=@rid AND Actio
 02408                AddParam(cmd, "@rid", reportId);
 02409                await using var r = await cmd.ExecuteReaderAsync(ct);
 02410                while (await r.ReadAsync(ct))
 02411                    result.ActionCounts[r.GetString(0)] = r.GetInt32(1);
 02412            }
 02413
 02414            return result;
 02415        }, cancellationToken);
 02416    }
 2417
 2418    public async Task<List<OzonPriceSnapshotRow>> GetOzonPriceSnapshotsAsync(
 2419        string reportId, string? article, CancellationToken cancellationToken = default)
 2420    {
 02421        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 2422        const string allCols = "ClientName,Article,OfferId,OzonClientId,PriceDataJson,PricePushedOld,PricePushedNew,Pric
 02423        if (string.IsNullOrWhiteSpace(article))
 2424        {
 02425            return await db.Database
 02426                .SqlQueryRaw<OzonPriceSnapshotRow>(
 02427                    $"SELECT {allCols} FROM BitrixStockSyncReportOzonPrices WHERE ReportId={{0}} ORDER BY ClientName,Art
 02428                    reportId)
 02429                .ToListAsync(cancellationToken);
 2430        }
 02431        return await db.Database
 02432            .SqlQueryRaw<OzonPriceSnapshotRow>(
 02433                $"SELECT {allCols} FROM BitrixStockSyncReportOzonPrices WHERE ReportId={{0}} AND Article={{1}} ORDER BY 
 02434                reportId, article.Trim())
 02435            .ToListAsync(cancellationToken);
 02436    }
 2437
 2438    private static async Task SaveOzonPriceSnapshotsAsync(
 2439        ApplicationDbContext db,
 2440        string reportId,
 2441        OzonApiDataManager apiManager,
 2442        OzonClient client,
 2443        HashSet<string> offerIds,
 2444        CancellationToken ct)
 2445    {
 02446        var snapshots = new List<OzonPriceSnapshotRecord>();
 02447        foreach (var chunk in offerIds.Chunk(500))
 2448        {
 02449            ct.ThrowIfCancellationRequested();
 2450            JObject? result;
 02451            try { result = await apiManager.GetProductPricesByArticles(chunk); }
 02452            catch { continue; }
 02453            if (result?["items"] == null) continue;
 2454
 02455            foreach (var item in result["items"]!)
 2456            {
 02457                if (item["price"] == null) continue;
 02458                EnrichOzonPriceItem(item);
 02459                var offerId = item["offer_id"]?.Value<string>() ?? "";
 02460                var article = offerId.Contains('=')
 02461                    ? offerId[..offerId.IndexOf('=')]
 02462                    : offerId;
 02463                snapshots.Add(new OzonPriceSnapshotRecord
 02464                {
 02465                    ReportId      = reportId,
 02466                    Article       = article,
 02467                    OfferId       = offerId,
 02468                    OzonClientId  = client.Id,
 02469                    ClientName    = client.Name ?? $"ID {client.Id}",
 02470                    PriceDataJson = item.ToString(Newtonsoft.Json.Formatting.None)
 02471                });
 2472            }
 2473        }
 02474        if (snapshots.Count > 0)
 02475            await InsertOzonPriceSnapshotsAsync(db, snapshots, ct);
 02476    }
 2477
 2478    private static void EnrichOzonPriceItem(JToken item)
 2479    {
 02480        var commissions = item["commissions"];
 02481        var priceObj    = item["price"] as JObject;
 02482        if (commissions == null || priceObj == null) return;
 2483
 02484        decimal salesPercent    = commissions["sales_percent_fbs"]?.Value<decimal>()             ?? 0;
 02485        decimal itemPrice       = priceObj["price"]?.Value<decimal>()                             ?? 0;
 02486        decimal acquiring       = item["acquiring"]?.Value<decimal>()                             ?? 0;
 02487        decimal fbsFirstMileMin = commissions["fbs_first_mile_min_amount"]?.Value<decimal>()     ?? 0;
 02488        decimal fbsFirstMileMax = commissions["fbs_first_mile_max_amount"]?.Value<decimal>()     ?? 0;
 02489        decimal fbsDirectMin    = commissions["fbs_direct_flow_trans_min_amount"]?.Value<decimal>() ?? 0;
 02490        decimal fbsDirectMax    = commissions["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0;
 02491        decimal fboDeliv        = commissions["fbo_deliv_to_customer_amount"]?.Value<decimal>()  ?? 0;
 2492
 02493        decimal ozonReward    = itemPrice * salesPercent / 100;
 02494        decimal maxCommission = ozonReward + acquiring + fbsFirstMileMax + fbsDirectMax + fboDeliv;
 02495        decimal minCommission = ozonReward + acquiring + fbsFirstMileMin + fbsDirectMin + fboDeliv;
 02496        double  marketingPrice = priceObj["marketing_seller_price"]?.Value<double>() ?? 0;
 02497        double  truePrice      = marketingPrice * 0.91;
 2498
 02499        priceObj["max_commission"] = Math.Round(maxCommission, 2);
 02500        priceObj["min_commission"] = Math.Round(minCommission, 2);
 02501        priceObj["ozon_reward"]    = Math.Round(ozonReward, 2);
 02502        priceObj["true_price"]     = Math.Round(truePrice, 2);
 02503    }
 2504
 2505    private static async Task InsertOzonPriceSnapshotsAsync(
 2506        ApplicationDbContext db,
 2507        List<OzonPriceSnapshotRecord> snapshots,
 2508        CancellationToken ct)
 2509    {
 2510        const string header =
 2511            "INSERT INTO BitrixStockSyncReportOzonPrices" +
 2512            "(ReportId,Article,OfferId,OzonClientId,ClientName,PriceDataJson,SnapshotAt) VALUES ";
 02513        foreach (var chunk in snapshots.Chunk(50))
 2514        {
 02515            ct.ThrowIfCancellationRequested();
 02516            var sb    = new StringBuilder(header);
 02517            var parms = new List<object?>(chunk.Length * 6);
 02518            var idx   = 0;
 02519            foreach (var s in chunk)
 2520            {
 02521                if (idx > 0) sb.Append(',');
 02522                var b = idx * 6;
 02523                sb.Append($"({{{b}}},{{{b+1}}},{{{b+2}}},{{{b+3}}},{{{b+4}}},{{{b+5}}},UTC_TIMESTAMP(6))");
 02524                parms.AddRange(new object?[] { s.ReportId, s.Article, s.OfferId, s.OzonClientId, s.ClientName, s.PriceDa
 02525                idx++;
 2526            }
 02527            await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(parms), ct);
 2528        }
 02529    }
 2530
 2531    // ─── Promo status & price settings ────────────────────────────────────────
 2532
 2533    public async Task<List<ReportPromoStatusDto>> GetReportPromosAsync(string reportId, CancellationToken cancellationTo
 2534    {
 02535        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02536        return await WithOpenConnectionAsync(db, async (conn, ct) =>
 02537        {
 02538            var result = new List<ReportPromoStatusDto>();
 02539            using var cmd = conn.CreateCommand();
 02540            // Live JOIN с WarehouseStockSnapshot по SaleFlag — не зависит от кэша InPromo в строках отчёта
 02541            cmd.CommandText = @"
 02542SELECT DISTINCT l.Article, COALESCE(l.OzonClientId, 0)
 02543FROM BitrixStockSyncReportLines l
 02544INNER JOIN (
 02545    SELECT DISTINCT Article
 02546    FROM WarehouseStockSnapshot
 02547    WHERE SaleFlag IS NOT NULL AND TRIM(SaleFlag) != ''
 02548) s ON l.Article = s.Article
 02549WHERE l.ReportId = @rid";
 02550            AddParam(cmd, "@rid", reportId);
 02551            await using var r = await cmd.ExecuteReaderAsync(ct);
 02552            while (await r.ReadAsync(ct))
 02553            {
 02554                result.Add(new ReportPromoStatusDto
 02555                {
 02556                    Article      = r.GetString(0),
 02557                    OzonClientId = r.GetInt32(1),
 02558                    InPromo      = true,
 02559                    PromoName    = "Распродажа"
 02560                });
 02561            }
 02562            return result;
 02563        }, cancellationToken);
 02564    }
 2565
 2566    public async Task<ReportPriceSettingsDto?> GetReportPriceSettingsAsync(string reportId, CancellationToken cancellati
 2567    {
 02568        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02569        return await WithOpenConnectionAsync(db, async (conn, ct) =>
 02570        {
 02571            using var cmd = conn.CreateCommand();
 02572            cmd.CommandText = "SELECT PriceSettingsJson FROM BitrixStockSyncReports WHERE ReportId=@rid LIMIT 1";
 02573            AddParam(cmd, "@rid", reportId);
 02574            var raw = await cmd.ExecuteScalarAsync(ct);
 02575            if (raw == null || raw == DBNull.Value) return null;
 02576            var json = raw.ToString();
 02577            if (string.IsNullOrWhiteSpace(json)) return null;
 02578            try
 02579            {
 02580                return System.Text.Json.JsonSerializer.Deserialize<ReportPriceSettingsDto>(json);
 02581            }
 02582            catch { return null; }
 02583        }, cancellationToken);
 02584    }
 2585
 2586    public async Task<(decimal? c0, decimal? c1)> GetArticleC1PriceAsync(string article, CancellationToken cancellationT
 2587    {
 02588        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02589        return await WithOpenConnectionAsync(db, async (conn, ct) =>
 02590        {
 02591            decimal? c0 = null, c1 = null;
 02592            using var cmd = conn.CreateCommand();
 02593            cmd.CommandText = "SELECT C0, C1ExistMinus5Percent FROM WarehouseStockSnapshot WHERE Article = @article COLL
 02594            AddParam(cmd, "@article", article);
 02595            await using var reader = await cmd.ExecuteReaderAsync(ct);
 02596            if (await reader.ReadAsync(ct))
 02597            {
 02598                if (!reader.IsDBNull(0)) c0 = Convert.ToDecimal(reader.GetValue(0));
 02599                if (!reader.IsDBNull(1)) c1 = Convert.ToDecimal(reader.GetValue(1));
 02600            }
 02601            return (c0, c1);
 02602        }, cancellationToken);
 02603    }
 2604
 2605    /// <summary>
 2606    /// Данные WarehouseStockSnapshot для базового артикула (без [N]).
 2607    /// TotalQty = сумма всех складских остатков (M1+M2+M3+B1+B2) из первой найденной строки.
 2608    /// Возвращает (null, null, 0) если артикул не найден.
 2609    /// </summary>
 2610    public async Task<(decimal? c0, decimal? c1, int totalQty)> GetArticleSnapshotAsync(
 2611        string article, CancellationToken cancellationToken = default)
 2612    {
 02613        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 02614        return await WithOpenConnectionAsync(db, async (conn, ct) =>
 02615        {
 02616            decimal? c0 = null, c1 = null;
 02617            var totalQty = 0;
 02618            using var cmd = conn.CreateCommand();
 02619            cmd.CommandText =
 02620                "SELECT C0, C1ExistMinus5Percent, " +
 02621                "CAST(COALESCE(M1Qty,0) + COALESCE(M2Qty,0) + COALESCE(M3Qty,0) + COALESCE(B1Qty,0) + COALESCE(B2Qty,0) 
 02622                "FROM WarehouseStockSnapshot WHERE Article = @article COLLATE utf8mb4_unicode_ci LIMIT 1";
 02623            AddParam(cmd, "@article", article);
 02624            await using var reader = await cmd.ExecuteReaderAsync(ct);
 02625            if (await reader.ReadAsync(ct))
 02626            {
 02627                if (!reader.IsDBNull(0)) c0 = Convert.ToDecimal(reader.GetValue(0));
 02628                if (!reader.IsDBNull(1)) c1 = Convert.ToDecimal(reader.GetValue(1));
 02629                if (!reader.IsDBNull(2)) totalQty = Math.Max(0, Convert.ToInt32(reader.GetValue(2)));
 02630            }
 02631            return (c0, c1, totalQty);
 02632        }, cancellationToken);
 02633    }
 2634}

Methods/Properties

.cctor()
.ctor(Services.BitrixStockServices.IWarehouseStockBitrixViewService,Services.DataServices.OzonClientServices,System.Net.Http.IHttpClientFactory,SlqStudio.Application.Services.EmailService.IEmailService,Services.DataServices.WarehouseMappingDataServices,OzonRepositories.Data.EtProducerRepository,Microsoft.Extensions.Caching.Memory.IMemoryCache,Microsoft.EntityFrameworkCore.IDbContextFactory`1<OzonRepositories.Context.ApplicationDbContext>,Microsoft.AspNetCore.SignalR.IHubContext`1<Services.SignalRServices.JobProgressHub>,Services.BitrixStockServices.BitrixStockActiveStageStore,Services.BitrixStockServices.IBitrixStockExceptionService,Services.BitrixStockServices.IBitrixStockLiveStockComparer)
BuildReportAsync()
Log()
Progress()
Complete()
BuildFilter(Services.BitrixStockServices.BitrixStockSyncReportStartRequest,System.Int32)
LoadOzonForBatchAsync()
GetReportAsync()
<GetReportAsync()
ListReportsAsync()
<ListReportsAsync()
FillOrphanAndPricePushAggregatesAsync()
GetReportOneCStoresAsync()
<GetReportOneCStoresAsync()
GetReportMappingCombosAsync()
<GetReportMappingCombosAsync()
GetReportLogTextAsync()
<GetReportLogTextAsync()
GetArchivedPreviewAsync()
<GetArchivedPreviewAsync()
BuildExceptionSkipLines(OzonDomains.Models.BitrixModels.RemainingStockBitrix,System.Collections.Generic.HashSet`1<System.String>,System.String)
BuildProductLines(OzonDomains.Models.BitrixModels.RemainingStockBitrix,OzonDomains.Models.OzonClient,System.Collections.Generic.List`1<OzonDomains.Models.WarehouseMapping>,System.Collections.Generic.HashSet`1<System.Int64>,System.Collections.Generic.HashSet`1<System.Int32>,Services.BitrixStockServices.BitrixStockSyncReportSummaryDto,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Collections.Generic.Dictionary`2<System.String,System.Int32>>)
BuildNotFoundLines(OzonDomains.Models.BitrixModels.RemainingStockBitrix,System.Collections.Generic.HashSet`1<System.String>,Services.BitrixStockServices.OzonApiFoundInfo)
LoadOrderReservesAsync()
GetOrderReserveMapAsync()
ResolveReserveStatusIdsAsync()
LoadDefaultReserveStatusNamesAsync()
GetReserveStatusOptionsAsync()
IncrementSummary(Services.BitrixStockServices.BitrixStockSyncReportSummaryDto,System.String)
LoadProductStocksChunkedParallelAsync()
<LoadProductStocksChunkedParallelAsync()
ApplySupplierPrefixesAsync()
ParseMultiClientIds(System.String)
ParseMultiClientNames(System.String)
LoadArchiveStatusAsync()
<LoadArchiveStatusAsync()
GetOrCreateWarehouseCacheAsync()
WithOpenConnectionAsync()
PrepareReportStorageAsync()
InsertReportLinesAsync()
UpdatePromoStatusAsync()
GetSaleArticlesAsync()
NormalizeArticleForIndex(System.String)
UpdateSaleFlagPromoStatusAsync()
FinalizeReportStorageAsync()
AddParam(System.Data.Common.DbCommand,System.String,System.Object)
PurgeOldCompletedReportsAsync()
<PurgeOldCompletedReportsAsync()
RefreshReportOzonApplyCountsAsync()
RefreshReportOzonCleanupCountsAsync()
BuildLinesWhereSql(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.Int32>,System.Collections.Generic.IReadOnlyList`1<System.String>)
AddLineFilterParams(System.Data.Common.DbCommand,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Collections.Generic.IReadOnlyList`1<System.Int32>,System.Boolean,System.Boolean)
LoadPreviousPricesAsync()
GetInitialApplyStatus(System.String)
get_PricePushStatus()
get_PricePushError()
GetOzonPriceInfoAsync()
UpdateOzonPriceSnapshotAsync()
RecordPricePushAsync()
RecordPricePushStatusAsync()
GetOzonPricedArticlesAsync()
GetReportAggregatesAsync()
<GetReportAggregatesAsync()
GetOzonPriceSnapshotsAsync()
SaveOzonPriceSnapshotsAsync()
EnrichOzonPriceItem(Newtonsoft.Json.Linq.JToken)
InsertOzonPriceSnapshotsAsync()
GetReportPromosAsync()
<GetReportPromosAsync()
GetReportPriceSettingsAsync()
<GetReportPriceSettingsAsync()
GetArticleC1PriceAsync()
<GetArticleC1PriceAsync()
GetArticleSnapshotAsync()
<GetArticleSnapshotAsync()