| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using Hangfire; |
| | | 4 | | using Microsoft.AspNetCore.SignalR; |
| | | 5 | | using Newtonsoft.Json; |
| | | 6 | | using Newtonsoft.Json.Linq; |
| | | 7 | | using OzonDomains; |
| | | 8 | | using OzonDomains.Models; |
| | | 9 | | using Services.ApiServices.OzonApi; |
| | | 10 | | using Services.DataServices; |
| | | 11 | | using Services.HangfireService; |
| | | 12 | | using Services.SignalRServices; |
| | | 13 | | using SlqStudio.Application.Services.EmailService; |
| | | 14 | | |
| | | 15 | | namespace Services.BitrixStockServices; |
| | | 16 | | |
| | | 17 | | public 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 | | |
| | 0 | 31 | | public BitrixStockSyncReportPricePushService( |
| | 0 | 32 | | OzonClientServices ozonClientServices, |
| | 0 | 33 | | IHttpClientFactory httpClientFactory, |
| | 0 | 34 | | IEmailService? emailService, |
| | 0 | 35 | | IHubContext<JobProgressHub> jobProgressHub, |
| | 0 | 36 | | IBitrixStockSyncReportService reportService, |
| | 0 | 37 | | BitrixStockActiveStageStore stageStore, |
| | 0 | 38 | | IBitrixStockExceptionService exceptions, |
| | 0 | 39 | | IBackgroundJobClient backgroundJobClient) |
| | | 40 | | { |
| | 0 | 41 | | _ozonClientServices = ozonClientServices; |
| | 0 | 42 | | _httpClientFactory = httpClientFactory; |
| | 0 | 43 | | _emailService = emailService; |
| | 0 | 44 | | _jobProgressHub = jobProgressHub; |
| | 0 | 45 | | _reportService = reportService; |
| | 0 | 46 | | _stageStore = stageStore; |
| | 0 | 47 | | _exceptions = exceptions; |
| | 0 | 48 | | _backgroundJobClient = backgroundJobClient; |
| | 0 | 49 | | } |
| | | 50 | | |
| | | 51 | | [Queue(HangfireQueueNames.Main)] |
| | | 52 | | public async Task PushPricesAsync( |
| | | 53 | | string reportId, |
| | | 54 | | BitrixStockSyncReportPricePushStartRequest request, |
| | | 55 | | string? progressJobId, |
| | | 56 | | CancellationToken cancellationToken = default) |
| | | 57 | | { |
| | 0 | 58 | | var hubId = progressJobId?.Trim() ?? string.Empty; |
| | 0 | 59 | | var hasHub = !string.IsNullOrWhiteSpace(hubId); |
| | 0 | 60 | | var lastProgressSentAt = DateTime.MinValue; |
| | | 61 | | |
| | | 62 | | async Task Log(string message) |
| | | 63 | | { |
| | 0 | 64 | | if (hasHub) |
| | 0 | 65 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken); |
| | 0 | 66 | | } |
| | | 67 | | |
| | | 68 | | async Task Progress(int current, int total, string text) |
| | | 69 | | { |
| | 0 | 70 | | if (!hasHub) return; |
| | 0 | 71 | | var now = DateTime.UtcNow; |
| | 0 | 72 | | if (current < total && (now - lastProgressSentAt).TotalMilliseconds < 250) return; |
| | 0 | 73 | | lastProgressSentAt = now; |
| | 0 | 74 | | await _jobProgressHub.Clients.Group(hubId) |
| | 0 | 75 | | .SendAsync("Progress", current, total, text, cancellationToken); |
| | 0 | 76 | | } |
| | | 77 | | |
| | | 78 | | async Task Complete(bool ok, object? payload) |
| | | 79 | | { |
| | 0 | 80 | | if (hasHub) |
| | 0 | 81 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken); |
| | 0 | 82 | | } |
| | | 83 | | |
| | | 84 | | // В режиме цепочки stage-store и SignalR-задержкой управляет оркестратор. |
| | 0 | 85 | | if (!request.Chained) |
| | | 86 | | { |
| | 0 | 87 | | _stageStore.Set(reportId, 4); |
| | | 88 | | // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа) |
| | 0 | 89 | | if (hasHub) await Task.Delay(800, cancellationToken); |
| | | 90 | | } |
| | 0 | 91 | | var pushedCount = 0; |
| | 0 | 92 | | var updatedCount = 0; |
| | | 93 | | |
| | | 94 | | try |
| | | 95 | | { |
| | | 96 | | // ── Загружаем исходные данные ────────────────────────────────────── |
| | 0 | 97 | | var snapshots = await _reportService.GetOzonPriceSnapshotsAsync(reportId, null, cancellationToken); |
| | 0 | 98 | | if (snapshots.Count == 0) |
| | | 99 | | { |
| | 0 | 100 | | await Log("Нет снимков цен для этого отчёта."); |
| | 0 | 101 | | await Complete(false, "Нет снимков цен"); |
| | 0 | 102 | | if (request.Chained) |
| | 0 | 103 | | throw new InvalidOperationException("Нет снимков цен для этого отчёта"); |
| | 0 | 104 | | return; |
| | | 105 | | } |
| | | 106 | | |
| | 0 | 107 | | var promos = await _reportService.GetReportPromosAsync(reportId, cancellationToken); |
| | 0 | 108 | | var settings = await _reportService.GetReportPriceSettingsAsync(reportId, cancellationToken); |
| | | 109 | | |
| | 0 | 110 | | var promoByArticle = promos |
| | 0 | 111 | | .GroupBy(p => p.Article, StringComparer.OrdinalIgnoreCase) |
| | 0 | 112 | | .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); |
| | | 113 | | |
| | 0 | 114 | | decimal noPromoMarkup = settings?.NoPromoMarkupPercent ?? 5m; |
| | 0 | 115 | | decimal promoMarkup = settings?.PromoMarkupPercent ?? 10m; |
| | | 116 | | |
| | 0 | 117 | | var allClients = await _ozonClientServices.GetOzonClients(); |
| | 0 | 118 | | var clientMap = allClients |
| | 0 | 119 | | .Where(c => c.ClientType == ClientType.OZON |
| | 0 | 120 | | && !string.IsNullOrEmpty(c.DecryptClientId) |
| | 0 | 121 | | && !string.IsNullOrEmpty(c.DecryptApiKey)) |
| | 0 | 122 | | .ToDictionary(c => c.Id); |
| | | 123 | | |
| | 0 | 124 | | var c1Cache = new Dictionary<string, (decimal? c0, decimal? c1)>(StringComparer.OrdinalIgnoreCase); |
| | | 125 | | |
| | 0 | 126 | | var toProcess = request.OnlyPending |
| | 0 | 127 | | ? snapshots.Where(s => !s.PricePushedNew.HasValue).ToList() |
| | 0 | 128 | | : snapshots; |
| | | 129 | | |
| | 0 | 130 | | if (request.MaxArticles.HasValue && request.MaxArticles.Value > 0) |
| | 0 | 131 | | toProcess = toProcess.Take(request.MaxArticles.Value).ToList(); |
| | | 132 | | |
| | 0 | 133 | | var total = toProcess.Count; |
| | 0 | 134 | | await Log($"Фаза 4a — расчёт цен: {total} снимков."); |
| | | 135 | | |
| | | 136 | | // ── Фаза 4a: расчёт цен ──────────────────────────────────────────── |
| | 0 | 137 | | var pendingByClient = new Dictionary<int, List<PricePendingEntry>>(); |
| | 0 | 138 | | var skippedCount = 0; |
| | 0 | 139 | | var unchangedCount = 0; |
| | 0 | 140 | | var unchangedItems = new List<(string Article, int OzonClientId)>(); |
| | | 141 | | |
| | 0 | 142 | | var skipPriceArticles = await _exceptions.GetSkipPriceArticlesSetAsync(cancellationToken); |
| | 0 | 143 | | var priceExceptionsMap = await _exceptions.GetClientPricesMapAsync(cancellationToken); |
| | 0 | 144 | | if (skipPriceArticles.Count > 0 || priceExceptionsMap.Count > 0) |
| | 0 | 145 | | await Log($"Справочник исключений цен: {skipPriceArticles.Count} арт. пропуск, {priceExceptionsMap.Count |
| | | 146 | | |
| | 0 | 147 | | for (var i = 0; i < toProcess.Count; i++) |
| | | 148 | | { |
| | 0 | 149 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 150 | | var snap = toProcess[i]; |
| | | 151 | | |
| | | 152 | | // Пропуск цен — только явные исключения (глобальный SkipPrice или правило кабинета skip). |
| | | 153 | | // Пропуск остатков (глобальный SkipStock / правила остатков) на цены не влияет. |
| | 0 | 154 | | if (skipPriceArticles.Contains(snap.Article)) |
| | | 155 | | { |
| | 0 | 156 | | await Log($"[{snap.Article}] Пропуск цены (глобальный справочник, SkipPrice)."); |
| | 0 | 157 | | skippedCount++; |
| | 0 | 158 | | goto nextSnap; |
| | | 159 | | } |
| | | 160 | | |
| | 0 | 161 | | priceExceptionsMap.TryGetValue((snap.Article.ToLowerInvariant(), snap.OzonClientId), out var priceRule); |
| | 0 | 162 | | if (priceRule?.PriceMode == BitrixStockPriceMode.Skip) |
| | | 163 | | { |
| | 0 | 164 | | await Log($"[{snap.Article}] Пропуск цены (правило кабинет #{snap.OzonClientId})."); |
| | 0 | 165 | | skippedCount++; |
| | 0 | 166 | | goto nextSnap; |
| | | 167 | | } |
| | | 168 | | |
| | 0 | 169 | | if (!clientMap.ContainsKey(snap.OzonClientId)) |
| | | 170 | | { |
| | 0 | 171 | | await Log($"[{snap.Article}] Клиент Ozon #{snap.OzonClientId} не найден, пропуск."); |
| | 0 | 172 | | skippedCount++; |
| | 0 | 173 | | goto nextSnap; |
| | | 174 | | } |
| | | 175 | | |
| | | 176 | | try |
| | | 177 | | { |
| | 0 | 178 | | if (!c1Cache.TryGetValue(snap.Article, out var c1Pair)) |
| | | 179 | | { |
| | 0 | 180 | | c1Pair = await _reportService.GetArticleC1PriceAsync(snap.Article, cancellationToken); |
| | 0 | 181 | | c1Cache[snap.Article] = c1Pair; |
| | | 182 | | } |
| | | 183 | | |
| | | 184 | | JObject priceJson; |
| | 0 | 185 | | try { priceJson = JObject.Parse(snap.PriceDataJson); } |
| | | 186 | | catch |
| | | 187 | | { |
| | 0 | 188 | | await Log($"[{snap.Article}] Не удалось разобрать PriceDataJson, пропуск."); |
| | 0 | 189 | | skippedCount++; |
| | 0 | 190 | | goto nextSnap; |
| | | 191 | | } |
| | | 192 | | |
| | 0 | 193 | | var promoEntry = promoByArticle.TryGetValue(snap.Article, out var pe) ? pe : null; |
| | 0 | 194 | | var result = PriceOptimizationManager.Calculate(new PriceOptimizationInput |
| | 0 | 195 | | { |
| | 0 | 196 | | C0 = c1Pair.c0, |
| | 0 | 197 | | C1 = c1Pair.c1, |
| | 0 | 198 | | PriceJson = priceJson, |
| | 0 | 199 | | InPromo = promoEntry?.InPromo ?? false, |
| | 0 | 200 | | NoPromoMarkupPercent = noPromoMarkup, |
| | 0 | 201 | | PromoMarkupPercent = promoMarkup |
| | 0 | 202 | | }); |
| | | 203 | | |
| | 0 | 204 | | if (result.WinnerPrice == null) |
| | | 205 | | { |
| | 0 | 206 | | skippedCount++; |
| | 0 | 207 | | goto nextSnap; |
| | | 208 | | } |
| | | 209 | | |
| | 0 | 210 | | var winner = result.WinnerPrice.Value; |
| | | 211 | | |
| | | 212 | | // Подстановка фиксированной цены из справочника исключений |
| | 0 | 213 | | decimal? fixedOldPriceOverride = null; |
| | 0 | 214 | | if (priceRule?.PriceMode == BitrixStockPriceMode.Fixed && priceRule.FixedPrice.HasValue) |
| | | 215 | | { |
| | 0 | 216 | | await Log($"[{snap.Article}] Фиксированная цена {priceRule.FixedPrice} (кабинет #{snap.OzonClien |
| | 0 | 217 | | winner = priceRule.FixedPrice.Value; |
| | 0 | 218 | | fixedOldPriceOverride = priceRule.FixedOldPrice; |
| | | 219 | | } |
| | | 220 | | |
| | | 221 | | // Умножение на размер партии [N] в OfferId (например TYT[5] → цена × 5) |
| | 0 | 222 | | var offerId = priceJson["offer_id"]?.Value<string>() ?? snap.OfferId; |
| | 0 | 223 | | var multiplier = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(offerId); |
| | 0 | 224 | | if (multiplier > 1) |
| | | 225 | | { |
| | 0 | 226 | | await Log($"[{snap.Article}] Партия ×{multiplier} ({offerId}): {winner:F0} × {multiplier} = {win |
| | 0 | 227 | | winner *= multiplier; |
| | 0 | 228 | | if (fixedOldPriceOverride.HasValue) |
| | 0 | 229 | | fixedOldPriceOverride = fixedOldPriceOverride.Value * multiplier; |
| | | 230 | | } |
| | | 231 | | |
| | | 232 | | // Пропускаем если цена не изменилась (±1 ₽) |
| | 0 | 233 | | var currentSellerPrice = priceJson["price"]?["marketing_seller_price"]?.Value<decimal?>() ?? 0m; |
| | 0 | 234 | | if (currentSellerPrice > 0 && Math.Abs(currentSellerPrice - winner) < 1m) |
| | | 235 | | { |
| | 0 | 236 | | unchangedCount++; |
| | 0 | 237 | | unchangedItems.Add((snap.Article, snap.OzonClientId)); |
| | 0 | 238 | | goto nextSnap; |
| | | 239 | | } |
| | | 240 | | |
| | 0 | 241 | | var productId = priceJson["product_id"]?.Value<long?>(); |
| | 0 | 242 | | var vat = priceJson["price"]?["vat"]?.Value<string>() ?? "0"; |
| | 0 | 243 | | var truePrice = priceJson["price"]?["price"]?.Value<decimal?>() |
| | 0 | 244 | | ?? priceJson["price"]?["customer_price"]?.Value<decimal?>() |
| | 0 | 245 | | ?? 0m; |
| | | 246 | | |
| | 0 | 247 | | if (productId == null) |
| | | 248 | | { |
| | 0 | 249 | | await Log($"[{snap.Article}] product_id отсутствует в снимке, пропуск."); |
| | 0 | 250 | | skippedCount++; |
| | 0 | 251 | | goto nextSnap; |
| | | 252 | | } |
| | | 253 | | |
| | 0 | 254 | | var oldPrice = fixedOldPriceOverride ?? ComputeOldPrice(winner); |
| | | 255 | | |
| | 0 | 256 | | if (!pendingByClient.TryGetValue(snap.OzonClientId, out var clientList)) |
| | | 257 | | { |
| | 0 | 258 | | clientList = new List<PricePendingEntry>(); |
| | 0 | 259 | | pendingByClient[snap.OzonClientId] = clientList; |
| | | 260 | | } |
| | 0 | 261 | | clientList.Add(new PricePendingEntry( |
| | 0 | 262 | | Article: snap.Article, |
| | 0 | 263 | | OfferId: offerId, |
| | 0 | 264 | | ProductId: productId.Value, |
| | 0 | 265 | | Vat: vat, |
| | 0 | 266 | | TruePrice: truePrice, |
| | 0 | 267 | | WinnerPrice: winner, |
| | 0 | 268 | | OldPrice: oldPrice, |
| | 0 | 269 | | MarkupPct: result.MarkupPercentUsed, |
| | 0 | 270 | | PMin: result.PMin, |
| | 0 | 271 | | ClientId: snap.OzonClientId, |
| | 0 | 272 | | Multiplier: multiplier)); |
| | 0 | 273 | | } |
| | 0 | 274 | | catch (Exception ex) |
| | | 275 | | { |
| | 0 | 276 | | await Log($"[{snap.Article}] Ошибка расчёта: {ex}"); |
| | 0 | 277 | | skippedCount++; |
| | | 278 | | } |
| | | 279 | | |
| | | 280 | | nextSnap: |
| | 0 | 281 | | if ((i + 1) % 100 == 0 || i + 1 == total) |
| | 0 | 282 | | await Progress(i + 1, total, $"Расчёт {i + 1}/{total}"); |
| | 0 | 283 | | } |
| | | 284 | | |
| | | 285 | | // Запись Unchanged — батч параллельных await (не fire-and-forget) |
| | 0 | 286 | | if (unchangedItems.Count > 0) |
| | | 287 | | { |
| | 0 | 288 | | using var sem = new SemaphoreSlim(8, 8); |
| | 0 | 289 | | await Task.WhenAll(unchangedItems.Select(async x => |
| | 0 | 290 | | { |
| | 0 | 291 | | await sem.WaitAsync(CancellationToken.None); |
| | 0 | 292 | | try { await _reportService.RecordPricePushStatusAsync(reportId, x.Article, x.OzonClientId, "Unchange |
| | 0 | 293 | | catch { /* некритично */ } |
| | 0 | 294 | | finally { sem.Release(); } |
| | 0 | 295 | | })); |
| | 0 | 296 | | } |
| | | 297 | | |
| | 0 | 298 | | var pendingTotal = pendingByClient.Values.Sum(l => l.Count); |
| | 0 | 299 | | await Log($"Расчёт завершён. К накату: {pendingTotal}, без изменений: {unchangedCount}, пропущено: {skippedC |
| | | 300 | | |
| | 0 | 301 | | if (pendingTotal == 0) |
| | | 302 | | { |
| | 0 | 303 | | await Complete(true, new { pushedCount = 0, updatedCount = 0, unchangedCount }); |
| | 0 | 304 | | return; |
| | | 305 | | } |
| | | 306 | | |
| | | 307 | | // ── Накат батчами по клиентам ────────────────────────────────────── |
| | | 308 | | // Создаём менеджеры один раз — используются и в 4a, и в 4b |
| | 0 | 309 | | var mgrByClient = pendingByClient.Keys |
| | 0 | 310 | | .Where(clientMap.ContainsKey) |
| | 0 | 311 | | .ToDictionary(id => id, |
| | 0 | 312 | | id => BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, clientMap[id])); |
| | | 313 | | |
| | 0 | 314 | | var successList = new List<(string Article, string OfferId, int ClientId)>(); |
| | | 315 | | |
| | 0 | 316 | | foreach (var (clientId, entries) in pendingByClient) |
| | | 317 | | { |
| | 0 | 318 | | if (!mgrByClient.TryGetValue(clientId, out var mgr)) continue; |
| | | 319 | | |
| | 0 | 320 | | var batches = entries.Chunk(PriceBatchSize).ToList(); |
| | 0 | 321 | | for (var b = 0; b < batches.Count; b++) |
| | | 322 | | { |
| | 0 | 323 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 324 | | var batch = batches[b]; |
| | 0 | 325 | | var priceItems = batch.Select(e => new ProductPriceItem |
| | 0 | 326 | | { |
| | 0 | 327 | | OfferId = e.OfferId, |
| | 0 | 328 | | ProductId = e.ProductId, |
| | 0 | 329 | | Price = ((long)e.WinnerPrice).ToString(CultureInfo.InvariantCulture), |
| | 0 | 330 | | OldPrice = ((long)e.OldPrice).ToString(CultureInfo.InvariantCulture), |
| | 0 | 331 | | MinPrice = ((long)e.WinnerPrice).ToString(CultureInfo.InvariantCulture), |
| | 0 | 332 | | NetPrice = "0", |
| | 0 | 333 | | Vat = e.Vat, |
| | 0 | 334 | | }).ToList(); |
| | | 335 | | |
| | 0 | 336 | | await Log($"[Клиент {clientId}] Батч {b + 1}/{batches.Count}: отправка {priceItems.Count} цен…"); |
| | | 337 | | try |
| | | 338 | | { |
| | 0 | 339 | | var batchResult = await mgr.ImportProductPrices(priceItems); |
| | | 340 | | |
| | 0 | 341 | | var errorOfferIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 342 | | var errorProductIds = new HashSet<long>(); |
| | 0 | 343 | | var errorMsgByOfferId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 344 | | var errorMsgByProductId = new Dictionary<long, string>(); |
| | 0 | 345 | | var vatFixByOfferId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 346 | | var vatFixByProductId = new Dictionary<long, string>(); |
| | 0 | 347 | | foreach (var r in batchResult.Result ?? new()) |
| | | 348 | | { |
| | 0 | 349 | | if (r.Errors is not { Count: > 0 }) continue; |
| | 0 | 350 | | var errMsg = string.Join("; ", r.Errors.Select(err => err.Message)); |
| | 0 | 351 | | var label = !string.IsNullOrEmpty(r.OfferId) |
| | 0 | 352 | | ? r.OfferId |
| | 0 | 353 | | : r.ProductId > 0 ? r.ProductId.ToString() : "?"; |
| | | 354 | | |
| | 0 | 355 | | var fixedVat = ParseAvailableVat(errMsg); |
| | 0 | 356 | | if (fixedVat != null) |
| | | 357 | | { |
| | 0 | 358 | | if (!string.IsNullOrEmpty(r.OfferId)) vatFixByOfferId[r.OfferId] = fixedVat; |
| | 0 | 359 | | if (r.ProductId > 0) vatFixByProductId[r.ProductId] = fixedVat; |
| | | 360 | | } |
| | | 361 | | else |
| | | 362 | | { |
| | 0 | 363 | | await Log($"[{label}] Ошибка: {errMsg}"); |
| | 0 | 364 | | if (!string.IsNullOrEmpty(r.OfferId)) { errorOfferIds.Add(r.OfferId); errorMsgByOfferI |
| | 0 | 365 | | if (r.ProductId > 0) { errorProductIds.Add(r.ProductId); errorMsgByProd |
| | | 366 | | } |
| | 0 | 367 | | } |
| | | 368 | | |
| | | 369 | | // Ретрай для VAT-ошибок с исправленной ставкой |
| | 0 | 370 | | if (vatFixByOfferId.Count + vatFixByProductId.Count > 0) |
| | | 371 | | { |
| | 0 | 372 | | var vatRetry = batch |
| | 0 | 373 | | .Where(e => vatFixByOfferId.ContainsKey(e.OfferId) || vatFixByProductId.ContainsKey(e.Pr |
| | 0 | 374 | | .Select(e => |
| | 0 | 375 | | { |
| | 0 | 376 | | var fixedVat = vatFixByOfferId.TryGetValue(e.OfferId, out var v1) ? v1 |
| | 0 | 377 | | : vatFixByProductId.TryGetValue(e.ProductId, out var v2) ? v2 |
| | 0 | 378 | | : e.Vat; |
| | 0 | 379 | | return (Entry: e, FixedVat: fixedVat); |
| | 0 | 380 | | }).ToList(); |
| | | 381 | | |
| | 0 | 382 | | await Log($"[Клиент {clientId}] Ретрай НДС: {vatRetry.Count} товаров…"); |
| | 0 | 383 | | var retryItems = vatRetry.Select(x => new ProductPriceItem |
| | 0 | 384 | | { |
| | 0 | 385 | | OfferId = x.Entry.OfferId, |
| | 0 | 386 | | ProductId = x.Entry.ProductId, |
| | 0 | 387 | | Price = ((long)x.Entry.WinnerPrice).ToString(CultureInfo.InvariantCulture), |
| | 0 | 388 | | OldPrice = ((long)x.Entry.OldPrice).ToString(CultureInfo.InvariantCulture), |
| | 0 | 389 | | MinPrice = ((long)x.Entry.WinnerPrice).ToString(CultureInfo.InvariantCulture), |
| | 0 | 390 | | NetPrice = "0", |
| | 0 | 391 | | Vat = x.FixedVat, |
| | 0 | 392 | | }).ToList(); |
| | | 393 | | |
| | 0 | 394 | | var retryResult = await mgr.ImportProductPrices(retryItems); |
| | 0 | 395 | | foreach (var r in retryResult.Result ?? new()) |
| | | 396 | | { |
| | 0 | 397 | | if (r.Errors is not { Count: > 0 }) continue; |
| | 0 | 398 | | var errMsg = string.Join("; ", r.Errors.Select(err => err.Message)); |
| | 0 | 399 | | var label = !string.IsNullOrEmpty(r.OfferId) ? r.OfferId : r.ProductId.ToString(); |
| | 0 | 400 | | await Log($"[{label}] Ошибка (ретрай НДС): {errMsg}"); |
| | 0 | 401 | | if (!string.IsNullOrEmpty(r.OfferId)) { errorOfferIds.Add(r.OfferId); errorMsgByOfferI |
| | 0 | 402 | | if (r.ProductId > 0) { errorProductIds.Add(r.ProductId); errorMsgByProd |
| | 0 | 403 | | } |
| | 0 | 404 | | } |
| | | 405 | | |
| | | 406 | | // Записываем результат каждого артикула в батче |
| | 0 | 407 | | var batchPushed = 0; |
| | 0 | 408 | | foreach (var e in batch) |
| | | 409 | | { |
| | 0 | 410 | | if (errorOfferIds.Contains(e.OfferId) || errorProductIds.Contains(e.ProductId)) |
| | | 411 | | { |
| | 0 | 412 | | var errForArticle = errorMsgByOfferId.TryGetValue(e.OfferId, out var m1) ? m1 |
| | 0 | 413 | | : errorMsgByProductId.TryGetValue(e.ProductId, out var m2) ? m2 |
| | 0 | 414 | | : "Ошибка Ozon"; |
| | | 415 | | try |
| | | 416 | | { |
| | 0 | 417 | | await _reportService.RecordPricePushStatusAsync( |
| | 0 | 418 | | reportId, e.Article, e.ClientId, "Error", errForArticle, cancellationToken); |
| | 0 | 419 | | } |
| | 0 | 420 | | catch (Exception dbEx) { await Log($"[{e.Article}] Запись ошибки: {dbEx.Message}"); } |
| | 0 | 421 | | continue; |
| | | 422 | | } |
| | | 423 | | |
| | | 424 | | try |
| | | 425 | | { |
| | 0 | 426 | | await _reportService.RecordPricePushAsync( |
| | 0 | 427 | | reportId, e.Article, e.ClientId, |
| | 0 | 428 | | e.TruePrice, e.WinnerPrice, |
| | 0 | 429 | | markupPct: e.MarkupPct, colorIndexBefore: null, |
| | 0 | 430 | | competitorPrice: e.PMin, cancellationToken); |
| | 0 | 431 | | } |
| | 0 | 432 | | catch (Exception dbEx) { await Log($"[{e.Article}] Запись наката: {dbEx.Message}"); } |
| | | 433 | | |
| | 0 | 434 | | successList.Add((e.Article, e.OfferId, e.ClientId)); |
| | 0 | 435 | | pushedCount++; |
| | 0 | 436 | | batchPushed++; |
| | 0 | 437 | | } |
| | | 438 | | |
| | 0 | 439 | | var errorEntryCount = batch.Count(e => |
| | 0 | 440 | | errorOfferIds.Contains(e.OfferId) || errorProductIds.Contains(e.ProductId)); |
| | 0 | 441 | | await Log($"[Клиент {clientId}] Батч {b + 1}: ✓ {batchPushed}/{priceItems.Count}" + |
| | 0 | 442 | | (errorEntryCount > 0 ? $", ошибок: {errorEntryCount}" : "")); |
| | 0 | 443 | | } |
| | 0 | 444 | | catch (Exception ex) |
| | | 445 | | { |
| | 0 | 446 | | await Log($"[Клиент {clientId}] Батч {b + 1} ошибка: {ex}"); |
| | | 447 | | } |
| | | 448 | | |
| | 0 | 449 | | await Progress(pushedCount, pendingTotal, $"Клиент {clientId}, батч {b + 1}/{batches.Count}"); |
| | 0 | 450 | | } |
| | 0 | 451 | | } |
| | | 452 | | |
| | 0 | 453 | | await Log($"Фаза 4a завершена. Накатано: {pushedCount} из {pendingTotal}."); |
| | | 454 | | |
| | | 455 | | // ── Фаза 4b: Обновление снимков батчами ─────────────────────────── |
| | 0 | 456 | | if (successList.Count > 0) |
| | | 457 | | { |
| | 0 | 458 | | await Log($"Фаза 4b — Обновление индексов: {successList.Count} артикулов."); |
| | | 459 | | |
| | 0 | 460 | | var successByClient = successList |
| | 0 | 461 | | .GroupBy(x => x.ClientId) |
| | 0 | 462 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 463 | | |
| | 0 | 464 | | foreach (var (clientId, items) in successByClient) |
| | | 465 | | { |
| | 0 | 466 | | if (!mgrByClient.TryGetValue(clientId, out var mgr)) continue; |
| | | 467 | | |
| | 0 | 468 | | var offerIds = items.Select(x => x.OfferId).ToArray(); |
| | 0 | 469 | | var offerToArticle = items.ToDictionary(x => x.OfferId, x => x.Article, StringComparer.OrdinalIgnore |
| | 0 | 470 | | var batches = offerIds.Chunk(SnapshotBatchSize).ToList(); |
| | | 471 | | |
| | 0 | 472 | | for (var b = 0; b < batches.Count; b++) |
| | | 473 | | { |
| | 0 | 474 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 475 | | var batchOffers = batches[b]; |
| | | 476 | | |
| | | 477 | | try |
| | | 478 | | { |
| | 0 | 479 | | var rawResponse = await mgr.GetProductPricesByArticles(batchOffers); |
| | 0 | 480 | | var fetchedItems = rawResponse?["items"] as JArray; |
| | 0 | 481 | | if (fetchedItems == null) |
| | | 482 | | { |
| | 0 | 483 | | await Log($"[Клиент {clientId}] Батч {b + 1}: нет ответа Ozon."); |
| | 0 | 484 | | continue; |
| | | 485 | | } |
| | | 486 | | |
| | 0 | 487 | | foreach (var fetchedItem in fetchedItems.OfType<JObject>()) |
| | | 488 | | { |
| | 0 | 489 | | var fetchedOfferId = fetchedItem["offer_id"]?.Value<string>(); |
| | 0 | 490 | | if (string.IsNullOrEmpty(fetchedOfferId)) continue; |
| | 0 | 491 | | if (!offerToArticle.TryGetValue(fetchedOfferId, out var article)) continue; |
| | | 492 | | |
| | 0 | 493 | | EnrichOzonPriceItemInline(fetchedItem); |
| | 0 | 494 | | var freshJson = fetchedItem.ToString(Formatting.None); |
| | | 495 | | |
| | 0 | 496 | | await _reportService.UpdateOzonPriceSnapshotAsync( |
| | 0 | 497 | | reportId, article, clientId, freshJson, cancellationToken); |
| | 0 | 498 | | updatedCount++; |
| | | 499 | | } |
| | | 500 | | |
| | 0 | 501 | | await Log($"[Клиент {clientId}] Батч {b + 1}/{batches.Count}: обновлено {fetchedItems.Count} |
| | 0 | 502 | | } |
| | 0 | 503 | | catch (Exception ex) |
| | | 504 | | { |
| | 0 | 505 | | await Log($"[Клиент {clientId}] Батч {b + 1} ошибка обновления: {ex}"); |
| | | 506 | | } |
| | | 507 | | } |
| | 0 | 508 | | } |
| | | 509 | | |
| | 0 | 510 | | await Log($"Фаза 4b завершена. Обновлено: {updatedCount}."); |
| | | 511 | | } |
| | | 512 | | |
| | | 513 | | // Планируем фазу 4c: обновление индексов через 5 мин (Ozon обновляет их с задержкой). |
| | | 514 | | // Не критично для успеха этапа 4 — при таймауте Hangfire storage цепочка не должна падать. |
| | 0 | 515 | | string? scheduledIndexJobId = null; |
| | 0 | 516 | | if (pushedCount > 0) |
| | | 517 | | { |
| | 0 | 518 | | scheduledIndexJobId = reportId + "_s4c_" + Guid.NewGuid().ToString("N")[..6]; |
| | 0 | 519 | | var hangfireJobId = await _backgroundJobClient.TryScheduleMainAsync<IBitrixStockSyncReportPricePushServi |
| | 0 | 520 | | svc => svc.UpdatePriceIndexesAsync(reportId, scheduledIndexJobId, CancellationToken.None), |
| | 0 | 521 | | TimeSpan.FromMinutes(5), |
| | 0 | 522 | | Log, |
| | 0 | 523 | | cancellationToken: cancellationToken); |
| | | 524 | | |
| | 0 | 525 | | if (hangfireJobId != null) |
| | | 526 | | { |
| | 0 | 527 | | await Log($"Фаза 4c запланирована через 5 мин (Hangfire {hangfireJobId})."); |
| | | 528 | | } |
| | | 529 | | else |
| | | 530 | | { |
| | 0 | 531 | | scheduledIndexJobId = null; |
| | 0 | 532 | | await Log("Фаза 4c не запланирована (таймаут Hangfire). Цены уже накатаны; индексы можно обновить вр |
| | | 533 | | } |
| | | 534 | | } |
| | | 535 | | |
| | 0 | 536 | | await Complete(true, new { pushedCount, updatedCount, unchangedCount, scheduledIndexJobId }); |
| | 0 | 537 | | } |
| | 0 | 538 | | catch (OperationCanceledException) |
| | | 539 | | { |
| | 0 | 540 | | await Log("Задание отменено."); |
| | 0 | 541 | | await Complete(false, "Отменено"); |
| | 0 | 542 | | throw; |
| | 0 | 543 | | } |
| | 0 | 544 | | catch (Exception ex) |
| | | 545 | | { |
| | 0 | 546 | | await Log($"Критическая ошибка: {ex}"); |
| | 0 | 547 | | await Complete(false, ex.Message); |
| | 0 | 548 | | throw; |
| | 0 | 549 | | } |
| | | 550 | | finally |
| | | 551 | | { |
| | 0 | 552 | | if (!request.Chained) _stageStore.Clear(reportId); |
| | | 553 | | } |
| | 0 | 554 | | } |
| | | 555 | | |
| | | 556 | | [Queue(HangfireQueueNames.Main)] |
| | | 557 | | public async Task UpdatePriceIndexesAsync( |
| | | 558 | | string reportId, |
| | | 559 | | string? progressJobId, |
| | | 560 | | CancellationToken cancellationToken = default) |
| | | 561 | | { |
| | 0 | 562 | | var hubId = progressJobId?.Trim() ?? string.Empty; |
| | 0 | 563 | | var hasHub = !string.IsNullOrWhiteSpace(hubId); |
| | | 564 | | |
| | | 565 | | async Task Log(string message) |
| | | 566 | | { |
| | 0 | 567 | | if (hasHub) |
| | 0 | 568 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken); |
| | 0 | 569 | | } |
| | | 570 | | |
| | | 571 | | async Task Progress(int current, int total, string text) |
| | | 572 | | { |
| | 0 | 573 | | if (!hasHub) return; |
| | 0 | 574 | | await _jobProgressHub.Clients.Group(hubId) |
| | 0 | 575 | | .SendAsync("Progress", current, total, text, cancellationToken); |
| | 0 | 576 | | } |
| | | 577 | | |
| | | 578 | | async Task Complete(bool ok, object? payload) |
| | | 579 | | { |
| | 0 | 580 | | if (hasHub) |
| | 0 | 581 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken); |
| | 0 | 582 | | } |
| | | 583 | | |
| | 0 | 584 | | var updatedCount = 0; |
| | | 585 | | try |
| | | 586 | | { |
| | 0 | 587 | | var allSnapshots = await _reportService.GetOzonPriceSnapshotsAsync(reportId, null, cancellationToken); |
| | 0 | 588 | | var snapshots = allSnapshots.Where(s => s.PricePushedNew.HasValue).ToList(); |
| | | 589 | | |
| | 0 | 590 | | if (snapshots.Count == 0) |
| | | 591 | | { |
| | 0 | 592 | | await Log("Нет накатанных артикулов для обновления индексов."); |
| | 0 | 593 | | await Complete(true, new { updatedCount = 0 }); |
| | 0 | 594 | | return; |
| | | 595 | | } |
| | | 596 | | |
| | 0 | 597 | | await Log($"Фаза 4c — обновление индексов: {snapshots.Count} накатанных артикулов."); |
| | | 598 | | |
| | 0 | 599 | | var allClients = await _ozonClientServices.GetOzonClients(); |
| | 0 | 600 | | var clientMap = allClients |
| | 0 | 601 | | .Where(c => c.ClientType == ClientType.OZON |
| | 0 | 602 | | && !string.IsNullOrEmpty(c.DecryptClientId) |
| | 0 | 603 | | && !string.IsNullOrEmpty(c.DecryptApiKey)) |
| | 0 | 604 | | .ToDictionary(c => c.Id); |
| | | 605 | | |
| | 0 | 606 | | var byClient = snapshots |
| | 0 | 607 | | .GroupBy(s => s.OzonClientId) |
| | 0 | 608 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 609 | | |
| | 0 | 610 | | var total = snapshots.Count; |
| | | 611 | | |
| | 0 | 612 | | foreach (var (clientId, items) in byClient) |
| | | 613 | | { |
| | 0 | 614 | | if (!clientMap.TryGetValue(clientId, out var ozonClient)) continue; |
| | 0 | 615 | | var mgr = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, ozonClient); |
| | | 616 | | |
| | 0 | 617 | | var offerToArticle = items |
| | 0 | 618 | | .ToDictionary(s => s.OfferId, s => s.Article, StringComparer.OrdinalIgnoreCase); |
| | 0 | 619 | | var offerIds = items.Select(s => s.OfferId).ToArray(); |
| | 0 | 620 | | var batches = offerIds.Chunk(SnapshotBatchSize).ToList(); |
| | | 621 | | |
| | 0 | 622 | | for (var b = 0; b < batches.Count; b++) |
| | | 623 | | { |
| | 0 | 624 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 625 | | try |
| | | 626 | | { |
| | 0 | 627 | | var rawResponse = await mgr.GetProductPricesByArticles(batches[b]); |
| | 0 | 628 | | var fetchedItems = rawResponse?["items"] as JArray; |
| | 0 | 629 | | if (fetchedItems == null) |
| | | 630 | | { |
| | 0 | 631 | | await Log($"[Клиент {clientId}] Батч {b + 1}: нет ответа Ozon."); |
| | 0 | 632 | | continue; |
| | | 633 | | } |
| | | 634 | | |
| | 0 | 635 | | foreach (var fetchedItem in fetchedItems.OfType<JObject>()) |
| | | 636 | | { |
| | 0 | 637 | | var fetchedOfferId = fetchedItem["offer_id"]?.Value<string>(); |
| | 0 | 638 | | if (string.IsNullOrEmpty(fetchedOfferId)) continue; |
| | 0 | 639 | | if (!offerToArticle.TryGetValue(fetchedOfferId, out var article)) continue; |
| | | 640 | | |
| | 0 | 641 | | EnrichOzonPriceItemInline(fetchedItem); |
| | 0 | 642 | | var freshJson = fetchedItem.ToString(Formatting.None); |
| | | 643 | | |
| | 0 | 644 | | await _reportService.UpdateOzonPriceSnapshotAsync( |
| | 0 | 645 | | reportId, article, clientId, freshJson, cancellationToken); |
| | 0 | 646 | | updatedCount++; |
| | | 647 | | } |
| | | 648 | | |
| | 0 | 649 | | await Log($"[Клиент {clientId}] Батч {b + 1}/{batches.Count}: обновлено {fetchedItems.Count} инд |
| | 0 | 650 | | } |
| | 0 | 651 | | catch (Exception ex) |
| | | 652 | | { |
| | 0 | 653 | | await Log($"[Клиент {clientId}] Батч {b + 1} ошибка: {ex}"); |
| | | 654 | | } |
| | | 655 | | |
| | 0 | 656 | | await Progress(updatedCount, total, $"Клиент {clientId}, батч {b + 1}/{batches.Count}"); |
| | | 657 | | } |
| | 0 | 658 | | } |
| | | 659 | | |
| | 0 | 660 | | await Log($"Фаза 4c завершена. Обновлено индексов: {updatedCount}."); |
| | 0 | 661 | | await Complete(true, new { updatedCount }); |
| | 0 | 662 | | } |
| | 0 | 663 | | catch (OperationCanceledException) |
| | | 664 | | { |
| | 0 | 665 | | await Log("Обновление индексов отменено."); |
| | 0 | 666 | | await Complete(false, "Отменено"); |
| | 0 | 667 | | } |
| | 0 | 668 | | catch (Exception ex) |
| | | 669 | | { |
| | 0 | 670 | | await Log($"Критическая ошибка обновления индексов: {ex}"); |
| | 0 | 671 | | await Complete(false, ex.Message); |
| | 0 | 672 | | } |
| | 0 | 673 | | } |
| | | 674 | | |
| | 1 | 675 | | private static readonly Regex VatRatesRegex = |
| | 1 | 676 | | 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 | | { |
| | 11 | 682 | | if (!errorMessage.Contains("НДС", StringComparison.OrdinalIgnoreCase)) return null; |
| | 9 | 683 | | var m = VatRatesRegex.Match(errorMessage); |
| | 10 | 684 | | if (!m.Success) return null; |
| | 8 | 685 | | var first = m.Groups[1].Value.Split(',')[0].Trim().TrimEnd('%').Trim(); |
| | 16 | 686 | | 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; |
| | 16 | 692 | | if (yourPrice <= 400) minOld = yourPrice + 21m; |
| | 7 | 693 | | else if (yourPrice <= 10000) minOld = Math.Ceiling(yourPrice * 1.053m); |
| | 1 | 694 | | else minOld = yourPrice + 501m; |
| | 10 | 695 | | return Math.Max(Math.Round(yourPrice * 1.25m, MidpointRounding.AwayFromZero), minOld); |
| | | 696 | | } |
| | | 697 | | |
| | | 698 | | private static void EnrichOzonPriceItemInline(JObject item) |
| | | 699 | | { |
| | 3 | 700 | | var commissions = item["commissions"]; |
| | 3 | 701 | | var priceObj = item["price"] as JObject; |
| | 5 | 702 | | if (commissions == null || priceObj == null) return; |
| | | 703 | | |
| | 1 | 704 | | decimal salesPercent = commissions["sales_percent_fbs"]?.Value<decimal>() ?? 0; |
| | 1 | 705 | | decimal itemPrice = priceObj["price"]?.Value<decimal>() ?? 0; |
| | 1 | 706 | | decimal acquiring = item["acquiring"]?.Value<decimal>() ?? 0; |
| | 1 | 707 | | decimal fbsFirstMileMin = commissions["fbs_first_mile_min_amount"]?.Value<decimal>() ?? 0; |
| | 1 | 708 | | decimal fbsFirstMileMax = commissions["fbs_first_mile_max_amount"]?.Value<decimal>() ?? 0; |
| | 1 | 709 | | decimal fbsDirectMin = commissions["fbs_direct_flow_trans_min_amount"]?.Value<decimal>() ?? 0; |
| | 1 | 710 | | decimal fbsDirectMax = commissions["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0; |
| | 1 | 711 | | decimal fboDeliv = commissions["fbo_deliv_to_customer_amount"]?.Value<decimal>() ?? 0; |
| | | 712 | | |
| | 1 | 713 | | decimal ozonReward = itemPrice * salesPercent / 100; |
| | 1 | 714 | | decimal maxCommission = ozonReward + acquiring + fbsFirstMileMax + fbsDirectMax + fboDeliv; |
| | 1 | 715 | | decimal minCommission = ozonReward + acquiring + fbsFirstMileMin + fbsDirectMin + fboDeliv; |
| | 1 | 716 | | decimal marketingPrice = priceObj["marketing_seller_price"]?.Value<decimal>() ?? 0; |
| | 1 | 717 | | decimal truePrice = marketingPrice * 0.91m; |
| | | 718 | | |
| | 1 | 719 | | priceObj["max_commission"] = Math.Round(maxCommission, 2); |
| | 1 | 720 | | priceObj["min_commission"] = Math.Round(minCommission, 2); |
| | 1 | 721 | | priceObj["ozon_reward"] = Math.Round(ozonReward, 2); |
| | 1 | 722 | | priceObj["true_price"] = Math.Round(truePrice, 2); |
| | 1 | 723 | | } |
| | | 724 | | |
| | 0 | 725 | | private record PricePendingEntry( |
| | 0 | 726 | | string Article, |
| | 0 | 727 | | string OfferId, |
| | 0 | 728 | | long ProductId, |
| | 0 | 729 | | string Vat, |
| | 0 | 730 | | decimal TruePrice, |
| | 0 | 731 | | decimal WinnerPrice, |
| | 0 | 732 | | decimal OldPrice, |
| | 0 | 733 | | decimal MarkupPct, |
| | 0 | 734 | | decimal? PMin, |
| | 0 | 735 | | int ClientId, |
| | 0 | 736 | | int Multiplier = 1); |
| | | 737 | | } |