< Summary

Information
Class: Services.BitrixStockServices.BitrixStockSyncReportPricePushService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncReportPricePushService.cs
Line coverage
7%
Covered lines: 32
Uncovered lines: 425
Coverable lines: 457
Total lines: 737
Line coverage: 7%
Branch coverage
9%
Covered branches: 27
Total branches: 276
Branch coverage: 9.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
PushPricesAsync()0%340401840%
Log()0%620%
Progress()0%4260%
Complete()0%620%
<PushPricesAsync()100%210%
UpdatePriceIndexesAsync()0%600240%
Log()0%620%
Progress()0%620%
Complete()0%620%
.cctor()100%11100%
ParseAvailableVat(...)100%1010100%
ComputeOldPrice(...)100%44100%
EnrichOzonPriceItemInline(...)59.09%2222100%
.ctor(...)100%210%
get_Article()100%210%
get_OfferId()100%210%
get_ProductId()100%210%
get_Vat()100%210%
get_TruePrice()100%210%
get_WinnerPrice()100%210%
get_OldPrice()100%210%
get_MarkupPct()100%210%
get_PMin()100%210%
get_ClientId()100%210%
get_Multiplier()100%210%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using Hangfire;
 4using Microsoft.AspNetCore.SignalR;
 5using Newtonsoft.Json;
 6using Newtonsoft.Json.Linq;
 7using OzonDomains;
 8using OzonDomains.Models;
 9using Services.ApiServices.OzonApi;
 10using Services.DataServices;
 11using Services.HangfireService;
 12using Services.SignalRServices;
 13using SlqStudio.Application.Services.EmailService;
 14
 15namespace Services.BitrixStockServices;
 16
 17public class BitrixStockSyncReportPricePushService : IBitrixStockSyncReportPricePushService
 18{
 19    private readonly OzonClientServices _ozonClientServices;
 20    private readonly IHttpClientFactory _httpClientFactory;
 21    private readonly IEmailService? _emailService;
 22    private readonly IHubContext<JobProgressHub> _jobProgressHub;
 23    private readonly IBitrixStockSyncReportService _reportService;
 24    private readonly BitrixStockActiveStageStore _stageStore;
 25    private readonly IBitrixStockExceptionService _exceptions;
 26    private readonly IBackgroundJobClient _backgroundJobClient;
 27
 28    private const int PriceBatchSize    = 1000;
 29    private const int SnapshotBatchSize = 500;
 30
 031    public BitrixStockSyncReportPricePushService(
 032        OzonClientServices ozonClientServices,
 033        IHttpClientFactory httpClientFactory,
 034        IEmailService? emailService,
 035        IHubContext<JobProgressHub> jobProgressHub,
 036        IBitrixStockSyncReportService reportService,
 037        BitrixStockActiveStageStore stageStore,
 038        IBitrixStockExceptionService exceptions,
 039        IBackgroundJobClient backgroundJobClient)
 40    {
 041        _ozonClientServices = ozonClientServices;
 042        _httpClientFactory  = httpClientFactory;
 043        _emailService       = emailService;
 044        _jobProgressHub     = jobProgressHub;
 045        _reportService      = reportService;
 046        _stageStore         = stageStore;
 047        _exceptions         = exceptions;
 048        _backgroundJobClient = backgroundJobClient;
 049    }
 50
 51    [Queue(HangfireQueueNames.Main)]
 52    public async Task PushPricesAsync(
 53        string reportId,
 54        BitrixStockSyncReportPricePushStartRequest request,
 55        string? progressJobId,
 56        CancellationToken cancellationToken = default)
 57    {
 058        var hubId  = progressJobId?.Trim() ?? string.Empty;
 059        var hasHub = !string.IsNullOrWhiteSpace(hubId);
 060        var lastProgressSentAt = DateTime.MinValue;
 61
 62        async Task Log(string message)
 63        {
 064            if (hasHub)
 065                await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken);
 066        }
 67
 68        async Task Progress(int current, int total, string text)
 69        {
 070            if (!hasHub) return;
 071            var now = DateTime.UtcNow;
 072            if (current < total && (now - lastProgressSentAt).TotalMilliseconds < 250) return;
 073            lastProgressSentAt = now;
 074            await _jobProgressHub.Clients.Group(hubId)
 075                .SendAsync("Progress", current, total, text, cancellationToken);
 076        }
 77
 78        async Task Complete(bool ok, object? payload)
 79        {
 080            if (hasHub)
 081                await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken);
 082        }
 83
 84        // В режиме цепочки stage-store и SignalR-задержкой управляет оркестратор.
 085        if (!request.Chained)
 86        {
 087            _stageStore.Set(reportId, 4);
 88            // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа)
 089            if (hasHub) await Task.Delay(800, cancellationToken);
 90        }
 091        var pushedCount  = 0;
 092        var updatedCount = 0;
 93
 94        try
 95        {
 96            // ── Загружаем исходные данные ──────────────────────────────────────
 097            var snapshots = await _reportService.GetOzonPriceSnapshotsAsync(reportId, null, cancellationToken);
 098            if (snapshots.Count == 0)
 99            {
 0100                await Log("Нет снимков цен для этого отчёта.");
 0101                await Complete(false, "Нет снимков цен");
 0102                if (request.Chained)
 0103                    throw new InvalidOperationException("Нет снимков цен для этого отчёта");
 0104                return;
 105            }
 106
 0107            var promos   = await _reportService.GetReportPromosAsync(reportId, cancellationToken);
 0108            var settings = await _reportService.GetReportPriceSettingsAsync(reportId, cancellationToken);
 109
 0110            var promoByArticle = promos
 0111                .GroupBy(p => p.Article, StringComparer.OrdinalIgnoreCase)
 0112                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
 113
 0114            decimal noPromoMarkup = settings?.NoPromoMarkupPercent ?? 5m;
 0115            decimal promoMarkup   = settings?.PromoMarkupPercent   ?? 10m;
 116
 0117            var allClients = await _ozonClientServices.GetOzonClients();
 0118            var clientMap  = allClients
 0119                .Where(c => c.ClientType == ClientType.OZON
 0120                         && !string.IsNullOrEmpty(c.DecryptClientId)
 0121                         && !string.IsNullOrEmpty(c.DecryptApiKey))
 0122                .ToDictionary(c => c.Id);
 123
 0124            var c1Cache = new Dictionary<string, (decimal? c0, decimal? c1)>(StringComparer.OrdinalIgnoreCase);
 125
 0126            var toProcess = request.OnlyPending
 0127                ? snapshots.Where(s => !s.PricePushedNew.HasValue).ToList()
 0128                : snapshots;
 129
 0130            if (request.MaxArticles.HasValue && request.MaxArticles.Value > 0)
 0131                toProcess = toProcess.Take(request.MaxArticles.Value).ToList();
 132
 0133            var total = toProcess.Count;
 0134            await Log($"Фаза 4a — расчёт цен: {total} снимков.");
 135
 136            // ── Фаза 4a: расчёт цен ────────────────────────────────────────────
 0137            var pendingByClient = new Dictionary<int, List<PricePendingEntry>>();
 0138            var skippedCount   = 0;
 0139            var unchangedCount = 0;
 0140            var unchangedItems = new List<(string Article, int OzonClientId)>();
 141
 0142            var skipPriceArticles = await _exceptions.GetSkipPriceArticlesSetAsync(cancellationToken);
 0143            var priceExceptionsMap = await _exceptions.GetClientPricesMapAsync(cancellationToken);
 0144            if (skipPriceArticles.Count > 0 || priceExceptionsMap.Count > 0)
 0145                await Log($"Справочник исключений цен: {skipPriceArticles.Count} арт. пропуск, {priceExceptionsMap.Count
 146
 0147            for (var i = 0; i < toProcess.Count; i++)
 148            {
 0149                cancellationToken.ThrowIfCancellationRequested();
 0150                var snap = toProcess[i];
 151
 152                // Пропуск цен — только явные исключения (глобальный SkipPrice или правило кабинета skip).
 153                // Пропуск остатков (глобальный SkipStock / правила остатков) на цены не влияет.
 0154                if (skipPriceArticles.Contains(snap.Article))
 155                {
 0156                    await Log($"[{snap.Article}] Пропуск цены (глобальный справочник, SkipPrice).");
 0157                    skippedCount++;
 0158                    goto nextSnap;
 159                }
 160
 0161                priceExceptionsMap.TryGetValue((snap.Article.ToLowerInvariant(), snap.OzonClientId), out var priceRule);
 0162                if (priceRule?.PriceMode == BitrixStockPriceMode.Skip)
 163                {
 0164                    await Log($"[{snap.Article}] Пропуск цены (правило кабинет #{snap.OzonClientId}).");
 0165                    skippedCount++;
 0166                    goto nextSnap;
 167                }
 168
 0169                if (!clientMap.ContainsKey(snap.OzonClientId))
 170                {
 0171                    await Log($"[{snap.Article}] Клиент Ozon #{snap.OzonClientId} не найден, пропуск.");
 0172                    skippedCount++;
 0173                    goto nextSnap;
 174                }
 175
 176                try
 177                {
 0178                    if (!c1Cache.TryGetValue(snap.Article, out var c1Pair))
 179                    {
 0180                        c1Pair = await _reportService.GetArticleC1PriceAsync(snap.Article, cancellationToken);
 0181                        c1Cache[snap.Article] = c1Pair;
 182                    }
 183
 184                    JObject priceJson;
 0185                    try { priceJson = JObject.Parse(snap.PriceDataJson); }
 186                    catch
 187                    {
 0188                        await Log($"[{snap.Article}] Не удалось разобрать PriceDataJson, пропуск.");
 0189                        skippedCount++;
 0190                        goto nextSnap;
 191                    }
 192
 0193                    var promoEntry = promoByArticle.TryGetValue(snap.Article, out var pe) ? pe : null;
 0194                    var result = PriceOptimizationManager.Calculate(new PriceOptimizationInput
 0195                    {
 0196                        C0                   = c1Pair.c0,
 0197                        C1                   = c1Pair.c1,
 0198                        PriceJson            = priceJson,
 0199                        InPromo              = promoEntry?.InPromo ?? false,
 0200                        NoPromoMarkupPercent = noPromoMarkup,
 0201                        PromoMarkupPercent   = promoMarkup
 0202                    });
 203
 0204                    if (result.WinnerPrice == null)
 205                    {
 0206                        skippedCount++;
 0207                        goto nextSnap;
 208                    }
 209
 0210                    var winner = result.WinnerPrice.Value;
 211
 212                    // Подстановка фиксированной цены из справочника исключений
 0213                    decimal? fixedOldPriceOverride = null;
 0214                    if (priceRule?.PriceMode == BitrixStockPriceMode.Fixed && priceRule.FixedPrice.HasValue)
 215                    {
 0216                        await Log($"[{snap.Article}] Фиксированная цена {priceRule.FixedPrice} (кабинет #{snap.OzonClien
 0217                        winner = priceRule.FixedPrice.Value;
 0218                        fixedOldPriceOverride = priceRule.FixedOldPrice;
 219                    }
 220
 221                    // Умножение на размер партии [N] в OfferId (например TYT[5] → цена × 5)
 0222                    var offerId    = priceJson["offer_id"]?.Value<string>() ?? snap.OfferId;
 0223                    var multiplier = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(offerId);
 0224                    if (multiplier > 1)
 225                    {
 0226                        await Log($"[{snap.Article}] Партия ×{multiplier} ({offerId}): {winner:F0} × {multiplier} = {win
 0227                        winner *= multiplier;
 0228                        if (fixedOldPriceOverride.HasValue)
 0229                            fixedOldPriceOverride = fixedOldPriceOverride.Value * multiplier;
 230                    }
 231
 232                    // Пропускаем если цена не изменилась (±1 ₽)
 0233                    var currentSellerPrice = priceJson["price"]?["marketing_seller_price"]?.Value<decimal?>() ?? 0m;
 0234                    if (currentSellerPrice > 0 && Math.Abs(currentSellerPrice - winner) < 1m)
 235                    {
 0236                        unchangedCount++;
 0237                        unchangedItems.Add((snap.Article, snap.OzonClientId));
 0238                        goto nextSnap;
 239                    }
 240
 0241                    var productId = priceJson["product_id"]?.Value<long?>();
 0242                    var vat       = priceJson["price"]?["vat"]?.Value<string>() ?? "0";
 0243                    var truePrice = priceJson["price"]?["price"]?.Value<decimal?>()
 0244                                 ?? priceJson["price"]?["customer_price"]?.Value<decimal?>()
 0245                                 ?? 0m;
 246
 0247                    if (productId == null)
 248                    {
 0249                        await Log($"[{snap.Article}] product_id отсутствует в снимке, пропуск.");
 0250                        skippedCount++;
 0251                        goto nextSnap;
 252                    }
 253
 0254                    var oldPrice = fixedOldPriceOverride ?? ComputeOldPrice(winner);
 255
 0256                    if (!pendingByClient.TryGetValue(snap.OzonClientId, out var clientList))
 257                    {
 0258                        clientList = new List<PricePendingEntry>();
 0259                        pendingByClient[snap.OzonClientId] = clientList;
 260                    }
 0261                    clientList.Add(new PricePendingEntry(
 0262                        Article:     snap.Article,
 0263                        OfferId:     offerId,
 0264                        ProductId:   productId.Value,
 0265                        Vat:         vat,
 0266                        TruePrice:   truePrice,
 0267                        WinnerPrice: winner,
 0268                        OldPrice:    oldPrice,
 0269                        MarkupPct:   result.MarkupPercentUsed,
 0270                        PMin:        result.PMin,
 0271                        ClientId:    snap.OzonClientId,
 0272                        Multiplier:  multiplier));
 0273                }
 0274                catch (Exception ex)
 275                {
 0276                    await Log($"[{snap.Article}] Ошибка расчёта: {ex}");
 0277                    skippedCount++;
 278                }
 279
 280                nextSnap:
 0281                if ((i + 1) % 100 == 0 || i + 1 == total)
 0282                    await Progress(i + 1, total, $"Расчёт {i + 1}/{total}");
 0283            }
 284
 285            // Запись Unchanged — батч параллельных await (не fire-and-forget)
 0286            if (unchangedItems.Count > 0)
 287            {
 0288                using var sem = new SemaphoreSlim(8, 8);
 0289                await Task.WhenAll(unchangedItems.Select(async x =>
 0290                {
 0291                    await sem.WaitAsync(CancellationToken.None);
 0292                    try { await _reportService.RecordPricePushStatusAsync(reportId, x.Article, x.OzonClientId, "Unchange
 0293                    catch { /* некритично */ }
 0294                    finally { sem.Release(); }
 0295                }));
 0296            }
 297
 0298            var pendingTotal = pendingByClient.Values.Sum(l => l.Count);
 0299            await Log($"Расчёт завершён. К накату: {pendingTotal}, без изменений: {unchangedCount}, пропущено: {skippedC
 300
 0301            if (pendingTotal == 0)
 302            {
 0303                await Complete(true, new { pushedCount = 0, updatedCount = 0, unchangedCount });
 0304                return;
 305            }
 306
 307            // ── Накат батчами по клиентам ──────────────────────────────────────
 308            // Создаём менеджеры один раз — используются и в 4a, и в 4b
 0309            var mgrByClient = pendingByClient.Keys
 0310                .Where(clientMap.ContainsKey)
 0311                .ToDictionary(id => id,
 0312                    id => BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, clientMap[id]));
 313
 0314            var successList = new List<(string Article, string OfferId, int ClientId)>();
 315
 0316            foreach (var (clientId, entries) in pendingByClient)
 317            {
 0318                if (!mgrByClient.TryGetValue(clientId, out var mgr)) continue;
 319
 0320                var batches = entries.Chunk(PriceBatchSize).ToList();
 0321                for (var b = 0; b < batches.Count; b++)
 322                {
 0323                    cancellationToken.ThrowIfCancellationRequested();
 0324                    var batch      = batches[b];
 0325                    var priceItems = batch.Select(e => new ProductPriceItem
 0326                    {
 0327                        OfferId   = e.OfferId,
 0328                        ProductId = e.ProductId,
 0329                        Price     = ((long)e.WinnerPrice).ToString(CultureInfo.InvariantCulture),
 0330                        OldPrice  = ((long)e.OldPrice).ToString(CultureInfo.InvariantCulture),
 0331                        MinPrice  = ((long)e.WinnerPrice).ToString(CultureInfo.InvariantCulture),
 0332                        NetPrice  = "0",
 0333                        Vat       = e.Vat,
 0334                    }).ToList();
 335
 0336                    await Log($"[Клиент {clientId}] Батч {b + 1}/{batches.Count}: отправка {priceItems.Count} цен…");
 337                    try
 338                    {
 0339                        var batchResult = await mgr.ImportProductPrices(priceItems);
 340
 0341                        var errorOfferIds       = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0342                        var errorProductIds     = new HashSet<long>();
 0343                        var errorMsgByOfferId   = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 0344                        var errorMsgByProductId = new Dictionary<long, string>();
 0345                        var vatFixByOfferId     = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 0346                        var vatFixByProductId   = new Dictionary<long, string>();
 0347                        foreach (var r in batchResult.Result ?? new())
 348                        {
 0349                            if (r.Errors is not { Count: > 0 }) continue;
 0350                            var errMsg = string.Join("; ", r.Errors.Select(err => err.Message));
 0351                            var label  = !string.IsNullOrEmpty(r.OfferId)
 0352                                ? r.OfferId
 0353                                : r.ProductId > 0 ? r.ProductId.ToString() : "?";
 354
 0355                            var fixedVat = ParseAvailableVat(errMsg);
 0356                            if (fixedVat != null)
 357                            {
 0358                                if (!string.IsNullOrEmpty(r.OfferId)) vatFixByOfferId[r.OfferId]   = fixedVat;
 0359                                if (r.ProductId > 0)                  vatFixByProductId[r.ProductId] = fixedVat;
 360                            }
 361                            else
 362                            {
 0363                                await Log($"[{label}] Ошибка: {errMsg}");
 0364                                if (!string.IsNullOrEmpty(r.OfferId)) { errorOfferIds.Add(r.OfferId);   errorMsgByOfferI
 0365                                if (r.ProductId > 0)                  { errorProductIds.Add(r.ProductId); errorMsgByProd
 366                            }
 0367                        }
 368
 369                        // Ретрай для VAT-ошибок с исправленной ставкой
 0370                        if (vatFixByOfferId.Count + vatFixByProductId.Count > 0)
 371                        {
 0372                            var vatRetry = batch
 0373                                .Where(e => vatFixByOfferId.ContainsKey(e.OfferId) || vatFixByProductId.ContainsKey(e.Pr
 0374                                .Select(e =>
 0375                                {
 0376                                    var fixedVat = vatFixByOfferId.TryGetValue(e.OfferId, out var v1) ? v1
 0377                                                 : vatFixByProductId.TryGetValue(e.ProductId, out var v2) ? v2
 0378                                                 : e.Vat;
 0379                                    return (Entry: e, FixedVat: fixedVat);
 0380                                }).ToList();
 381
 0382                            await Log($"[Клиент {clientId}] Ретрай НДС: {vatRetry.Count} товаров…");
 0383                            var retryItems = vatRetry.Select(x => new ProductPriceItem
 0384                            {
 0385                                OfferId   = x.Entry.OfferId,
 0386                                ProductId = x.Entry.ProductId,
 0387                                Price     = ((long)x.Entry.WinnerPrice).ToString(CultureInfo.InvariantCulture),
 0388                                OldPrice  = ((long)x.Entry.OldPrice).ToString(CultureInfo.InvariantCulture),
 0389                                MinPrice  = ((long)x.Entry.WinnerPrice).ToString(CultureInfo.InvariantCulture),
 0390                                NetPrice  = "0",
 0391                                Vat       = x.FixedVat,
 0392                            }).ToList();
 393
 0394                            var retryResult = await mgr.ImportProductPrices(retryItems);
 0395                            foreach (var r in retryResult.Result ?? new())
 396                            {
 0397                                if (r.Errors is not { Count: > 0 }) continue;
 0398                                var errMsg = string.Join("; ", r.Errors.Select(err => err.Message));
 0399                                var label  = !string.IsNullOrEmpty(r.OfferId) ? r.OfferId : r.ProductId.ToString();
 0400                                await Log($"[{label}] Ошибка (ретрай НДС): {errMsg}");
 0401                                if (!string.IsNullOrEmpty(r.OfferId)) { errorOfferIds.Add(r.OfferId);   errorMsgByOfferI
 0402                                if (r.ProductId > 0)                  { errorProductIds.Add(r.ProductId); errorMsgByProd
 0403                            }
 0404                        }
 405
 406                        // Записываем результат каждого артикула в батче
 0407                        var batchPushed = 0;
 0408                        foreach (var e in batch)
 409                        {
 0410                            if (errorOfferIds.Contains(e.OfferId) || errorProductIds.Contains(e.ProductId))
 411                            {
 0412                                var errForArticle = errorMsgByOfferId.TryGetValue(e.OfferId, out var m1) ? m1
 0413                                    : errorMsgByProductId.TryGetValue(e.ProductId, out var m2) ? m2
 0414                                    : "Ошибка Ozon";
 415                                try
 416                                {
 0417                                    await _reportService.RecordPricePushStatusAsync(
 0418                                        reportId, e.Article, e.ClientId, "Error", errForArticle, cancellationToken);
 0419                                }
 0420                                catch (Exception dbEx) { await Log($"[{e.Article}] Запись ошибки: {dbEx.Message}"); }
 0421                                continue;
 422                            }
 423
 424                            try
 425                            {
 0426                                await _reportService.RecordPricePushAsync(
 0427                                    reportId, e.Article, e.ClientId,
 0428                                    e.TruePrice, e.WinnerPrice,
 0429                                    markupPct: e.MarkupPct, colorIndexBefore: null,
 0430                                    competitorPrice: e.PMin, cancellationToken);
 0431                            }
 0432                            catch (Exception dbEx) { await Log($"[{e.Article}] Запись наката: {dbEx.Message}"); }
 433
 0434                            successList.Add((e.Article, e.OfferId, e.ClientId));
 0435                            pushedCount++;
 0436                            batchPushed++;
 0437                        }
 438
 0439                        var errorEntryCount = batch.Count(e =>
 0440                            errorOfferIds.Contains(e.OfferId) || errorProductIds.Contains(e.ProductId));
 0441                        await Log($"[Клиент {clientId}] Батч {b + 1}: ✓ {batchPushed}/{priceItems.Count}" +
 0442                                  (errorEntryCount > 0 ? $", ошибок: {errorEntryCount}" : ""));
 0443                    }
 0444                    catch (Exception ex)
 445                    {
 0446                        await Log($"[Клиент {clientId}] Батч {b + 1} ошибка: {ex}");
 447                    }
 448
 0449                    await Progress(pushedCount, pendingTotal, $"Клиент {clientId}, батч {b + 1}/{batches.Count}");
 0450                }
 0451            }
 452
 0453            await Log($"Фаза 4a завершена. Накатано: {pushedCount} из {pendingTotal}.");
 454
 455            // ── Фаза 4b: Обновление снимков батчами ───────────────────────────
 0456            if (successList.Count > 0)
 457            {
 0458                await Log($"Фаза 4b — Обновление индексов: {successList.Count} артикулов.");
 459
 0460                var successByClient = successList
 0461                    .GroupBy(x => x.ClientId)
 0462                    .ToDictionary(g => g.Key, g => g.ToList());
 463
 0464                foreach (var (clientId, items) in successByClient)
 465                {
 0466                    if (!mgrByClient.TryGetValue(clientId, out var mgr)) continue;
 467
 0468                    var offerIds       = items.Select(x => x.OfferId).ToArray();
 0469                    var offerToArticle = items.ToDictionary(x => x.OfferId, x => x.Article, StringComparer.OrdinalIgnore
 0470                    var batches        = offerIds.Chunk(SnapshotBatchSize).ToList();
 471
 0472                    for (var b = 0; b < batches.Count; b++)
 473                    {
 0474                        cancellationToken.ThrowIfCancellationRequested();
 0475                        var batchOffers = batches[b];
 476
 477                        try
 478                        {
 0479                            var rawResponse  = await mgr.GetProductPricesByArticles(batchOffers);
 0480                            var fetchedItems = rawResponse?["items"] as JArray;
 0481                            if (fetchedItems == null)
 482                            {
 0483                                await Log($"[Клиент {clientId}] Батч {b + 1}: нет ответа Ozon.");
 0484                                continue;
 485                            }
 486
 0487                            foreach (var fetchedItem in fetchedItems.OfType<JObject>())
 488                            {
 0489                                var fetchedOfferId = fetchedItem["offer_id"]?.Value<string>();
 0490                                if (string.IsNullOrEmpty(fetchedOfferId)) continue;
 0491                                if (!offerToArticle.TryGetValue(fetchedOfferId, out var article)) continue;
 492
 0493                                EnrichOzonPriceItemInline(fetchedItem);
 0494                                var freshJson = fetchedItem.ToString(Formatting.None);
 495
 0496                                await _reportService.UpdateOzonPriceSnapshotAsync(
 0497                                    reportId, article, clientId, freshJson, cancellationToken);
 0498                                updatedCount++;
 499                            }
 500
 0501                            await Log($"[Клиент {clientId}] Батч {b + 1}/{batches.Count}: обновлено {fetchedItems.Count}
 0502                        }
 0503                        catch (Exception ex)
 504                        {
 0505                            await Log($"[Клиент {clientId}] Батч {b + 1} ошибка обновления: {ex}");
 506                        }
 507                    }
 0508                }
 509
 0510                await Log($"Фаза 4b завершена. Обновлено: {updatedCount}.");
 511            }
 512
 513            // Планируем фазу 4c: обновление индексов через 5 мин (Ozon обновляет их с задержкой).
 514            // Не критично для успеха этапа 4 — при таймауте Hangfire storage цепочка не должна падать.
 0515            string? scheduledIndexJobId = null;
 0516            if (pushedCount > 0)
 517            {
 0518                scheduledIndexJobId = reportId + "_s4c_" + Guid.NewGuid().ToString("N")[..6];
 0519                var hangfireJobId = await _backgroundJobClient.TryScheduleMainAsync<IBitrixStockSyncReportPricePushServi
 0520                    svc => svc.UpdatePriceIndexesAsync(reportId, scheduledIndexJobId, CancellationToken.None),
 0521                    TimeSpan.FromMinutes(5),
 0522                    Log,
 0523                    cancellationToken: cancellationToken);
 524
 0525                if (hangfireJobId != null)
 526                {
 0527                    await Log($"Фаза 4c запланирована через 5 мин (Hangfire {hangfireJobId}).");
 528                }
 529                else
 530                {
 0531                    scheduledIndexJobId = null;
 0532                    await Log("Фаза 4c не запланирована (таймаут Hangfire). Цены уже накатаны; индексы можно обновить вр
 533                }
 534            }
 535
 0536            await Complete(true, new { pushedCount, updatedCount, unchangedCount, scheduledIndexJobId });
 0537        }
 0538        catch (OperationCanceledException)
 539        {
 0540            await Log("Задание отменено.");
 0541            await Complete(false, "Отменено");
 0542            throw;
 0543        }
 0544        catch (Exception ex)
 545        {
 0546            await Log($"Критическая ошибка: {ex}");
 0547            await Complete(false, ex.Message);
 0548            throw;
 0549        }
 550        finally
 551        {
 0552            if (!request.Chained) _stageStore.Clear(reportId);
 553        }
 0554    }
 555
 556    [Queue(HangfireQueueNames.Main)]
 557    public async Task UpdatePriceIndexesAsync(
 558        string reportId,
 559        string? progressJobId,
 560        CancellationToken cancellationToken = default)
 561    {
 0562        var hubId  = progressJobId?.Trim() ?? string.Empty;
 0563        var hasHub = !string.IsNullOrWhiteSpace(hubId);
 564
 565        async Task Log(string message)
 566        {
 0567            if (hasHub)
 0568                await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken);
 0569        }
 570
 571        async Task Progress(int current, int total, string text)
 572        {
 0573            if (!hasHub) return;
 0574            await _jobProgressHub.Clients.Group(hubId)
 0575                .SendAsync("Progress", current, total, text, cancellationToken);
 0576        }
 577
 578        async Task Complete(bool ok, object? payload)
 579        {
 0580            if (hasHub)
 0581                await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken);
 0582        }
 583
 0584        var updatedCount = 0;
 585        try
 586        {
 0587            var allSnapshots = await _reportService.GetOzonPriceSnapshotsAsync(reportId, null, cancellationToken);
 0588            var snapshots    = allSnapshots.Where(s => s.PricePushedNew.HasValue).ToList();
 589
 0590            if (snapshots.Count == 0)
 591            {
 0592                await Log("Нет накатанных артикулов для обновления индексов.");
 0593                await Complete(true, new { updatedCount = 0 });
 0594                return;
 595            }
 596
 0597            await Log($"Фаза 4c — обновление индексов: {snapshots.Count} накатанных артикулов.");
 598
 0599            var allClients = await _ozonClientServices.GetOzonClients();
 0600            var clientMap  = allClients
 0601                .Where(c => c.ClientType == ClientType.OZON
 0602                         && !string.IsNullOrEmpty(c.DecryptClientId)
 0603                         && !string.IsNullOrEmpty(c.DecryptApiKey))
 0604                .ToDictionary(c => c.Id);
 605
 0606            var byClient = snapshots
 0607                .GroupBy(s => s.OzonClientId)
 0608                .ToDictionary(g => g.Key, g => g.ToList());
 609
 0610            var total = snapshots.Count;
 611
 0612            foreach (var (clientId, items) in byClient)
 613            {
 0614                if (!clientMap.TryGetValue(clientId, out var ozonClient)) continue;
 0615                var mgr = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, ozonClient);
 616
 0617                var offerToArticle = items
 0618                    .ToDictionary(s => s.OfferId, s => s.Article, StringComparer.OrdinalIgnoreCase);
 0619                var offerIds = items.Select(s => s.OfferId).ToArray();
 0620                var batches  = offerIds.Chunk(SnapshotBatchSize).ToList();
 621
 0622                for (var b = 0; b < batches.Count; b++)
 623                {
 0624                    cancellationToken.ThrowIfCancellationRequested();
 625                    try
 626                    {
 0627                        var rawResponse  = await mgr.GetProductPricesByArticles(batches[b]);
 0628                        var fetchedItems = rawResponse?["items"] as JArray;
 0629                        if (fetchedItems == null)
 630                        {
 0631                            await Log($"[Клиент {clientId}] Батч {b + 1}: нет ответа Ozon.");
 0632                            continue;
 633                        }
 634
 0635                        foreach (var fetchedItem in fetchedItems.OfType<JObject>())
 636                        {
 0637                            var fetchedOfferId = fetchedItem["offer_id"]?.Value<string>();
 0638                            if (string.IsNullOrEmpty(fetchedOfferId)) continue;
 0639                            if (!offerToArticle.TryGetValue(fetchedOfferId, out var article)) continue;
 640
 0641                            EnrichOzonPriceItemInline(fetchedItem);
 0642                            var freshJson = fetchedItem.ToString(Formatting.None);
 643
 0644                            await _reportService.UpdateOzonPriceSnapshotAsync(
 0645                                reportId, article, clientId, freshJson, cancellationToken);
 0646                            updatedCount++;
 647                        }
 648
 0649                        await Log($"[Клиент {clientId}] Батч {b + 1}/{batches.Count}: обновлено {fetchedItems.Count} инд
 0650                    }
 0651                    catch (Exception ex)
 652                    {
 0653                        await Log($"[Клиент {clientId}] Батч {b + 1} ошибка: {ex}");
 654                    }
 655
 0656                    await Progress(updatedCount, total, $"Клиент {clientId}, батч {b + 1}/{batches.Count}");
 657                }
 0658            }
 659
 0660            await Log($"Фаза 4c завершена. Обновлено индексов: {updatedCount}.");
 0661            await Complete(true, new { updatedCount });
 0662        }
 0663        catch (OperationCanceledException)
 664        {
 0665            await Log("Обновление индексов отменено.");
 0666            await Complete(false, "Отменено");
 0667        }
 0668        catch (Exception ex)
 669        {
 0670            await Log($"Критическая ошибка обновления индексов: {ex}");
 0671            await Complete(false, ex.Message);
 0672        }
 0673    }
 674
 1675    private static readonly Regex VatRatesRegex =
 1676        new(@"ставки\s+([\d%,\s]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 677
 678    /// <summary>Извлекает первую доступную ставку НДС из текста ошибки Ozon.
 679    /// Возвращает "0", "0.1" или "0.2", либо null если ошибка не про НДС.</summary>
 680    internal static string? ParseAvailableVat(string errorMessage)
 681    {
 11682        if (!errorMessage.Contains("НДС", StringComparison.OrdinalIgnoreCase)) return null;
 9683        var m = VatRatesRegex.Match(errorMessage);
 10684        if (!m.Success) return null;
 8685        var first = m.Groups[1].Value.Split(',')[0].Trim().TrimEnd('%').Trim();
 16686        return first switch { "0" => "0", "10" => "0.1", "20" => "0.2", _ => null };
 687    }
 688
 689    internal static decimal ComputeOldPrice(decimal yourPrice)
 690    {
 691        decimal minOld;
 16692        if (yourPrice <= 400)        minOld = yourPrice + 21m;
 7693        else if (yourPrice <= 10000) minOld = Math.Ceiling(yourPrice * 1.053m);
 1694        else                         minOld = yourPrice + 501m;
 10695        return Math.Max(Math.Round(yourPrice * 1.25m, MidpointRounding.AwayFromZero), minOld);
 696    }
 697
 698    private static void EnrichOzonPriceItemInline(JObject item)
 699    {
 3700        var commissions = item["commissions"];
 3701        var priceObj    = item["price"] as JObject;
 5702        if (commissions == null || priceObj == null) return;
 703
 1704        decimal salesPercent    = commissions["sales_percent_fbs"]?.Value<decimal>()                ?? 0;
 1705        decimal itemPrice       = priceObj["price"]?.Value<decimal>()                               ?? 0;
 1706        decimal acquiring       = item["acquiring"]?.Value<decimal>()                               ?? 0;
 1707        decimal fbsFirstMileMin = commissions["fbs_first_mile_min_amount"]?.Value<decimal>()        ?? 0;
 1708        decimal fbsFirstMileMax = commissions["fbs_first_mile_max_amount"]?.Value<decimal>()        ?? 0;
 1709        decimal fbsDirectMin    = commissions["fbs_direct_flow_trans_min_amount"]?.Value<decimal>() ?? 0;
 1710        decimal fbsDirectMax    = commissions["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0;
 1711        decimal fboDeliv        = commissions["fbo_deliv_to_customer_amount"]?.Value<decimal>()     ?? 0;
 712
 1713        decimal ozonReward    = itemPrice * salesPercent / 100;
 1714        decimal maxCommission = ozonReward + acquiring + fbsFirstMileMax + fbsDirectMax + fboDeliv;
 1715        decimal minCommission = ozonReward + acquiring + fbsFirstMileMin + fbsDirectMin + fboDeliv;
 1716        decimal marketingPrice = priceObj["marketing_seller_price"]?.Value<decimal>() ?? 0;
 1717        decimal truePrice      = marketingPrice * 0.91m;
 718
 1719        priceObj["max_commission"] = Math.Round(maxCommission, 2);
 1720        priceObj["min_commission"] = Math.Round(minCommission, 2);
 1721        priceObj["ozon_reward"]    = Math.Round(ozonReward, 2);
 1722        priceObj["true_price"]     = Math.Round(truePrice, 2);
 1723    }
 724
 0725    private record PricePendingEntry(
 0726        string  Article,
 0727        string  OfferId,
 0728        long    ProductId,
 0729        string  Vat,
 0730        decimal TruePrice,
 0731        decimal WinnerPrice,
 0732        decimal OldPrice,
 0733        decimal MarkupPct,
 0734        decimal? PMin,
 0735        int     ClientId,
 0736        int     Multiplier = 1);
 737}