< Summary

Line coverage
0%
Covered lines: 1
Uncovered lines: 1317
Coverable lines: 1318
Total lines: 2140
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 694
Branch coverage: 0%
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: get_BatchRows()100%210%
File 1: get_BatchCatalog()100%210%
File 1: get_CatalogByOfferId()100%210%
File 1: .ctor(...)100%210%
File 1: get_Rows()100%210%
File 1: get_CatalogByOfferId()100%210%
File 1: get_NonBatchCatalogTotal()100%210%
File 1: get_EnrichApiCalls()100%210%
File 1: LoadBatchCatalogPhaseAsync()0%2040%
File 1: LoadOrphanStockPhaseAsync()0%210140%
File 1: FlushBufferAsync()0%620%
File 1: BuildBatchVariantItemsAsync()0%600240%
File 1: ApplyFreeStockAsync()0%506220%
File 1: ProcessBatchVariantsAsync()0%7140840%
File 1: ParseOfferIdParts(...)0%2040%
File 1: CreateSyntheticCatalogRow(...)0%620%
File 2: .cctor()100%210%
File 2: get_Updated()100%210%
File 2: OrphanZeroKey(...)0%2040%
File 2: .ctor(...)100%210%
File 2: GetOrphanPreviewAsync()0%620%
File 2: GetReportFilesAsync()0%620%
File 2: RunOrphanCleanupAsync()0%504002240%
File 2: Log()0%620%
File 2: Progress()0%620%
File 2: Complete()0%620%
File 2: ParseWarehouseStockXlsx(...)0%1640400%
File 2: InsertOrphanLinesAsync()0%702260%
File 2: InsertBatchVariantLinesAsync()0%7482860%
File 2: InsertMissingBatchVariantLinesAsync()0%342180%
File 2: PushBatchVariantPricesAsync()0%5852760%
File 2: .ctor(...)100%210%
File 2: get_C0()100%210%
File 2: get_C1()100%210%
File 2: get_Quantities()100%210%
File 2: get_Note()100%210%
File 2: get_Manufacturer()100%210%
File 2: .ctor(...)100%210%
File 2: get_Manufacturer()100%210%
File 2: get_C0()100%210%
File 2: get_C1()100%210%
File 2: get_M1()100%210%
File 2: get_M2()100%210%
File 2: get_M3()100%210%
File 2: get_B1()100%210%
File 2: get_B2()100%210%
File 2: GetArticleSnapshotAsync()0%342180%
File 2: EmptyQuantities()100%210%
File 2: ReadQty(...)0%620%
File 2: SaveReportFileAsync()0%7280%
File 2: LoadReportFilesAsync()100%210%
File 2: LoadOrphanLinesAsync()100%210%
File 2: LoadManufacturersAsync()100%210%
File 2: SaveOzonFileBytesAsync()100%210%
File 2: BuildWarehouseDisplayByOzonId(...)0%7280%
File 2: .ctor(...)100%210%
File 2: get_Row()100%210%
File 2: get_Multiplier()100%210%
File 2: get_BaseOfferId()100%210%
File 2: get_C0()100%210%
File 2: get_C1()100%210%
File 2: get_TotalQty()100%210%
File 2: get_SnapshotNote()100%210%
File 2: get_Manufacturer()100%210%
File 2: get_RawQty()100%210%
File 2: get_ReservedQty()100%210%
File 2: get_LiveClampedTo()100%210%
File 2: get_BatchStock()0%620%
File 2: get_Code()100%210%
File 2: get_Name()100%210%
File 2: get_OzonClientId()100%11100%
File 2: get_MultiClientIdsJson()100%210%
File 2: get_OfferId()100%210%
File 2: get_WarehouseId()100%210%
File 2: get_Article()100%210%
File 2: get_Value()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockOzonOrphanCleanupService.CatalogDb.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using OzonDomains.Models;
 3using OzonRepositories.Context;
 4using Services.ApiServices.OzonApi;
 5using Services.ApiServices.OzonApi.Filters;
 6
 7namespace Services.BitrixStockServices;
 8
 9public sealed partial class BitrixStockOzonOrphanCleanupService
 10{
 011    private sealed record BatchCatalogPhaseResult(
 012        List<OzonCatalogStockRow> BatchRows,
 013        IReadOnlyList<OzonProductCatalogRow> BatchCatalog,
 014        IReadOnlyDictionary<string, OzonProductCatalogRow> CatalogByOfferId);
 15
 016    private sealed record OrphanStockPhaseResult(
 017        List<OzonCatalogStockRow> Rows,
 018        IReadOnlyDictionary<string, OzonProductCatalogRow> CatalogByOfferId,
 019        int NonBatchCatalogTotal,
 020        int EnrichApiCalls);
 21
 22    /// <summary>
 23    /// Фаза 5a: batch [N] из БД сразу (Ozon-остаток может быть 0 — пуш по WarehouseStockSnapshot).
 24    /// </summary>
 25    private async Task<BatchCatalogPhaseResult> LoadBatchCatalogPhaseAsync(
 26        OzonDomains.Models.OzonClient client,
 27        string accountId,
 28        IReadOnlyList<long> warehouseIds,
 29        IReadOnlyDictionary<long, string> warehouseDisplayByOzonId,
 30        Func<string, Task> log,
 31        CancellationToken cancellationToken)
 32    {
 33        // IncludeArchived=true: архивный товар с живым остатком на выключенном складе должен
 34        // зануляться так же, как активный — Ozon принимает Stock=0 и для архивных offer_id.
 035        var filter = new OzonProductsCatalogFilter { IncludeArchived = true };
 036        await log($"[{client.Name}] Фаза 5a — партии [N] из OzonApi.ozon_products (account_id={accountId})…");
 37
 038        var batchCatalog = await _catalogService.GetBatchVariantsAsync(accountId, filter, cancellationToken)
 039            .ConfigureAwait(false);
 40
 041        var catalogByOfferId = batchCatalog.ToDictionary(b => b.OfferId, StringComparer.OrdinalIgnoreCase);
 42
 043        var batchRows = new List<OzonCatalogStockRow>(batchCatalog.Count * Math.Max(1, warehouseIds.Count));
 044        foreach (var batch in batchCatalog)
 45        {
 046            foreach (var whId in warehouseIds)
 047                batchRows.Add(CreateSyntheticCatalogRow(batch, whId, warehouseDisplayByOzonId));
 48        }
 49
 050        await log(
 051            $"[{client.Name}] БД batch [N]: {batchCatalog.Count} offer_id → {batchRows.Count} строк " +
 052            $"(×{warehouseIds.Count} складов). Ozon qty=0 допустим — накат floor(1С/N) по WarehouseStockSnapshot.");
 53
 054        return new BatchCatalogPhaseResult(batchRows, batchCatalog, catalogByOfferId);
 055    }
 56
 57    /// <summary>
 58    /// Фаза 5b: non-batch каталог + Ozon API stocks (только для поиска сирот с остатком &gt; 0).
 59    /// </summary>
 60    private async Task<OrphanStockPhaseResult> LoadOrphanStockPhaseAsync(
 61        OzonDomains.Models.OzonClient client,
 62        string accountId,
 63        OzonApiDataManager apiManager,
 64        IReadOnlyList<long> warehouseIds,
 65        IReadOnlyDictionary<long, string> warehouseDisplayByOzonId,
 66        Func<string, Task> log,
 67        CancellationToken cancellationToken)
 68    {
 69        // IncludeArchived=true: см. комментарий в LoadBatchCatalogPhaseAsync — иначе архивные
 70        // offer_id с живым остатком на выключенном складе никогда не сканируются на сиротство.
 071        var filter = new OzonProductsCatalogFilter { IncludeArchived = true };
 072        var catalogMeta = await _catalogService.GetCatalogMetaAsync(accountId, filter, cancellationToken);
 073        var nonBatchTotal = await _catalogService.CountNonBatchByAccountAsync(accountId, filter, cancellationToken);
 74
 75        // offer_id со скобками, но multiplier<=1 (напр. "[1]") исключены SQL-фильтром LIKE '%[%]%'
 76        // и из non-batch потока, и из батчей (GetBatchVariantsAsync отбрасывает multiplier<=1) —
 77        // без этого они никогда не сканируются на сиротство и не обнуляются на выключенных складах.
 078        var bracketedNonBatch = await _catalogService.GetBracketedNonBatchVariantsAsync(accountId, filter, cancellationT
 079        if (bracketedNonBatch.Count > 0)
 80        {
 081            nonBatchTotal += bracketedNonBatch.Count;
 082            await log($"[{client.Name}] Псевдо-партийные offer_id (скобки, но multiplier<=1): {bracketedNonBatch.Count} 
 83        }
 84
 085        var freshness = catalogMeta.MaxUpdatedAtUtc.HasValue
 086            ? $"обновлён {catalogMeta.MaxUpdatedAtUtc.Value:yyyy-MM-dd HH:mm} UTC"
 087            : "дата обновления неизвестна";
 088        var accountLabel = string.IsNullOrWhiteSpace(catalogMeta.AccountName)
 089            ? accountId
 090            : $"{catalogMeta.AccountName} (account_id={accountId})";
 91
 092        await log($"[{client.Name}] Фаза 5b — сироты: non-batch каталог {nonBatchTotal} товаров (всего {catalogMeta.Tota
 093        await log($"[{client.Name}] Аккаунт: {accountLabel}. Batch [N] уже обработаны в фазе 5a.");
 94
 095        var warehouseList = await apiManager.GetWarehouseList().ConfigureAwait(false);
 096        await log($"[{client.Name}] Ozon API GET /v1/warehouse/list: {warehouseList.Count} склад(ов).");
 97
 098        var targetWarehouseIds = warehouseIds.ToHashSet();
 099        var warehouseNameByIdLong = warehouseIds.ToDictionary(
 0100            id => id,
 0101            id => warehouseDisplayByOzonId.GetValueOrDefault(id) ?? id.ToString());
 102
 0103        var enrichedRows = new List<OzonCatalogStockRow>();
 0104        var catalogByOfferId = new Dictionary<string, OzonProductCatalogRow>(StringComparer.OrdinalIgnoreCase);
 0105        var buffer = new List<OzonProductCatalogRow>(500);
 0106        var processedCatalog = 0;
 0107        var enrichBatchNo = 0;
 108
 109        async Task FlushBufferAsync()
 110        {
 0111            if (buffer.Count == 0)
 0112                return;
 113
 0114            enrichBatchNo++;
 0115            await log($"[{client.Name}] Сироты батч #{enrichBatchNo}: {buffer.Count} non-batch offer_id → Ozon API stock
 116
 117            // Один упавший батч (таймаут/rate limit Ozon) не должен обрывать весь этап 5 —
 118            // раньше необработанное исключение здесь прерывало сканирование сирот для ВСЕХ
 119            // клиентов пресета, и OzonOrphanLastAtUtc никогда не проставлялся (см. историю бага
 120            // с необнуляемыми остатками на выключенных складах).
 121            try
 122            {
 0123                var batchRows = await _stockEnricher.EnrichBatchAsync(
 0124                    apiManager,
 0125                    buffer,
 0126                    targetWarehouseIds,
 0127                    warehouseNameByIdLong,
 0128                    warehouseList,
 0129                    log,
 0130                    cancellationToken).ConfigureAwait(false);
 131
 0132                enrichedRows.AddRange(batchRows);
 0133            }
 0134            catch (OperationCanceledException) { throw; }
 0135            catch (Exception ex)
 136            {
 0137                await log($"[{client.Name}] Сироты батч #{enrichBatchNo}: ошибка Ozon API, батч пропущен — {ex.Message}"
 138                // Персистентно — offer_id из пропущенного батча в этом прогоне не проверяются
 139                // вообще (не станут ни сиротой, ни партией), даже если их склад отключён.
 0140                _logger.LogWarning(ex,
 0141                    "BitrixStock этап 5 [{ClientName}]: батч сирот #{BatchNo} ({BatchSize} offer_id) пропущен из-за ошиб
 0142                    client.Name, enrichBatchNo, buffer.Count);
 0143            }
 144
 0145            processedCatalog += buffer.Count;
 0146            await log($"[{client.Name}] Сироты: обработано {processedCatalog}/{nonBatchTotal} non-batch, строк с остатко
 0147            buffer.Clear();
 148        }
 149
 0150        await foreach (var row in _catalogService.StreamNonBatchByAccountAsync(accountId, filter, cancellationToken))
 151        {
 0152            catalogByOfferId[row.OfferId] = row;
 0153            buffer.Add(row);
 0154            if (buffer.Count >= 500)
 0155                await FlushBufferAsync().ConfigureAwait(false);
 156        }
 157
 0158        foreach (var row in bracketedNonBatch)
 159        {
 0160            catalogByOfferId[row.OfferId] = row;
 0161            buffer.Add(row);
 0162            if (buffer.Count >= 500)
 0163                await FlushBufferAsync().ConfigureAwait(false);
 164        }
 165
 0166        await FlushBufferAsync().ConfigureAwait(false);
 167
 0168        await log(
 0169            $"[{client.Name}] Итог фазы 5b: {enrichedRows.Count} строк с Ozon-остатком > 0 " +
 0170            $"(API-запросов stocks: {enrichBatchNo}).");
 171
 0172        return new OrphanStockPhaseResult(enrichedRows, catalogByOfferId, nonBatchTotal, enrichBatchNo);
 0173    }
 174
 175    private async Task<List<BatchVariantItem>> BuildBatchVariantItemsAsync(
 176        ApplicationDbContext db,
 177        IReadOnlyList<OzonCatalogStockRow> batchRows,
 178        IReadOnlyDictionary<string, IReadOnlyCollection<string>> warehouseStoresById,
 179        IReadOnlySet<string> applyDisabledWarehouseIds,
 180        IReadOnlyDictionary<string, Dictionary<string, int>> reserveMap,
 181        Func<string, Task> log,
 182        CancellationToken cancellationToken)
 183    {
 0184        var snapshotCache = new Dictionary<string, BatchArticleSnapshot>(StringComparer.OrdinalIgnoreCase);
 0185        var result = new List<BatchVariantItem>();
 0186        var skippedNoSnapshot = 0;
 0187        var loggedSamples = 0;
 188        const int maxSampleLogs = 20;
 189
 0190        foreach (var row in batchRows)
 191        {
 0192            cancellationToken.ThrowIfCancellationRequested();
 0193            var multiplier = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(row.OriginalOfferId);
 0194            if (multiplier <= 1)
 195                continue;
 196
 0197            var baseArticle = BitrixStockOrphanOfferMatching.ExtractArticle(row.OriginalOfferId);
 0198            if (!snapshotCache.TryGetValue(row.OriginalOfferId, out var snap))
 199            {
 0200                snap = await GetArticleSnapshotAsync(db, row.OriginalOfferId, cancellationToken);
 0201                snapshotCache[row.OriginalOfferId] = snap;
 202            }
 203
 0204            var totalQty = BitrixStockBatchVariantStockResolver.ResolveWarehouseQuantity(
 0205                snap.Quantities,
 0206                warehouseStoresById.GetValueOrDefault(row.WarehouseId));
 0207            var (c0, c1) = (snap.C0, snap.C1);
 208            // Склад из applyDisabledWarehouseIds всё равно нужно обнулить, даже если по базовому
 209            // артикулу вообще нет данных в WarehouseStockSnapshot — иначе такая партия (напр. [2]/[20])
 210            // остаётся невидимой для этапа 5 и никогда не зануляется на выключенном складе.
 0211            if (c0 == null && totalQty == 0 && string.IsNullOrEmpty(snap.Note) && !applyDisabledWarehouseIds.Contains(ro
 212            {
 0213                skippedNoSnapshot++;
 0214                continue;
 215            }
 216
 0217            var baseOfferId = BitrixStockOrphanOfferMatching.NormalizeOfferId(row.OriginalOfferId);
 0218            result.Add(new BatchVariantItem(row, multiplier, baseOfferId, c0, c1, totalQty, snap.Note)
 0219            {
 0220                Manufacturer = snap.Manufacturer
 0221            });
 222
 0223            if (loggedSamples < maxSampleLogs)
 224            {
 0225                loggedSamples++;
 0226                var pushQty = totalQty > 0 ? Math.Max(0, totalQty / multiplier) : 0;
 0227                await log(string.IsNullOrEmpty(snap.Note)
 0228                    ? $"  [Партия ×{multiplier}] {row.OriginalOfferId} склад={row.WarehouseName} " +
 0229                      $"(Ozon={row.AvailableQty}): 1С={totalQty} → push {pushQty} уп., C0={c0:F0}"
 0230                    : $"  [⓪ Несовпадение производителя] {row.OriginalOfferId} склад={row.WarehouseName}: {snap.Note}");
 231            }
 0232        }
 233
 0234        if (result.Count > loggedSamples)
 0235            await log($"  … и ещё {result.Count - loggedSamples} партий (всего {result.Count} offer×склад)");
 0236        if (skippedNoSnapshot > 0)
 0237            await log($"  Партии без WarehouseStockSnapshot: {skippedNoSnapshot} строк пропущено");
 238
 0239        var unique = result
 0240            .GroupBy(b => $"{b.Row.OriginalOfferId}|{b.Row.WarehouseId}", StringComparer.OrdinalIgnoreCase)
 0241            .Select(g => g.First())
 0242            .ToList();
 243
 0244        return await ApplyFreeStockAsync(unique, warehouseStoresById, applyDisabledWarehouseIds, reserveMap, log, cancel
 0245    }
 246
 247    /// <summary>
 248    /// Заменяет остаток партии (сырой остаток снимка таблицы) свободным: минус резерв заказов и не выше остатка 1С онла
 249    /// Склады со снятой связью не трогаем — они всё равно обнуляются.
 250    /// </summary>
 251    private async Task<List<BatchVariantItem>> ApplyFreeStockAsync(
 252        List<BatchVariantItem> items,
 253        IReadOnlyDictionary<string, IReadOnlyCollection<string>> warehouseStoresById,
 254        IReadOnlySet<string> applyDisabledWarehouseIds,
 255        IReadOnlyDictionary<string, Dictionary<string, int>> reserveMap,
 256        Func<string, Task> log,
 257        CancellationToken cancellationToken)
 258    {
 0259        if (items.Count == 0)
 0260            return items;
 261
 0262        var requests = items.Select(b => new BatchFreeStockRequest(
 0263            BitrixStockOrphanOfferMatching.ExtractArticle(b.Row.OriginalOfferId),
 0264            b.Manufacturer,
 0265            (warehouseStoresById.GetValueOrDefault(b.Row.WarehouseId) ?? Array.Empty<string>()).ToList(),
 0266            b.TotalQty,
 0267            b.Multiplier,
 0268            SkipLive: applyDisabledWarehouseIds.Contains(b.Row.WarehouseId)
 0269                      || b.TotalQty <= 0
 0270                      || !string.IsNullOrWhiteSpace(b.SnapshotNote))).ToList();
 271
 0272        var free = await BitrixStockBatchVariantFreeStock.ResolveAsync(requests, reserveMap, _liveStockGuard, cancellati
 273
 0274        var result = new List<BatchVariantItem>(items.Count);
 0275        var reservedCount = 0;
 0276        var liveClampedCount = 0;
 0277        var samples = 0;
 0278        for (var i = 0; i < items.Count; i++)
 279        {
 0280            var item = items[i];
 0281            var f = free[i];
 0282            if (applyDisabledWarehouseIds.Contains(item.Row.WarehouseId))
 283            {
 0284                result.Add(item);
 0285                continue;
 286            }
 287
 0288            if (f.ReservedQty > 0) reservedCount++;
 0289            if (f.ClampedByLive) liveClampedCount++;
 290
 0291            result.Add(item with
 0292            {
 0293                TotalQty = f.FreeQty,
 0294                RawQty = item.TotalQty,
 0295                ReservedQty = f.ReservedQty,
 0296                LiveClampedTo = f.ClampedByLive ? f.LiveFreeQty : null
 0297            });
 298
 0299            if ((f.ReservedQty > 0 || f.ClampedByLive) && samples < 15)
 300            {
 0301                samples++;
 0302                await log($"  [Партия ×{item.Multiplier}] {item.Row.OriginalOfferId} склад={item.Row.WarehouseName}: " +
 0303                          $"в таблице {item.TotalQty}, резерв {f.ReservedQty}" +
 0304                          (f.ClampedByLive ? $", по 1С онлайн свободно {f.LiveFreeQty}" : string.Empty) +
 0305                          $" → {f.FreeQty} шт. → push {(f.FreeQty > 0 ? f.FreeQty / item.Multiplier : 0)} уп.");
 306            }
 307        }
 308
 0309        await log($"Партии: свободный остаток посчитан для {items.Count} строк, с резервом {reservedCount}, ограничено п
 0310        return result;
 0311    }
 312
 313    private async Task<int> ProcessBatchVariantsAsync(
 314        OzonApiDataManager apiManager,
 315        ApplicationDbContext db,
 316        string reportId,
 317        int clientId,
 318        string clientName,
 319        List<BatchVariantItem> batchVariants,
 320        IReadOnlyDictionary<string, OzonProductCatalogRow> catalogByOfferId,
 321        Dictionary<string, string> warehouseNameById,
 322        Dictionary<string, string> manufacturers,
 323        HashSet<string> applyDisabledWarehouseIds,
 324        Func<string, Task> log,
 325        Func<int, int, string, Task> progress,
 326        int clientIndex,
 327        int clientsTotal,
 328        CancellationToken cancellationToken)
 329    {
 0330        if (batchVariants.Count == 0)
 0331            return 0;
 332
 0333        await log($"[{clientName}] Обрабатываем {batchVariants.Count} партийных строк (offer×склад)…");
 334
 0335        var ozonProductErrors = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 0336        var notCreatedOfferIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0337        var batchVariantsProcessed = 0;
 0338        var loggedPushSamples = 0;
 0339        var loggedZeroPackSamples = 0;
 340        const int maxPushLogSamples = 15;
 341
 0342        for (var i = 0; i < batchVariants.Count; i += StockBatchSize)
 343        {
 0344            cancellationToken.ThrowIfCancellationRequested();
 0345            var batch = batchVariants.Skip(i).Take(StockBatchSize).ToList();
 0346            var stockItems = batch.Select(b =>
 0347            {
 0348                var isDisabled = applyDisabledWarehouseIds.Contains(b.Row.WarehouseId);
 0349                if (isDisabled)
 0350                    return new StockItemForUpdate
 0351                    {
 0352                        OfferId = b.Row.OriginalOfferId,
 0353                        ProductId = 0,
 0354                        Stock = 0,
 0355                        WarehouseId = long.Parse(b.Row.WarehouseId)
 0356                    };
 0357                return new StockItemForUpdate
 0358                {
 0359                    OfferId = b.Row.OriginalOfferId,
 0360                    ProductId = 0,
 0361                    Stock = b.BatchStock,
 0362                    WarehouseId = long.Parse(b.Row.WarehouseId)
 0363                };
 0364            }).ToList();
 365
 0366            foreach (var b in batch.Where(b => applyDisabledWarehouseIds.Contains(b.Row.WarehouseId)))
 0367                await log($"  [⓪ Обнулено (связь)] {b.Row.OriginalOfferId} склад {b.Row.WarehouseName}: снятая связь → 0
 0368            foreach (var b in batch.Where(b => !applyDisabledWarehouseIds.Contains(b.Row.WarehouseId) && b.BatchStock ==
 0369                                               (b.TotalQty > 0 || !string.IsNullOrWhiteSpace(b.SnapshotNote))))
 370            {
 0371                if (loggedZeroPackSamples < maxPushLogSamples)
 372                {
 0373                    loggedZeroPackSamples++;
 0374                    await log(string.IsNullOrWhiteSpace(b.SnapshotNote)
 0375                        ? $"  [⓪ Нет пак.] {b.Row.OriginalOfferId} склад {b.Row.WarehouseName}: 1С={b.TotalQty} шт. < {b
 0376                        : $"  [⓪ Несовпадение производителя] {b.Row.OriginalOfferId} склад {b.Row.WarehouseName}: {b.Sna
 377                }
 378            }
 0379            foreach (var b in batch.Where(b => !applyDisabledWarehouseIds.Contains(b.Row.WarehouseId) && b.BatchStock > 
 380            {
 0381                if (loggedPushSamples < maxPushLogSamples)
 382                {
 0383                    loggedPushSamples++;
 0384                    await log($"  [↑ Накат] {b.Row.OriginalOfferId} склад {b.Row.WarehouseName}: Ozon={b.Row.AvailableQt
 385                }
 386            }
 387
 0388            var disabledInBatch = batch.Count(b => applyDisabledWarehouseIds.Contains(b.Row.WarehouseId));
 389            try
 390            {
 0391                var result = await apiManager.UpdateProductStocks(stockItems);
 0392                var successes = result.Result?.Count(r => r.Updated) ?? 0;
 0393                batchVariantsProcessed += successes;
 0394                var failedOfferIds = new List<string>();
 0395                foreach (var err in result.Result?.Where(r => !r.Updated) ?? Enumerable.Empty<UpdateStockResult>())
 396                {
 0397                    var errCode = err.Errors?.FirstOrDefault()?.Code ?? "";
 0398                    var msg = err.Errors?.FirstOrDefault()?.Message ?? "неизвестная ошибка";
 0399                    await log($"[{clientName}] Ошибка пуша партии {err.OfferId}: {msg}");
 0400                    failedOfferIds.Add($"{err.OfferId}:{msg}");
 0401                    if (errCode.Equals("PRODUCT_IS_NOT_CREATED", StringComparison.OrdinalIgnoreCase)
 0402                        && !string.IsNullOrEmpty(err.OfferId))
 0403                        notCreatedOfferIds.Add(err.OfferId);
 0404                }
 405                // Персистентно (ILogger) — Log() выше виден только при живом SignalR-браузере.
 0406                _logger.LogInformation(
 0407                    "BitrixStock этап 5 [{ClientName}]: батч партий {BatchIndex} — {Successes}/{BatchSize} " +
 0408                    "успешно ({Disabled} из них — снятые связи → 0){Failures}",
 0409                    clientName, i / StockBatchSize + 1, successes, stockItems.Count, disabledInBatch,
 0410                    failedOfferIds.Count > 0 ? $"; ошибки: {string.Join("; ", failedOfferIds)}" : string.Empty);
 0411                await progress(clientIndex, clientsTotal,
 0412                    $"[{clientName}] Партии — остатки: {Math.Min(i + StockBatchSize, batchVariants.Count)}/{batchVariant
 0413            }
 0414            catch (Exception ex)
 415            {
 0416                await log($"[{clientName}] Ошибка пакета UpdateProductStocks (партии): {ex.Message}");
 0417                _logger.LogWarning(ex,
 0418                    "BitrixStock этап 5 [{ClientName}]: ошибка пакета UpdateProductStocks (партии, {Disabled} снятых свя
 0419                    clientName, disabledInBatch);
 0420            }
 0421        }
 422
 0423        await log($"[{clientName}] Партии — пуш остатков: {batchVariantsProcessed}/{batchVariants.Count}");
 0424        _logger.LogInformation(
 0425            "BitrixStock этап 5 [{ClientName}]: партии — пуш остатков {Processed}/{Total}",
 0426            clientName, batchVariantsProcessed, batchVariants.Count);
 427
 0428        if (notCreatedOfferIds.Count > 0)
 429        {
 0430            await log($"[{clientName}] Запрашиваем ошибки Ozon для {notCreatedOfferIds.Count} не созданных товар(а/ов)…"
 431            try
 432            {
 0433                var infoResp = await apiManager.GetProductInfoListAsync(offerIds: notCreatedOfferIds.ToArray());
 0434                var items = infoResp?["items"];
 0435                if (items != null)
 436                {
 0437                    foreach (var item in items)
 438                    {
 0439                        var offerId = item["offer_id"]?.ToString();
 0440                        if (string.IsNullOrEmpty(offerId)) continue;
 0441                        var errors = item["errors"];
 0442                        if (errors == null || !errors.HasValues) continue;
 0443                        var parts = new List<string>();
 0444                        foreach (var e in errors)
 445                        {
 0446                            var attrName = e["texts"]?["attribute_name"]?.ToString();
 0447                            var desc = e["texts"]?["description"]?.ToString();
 0448                            parts.Add(string.IsNullOrEmpty(attrName) ? desc ?? e.ToString()
 0449                                : $"атрибут «{attrName}»: {desc}");
 450                        }
 0451                        if (parts.Count > 0)
 0452                            ozonProductErrors[offerId] = string.Join("; ", parts);
 453                    }
 454                }
 0455            }
 0456            catch (Exception ex)
 457            {
 0458                await log($"[{clientName}] Не удалось получить ошибки Ozon для не созданных товаров: {ex.Message}");
 459            }
 460        }
 461
 0462        var pushedPrices = await PushBatchVariantPricesAsync(
 0463            apiManager, batchVariants, reportId, clientId, clientName, catalogByOfferId,
 0464            log, progress, clientIndex, clientsTotal, cancellationToken);
 465
 0466        await InsertBatchVariantLinesAsync(
 0467            db, reportId, clientId, clientName, batchVariants,
 0468            warehouseNameById, manufacturers, applyDisabledWarehouseIds, pushedPrices, ozonProductErrors, cancellationTo
 469
 0470        return batchVariantsProcessed;
 0471    }
 472
 473    private static (string CleanOfferId, string? BrandCode) ParseOfferIdParts(string rawOfferId)
 474    {
 0475        var eqIdx = rawOfferId.IndexOf('=');
 0476        if (eqIdx > 0)
 477        {
 0478            var clean = rawOfferId[..eqIdx].Trim();
 0479            var brandPart = rawOfferId[(eqIdx + 1)..].Trim();
 0480            var m = BrandCodeRegex.Match(brandPart);
 0481            return (clean, m.Success ? m.Groups[1].Value : null);
 482        }
 483
 0484        return (rawOfferId.Trim(), null);
 485    }
 486
 487    private static OzonCatalogStockRow CreateSyntheticCatalogRow(
 488        OzonProductCatalogRow catalogRow,
 489        long warehouseId,
 490        IReadOnlyDictionary<long, string> warehouseDisplayByOzonId)
 491    {
 0492        var (cleanOfferId, brandCode) = ParseOfferIdParts(catalogRow.OfferId);
 0493        return new OzonCatalogStockRow
 0494        {
 0495            WarehouseId = warehouseId.ToString(),
 0496            WarehouseName = warehouseDisplayByOzonId.GetValueOrDefault(warehouseId) ?? warehouseId.ToString(),
 0497            OfferId = cleanOfferId,
 0498            OriginalOfferId = catalogRow.OfferId,
 0499            BrandCode = brandCode,
 0500            ProductName = catalogRow.Name,
 0501            AvailableQty = 0,
 0502            ProductId = catalogRow.ProductId
 0503        };
 504    }
 505}

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using Hangfire;
 4using Hangfire.Storage;
 5using Microsoft.AspNetCore.SignalR;
 6using Microsoft.EntityFrameworkCore;
 7using Microsoft.Extensions.Logging;
 8using Newtonsoft.Json.Linq;
 9using OfficeOpenXml;
 10using OzonRepositories.Context;
 11using OzonRepositories.Data;
 12using Services.ApiServices.OzonApi;
 13using Services.ApiServices.OzonApi.Filters;
 14using Services.DataServices;
 15using Services.HangfireService;
 16using Services.SignalRServices;
 17using SlqStudio.Application.Services.EmailService;
 18
 19namespace Services.BitrixStockServices;
 20
 21public sealed partial class BitrixStockOzonOrphanCleanupService : IBitrixStockOzonOrphanCleanupService
 22{
 23    private const int StockBatchSize = 100;
 24    /// <summary>Ozon warehouse/stock отчёт может готовиться дольше 3 мин.</summary>
 25    private const int OzonReportWaitMs = 600_000;
 26    private const string OzonFilesTable = "BitrixStockSyncReportOzonFiles";
 027    private static readonly string OzonFilesStorageRoot =
 028        Path.Combine(AppContext.BaseDirectory, "OzonStockFiles");
 29    // Regex: захватывает код бренда (2-5 символов) до опционального [суффикса]
 030    private static readonly Regex BrandCodeRegex =
 031        new(@"^([A-Z0-9]{2,5})(?:\[.*\])?$", RegexOptions.Compiled);
 32
 33    /// <summary>Что Ozon ответил на попытку обнулить одну пару offer_id × склад.</summary>
 034    internal readonly record struct OrphanZeroResult(bool Updated, string? Error);
 35
 36    /// <summary>Ключ сопоставления строки-сироты с ответом Ozon: offer_id + склад.</summary>
 37    internal static string OrphanZeroKey(string? offerId, string? warehouseId) =>
 038        $"{offerId?.Trim()}|{warehouseId?.Trim()}";
 39
 40    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
 41    private readonly OzonClientServices _ozonClientServices;
 42    private readonly IHttpClientFactory _httpClientFactory;
 43    private readonly IEmailService? _emailService;
 44    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 45    private readonly BitrixStockActiveStageStore _stageStore;
 46    private readonly IOzonProductsCatalogService _catalogService;
 47    private readonly IOzonCatalogStockEnricher _stockEnricher;
 48    private readonly EtProducerRepository _etProducerRepository;
 49    private readonly IBitrixStockLiveStockGuard _liveStockGuard;
 50    private readonly ILogger<BitrixStockOzonOrphanCleanupService> _logger;
 51
 052    public BitrixStockOzonOrphanCleanupService(
 053        IDbContextFactory<ApplicationDbContext> dbFactory,
 054        OzonClientServices ozonClientServices,
 055        IHttpClientFactory httpClientFactory,
 056        IEmailService? emailService,
 057        IHubContext<JobProgressHub> jobProgressHub,
 058        BitrixStockActiveStageStore stageStore,
 059        IOzonProductsCatalogService catalogService,
 060        IOzonCatalogStockEnricher stockEnricher,
 061        EtProducerRepository etProducerRepository,
 062        IBitrixStockLiveStockGuard liveStockGuard,
 063        ILogger<BitrixStockOzonOrphanCleanupService> logger)
 64    {
 065        _dbFactory = dbFactory;
 066        _ozonClientServices = ozonClientServices;
 067        _httpClientFactory = httpClientFactory;
 068        _emailService = emailService;
 069        _jobProgressHub = jobProgressHub;
 070        _stageStore = stageStore;
 071        _catalogService = catalogService;
 072        _stockEnricher = stockEnricher;
 073        _etProducerRepository = etProducerRepository;
 074        _liveStockGuard = liveStockGuard;
 075        _logger = logger;
 076    }
 77
 78    // ─── GetOrphanPreviewAsync ────────────────────────────────────────────────
 79
 80    public async Task<OzonOrphanPreviewDto> GetOrphanPreviewAsync(
 81        string reportId,
 82        CancellationToken cancellationToken = default)
 83    {
 084        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 085        var files = await LoadReportFilesAsync(db, reportId, cancellationToken);
 086        var orphans = await LoadOrphanLinesAsync(db, reportId, cancellationToken);
 087        return new OzonOrphanPreviewDto
 088        {
 089            ReportFiles = files,
 090            Orphans = orphans,
 091            TotalOrphans = orphans.Count,
 092            IsAlreadyRun = orphans.Any(o => o.OzonOrphanStatus != null)
 093        };
 094    }
 95
 96    public async Task<List<OzonReportFileDto>> GetReportFilesAsync(
 97        string reportId,
 98        CancellationToken cancellationToken = default)
 99    {
 0100        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 0101        return await LoadReportFilesAsync(db, reportId, cancellationToken);
 0102    }
 103
 104    // ─── RunOrphanCleanupAsync ────────────────────────────────────────────────
 105
 106    [Queue(HangfireQueueNames.Main)]
 107    public async Task RunOrphanCleanupAsync(
 108        string reportId,
 109        BitrixStockOzonOrphanCleanupRequest request,
 110        string? progressJobId,
 111        CancellationToken cancellationToken = default)
 112    {
 0113        ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
 114
 0115        var hubId = progressJobId ?? reportId + "_s5";
 0116        var hasHub = !string.IsNullOrWhiteSpace(hubId);
 0117        var dryRun = request.DryRun;
 118
 119        async Task Log(string msg)
 120        {
 0121            if (hasHub)
 0122                await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", msg, cancellationToken);
 0123        }
 124
 125        async Task Progress(int cur, int total, string text)
 126        {
 0127            if (!hasHub) return;
 0128            await _jobProgressHub.Clients.Group(hubId).SendAsync("Progress", cur, total, text, cancellationToken);
 0129        }
 130
 131        async Task Complete(bool ok, object? payload)
 132        {
 0133            if (hasHub)
 0134                await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken);
 0135        }
 136
 0137        IDisposable? distLock = null;
 0138        IStorageConnection? lockConn = null;
 139
 140        try
 141        {
 0142            if (!dryRun)
 143            {
 144                try
 145                {
 0146                    lockConn = JobStorage.Current.GetConnection();
 0147                    distLock = lockConn.AcquireDistributedLock(
 0148                        BitrixStockPushJobService.PushLockKey,
 0149                        TimeSpan.FromMilliseconds(1));
 0150                }
 151                catch (DistributedLockTimeoutException)
 152                {
 153                    const string msg = "Обновление остатков Ozon уже выполняется. Повторный запуск отменён.";
 0154                    await Log(msg);
 0155                    await Complete(false, msg);
 0156                    return;
 157                }
 158            }
 159
 0160            if (!dryRun && !request.Chained)
 161            {
 0162                _stageStore.Set(reportId, 5);
 163                // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа)
 0164                if (hasHub) await Task.Delay(800, cancellationToken);
 165            }
 166
 0167            await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 168
 169            // Загружаем метаданные отчёта
 0170            var reportMeta = await db.Database.SqlQueryRaw<ReportMeta>(
 0171                "SELECT OzonClientId, MultiClientIdsJson FROM BitrixStockSyncReports WHERE ReportId = {0}",
 0172                reportId).SingleOrDefaultAsync(cancellationToken);
 173
 0174            if (reportMeta == null)
 175            {
 0176                await Log("Отчёт не найден.");
 0177                await Complete(false, "Отчёт не найден");
 0178                return;
 179            }
 180
 181            // Определяем список clientIds из отчёта
 0182            var clientIds = BitrixStockMultiClientParser.ParseMultiClientIds(reportMeta.MultiClientIdsJson);
 0183            if (clientIds.Count == 0 && reportMeta.OzonClientId > 0)
 0184                clientIds.Add(reportMeta.OzonClientId);
 185
 0186            if (clientIds.Count == 0)
 187            {
 0188                await Log("Нет клиентов Ozon в отчёте.");
 0189                await Complete(false, "Нет клиентов");
 0190                return;
 191            }
 192
 0193            await Log($"Этап 5: очистка сирот для {clientIds.Count} клиент(ов)…");
 194
 0195            var catalogSource = Stage5CatalogSourceExtensions.Parse(request.StageSettings?.Stage5CatalogSource);
 0196            await Log($"Источник каталога этапа 5: {catalogSource.ToLogLabel()} (Stage5CatalogSource={request.StageSetti
 0197            if (catalogSource == Stage5CatalogSource.OzonProductsDb)
 0198                await Log("Каталог: БД OzonApi.ozon_products (фильтр account_id = Ozon Client-Id). Остатки по складам: l
 199            else
 0200                await Log("Каталог: legacy xlsx POST /v1/report/warehouse/stock → poll /v1/report/info → скачивание файл
 201
 202            // Загружаем справочник производителей (Code → Name) для логирования и отчёта
 0203            var manufacturers = await LoadManufacturersAsync(db, cancellationToken);
 204
 205            // Резерв заказов — партии [N] считаются от свободного остатка базового артикула, как строки этапа 1.
 0206            var reserveStatusIds = await BitrixStockSyncReportService.ResolveReserveStatusIdsAsync(
 0207                db, request.StageSettings?.ReserveStatusIds, cancellationToken);
 0208            var reserveMap = await BitrixStockSyncReportService.LoadOrderReservesAsync(db, reserveStatusIds, cancellatio
 0209            await Log(reserveMap.Count > 0
 0210                ? $"Резервы заказов для партий: {reserveMap.Count} арт. с ненулевым резервом."
 0211                : "Резервы заказов для партий: нет (статусы резерва не найдены или заказов нет).");
 212
 0213            var reportKeys = await db.Database.SqlQueryRaw<OfferWarehouseArticleKey>(
 0214                    "SELECT LOWER(TRIM(OfferId)) AS OfferId, CAST(OzonWarehouseId AS CHAR) AS WarehouseId, " +
 0215                    "LOWER(TRIM(Article)) AS Article " +
 0216                    "FROM BitrixStockSyncReportLines WHERE ReportId = {0} AND IsOzonOrphan = 0 AND OfferId IS NOT NULL A
 0217                    reportId)
 0218                .ToListAsync(cancellationToken);
 0219            var reportKeyIndex = BitrixStockOrphanOfferMatching.BuildReportKeyIndex(
 0220                reportKeys.Select(k => new BitrixStockOrphanOfferMatching.ReportLineKey
 0221                {
 0222                    OfferId = k.OfferId,
 0223                    WarehouseId = k.WarehouseId,
 0224                    Article = k.Article
 0225                }));
 226
 227            // Склады, которые реально фигурируют в строках отчёта (OzonWarehouseId > 0)
 0228            var reportWarehouseIdSet = reportKeys
 0229                .Select(k => k.WarehouseId)
 0230                .Where(id => long.TryParse(id, out var v) && v > 0)
 0231                .Select(id => long.Parse(id))
 0232                .ToHashSet();
 233
 234            // 1С-склады из отчёта (M2, M3, B1, B2 и т.п.) — для выбора правильного маппинга
 0235            var reportOneCStores = (await db.Database.SqlQueryRaw<StringValue>(
 0236                    "SELECT DISTINCT OneCStore AS Value FROM BitrixStockSyncReportLines " +
 0237                    "WHERE ReportId = {0} AND OneCStore IS NOT NULL AND OneCStore != ''",
 0238                    reportId)
 0239                .ToListAsync(cancellationToken))
 0240                .Select(r => r.Value)
 0241                .ToHashSet(StringComparer.OrdinalIgnoreCase);
 242
 0243            var httpClient = _httpClientFactory.CreateClient();
 0244            httpClient.Timeout = TimeSpan.FromSeconds(60);
 245
 0246            var totalOrphansFound = 0;
 0247            var totalOrphansZeroed = 0;
 0248            var clientsFilesSaved = 0;
 0249            var clientIndex = 0;
 250
 0251            foreach (var clientId in clientIds)
 252            {
 0253                cancellationToken.ThrowIfCancellationRequested();
 0254                clientIndex++;
 255
 256                // Один клиент не должен обрывать этап 5 для остальных — раньше необработанное
 257                // исключение здесь (Ozon API, БД) прерывало весь метод целиком (см. внешний catch),
 258                // OzonOrphanLastAtUtc никогда не проставлялся, и живой remainder на отключённых
 259                // складах не обнулялся ни для этого, ни для последующих клиентов.
 260                try
 261                {
 262
 0263                var client = await _ozonClientServices.GetOzonClientAsync(clientId);
 0264                if (client == null)
 265                {
 0266                    await Log($"Клиент {clientId} не найден — пропуск.");
 0267                    continue;
 268                }
 269
 0270                var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 271
 272                // Склады клиента из маппингов
 0273                var mappings = await db.WarehouseMappings
 0274                    .Where(m => m.OzonClientId == clientId && m.OzonWarehouseId != null)
 0275                    .OrderBy(m => m.Id)
 0276                    .ToListAsync(cancellationToken);
 277
 278                // Этап 5: исключаем отключённые в пресете связи складов (напр. M2out → Cars),
 279                // чтобы такие склады не запрашивались у Ozon и не обнулялись. Пусто = все склады.
 0280                var orphanDisabledMappingIds = request.StageSettings?.OrphanDisabledWarehouseMappingIds;
 0281                if (orphanDisabledMappingIds is { Count: > 0 })
 282                {
 0283                    var beforeCount = mappings.Count;
 0284                    mappings = mappings.Where(m => !orphanDisabledMappingIds.Contains(m.Id)).ToList();
 0285                    if (beforeCount != mappings.Count)
 0286                        await Log($"[{client.Name}] Исключено {beforeCount - mappings.Count} связ(и) складов по настройк
 287                }
 288
 289                // «Снятые связи» этапа 2 (ApplyDisabledWarehouseMappingIds) — для batch-вариантов:
 290                // на эти склады в Stage 5 пушим 0, как это делает Stage 2 через «⓪ Обнулено (связь)».
 0291                var applyDisabledMappingIds = (request.StageSettings?.ApplyDisabledWarehouseMappingIds ?? new List<int>(
 0292                var applyDisabledWarehouseIds = mappings
 0293                    .Where(m => m.OzonWarehouseId != null && applyDisabledMappingIds.Contains(m.Id))
 0294                    .Select(m => m.OzonWarehouseId!.Trim())
 0295                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 0296                if (applyDisabledWarehouseIds.Count > 0)
 0297                    await Log($"[{client.Name}] Снятые связи (этап 2) для batch-вариантов: склады Ozon будут обнулены — 
 298                // Персистентно (ILogger), в отличие от Log() выше — тот виден только при живом
 299                // SignalR-браузере, а плановые Hangfire-прогоны его теряют без следа.
 0300                _logger.LogInformation(
 0301                    "BitrixStock этап 5 [{ClientName}]: applyDisabledWarehouseIds = [{WarehouseIds}]",
 0302                    client.Name, string.Join(",", applyDisabledWarehouseIds));
 303
 0304                var warehouseDisplayByOzonId = BuildWarehouseDisplayByOzonId(mappings);
 305
 0306                var warehouseIds = mappings
 0307                    .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out var wid)
 0308                                && reportWarehouseIdSet.Contains(wid))
 0309                    .Select(m => long.Parse(m.OzonWarehouseId!.Trim()))
 0310                    .Distinct()
 0311                    .ToList();
 312
 313                // Fallback: если у reportKeys нет складов (пустой отчёт) — берём все маппинги
 0314                if (warehouseIds.Count == 0 && reportWarehouseIdSet.Count == 0)
 315                {
 0316                    warehouseIds = mappings
 0317                        .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out _))
 0318                        .Select(m => long.Parse(m.OzonWarehouseId!.Trim()))
 0319                        .Distinct()
 0320                        .ToList();
 0321                    if (warehouseIds.Count > 0)
 0322                        await Log($"[{client.Name}] Отчёт не содержит складов — используем все {warehouseIds.Count} из м
 323                }
 324
 0325                if (warehouseIds.Count == 0)
 326                {
 0327                    await Log($"[{client.Name}] Нет складов в WarehouseMapping — пропуск.");
 0328                    continue;
 329                }
 330
 0331                var whDesc = string.Join(", ", mappings
 0332                    .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out var wid) && warehouseIds.Contains(wid))
 0333                    .DistinctBy(m => m.OzonWarehouseId)
 0334                    .Select(m => $"{m.OzonWarehouseName} ({m.OzonWarehouseId})"));
 335
 0336                var warehouseNameById = mappings
 0337                    .Where(m => m.OzonWarehouseId != null
 0338                                && m.BitrixWarehouseName != null
 0339                                && (reportOneCStores.Count == 0 || reportOneCStores.Contains(m.BitrixWarehouseName)))
 0340                    .GroupBy(m => m.OzonWarehouseId!.Trim())
 0341                    .ToDictionary(g => g.Key, g => g.First().BitrixWarehouseName!);
 0342                var warehouseStoresById = mappings
 0343                    .Where(m => m.OzonWarehouseId != null
 0344                                && m.BitrixWarehouseName != null
 0345                                && (reportOneCStores.Count == 0 || reportOneCStores.Contains(m.BitrixWarehouseName)))
 0346                    .GroupBy(m => m.OzonWarehouseId!.Trim(), StringComparer.OrdinalIgnoreCase)
 0347                    .ToDictionary(
 0348                        g => g.Key,
 0349                        g => (IReadOnlyCollection<string>)g.Select(m => m.BitrixWarehouseName!.Trim())
 0350                            .Distinct(StringComparer.OrdinalIgnoreCase)
 0351                            .ToArray(),
 0352                        StringComparer.OrdinalIgnoreCase);
 353
 354                List<OzonCatalogStockRow> ozonRows;
 355                string reportCode;
 356                string? fileUrl;
 0357                IReadOnlyDictionary<string, OzonProductCatalogRow> catalogByOfferId =
 0358                    new Dictionary<string, OzonProductCatalogRow>(StringComparer.OrdinalIgnoreCase);
 0359                IReadOnlyList<OzonProductCatalogRow> batchCatalogFor5b = Array.Empty<OzonProductCatalogRow>();
 0360                List<OzonCatalogStockRow> batchRowsForMeta = new();
 0361                List<BatchVariantItem>? batchVariantsPrebuilt = null;
 0362                var batchProcessedEarly = false;
 363
 0364                if (catalogSource == Stage5CatalogSource.WarehouseStockReport)
 365                {
 0366                    await Log($"[{client.Name}] Источник: Ozon xlsx-отчёт по {warehouseIds.Count} складу(ам): {whDesc}…"
 0367                    await Progress(clientIndex - 1, clientIds.Count, $"Запрос Ozon-отчёта для {client.Name}…");
 368
 369                    try
 370                    {
 0371                        reportCode = await apiManager.GetWarehouseStockReportCodeAsync(warehouseIds);
 0372                        await Log($"[{client.Name}] Код отчёта: {reportCode}, ожидаем готовности…");
 0373                        fileUrl = await apiManager.WaitForReportReady(
 0374                            reportCode,
 0375                            maxWaitTimeMs: OzonReportWaitMs,
 0376                            pollIntervalMs: 5_000,
 0377                            onStatusLog: Log);
 0378                        await Log($"[{client.Name}] Отчёт готов: {fileUrl}");
 0379                    }
 0380                    catch (Exception ex)
 381                    {
 0382                        await Log($"[{client.Name}] Ошибка получения Ozon-отчёта: {ex.Message}");
 0383                        continue;
 384                    }
 385
 386                    byte[] xlsxBytes;
 387                    try
 388                    {
 0389                        xlsxBytes = await OzonReportDownloadHelper.DownloadBytesWithRetryAsync(httpClient, fileUrl, canc
 0390                    }
 0391                    catch (Exception ex)
 392                    {
 0393                        await Log($"[{client.Name}] Ошибка скачивания файла: {ex.Message}");
 0394                        continue;
 395                    }
 396
 0397                    _ = SaveOzonFileBytesAsync(xlsxBytes, reportId, reportCode, cancellationToken);
 0398                    ozonRows = ParseWarehouseStockXlsx(xlsxBytes);
 0399                }
 400                else
 401                {
 0402                    var accountId = client.DecryptClientId?.Trim();
 0403                    if (string.IsNullOrWhiteSpace(accountId))
 404                    {
 0405                        await Log($"[{client.Name}] account_id (DecryptClientId) не задан — пропуск каталога OzonApi.");
 0406                        continue;
 407                    }
 408
 0409                    await Log(
 0410                        $"[{client.Name}] Источник: OzonApi.ozon_products (фаза 5a партии → фаза 5b сироты) " +
 0411                        $"по {warehouseIds.Count} складу(ам): {whDesc}…");
 0412                    await Progress(clientIndex - 1, clientIds.Count, $"Партии [N] из OzonApi для {client.Name}…");
 413
 0414                    reportCode = $"ozon_products_db_{accountId}_{DateTime.UtcNow:yyyyMMddHHmmss}";
 0415                    fileUrl = null;
 416
 0417                    var batchPhase = await LoadBatchCatalogPhaseAsync(
 0418                        client, accountId, warehouseIds, warehouseDisplayByOzonId, Log, cancellationToken);
 0419                    batchCatalogFor5b = batchPhase.BatchCatalog;
 0420                    batchRowsForMeta = batchPhase.BatchRows;
 0421                    catalogByOfferId = new Dictionary<string, OzonProductCatalogRow>(batchPhase.CatalogByOfferId);
 422
 0423                    batchVariantsPrebuilt = await BuildBatchVariantItemsAsync(
 0424                        db, batchPhase.BatchRows, warehouseStoresById, applyDisabledWarehouseIds, reserveMap, Log, cance
 0425                    await Log($"[{client.Name}] Партий к накату (есть WarehouseStock): {batchVariantsPrebuilt.Count}");
 426
 0427                    if (!dryRun && batchVariantsPrebuilt.Count > 0)
 428                    {
 0429                        await ProcessBatchVariantsAsync(
 0430                            apiManager, db, reportId, clientId, client.Name, batchVariantsPrebuilt,
 0431                            catalogByOfferId, warehouseNameById, manufacturers, applyDisabledWarehouseIds,
 0432                            Log, Progress, clientIndex, clientIds.Count, cancellationToken);
 0433                        batchProcessedEarly = true;
 434                    }
 435
 0436                    await Progress(clientIndex - 1, clientIds.Count, $"Поиск сирот OzonApi для {client.Name}…");
 0437                    var orphanPhase = await LoadOrphanStockPhaseAsync(
 0438                        client, accountId, apiManager, warehouseIds, warehouseDisplayByOzonId, Log, cancellationToken);
 439
 0440                    var mergedCatalog = new Dictionary<string, OzonProductCatalogRow>(catalogByOfferId, StringComparer.O
 0441                    foreach (var kv in orphanPhase.CatalogByOfferId)
 0442                        mergedCatalog[kv.Key] = kv.Value;
 0443                    catalogByOfferId = mergedCatalog;
 0444                    ozonRows = orphanPhase.Rows;
 0445                }
 446
 0447                var rowsWithStock = ozonRows.Where(r => r.AvailableQty > 0).ToList();
 0448                var withBrand = ozonRows.Count(r => r.BrandCode != null);
 0449                var strippedCount = ozonRows.Count(r => r.OriginalOfferId != r.OfferId);
 0450                var totalSourceRows = catalogSource == Stage5CatalogSource.OzonProductsDb
 0451                    ? ozonRows.Count + batchRowsForMeta.Count
 0452                    : ozonRows.Count;
 0453                await Log(
 0454                    $"[{client.Name}] Строк в источнике: {totalSourceRows} " +
 0455                    $"(non-batch с остатком: {rowsWithStock.Count}, batch×склад: {batchRowsForMeta.Count}, " +
 0456                    $"с брендом: {withBrand})");
 457
 458                // ── Шаг 1: выделяем партийные [N]-товары (legacy xlsx) или берём из фазы 5a ──
 459                List<BatchVariantItem> batchVariants;
 460                List<OzonCatalogStockRow> nonBatchRows;
 461
 0462                if (batchVariantsPrebuilt != null)
 463                {
 0464                    batchVariants = batchVariantsPrebuilt;
 0465                    nonBatchRows = rowsWithStock;
 466                }
 467                else
 468                {
 0469                    batchVariants = new List<BatchVariantItem>();
 0470                    nonBatchRows = new List<OzonCatalogStockRow>();
 471
 0472                    foreach (var row in ozonRows)
 473                    {
 0474                        var multiplier = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(row.OriginalOfferId);
 0475                        if (multiplier <= 1)
 476                        {
 0477                            if (row.AvailableQty > 0)
 0478                                nonBatchRows.Add(row);
 0479                            continue;
 480                        }
 481
 0482                        var baseArticle = BitrixStockOrphanOfferMatching.ExtractArticle(row.OriginalOfferId);
 0483                        var snapshot = await GetArticleSnapshotAsync(db, row.OriginalOfferId, cancellationToken);
 0484                        var totalQty = BitrixStockBatchVariantStockResolver.ResolveWarehouseQuantity(
 0485                            snapshot.Quantities,
 0486                            warehouseStoresById.GetValueOrDefault(row.WarehouseId));
 0487                        var (c0, c1) = (snapshot.C0, snapshot.C1);
 0488                        if (c0 == null && totalQty == 0 && string.IsNullOrEmpty(snapshot.Note))
 489                        {
 0490                            if (row.AvailableQty > 0)
 0491                                nonBatchRows.Add(row);
 0492                            await Log($"  [BatchCheck] {row.OriginalOfferId}: базовый «{baseArticle}» не найден в снэпшо
 493                        }
 494                        else
 495                        {
 0496                            var baseOfferId = BitrixStockOrphanOfferMatching.NormalizeOfferId(row.OriginalOfferId);
 0497                            batchVariants.Add(new BatchVariantItem(row, multiplier, baseOfferId, c0, c1, totalQty, snaps
 0498                            {
 0499                                Manufacturer = snapshot.Manufacturer
 0500                            });
 0501                            await Log(string.IsNullOrEmpty(snapshot.Note)
 0502                                ? $"  [Партия ×{multiplier}] {row.OriginalOfferId}: базовый «{baseArticle}» в 1С={totalQ
 0503                                : $"  [⓪ Несовпадение производителя] {row.OriginalOfferId}: {snapshot.Note}");
 504                        }
 0505                    }
 506
 0507                    batchVariants = await ApplyFreeStockAsync(
 0508                        batchVariants, warehouseStoresById, applyDisabledWarehouseIds, reserveMap, Log, cancellationToke
 509                }
 510
 511                // ── Шаг 2: из не-партийных строк (available>0) находим сирот ─────────────
 512                // Сирота: offer_id + warehouse_id есть в Ozon-отчёте, но НЕ покрыт строками этапа 1.
 513                // Склад из ApplyDisabledWarehouseMappingIds («снять галку = обнулить») зануляется
 514                // безусловно, даже если IsCoveredByReport() ошибочно считает offer_id «покрытым» —
 515                // это происходит, когда несколько РАЗНЫХ товаров в Ozon делят один базовый артикул,
 516                // отличаясь только суффиксом [N] (ExtractArticle/NormalizeOfferId его отбрасывают),
 517                // и строка отчёта другого варианта того же артикула маскирует настоящую сироту.
 0518                var potentialOrphans = nonBatchRows
 0519                    .Where(r => applyDisabledWarehouseIds.Contains(r.WarehouseId)
 0520                        || !BitrixStockOrphanOfferMatching.IsCoveredByReport(
 0521                            reportKeyIndex, r.OriginalOfferId, r.WarehouseId))
 0522                    .ToList();
 523
 524                // Полностью зарезервированные строки (present > 0, free_stock = 0) сиротами не считаем:
 525                // покупатель такой товар уже не закажет, а Ozon не опускает остаток ниже резерва — раньше
 526                // такие позиции попадали в отчёт каждый прогон, «обнулялись» со статусом Success и
 527                // возвращались через два часа (напр. 9030104004=TYT на M2: present 2 / reserved 2).
 0528                var (trulyOrphans, fullyReserved) =
 0529                    BitrixStockOrphanOfferMatching.SplitByFreeStock(potentialOrphans);
 530
 0531                await Log($"[{client.Name}] «Сирот» найдено: {trulyOrphans.Count}, партий (BatchVariant): {batchVariants
 0532                if (fullyReserved.Count > 0)
 0533                    await Log(
 0534                        $"[{client.Name}] Пропущено полностью зарезервированных: {fullyReserved.Count} " +
 0535                        $"(остаток на Ozon есть, но свободного нет) — {string.Join(", ", fullyReserved.Take(5).Select(r 
 0536                        (fullyReserved.Count > 5 ? $" … и ещё {fullyReserved.Count - 5}" : string.Empty));
 537                // Логируем первые 5 сирот для диагностики
 0538                foreach (var o in trulyOrphans.Take(5))
 539                {
 0540                    var mfr = o.BrandCode != null && manufacturers.TryGetValue(o.BrandCode, out var mn) ? mn : o.BrandCo
 0541                    var mfrStr = mfr != null ? $" [{mfr}]" : string.Empty;
 0542                    var origStr = o.OriginalOfferId != o.OfferId ? $" (исходный: {o.OriginalOfferId})" : string.Empty;
 0543                    await Log($"  → {o.OfferId}{origStr}{mfrStr} qty={o.AvailableQty} (свободно {o.FreeQty}) склад={o.Wa
 544                }
 0545                if (trulyOrphans.Count > 5) await Log($"  … и ещё {trulyOrphans.Count - 5}");
 0546                totalOrphansFound += trulyOrphans.Count;
 547
 0548                var orphansZeroed = 0;
 549
 0550                if (!dryRun && trulyOrphans.Count > 0)
 551                {
 552                    // Фактический итог обнуления по каждой строке — идёт в OzonOrphanStatus/Note.
 553                    // Раньше всем строкам писался безусловный "Success", поэтому непрошедшее обнуление
 554                    // выглядело в UI как выполненное и повторялось прогон за прогоном незамеченным.
 0555                    var zeroResults = new Dictionary<string, OrphanZeroResult>(StringComparer.OrdinalIgnoreCase);
 556
 0557                    for (var i = 0; i < trulyOrphans.Count; i += StockBatchSize)
 558                    {
 0559                        cancellationToken.ThrowIfCancellationRequested();
 0560                        var batch = trulyOrphans.Skip(i).Take(StockBatchSize).ToList();
 561
 562                        // Обнуляем по исходному артикулу из xlsx (с суффиксом бренда, напр. "KSB0108MG=KRU")
 0563                        var stockItems = batch.Select(o => new StockItemForUpdate
 0564                        {
 0565                            OfferId = o.OriginalOfferId,
 0566                            ProductId = o.ProductId ?? 0,
 0567                            Stock = 0,
 0568                            WarehouseId = long.Parse(o.WarehouseId)
 0569                        }).ToList();
 570                        try
 571                        {
 0572                            var result = await apiManager.UpdateProductStocks(stockItems);
 0573                            var successes = result.Result?.Count(r => r.Updated) ?? 0;
 0574                            var errors = result.Result?.Where(r => !r.Updated).ToList() ?? new();
 0575                            orphansZeroed += successes;
 576
 0577                            foreach (var r in result.Result ?? new List<UpdateStockResult>())
 0578                                zeroResults[OrphanZeroKey(r.OfferId, r.WarehouseId.ToString())] = new OrphanZeroResult(
 0579                                    r.Updated,
 0580                                    r.Updated ? null : r.Errors?.FirstOrDefault()?.Message ?? "неизвестная ошибка");
 581
 0582                            foreach (var err in errors)
 583                            {
 0584                                var firstErrMsg = err.Errors?.FirstOrDefault()?.Message ?? "неизвестная ошибка";
 0585                                await Log($"[{client.Name}] Ошибка обнуления {err.OfferId}: {firstErrMsg}");
 0586                                _logger.LogWarning(
 0587                                    "BitrixStock этап 5 [{ClientName}]: не удалось обнулить сироту {OfferId} — {Error}",
 0588                                    client.Name, err.OfferId, firstErrMsg);
 0589                            }
 0590                            await Progress(clientIndex, clientIds.Count,
 0591                                $"[{client.Name}] Обнуляем: {Math.Min(i + StockBatchSize, trulyOrphans.Count)}/{trulyOrp
 0592                        }
 0593                        catch (Exception ex)
 594                        {
 0595                            await Log($"[{client.Name}] Ошибка пакета UpdateProductStocks: {ex.Message}");
 0596                            _logger.LogWarning(ex,
 0597                                "BitrixStock этап 5 [{ClientName}]: ошибка пакета UpdateProductStocks (сироты)",
 0598                                client.Name);
 0599                            foreach (var o in batch)
 0600                                zeroResults[OrphanZeroKey(o.OriginalOfferId, o.WarehouseId)] =
 0601                                    new OrphanZeroResult(false, $"пакет не отправлен: {ex.Message}");
 0602                        }
 0603                    }
 604
 0605                    await Log($"[{client.Name}] Обнулено: {orphansZeroed}/{trulyOrphans.Count}");
 0606                    _logger.LogInformation(
 0607                        "BitrixStock этап 5 [{ClientName}]: сирот обнулено {Zeroed}/{Total}",
 0608                        client.Name, orphansZeroed, trulyOrphans.Count);
 609
 610                    // Записываем строки-сироты в отчёт
 0611                    await InsertOrphanLinesAsync(db, reportId, clientId, client.Name, trulyOrphans,
 0612                        warehouseNameById, manufacturers, zeroResults, cancellationToken);
 0613                }
 614
 615                // ── Обработка batch-вариантов (партийные товары [N]) ────────────────
 0616                if (!batchProcessedEarly && !dryRun && batchVariants.Count > 0)
 617                {
 0618                    await ProcessBatchVariantsAsync(
 0619                        apiManager, db, reportId, clientId, client.Name, batchVariants,
 0620                        catalogByOfferId, warehouseNameById, manufacturers, applyDisabledWarehouseIds,
 0621                        Log, Progress, clientIndex, clientIds.Count, cancellationToken);
 622                }
 623
 0624                totalOrphansZeroed += orphansZeroed;
 625
 626                // ── Этап 5b: batch-варианты, отсутствующие в источнике остатков ──────
 0627                var sourceOfferIds = ozonRows
 0628                    .Select(r => r.OriginalOfferId)
 0629                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 0630                foreach (var batchOffer in batchCatalogFor5b)
 0631                    sourceOfferIds.Add(batchOffer.OfferId);
 632
 633                HashSet<string> allOzonBatchIds;
 0634                if (catalogSource == Stage5CatalogSource.WarehouseStockReport)
 635                {
 636                    try
 637                    {
 0638                        allOzonBatchIds = await apiManager.GetAllBatchOfferIdsAsync(cancellationToken);
 0639                        await Log($"[{client.Name}] Получено batch offer_ids из Ozon API: {allOzonBatchIds.Count}, в ист
 0640                    }
 0641                    catch (Exception ex)
 642                    {
 0643                        await Log($"[{client.Name}] Не удалось получить batch offer_ids из Ozon (Этап 5b пропущен): {ex.
 0644                        allOzonBatchIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 645                    }
 646                }
 647                else
 648                {
 0649                    allOzonBatchIds = batchCatalogFor5b
 0650                        .Select(b => b.OfferId)
 0651                        .ToHashSet(StringComparer.OrdinalIgnoreCase);
 0652                    await Log($"[{client.Name}] Этап 5b: batch из фазы 5a — {allOzonBatchIds.Count} offer_id, в источник
 653                }
 654
 0655                var missingBatchOfferIds = allOzonBatchIds
 0656                    .Where(id => !sourceOfferIds.Contains(id))
 0657                    .ToList();
 658
 0659                if (missingBatchOfferIds.Count > 0)
 660                {
 0661                    await Log($"[{client.Name}] Batch-вариантов не в источнике: {missingBatchOfferIds.Count} — проверяем
 662
 663                    // Для каждого отсутствующего offer_id ищем базовый артикул в snapshot
 0664                    var missingWithSnapshot = new List<(string OfferId, int Multiplier, string BaseArticle, decimal? C0,
 0665                    foreach (var offerId in missingBatchOfferIds)
 666                    {
 0667                        var baseArticle = BitrixStockOrphanOfferMatching.ExtractArticle(offerId);
 0668                        var snapshot = await GetArticleSnapshotAsync(db, offerId, cancellationToken);
 0669                        var (c0, c1) = (snapshot.C0, snapshot.C1);
 0670                        var totalQty = snapshot.Quantities.Values.Sum();
 0671                        if (c0 != null || totalQty > 0)
 672                        {
 0673                            var mult = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(offerId);
 0674                            missingWithSnapshot.Add((offerId, mult, baseArticle, c0, c1, totalQty));
 675                        }
 0676                    }
 677
 0678                    if (missingWithSnapshot.Count > 0)
 679                    {
 0680                        await Log($"[{client.Name}] Из них с данными в 1С: {missingWithSnapshot.Count} — запрашиваем ста
 681
 682                        // Определяем is_created через /v3/product/info/list
 0683                        var isCreatedMap = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
 0684                        var ozonMissingErrors = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 0685                        foreach (var chunk in missingWithSnapshot.Chunk(100))
 686                        {
 0687                            cancellationToken.ThrowIfCancellationRequested();
 0688                            var offerIds = chunk.Select(x => x.OfferId).ToArray();
 689                            try
 690                            {
 0691                                var infoResp = await apiManager.GetProductInfoListAsync(offerIds: offerIds);
 0692                                var items = infoResp?["items"];
 0693                                if (items != null)
 694                                {
 0695                                    foreach (var item in items)
 696                                    {
 0697                                        var oid = item["offer_id"]?.ToString();
 0698                                        if (string.IsNullOrEmpty(oid)) continue;
 0699                                        isCreatedMap[oid] = item["statuses"]?["is_created"]?.Value<bool>() ?? false;
 700                                        // Собираем ошибки валидации для is_created=false
 0701                                        if (!isCreatedMap[oid])
 702                                        {
 0703                                            var errors = item["errors"];
 0704                                            if (errors != null)
 705                                            {
 0706                                                var parts = new List<string>();
 0707                                                foreach (var e in errors)
 708                                                {
 0709                                                    var attrName = e["texts"]?["attribute_name"]?.ToString();
 0710                                                    var desc = e["texts"]?["description"]?.ToString();
 0711                                                    parts.Add(string.IsNullOrEmpty(attrName)
 0712                                                        ? desc ?? e.ToString()
 0713                                                        : $"атрибут «{attrName}»: {desc}");
 714                                                }
 0715                                                if (parts.Count > 0)
 0716                                                    ozonMissingErrors[oid] = string.Join("; ", parts);
 717                                            }
 718                                        }
 719                                    }
 720                                }
 0721                            }
 0722                            catch (Exception ex)
 723                            {
 0724                                await Log($"[{client.Name}] Ошибка GetProductInfoList для отсутствующих batch: {ex.Messa
 725                            }
 726                        }
 727
 0728                        await InsertMissingBatchVariantLinesAsync(db, reportId, clientId, client.Name,
 0729                            missingWithSnapshot, isCreatedMap, ozonMissingErrors, warehouseNameById, cancellationToken);
 730
 0731                        var notInFbsCount = missingWithSnapshot.Count(x =>
 0732                            isCreatedMap.TryGetValue(x.OfferId, out var ic) && ic);
 0733                        var notCreatedCount = missingWithSnapshot.Count(x =>
 0734                            isCreatedMap.TryGetValue(x.OfferId, out var ic) && !ic);
 0735                        await Log($"[{client.Name}] Этап 5b: не в FBS управлении: {notInFbsCount}, ошибка карточки: {not
 0736                    }
 0737                }
 738
 739                // Сохраняем метаданные файла (один файл на клиента — покрывает все запрошенные склады)
 0740                await SaveReportFileAsync(db, reportId, clientId, fileUrl ?? string.Empty, reportCode, mappings, warehou
 0741                    totalSourceRows, rowsWithStock.Count, trulyOrphans.Count + batchVariants.Count,
 0742                    dryRun ? 0 : orphansZeroed, cancellationToken);
 0743                var sourceNote = catalogSource == Stage5CatalogSource.OzonProductsDb
 0744                    ? $"OzonApi DB ({reportCode})"
 0745                    : $"xlsx ({reportCode})";
 0746                await Log($"[{client.Name}] Метаданные источника сохранены: {sourceNote}, строк={totalSourceRows}, non-b
 0747                clientsFilesSaved++;
 0748                }
 0749                catch (OperationCanceledException) { throw; }
 0750                catch (Exception ex)
 751                {
 0752                    await Log($"Клиент {clientId}: критическая ошибка этапа 5, клиент пропущен — {ex.Message}");
 753                    try
 754                    {
 755                        const string warnSql =
 756                            "UPDATE BitrixStockSyncReports SET Warning = {0} WHERE ReportId = {1}";
 0757                        var warnParams = EfSqlRawParameterGuard.Materialize(
 0758                            $"Этап 5 (клиент {clientId}): {ex.Message}", reportId);
 0759                        EfSqlRawParameterGuard.Validate(warnSql, warnParams, "orphan.persist_client_error");
 0760                        await using var warnDb = await _dbFactory.CreateDbContextAsync(cancellationToken);
 0761                        await warnDb.Database.ExecuteSqlRawAsync(warnSql, warnParams, cancellationToken);
 0762                    }
 0763                    catch { /* лучшая попытка — не даём вторичной ошибке скрыть первую */ }
 0764                }
 0765            }
 766
 0767            if (clientsFilesSaved == 0)
 768            {
 0769                var noFilesMsg = catalogSource == Stage5CatalogSource.OzonProductsDb
 0770                    ? "Каталог OzonApi.ozon_products не загружен ни для одного клиента (нет account_id, ошибка БД/API ил
 0771                    : "Ozon-отчёт не загружен ни для одного клиента (таймаут API, ошибка скачивания или нет складов). За
 0772                await Log(noFilesMsg);
 0773                await Complete(false, noFilesMsg);
 0774                return;
 775            }
 776
 0777            if (!dryRun)
 778            {
 779                const string updateSql =
 780                    "UPDATE BitrixStockSyncReports SET OzonOrphanLastAtUtc = {0} WHERE ReportId = {1}";
 0781                var updateParams = EfSqlRawParameterGuard.Materialize(DateTime.UtcNow, reportId);
 0782                EfSqlRawParameterGuard.Validate(updateSql, updateParams, "orphan.update_timestamp");
 0783                await db.Database.ExecuteSqlRawAsync(updateSql, updateParams, cancellationToken);
 784            }
 785
 0786            var summary = dryRun
 0787                ? $"Предпросмотр: найдено {totalOrphansFound} сирот."
 0788                : $"Завершено. Сирот найдено: {totalOrphansFound}, обнулено: {totalOrphansZeroed}.";
 0789            await Log(summary);
 0790            await Complete(true, new { reportId, totalOrphansFound, totalOrphansZeroed, dryRun });
 0791        }
 0792        catch (OperationCanceledException) { throw; }
 0793        catch (Exception ex)
 794        {
 0795            await Log($"Критическая ошибка этапа 5: {ex.Message}");
 0796            await Complete(false, ex.Message);
 0797        }
 798        finally
 799        {
 0800            HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConn, _logger);
 0801            if (!dryRun && !request.Chained) _stageStore.Clear(reportId);
 802        }
 0803    }
 804
 805    // ─── Вспомогательные методы ───────────────────────────────────────────────
 806
 807    private static List<OzonCatalogStockRow> ParseWarehouseStockXlsx(byte[] bytes)
 808    {
 0809        var result = new List<OzonCatalogStockRow>();
 0810        using var ms = new MemoryStream(bytes);
 0811        using var pkg = new ExcelPackage(ms);
 0812        var ws = pkg.Workbook.Worksheets.FirstOrDefault();
 0813        if (ws == null) return result;
 814
 0815        var rowCount = ws.Dimension?.Rows ?? 0;
 0816        for (var row = 2; row <= rowCount; row++) // row 1 = header
 817        {
 818            // A=1:WarehouseId, B=2:WarehouseName, C=3:Артикул, D=4:Название товара, E=5:Доступно, F=6:Зарезервировано
 0819            var warehouseId = ws.Cells[row, 1].Text?.Trim() ?? string.Empty;
 0820            var rawOfferId  = ws.Cells[row, 3].Text?.Trim() ?? string.Empty;
 0821            if (string.IsNullOrWhiteSpace(warehouseId) || string.IsNullOrWhiteSpace(rawOfferId)) continue;
 822
 0823            var productName = ws.Cells[row, 4].Text?.Trim();
 824
 825            // Артикул Ozon: "ADA100107=BLP" или "1102601M02=NSS[3]" или "JB05001A" (без =)
 826            // Для сравнения со снэпшотом берём часть до знака = (реальный offer_id)
 827            string cleanOfferId;
 0828            string? brandCode = null;
 0829            var eqIdx = rawOfferId.IndexOf('=');
 0830            if (eqIdx > 0)
 831            {
 0832                cleanOfferId = rawOfferId[..eqIdx].Trim();
 0833                var brandPart = rawOfferId[(eqIdx + 1)..].Trim();
 0834                var m = BrandCodeRegex.Match(brandPart);
 0835                if (m.Success) brandCode = m.Groups[1].Value;
 836            }
 837            else
 838            {
 0839                cleanOfferId = rawOfferId;
 840            }
 841
 0842            var availableRaw = ws.Cells[row, 5].Text?.Trim() ?? "0";
 0843            var available = int.TryParse(availableRaw, out var av) ? av : 0;
 0844            var reservedRaw = ws.Cells[row, 6].Text?.Trim() ?? "0";
 0845            var reserved = int.TryParse(reservedRaw, out var rv) ? rv : 0;
 846
 0847            result.Add(new OzonCatalogStockRow
 0848            {
 0849                WarehouseId     = warehouseId,
 0850                WarehouseName   = ws.Cells[row, 2].Text?.Trim() ?? string.Empty,
 0851                OfferId         = cleanOfferId,
 0852                OriginalOfferId = rawOfferId,
 0853                BrandCode       = brandCode,
 0854                ProductName     = productName,
 0855                AvailableQty    = available,
 0856                ReservedQty     = reserved,
 0857                FreeQty         = Math.Max(0, available - reserved)
 0858            });
 859        }
 0860        return result;
 0861    }
 862
 863    private static async Task InsertOrphanLinesAsync(
 864        ApplicationDbContext db,
 865        string reportId,
 866        int clientId,
 867        string clientName,
 868        List<OzonCatalogStockRow> orphans,
 869        Dictionary<string, string> warehouseNameById,
 870        Dictionary<string, string> manufacturers,
 871        IReadOnlyDictionary<string, OrphanZeroResult> zeroResults,
 872        CancellationToken ct)
 873    {
 0874        var now = DateTime.UtcNow;
 0875        foreach (var batch in orphans.Chunk(100))
 876        {
 0877            var values = new List<object?>();
 0878            var sb = new System.Text.StringBuilder();
 0879            sb.Append("INSERT IGNORE INTO BitrixStockSyncReportLines " +
 0880                      "(ReportId,ProductId,Article,OfferId,OneCStore,OzonWarehouseName,OzonWarehouseId," +
 0881                      "MappingDescription,OneCQty,OzonQty,Delta,Action,ActionLabel,ActionIcon,RowClass," +
 0882                      "Note,OzonProductId,OzonApplyStatus,IsArchived,IsAutoArchived,OzonClientId,OzonClientName," +
 0883                      "OneCPrice,PrevOneCPrice,ReservedQty,IsOzonOrphan,OzonOrphanStatus,OzonOrphanAtUtc,CreatedAtUtc) V
 0884            var pIdx = 0;
 0885            for (var i = 0; i < batch.Length; i++)
 886            {
 0887                var o = batch[i];
 0888                var bitrixName = warehouseNameById.GetValueOrDefault(o.WarehouseId, o.WarehouseName);
 889
 890                // Производитель из справочника по коду бренда
 0891                var mfrName = o.BrandCode != null && manufacturers.TryGetValue(o.BrandCode, out var mn)
 0892                    ? $"{mn} ({o.BrandCode})"
 0893                    : o.BrandCode ?? "—";
 0894                var mappingDesc = $"Сирота — {mfrName}";
 895
 896                // Фактический итог обнуления по этой паре offer×склад. Строки без ответа Ozon
 897                // (пакет не дошёл, товар не вернулся в result) помечаем Failed, а не Success —
 898                // иначе непрошедшее обнуление молча повторяется каждый прогон.
 0899                var zero = zeroResults.GetValueOrDefault(
 0900                    OrphanZeroKey(o.OriginalOfferId, o.WarehouseId),
 0901                    new OrphanZeroResult(false, "Ozon не подтвердил обнуление"));
 902
 903                // В Note: исходный артикул из xlsx (если отличается) + название товара
 0904                var noteParts = new List<string>();
 0905                if (o.OriginalOfferId != o.OfferId) noteParts.Add($"xlsx: {o.OriginalOfferId}");
 0906                if (!zero.Updated) noteParts.Add($"Не обнулено: {zero.Error}");
 0907                if (o.ReservedQty > 0) noteParts.Add($"в резерве {o.ReservedQty} из {o.AvailableQty}");
 0908                if (!string.IsNullOrWhiteSpace(o.ProductName))
 0909                    noteParts.Add(o.ProductName.Length > 120 ? o.ProductName[..120] + "…" : o.ProductName);
 0910                object? note = noteParts.Count > 0 ? string.Join(" | ", noteParts) : null;
 911
 0912                if (i > 0) sb.Append(',');
 0913                var b = pIdx;
 0914                sb.Append($"({{{b}}},{{{b+1}}},{{{b+2}}},{{{b+3}}},{{{b+4}}},{{{b+5}}},{{{b+6}}}," +
 0915                          $"{{{b+7}}},{{{b+8}}},{{{b+9}}},{{{b+10}}},{{{b+11}}},{{{b+12}}},{{{b+13}}},{{{b+14}}}," +
 0916                          $"{{{b+15}}},{{{b+16}}},{{{b+17}}},{{{b+18}}},{{{b+19}}},{{{b+20}}},{{{b+21}}}," +
 0917                          $"{{{b+22}}},{{{b+23}}},{{{b+24}}},{{{b+25}}},{{{b+26}}},{{{b+27}}},{{{b+28}}})");
 0918                values.Add(reportId);
 0919                values.Add(0);
 0920                values.Add(o.OfferId);              // Article = чистый артикул
 0921                values.Add(o.OriginalOfferId);      // OfferId = как в Ozon (для API и сопоставления)
 0922                values.Add(bitrixName);
 0923                values.Add(o.WarehouseName);
 0924                values.Add(o.WarehouseId);
 0925                values.Add(mappingDesc);   // Производитель
 0926                values.Add(0);             // OneCQty
 0927                values.Add(o.AvailableQty);
 0928                values.Add(-o.AvailableQty);
 0929                values.Add(BitrixStockSyncReportActions.OzonZero);
 0930                values.Add("Обнулить (сирота)");
 0931                values.Add("fa-trash");
 0932                values.Add("table-danger");
 0933                values.Add(note);          // исходный xlsx-артикул + название товара
 0934                values.Add((object?)null);
 0935                values.Add((object?)null);
 0936                values.Add(false);
 0937                values.Add(false);
 0938                values.Add(clientId);
 0939                values.Add(clientName);
 0940                values.Add((object?)null);
 0941                values.Add((object?)null);
 0942                values.Add(o.ReservedQty);
 0943                values.Add(true);          // IsOzonOrphan
 0944                values.Add(zero.Updated ? "Success" : "Failed");
 0945                values.Add(now);
 0946                values.Add(now);
 0947                pIdx += 29;
 948            }
 0949            EfSqlRawParameterGuard.Validate(sb.ToString(), values, "orphan.insert_line");
 0950            await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(values), ct);
 951        }
 0952    }
 953
 954    private static async Task InsertBatchVariantLinesAsync(
 955        ApplicationDbContext db,
 956        string reportId,
 957        int clientId,
 958        string clientName,
 959        List<BatchVariantItem> batchVariants,
 960        Dictionary<string, string> warehouseNameById,
 961        Dictionary<string, string> manufacturers,
 962        IReadOnlySet<string> applyDisabledWarehouseIds,
 963        Dictionary<string, decimal> pushedPrices,
 964        Dictionary<string, string> ozonProductErrors,
 965        CancellationToken ct)
 966    {
 0967        var now = DateTime.UtcNow;
 0968        foreach (var chunk in batchVariants.Chunk(100))
 969        {
 0970            var values = new List<object?>();
 0971            var sb = new System.Text.StringBuilder();
 0972            sb.Append("INSERT IGNORE INTO BitrixStockSyncReportLines " +
 0973                      "(ReportId,ProductId,Article,OfferId,OneCStore,OzonWarehouseName,OzonWarehouseId," +
 0974                      "MappingDescription,OneCQty,OzonQty,Delta,Action,ActionLabel,ActionIcon,RowClass," +
 0975                      "Note,OzonProductId,OzonApplyStatus,IsArchived,IsAutoArchived,OzonClientId,OzonClientName," +
 0976                      "OneCPrice,PrevOneCPrice,ReservedQty,IsOzonOrphan,OzonOrphanStatus,OzonOrphanAtUtc,CreatedAtUtc," 
 0977                      "BatchMultiplier,BatchBaseArticle,BatchPushedPrice) VALUES ");
 0978            var pIdx = 0;
 0979            for (var i = 0; i < chunk.Length; i++)
 980            {
 0981                var bv = chunk[i];
 0982                var o = bv.Row;
 0983                var bitrixName = warehouseNameById.GetValueOrDefault(o.WarehouseId, o.WarehouseName);
 984
 0985                var mfrName = o.BrandCode != null && manufacturers.TryGetValue(o.BrandCode, out var mn)
 0986                    ? $"{mn} ({o.BrandCode})"
 0987                    : o.BrandCode ?? "—";
 988
 0989                var isDisabledWarehouse = applyDisabledWarehouseIds.Contains(o.WarehouseId);
 0990                ozonProductErrors.TryGetValue(o.OriginalOfferId, out var ozonError);
 0991                var isNotCreated  = !isDisabledWarehouse && !string.IsNullOrEmpty(ozonError);
 0992                var isManufacturerMismatch = !string.IsNullOrWhiteSpace(bv.SnapshotNote);
 0993                var isZeroStock = !isDisabledWarehouse && !isNotCreated && bv.BatchStock == 0 &&
 0994                    (bv.TotalQty > 0 || isManufacturerMismatch);
 0995                var effectiveStock = isDisabledWarehouse ? 0 : bv.BatchStock;
 0996                pushedPrices.TryGetValue(o.OriginalOfferId, out var pushedPrice);
 997
 0998                var noteParts = new List<string>
 0999                {
 01000                    isNotCreated
 01001                        ? $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | Товар не создан в Ozon FBS"
 01002                        : isZeroStock
 01003                            ? isManufacturerMismatch
 01004                                ? $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | Несовпадение производителя → 0 уп
 01005                                : $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | 1С: {bv.TotalQty} шт. < {bv.Multi
 01006                            : $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | 1С: {bv.TotalQty} шт. → {effectiveSto
 01007                };
 01008                if (isNotCreated)
 01009                    noteParts.Add($"Проблема на стороне Ozon: {ozonError} Нужно зайти в ЛК → найти этот товар → открыть 
 01010                if (!isDisabledWarehouse && !isZeroStock && !isNotCreated && pushedPrice > 0)
 01011                    noteParts.Add($"Цена Ozon: {(bv.C0.HasValue ? $"{bv.C0:F0} ₽ × {bv.Multiplier} = " : "")}{pushedPric
 01012                if (isDisabledWarehouse)
 01013                    noteParts.Add("Снятая связь (этап 2) → обнулено");
 01014                if (!isDisabledWarehouse && bv.ReservedQty > 0)
 01015                    noteParts.Add($"В резерве {bv.ReservedQty} шт. из {bv.RawQty}");
 01016                if (!isDisabledWarehouse && bv.LiveClampedTo is { } liveFree)
 01017                    noteParts.Add($"Остаток ограничен по 1С онлайн: свободно {liveFree} шт.");
 01018                if (!string.IsNullOrWhiteSpace(bv.SnapshotNote))
 01019                    noteParts.Add(bv.SnapshotNote);
 01020                if (!string.IsNullOrWhiteSpace(o.ProductName))
 01021                    noteParts.Add(o.ProductName.Length > 100 ? o.ProductName[..100] + "…" : o.ProductName);
 01022                object? note = string.Join(" | ", noteParts);
 1023
 01024                if (i > 0) sb.Append(',');
 01025                var b = pIdx;
 01026                sb.Append($"({{{b}}},{{{b+1}}},{{{b+2}}},{{{b+3}}},{{{b+4}}},{{{b+5}}},{{{b+6}}}," +
 01027                          $"{{{b+7}}},{{{b+8}}},{{{b+9}}},{{{b+10}}},{{{b+11}}},{{{b+12}}},{{{b+13}}},{{{b+14}}}," +
 01028                          $"{{{b+15}}},{{{b+16}}},{{{b+17}}},{{{b+18}}},{{{b+19}}},{{{b+20}}},{{{b+21}}}," +
 01029                          $"{{{b+22}}},{{{b+23}}},{{{b+24}}},{{{b+25}}},{{{b+26}}},{{{b+27}}},{{{b+28}}}," +
 01030                          $"{{{b+29}}},{{{b+30}}},{{{b+31}}})");
 01031                values.Add(reportId);
 01032                values.Add(0);
 01033                values.Add(o.OfferId);              // Article = чистый артикул
 01034                values.Add(o.OriginalOfferId);      // OfferId = как в Ozon (с [N])
 01035                values.Add(bitrixName);
 01036                values.Add(o.WarehouseName);
 01037                values.Add(o.WarehouseId);
 01038                values.Add($"Партия ×{bv.Multiplier} — {mfrName}");
 01039                values.Add(bv.TotalQty);            // OneCQty = свободный остаток базового артикула (без резерва, не вы
 01040                values.Add(o.AvailableQty);         // OzonQty = было в Ozon
 01041                values.Add(effectiveStock - o.AvailableQty); // Delta
 01042                var action = isDisabledWarehouse
 01043                    ? BitrixStockSyncReportActions.BatchVariantDisabledLink
 01044                    : isNotCreated
 01045                        ? BitrixStockSyncReportActions.BatchVariantNotCreated
 01046                        : isZeroStock
 01047                            ? BitrixStockSyncReportActions.BatchVariantZeroStock
 01048                            : BitrixStockSyncReportActions.BatchVariant;
 01049                values.Add(action);
 01050                values.Add(isDisabledWarehouse ? "⓪ Обнулено (связь)" : isNotCreated ? "⚠ Не создан" : isZeroStock ? isM
 01051                values.Add(isDisabledWarehouse ? "fa-link-slash" : isNotCreated ? "fa-triangle-exclamation" : isZeroStoc
 01052                values.Add(isDisabledWarehouse ? "table-secondary" : isNotCreated ? "table-danger" : isZeroStock ? "tabl
 01053                values.Add(note);
 01054                values.Add((object?)null);  // OzonProductId
 01055                values.Add((object?)null);  // OzonApplyStatus
 01056                values.Add(false);
 01057                values.Add(false);
 01058                values.Add(clientId);
 01059                values.Add(clientName);
 01060                values.Add(bv.C0);          // OneCPrice = C0
 01061                values.Add((object?)null);  // PrevOneCPrice
 01062                values.Add(bv.ReservedQty); // ReservedQty
 01063                values.Add(false);          // IsOzonOrphan = false (не сирота!)
 01064                values.Add((object?)null);  // OzonOrphanStatus
 01065                values.Add((object?)null);  // OzonOrphanAtUtc
 01066                values.Add(now);
 01067                values.Add(bv.Multiplier);  // BatchMultiplier
 01068                values.Add(bv.BaseOfferId); // BatchBaseArticle
 01069                values.Add(isDisabledWarehouse || isZeroStock || isNotCreated || pushedPrice == 0 ? (object?)null : (dec
 01070                pIdx += 32;
 1071            }
 01072            EfSqlRawParameterGuard.Validate(sb.ToString(), values, "batch_variant.insert_line");
 01073            await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(values), ct);
 1074        }
 01075    }
 1076
 1077    private static async Task InsertMissingBatchVariantLinesAsync(
 1078        ApplicationDbContext db,
 1079        string reportId,
 1080        int clientId,
 1081        string clientName,
 1082        List<(string OfferId, int Multiplier, string BaseArticle, decimal? C0, decimal? C1, int TotalQty)> items,
 1083        Dictionary<string, bool> isCreatedMap,
 1084        Dictionary<string, string> ozonMissingErrors,
 1085        Dictionary<string, string> warehouseNameById,
 1086        CancellationToken ct)
 1087    {
 01088        var now = DateTime.UtcNow;
 01089        foreach (var chunk in items.Chunk(100))
 1090        {
 01091            var values = new List<object?>();
 01092            var sb = new System.Text.StringBuilder();
 01093            sb.Append("INSERT IGNORE INTO BitrixStockSyncReportLines " +
 01094                      "(ReportId,ProductId,Article,OfferId,OneCStore,OzonWarehouseName,OzonWarehouseId," +
 01095                      "MappingDescription,OneCQty,OzonQty,Delta,Action,ActionLabel,ActionIcon,RowClass," +
 01096                      "Note,OzonProductId,OzonApplyStatus,IsArchived,IsAutoArchived,OzonClientId,OzonClientName," +
 01097                      "OneCPrice,PrevOneCPrice,ReservedQty,IsOzonOrphan,OzonOrphanStatus,OzonOrphanAtUtc,CreatedAtUtc," 
 01098                      "BatchMultiplier,BatchBaseArticle,BatchPushedPrice) VALUES ");
 01099            var pIdx = 0;
 01100            for (var i = 0; i < chunk.Length; i++)
 1101            {
 01102                var (offerId, mult, baseArticle, c0, c1, totalQty) = chunk[i];
 01103                isCreatedMap.TryGetValue(offerId, out var isCreated);
 01104                ozonMissingErrors.TryGetValue(offerId, out var ozonError);
 1105
 01106                var action = isCreated
 01107                    ? BitrixStockSyncReportActions.BatchVariantNotInFbs
 01108                    : BitrixStockSyncReportActions.BatchVariantNotCreated;
 01109                var label = isCreated ? "⊘ Нет в FBS" : "⚠ Не создан";
 01110                var icon  = isCreated ? "fa-store-slash" : "fa-triangle-exclamation";
 01111                var rowClass = isCreated ? "table-warning" : "table-danger";
 1112
 01113                var noteParts = new List<string>();
 01114                noteParts.Add($"×{mult} шт. | Базовый: {baseArticle} | 1С: {totalQty} шт.");
 01115                if (isCreated)
 01116                    noteParts.Add("Товар есть в каталоге Ozon, но не добавлен в FBS управление остатками. Зайти в ЛК → F
 1117                else
 1118                {
 01119                    noteParts.Add("Товар не создан в Ozon FBS — ошибка валидации карточки.");
 01120                    if (!string.IsNullOrEmpty(ozonError))
 01121                        noteParts.Add($"Проблема на стороне Ozon: {ozonError} Нужно зайти в ЛК → найти этот товар → откр
 1122                }
 01123                var note = string.Join(" ", noteParts);
 1124
 01125                if (i > 0) sb.Append(',');
 01126                sb.Append($"(@p{pIdx},@p{pIdx+1},@p{pIdx+2},@p{pIdx+3},@p{pIdx+4},@p{pIdx+5},@p{pIdx+6}," +
 01127                          $"@p{pIdx+7},@p{pIdx+8},@p{pIdx+9},@p{pIdx+10},@p{pIdx+11},@p{pIdx+12},@p{pIdx+13},@p{pIdx+14}
 01128                          $"@p{pIdx+15},@p{pIdx+16},@p{pIdx+17},@p{pIdx+18},@p{pIdx+19},@p{pIdx+20},@p{pIdx+21}," +
 01129                          $"@p{pIdx+22},@p{pIdx+23},@p{pIdx+24},@p{pIdx+25},@p{pIdx+26},@p{pIdx+27},@p{pIdx+28}," +
 01130                          $"@p{pIdx+29},@p{pIdx+30},@p{pIdx+31})");
 01131                values.Add(reportId);                          // ReportId
 01132                values.Add(clientId);                          // ProductId (ClientId)
 01133                values.Add(baseArticle);                       // Article
 01134                values.Add(offerId);                           // OfferId
 01135                values.Add((object?)null);                     // OneCStore
 01136                values.Add("—");                               // OzonWarehouseName
 01137                values.Add(0L);                                // OzonWarehouseId
 01138                values.Add((object?)null);                     // MappingDescription
 01139                values.Add(totalQty);                          // OneCQty
 01140                values.Add(0);                                 // OzonQty
 01141                values.Add(0);                                 // Delta
 01142                values.Add(action);                            // Action
 01143                values.Add(label);                             // ActionLabel
 01144                values.Add(icon);                              // ActionIcon
 01145                values.Add(rowClass);                          // RowClass
 01146                values.Add(note);                              // Note
 01147                values.Add((object?)null);                     // OzonProductId
 01148                values.Add("Не в отчёте");                    // OzonApplyStatus
 01149                values.Add(false);                             // IsArchived
 01150                values.Add(false);                             // IsAutoArchived
 01151                values.Add(clientId);                          // OzonClientId
 01152                values.Add(clientName);                        // OzonClientName
 01153                values.Add(c0);                                // OneCPrice
 01154                values.Add((object?)null);                     // PrevOneCPrice
 01155                values.Add(0);                                 // ReservedQty
 01156                values.Add(false);                             // IsOzonOrphan
 01157                values.Add((object?)null);                     // OzonOrphanStatus
 01158                values.Add((object?)null);                     // OzonOrphanAtUtc
 01159                values.Add(now);                               // CreatedAtUtc
 01160                values.Add(mult);                              // BatchMultiplier
 01161                values.Add(baseArticle);                       // BatchBaseArticle
 01162                values.Add((object?)null);                     // BatchPushedPrice
 01163                pIdx += 32;
 1164            }
 01165            EfSqlRawParameterGuard.Validate(sb.ToString(), values, "missing_batch.insert_line");
 01166            await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(values), ct);
 1167        }
 01168    }
 1169
 1170    private async Task<Dictionary<string, decimal>> PushBatchVariantPricesAsync(
 1171        OzonApiDataManager apiManager,
 1172        List<BatchVariantItem> batchVariants,
 1173        string reportId,
 1174        int clientId,
 1175        string clientName,
 1176        IReadOnlyDictionary<string, OzonProductCatalogRow> catalogByOfferId,
 1177        Func<string, Task> log,
 1178        Func<int, int, string, Task> progress,
 1179        int clientIndex,
 1180        int clientsTotal,
 1181        CancellationToken ct)
 1182    {
 1183        // Загружаем настройки процента наценки из отчёта (fallback: 5% без акции, 10% с акцией)
 01184        decimal noPromoMarkup = 5m, promoMarkup = 10m;
 1185
 1186        const int priceBatchSize = 200;
 01187        var offerIds = batchVariants.Select(b => b.Row.OriginalOfferId).ToArray();
 1188
 01189        var priceByOfferId = new Dictionary<string, JObject>(StringComparer.OrdinalIgnoreCase);
 01190        var offersNeedingApi = new List<string>();
 01191        foreach (var offerId in offerIds)
 1192        {
 01193            if (catalogByOfferId.TryGetValue(offerId, out var catalogRow))
 01194                priceByOfferId[offerId] = OzonProductCatalogPriceJsonBuilder.Build(catalogRow);
 1195            else
 01196                offersNeedingApi.Add(offerId);
 1197        }
 1198
 01199        await log($"[{clientName}] Партии — цены: из OzonApi.ozon_products {priceByOfferId.Count}, через Ozon API GetPro
 1200
 01201        foreach (var batchOffers in offersNeedingApi.Chunk(priceBatchSize))
 1202        {
 1203            try
 1204            {
 01205                await log($"[{clientName}] Ozon API GetProductPrices: {batchOffers.Length} offer_id (fallback, нет в кат
 01206                var raw = await apiManager.GetProductPricesByArticles(batchOffers);
 01207                var items = raw?["items"] as JArray;
 01208                if (items == null) continue;
 01209                foreach (var item in items.OfType<JObject>())
 1210                {
 01211                    var oid = item["offer_id"]?.Value<string>();
 01212                    if (!string.IsNullOrEmpty(oid))
 01213                        priceByOfferId[oid] = item;
 1214                }
 01215            }
 01216            catch (Exception ex)
 1217            {
 01218                await log($"[{clientName}] Партии — ошибка получения цен Ozon: {ex.Message}");
 1219            }
 01220        }
 1221
 01222        var priceItems = new List<ProductPriceItem>();
 01223        var pushedPrices = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
 1224
 01225        foreach (var bv in batchVariants)
 1226        {
 01227            if (!priceByOfferId.TryGetValue(bv.Row.OriginalOfferId, out var priceJson))
 1228            {
 01229                await log($"  [Цена] {bv.Row.OriginalOfferId}: нет данных Ozon/каталога — пропуск цены");
 01230                continue;
 1231            }
 1232
 01233            var productId = bv.Row.ProductId ?? priceJson["product_id"]?.Value<long?>();
 01234            if (productId == null)
 1235            {
 01236                await log($"  [Цена] {bv.Row.OriginalOfferId}: product_id не найден — пропуск цены");
 01237                continue;
 1238            }
 1239
 01240            var vat = priceJson["price"]?["vat"]?.Value<string>() ?? "0";
 1241
 1242            PriceOptimizationResult optResult;
 1243            try
 1244            {
 01245                optResult = PriceOptimizationManager.Calculate(new PriceOptimizationInput
 01246                {
 01247                    C0                   = bv.C0,
 01248                    C1                   = bv.C1,
 01249                    PriceJson            = priceJson,
 01250                    InPromo              = false,
 01251                    NoPromoMarkupPercent = noPromoMarkup,
 01252                    PromoMarkupPercent   = promoMarkup
 01253                });
 01254            }
 1255            catch (Exception ex)
 1256            {
 01257                await log($"  [Цена] {bv.Row.OriginalOfferId}: ошибка расчёта — {ex.Message}");
 01258                continue;
 1259            }
 1260
 01261            if (optResult.Error != null)
 1262            {
 01263                await log($"  [Цена] {bv.Row.OriginalOfferId}: {optResult.Error} — пропуск");
 01264                continue;
 1265            }
 1266
 01267            if (optResult.WinnerPrice == null)
 1268            {
 01269                await log($"  [Цена] {bv.Row.OriginalOfferId}: PriceOptimizationManager вернул null — пропуск");
 01270                continue;
 1271            }
 1272
 01273            var winner   = optResult.WinnerPrice.Value * bv.Multiplier;
 01274            var oldPrice = BitrixStockSyncReportPricePushService.ComputeOldPrice(winner);
 01275            await log($"  [Цена] {bv.Row.OriginalOfferId} ×{bv.Multiplier}: " +
 01276                      $"{optResult.WinnerPrice.Value:F0} × {bv.Multiplier} = {winner:F0} ₽ → Ozon");
 1277
 01278            pushedPrices[bv.Row.OriginalOfferId] = winner;
 1279
 01280            priceItems.Add(new ProductPriceItem
 01281            {
 01282                OfferId  = bv.Row.OriginalOfferId,
 01283                ProductId = productId.Value,
 01284                Price    = ((long)winner).ToString(CultureInfo.InvariantCulture),
 01285                OldPrice = ((long)oldPrice).ToString(CultureInfo.InvariantCulture),
 01286                MinPrice = ((long)winner).ToString(CultureInfo.InvariantCulture),
 01287                NetPrice = "0",
 01288                Vat      = vat
 01289            });
 01290        }
 1291
 01292        if (priceItems.Count == 0) return pushedPrices;
 1293
 1294        // Дедуплицируем по OfferId: в Ozon API цена привязана к товару целиком, а не к складу
 01295        var distinctPriceItems = priceItems
 01296            .GroupBy(p => p.OfferId, StringComparer.OrdinalIgnoreCase)
 01297            .Select(g => g.Last())
 01298            .ToList();
 1299
 1300        try
 1301        {
 01302            var result = await apiManager.ImportProductPrices(distinctPriceItems);
 01303            var pushed = result.Result?.Count(r => r.Updated) ?? 0;
 01304            var errors = result.Result?.Where(r => !r.Updated).ToList() ?? new();
 1305
 1306            // Проверяем ошибки НДС и делаем точечный ретрай с корректной ставкой
 01307            var vatRetryItems = new List<ProductPriceItem>();
 01308            foreach (var err in errors)
 1309            {
 01310                var errMsg = string.Join("; ", err.Errors?.Select(e => e.Message) ?? Enumerable.Empty<string>());
 01311                var fixedVat = BitrixStockSyncReportPricePushService.ParseAvailableVat(errMsg);
 01312                if (fixedVat != null && !string.IsNullOrEmpty(err.OfferId))
 1313                {
 01314                    var original = distinctPriceItems.FirstOrDefault(p =>
 01315                        string.Equals(p.OfferId, err.OfferId, StringComparison.OrdinalIgnoreCase));
 01316                    if (original != null)
 1317                    {
 01318                        vatRetryItems.Add(new ProductPriceItem
 01319                        {
 01320                            OfferId   = original.OfferId,
 01321                            ProductId = original.ProductId,
 01322                            Price     = original.Price,
 01323                            OldPrice  = original.OldPrice,
 01324                            MinPrice  = original.MinPrice,
 01325                            NetPrice  = original.NetPrice,
 01326                            Vat       = fixedVat
 01327                        });
 1328                    }
 1329                }
 1330            }
 1331
 01332            if (vatRetryItems.Count > 0)
 1333            {
 01334                await log($"[{clientName}] Партии — ретрай НДС: {vatRetryItems.Count} товаров…");
 1335                try
 1336                {
 01337                    var retryResult = await apiManager.ImportProductPrices(vatRetryItems);
 01338                    foreach (var r in retryResult.Result ?? new())
 1339                    {
 01340                        if (r.Updated)
 1341                        {
 01342                            pushed++;
 01343                            errors.RemoveAll(e => string.Equals(e.OfferId, r.OfferId, StringComparison.OrdinalIgnoreCase
 1344                        }
 1345                    }
 01346                }
 01347                catch (Exception retryEx)
 1348                {
 01349                    await log($"[{clientName}] Партии — ошибка ретрая НДС: {retryEx.Message}");
 1350                }
 1351            }
 1352
 01353            await log($"[{clientName}] Партии — цены накачены: {pushed}/{distinctPriceItems.Count}");
 01354            foreach (var err in errors)
 1355            {
 01356                var msg = err.Errors?.FirstOrDefault()?.Message ?? "неизвестная ошибка";
 01357                await log($"  [Цена] Ошибка {err.OfferId}: {msg}");
 01358                pushedPrices.Remove(err.OfferId ?? string.Empty);
 01359            }
 01360        }
 01361        catch (Exception ex)
 1362        {
 01363            await log($"[{clientName}] Партии — ошибка пуша цен: {ex.Message}");
 01364            pushedPrices.Clear();
 1365        }
 1366
 01367        return pushedPrices;
 01368    }
 1369
 01370    private sealed record BatchArticleSnapshot(
 01371        decimal? C0,
 01372        decimal? C1,
 01373        IReadOnlyDictionary<string, int> Quantities,
 01374        string? Note = null,
 01375        string? Manufacturer = null);
 1376
 01377    private sealed record SnapshotRow(
 01378        string Manufacturer,
 01379        decimal? C0,
 01380        decimal? C1,
 01381        int M1,
 01382        int M2,
 01383        int M3,
 01384        int B1,
 01385        int B2);
 1386
 1387    private async Task<BatchArticleSnapshot> GetArticleSnapshotAsync(
 1388        ApplicationDbContext db, string offerId, CancellationToken ct)
 1389    {
 01390        var article = BitrixStockOrphanOfferMatching.ExtractArticle(offerId);
 01391        if (string.IsNullOrWhiteSpace(article))
 01392            return new BatchArticleSnapshot(null, null, EmptyQuantities(), "Остаток → 0: пустой артикул Ozon.");
 1393
 1394        try
 1395        {
 1396            // GetDbConnection() возвращает соединение, которым владеет EF-контекст.
 1397            // Нельзя оборачивать в using — это закроет соединение и сломает дальнейшие операции db.
 01398            var conn = db.Database.GetDbConnection();
 01399            if (conn.State != System.Data.ConnectionState.Open)
 01400                await conn.OpenAsync(ct);
 01401            using var cmd = conn.CreateCommand();
 1402            // Сначала ищем точное совпадение; если не нашли — расширяем: Article начинается с «article=»
 1403            // Это нужно, когда снэпшот хранит «9018906140=TYT» или «9018906140=TYT[50]»,
 1404            // а мы ищем по базовому «9018906140» (после ExtractArticle).
 01405            cmd.CommandText =
 01406                "SELECT Manufacturer, C0, C1ExistMinus5Percent, M1Qty, M2Qty, M3Qty, B1Qty, B2Qty " +
 01407                "FROM WarehouseStockSnapshot " +
 01408                "WHERE Article = @article COLLATE utf8mb4_unicode_ci " +
 01409                "   OR Article LIKE @articlePrefix COLLATE utf8mb4_unicode_ci";
 01410            var pArticle = cmd.CreateParameter();
 01411            pArticle.ParameterName = "@article";
 01412            pArticle.Value = article;
 01413            cmd.Parameters.Add(pArticle);
 01414            var pPrefix = cmd.CreateParameter();
 01415            pPrefix.ParameterName = "@articlePrefix";
 1416            // Экранируем спецсимволы LIKE-паттерна в самом артикуле
 01417            pPrefix.Value = article.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_") + "=%";
 01418            cmd.Parameters.Add(pPrefix);
 01419            await using var reader = await cmd.ExecuteReaderAsync(ct);
 01420            var rows = new List<SnapshotRow>();
 01421            while (await reader.ReadAsync(ct))
 1422            {
 01423                rows.Add(new SnapshotRow(
 01424                    reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
 01425                    reader.IsDBNull(1) ? null : Convert.ToDecimal(reader.GetValue(1)),
 01426                    reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2)),
 01427                    ReadQty(reader, 3), ReadQty(reader, 4), ReadQty(reader, 5),
 01428                    ReadQty(reader, 6), ReadQty(reader, 7)));
 1429            }
 1430
 01431            var manufacturerNames = rows.Select(r => r.Manufacturer)
 01432                .Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 01433            var prefixes = await _etProducerRepository.GetMarketPrefixesByNamesAsync(manufacturerNames);
 01434            var match = BitrixStockWarehouseSnapshotMatching.Match(
 01435                offerId,
 01436                rows.Select(r => new BitrixStockWarehouseSnapshotMatching.SnapshotRow<SnapshotRow>(
 01437                    r.Manufacturer, prefixes.GetValueOrDefault(r.Manufacturer), r)));
 01438            if (match.IsMatch)
 1439            {
 01440                var value = match.Value!;
 01441                return new BatchArticleSnapshot(value.C0, value.C1, new Dictionary<string, int>(StringComparer.OrdinalIg
 01442                {
 01443                    ["M1"] = value.M1, ["M2"] = value.M2, ["M3"] = value.M3,
 01444                    ["B1"] = value.B1, ["B2"] = value.B2
 01445                }, Manufacturer: value.Manufacturer);
 1446            }
 01447            return new BatchArticleSnapshot(null, null, EmptyQuantities(), match.Note);
 01448        }
 01449        catch { return new BatchArticleSnapshot(null, null, EmptyQuantities()); }
 01450    }
 1451
 1452    private static IReadOnlyDictionary<string, int> EmptyQuantities() =>
 01453        new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
 1454
 1455    private static int ReadQty(System.Data.Common.DbDataReader reader, int ordinal) =>
 01456        reader.IsDBNull(ordinal) ? 0 : Math.Max(0, Convert.ToInt32(reader.GetValue(ordinal)));
 1457
 1458    private static async Task SaveReportFileAsync(
 1459        ApplicationDbContext db,
 1460        string reportId,
 1461        int clientId,
 1462        string fileUrl,
 1463        string reportCode,
 1464        List<OzonDomains.Models.WarehouseMapping> mappings,
 1465        List<long> queriedWarehouseIds,
 1466        int rowsTotal,
 1467        int rowsWithStock,
 1468        int orphansFound,
 1469        int orphansZeroed,
 1470        CancellationToken ct)
 1471    {
 1472        // Этап 5 скачивает ОДИН xlsx на клиента (покрывает все запрошенные склады).
 1473        // Сохраняем ровно одну запись на файл, а не по строке на каждый склад-маппинг.
 01474        var queriedMappings = mappings
 01475            .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out var wid) && queriedWarehouseIds.Contains(wid))
 01476            .DistinctBy(m => m.OzonWarehouseId)
 01477            .ToList();
 1478
 1479        // Список складов в файле — для колонки «Склад» в UI (напр. "M2, M3, M2S, B12")
 01480        var warehouseLabel = queriedMappings.Count > 0
 01481            ? string.Join(", ", queriedMappings.Select(m => m.OzonWarehouseName))
 01482            : "—";
 01483        if (warehouseLabel.Length > 120) warehouseLabel = warehouseLabel[..120];
 1484
 01485        var firstWarehouseId = queriedMappings.FirstOrDefault()?.OzonWarehouseId
 01486            ?? queriedWarehouseIds.FirstOrDefault().ToString();
 1487
 1488        const string insertSql =
 1489            $"INSERT INTO {OzonFilesTable} " +
 1490            "(ReportId,OzonClientId,OzonWarehouseId,OzonWarehouseName,BitrixWarehouseName," +
 1491            "OzonReportCode,FileUrl,RowsTotal,RowsWithStock,OrphansFound,OrphansZeroed,CreatedAtUtc) " +
 1492            "VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})";
 01493        var insertParams = EfSqlRawParameterGuard.Materialize(
 01494            reportId, clientId, firstWarehouseId, warehouseLabel, warehouseLabel,
 01495            reportCode, fileUrl, rowsTotal, rowsWithStock, orphansFound, orphansZeroed,
 01496            DateTime.UtcNow);
 01497        EfSqlRawParameterGuard.Validate(insertSql, insertParams, "orphan.save_report_file");
 01498        await db.Database.ExecuteSqlRawAsync(insertSql, insertParams, ct);
 01499    }
 1500
 1501    private static async Task<List<OzonReportFileDto>> LoadReportFilesAsync(
 1502        ApplicationDbContext db,
 1503        string reportId,
 1504        CancellationToken ct)
 1505    {
 1506        // Проверяем, существует ли таблица
 1507        try
 1508        {
 01509            return await db.Database.SqlQueryRaw<OzonReportFileDto>(
 01510                $"SELECT Id,ReportId,OzonClientId,OzonWarehouseId,OzonWarehouseName,BitrixWarehouseName," +
 01511                $"OzonReportCode,FileUrl,RowsTotal,RowsWithStock,OrphansFound,OrphansZeroed,CreatedAtUtc " +
 01512                $"FROM {OzonFilesTable} WHERE ReportId = {{0}} ORDER BY CreatedAtUtc DESC",
 01513                reportId).ToListAsync(ct);
 1514        }
 01515        catch { return new List<OzonReportFileDto>(); }
 01516    }
 1517
 1518    private static async Task<List<OzonOrphanPreviewLine>> LoadOrphanLinesAsync(
 1519        ApplicationDbContext db,
 1520        string reportId,
 1521        CancellationToken ct)
 1522    {
 1523        try
 1524        {
 01525            return await db.Database.SqlQueryRaw<OzonOrphanPreviewLine>(
 01526                "SELECT OfferId, OzonWarehouseName, OneCStore AS BitrixWarehouseName, " +
 01527                "OzonQty AS AvailableQty, OzonOrphanStatus " +
 01528                "FROM BitrixStockSyncReportLines " +
 01529                "WHERE ReportId = {0} AND IsOzonOrphan = 1 ORDER BY OneCStore, OfferId",
 01530                reportId).ToListAsync(ct);
 1531        }
 01532        catch { return new List<OzonOrphanPreviewLine>(); }
 01533    }
 1534
 1535    private static async Task<Dictionary<string, string>> LoadManufacturersAsync(
 1536        ApplicationDbContext db, CancellationToken ct)
 1537    {
 1538        try
 1539        {
 01540            var rows = await db.Database.SqlQueryRaw<ManufacturerRow>(
 01541                "SELECT Code, Name FROM Manufacturers WHERE Code IS NOT NULL AND Name IS NOT NULL"
 01542            ).ToListAsync(ct);
 01543            return rows.ToDictionary(r => r.Code, r => r.Name, StringComparer.OrdinalIgnoreCase);
 1544        }
 01545        catch { return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); }
 01546    }
 1547
 1548    private static async Task SaveOzonFileBytesAsync(byte[] bytes, string reportId, string reportCode,
 1549        CancellationToken ct)
 1550    {
 1551        try
 1552        {
 01553            var dir = Path.Combine(OzonFilesStorageRoot, reportId);
 01554            Directory.CreateDirectory(dir);
 01555            var path = Path.Combine(dir, $"{reportCode}.xlsx");
 01556            await File.WriteAllBytesAsync(path, bytes, ct);
 01557        }
 01558        catch { /* fire-and-forget: ошибка сохранения не останавливает этап */ }
 01559    }
 1560
 1561    // ─── Вспомогательные типы ─────────────────────────────────────────────────
 1562
 1563    private static Dictionary<long, string> BuildWarehouseDisplayByOzonId(
 1564        IEnumerable<OzonDomains.Models.WarehouseMapping> mappings)
 1565    {
 01566        var byOzonId = mappings
 01567            .Where(m => m.OzonWarehouseId != null)
 01568            .GroupBy(m => m.OzonWarehouseId!.Trim(), StringComparer.OrdinalIgnoreCase)
 01569            .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
 1570
 01571        var result = new Dictionary<long, string>();
 01572        foreach (var (ozonId, mapping) in byOzonId)
 1573        {
 01574            if (!long.TryParse(ozonId, out var wid))
 1575                continue;
 01576            var ozonName = mapping.OzonWarehouseName ?? ozonId;
 01577            var label = string.IsNullOrWhiteSpace(mapping.BitrixWarehouseName)
 01578                ? ozonName
 01579                : $"{ozonName} (1С:{mapping.BitrixWarehouseName})";
 01580            result[wid] = label;
 1581        }
 1582
 01583        return result;
 1584    }
 1585
 1586    /// <summary>Позиция-упаковка [N]: базовый артикул найден в WarehouseStockSnapshot, не является сиротой.</summary>
 01587    private sealed record BatchVariantItem(
 01588        OzonCatalogStockRow Row,
 01589        int          Multiplier,
 01590        string       BaseOfferId,
 01591        decimal?     C0,
 01592        decimal?     C1,
 01593        int          TotalQty,
 01594        string?      SnapshotNote = null)
 1595    {
 1596        /// <summary>Производитель из снимка таблицы — нужен, чтобы найти карточку в 1С при сверке остатка.</summary>
 01597        public string? Manufacturer { get; init; }
 1598
 1599        /// <summary>Остаток базового артикула по снимку таблицы до вычета резерва и сверки с 1С.</summary>
 01600        public int RawQty { get; init; }
 1601
 1602        /// <summary>Резерв заказов по базовому артикулу на складах партии, вычтенный из <see cref="TotalQty"/>.</summar
 01603        public int ReservedQty { get; init; }
 1604
 1605        /// <summary>Свободно по 1С онлайн, если оно оказалось меньше остатка снимка за вычетом резерва.</summary>
 01606        public int? LiveClampedTo { get; init; }
 1607
 1608        /// <summary>Целевой остаток в упаковках: floor(TotalQty / Multiplier); TotalQty — свободный остаток (без резерв
 01609        public int BatchStock => TotalQty > 0 ? Math.Max(0, TotalQty / Multiplier) : 0;
 1610    }
 1611
 1612    private sealed class ManufacturerRow
 1613    {
 01614        public string Code { get; set; } = string.Empty;
 01615        public string Name { get; set; } = string.Empty;
 1616    }
 1617
 1618    internal sealed class ReportMeta
 1619    {
 11620        public int OzonClientId { get; set; }
 01621        public string? MultiClientIdsJson { get; set; }
 1622    }
 1623
 1624    private sealed class OfferWarehouseArticleKey
 1625    {
 01626        public string OfferId { get; set; } = string.Empty;
 01627        public string WarehouseId { get; set; } = string.Empty;
 01628        public string? Article { get; set; }
 1629    }
 1630
 1631    private sealed class StringValue
 1632    {
 01633        public string Value { get; set; } = string.Empty;
 1634    }
 1635}

Methods/Properties

.ctor(System.Collections.Generic.List`1<Services.BitrixStockServices.OzonCatalogStockRow>,System.Collections.Generic.IReadOnlyList`1<Services.BitrixStockServices.OzonProductCatalogRow>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,Services.BitrixStockServices.OzonProductCatalogRow>)
get_BatchRows()
get_BatchCatalog()
get_CatalogByOfferId()
.ctor(System.Collections.Generic.List`1<Services.BitrixStockServices.OzonCatalogStockRow>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,Services.BitrixStockServices.OzonProductCatalogRow>,System.Int32,System.Int32)
get_Rows()
get_CatalogByOfferId()
get_NonBatchCatalogTotal()
get_EnrichApiCalls()
LoadBatchCatalogPhaseAsync()
LoadOrphanStockPhaseAsync()
FlushBufferAsync()
BuildBatchVariantItemsAsync()
ApplyFreeStockAsync()
ProcessBatchVariantsAsync()
ParseOfferIdParts(System.String)
CreateSyntheticCatalogRow(Services.BitrixStockServices.OzonProductCatalogRow,System.Int64,System.Collections.Generic.IReadOnlyDictionary`2<System.Int64,System.String>)
.cctor()
get_Updated()
OrphanZeroKey(System.String,System.String)
.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.BitrixStockActiveStageStore,Services.BitrixStockServices.IOzonProductsCatalogService,Services.BitrixStockServices.IOzonCatalogStockEnricher,OzonRepositories.Data.EtProducerRepository,Services.BitrixStockServices.IBitrixStockLiveStockGuard,Microsoft.Extensions.Logging.ILogger`1<Services.BitrixStockServices.BitrixStockOzonOrphanCleanupService>)
GetOrphanPreviewAsync()
GetReportFilesAsync()
RunOrphanCleanupAsync()
Log()
Progress()
Complete()
ParseWarehouseStockXlsx(System.Byte[])
InsertOrphanLinesAsync()
InsertBatchVariantLinesAsync()
InsertMissingBatchVariantLinesAsync()
PushBatchVariantPricesAsync()
.ctor(System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Int32>,System.String,System.String)
get_C0()
get_C1()
get_Quantities()
get_Note()
get_Manufacturer()
.ctor(System.String,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Int32,System.Int32,System.Int32,System.Int32,System.Int32)
get_Manufacturer()
get_C0()
get_C1()
get_M1()
get_M2()
get_M3()
get_B1()
get_B2()
GetArticleSnapshotAsync()
EmptyQuantities()
ReadQty(System.Data.Common.DbDataReader,System.Int32)
SaveReportFileAsync()
LoadReportFilesAsync()
LoadOrphanLinesAsync()
LoadManufacturersAsync()
SaveOzonFileBytesAsync()
BuildWarehouseDisplayByOzonId(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.WarehouseMapping>)
.ctor(Services.BitrixStockServices.OzonCatalogStockRow,System.Int32,System.String,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Int32,System.String)
get_Row()
get_Multiplier()
get_BaseOfferId()
get_C0()
get_C1()
get_TotalQty()
get_SnapshotNote()
get_Manufacturer()
get_RawQty()
get_ReservedQty()
get_LiveClampedTo()
get_BatchStock()
get_Code()
get_Name()
get_OzonClientId()
get_MultiClientIdsJson()
get_OfferId()
get_WarehouseId()
get_Article()
get_Value()