< Summary

Line coverage
4%
Covered lines: 56
Uncovered lines: 1207
Coverable lines: 1263
Total lines: 1970
Line coverage: 4.4%
Branch coverage
5%
Covered branches: 37
Total branches: 696
Branch coverage: 5.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: .ctor(...)100%210%
File 1: ApplyReportAsync()0%607622460%
File 1: Log()0%620%
File 1: Progress()0%7280%
File 1: Complete()0%620%
File 1: LogLine()0%420200%
File 1: RetryRateLimitedAsync()0%156120%
File 1: <ApplyReportAsync()0%156120%
File 1: <ApplyReportAsync()0%272160%
File 1: ApplySingleAsync()0%156120%
File 1: ApplyLiveStockGuardAsync()0%272160%
File 1: GetTargetStock(...)100%210%
File 1: ResolveApplyActions(...)0%2040%
File 1: LoadReportMetaAsync()100%210%
File 1: <LoadReportMetaAsync()0%110100%
File 1: LoadClientReferenceLinesAsync()0%620%
File 1: <LoadClientReferenceLinesAsync()0%600240%
File 1: LoadClientLinesWithPositiveStockAsync()100%210%
File 1: <LoadClientLinesWithPositiveStockAsync()0%1190340%
File 1: LoadLinesToApplyAsync()100%210%
File 1: <LoadLinesToApplyAsync()0%2756520%
File 1: MarkLineAsync()0%620%
File 1: TryUpdateBatchAsync()0%342180%
File 1: FormatStockErrors(...)0%156120%
File 1: LoadAllReportOfferIdsAsync()100%210%
File 1: <LoadAllReportOfferIdsAsync()0%7280%
File 1: LoadOfferArchiveTypesAsync()100%210%
File 1: <LoadOfferArchiveTypesAsync()0%110100%
File 1: WithOpenConnectionAsync()100%210%
File 1: AddParam(...)0%620%
File 1: get_OzonClientId()100%11100%
File 1: ApplyPriorityForTwoClients(...)76.31%443884.37%
File 1: IsFoundForPriority(...)58.33%1212100%
File 1: MarkSkip(...)100%11100%
File 1: GetPriorityKey(...)100%210%
File 1: IsNotSellableError(...)0%620%
File 1: ParseMultiClientIds(...)100%11100%
File 1: get_LineId()100%11100%
File 1: get_ProductId()100%210%
File 1: get_Article()100%11100%
File 1: get_OfferId()100%11100%
File 1: get_OneCStore()100%11100%
File 1: get_OzonWarehouseName()100%11100%
File 1: get_OzonWarehouseId()100%11100%
File 1: get_FbsMappedWarehouseId()100%210%
File 1: get_EffectiveWarehouseId()50%22100%
File 1: get_OneCQty()100%11100%
File 1: get_OzonQty()100%11100%
File 1: get_Action()100%11100%
File 1: get_OzonProductId()100%11100%
File 1: get_IsArchived()100%11100%
File 1: get_IsAutoArchived()100%210%
File 1: get_OzonClientId()100%11100%
File 1: get_PrioritySkip()100%11100%
File 1: get_PrioritySkipReason()100%11100%
File 1: get_PriorityForceClear()100%11100%
File 1: get_PriorityNeedUnarchive()100%11100%
File 1: get_BatchMultiplier()100%11100%
File 1: get_ReservedQty()100%210%
File 1: get_LiveStockNote()100%210%
File 2: ZeroDisabledWarehouseStocksAsync()0%2756520%
File 2: <ZeroDisabledWarehouseStocksAsync()100%210%
File 2: <ZeroDisabledWarehouseStocksAsync()0%620%
File 2: <ZeroDisabledWarehouseStocksAsync()0%620%
File 2: DisabledZeroFallbackSuffix(...)0%620%
File 2: LogDisabledTargetLine()0%620%
File 2: LoadApplyDisabledReportProductsAsync()100%210%
File 2: <LoadApplyDisabledReportProductsAsync()0%1056320%
File 2: LoadApplyDisabledLineStatesAsync()100%210%
File 2: <LoadApplyDisabledLineStatesAsync()0%272160%

File(s)

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

#LineLine coverage
 1using Hangfire;
 2using Hangfire.Storage;
 3using Microsoft.AspNetCore.SignalR;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Http;
 6using OzonDomains;
 7using OzonDomains.Models;
 8using OzonRepositories.Context;
 9using Services.ApiServices.OzonApi;
 10using Services.ApiServices.OzonApi.Filters;
 11using Services.DataServices;
 12using Services.HangfireService;
 13using Services.SignalRServices;
 14using SlqStudio.Application.Services.EmailService;
 15
 16namespace Services.BitrixStockServices;
 17
 18public partial class BitrixStockSyncReportApplyService : IBitrixStockSyncReportApplyService
 19{
 20    private const int BatchSize = 100;
 21    private const int DelayBetweenBatchesMs = 400;
 22
 23    /// <summary>JOIN WarehouseMapping по OzonWarehouseId (коллизия нескольких складов 1С → один Ozon).</summary>
 24    private const string ReportLineWarehouseMappingJoinSql =
 25        "LEFT JOIN WarehouseMapping m ON m.Id = (" +
 26        "SELECT m2.Id FROM WarehouseMapping m2 " +
 27        "WHERE m2.OzonClientId = l.OzonClientId " +
 28        "AND CAST(m2.OzonWarehouseId AS SIGNED) = l.OzonWarehouseId " +
 29        "ORDER BY m2.Priority ASC, m2.Id ASC LIMIT 1)";
 30
 31    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
 32    private readonly OzonClientServices _ozonClientServices;
 33    private readonly IHttpClientFactory _httpClientFactory;
 34    private readonly IEmailService? _emailService;
 35    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 36    private readonly BitrixStockSyncApplyArticleLogService _articleLog;
 37    private readonly BitrixStockActiveStageStore _stageStore;
 38    private readonly IBitrixStockExceptionService _exceptions;
 39    private readonly IBitrixStockLiveStockGuard _liveStockGuard;
 40
 041    public BitrixStockSyncReportApplyService(
 042        IDbContextFactory<ApplicationDbContext> dbFactory,
 043        OzonClientServices ozonClientServices,
 044        IHttpClientFactory httpClientFactory,
 045        IEmailService? emailService,
 046        IHubContext<JobProgressHub> jobProgressHub,
 047        BitrixStockSyncApplyArticleLogService articleLog,
 048        BitrixStockActiveStageStore stageStore,
 049        IBitrixStockExceptionService exceptions,
 050        IBitrixStockLiveStockGuard liveStockGuard)
 51    {
 052        _dbFactory = dbFactory;
 053        _ozonClientServices = ozonClientServices;
 054        _httpClientFactory = httpClientFactory;
 055        _emailService = emailService;
 056        _jobProgressHub = jobProgressHub;
 057        _articleLog = articleLog;
 058        _stageStore = stageStore;
 059        _exceptions = exceptions;
 060        _liveStockGuard = liveStockGuard;
 061    }
 62
 63    [Queue(HangfireQueueNames.Main)]
 64    public async Task<BitrixStockApplyResult> ApplyReportAsync(
 65        string reportId,
 66        BitrixStockSyncReportApplyStartRequest request,
 67        string? progressJobId,
 68        CancellationToken cancellationToken = default)
 69    {
 070        var applyResult = new BitrixStockApplyResult();
 071        var hubId = progressJobId?.Trim() ?? string.Empty;
 072        var hasHub = !string.IsNullOrWhiteSpace(hubId);
 73
 74        async Task Log(string message)
 75        {
 076            if (hasHub)
 77            {
 078                await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken);
 79            }
 080        }
 81
 082        var lastProgressSentAt = DateTime.MinValue;
 83        async Task Progress(int current, int total, string text)
 84        {
 085            if (!hasHub) return;
 086            var now = DateTime.UtcNow;
 87            // Отправляем не чаще раза в 250 мс, кроме финального события
 088            if (current < total && (now - lastProgressSentAt).TotalMilliseconds < 250) return;
 089            lastProgressSentAt = now;
 090            await _jobProgressHub.Clients.Group(hubId)
 091                .SendAsync("Progress", current, total, text ?? "", cancellationToken);
 092        }
 93
 94        async Task Complete(bool ok, object? payload)
 95        {
 096            if (hasHub)
 97            {
 098                await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken);
 99            }
 0100        }
 101
 0102        IDisposable? distLock = null;
 0103        IStorageConnection? lockConnection = null;
 104
 105        try
 106        {
 107            try
 108            {
 0109                lockConnection = JobStorage.Current.GetConnection();
 0110                distLock = lockConnection.AcquireDistributedLock(
 0111                    BitrixStockPushJobService.PushLockKey,
 0112                    TimeSpan.FromMilliseconds(1));
 0113            }
 114            catch (DistributedLockTimeoutException)
 115            {
 116                const string msg = "Обновление остатков Ozon уже выполняется. Повторный запуск отменён.";
 0117                await Log(msg);
 0118                await Complete(false, msg);
 0119                applyResult.LockBusy = true;
 0120                return applyResult;
 121            }
 122
 123            // В режиме цепочки stage-store и SignalR-задержкой управляет оркестратор.
 0124            if (!request.Chained)
 125            {
 0126                _stageStore.Set(reportId, 2);
 127                // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа)
 0128                if (hasHub) await Task.Delay(800, cancellationToken);
 129            }
 0130            await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 0131            var logJobId = (progressJobId ?? request.JobId ?? Guid.NewGuid().ToString("N")).Trim();
 132
 0133            var meta = await LoadReportMetaAsync(db, reportId, cancellationToken);
 0134            if (meta == null)
 135            {
 0136                await Log("Отчёт не найден");
 0137                await Complete(false, "Отчёт не найден");
 0138                return applyResult;
 139            }
 140
 0141            if (!string.Equals(meta.Status, "Completed", StringComparison.OrdinalIgnoreCase))
 142            {
 0143                await Log($"Отчёт в статусе «{meta.Status}» — дождитесь завершения формирования");
 0144                await Complete(false, "Отчёт ещё не готов");
 0145                return applyResult;
 146            }
 147
 0148            var actions = ResolveApplyActions(request.Actions);
 0149            var disabledMappingIds = request.DisabledWarehouseMappingIds?
 150                .Where(id => id > 0)
 0151                .ToHashSet() ?? new HashSet<int>();
 152
 153            var articleFilter = request.Articles?.Where(a => !string.IsNullOrWhiteSpace(a)).ToList();
 0154            var lines = await LoadLinesToApplyAsync(db, reportId, actions, request.OnlyPending, request.MaxLines,
 0155                disabledMappingIds, articleFilter, cancellationToken);
 0156            var skipStockArticlesApply = await _exceptions.GetSkipStockArticlesSetAsync(cancellationToken);
 0157            var orderedClientIds = ParseMultiClientIds(meta.MultiClientIdsJson);
 0158            var priorityClient1Id = orderedClientIds.Count > 0 ? orderedClientIds[0] : 0;
 0159            var priorityClient2Id = orderedClientIds.Count > 1 ? orderedClientIds[1] : 0;
 160            // Для multi-client отчётов К2 может иметь Action=Match (остатки совпадают с 1С) или
 161            // отключённую связь и не попасть в lines. Но раз К1 найден — К2 всё равно нужно обнулить.
 162            // Загружаем ВСЕ К2-строки с OzonQty>0 напрямую, независимо от того есть ли К2 в lines.
 0163            Dictionary<string, ApplyLineRow>? k1RefMap = null;
 0164            if (orderedClientIds.Count >= 2)
 165            {
 0166                var k1Id = orderedClientIds[0];
 0167                var k2Id = orderedClientIds[1];
 168
 169                // Все К2-строки с OzonQty>0 — кандидаты на принудительное обнуление приоритетом К1
 0170                var k2Candidates = await LoadClientLinesWithPositiveStockAsync(
 0171                    db, reportId, k2Id, request.OnlyPending, articleFilter, cancellationToken);
 172
 173                // Объединяем пары из К2-кандидатов и уже загруженных К2-строк для запроса К1-ref
 0174                var pairsForK1Ref = k2Candidates
 175                    .Select(l => $"{l.Article}||{l.OneCStore}")
 176                    .Concat(lines.Where(l => l.OzonClientId == k2Id).Select(l => $"{l.Article}||{l.OneCStore}"))
 0177                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 178
 0179                if (pairsForK1Ref.Count > 0)
 180                {
 0181                    var k1RefLines = await LoadClientReferenceLinesAsync(db, reportId, k1Id, pairsForK1Ref, cancellation
 0182                    k1RefMap = k1RefLines
 183                        .GroupBy(l => $"{l.Article}||{l.OneCStore}", StringComparer.OrdinalIgnoreCase)
 184                        .ToDictionary(g => g.Key, g => g.OrderBy(l => l.LineId).First(), StringComparer.OrdinalIgnoreCas
 0185                    if (k1RefMap.Count > 0)
 0186                        await Log($"Приоритет К1→К2: {k1RefMap.Count} ref-строк К1 для {pairsForK1Ref.Count} пар");
 187                }
 188
 189                // Добавляем К2-кандидатов где К1 найден и строка ещё не в основном списке
 190                var existingK2Ids = lines.Where(l => l.OzonClientId == k2Id).Select(l => l.LineId).ToHashSet();
 0191                var k2ToAdd = k2Candidates
 0192                    .Where(l => !existingK2Ids.Contains(l.LineId) &&
 0193                                k1RefMap?.ContainsKey($"{l.Article}||{l.OneCStore}") == true)
 0194                    .ToList();
 0195                lines.AddRange(k2ToAdd);
 0196                if (k2ToAdd.Count > 0)
 0197                    await Log($"Приоритет К2: {k2ToAdd.Count} строк К2 с OzonQty>0 добавлены для принудительного обнулен
 0198            }
 0199            ApplyPriorityForTwoClients(lines, meta, k1RefMap);
 0200            var k1NotSellableKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 201
 0202            if (disabledMappingIds.Count > 0)
 203            {
 0204                await Log($"Отключено связей: {disabledMappingIds.Count}");
 205            }
 206
 0207            var archivedMode = request.ArchivedMode;
 208
 0209            await Log($"Синхронизация в Ozon: клиент {meta.OzonClientName}, строк к обработке: {lines.Count}");
 0210            if (lines.Count == 0 && disabledMappingIds.Count == 0)
 211            {
 0212                await Log("Нечего применять — все строки совпадают или пропущены.");
 0213                await Complete(true, new { reportId, success = true, processed = 0 });
 214                // SuccessCount = 0: оркестратор сам решит пропустить этап 3.
 0215                return applyResult;
 216            }
 217
 218            // Определяем всех уникальных клиентов из строк (поддержка мультиклиентного отчёта)
 0219            var allLineClientIds = lines
 220                .Select(l => l.OzonClientId)
 0221                .Distinct()
 222                .Where(id => id > 0)
 0223                .ToList();
 0224            if (!allLineClientIds.Any()) allLineClientIds = new List<int> { meta.OzonClientId };
 225
 0226            var allOzonClients = await _ozonClientServices.GetOzonClients();
 0227            var clientMap = allLineClientIds
 0228                .Select(id => allOzonClients.FirstOrDefault(c => c.Id == id && c.ClientType == ClientType.OZON))
 229                .Where(c => c != null && !string.IsNullOrEmpty(c.DecryptClientId) && !string.IsNullOrEmpty(c.DecryptApiK
 230                .ToDictionary(c => c!.Id, c => c!);
 231
 0232            if (!clientMap.Any())
 233            {
 0234                await Log("Клиент Ozon не найден или не настроен");
 0235                await Complete(false, "Клиент Ozon не найден");
 0236                return applyResult;
 237            }
 238
 239            // Загружаем актуальный архивный статус из Ozon API отдельно по каждому клиенту
 0240            var archivedOfferIdsByClient = new Dictionary<int, HashSet<string>>();
 0241            var archivedOfferIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0242            if (archivedMode != ArchivedProductMode.Include)
 243            {
 244                // Для UnarchiveFirst берём ВСЕ OfferIds из отчёта, иначе только накатываемые
 0245                var allOfferIdsToCheck = archivedMode == ArchivedProductMode.UnarchiveFirst
 0246                    ? await LoadAllReportOfferIdsAsync(db, reportId, cancellationToken)
 247                    : lines.Where(l => !string.IsNullOrEmpty(l.OfferId))
 248                        .Select(l => l.OfferId!)
 0249                        .Distinct(StringComparer.OrdinalIgnoreCase)
 0250                        .ToArray();
 251
 0252                if (allOfferIdsToCheck.Length > 0)
 253                {
 0254                    await Log($"Проверка архивного статуса {allOfferIdsToCheck.Length} товаров в Ozon…");
 255
 0256                    foreach (var (clientId, client) in clientMap)
 257                    {
 0258                        var clientArchived = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0259                        archivedOfferIdsByClient[clientId] = clientArchived;
 0260                        var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 261                        try
 262                        {
 0263                            foreach (var chunk in allOfferIdsToCheck.Chunk(100))
 264                            {
 0265                                cancellationToken.ThrowIfCancellationRequested();
 0266                                var resp = await apiManager.GetProductInfoListAsync(offerIds: chunk);
 0267                                if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items) continue;
 0268                                foreach (var item in items)
 269                                {
 0270                                    var isArch = item["is_archived"]?.ToObject<bool>() == true;
 0271                                    if (!isArch) continue;
 0272                                    var oid = item["offer_id"]?.ToString();
 0273                                    if (string.IsNullOrEmpty(oid)) continue;
 0274                                    clientArchived.Add(oid);
 0275                                    archivedOfferIds.Add(oid);
 276                                }
 0277                                await Task.Delay(100, cancellationToken);
 278                            }
 0279                        }
 0280                        catch (Exception ex)
 281                        {
 0282                            await Log($"Предупреждение: не удалось получить статус архива для клиента {client.Name} — {e
 283                        }
 0284                    }
 285
 0286                    await Log(archivedOfferIds.Count > 0
 0287                        ? $"Найдено архивных товаров: {archivedOfferIds.Count}"
 0288                        : "Архивных товаров не найдено");
 289                }
 0290            }
 291
 0292            if (archivedMode == ArchivedProductMode.Skip && archivedOfferIds.Count > 0)
 293            {
 0294                await Log($"Пропуск {archivedOfferIds.Count} архивных товаров");
 0295                lines = lines.Where(l => string.IsNullOrEmpty(l.OfferId) || !archivedOfferIds.Contains(l.OfferId!)).ToLi
 296            }
 297
 298            // Разархивирование перед накатом: все архивные из отчёта, не только накатываемые
 0299            if (archivedMode == ArchivedProductMode.UnarchiveFirst && archivedOfferIds.Count > 0)
 300            {
 301                // Применяем фильтр по типу архива (авто / ручной / все)
 0302                var unarchiveFilter = request.UnarchiveFilter;
 0303                if (unarchiveFilter != UnarchiveFilterMode.All)
 304                {
 0305                    var offerArchiveTypes = await LoadOfferArchiveTypesAsync(db, reportId, cancellationToken);
 0306                    var before = archivedOfferIds.Count;
 0307                    archivedOfferIds.RemoveWhere(offerId =>
 0308                    {
 0309                        if (!offerArchiveTypes.TryGetValue(offerId, out var isAuto))
 0310                            return false;
 0311                        return unarchiveFilter == UnarchiveFilterMode.AutoOnly ? !isAuto : isAuto;
 0312                    });
 0313                    var excluded = before - archivedOfferIds.Count;
 0314                    if (excluded > 0)
 315                    {
 0316                        var filterLabel = unarchiveFilter == UnarchiveFilterMode.AutoOnly ? "только авто-архив" : "тольк
 0317                        await Log($"Фильтр архива: {filterLabel}, исключено {excluded} товаров, к разархивации: {archive
 318                    }
 319                }
 320
 0321                if (archivedOfferIds.Count == 0)
 322                {
 0323                    await Log("После применения фильтра архива нет товаров для разархивации");
 324                }
 325                else
 326                {
 327                // Собираем product_id по offer_id из всех строк отчёта (lines + дополнительная выборка)
 0328                await Log($"Разархивирование {archivedOfferIds.Count} товаров перед накатом…");
 329
 330                // product_id для archived строк собираем отдельно по клиентам
 0331                var unarchiveIdsByClient = new Dictionary<int, HashSet<long>>();
 0332                foreach (var line in lines)
 333                {
 0334                    if (line.OzonClientId <= 0 ||
 0335                        string.IsNullOrEmpty(line.OfferId) ||
 0336                        !archivedOfferIds.Contains(line.OfferId) ||
 0337                        line.OzonProductId is not > 0)
 338                    {
 339                        continue;
 340                    }
 341
 0342                    if (!unarchiveIdsByClient.TryGetValue(line.OzonClientId, out var set))
 343                    {
 0344                        set = new HashSet<long>();
 0345                        unarchiveIdsByClient[line.OzonClientId] = set;
 346                    }
 0347                    set.Add(line.OzonProductId.Value);
 348                }
 349
 350                // product_id для Match-строк получаем из API отдельно по каждому клиенту
 351                try
 352                {
 0353                    foreach (var (clientId, clientArchivedOfferIds) in archivedOfferIdsByClient)
 354                    {
 0355                        if (clientArchivedOfferIds.Count == 0 || !clientMap.TryGetValue(clientId, out var client))
 356                            continue;
 357
 0358                        var clientApiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 0359                        foreach (var chunk in clientArchivedOfferIds.Chunk(100))
 360                        {
 0361                            cancellationToken.ThrowIfCancellationRequested();
 0362                            var resp = await clientApiManager.GetProductInfoListAsync(offerIds: chunk.ToArray());
 0363                            if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items2) continue;
 0364                            foreach (var item in items2)
 365                            {
 0366                                var pid = item["id"]?.ToObject<long>();
 0367                                if (pid is > 0)
 368                                {
 0369                                    if (!unarchiveIdsByClient.TryGetValue(clientId, out var set))
 370                                    {
 0371                                        set = new HashSet<long>();
 0372                                        unarchiveIdsByClient[clientId] = set;
 373                                    }
 0374                                    set.Add(pid.Value);
 375                                }
 376                            }
 377                        }
 0378                    }
 0379                }
 0380                catch (OperationCanceledException) { throw; }
 0381                catch (Exception ex) { await Log($"Предупреждение: не удалось получить product_id из Ozon API для разарх
 382
 383                var totalToUnarchive = unarchiveIdsByClient.Values.Sum(s => s.Count);
 0384                if (totalToUnarchive > 0)
 385                {
 0386                    var unarchiveOk = 0;
 0387                    var unarchiveFail = 0;
 0388                    foreach (var (clientId, idsSet) in unarchiveIdsByClient)
 389                    {
 0390                        if (!clientMap.TryGetValue(clientId, out var client))
 391                        {
 0392                            unarchiveFail += idsSet.Count;
 0393                            continue;
 394                        }
 395
 0396                        var clientApi = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 0397                        foreach (var chunk in idsSet.Chunk(100))
 398                        {
 0399                            cancellationToken.ThrowIfCancellationRequested();
 400                            try
 401                            {
 0402                                var ok = await clientApi.UnarchiveProductsAsync(chunk);
 0403                                if (ok) unarchiveOk += chunk.Length;
 0404                                else unarchiveFail += chunk.Length;
 0405                            }
 0406                            catch (Exception ex)
 407                            {
 0408                                unarchiveFail += chunk.Length;
 0409                                await Log($"Ошибка разархивирования батча (клиент {client.Name}): {BitrixOzonStockErrorM
 410                            }
 0411                            await Task.Delay(300, cancellationToken);
 0412                        }
 0413                    }
 0414                    await Log($"Разархивировано: {unarchiveOk}, ошибок: {unarchiveFail}");
 415                    // Небольшая пауза чтобы Ozon успел обработать изменение статуса
 0416                    await Task.Delay(2000, cancellationToken);
 417                }
 0418                } // else (archivedOfferIds.Count > 0 after filter)
 0419            }
 420
 421            // Match: отчёт считает «совпало» по снимку таблицы, а таблица может держать остаток, которого в 1С уже нет
 422            // (кейс 2820: M1 = 7 в таблице, в 1С на M1 пусто). Такие строки сверяем с 1С онлайн и, если свободно меньше
 423            // превращаем в Decrease. Без снижения остатка в настройках пресета сверка Match не нужна.
 0424            var matchProbes = new List<ApplyLineRow>();
 0425            if (actions.Contains(BitrixStockSyncReportActions.Decrease, StringComparer.OrdinalIgnoreCase))
 426            {
 0427                var loadedKeys = lines
 428                    .Select(l => (l.OzonClientId, l.Article.ToLowerInvariant(), l.OneCStore.ToLowerInvariant()))
 0429                    .ToHashSet();
 0430                var matchRows = await LoadLinesToApplyAsync(db, reportId, [BitrixStockSyncReportActions.Match],
 0431                    request.OnlyPending, null, disabledMappingIds, articleFilter, cancellationToken);
 0432                matchProbes = matchRows
 0433                    .Where(l => l.OzonQty > 0 && l.OneCQty > 0 && l.BatchMultiplier <= 1 && !l.IsArchived
 0434                                && (orderedClientIds.Count < 2 || l.OzonClientId == priorityClient1Id)
 0435                                && !loadedKeys.Contains((l.OzonClientId, l.Article.ToLowerInvariant(), l.OneCStore.ToLow
 0436                    .ToList();
 0437            }
 438
 0439            lines.AddRange(await ApplyLiveStockGuardAsync(db, lines, matchProbes, Log, cancellationToken));
 440
 0441            var total = lines.Count;
 0442            var processed = 0;
 0443            var successCount = 0;
 0444            var failedCount = 0;
 445
 446            // Хелпер записи в журнал по строке — пишем fire-and-forget, не блокируем накат
 447            async Task LogLine(
 448                ApplyLineRow line,
 449                int clientId,
 450                string clientName,
 451                bool ok,
 452                string? error,
 453                string? actionCodeOverride = null,
 454                string? actionLabelOverride = null)
 455            {
 456                try
 457                {
 0458                    var targetQty = GetTargetStock(line);
 0459                    var actionCode = actionCodeOverride ?? (ok
 0460                        ? (line.PriorityForceClear || string.Equals(line.Action, BitrixStockSyncReportActions.Clear, Str
 0461                            ? ApplyArticleActionCodes.Clear
 0462                            : (line.PriorityNeedUnarchive || line.IsArchived
 0463                                ? ApplyArticleActionCodes.UnarchiveApply
 0464                                : ApplyArticleActionCodes.Apply))
 0465                        : "FAILED");
 0466                    await _articleLog.WriteAsync(db, reportId, logJobId, new BitrixStockSyncApplyArticleLogEntry
 0467                    {
 0468                        Article   = line.Article,
 0469                        OneCStore = line.OneCStore,
 0470                        OneCQty   = line.OneCQty,
 0471                        Client1 = new ApplyArticleClientEntry
 0472                        {
 0473                            ClientId    = clientId,
 0474                            ClientName  = clientName,
 0475                            QtyBefore   = line.OzonQty,
 0476                            QtyAfter    = ok ? targetQty : null,
 0477                            WasArchived = line.IsArchived,
 0478                            ActionCode  = actionCode,
 0479                            ActionLabel = actionLabelOverride ?? (ok ? $"{line.OzonQty} → {targetQty}" : error)
 0480                        },
 0481                        Summary = ok
 0482                            ? $"✓ {line.Article} ({line.OneCStore}): {line.OzonQty} → {targetQty} Ozon"
 0483                            : $"✗ {line.Article} ({line.OneCStore}): {error}"
 0484                    }, cancellationToken);
 0485                }
 0486                catch (OperationCanceledException) { throw; }
 0487                catch (Exception) { /* журнал не должен прерывать накат */ }
 0488            }
 489
 490            // Группируем строки по клиенту Ozon для мультиклиентного наката
 0491            var linesByClient = lines
 0492                .GroupBy(l => l.OzonClientId > 0 ? l.OzonClientId : meta.OzonClientId)
 0493                .OrderBy(g =>
 0494                {
 0495                    var idx = orderedClientIds.IndexOf(g.Key);
 0496                    return idx >= 0 ? idx : int.MaxValue;
 0497                })
 0498                .ToList();
 499
 500            // К1 ref-строки (Action=Match, не обрабатываются напрямую) помечаем когда К2 обнуляется приоритетом
 0501            var markedK1LineIds = new HashSet<long>();
 502
 503            // Строки, на которых Ozon ответил «остаток обновляется слишком часто»: после пакетов повторяем их
 504            // с паузой в этом же цикле (Ozon сам просит подождать 1–2 минуты).
 0505            var rateLimited = new List<ApplyLineRow>();
 506
 507            async Task RetryRateLimitedAsync(OzonApiDataManager api, int clientId, string clientName)
 508            {
 0509                TimeSpan[] pauses = [TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(90)];
 0510                for (var round = 0; round < pauses.Length && rateLimited.Count > 0; round++)
 511                {
 0512                    await Log($"Повтор {rateLimited.Count} позиций после «слишком часто» (попытка {round + 1}/{pauses.Le
 0513                    await Task.Delay(pauses[round], cancellationToken);
 514
 0515                    foreach (var line in rateLimited.ToList())
 516                    {
 0517                        cancellationToken.ThrowIfCancellationRequested();
 0518                        var (ok, err) = await ApplySingleAsync(db, api, line, cancellationToken);
 0519                        if (ok)
 520                        {
 0521                            rateLimited.Remove(line);
 0522                            failedCount--;
 0523                            successCount++;
 0524                            await Log($"✓ {line.Article} ({line.OneCStore}): обновлено повтором после паузы");
 0525                            await LogLine(line, clientId, clientName, true, null);
 526                        }
 0527                        else if (!BitrixStockApplyBatchMatcher.IsTooFrequentError(err))
 528                        {
 0529                            rateLimited.Remove(line); // другая ошибка — повтор бессмысленен, строка остаётся Failed с э
 0530                            await Log($"✗ {line.Article} ({line.OneCStore}): {err}");
 531                        }
 532
 0533                        await Task.Delay(200, cancellationToken);
 0534                    }
 535                }
 536
 0537                foreach (var line in rateLimited)
 0538                    await Log($"✗ {line.Article} ({line.OneCStore}): Ozon всё ещё отвечает «слишком часто» — останется н
 0539                rateLimited.Clear();
 0540            }
 541
 0542            foreach (var clientGroup in linesByClient)
 543            {
 0544                var clientId = clientGroup.Key;
 0545                var clientLines = clientGroup.ToList();
 546
 547                // Получаем API-менеджер для этого клиента
 548                OzonApiDataManager clientApiManager;
 0549                if (clientMap.TryGetValue(clientId, out var groupClient))
 550                {
 0551                    clientApiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, groupClient!);
 0552                    await Log($"--- Клиент: {groupClient.Name} ({clientLines.Count} строк) ---");
 553                }
 554                else
 555                {
 556                    // Клиент не найден — помечаем строки как ошибочные
 0557                    foreach (var line in clientLines)
 558                    {
 0559                        processed++;
 0560                        failedCount++;
 0561                        await MarkLineAsync(db, line.LineId, false, $"Клиент Ozon ID {clientId} не найден", cancellation
 0562                        await Log($"✗ {line.Article}: клиент Ozon ID {clientId} не найден");
 0563                        await Progress(processed, total, line.Article);
 0564                    }
 0565                    continue;
 566                }
 567
 0568                var clientOzonClientName = groupClient?.Name ?? meta.OzonClientName;
 569
 0570                for (var offset = 0; offset < clientLines.Count; offset += BatchSize)
 571                {
 0572                    cancellationToken.ThrowIfCancellationRequested();
 0573                    var batch = clientLines.Skip(offset).Take(BatchSize).ToList();
 0574                    await Log($"Батч {offset / BatchSize + 1}: {batch.Count} позиций");
 575
 0576                    var applicable = new List<ApplyLineRow>();
 0577                    foreach (var line in batch)
 578                    {
 0579                        if (line.PrioritySkip)
 580                        {
 0581                            processed++;
 0582                            successCount++;
 0583                            var skipReason = line.PrioritySkipReason;
 0584                            if (priorityClient2Id > 0 &&
 0585                                line.OzonClientId == priorityClient2Id &&
 0586                                k1NotSellableKeys.Contains(GetPriorityKey(line)))
 587                            {
 0588                                skipReason = "К2 пропущен: К1 не продается (приоритет)";
 589                            }
 590
 0591                            var skipCode = skipReason?.Contains("К1 не продается", StringComparison.OrdinalIgnoreCase) =
 0592                                ? ApplyArticleActionCodes.SkipPriorityK1Failed
 0593                                : skipReason?.Contains("архив", StringComparison.OrdinalIgnoreCase) == true
 0594                                ? ApplyArticleActionCodes.SkipArchived
 0595                                : (skipReason?.Contains("уже 0", StringComparison.OrdinalIgnoreCase) == true
 0596                                    ? ApplyArticleActionCodes.SkipAlreadyZero
 0597                                    : (skipReason?.Contains("не найден", StringComparison.OrdinalIgnoreCase) == true
 0598                                        ? ApplyArticleActionCodes.SkipNotFound
 0599                                        : ApplyArticleActionCodes.SkipMatch));
 0600                            await MarkLineAsync(db, line.LineId, true, skipReason, cancellationToken);
 0601                            await Log($"• {line.Article} ({line.OneCStore}): {skipReason}");
 0602                            await LogLine(line, clientId, clientOzonClientName, true, null, skipCode, skipReason);
 0603                            await Progress(processed, total, line.Article);
 0604                            continue;
 605                        }
 606
 0607                        if (line.EffectiveWarehouseId <= 0 || line.OzonProductId is not > 0 ||
 0608                            string.IsNullOrWhiteSpace(line.OfferId))
 609                        {
 0610                            processed++;
 0611                            successCount++;
 0612                            var skipErr = line.EffectiveWarehouseId <= 0
 0613                                ? "Нет склада Ozon (настройте WarehouseMapping) — пропущено"
 0614                                : "Нет product_id или offer_id — пропущено";
 0615                            await MarkLineAsync(db, line.LineId, true, skipErr, cancellationToken);
 0616                            await Log($"• {line.Article}: {skipErr}");
 0617                            await LogLine(line, clientId, clientOzonClientName, true, skipErr,
 0618                                ApplyArticleActionCodes.SkipNotFound);
 0619                            await Progress(processed, total, line.Article);
 0620                            continue;
 621                        }
 622
 623                        // Целевой остаток уже совпадает с текущим — запрос в Ozon бессмысленен.
 624                        // Исключение: PriorityForceClear при OzonQty=0 — отчёт мог подтянуть устаревший
 625                        // остаток (товары "Убран из продажи" API возвращает 0, физически может быть > 0).
 0626                        var targetQty = GetTargetStock(line);
 0627                        if (targetQty == line.OzonQty && !line.PriorityForceClear)
 628                        {
 0629                            processed++;
 0630                            successCount++;
 0631                            var matchReason = line.LiveStockNote ?? "Уже совпадает — пропущено";
 0632                            await MarkLineAsync(db, line.LineId, true, matchReason, cancellationToken, targetQty);
 0633                            await Log($"• {line.Article} ({line.OneCStore}): {line.OzonQty} = {targetQty} — {(line.LiveS
 0634                            await LogLine(line, clientId, clientOzonClientName, true, matchReason,
 0635                                ApplyArticleActionCodes.SkipAlreadyMatches, matchReason);
 0636                            await Progress(processed, total, line.Article);
 0637                            continue;
 638                        }
 639
 640                        // Проверка справочника исключений (добавленных после построения отчёта)
 0641                        if (!string.IsNullOrEmpty(line.Article) &&
 0642                            (skipStockArticlesApply.Contains(line.Article) ||
 0643                             (await _exceptions.FindStockRuleAsync(line.Article, clientId, cancellationToken))?.StockMod
 644                        {
 0645                            processed++;
 0646                            successCount++;
 647                            const string excReason = "Пропуск (справочник исключений)";
 0648                            await MarkLineAsync(db, line.LineId, true, excReason, cancellationToken);
 0649                            await Log($"• {line.Article} ({line.OneCStore}): {excReason}");
 0650                            await Progress(processed, total, line.Article);
 0651                            continue;
 652                        }
 653
 0654                        applicable.Add(line);
 0655                    }
 656
 0657                    if (applicable.Count > 0)
 658                    {
 0659                        var needUnarchive = applicable
 660                            .Where(l => l.PriorityNeedUnarchive && l.OzonProductId is > 0)
 661                            .Select(l => l.OzonProductId!.Value)
 0662                            .Distinct()
 0663                            .ToArray();
 0664                        if (needUnarchive.Length > 0)
 665                        {
 0666                            if (archivedMode == ArchivedProductMode.Skip)
 667                            {
 668                                foreach (var line in applicable.Where(l => l.PriorityNeedUnarchive).ToList())
 669                                {
 0670                                    processed++;
 0671                                    successCount++;
 0672                                    line.PrioritySkip = true;
 0673                                    line.PrioritySkipReason = "Архивный товар (режим Skip)";
 0674                                    await MarkLineAsync(db, line.LineId, true, line.PrioritySkipReason, cancellationToke
 0675                                    await Log($"• {line.Article} ({line.OneCStore}): {line.PrioritySkipReason}");
 0676                                    await LogLine(line, clientId, clientOzonClientName, true, null, ApplyArticleActionCo
 0677                                    await Progress(processed, total, line.Article);
 0678                                }
 679                                applicable = applicable.Where(l => !l.PriorityNeedUnarchive).ToList();
 680                            }
 681                            else
 682                            {
 683                                try
 684                                {
 0685                                    var unarchiveOk = await clientApiManager.UnarchiveProductsAsync(needUnarchive);
 0686                                    if (!unarchiveOk)
 0687                                        await Log($"Предупреждение: не удалось разархивировать часть товаров (клиент {cl
 0688                                }
 0689                                catch (Exception ex)
 690                                {
 0691                                    await Log($"Предупреждение: ошибка разархивирования перед накатом ({clientOzonClient
 692                                }
 693                            }
 694                        }
 695
 0696                        if (applicable.Count == 0)
 697                            continue;
 698
 699                        var stockPayload = applicable.Select(l => new StockItemForUpdate
 700                        {
 701                            OfferId = l.OfferId!,
 702                            ProductId = l.OzonProductId!.Value,
 703                            Stock = GetTargetStock(l),
 704                            WarehouseId = l.EffectiveWarehouseId
 705                        }).ToList();
 706
 0707                        var pushItems = applicable.Select(l => new BitrixStockPushItemDto
 0708                        {
 0709                            Article = l.Article,
 0710                            OfferId = l.OfferId!,
 0711                            ProductId = l.OzonProductId!.Value,
 0712                            WarehouseId = l.EffectiveWarehouseId,
 0713                            WarehouseName = l.OzonWarehouseName,
 0714                            OzonClient = clientOzonClientName,
 0715                            LinkedStoreName = l.OneCStore,
 0716                            PreviousStock = l.OzonQty,
 0717                            TargetStock = GetTargetStock(l)
 0718                        }).ToList();
 719
 0720                        var handled = new HashSet<long>();
 0721                        var batchOk = await TryUpdateBatchAsync(clientApiManager, stockPayload, pushItems,
 0722                            async (item, error) =>
 0723                            {
 0724                                var line = applicable.FirstOrDefault(l =>
 0725                                    l.OzonProductId == item.ProductId && l.EffectiveWarehouseId == item.WarehouseId);
 0726                                if (line == null) return;
 0727                                handled.Add(line.LineId);
 0728                                processed++;
 0729                                failedCount++;
 0730                                if (BitrixStockApplyBatchMatcher.IsTooFrequentError(error))
 0731                                    rateLimited.Add(line);
 0732                                await MarkLineAsync(db, line.LineId, false, error, cancellationToken);
 0733                                await Log($"✗ {line.Article} ({line.OneCStore}): {error}");
 0734                                if (priorityClient1Id > 0 &&
 0735                                    line.OzonClientId == priorityClient1Id &&
 0736                                    IsNotSellableError(error))
 0737                                {
 0738                                    k1NotSellableKeys.Add(GetPriorityKey(line));
 0739                                }
 0740                                await LogLine(line, clientId, clientOzonClientName, false, error);
 0741                                await Progress(processed, total, line.Article);
 0742                            },
 0743                            async item =>
 0744                            {
 0745                                var line = applicable.FirstOrDefault(l =>
 0746                                    l.OzonProductId == item.ProductId && l.EffectiveWarehouseId == item.WarehouseId);
 0747                                if (line == null) return;
 0748                                handled.Add(line.LineId);
 0749                                processed++;
 0750                                successCount++;
 0751                                var targetQty = GetTargetStock(line);
 0752                                var successNote = line.PriorityForceClear ? "К2 обнулено (приоритет К1)" : null;
 0753                                await MarkLineAsync(db, line.LineId, true, successNote, cancellationToken, targetQty);
 0754                                // Маркируем К1 ref-строку чтобы она отображалась в отчёте (иначе OzonApplyStatus=NULL)
 0755                                if (line.PriorityForceClear && k1RefMap != null &&
 0756                                    k1RefMap.TryGetValue(GetPriorityKey(line), out var k1RefLine) &&
 0757                                    markedK1LineIds.Add(k1RefLine.LineId))
 0758                                {
 0759                                    await MarkLineAsync(db, k1RefLine.LineId, true, "К1 — К2 обнулён (приоритет)", cance
 0760                                }
 0761                                await Log($"✓ {line.Article} ({line.OneCStore}): {line.OzonQty} → {targetQty} Ozon{(line
 0762                                await LogLine(line, clientId, clientOzonClientName, true, null);
 0763                                await Progress(processed, total, line.Article);
 0764                            },
 0765                            cancellationToken);
 766
 0767                        if (!batchOk)
 768                        {
 0769                            foreach (var line in applicable.Where(l => !handled.Contains(l.LineId)))
 770                            {
 0771                                var (ok, err) = await ApplySingleAsync(db, clientApiManager, line, cancellationToken);
 0772                                processed++;
 0773                                if (ok)
 774                                {
 0775                                    successCount++;
 0776                                    await Log($"✓ {line.Article}: повтор по одной");
 0777                                    await LogLine(line, clientId, clientOzonClientName, true, null);
 778                                }
 779                                else
 780                                {
 0781                                    failedCount++;
 0782                                    if (BitrixStockApplyBatchMatcher.IsTooFrequentError(err))
 0783                                        rateLimited.Add(line);
 0784                                    await Log($"✗ {line.Article}: {err}");
 0785                                    if (priorityClient1Id > 0 &&
 0786                                        line.OzonClientId == priorityClient1Id &&
 0787                                        IsNotSellableError(err))
 788                                    {
 0789                                        k1NotSellableKeys.Add(GetPriorityKey(line));
 790                                    }
 0791                                    await LogLine(line, clientId, clientOzonClientName, false, err);
 792                                }
 0793                                await Progress(processed, total, line.Article);
 0794                                await Task.Delay(200, cancellationToken);
 0795                            }
 796                        }
 797                    }
 798
 0799                    if (offset + BatchSize < clientLines.Count)
 0800                        await Task.Delay(DelayBetweenBatchesMs, cancellationToken);
 0801                }
 802
 0803                if (rateLimited.Count > 0)
 0804                    await RetryRateLimitedAsync(clientApiManager, clientId, clientOzonClientName);
 0805            }
 806
 807            // ── Обнуление остатков для отключённых связей (live Ozon по Warehouse ID) ──
 0808            if (disabledMappingIds.Count > 0)
 809            {
 0810                var disabledResult = await ZeroDisabledWarehouseStocksAsync(
 0811                    db,
 0812                    reportId,
 0813                    disabledMappingIds,
 0814                    meta,
 0815                    clientMap,
 0816                    request.OnlyPending,
 0817                    articleFilter,
 0818                    processed,
 0819                    total,
 0820                    successCount,
 0821                    failedCount,
 0822                    Log,
 0823                    Progress,
 0824                    LogLine,
 0825                    cancellationToken);
 0826                processed = disabledResult.Processed;
 0827                total = disabledResult.Total;
 0828                successCount = disabledResult.SuccessCount;
 0829                failedCount = disabledResult.FailedCount;
 830            }
 831
 832            // Пометить строки «Не найден» как пропущенные — не участвуют в синхронизации
 0833            var notFoundMarked = await db.Database.ExecuteSqlRawAsync(
 0834                @"UPDATE BitrixStockSyncReportLines
 0835                  SET OzonApplyStatus = {1}, OzonAppliedUtc = UTC_TIMESTAMP(6), OzonApplyError = {2}
 0836                  WHERE ReportId = {0} AND Action = {3}
 0837                    AND (OzonApplyStatus IS NULL OR OzonApplyStatus = 'Pending')",
 0838                EfSqlRawParameterGuard.Materialize(
 0839                    reportId,
 0840                    BitrixStockSyncReportApplyStatuses.Skipped,
 0841                    "Не найден — не участвует в синхронизации",
 0842                    BitrixStockSyncReportActions.NotFound),
 0843                cancellationToken);
 0844            if (notFoundMarked > 0)
 0845                await Log($"Пропущено строк «Не найден»: {notFoundMarked} (нет склада Ozon или товар не найден в API)");
 846
 0847            await BitrixStockSyncReportService.RefreshReportOzonApplyCountsAsync(db, reportId, cancellationToken);
 0848            await Log($"Готово: успешно {successCount}, ошибок {failedCount} из {total}");
 0849            await Progress(total, total, "Синхронизация завершена");
 0850            await Complete(true, new
 0851            {
 0852                reportId,
 0853                success = true,
 0854                total,
 0855                successCount,
 0856                failedCount
 0857            });
 858
 859            // Цепочкой этапов (2→3→4→5) управляет BitrixStockSyncPipelineOrchestrator.
 0860            applyResult.SuccessCount = successCount;
 0861            applyResult.FailedCount  = failedCount;
 0862            applyResult.Total        = total;
 0863            return applyResult;
 0864        }
 0865        catch (Exception ex)
 866        {
 0867            await Log($"Ошибка: {ex.Message}");
 0868            await Complete(false, ex.Message);
 0869            throw;
 0870        }
 871        finally
 872        {
 0873            if (!request.Chained)
 0874                _stageStore.Clear(reportId);
 0875            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 876        }
 0877    }
 878
 879    private static async Task<(bool Ok, string? Error)> ApplySingleAsync(
 880        ApplicationDbContext db,
 881        OzonApiDataManager apiManager,
 882        ApplyLineRow line,
 883        CancellationToken cancellationToken)
 884    {
 885        try
 886        {
 0887            var targetStock = GetTargetStock(line);
 0888            var single = new List<StockItemForUpdate>
 0889            {
 0890                new()
 0891                {
 0892                    OfferId = line.OfferId!,
 0893                    ProductId = line.OzonProductId!.Value,
 0894                    Stock = targetStock,
 0895                    WarehouseId = line.EffectiveWarehouseId
 0896                }
 0897            };
 0898            var result = await apiManager.UpdateProductStocks(single);
 0899            var row = result?.Result?.FirstOrDefault();
 0900            if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any()))
 901            {
 0902                await MarkLineAsync(db, line.LineId, true, null, cancellationToken, targetStock);
 0903                return (true, null);
 904            }
 905
 0906            var err = FormatStockErrors(row);
 0907            await MarkLineAsync(db, line.LineId, false, err, cancellationToken);
 0908            return (false, err);
 909        }
 910        catch (Exception ex)
 911        {
 0912            await MarkLineAsync(db, line.LineId, false, ex.Message, cancellationToken);
 0913            return (false, ex.Message);
 914        }
 0915    }
 916
 917    /// <summary>
 918    /// Остатки отчёта взяты из снимка Google-таблицы, а таблица может отставать от 1С (товар уже перемещён,
 919    /// в таблице всё ещё числится). Перед накатом остатка вверх сверяем его с регистром 1С онлайн и не даём
 920    /// выставить на Ozon больше, чем свободно в 1С. Строки, которые не удалось проверить (карточка, склад
 921    /// или производитель не определены, 1С недоступна), накатываются как раньше.
 922    /// </summary>
 923    /// <summary>
 924    /// Сверка строк наката с 1С онлайн. Проверяются Push и Decrease (остаток к накату ограничивается сверху живым
 925    /// остатком 1С) и строки Match из <paramref name="matchProbes"/>: если по 1С свободно меньше, чем на Ozon,
 926    /// строка превращается в Decrease и возвращается вызывающему для добавления к накату.
 927    /// </summary>
 928    private async Task<List<ApplyLineRow>> ApplyLiveStockGuardAsync(
 929        ApplicationDbContext db,
 930        List<ApplyLineRow> lines,
 931        List<ApplyLineRow> matchProbes,
 932        Func<string, Task> log,
 933        CancellationToken cancellationToken)
 934    {
 0935        var converted = new List<ApplyLineRow>();
 0936        var candidates = lines
 937            .Where(l => (string.Equals(l.Action, BitrixStockSyncReportActions.Push, StringComparison.OrdinalIgnoreCase)
 938                         || string.Equals(l.Action, BitrixStockSyncReportActions.Decrease, StringComparison.OrdinalIgnor
 939                        && !l.PriorityForceClear && !l.PrioritySkip && l.OneCQty > 0 && !string.IsNullOrWhiteSpace(l.Art
 940            .Concat(matchProbes.Where(l => !string.IsNullOrWhiteSpace(l.Article)))
 0941            .ToList();
 0942        if (candidates.Count == 0)
 0943            return converted;
 944
 945        try
 946        {
 947            // Производитель нужен, чтобы найти карточку в 1С; в строке отчёта его нет — берём из снимка.
 948            var articles = candidates.Select(l => l.Article).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 0949            var manufacturers = new List<(string Article, string Manufacturer)>();
 0950            foreach (var chunk in articles.Chunk(500))
 951            {
 0952                var part = await db.WarehouseStockSnapshots.AsNoTracking()
 0953                    .Where(s => chunk.Contains(s.Article) && s.Manufacturer != null && s.Manufacturer != "")
 0954                    .Select(s => new { s.Article, s.Manufacturer })
 0955                    .Distinct()
 0956                    .ToListAsync(cancellationToken);
 957                manufacturers.AddRange(part.Select(p => (p.Article ?? string.Empty, p.Manufacturer!)));
 958            }
 959
 0960            var producerByArticle = manufacturers
 961                .GroupBy(m => m.Article, StringComparer.OrdinalIgnoreCase)
 962                .Select(g => (Article: g.Key, Producers: g.Select(x => x.Manufacturer).Distinct(StringComparer.OrdinalIg
 963                .Where(x => x.Producers.Count == 1) // один артикул у нескольких производителей — карточку не угадать
 964                .ToDictionary(x => x.Article, x => x.Producers[0], StringComparer.OrdinalIgnoreCase);
 965
 0966            var toCheck = candidates
 0967                .Where(l => producerByArticle.ContainsKey(l.Article) && BitrixStockLiveStockClamp.SplitStores(l.OneCStor
 0968                .Select(l => new LiveStockLine(l.LineId, l.Article, producerByArticle[l.Article],
 0969                    BitrixStockLiveStockClamp.SplitStores(l.OneCStore), l.ReservedQty))
 0970                .ToList();
 0971            if (toCheck.Count == 0)
 0972                return converted;
 973
 0974            var freeByLine = await _liveStockGuard.GetLiveFreeStockAsync(toCheck, cancellationToken);
 0975            var clamped = 0;
 0976            foreach (var line in candidates)
 977            {
 0978                if (!freeByLine.TryGetValue(line.LineId, out var free) || free >= line.OneCQty)
 979                    continue;
 980
 0981                var newQty = BitrixStockLiveStockClamp.Clamp(line.OneCQty, free);
 0982                line.LiveStockNote = $"Остаток ограничен по 1С онлайн: в отчёте {line.OneCQty}, свободно в 1С {free}";
 0983                await log($"⚠ {line.Article} ({line.OneCStore}): в отчёте 1С {line.OneCQty}, по 1С онлайн свободно {free
 0984                line.OneCQty = newQty;
 0985                clamped++;
 986
 0987                if (matchProbes.Contains(line))
 988                {
 0989                    line.Action = BitrixStockSyncReportActions.Decrease;
 0990                    converted.Add(line);
 991                }
 0992            }
 993
 0994            await log(clamped > 0
 0995                ? $"Сверка с 1С онлайн: проверено {freeByLine.Count} из {candidates.Count} строк, ограничено {clamped}"
 0996                : $"Сверка с 1С онлайн: проверено {freeByLine.Count} из {candidates.Count} строк, расхождений нет");
 0997        }
 0998        catch (OperationCanceledException) { throw; }
 0999        catch (Exception ex)
 1000        {
 1001            // Fail-open: недоступность 1С не должна останавливать накат остатков.
 01002            await log($"Предупреждение: сверка остатков с 1С онлайн не выполнена — {ex.Message}");
 1003        }
 1004
 01005        return converted;
 01006    }
 1007
 1008    private static int GetTargetStock(ApplyLineRow line) =>
 01009        BitrixStockApplyCalculator.GetTargetStock(
 01010            line.PriorityForceClear, line.Action, line.OneCQty, line.BatchMultiplier);
 1011
 1012    private static List<string> ResolveApplyActions(List<string>? requested)
 1013    {
 01014        if (requested == null || requested.Count == 0)
 1015        {
 01016            return BitrixStockSyncReportActions.ApplicableToOzon.ToList();
 1017        }
 1018
 01019        return requested
 01020            .Where(a => !string.IsNullOrWhiteSpace(a))
 01021            .Select(a => a.Trim())
 01022            .Where(a => BitrixStockSyncReportActions.ApplicableToOzon.Contains(a))
 01023            .Distinct(StringComparer.OrdinalIgnoreCase)
 01024            .ToList();
 1025    }
 1026
 1027    private static async Task<ReportMetaRow?> LoadReportMetaAsync(
 1028        ApplicationDbContext db,
 1029        string reportId,
 1030        CancellationToken cancellationToken)
 1031    {
 01032        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01033        {
 01034            var cmd = connection.CreateCommand();
 01035            cmd.CommandText =
 01036                @"SELECT OzonClientId, OzonClientName, Status, COALESCE(MultiClientIdsJson, '[]')
 01037                  FROM BitrixStockSyncReports WHERE ReportId = @id LIMIT 1;";
 01038            AddParam(cmd, "@id", reportId);
 01039            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01040            if (!await reader.ReadAsync(ct))
 01041            {
 01042                return null;
 01043            }
 01044
 01045            return new ReportMetaRow(reader.GetInt32(0), reader.GetString(1), reader.GetString(2),
 01046                reader.IsDBNull(3) ? "[]" : reader.GetString(3));
 01047        }, cancellationToken);
 01048    }
 1049
 1050    private static async Task<List<ApplyLineRow>> LoadClientReferenceLinesAsync(
 1051        ApplicationDbContext db,
 1052        string reportId,
 1053        int clientId,
 1054        ISet<string> articleStorePairs,
 1055        CancellationToken cancellationToken)
 1056    {
 01057        if (articleStorePairs.Count == 0) return new List<ApplyLineRow>();
 1058
 01059        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01060        {
 01061            var pairList = articleStorePairs.ToList();
 1062            var pairParams = string.Join(", ", pairList.Select((_, i) => $"@pair{i}"));
 01063            var sql = $@"
 01064SELECT l.Id, l.ProductId, l.Article, l.OfferId, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId,
 01065       l.OneCQty, l.OzonQty, l.Action, l.OzonProductId, m.Id as MappingId,
 01066       COALESCE(l.IsArchived, 0) as IsArchived, COALESCE(l.IsAutoArchived, 0) as IsAutoArchived,
 01067       COALESCE(l.OzonClientId, 0) as OzonClientId,
 01068       COALESCE(CAST(mfbs.OzonWarehouseId AS SIGNED), 0) as FbsMappedWarehouseId,
 01069       COALESCE(l.BatchMultiplier, 1) as BatchMultiplier
 01070FROM BitrixStockSyncReportLines l
 01071{ReportLineWarehouseMappingJoinSql}
 01072LEFT JOIN WarehouseMapping mfbs ON l.Action = 'FbsNoWarehouse'
 01073  AND l.OneCStore COLLATE utf8mb4_unicode_ci = mfbs.BitrixWarehouseName COLLATE utf8mb4_unicode_ci
 01074  AND mfbs.OzonClientId = l.OzonClientId
 01075  AND mfbs.OzonWarehouseId IS NOT NULL AND mfbs.OzonWarehouseId != ''
 01076WHERE l.ReportId = @reportId
 01077  AND l.OzonClientId = @clientId
 01078  AND CONCAT(l.Article, '||', COALESCE(l.OneCStore, '')) IN ({pairParams})
 01079ORDER BY l.Id";
 01080
 01081            var cmd = connection.CreateCommand();
 01082            cmd.CommandText = sql;
 01083            AddParam(cmd, "@reportId", reportId);
 01084            AddParam(cmd, "@clientId", clientId);
 01085            for (var i = 0; i < pairList.Count; i++)
 01086                AddParam(cmd, $"@pair{i}", pairList[i]);
 01087
 01088            var list = new List<ApplyLineRow>();
 01089            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01090            while (await reader.ReadAsync(ct))
 01091            {
 01092                list.Add(new ApplyLineRow
 01093                {
 01094                    LineId            = reader.GetInt64(0),
 01095                    ProductId         = reader.GetInt32(1),
 01096                    Article           = reader.GetString(2),
 01097                    OfferId           = reader.IsDBNull(3)  ? null : reader.GetString(3),
 01098                    OneCStore         = reader.GetString(4),
 01099                    OzonWarehouseName = reader.GetString(5),
 01100                    OzonWarehouseId   = reader.GetInt64(6),
 01101                    OneCQty           = reader.GetInt32(7),
 01102                    OzonQty           = reader.GetInt32(8),
 01103                    Action            = reader.GetString(9),
 01104                    OzonProductId     = reader.IsDBNull(10) ? null : reader.GetInt64(10),
 01105                    IsArchived        = !reader.IsDBNull(12) && reader.GetInt32(12) != 0,
 01106                    IsAutoArchived    = !reader.IsDBNull(13) && reader.GetInt32(13) != 0,
 01107                    OzonClientId         = reader.IsDBNull(14) ? 0 : reader.GetInt32(14),
 01108                    FbsMappedWarehouseId = reader.IsDBNull(15) ? 0 : reader.GetInt64(15),
 01109                    BatchMultiplier      = reader.IsDBNull(16) ? 1 : reader.GetInt32(16)
 01110                });
 01111            }
 01112
 01113            return list;
 01114        }, cancellationToken);
 01115    }
 1116
 1117    private static async Task<List<ApplyLineRow>> LoadClientLinesWithPositiveStockAsync(
 1118        ApplicationDbContext db,
 1119        string reportId,
 1120        int clientId,
 1121        bool onlyPending,
 1122        IReadOnlyList<string>? articleFilter,
 1123        CancellationToken cancellationToken)
 1124    {
 01125        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01126        {
 01127            var sql = @"
 01128SELECT l.Id, l.ProductId, l.Article, l.OfferId, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId,
 01129       l.OneCQty, l.OzonQty, l.Action, l.OzonProductId, m.Id as MappingId,
 01130       COALESCE(l.IsArchived, 0) as IsArchived, COALESCE(l.IsAutoArchived, 0) as IsAutoArchived,
 01131       COALESCE(l.OzonClientId, 0) as OzonClientId,
 01132       COALESCE(CAST(mfbs.OzonWarehouseId AS SIGNED), 0) as FbsMappedWarehouseId,
 01133       COALESCE(l.BatchMultiplier, 1) as BatchMultiplier
 01134FROM BitrixStockSyncReportLines l
 01135{ReportLineWarehouseMappingJoinSql}
 01136LEFT JOIN WarehouseMapping mfbs ON l.Action = 'FbsNoWarehouse'
 01137  AND l.OneCStore COLLATE utf8mb4_unicode_ci = mfbs.BitrixWarehouseName COLLATE utf8mb4_unicode_ci
 01138  AND mfbs.OzonClientId = l.OzonClientId
 01139  AND mfbs.OzonWarehouseId IS NOT NULL AND mfbs.OzonWarehouseId != ''
 01140WHERE l.ReportId = @reportId
 01141  AND l.OzonClientId = @clientId
 01142  AND l.OzonQty > 0";
 01143
 01144            if (onlyPending)
 01145                sql += " AND (l.OzonApplyStatus IS NULL OR l.OzonApplyStatus IN ('Pending','Failed'))";
 01146
 01147            if (articleFilter is { Count: > 0 })
 01148            {
 1149                var artIn = string.Join(", ", articleFilter.Select((_, i) => $"@art{i}"));
 01150                sql += $" AND l.Article IN ({artIn})";
 01151            }
 01152
 01153            sql += " ORDER BY l.Id";
 01154
 01155            var cmd = connection.CreateCommand();
 01156            cmd.CommandText = sql;
 01157            AddParam(cmd, "@reportId", reportId);
 01158            AddParam(cmd, "@clientId", clientId);
 01159            if (articleFilter is { Count: > 0 })
 01160                for (var i = 0; i < articleFilter.Count; i++)
 01161                    AddParam(cmd, $"@art{i}", articleFilter[i]);
 01162
 01163            var list = new List<ApplyLineRow>();
 01164            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01165            while (await reader.ReadAsync(ct))
 01166            {
 01167                list.Add(new ApplyLineRow
 01168                {
 01169                    LineId               = reader.GetInt64(0),
 01170                    ProductId            = reader.GetInt32(1),
 01171                    Article              = reader.GetString(2),
 01172                    OfferId              = reader.IsDBNull(3)  ? null : reader.GetString(3),
 01173                    OneCStore            = reader.GetString(4),
 01174                    OzonWarehouseName    = reader.GetString(5),
 01175                    OzonWarehouseId      = reader.GetInt64(6),
 01176                    OneCQty              = reader.GetInt32(7),
 01177                    OzonQty              = reader.GetInt32(8),
 01178                    Action               = reader.GetString(9),
 01179                    OzonProductId        = reader.IsDBNull(10) ? null : reader.GetInt64(10),
 01180                    IsArchived           = !reader.IsDBNull(12) && reader.GetInt32(12) != 0,
 01181                    IsAutoArchived       = !reader.IsDBNull(13) && reader.GetInt32(13) != 0,
 01182                    OzonClientId         = reader.IsDBNull(14) ? 0 : reader.GetInt32(14),
 01183                    FbsMappedWarehouseId = reader.IsDBNull(15) ? 0 : reader.GetInt64(15),
 01184                    BatchMultiplier      = reader.IsDBNull(16) ? 1 : reader.GetInt32(16)
 01185                });
 01186            }
 01187
 01188            return list;
 01189        }, cancellationToken);
 01190    }
 1191
 1192    private static async Task<List<ApplyLineRow>> LoadLinesToApplyAsync(
 1193        ApplicationDbContext db,
 1194        string reportId,
 1195        IReadOnlyList<string> actions,
 1196        bool onlyPending,
 1197        int? maxLines,
 1198        ISet<int> disabledMappingIds,
 1199        IReadOnlyList<string>? articles,
 1200        CancellationToken cancellationToken)
 1201    {
 01202        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01203        {
 1204            var actionIn = string.Join(", ", actions.Select((_, i) => $"@a{i}"));
 01205            var sql = $@"
 01206SELECT l.Id, l.ProductId, l.Article, l.OfferId, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId,
 01207       l.OneCQty, l.OzonQty, l.Action, l.OzonProductId, m.Id as MappingId,
 01208       COALESCE(l.IsArchived, 0) as IsArchived, COALESCE(l.IsAutoArchived, 0) as IsAutoArchived,
 01209       COALESCE(l.OzonClientId, 0) as OzonClientId,
 01210       COALESCE(CAST(mfbs.OzonWarehouseId AS SIGNED), 0) as FbsMappedWarehouseId,
 01211       COALESCE(l.BatchMultiplier, 1) as BatchMultiplier,
 01212       COALESCE(l.ReservedQty, 0) as ReservedQty
 01213FROM BitrixStockSyncReportLines l
 01214{ReportLineWarehouseMappingJoinSql}
 01215LEFT JOIN WarehouseMapping mfbs ON l.Action = 'FbsNoWarehouse'
 01216  AND l.OneCStore COLLATE utf8mb4_unicode_ci = mfbs.BitrixWarehouseName COLLATE utf8mb4_unicode_ci
 01217  AND mfbs.OzonClientId = l.OzonClientId
 01218  AND mfbs.OzonWarehouseId IS NOT NULL AND mfbs.OzonWarehouseId != ''
 01219WHERE l.ReportId = @reportId
 01220AND l.Action IN ({actionIn})";
 01221            if (onlyPending)
 01222            {
 01223                sql += " AND (l.OzonApplyStatus IS NULL OR l.OzonApplyStatus IN ('Pending','Failed'))";
 01224            }
 01225
 01226            if (articles is { Count: > 0 })
 01227            {
 1228                var artIn = string.Join(", ", articles.Select((_, i) => $"@art{i}"));
 01229                sql += $" AND l.Article IN ({artIn})";
 01230            }
 01231
 01232            sql += " ORDER BY l.Id";
 01233            if (maxLines is > 0)
 01234            {
 01235                sql += " LIMIT @take";
 01236            }
 01237
 01238            var cmd = connection.CreateCommand();
 01239            cmd.CommandText = sql;
 01240            AddParam(cmd, "@reportId", reportId);
 01241            for (var i = 0; i < actions.Count; i++)
 01242            {
 01243                AddParam(cmd, $"@a{i}", actions[i]);
 01244            }
 01245
 01246            if (articles is { Count: > 0 })
 01247            {
 01248                for (var i = 0; i < articles.Count; i++)
 01249                    AddParam(cmd, $"@art{i}", articles[i]);
 01250            }
 01251
 01252            if (maxLines is > 0)
 01253            {
 01254                AddParam(cmd, "@take", maxLines.Value);
 01255            }
 01256
 01257            var list = new List<ApplyLineRow>();
 01258            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01259            while (await reader.ReadAsync(ct))
 01260            {
 01261                int? mappingId = reader.IsDBNull(11) ? (int?)null : reader.GetInt32(11);
 01262
 01263                // Пропустить если связь отключена
 01264                if (mappingId.HasValue && disabledMappingIds.Contains(mappingId.Value))
 01265                {
 01266                    continue;
 01267                }
 01268
 01269                list.Add(new ApplyLineRow
 01270                {
 01271                    LineId = reader.GetInt64(0),
 01272                    ProductId = reader.GetInt32(1),
 01273                    Article = reader.GetString(2),
 01274                    OfferId = reader.IsDBNull(3) ? null : reader.GetString(3),
 01275                    OneCStore = reader.GetString(4),
 01276                    OzonWarehouseName = reader.GetString(5),
 01277                    OzonWarehouseId = reader.GetInt64(6),
 01278                    OneCQty = reader.GetInt32(7),
 01279                    OzonQty = reader.GetInt32(8),
 01280                    Action = reader.GetString(9),
 01281                    OzonProductId = reader.IsDBNull(10) ? null : reader.GetInt64(10),
 01282                    IsArchived           = !reader.IsDBNull(12) && reader.GetInt32(12) != 0,
 01283                    IsAutoArchived       = !reader.IsDBNull(13) && reader.GetInt32(13) != 0,
 01284                    OzonClientId         = reader.IsDBNull(14) ? 0 : reader.GetInt32(14),
 01285                    FbsMappedWarehouseId = reader.IsDBNull(15) ? 0 : reader.GetInt64(15),
 01286                    BatchMultiplier      = reader.IsDBNull(16) ? 1 : reader.GetInt32(16),
 01287                    ReservedQty          = reader.IsDBNull(17) ? 0 : reader.GetInt32(17)
 01288                });
 01289            }
 01290
 01291            return list;
 01292        }, cancellationToken);
 01293    }
 1294
 1295    private static async Task MarkLineAsync(
 1296        ApplicationDbContext db,
 1297        long lineId,
 1298        bool success,
 1299        string? error,
 1300        CancellationToken cancellationToken,
 1301        int? qtyAfter = null)
 1302    {
 01303        await db.Database.ExecuteSqlRawAsync(
 01304            @"UPDATE BitrixStockSyncReportLines
 01305SET OzonApplyStatus = {1}, OzonAppliedUtc = UTC_TIMESTAMP(6), OzonApplyError = {2}, OzonQtyAfter = {3}
 01306WHERE Id = {0};",
 01307            EfSqlRawParameterGuard.Materialize(lineId, success ? BitrixStockSyncReportApplyStatuses.Success : BitrixStoc
 01308            cancellationToken);
 01309    }
 1310
 1311    private static async Task<bool> TryUpdateBatchAsync(
 1312        OzonApiDataManager apiManager,
 1313        List<StockItemForUpdate> stockPayload,
 1314        List<BitrixStockPushItemDto> batchItems,
 1315        Func<BitrixStockPushItemDto, string, Task> onFailed,
 1316        Func<BitrixStockPushItemDto, Task> onSuccess,
 1317        CancellationToken cancellationToken)
 1318    {
 1319        try
 1320        {
 01321            var result = await apiManager.UpdateProductStocks(stockPayload);
 01322            if (result?.Result == null || result.Result.Count == 0)
 1323            {
 01324                return false;
 1325            }
 1326
 01327            var allOk = true;
 01328            foreach (var row in result.Result)
 1329            {
 01330                cancellationToken.ThrowIfCancellationRequested();
 1331                // По offer_id + складу, а не только по product_id: в отчёте он может быть устаревшим.
 01332                var item = BitrixStockApplyBatchMatcher.FindItem(batchItems, row);
 01333                if (item == null)
 1334                {
 1335                    continue;
 1336                }
 1337
 01338                if (row.Updated && (row.Errors == null || !row.Errors.Any()))
 1339                {
 01340                    await onSuccess(item);
 1341                }
 1342                else
 1343                {
 01344                    allOk = false;
 01345                    await onFailed(item, FormatStockErrors(row));
 1346                }
 1347            }
 1348
 01349            return allOk;
 1350        }
 01351        catch
 1352        {
 01353            return false;
 1354        }
 01355    }
 1356
 1357    private static string FormatStockErrors(UpdateStockResult? row)
 1358    {
 01359        if (row?.Errors != null && row.Errors.Any())
 1360        {
 01361            return string.Join("; ", row.Errors.Select(e => BitrixOzonStockErrorMapper.Map(e.Message ?? e.Code)));
 1362        }
 1363
 01364        return row?.Updated == true ? string.Empty : "Ozon не обновил остаток";
 1365    }
 1366
 1367    private static async Task<string[]> LoadAllReportOfferIdsAsync(
 1368        ApplicationDbContext db,
 1369        string reportId,
 1370        CancellationToken cancellationToken)
 1371    {
 01372        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01373        {
 01374            var cmd = connection.CreateCommand();
 01375            cmd.CommandText = "SELECT DISTINCT OfferId FROM BitrixStockSyncReportLines WHERE ReportId = @reportId AND Of
 01376            AddParam(cmd, "@reportId", reportId);
 01377            var list = new List<string>();
 01378            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01379            while (await reader.ReadAsync(ct))
 01380                list.Add(reader.GetString(0));
 01381            return list.ToArray();
 01382        }, cancellationToken);
 01383    }
 1384
 1385    /// <summary>Возвращает словарь OfferId → IsAutoArchived для всех архивных строк отчёта.</summary>
 1386    private static async Task<Dictionary<string, bool>> LoadOfferArchiveTypesAsync(
 1387        ApplicationDbContext db,
 1388        string reportId,
 1389        CancellationToken cancellationToken)
 1390    {
 01391        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 01392        {
 01393            var cmd = connection.CreateCommand();
 01394            cmd.CommandText = @"
 01395SELECT OfferId, MAX(IsAutoArchived) AS IsAutoArchived
 01396FROM BitrixStockSyncReportLines
 01397WHERE ReportId = @reportId AND IsArchived = 1 AND OfferId IS NOT NULL AND OfferId != ''
 01398GROUP BY OfferId;";
 01399            AddParam(cmd, "@reportId", reportId);
 01400            var dict = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
 01401            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01402            while (await reader.ReadAsync(ct))
 01403            {
 01404                var offerId = reader.GetString(0);
 01405                var isAuto = !reader.IsDBNull(1) && Convert.ToInt32(reader.GetValue(1)) != 0;
 01406                dict[offerId] = isAuto;
 01407            }
 01408            return dict;
 01409        }, cancellationToken);
 01410    }
 1411
 1412    private static async Task<T> WithOpenConnectionAsync<T>(
 1413        ApplicationDbContext db,
 1414        Func<System.Data.Common.DbConnection, CancellationToken, Task<T>> action,
 1415        CancellationToken cancellationToken)
 1416    {
 01417        await db.Database.OpenConnectionAsync(cancellationToken);
 1418        try
 1419        {
 01420            return await action(db.Database.GetDbConnection(), cancellationToken);
 1421        }
 1422        finally
 1423        {
 01424            await db.Database.CloseConnectionAsync();
 1425        }
 01426    }
 1427
 1428    private static void AddParam(System.Data.Common.DbCommand command, string name, object? value)
 1429    {
 01430        var parameter = command.CreateParameter();
 01431        parameter.ParameterName = name;
 01432        parameter.Value = value ?? DBNull.Value;
 01433        command.Parameters.Add(parameter);
 01434    }
 1435
 121436    private sealed record ReportMetaRow(int OzonClientId, string OzonClientName, string Status, string MultiClientIdsJso
 1437
 1438    private static void ApplyPriorityForTwoClients(List<ApplyLineRow> lines, ReportMetaRow meta, Dictionary<string, Appl
 1439    {
 61440        var orderedClients = ParseMultiClientIds(meta.MultiClientIdsJson);
 61441        if (orderedClients.Count < 2)
 01442            return;
 1443
 61444        var k1Id = orderedClients[0];
 61445        var k2Id = orderedClients[1];
 171446        var groups = lines.GroupBy(l => $"{l.Article}||{l.OneCStore}", StringComparer.OrdinalIgnoreCase);
 241447        foreach (var group in groups)
 1448        {
 211449            var k1 = group.Where(l => l.OzonClientId == k1Id).OrderBy(l => l.LineId).FirstOrDefault();
 1450            // Если K1 не загружен (Action=Match не входит в ApplicableToOzon) — берём из ref-карты
 61451            if (k1 == null && k1RefMap != null)
 11452                k1RefMap.TryGetValue(group.Key, out k1);
 1453            // Все строки К2 в группе — один 1С-склад может иметь несколько Ozon-складов (M2→M2, M2→M2S)
 241454            var k2Lines = group.Where(l => l.OzonClientId == k2Id).OrderBy(l => l.LineId).ToList();
 61455            var k1Found = IsFoundForPriority(k1);
 1456
 61457            if (!k1Found)
 1458            {
 11459                if (k1 != null) MarkSkip(k1, "К1 не найден");
 11460                var anyK2Found = k2Lines.Any(IsFoundForPriority);
 11461                if (!anyK2Found) { foreach (var l in k2Lines) MarkSkip(l, "К2 не найден"); continue; }
 51462                foreach (var l in k2Lines) if (l.IsArchived) l.PriorityNeedUnarchive = true;
 1463                continue;
 1464            }
 1465
 51466            if (k1!.IsArchived) k1.PriorityNeedUnarchive = true;
 1467
 51468            if (k2Lines.Count == 0) continue;
 1469
 1470            // Применяем приоритет ко ВСЕМ строкам К2 в группе
 221471            foreach (var k2 in k2Lines)
 1472            {
 61473                if (!IsFoundForPriority(k2))
 1474                {
 01475                    MarkSkip(k2, "К2 не найден");
 01476                    continue;
 1477                }
 1478
 61479                if (k2.IsArchived)
 1480                {
 01481                    MarkSkip(k2, "К2 архивный — не трогаем");
 01482                    continue;
 1483                }
 1484
 61485                if (k2.OzonQty == 0)
 1486                {
 1487                    // Для Action=Push: отчёт может содержать устаревший остаток (e.g. товары "Убран из продажи"
 1488                    // возвращают present=0 из API даже при физическом FBS-остатке > 0).
 1489                    // Явно отправляем 0, чтобы гарантированно обнулить К2.
 51490                    if (string.Equals(k2.Action, BitrixStockSyncReportActions.Push, StringComparison.OrdinalIgnoreCase))
 41491                        k2.PriorityForceClear = true;
 1492                    else
 11493                        MarkSkip(k2, "К2 уже 0 — пропуск");
 1494                }
 1495                else
 1496                {
 11497                    k2.PriorityForceClear = true;
 1498                }
 1499            }
 1500        }
 61501    }
 1502
 1503    private static bool IsFoundForPriority(ApplyLineRow? line)
 1504    {
 141505        if (line == null) return false;
 121506        if (string.Equals(line.Action, BitrixStockSyncReportActions.NotFound, StringComparison.OrdinalIgnoreCase)) retur
 121507        if (line.EffectiveWarehouseId <= 0 || line.OzonProductId is not > 0 || string.IsNullOrWhiteSpace(line.OfferId)) 
 121508        return true;
 1509    }
 1510
 1511    private static void MarkSkip(ApplyLineRow line, string reason)
 1512    {
 11513        line.PrioritySkip = true;
 11514        line.PrioritySkipReason = reason;
 11515        line.PriorityForceClear = false;
 11516        line.PriorityNeedUnarchive = false;
 11517    }
 1518
 1519    private static string GetPriorityKey(ApplyLineRow line) =>
 01520        $"{line.Article}||{line.OneCStore}";
 1521
 1522    private static bool IsNotSellableError(string? error) =>
 01523        !string.IsNullOrWhiteSpace(error) &&
 01524        error.Contains("Не продается", StringComparison.OrdinalIgnoreCase);
 1525
 1526    private static List<int> ParseMultiClientIds(string json) =>
 61527        BitrixStockMultiClientParser.ParseMultiClientIds(json);
 1528
 1529    private sealed class ApplyLineRow
 1530    {
 231531        public long LineId { get; init; }
 01532        public int ProductId { get; init; }
 351533        public string Article { get; init; } = string.Empty;
 241534        public string? OfferId { get; init; }
 351535        public string OneCStore { get; init; } = string.Empty;
 121536        public string OzonWarehouseName { get; init; } = string.Empty;
 361537        public long OzonWarehouseId { get; init; }
 1538        /// <summary>Для FbsNoWarehouse строк: warehouse_id из WarehouseMapping по OneCStore+OzonClientId.</summary>
 01539        public long FbsMappedWarehouseId { get; init; }
 121540        public long EffectiveWarehouseId => OzonWarehouseId > 0 ? OzonWarehouseId : FbsMappedWarehouseId;
 1541        /// <summary>Остаток 1С к накату. Меняется только защитой по живому остатку 1С (ApplyLiveStockGuardAsync).</summ
 121542        public int OneCQty { get; set; }
 181543        public int OzonQty { get; init; }
 411544        public string Action { get; set; } = string.Empty;
 241545        public long? OzonProductId { get; init; }
 241546        public bool IsArchived { get; init; }
 01547        public bool IsAutoArchived { get; init; }
 341548        public int OzonClientId { get; init; }
 61549        public bool PrioritySkip { get; set; }
 11550        public string? PrioritySkipReason { get; set; }
 131551        public bool PriorityForceClear { get; set; }
 11552        public bool PriorityNeedUnarchive { get; set; }
 1553        /// <summary>Размер партии [N] из OfferId (например 5 для [5]). 1 если не партия.</summary>
 121554        public int BatchMultiplier { get; init; } = 1;
 1555        /// <summary>Резерв заказов, уже вычтенный из OneCQty при построении отчёта.</summary>
 01556        public int ReservedQty { get; init; }
 1557        /// <summary>Причина, по которой остаток ограничен по живому остатку 1С (пишется в примечание строки).</summary>
 01558        public string? LiveStockNote { get; set; }
 1559    }
 1560}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncReportApplyService.DisabledZero.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using OzonDomains;
 3using OzonDomains.Models;
 4using OzonRepositories.Context;
 5using Services.ApiServices.OzonApi;
 6using Services.ApiServices.OzonApi.Filters;
 7
 8namespace Services.BitrixStockServices;
 9
 10internal sealed class BitrixStockApplyDisabledZeroResult
 11{
 12    public int Processed { get; set; }
 13    public int Total { get; set; }
 14    public int SuccessCount { get; set; }
 15    public int FailedCount { get; set; }
 16}
 17
 18public partial class BitrixStockSyncReportApplyService
 19{
 20    private async Task<BitrixStockApplyDisabledZeroResult> ZeroDisabledWarehouseStocksAsync(
 21        ApplicationDbContext db,
 22        string reportId,
 23        ISet<int> disabledMappingIds,
 24        ReportMetaRow meta,
 25        IReadOnlyDictionary<int, OzonClient> clientMap,
 26        bool onlyPending,
 27        IReadOnlyList<string>? articleFilter,
 28        int processed,
 29        int total,
 30        int successCount,
 31        int failedCount,
 32        Func<string, Task> log,
 33        Func<int, int, string, Task> progress,
 34        Func<ApplyLineRow, int, string, bool, string?, string?, string?, Task> logLine,
 35        CancellationToken cancellationToken)
 36    {
 037        var result = new BitrixStockApplyDisabledZeroResult
 038        {
 039            Processed = processed,
 040            Total = total,
 041            SuccessCount = successCount,
 042            FailedCount = failedCount
 043        };
 44
 045        if (disabledMappingIds.Count == 0)
 046            return result;
 47
 048        var products = await LoadApplyDisabledReportProductsAsync(db, reportId, articleFilter, cancellationToken);
 049        if (products.Count == 0)
 50        {
 051            await log("Снятые связи: в отчёте нет товаров для live-обнуления.");
 052            return result;
 53        }
 54
 055        var lineStates = await LoadApplyDisabledLineStatesAsync(db, reportId, cancellationToken);
 056        var productsByClient = products
 057            .GroupBy(p => p.OzonClientId > 0 ? p.OzonClientId : meta.OzonClientId)
 058            .ToList();
 59
 060        var allTargets = new List<BitrixStockSyncApplyDisabledZeroTarget>();
 061        foreach (var clientGroup in productsByClient)
 62        {
 063            cancellationToken.ThrowIfCancellationRequested();
 064            var clientId = clientGroup.Key;
 065            if (!clientMap.TryGetValue(clientId, out var client))
 66            {
 067                await log($"Снятые связи: клиент Ozon ID {clientId} не найден — пропуск.");
 068                continue;
 69            }
 70
 071            var mappings = await db.WarehouseMappings
 072                .Where(m => m.OzonClientId == clientId && m.OzonWarehouseId != null && m.OzonWarehouseId != "")
 073                .OrderBy(m => m.Id)
 074                .ToListAsync(cancellationToken);
 75
 076            var warehouseSets = BitrixStockSyncApplyDisabledPlanner.BuildWarehouseSets(mappings, disabledMappingIds);
 077            if (warehouseSets.DisabledWarehouseIds.Count == 0)
 78            {
 079                await log($"[{client.Name}] Снятые связи: нет складов Ozon для обнуления (коллизия с включёнными связями
 080                continue;
 81            }
 82
 083            var whList = warehouseSets.DisabledWarehouseIds
 084                .Select(id => warehouseSets.WarehouseNameById.TryGetValue(id, out var name)
 085                    ? $"{name} ({id})"
 086                    : id.ToString())
 087                .ToList();
 088            await log($"[{client.Name}] Снятые связи: {whList.Count} склад(ов) Ozon — {string.Join(", ", whList)}");
 89
 090            var offerIds = clientGroup
 91                .Select(p => p.OfferId)
 92                .Where(id => !string.IsNullOrWhiteSpace(id))
 093                .Distinct(StringComparer.OrdinalIgnoreCase)
 094                .ToList();
 95
 096            await log($"[{client.Name}] Live FBS Ozon: {offerIds.Count} offer_id…");
 097            var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 098            var fbsByOfferId = await BitrixStockSyncFbsWarehouseBatchLoader.LoadByOfferIdsAsync(
 099                apiManager,
 0100                offerIds,
 0101                async msg => await log($"[{client.Name}] {msg}"),
 0102                cancellationToken);
 103
 0104            var articleSet = articleFilter is { Count: > 0 }
 0105                ? articleFilter.ToHashSet(StringComparer.OrdinalIgnoreCase)
 0106                : null;
 107
 0108            var targets = BitrixStockSyncApplyDisabledPlanner.SelectTargets(
 0109                clientGroup.ToList(),
 0110                fbsByOfferId,
 0111                warehouseSets,
 0112                lineStates,
 0113                onlyPending,
 0114                articleSet,
 0115                out var selectionStats);
 116
 0117            if (selectionStats.FallbackFromReportCount > 0)
 0118                await log($"[{client.Name}] Снятые связи: {selectionStats.FallbackFromReportCount} поз. — live Ozon не в
 0119            if (selectionStats.AlreadyZeroCount > 0)
 0120                await log($"[{client.Name}] Снятые связи: уже 0 (обнулять нечего) — {selectionStats.AlreadyZeroCount} по
 0121            if (selectionStats.AlreadyClearedSkipCount > 0)
 0122                await log($"[{client.Name}] Снятые связи: пропущено — уже обнулено ранее (onlyPending) — {selectionStats
 123
 0124            allTargets.AddRange(targets);
 0125        }
 126
 0127        if (allTargets.Count == 0)
 128        {
 0129            await log("Снятые связи: live Ozon — нечего обнулять (остатки 0 или уже обработано).");
 0130            return result;
 131        }
 132
 0133        result.Total += allTargets.Count;
 0134        await log($"Снятые связи: к обнулению {allTargets.Count} позиций (live Ozon)…");
 135
 136        foreach (var clientTargets in allTargets.GroupBy(t => t.OzonClientId))
 137        {
 0138            cancellationToken.ThrowIfCancellationRequested();
 0139            var clientId = clientTargets.Key;
 0140            if (!clientMap.TryGetValue(clientId, out var client))
 141                continue;
 142
 0143            var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 0144            var clientName = client.Name ?? meta.OzonClientName;
 0145            var targets = clientTargets.ToList();
 146
 0147            for (var offset = 0; offset < targets.Count; offset += BatchSize)
 148            {
 0149                cancellationToken.ThrowIfCancellationRequested();
 0150                var batch = targets.Skip(offset).Take(BatchSize).ToList();
 151                var lookup = batch.ToDictionary(t => $"{t.OzonProductId}_{t.WarehouseId}");
 152
 153                var payload = batch.Select(t => new StockItemForUpdate
 154                {
 155                    OfferId = t.OfferId,
 156                    ProductId = t.OzonProductId,
 157                    Stock = 0,
 158                    WarehouseId = t.WarehouseId
 159                }).ToList();
 160
 0161                var pushItems = batch.Select(t => new BitrixStockPushItemDto
 0162                {
 0163                    Article = t.Article,
 0164                    OfferId = t.OfferId,
 0165                    ProductId = t.OzonProductId,
 0166                    WarehouseId = t.WarehouseId,
 0167                    WarehouseName = t.WarehouseName,
 0168                    OzonClient = clientName,
 0169                    LinkedStoreName = t.OneCStore,
 0170                    PreviousStock = t.Present,
 0171                    TargetStock = 0
 0172                }).ToList();
 173
 0174                var handled = new HashSet<string>(StringComparer.Ordinal);
 0175                var batchOk = await TryUpdateBatchAsync(apiManager, payload, pushItems,
 0176                    async (item, error) =>
 0177                    {
 0178                        if (!lookup.TryGetValue($"{item.ProductId}_{item.WarehouseId}", out var target))
 0179                            return;
 0180                        handled.Add($"{target.OzonProductId}_{target.WarehouseId}");
 0181                        result.Processed++;
 0182                        result.FailedCount++;
 0183                        await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync(
 0184                            db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName,
 0185                            target.OneCStore, false, error, target.Present, target.OfferId, target.OzonProductId,
 0186                            cancellationToken);
 0187                        await log($"✗ {target.Article} ({target.OneCStore}→{target.WarehouseName}): {error} (связь отклю
 0188                        await LogDisabledTargetLine(target, clientId, clientName, false, error, logLine);
 0189                        await progress(result.Processed, result.Total, target.Article);
 0190                    },
 0191                    async item =>
 0192                    {
 0193                        if (!lookup.TryGetValue($"{item.ProductId}_{item.WarehouseId}", out var target))
 0194                            return;
 0195                        handled.Add($"{target.OzonProductId}_{target.WarehouseId}");
 0196                        result.Processed++;
 0197                        result.SuccessCount++;
 0198                        await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync(
 0199                            db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName,
 0200                            target.OneCStore, true, null, target.Present, target.OfferId, target.OzonProductId,
 0201                            cancellationToken);
 0202                        await log($"⓪ {target.Article} ({target.OneCStore}→{target.WarehouseName}): {target.Present} → 0
 0203                        await LogDisabledTargetLine(target, clientId, clientName, true, null, logLine);
 0204                        await progress(result.Processed, result.Total, target.Article);
 0205                    },
 0206                    cancellationToken);
 207
 0208                if (!batchOk)
 209                {
 0210                    foreach (var target in batch.Where(t => !handled.Contains($"{t.OzonProductId}_{t.WarehouseId}")))
 211                    {
 0212                        cancellationToken.ThrowIfCancellationRequested();
 0213                        var single = new List<StockItemForUpdate>
 0214                        {
 0215                            new()
 0216                            {
 0217                                OfferId = target.OfferId,
 0218                                ProductId = target.OzonProductId,
 0219                                Stock = 0,
 0220                                WarehouseId = target.WarehouseId
 0221                            }
 0222                        };
 223                        try
 224                        {
 0225                            var apiResult = await apiManager.UpdateProductStocks(single);
 0226                            var row = apiResult?.Result?.FirstOrDefault();
 0227                            if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any()))
 228                            {
 0229                                result.Processed++;
 0230                                result.SuccessCount++;
 0231                                await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync(
 0232                                    db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName,
 0233                                    target.OneCStore, true, null, target.Present, target.OfferId, target.OzonProductId,
 0234                                    cancellationToken);
 0235                                await log($"⓪ {target.Article}: повтор по одной (связь отключена, Ozon ID {target.Wareho
 0236                                await LogDisabledTargetLine(target, clientId, clientName, true, null, logLine);
 237                            }
 238                            else
 239                            {
 0240                                var err = FormatStockErrors(row);
 0241                                result.Processed++;
 0242                                result.FailedCount++;
 0243                                await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync(
 0244                                    db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName,
 0245                                    target.OneCStore, false, err, target.Present, target.OfferId, target.OzonProductId,
 0246                                    cancellationToken);
 0247                                await log($"✗ {target.Article}: {err} (связь отключена){DisabledZeroFallbackSuffix(targe
 0248                                await LogDisabledTargetLine(target, clientId, clientName, false, err, logLine);
 0249                            }
 0250                        }
 0251                        catch (Exception ex)
 252                        {
 0253                            result.Processed++;
 0254                            result.FailedCount++;
 0255                            await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync(
 0256                                db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName,
 0257                                target.OneCStore, false, ex.Message, target.Present, target.OfferId, target.OzonProductI
 0258                                cancellationToken);
 0259                            await log($"✗ {target.Article}: {ex.Message} (связь отключена){DisabledZeroFallbackSuffix(ta
 0260                            await LogDisabledTargetLine(target, clientId, clientName, false, ex.Message, logLine);
 0261                        }
 262
 0263                        await progress(result.Processed, result.Total, target.Article);
 0264                        await Task.Delay(200, cancellationToken);
 0265                    }
 266                }
 267
 0268                if (offset + BatchSize < targets.Count)
 0269                    await Task.Delay(DelayBetweenBatchesMs, cancellationToken);
 0270            }
 0271        }
 272
 0273        return result;
 0274    }
 275
 276    private static string DisabledZeroFallbackSuffix(BitrixStockSyncApplyDisabledZeroTarget target) =>
 0277        target.FromReportFallback ? " [остаток из отчёта, live Ozon склад не вернул]" : string.Empty;
 278
 279    private static async Task LogDisabledTargetLine(
 280        BitrixStockSyncApplyDisabledZeroTarget target,
 281        int clientId,
 282        string clientName,
 283        bool ok,
 284        string? error,
 285        Func<ApplyLineRow, int, string, bool, string?, string?, string?, Task> logLine)
 286    {
 0287        var pseudoLine = new ApplyLineRow
 0288        {
 0289            Article = target.Article,
 0290            OneCStore = target.OneCStore,
 0291            OzonWarehouseName = target.WarehouseName,
 0292            OzonQty = target.Present,
 0293            OneCQty = 0
 0294        };
 0295        await logLine(
 0296            pseudoLine,
 0297            clientId,
 0298            clientName,
 0299            ok,
 0300            error,
 0301            ApplyArticleActionCodes.ClearDisabledLink,
 0302            ok ? $"{target.Present} → 0 (связь отключена)" : error);
 0303    }
 304
 305    private static async Task<List<BitrixStockSyncApplyDisabledReportProduct>> LoadApplyDisabledReportProductsAsync(
 306        ApplicationDbContext db,
 307        string reportId,
 308        IReadOnlyList<string>? articleFilter,
 309        CancellationToken cancellationToken)
 310    {
 0311        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 0312        {
 0313            // Построчно (не DISTINCT по продукту) — нужны OzonWarehouseId/OzonQty каждой строки отчёта
 0314            // как fallback-остаток, когда live-запрос к Ozon на этапе 2 не вернёт разбивку по складу.
 0315            var sql = @"
 0316SELECT COALESCE(l.OzonClientId, 0), l.Article, l.OfferId, l.OzonProductId, l.OzonWarehouseId, l.OzonQty
 0317FROM BitrixStockSyncReportLines l
 0318WHERE l.ReportId = @reportId
 0319  AND l.OfferId IS NOT NULL AND l.OfferId != ''
 0320  AND l.OzonProductId IS NOT NULL AND l.OzonProductId > 0";
 0321
 0322            if (articleFilter is { Count: > 0 })
 0323            {
 324                var artIn = string.Join(", ", articleFilter.Select((_, i) => $"@art{i}"));
 0325                sql += $" AND l.Article IN ({artIn})";
 0326            }
 0327
 0328            var cmd = connection.CreateCommand();
 0329            cmd.CommandText = sql;
 0330            AddParam(cmd, "@reportId", reportId);
 0331            if (articleFilter is { Count: > 0 })
 0332            {
 0333                for (var i = 0; i < articleFilter.Count; i++)
 0334                    AddParam(cmd, $"@art{i}", articleFilter[i]);
 0335            }
 0336
 0337            var warehousesByKey =
 0338                new Dictionary<(int ClientId, string Article, string OfferId, long ProductId),
 0339                    List<BitrixStockSyncApplyDisabledReportWarehouseQty>>();
 0340
 0341            await using var reader = await cmd.ExecuteReaderAsync(ct);
 0342            while (await reader.ReadAsync(ct))
 0343            {
 0344                var clientId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0);
 0345                var article = reader.GetString(1);
 0346                var offerId = reader.IsDBNull(2) ? string.Empty : reader.GetString(2);
 0347                var productId = reader.IsDBNull(3) ? 0L : reader.GetInt64(3);
 0348                var warehouseId = reader.IsDBNull(4) ? 0L : reader.GetInt64(4);
 0349                var ozonQty = reader.IsDBNull(5) ? 0 : reader.GetInt32(5);
 0350
 0351                var key = (clientId, article, offerId, productId);
 0352                if (!warehousesByKey.TryGetValue(key, out var whList))
 0353                {
 0354                    whList = new List<BitrixStockSyncApplyDisabledReportWarehouseQty>();
 0355                    warehousesByKey[key] = whList;
 0356                }
 0357
 0358                if (warehouseId > 0)
 0359                    whList.Add(new BitrixStockSyncApplyDisabledReportWarehouseQty { WarehouseId = warehouseId, OzonQty =
 0360            }
 0361
 362            return warehousesByKey.Select(kv => new BitrixStockSyncApplyDisabledReportProduct
 363            {
 364                OzonClientId = kv.Key.ClientId,
 365                Article = kv.Key.Article,
 366                OfferId = kv.Key.OfferId,
 367                OzonProductId = kv.Key.ProductId,
 368                ReportWarehouses = kv.Value
 369            }).ToList();
 0370        }, cancellationToken);
 0371    }
 372
 373    private static async Task<Dictionary<string, BitrixStockSyncApplyDisabledLineState>> LoadApplyDisabledLineStatesAsyn
 374        ApplicationDbContext db,
 375        string reportId,
 376        CancellationToken cancellationToken)
 377    {
 0378        return await WithOpenConnectionAsync(db, async (connection, ct) =>
 0379        {
 0380            const string sql = @"
 0381SELECT l.Article, COALESCE(l.OzonClientId, 0), l.OzonWarehouseId,
 0382       l.OzonApplyStatus, l.OzonApplyError, l.Action
 0383FROM BitrixStockSyncReportLines l
 0384WHERE l.ReportId = @reportId
 0385  AND l.OzonWarehouseId > 0";
 0386
 0387            var cmd = connection.CreateCommand();
 0388            cmd.CommandText = sql;
 0389            AddParam(cmd, "@reportId", reportId);
 0390
 0391            var dict = new Dictionary<string, BitrixStockSyncApplyDisabledLineState>(StringComparer.OrdinalIgnoreCase);
 0392            await using var reader = await cmd.ExecuteReaderAsync(ct);
 0393            while (await reader.ReadAsync(ct))
 0394            {
 0395                var article = reader.GetString(0);
 0396                var clientId = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
 0397                var warehouseId = reader.GetInt64(2);
 0398                var key = BitrixStockSyncApplyDisabledPlanner.BuildLineStateKey(article, clientId, warehouseId);
 0399                dict[key] = new BitrixStockSyncApplyDisabledLineState
 0400                {
 0401                    OzonApplyStatus = reader.IsDBNull(3) ? null : reader.GetString(3),
 0402                    OzonApplyError = reader.IsDBNull(4) ? null : reader.GetString(4),
 0403                    Action = reader.IsDBNull(5) ? null : reader.GetString(5)
 0404                };
 0405            }
 0406
 0407            return dict;
 0408        }, cancellationToken);
 0409    }
 410}

Methods/Properties

.ctor(Microsoft.EntityFrameworkCore.IDbContextFactory`1<OzonRepositories.Context.ApplicationDbContext>,Services.DataServices.OzonClientServices,System.Net.Http.IHttpClientFactory,SlqStudio.Application.Services.EmailService.IEmailService,Microsoft.AspNetCore.SignalR.IHubContext`1<Services.SignalRServices.JobProgressHub>,Services.BitrixStockServices.BitrixStockSyncApplyArticleLogService,Services.BitrixStockServices.BitrixStockActiveStageStore,Services.BitrixStockServices.IBitrixStockExceptionService,Services.BitrixStockServices.IBitrixStockLiveStockGuard)
ApplyReportAsync()
Log()
Progress()
Complete()
LogLine()
RetryRateLimitedAsync()
<ApplyReportAsync()
<ApplyReportAsync()
ApplySingleAsync()
ApplyLiveStockGuardAsync()
GetTargetStock(Services.BitrixStockServices.BitrixStockSyncReportApplyService/ApplyLineRow)
ResolveApplyActions(System.Collections.Generic.List`1<System.String>)
LoadReportMetaAsync()
<LoadReportMetaAsync()
LoadClientReferenceLinesAsync()
<LoadClientReferenceLinesAsync()
LoadClientLinesWithPositiveStockAsync()
<LoadClientLinesWithPositiveStockAsync()
LoadLinesToApplyAsync()
<LoadLinesToApplyAsync()
MarkLineAsync()
TryUpdateBatchAsync()
FormatStockErrors(Services.ApiServices.OzonApi.Filters.UpdateStockResult)
LoadAllReportOfferIdsAsync()
<LoadAllReportOfferIdsAsync()
LoadOfferArchiveTypesAsync()
<LoadOfferArchiveTypesAsync()
WithOpenConnectionAsync()
AddParam(System.Data.Common.DbCommand,System.String,System.Object)
get_OzonClientId()
ApplyPriorityForTwoClients(System.Collections.Generic.List`1<Services.BitrixStockServices.BitrixStockSyncReportApplyService/ApplyLineRow>,Services.BitrixStockServices.BitrixStockSyncReportApplyService/ReportMetaRow,System.Collections.Generic.Dictionary`2<System.String,Services.BitrixStockServices.BitrixStockSyncReportApplyService/ApplyLineRow>)
IsFoundForPriority(Services.BitrixStockServices.BitrixStockSyncReportApplyService/ApplyLineRow)
MarkSkip(Services.BitrixStockServices.BitrixStockSyncReportApplyService/ApplyLineRow,System.String)
GetPriorityKey(Services.BitrixStockServices.BitrixStockSyncReportApplyService/ApplyLineRow)
IsNotSellableError(System.String)
ParseMultiClientIds(System.String)
get_LineId()
get_ProductId()
get_Article()
get_OfferId()
get_OneCStore()
get_OzonWarehouseName()
get_OzonWarehouseId()
get_FbsMappedWarehouseId()
get_EffectiveWarehouseId()
get_OneCQty()
get_OzonQty()
get_Action()
get_OzonProductId()
get_IsArchived()
get_IsAutoArchived()
get_OzonClientId()
get_PrioritySkip()
get_PrioritySkipReason()
get_PriorityForceClear()
get_PriorityNeedUnarchive()
get_BatchMultiplier()
get_ReservedQty()
get_LiveStockNote()
ZeroDisabledWarehouseStocksAsync()
<ZeroDisabledWarehouseStocksAsync()
<ZeroDisabledWarehouseStocksAsync()
<ZeroDisabledWarehouseStocksAsync()
DisabledZeroFallbackSuffix(Services.BitrixStockServices.BitrixStockSyncApplyDisabledZeroTarget)
LogDisabledTargetLine()
LoadApplyDisabledReportProductsAsync()
<LoadApplyDisabledReportProductsAsync()
LoadApplyDisabledLineStatesAsync()
<LoadApplyDisabledLineStatesAsync()