| | | 1 | | using Hangfire; |
| | | 2 | | using Hangfire.Storage; |
| | | 3 | | using Microsoft.AspNetCore.SignalR; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Caching.Memory; |
| | | 6 | | using Microsoft.Extensions.Http; |
| | | 7 | | using Newtonsoft.Json.Linq; |
| | | 8 | | using OzonDomains; |
| | | 9 | | using OzonDomains.Models; |
| | | 10 | | using OzonRepositories.Context; |
| | | 11 | | using Services.ApiServices.OzonApi; |
| | | 12 | | using Services.ApiServices.OzonApi.Filters; |
| | | 13 | | using Services.DataServices; |
| | | 14 | | using Services.HangfireService; |
| | | 15 | | using Services.SignalRServices; |
| | | 16 | | using SlqStudio.Application.Services.EmailService; |
| | | 17 | | |
| | | 18 | | namespace Services.BitrixStockServices; |
| | | 19 | | |
| | | 20 | | public class BitrixStockSyncReportCleanupService : IBitrixStockSyncReportCleanupService |
| | | 21 | | { |
| | | 22 | | private const int BatchSize = 100; |
| | | 23 | | private const int DelayBetweenBatchesMs = 400; |
| | | 24 | | private const int PreviewDefaultMaxProducts = 15; |
| | | 25 | | private const int PreviewMaxGroupsPerRequest = BitrixStockSyncCleanupPlanner.PreviewMaxGroupsPerRequest; |
| | | 26 | | private const int FbsStocksChunkSize = 200; |
| | | 27 | | |
| | | 28 | | /// <summary>SKU на один запрос stocks-by-warehouse (ответ читается постранично по cursor).</summary> |
| | | 29 | | private const int FbsSkuChunkSize = 300; |
| | | 30 | | private const int ScanProgressLogEveryGroups = 50; |
| | | 31 | | |
| | | 32 | | private readonly IDbContextFactory<ApplicationDbContext> _dbFactory; |
| | | 33 | | private readonly OzonClientServices _ozonClientServices; |
| | | 34 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 35 | | private readonly IEmailService? _emailService; |
| | | 36 | | private readonly IHubContext<JobProgressHub> _jobProgressHub; |
| | | 37 | | private readonly BitrixStockSyncApplyArticleLogService _articleLog; |
| | | 38 | | private readonly IMemoryCache _memoryCache; |
| | | 39 | | private readonly BitrixStockActiveStageStore _stageStore; |
| | | 40 | | private readonly IBitrixStockSyncReportPricePushService _pricePushService; |
| | | 41 | | |
| | 0 | 42 | | public BitrixStockSyncReportCleanupService( |
| | 0 | 43 | | IDbContextFactory<ApplicationDbContext> dbFactory, |
| | 0 | 44 | | OzonClientServices ozonClientServices, |
| | 0 | 45 | | IHttpClientFactory httpClientFactory, |
| | 0 | 46 | | IEmailService? emailService, |
| | 0 | 47 | | IHubContext<JobProgressHub> jobProgressHub, |
| | 0 | 48 | | BitrixStockSyncApplyArticleLogService articleLog, |
| | 0 | 49 | | IMemoryCache memoryCache, |
| | 0 | 50 | | BitrixStockActiveStageStore stageStore, |
| | 0 | 51 | | IBitrixStockSyncReportPricePushService pricePushService) |
| | | 52 | | { |
| | 0 | 53 | | _dbFactory = dbFactory; |
| | 0 | 54 | | _ozonClientServices = ozonClientServices; |
| | 0 | 55 | | _httpClientFactory = httpClientFactory; |
| | 0 | 56 | | _emailService = emailService; |
| | 0 | 57 | | _jobProgressHub = jobProgressHub; |
| | 0 | 58 | | _articleLog = articleLog; |
| | 0 | 59 | | _memoryCache = memoryCache; |
| | 0 | 60 | | _stageStore = stageStore; |
| | 0 | 61 | | _pricePushService = pricePushService; |
| | 0 | 62 | | } |
| | | 63 | | |
| | | 64 | | public Task<BitrixStockSyncCleanupPreviewDto> GetCleanupPreviewAsync( |
| | | 65 | | string reportId, |
| | | 66 | | bool onlyPending = true, |
| | | 67 | | int? maxProducts = null, |
| | | 68 | | IReadOnlyList<string>? articles = null, |
| | | 69 | | string? search = null, |
| | | 70 | | int skipGroups = 0, |
| | | 71 | | CancellationToken cancellationToken = default) => |
| | 0 | 72 | | BuildPreviewOrRunAsync(reportId, onlyPending, maxProducts, articles, search, skipGroups, |
| | 0 | 73 | | dryRun: true, jobId: null, cancellationToken); |
| | | 74 | | |
| | | 75 | | public async Task<BitrixStockSyncCleanupZeroResult> ZeroWarehouseAsync( |
| | | 76 | | string reportId, |
| | | 77 | | BitrixStockSyncCleanupZeroRequest request, |
| | | 78 | | CancellationToken cancellationToken = default) |
| | | 79 | | { |
| | 0 | 80 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 81 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "reportId обязателен" }; |
| | 0 | 82 | | if (string.IsNullOrWhiteSpace(request.Article)) |
| | 0 | 83 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Артикул обязателен" }; |
| | 0 | 84 | | if (!BitrixStockSyncCleanupPlanner.TryParseWarehouseId(request.WarehouseId, out var warehouseId)) |
| | 0 | 85 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Некорректный склад" }; |
| | | 86 | | |
| | 0 | 87 | | IDisposable? distLock = null; |
| | 0 | 88 | | IStorageConnection? lockConnection = null; |
| | | 89 | | try |
| | | 90 | | { |
| | | 91 | | try |
| | | 92 | | { |
| | 0 | 93 | | lockConnection = JobStorage.Current.GetConnection(); |
| | 0 | 94 | | distLock = lockConnection.AcquireDistributedLock( |
| | 0 | 95 | | BitrixStockPushJobService.PushLockKey, |
| | 0 | 96 | | TimeSpan.FromMilliseconds(1)); |
| | 0 | 97 | | } |
| | 0 | 98 | | catch (DistributedLockTimeoutException) |
| | | 99 | | { |
| | 0 | 100 | | return new BitrixStockSyncCleanupZeroResult |
| | 0 | 101 | | { |
| | 0 | 102 | | Success = false, |
| | 0 | 103 | | Error = "Обновление остатков Ozon уже выполняется. Повторите позже." |
| | 0 | 104 | | }; |
| | | 105 | | } |
| | | 106 | | |
| | 0 | 107 | | await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); |
| | | 108 | | |
| | 0 | 109 | | var article = request.Article.Trim(); |
| | 0 | 110 | | var lines = (await LoadCleanupReportLinesAsync(db, reportId.Trim(), new[] { article }, null, cancellationTok |
| | 0 | 111 | | .Where(l => l.OzonClientId == request.OzonClientId) |
| | 0 | 112 | | .ToList(); |
| | 0 | 113 | | if (lines.Count == 0) |
| | 0 | 114 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Артикул не найден в отчёте" }; |
| | | 115 | | |
| | 0 | 116 | | if (BitrixStockSyncCleanupPlanner.HasUnresolvedWarehouseLines(lines)) |
| | 0 | 117 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "В отчёте остаток Ozon без склада |
| | | 118 | | |
| | 0 | 119 | | var protectedIds = BitrixStockSyncCleanupPlanner.BuildProtectedWarehouseIds(lines); |
| | 0 | 120 | | if (protectedIds.Contains(warehouseId)) |
| | 0 | 121 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Склад защищён (остаток > 0 в отч |
| | | 122 | | |
| | 0 | 123 | | var first = lines[0]; |
| | 0 | 124 | | var client = (await _ozonClientServices.GetOzonClients()) |
| | 0 | 125 | | .FirstOrDefault(c => c.Id == request.OzonClientId && c.ClientType == ClientType.OZON); |
| | 0 | 126 | | if (client == null || string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApi |
| | 0 | 127 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Клиент Ozon не найден" }; |
| | | 128 | | |
| | 0 | 129 | | var offerId = !string.IsNullOrWhiteSpace(request.OfferId) ? request.OfferId.Trim() : first.OfferId; |
| | 0 | 130 | | var productId = request.OzonProductId > 0 ? request.OzonProductId : first.OzonProductId ?? 0; |
| | 0 | 131 | | if (string.IsNullOrWhiteSpace(offerId) || productId <= 0) |
| | 0 | 132 | | return new BitrixStockSyncCleanupZeroResult { Success = false, Error = "Нет offerId/productId для Ozon" |
| | | 133 | | |
| | 0 | 134 | | var qtyBefore = request.Present > 0 ? request.Present : 0; |
| | 0 | 135 | | var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | | 136 | | |
| | 0 | 137 | | var payload = new List<StockItemForUpdate> |
| | 0 | 138 | | { |
| | 0 | 139 | | new() |
| | 0 | 140 | | { |
| | 0 | 141 | | OfferId = offerId, |
| | 0 | 142 | | ProductId = productId, |
| | 0 | 143 | | Stock = 0, |
| | 0 | 144 | | WarehouseId = warehouseId |
| | 0 | 145 | | } |
| | 0 | 146 | | }; |
| | | 147 | | |
| | 0 | 148 | | var result = await apiManager.UpdateProductStocks(payload); |
| | 0 | 149 | | var row = result?.Result?.FirstOrDefault(); |
| | 0 | 150 | | var ozonOk = row != null && row.Updated && (row.Errors == null || !row.Errors.Any()); |
| | 0 | 151 | | var failError = ozonOk ? null : FormatStockErrors(row); |
| | | 152 | | |
| | 0 | 153 | | var reportLineWritten = false; |
| | | 154 | | try |
| | | 155 | | { |
| | 0 | 156 | | await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync( |
| | 0 | 157 | | db, reportId.Trim(), request.OzonClientId, article, warehouseId, |
| | 0 | 158 | | request.WarehouseName, ozonOk, ozonOk ? "Обнулён из превью (этап 3)" : failError, |
| | 0 | 159 | | qtyBefore, offerId, productId, cancellationToken); |
| | 0 | 160 | | await BitrixStockSyncReportService.RefreshReportOzonCleanupCountsAsync(db, reportId.Trim(), cancellation |
| | 0 | 161 | | reportLineWritten = true; |
| | 0 | 162 | | } |
| | 0 | 163 | | catch (Exception ex) |
| | | 164 | | { |
| | 0 | 165 | | if (!ozonOk) |
| | 0 | 166 | | throw; |
| | | 167 | | |
| | 0 | 168 | | return new BitrixStockSyncCleanupZeroResult |
| | 0 | 169 | | { |
| | 0 | 170 | | Success = true, |
| | 0 | 171 | | QtyBefore = qtyBefore, |
| | 0 | 172 | | ReportLineWritten = false, |
| | 0 | 173 | | Error = "Ozon обнулён, но строка отчёта не записана: " + ex.Message |
| | 0 | 174 | | }; |
| | | 175 | | } |
| | | 176 | | |
| | 0 | 177 | | if (!ozonOk) |
| | | 178 | | { |
| | 0 | 179 | | return new BitrixStockSyncCleanupZeroResult |
| | 0 | 180 | | { |
| | 0 | 181 | | Success = false, |
| | 0 | 182 | | Error = failError, |
| | 0 | 183 | | QtyBefore = qtyBefore, |
| | 0 | 184 | | ReportLineWritten = reportLineWritten |
| | 0 | 185 | | }; |
| | | 186 | | } |
| | | 187 | | |
| | 0 | 188 | | return new BitrixStockSyncCleanupZeroResult |
| | 0 | 189 | | { |
| | 0 | 190 | | Success = true, |
| | 0 | 191 | | QtyBefore = qtyBefore, |
| | 0 | 192 | | ReportLineWritten = reportLineWritten |
| | 0 | 193 | | }; |
| | 0 | 194 | | } |
| | | 195 | | finally |
| | | 196 | | { |
| | 0 | 197 | | HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection); |
| | | 198 | | } |
| | 0 | 199 | | } |
| | | 200 | | |
| | | 201 | | [Queue(HangfireQueueNames.Main)] |
| | | 202 | | public Task RunCleanupAsync( |
| | | 203 | | string reportId, |
| | | 204 | | BitrixStockSyncCleanupStartRequest request, |
| | | 205 | | string? progressJobId, |
| | | 206 | | CancellationToken cancellationToken = default) => |
| | 0 | 207 | | BuildPreviewOrRunAsync( |
| | 0 | 208 | | reportId, |
| | 0 | 209 | | request.OnlyPending, |
| | 0 | 210 | | request.MaxProducts, |
| | 0 | 211 | | request.Articles, |
| | 0 | 212 | | search: null, |
| | 0 | 213 | | skipGroups: 0, |
| | 0 | 214 | | dryRun: false, |
| | 0 | 215 | | jobId: progressJobId ?? request.JobId, |
| | 0 | 216 | | cancellationToken: cancellationToken, |
| | 0 | 217 | | stageSettings: request.StageSettings, |
| | 0 | 218 | | chained: request.Chained); |
| | | 219 | | |
| | | 220 | | private async Task<BitrixStockSyncCleanupPreviewDto> BuildPreviewOrRunAsync( |
| | | 221 | | string reportId, |
| | | 222 | | bool onlyPending, |
| | | 223 | | int? maxProducts, |
| | | 224 | | IReadOnlyList<string>? articles, |
| | | 225 | | string? search, |
| | | 226 | | int skipGroups, |
| | | 227 | | bool dryRun, |
| | | 228 | | string? jobId, |
| | | 229 | | CancellationToken cancellationToken, |
| | | 230 | | PresetStageSettings? stageSettings = null, |
| | | 231 | | bool chained = false) |
| | | 232 | | { |
| | 0 | 233 | | var hubId = jobId?.Trim() ?? string.Empty; |
| | 0 | 234 | | var hasHub = !string.IsNullOrWhiteSpace(hubId); |
| | | 235 | | |
| | | 236 | | async Task Log(string message) |
| | | 237 | | { |
| | 0 | 238 | | if (hasHub) |
| | 0 | 239 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken); |
| | 0 | 240 | | } |
| | | 241 | | |
| | 0 | 242 | | var lastProgressSentAt = DateTime.MinValue; |
| | | 243 | | async Task Progress(int current, int total, string text) |
| | | 244 | | { |
| | 0 | 245 | | if (!hasHub) return; |
| | 0 | 246 | | var now = DateTime.UtcNow; |
| | 0 | 247 | | if (current < total && (now - lastProgressSentAt).TotalMilliseconds < 250) return; |
| | 0 | 248 | | lastProgressSentAt = now; |
| | 0 | 249 | | await _jobProgressHub.Clients.Group(hubId) |
| | 0 | 250 | | .SendAsync("Progress", current, total, text ?? "", cancellationToken); |
| | 0 | 251 | | } |
| | | 252 | | |
| | | 253 | | async Task Complete(bool ok, object? payload) |
| | | 254 | | { |
| | 0 | 255 | | if (hasHub) |
| | 0 | 256 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken); |
| | 0 | 257 | | } |
| | | 258 | | |
| | 0 | 259 | | IDisposable? distLock = null; |
| | 0 | 260 | | IStorageConnection? lockConnection = null; |
| | | 261 | | |
| | | 262 | | try |
| | | 263 | | { |
| | 0 | 264 | | if (!dryRun) |
| | | 265 | | { |
| | | 266 | | try |
| | | 267 | | { |
| | 0 | 268 | | lockConnection = JobStorage.Current.GetConnection(); |
| | 0 | 269 | | distLock = lockConnection.AcquireDistributedLock( |
| | 0 | 270 | | BitrixStockPushJobService.PushLockKey, |
| | 0 | 271 | | TimeSpan.FromMilliseconds(1)); |
| | 0 | 272 | | } |
| | | 273 | | catch (DistributedLockTimeoutException) |
| | | 274 | | { |
| | | 275 | | const string msg = "Обновление остатков Ozon уже выполняется. Повторный запуск отменён."; |
| | 0 | 276 | | await Log(msg); |
| | 0 | 277 | | await Complete(false, msg); |
| | 0 | 278 | | return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId }; |
| | | 279 | | } |
| | | 280 | | } |
| | | 281 | | |
| | 0 | 282 | | if (!dryRun && !chained) |
| | | 283 | | { |
| | 0 | 284 | | _stageStore.Set(reportId, 3); |
| | | 285 | | // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа) |
| | 0 | 286 | | if (hasHub) await Task.Delay(800, cancellationToken); |
| | | 287 | | } |
| | 0 | 288 | | await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); |
| | 0 | 289 | | var status = await LoadReportStatusAsync(db, reportId, cancellationToken); |
| | 0 | 290 | | if (status == null) |
| | | 291 | | { |
| | 0 | 292 | | await Log("Отчёт не найден"); |
| | 0 | 293 | | await Complete(false, "Отчёт не найден"); |
| | 0 | 294 | | return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId }; |
| | | 295 | | } |
| | | 296 | | |
| | 0 | 297 | | if (!string.Equals(status, "Completed", StringComparison.OrdinalIgnoreCase)) |
| | | 298 | | { |
| | 0 | 299 | | await Log($"Отчёт в статусе «{status}» — дождитесь завершения формирования"); |
| | 0 | 300 | | await Complete(false, "Отчёт ещё не готов"); |
| | 0 | 301 | | return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId }; |
| | | 302 | | } |
| | | 303 | | |
| | | 304 | | var articleFilter = articles?.Where(a => !string.IsNullOrWhiteSpace(a)) |
| | | 305 | | .Select(a => a.Trim()) |
| | 0 | 306 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 307 | | .ToList(); |
| | 0 | 308 | | var allLines = await LoadCleanupReportLinesAsync(db, reportId, articleFilter, search, cancellationToken); |
| | 0 | 309 | | if (allLines.Count == 0) |
| | | 310 | | { |
| | 0 | 311 | | await Log("Нет строк с остатком > 0 для этапа 3"); |
| | 0 | 312 | | await Complete(true, new { reportId, total = 0 }); |
| | 0 | 313 | | return new BitrixStockSyncCleanupPreviewDto { ReportId = reportId }; |
| | | 314 | | } |
| | | 315 | | |
| | 0 | 316 | | var groups = allLines |
| | | 317 | | .GroupBy(l => $"{l.OzonClientId}||{l.Article}", StringComparer.OrdinalIgnoreCase) |
| | 0 | 318 | | .ToList(); |
| | | 319 | | |
| | 0 | 320 | | var preview = new BitrixStockSyncCleanupPreviewDto |
| | 0 | 321 | | { |
| | 0 | 322 | | ReportId = reportId, |
| | 0 | 323 | | TotalGroups = groups.Count |
| | 0 | 324 | | }; |
| | 0 | 325 | | var allOzonClients = await _ozonClientServices.GetOzonClients(); |
| | 0 | 326 | | var clientMap = allOzonClients |
| | | 327 | | .Where(c => c.ClientType == ClientType.OZON) |
| | | 328 | | .ToDictionary(c => c.Id); |
| | | 329 | | |
| | 0 | 330 | | var totalGroups = groups.Count; |
| | 0 | 331 | | var successCount = 0; |
| | 0 | 332 | | var failedCount = 0; |
| | 0 | 333 | | var logJobId = (jobId ?? Guid.NewGuid().ToString("N")).Trim(); |
| | 0 | 334 | | var previewMaxProducts = BitrixStockSyncCleanupPlanner.ResolvePreviewMaxProducts( |
| | 0 | 335 | | dryRun, maxProducts, articleFilter is { Count: > 0 }, PreviewDefaultMaxProducts); |
| | 0 | 336 | | var startOffset = dryRun ? Math.Max(0, skipGroups) : 0; |
| | 0 | 337 | | var scannedInRequest = 0; |
| | | 338 | | |
| | 0 | 339 | | await Log(dryRun |
| | 0 | 340 | | ? $"Этап 3 — превью: до {previewMaxProducts} артикулов (группы {startOffset + 1}… из {totalGroups})" |
| | 0 | 341 | | : $"Этап 3 — очистка лишних складов: {totalGroups} групп"); |
| | | 342 | | |
| | 0 | 343 | | var groupsToScan = BitrixStockSyncCleanupPlanner.SliceGroupsToScan( |
| | 0 | 344 | | groups, startOffset, dryRun, PreviewMaxGroupsPerRequest).ToList(); |
| | 0 | 345 | | var fbsCacheByClient = await LoadFbsWarehousesBatchByClientAsync( |
| | 0 | 346 | | groupsToScan, |
| | 0 | 347 | | clientMap, |
| | 0 | 348 | | dryRun, |
| | 0 | 349 | | Log, |
| | 0 | 350 | | Progress, |
| | 0 | 351 | | cancellationToken); |
| | | 352 | | |
| | 0 | 353 | | var scannedGroupIndex = 0; |
| | 0 | 354 | | foreach (var group in groupsToScan) |
| | | 355 | | { |
| | 0 | 356 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 357 | | scannedGroupIndex++; |
| | 0 | 358 | | if (dryRun) |
| | | 359 | | { |
| | 0 | 360 | | scannedInRequest++; |
| | 0 | 361 | | if (scannedInRequest > PreviewMaxGroupsPerRequest) |
| | | 362 | | { |
| | 0 | 363 | | preview.HasMore = true; |
| | 0 | 364 | | break; |
| | | 365 | | } |
| | | 366 | | } |
| | 0 | 367 | | else if (scannedGroupIndex % ScanProgressLogEveryGroups == 0 || scannedGroupIndex == groupsToScan.Count) |
| | | 368 | | { |
| | 0 | 369 | | await Log($"Сканирование групп: {startOffset + scannedGroupIndex}/{totalGroups}…"); |
| | 0 | 370 | | await Progress(startOffset + scannedGroupIndex, totalGroups, "Сканирование"); |
| | | 371 | | } |
| | | 372 | | |
| | 0 | 373 | | var lines = group.ToList(); |
| | 0 | 374 | | var first = lines[0]; |
| | 0 | 375 | | if (!clientMap.TryGetValue(first.OzonClientId, out var client) || |
| | 0 | 376 | | string.IsNullOrEmpty(client.DecryptClientId) || |
| | 0 | 377 | | string.IsNullOrEmpty(client.DecryptApiKey)) |
| | | 378 | | { |
| | 0 | 379 | | await Log($"✗ {first.Article}: клиент Ozon ID {first.OzonClientId} не найден"); |
| | 0 | 380 | | continue; |
| | | 381 | | } |
| | | 382 | | |
| | 0 | 383 | | if (BitrixStockSyncCleanupPlanner.HasUnresolvedWarehouseLines(lines)) |
| | | 384 | | { |
| | 0 | 385 | | await Log($"⚠ {first.Article}: в отчёте остаток Ozon без склада (FbsNoWarehouse) — очистка пропущена |
| | 0 | 386 | | continue; |
| | | 387 | | } |
| | | 388 | | |
| | 0 | 389 | | var protectedIds = BitrixStockSyncCleanupPlanner.BuildProtectedWarehouseIds(lines); |
| | 0 | 390 | | var cleanupStatusByWarehouse = lines |
| | | 391 | | .Where(l => l.OzonWarehouseId > 0) |
| | | 392 | | .GroupBy(l => l.OzonWarehouseId) |
| | | 393 | | .ToDictionary(g => g.Key, g => g.First().OzonCleanupStatus); |
| | | 394 | | |
| | | 395 | | List<BitrixStockSyncCleanupFbsWarehouse> fbsWarehouses; |
| | | 396 | | try |
| | | 397 | | { |
| | 0 | 398 | | fbsWarehouses = ResolveFbsWarehousesFromCache( |
| | 0 | 399 | | fbsCacheByClient, first.OzonClientId, first.OfferId); |
| | 0 | 400 | | } |
| | | 401 | | catch (Exception ex) |
| | | 402 | | { |
| | 0 | 403 | | await Log($"✗ {first.Article}: FBS API — {BitrixOzonStockErrorMapper.Map(ex.Message)}"); |
| | 0 | 404 | | continue; |
| | | 405 | | } |
| | | 406 | | |
| | 0 | 407 | | var toZero = BitrixStockSyncCleanupPlanner.SelectWarehousesToZero(protectedIds, fbsWarehouses); |
| | 0 | 408 | | if (!BitrixStockSyncCleanupPlanner.IsCleanupCandidate(toZero)) |
| | | 409 | | continue; |
| | | 410 | | |
| | 0 | 411 | | if (onlyPending && !BitrixStockSyncCleanupPlanner.IsGroupPending(toZero, cleanupStatusByWarehouse)) |
| | | 412 | | continue; |
| | | 413 | | |
| | 0 | 414 | | var productDto = BuildProductDto(lines, client, toZero, protectedIds, fbsWarehouses); |
| | 0 | 415 | | preview.Products.Add(productDto); |
| | 0 | 416 | | preview.TotalWarehousesToZero += productDto.WarehousesToZero.Count; |
| | | 417 | | |
| | 0 | 418 | | if (previewMaxProducts is > 0 && preview.Products.Count >= previewMaxProducts.Value) |
| | | 419 | | { |
| | 0 | 420 | | if (dryRun) |
| | 0 | 421 | | preview.HasMore = startOffset + scannedInRequest < totalGroups; |
| | 0 | 422 | | break; |
| | | 423 | | } |
| | 0 | 424 | | } |
| | | 425 | | |
| | 0 | 426 | | if (dryRun) |
| | | 427 | | { |
| | 0 | 428 | | preview.NextSkipGroups = startOffset + scannedInRequest; |
| | 0 | 429 | | preview.ScannedGroups = preview.NextSkipGroups; |
| | 0 | 430 | | if (!preview.HasMore) |
| | 0 | 431 | | preview.HasMore = preview.NextSkipGroups < totalGroups; |
| | 0 | 432 | | preview.IsTruncated = preview.HasMore; |
| | | 433 | | } |
| | | 434 | | else |
| | | 435 | | { |
| | 0 | 436 | | preview.ScannedGroups = totalGroups; |
| | | 437 | | } |
| | | 438 | | |
| | 0 | 439 | | preview.TotalProducts = preview.Products.Count; |
| | | 440 | | |
| | 0 | 441 | | if (dryRun) |
| | | 442 | | { |
| | 0 | 443 | | await Log($"Превью: {preview.TotalProducts} артикулов, {preview.TotalWarehousesToZero} складов к обнулен |
| | 0 | 444 | | (preview.HasMore ? $" (проверено {preview.ScannedGroups} из {totalGroups} групп, есть ещё)" : |
| | 0 | 445 | | await Complete(true, preview); |
| | 0 | 446 | | return preview; |
| | | 447 | | } |
| | | 448 | | |
| | 0 | 449 | | var runProducts = preview.Products; |
| | | 450 | | var totalOps = runProducts.Sum(p => p.WarehousesToZero.Count); |
| | 0 | 451 | | var processedOps = 0; |
| | | 452 | | |
| | 0 | 453 | | foreach (var product in runProducts) |
| | | 454 | | { |
| | 0 | 455 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 456 | | if (!clientMap.TryGetValue(product.OzonClientId, out var client)) |
| | | 457 | | continue; |
| | | 458 | | |
| | 0 | 459 | | var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | 0 | 460 | | var groupLines = allLines |
| | 0 | 461 | | .Where(l => l.OzonClientId == product.OzonClientId && |
| | 0 | 462 | | string.Equals(l.Article, product.Article, StringComparison.OrdinalIgnoreCase)) |
| | 0 | 463 | | .ToList(); |
| | | 464 | | |
| | 0 | 465 | | await Log($"--- {product.Article} ({product.OzonClientName}): обнулить {product.WarehousesToZero.Count} |
| | | 466 | | |
| | 0 | 467 | | for (var offset = 0; offset < product.WarehousesToZero.Count; offset += BatchSize) |
| | | 468 | | { |
| | 0 | 469 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 470 | | var batch = product.WarehousesToZero.Skip(offset).Take(BatchSize).ToList(); |
| | 0 | 471 | | var payload = batch.Select(w => new StockItemForUpdate |
| | 0 | 472 | | { |
| | 0 | 473 | | OfferId = product.OfferId, |
| | 0 | 474 | | ProductId = product.OzonProductId, |
| | 0 | 475 | | Stock = 0, |
| | 0 | 476 | | WarehouseId = w.WarehouseId |
| | 0 | 477 | | }).ToList(); |
| | | 478 | | |
| | 0 | 479 | | var pushItems = batch.Select(w => new BitrixStockPushItemDto |
| | 0 | 480 | | { |
| | 0 | 481 | | Article = product.Article, |
| | 0 | 482 | | OfferId = product.OfferId, |
| | 0 | 483 | | ProductId = product.OzonProductId, |
| | 0 | 484 | | WarehouseId = w.WarehouseId, |
| | 0 | 485 | | WarehouseName = w.WarehouseName, |
| | 0 | 486 | | OzonClient = product.OzonClientName, |
| | 0 | 487 | | PreviousStock = w.Present, |
| | 0 | 488 | | TargetStock = 0 |
| | 0 | 489 | | }).ToList(); |
| | | 490 | | |
| | 0 | 491 | | var handled = new HashSet<long>(); |
| | 0 | 492 | | var batchOk = await TryUpdateBatchAsync(apiManager, payload, pushItems, |
| | 0 | 493 | | async (item, error) => |
| | 0 | 494 | | { |
| | 0 | 495 | | var wh = batch.FirstOrDefault(b => b.WarehouseId == item.WarehouseId); |
| | 0 | 496 | | if (wh == null) return; |
| | 0 | 497 | | handled.Add(wh.WarehouseId); |
| | 0 | 498 | | processedOps++; |
| | 0 | 499 | | failedCount++; |
| | 0 | 500 | | await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync( |
| | 0 | 501 | | db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.WarehouseName, |
| | 0 | 502 | | false, error, wh.Present, product.OfferId, product.OzonProductId, cancellationToken); |
| | 0 | 503 | | await Log($"✗ {product.Article} ({wh.WarehouseName}): {error}"); |
| | 0 | 504 | | await WriteCleanupLogAsync(db, reportId, logJobId, product, wh, false, error, cancellationTo |
| | 0 | 505 | | await Progress(processedOps, totalOps, product.Article ?? ""); |
| | 0 | 506 | | }, |
| | 0 | 507 | | async item => |
| | 0 | 508 | | { |
| | 0 | 509 | | var wh = batch.FirstOrDefault(b => b.WarehouseId == item.WarehouseId); |
| | 0 | 510 | | if (wh == null) return; |
| | 0 | 511 | | handled.Add(wh.WarehouseId); |
| | 0 | 512 | | processedOps++; |
| | 0 | 513 | | successCount++; |
| | 0 | 514 | | const string note = "Обнулён пакетной очисткой (этап 3)"; |
| | 0 | 515 | | await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync( |
| | 0 | 516 | | db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.WarehouseName, |
| | 0 | 517 | | true, note, wh.Present, product.OfferId, product.OzonProductId, cancellationToken); |
| | 0 | 518 | | await Log($"⓪ {product.Article} ({wh.WarehouseName}): {wh.Present} → 0"); |
| | 0 | 519 | | await WriteCleanupLogAsync(db, reportId, logJobId, product, wh, true, note, cancellationToke |
| | 0 | 520 | | await Progress(processedOps, totalOps, product.Article ?? ""); |
| | 0 | 521 | | }, |
| | 0 | 522 | | cancellationToken); |
| | | 523 | | |
| | 0 | 524 | | if (!batchOk) |
| | | 525 | | { |
| | 0 | 526 | | foreach (var wh in batch.Where(w => !handled.Contains(w.WarehouseId))) |
| | | 527 | | { |
| | 0 | 528 | | var single = new List<StockItemForUpdate> |
| | 0 | 529 | | { |
| | 0 | 530 | | new() |
| | 0 | 531 | | { |
| | 0 | 532 | | OfferId = product.OfferId, |
| | 0 | 533 | | ProductId = product.OzonProductId, |
| | 0 | 534 | | Stock = 0, |
| | 0 | 535 | | WarehouseId = wh.WarehouseId |
| | 0 | 536 | | } |
| | 0 | 537 | | }; |
| | | 538 | | try |
| | | 539 | | { |
| | 0 | 540 | | var result = await apiManager.UpdateProductStocks(single); |
| | 0 | 541 | | var row = result?.Result?.FirstOrDefault(); |
| | 0 | 542 | | if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any())) |
| | | 543 | | { |
| | 0 | 544 | | processedOps++; |
| | 0 | 545 | | successCount++; |
| | 0 | 546 | | await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync( |
| | 0 | 547 | | db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.Warehous |
| | 0 | 548 | | true, "Обнулён пакетной очисткой (этап 3)", wh.Present, |
| | 0 | 549 | | product.OfferId, product.OzonProductId, cancellationToken); |
| | 0 | 550 | | await Log($"⓪ {product.Article} ({wh.WarehouseName}): повтор по одной"); |
| | | 551 | | } |
| | | 552 | | else |
| | | 553 | | { |
| | 0 | 554 | | var err = FormatStockErrors(row); |
| | 0 | 555 | | processedOps++; |
| | 0 | 556 | | failedCount++; |
| | 0 | 557 | | await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync( |
| | 0 | 558 | | db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.Warehous |
| | 0 | 559 | | false, err, wh.Present, product.OfferId, product.OzonProductId, cancellationToke |
| | 0 | 560 | | await Log($"✗ {product.Article}: {err}"); |
| | 0 | 561 | | } |
| | 0 | 562 | | } |
| | 0 | 563 | | catch (Exception ex) |
| | | 564 | | { |
| | 0 | 565 | | processedOps++; |
| | 0 | 566 | | failedCount++; |
| | 0 | 567 | | await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync( |
| | 0 | 568 | | db, reportId, product.OzonClientId, product.Article, wh.WarehouseId, wh.WarehouseNam |
| | 0 | 569 | | false, BitrixOzonStockErrorMapper.Map(ex.Message), wh.Present, |
| | 0 | 570 | | product.OfferId, product.OzonProductId, cancellationToken); |
| | 0 | 571 | | await Log($"✗ {product.Article}: {BitrixOzonStockErrorMapper.Map(ex.Message)}"); |
| | 0 | 572 | | } |
| | | 573 | | |
| | 0 | 574 | | await Progress(processedOps, totalOps, product.Article ?? ""); |
| | 0 | 575 | | await Task.Delay(200, cancellationToken); |
| | 0 | 576 | | } |
| | | 577 | | } |
| | | 578 | | |
| | 0 | 579 | | if (offset + BatchSize < product.WarehousesToZero.Count) |
| | 0 | 580 | | await Task.Delay(DelayBetweenBatchesMs, cancellationToken); |
| | 0 | 581 | | } |
| | | 582 | | |
| | 0 | 583 | | foreach (var protectedWh in product.ProtectedWarehouses) |
| | | 584 | | { |
| | 0 | 585 | | await BitrixStockSyncCleanupLineWriter.UpsertCleanupWarehouseAsync( |
| | 0 | 586 | | db, reportId, product.OzonClientId, product.Article, protectedWh.WarehouseId, |
| | 0 | 587 | | protectedWh.WarehouseName, true, "Сохранён (этап 3)", protectedWh.Present, |
| | 0 | 588 | | product.OfferId, product.OzonProductId, cancellationToken); |
| | | 589 | | } |
| | | 590 | | |
| | 0 | 591 | | } |
| | | 592 | | |
| | 0 | 593 | | await BitrixStockSyncReportService.RefreshReportOzonCleanupCountsAsync(db, reportId, cancellationToken); |
| | 0 | 594 | | await Log($"Этап 3 готово: успешно {successCount}, ошибок {failedCount} из {totalOps}"); |
| | 0 | 595 | | await Progress(totalOps, totalOps, "Очистка завершена"); |
| | 0 | 596 | | await Complete(true, new { reportId, successCount, failedCount, totalOps }); |
| | | 597 | | |
| | | 598 | | // Цепочкой этапов (4→5) управляет BitrixStockSyncPipelineOrchestrator. |
| | 0 | 599 | | return preview; |
| | 0 | 600 | | } |
| | 0 | 601 | | catch (Exception ex) |
| | | 602 | | { |
| | 0 | 603 | | await Log("Ошибка: " + ex.Message); |
| | 0 | 604 | | await Complete(false, ex.Message); |
| | 0 | 605 | | throw; |
| | 0 | 606 | | } |
| | | 607 | | finally |
| | | 608 | | { |
| | 0 | 609 | | if (!dryRun && !chained) _stageStore.Clear(reportId); |
| | 0 | 610 | | HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection); |
| | | 611 | | } |
| | 0 | 612 | | } |
| | | 613 | | |
| | | 614 | | private static BitrixStockSyncCleanupProductDto BuildProductDto( |
| | | 615 | | List<BitrixStockSyncCleanupReportLine> lines, |
| | | 616 | | OzonClient client, |
| | | 617 | | IReadOnlyList<BitrixStockSyncCleanupWarehouseTarget> toZero, |
| | | 618 | | IReadOnlySet<long> protectedIds, |
| | | 619 | | IReadOnlyList<BitrixStockSyncCleanupFbsWarehouse> fbsWarehouses) |
| | | 620 | | { |
| | 0 | 621 | | var first = lines[0]; |
| | 0 | 622 | | var lineByWarehouse = lines |
| | 0 | 623 | | .Where(l => l.OzonWarehouseId > 0) |
| | 0 | 624 | | .GroupBy(l => l.OzonWarehouseId) |
| | 0 | 625 | | .ToDictionary(g => g.Key, g => g.First()); |
| | | 626 | | |
| | 0 | 627 | | var protectedDtos = protectedIds |
| | 0 | 628 | | .Select(id => |
| | 0 | 629 | | { |
| | 0 | 630 | | lineByWarehouse.TryGetValue(id, out var line); |
| | 0 | 631 | | var fbs = fbsWarehouses.FirstOrDefault(w => w.WarehouseId == id); |
| | 0 | 632 | | return new BitrixStockSyncCleanupWarehouseDto |
| | 0 | 633 | | { |
| | 0 | 634 | | WarehouseId = id, |
| | 0 | 635 | | WarehouseName = line?.OzonWarehouseName ?? fbs?.WarehouseName ?? $"#{id}", |
| | 0 | 636 | | Present = line?.EffectiveQty ?? fbs?.Present ?? 0, |
| | 0 | 637 | | ReportLineId = line?.LineId, |
| | 0 | 638 | | CleanupStatus = line?.OzonCleanupStatus, |
| | 0 | 639 | | CleanupError = null |
| | 0 | 640 | | }; |
| | 0 | 641 | | }) |
| | 0 | 642 | | .OrderBy(w => w.WarehouseName, StringComparer.OrdinalIgnoreCase) |
| | 0 | 643 | | .ToList(); |
| | | 644 | | |
| | 0 | 645 | | var toZeroDtos = toZero.Select(w => |
| | 0 | 646 | | { |
| | 0 | 647 | | lineByWarehouse.TryGetValue(w.WarehouseId, out var line); |
| | 0 | 648 | | return new BitrixStockSyncCleanupWarehouseDto |
| | 0 | 649 | | { |
| | 0 | 650 | | WarehouseId = w.WarehouseId, |
| | 0 | 651 | | WarehouseName = w.WarehouseName, |
| | 0 | 652 | | Present = w.Present, |
| | 0 | 653 | | ReportLineId = line?.LineId, |
| | 0 | 654 | | CleanupStatus = line?.OzonCleanupStatus |
| | 0 | 655 | | }; |
| | 0 | 656 | | }).ToList(); |
| | | 657 | | |
| | 0 | 658 | | return new BitrixStockSyncCleanupProductDto |
| | 0 | 659 | | { |
| | 0 | 660 | | OzonClientId = first.OzonClientId, |
| | 0 | 661 | | OzonClientName = client.Name ?? first.OzonClientId.ToString(), |
| | 0 | 662 | | Article = first.Article, |
| | 0 | 663 | | OfferId = first.OfferId ?? string.Empty, |
| | 0 | 664 | | OzonProductId = first.OzonProductId ?? 0, |
| | 0 | 665 | | ProtectedWarehouses = protectedDtos, |
| | 0 | 666 | | WarehousesToZero = toZeroDtos |
| | 0 | 667 | | }; |
| | | 668 | | } |
| | | 669 | | |
| | | 670 | | private async Task<Dictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>>> LoadFbsWarehousesB |
| | | 671 | | IReadOnlyList<IGrouping<string, BitrixStockSyncCleanupReportLine>> groupsToScan, |
| | | 672 | | IReadOnlyDictionary<int, OzonClient> clientMap, |
| | | 673 | | bool dryRun, |
| | | 674 | | Func<string, Task> log, |
| | | 675 | | Func<int, int, string, Task> progress, |
| | | 676 | | CancellationToken cancellationToken) |
| | | 677 | | { |
| | 0 | 678 | | var result = new Dictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>>(); |
| | 0 | 679 | | var groupsByClient = groupsToScan |
| | | 680 | | .Select(g => g.ToList()) |
| | | 681 | | .Where(lines => lines.Count > 0) |
| | | 682 | | .GroupBy(lines => lines[0].OzonClientId) |
| | 0 | 683 | | .ToList(); |
| | | 684 | | |
| | 0 | 685 | | foreach (var clientGroups in groupsByClient) |
| | | 686 | | { |
| | 0 | 687 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 688 | | if (!clientMap.TryGetValue(clientGroups.Key, out var client) || |
| | 0 | 689 | | string.IsNullOrEmpty(client.DecryptClientId) || |
| | 0 | 690 | | string.IsNullOrEmpty(client.DecryptApiKey)) |
| | | 691 | | { |
| | | 692 | | continue; |
| | | 693 | | } |
| | | 694 | | |
| | 0 | 695 | | var offerIds = clientGroups |
| | | 696 | | .Select(lines => lines[0].OfferId) |
| | | 697 | | .Where(offerId => !string.IsNullOrWhiteSpace(offerId)) |
| | | 698 | | .Select(offerId => offerId!.Trim()) |
| | 0 | 699 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 700 | | .ToList(); |
| | 0 | 701 | | if (offerIds.Count == 0) |
| | | 702 | | { |
| | 0 | 703 | | result[clientGroups.Key] = new Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>(StringCompar |
| | 0 | 704 | | continue; |
| | | 705 | | } |
| | | 706 | | |
| | 0 | 707 | | await log(dryRun |
| | 0 | 708 | | ? $"Загрузка FBS Ozon: {offerIds.Count} артикулов ({client.Name})…" |
| | 0 | 709 | | : $"Загрузка FBS Ozon пакетами: {offerIds.Count} артикулов ({client.Name})…"); |
| | | 710 | | |
| | 0 | 711 | | var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | 0 | 712 | | var cache = await LoadFbsWarehousesBatchAsync(apiManager, offerIds, log, cancellationToken); |
| | 0 | 713 | | result[clientGroups.Key] = cache; |
| | 0 | 714 | | await progress(offerIds.Count, offerIds.Count, "FBS загружен"); |
| | 0 | 715 | | } |
| | | 716 | | |
| | 0 | 717 | | return result; |
| | 0 | 718 | | } |
| | | 719 | | |
| | | 720 | | private static List<BitrixStockSyncCleanupFbsWarehouse> ResolveFbsWarehousesFromCache( |
| | | 721 | | IReadOnlyDictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>> cacheByClient, |
| | | 722 | | int ozonClientId, |
| | | 723 | | string? offerId) |
| | | 724 | | { |
| | 0 | 725 | | if (string.IsNullOrWhiteSpace(offerId)) |
| | 0 | 726 | | return new List<BitrixStockSyncCleanupFbsWarehouse>(); |
| | | 727 | | |
| | 0 | 728 | | if (!cacheByClient.TryGetValue(ozonClientId, out var cache) || |
| | 0 | 729 | | !cache.TryGetValue(offerId.Trim(), out var warehouses)) |
| | | 730 | | { |
| | 0 | 731 | | return new List<BitrixStockSyncCleanupFbsWarehouse>(); |
| | | 732 | | } |
| | | 733 | | |
| | 0 | 734 | | return warehouses; |
| | | 735 | | } |
| | | 736 | | |
| | | 737 | | private static async Task<Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>> LoadFbsWarehousesBatchAsync( |
| | | 738 | | OzonApiDataManager apiManager, |
| | | 739 | | IReadOnlyList<string> offerIds, |
| | | 740 | | Func<string, Task> log, |
| | | 741 | | CancellationToken cancellationToken) |
| | | 742 | | { |
| | 0 | 743 | | var result = new Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 744 | | if (offerIds.Count == 0) |
| | 0 | 745 | | return result; |
| | | 746 | | |
| | 0 | 747 | | var distinctOfferIds = offerIds |
| | | 748 | | .Where(id => !string.IsNullOrWhiteSpace(id)) |
| | | 749 | | .Select(id => id.Trim()) |
| | 0 | 750 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 751 | | .ToList(); |
| | | 752 | | |
| | 0 | 753 | | var offerToSkus = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 754 | | var stockChunks = (distinctOfferIds.Count + FbsStocksChunkSize - 1) / FbsStocksChunkSize; |
| | 0 | 755 | | for (var chunkIndex = 0; chunkIndex < stockChunks; chunkIndex++) |
| | | 756 | | { |
| | 0 | 757 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 758 | | var chunk = distinctOfferIds |
| | 0 | 759 | | .Skip(chunkIndex * FbsStocksChunkSize) |
| | 0 | 760 | | .Take(FbsStocksChunkSize) |
| | 0 | 761 | | .ToArray(); |
| | 0 | 762 | | var stocks = await apiManager.GetProductStocks(chunk); |
| | 0 | 763 | | MergeOfferSkusFromStocks(stocks, offerToSkus); |
| | 0 | 764 | | if (stockChunks > 1) |
| | 0 | 765 | | await log($"FBS stocks: {chunkIndex + 1}/{stockChunks} пакетов"); |
| | 0 | 766 | | if (chunkIndex < stockChunks - 1) |
| | 0 | 767 | | await Task.Delay(600, cancellationToken); |
| | | 768 | | } |
| | | 769 | | |
| | 0 | 770 | | var allSkus = offerToSkus.Values |
| | | 771 | | .SelectMany(set => set) |
| | 0 | 772 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 773 | | .ToList(); |
| | 0 | 774 | | if (allSkus.Count == 0) |
| | 0 | 775 | | return result; |
| | | 776 | | |
| | 0 | 777 | | var skuToWarehouses = new Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>(StringComparer.OrdinalIgn |
| | 0 | 778 | | var skuChunks = (allSkus.Count + FbsSkuChunkSize - 1) / FbsSkuChunkSize; |
| | 0 | 779 | | for (var chunkIndex = 0; chunkIndex < skuChunks; chunkIndex++) |
| | | 780 | | { |
| | 0 | 781 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 782 | | var chunk = allSkus |
| | 0 | 783 | | .Skip(chunkIndex * FbsSkuChunkSize) |
| | 0 | 784 | | .Take(FbsSkuChunkSize) |
| | 0 | 785 | | .ToArray(); |
| | 0 | 786 | | var fbsResponse = await apiManager.GetProductWarehousesForBitrix(chunk, Array.Empty<string>()); |
| | 0 | 787 | | foreach (var pair in GroupFbsWarehousesBySku(ParseFbsWarehouses(fbsResponse))) |
| | | 788 | | { |
| | 0 | 789 | | if (!skuToWarehouses.TryGetValue(pair.Key, out var list)) |
| | | 790 | | { |
| | 0 | 791 | | list = new List<BitrixStockSyncCleanupFbsWarehouse>(); |
| | 0 | 792 | | skuToWarehouses[pair.Key] = list; |
| | | 793 | | } |
| | | 794 | | |
| | 0 | 795 | | list.AddRange(pair.Value); |
| | | 796 | | } |
| | | 797 | | |
| | 0 | 798 | | if (skuChunks > 1) |
| | 0 | 799 | | await log($"FBS warehouses: {chunkIndex + 1}/{skuChunks} пакетов"); |
| | 0 | 800 | | if (chunkIndex < skuChunks - 1) |
| | 0 | 801 | | await Task.Delay(600, cancellationToken); |
| | | 802 | | } |
| | | 803 | | |
| | 0 | 804 | | foreach (var (offerId, skus) in offerToSkus) |
| | | 805 | | { |
| | 0 | 806 | | var merged = new Dictionary<long, BitrixStockSyncCleanupFbsWarehouse>(); |
| | 0 | 807 | | foreach (var sku in skus) |
| | | 808 | | { |
| | 0 | 809 | | if (!skuToWarehouses.TryGetValue(sku, out var warehouses)) |
| | | 810 | | continue; |
| | | 811 | | |
| | 0 | 812 | | foreach (var warehouse in warehouses) |
| | | 813 | | { |
| | 0 | 814 | | merged[warehouse.WarehouseId] = warehouse; |
| | | 815 | | } |
| | | 816 | | } |
| | | 817 | | |
| | 0 | 818 | | result[offerId] = merged.Values |
| | | 819 | | .OrderBy(w => w.WarehouseName, StringComparer.OrdinalIgnoreCase) |
| | 0 | 820 | | .ToList(); |
| | | 821 | | } |
| | | 822 | | |
| | 0 | 823 | | return result; |
| | 0 | 824 | | } |
| | | 825 | | |
| | | 826 | | private async Task<List<BitrixStockSyncCleanupFbsWarehouse>> LoadFbsWarehousesAsync( |
| | | 827 | | OzonApiDataManager apiManager, |
| | | 828 | | string offerId, |
| | | 829 | | CancellationToken cancellationToken) |
| | | 830 | | { |
| | 0 | 831 | | var cache = await LoadFbsWarehousesBatchAsync( |
| | 0 | 832 | | apiManager, |
| | 0 | 833 | | new[] { offerId }, |
| | | 834 | | _ => Task.CompletedTask, |
| | 0 | 835 | | cancellationToken); |
| | 0 | 836 | | return ResolveFbsWarehousesFromCache( |
| | 0 | 837 | | new Dictionary<int, Dictionary<string, List<BitrixStockSyncCleanupFbsWarehouse>>> |
| | 0 | 838 | | { |
| | 0 | 839 | | [0] = cache |
| | 0 | 840 | | }, |
| | 0 | 841 | | 0, |
| | 0 | 842 | | offerId); |
| | 0 | 843 | | } |
| | | 844 | | |
| | | 845 | | private static void MergeOfferSkusFromStocks( |
| | | 846 | | JObject? stocks, |
| | | 847 | | IDictionary<string, HashSet<string>> offerToSkus) |
| | | 848 | | { |
| | 0 | 849 | | if (stocks?["items"] is not JArray items) |
| | 0 | 850 | | return; |
| | | 851 | | |
| | 0 | 852 | | foreach (var stockItem in items) |
| | | 853 | | { |
| | 0 | 854 | | var offerId = stockItem["offer_id"]?.ToString(); |
| | 0 | 855 | | if (string.IsNullOrWhiteSpace(offerId)) |
| | | 856 | | continue; |
| | | 857 | | |
| | 0 | 858 | | if (!offerToSkus.TryGetValue(offerId, out var skus)) |
| | | 859 | | { |
| | 0 | 860 | | skus = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 861 | | offerToSkus[offerId] = skus; |
| | | 862 | | } |
| | | 863 | | |
| | 0 | 864 | | if (stockItem["stocks"] is not JArray stockRows) |
| | | 865 | | continue; |
| | | 866 | | |
| | 0 | 867 | | foreach (var s in stockRows) |
| | | 868 | | { |
| | 0 | 869 | | var sku = s["sku"]?.ToString(); |
| | 0 | 870 | | if (!string.IsNullOrEmpty(sku)) |
| | 0 | 871 | | skus.Add(sku); |
| | | 872 | | } |
| | | 873 | | } |
| | 0 | 874 | | } |
| | | 875 | | |
| | | 876 | | private static IEnumerable<KeyValuePair<string, List<BitrixStockSyncCleanupFbsWarehouse>>> GroupFbsWarehousesBySku( |
| | | 877 | | IEnumerable<BitrixStockSyncCleanupFbsWarehouse> warehouses) |
| | | 878 | | { |
| | 0 | 879 | | return warehouses |
| | 0 | 880 | | .Where(w => w.Sku > 0) |
| | 0 | 881 | | .GroupBy(w => w.Sku.ToString(), StringComparer.OrdinalIgnoreCase) |
| | 0 | 882 | | .Select(g => new KeyValuePair<string, List<BitrixStockSyncCleanupFbsWarehouse>>(g.Key, g.ToList())); |
| | | 883 | | } |
| | | 884 | | |
| | | 885 | | internal static List<BitrixStockSyncCleanupFbsWarehouse> ParseFbsWarehouses(JObject? response) |
| | | 886 | | { |
| | 3 | 887 | | var list = new List<BitrixStockSyncCleanupFbsWarehouse>(); |
| | 3 | 888 | | var arr = response?["result"] as JArray ?? response?["products"] as JArray; |
| | 3 | 889 | | if (arr == null) return list; |
| | | 890 | | |
| | 10 | 891 | | foreach (var entry in arr) |
| | | 892 | | { |
| | 2 | 893 | | var warehouseId = entry["warehouse_id"]?.Value<long>() ?? 0; |
| | 2 | 894 | | if (warehouseId <= 0) continue; |
| | 2 | 895 | | list.Add(new BitrixStockSyncCleanupFbsWarehouse |
| | 2 | 896 | | { |
| | 2 | 897 | | WarehouseId = warehouseId, |
| | 2 | 898 | | WarehouseName = entry["warehouse_name"]?.ToString() ?? $"#{warehouseId}", |
| | 2 | 899 | | Present = entry["present"]?.Value<int>() ?? 0, |
| | 2 | 900 | | ProductId = entry["product_id"]?.Value<long>() ?? 0, |
| | 2 | 901 | | Sku = entry["sku"]?.Value<long>() ?? 0 |
| | 2 | 902 | | }); |
| | | 903 | | } |
| | | 904 | | |
| | 3 | 905 | | return list; |
| | | 906 | | } |
| | | 907 | | |
| | | 908 | | private static async Task<string?> LoadReportStatusAsync( |
| | | 909 | | ApplicationDbContext db, |
| | | 910 | | string reportId, |
| | | 911 | | CancellationToken cancellationToken) |
| | | 912 | | { |
| | 0 | 913 | | var rows = await db.Database.SqlQueryRaw<string>( |
| | 0 | 914 | | "SELECT Status FROM BitrixStockSyncReports WHERE ReportId = {0} LIMIT 1", |
| | 0 | 915 | | reportId.Trim()).ToListAsync(cancellationToken); |
| | 0 | 916 | | return rows.FirstOrDefault(); |
| | 0 | 917 | | } |
| | | 918 | | |
| | | 919 | | private static async Task<List<BitrixStockSyncCleanupReportLine>> LoadCleanupReportLinesAsync( |
| | | 920 | | ApplicationDbContext db, |
| | | 921 | | string reportId, |
| | | 922 | | IReadOnlyList<string>? articles, |
| | | 923 | | string? search, |
| | | 924 | | CancellationToken cancellationToken) |
| | | 925 | | { |
| | 0 | 926 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | 0 | 927 | | { |
| | 0 | 928 | | var sql = @" |
| | 0 | 929 | | SELECT l.Id, COALESCE(l.OzonClientId, 0), l.Article, l.OfferId, l.OzonProductId, |
| | 0 | 930 | | l.OzonWarehouseId, l.OzonWarehouseName, |
| | 0 | 931 | | COALESCE(l.OzonQtyAfter, l.OzonQty) AS EffectiveQty, |
| | 0 | 932 | | l.OzonCleanupStatus |
| | 0 | 933 | | FROM BitrixStockSyncReportLines l |
| | 0 | 934 | | WHERE l.ReportId = @reportId |
| | 0 | 935 | | AND COALESCE(l.OzonQtyAfter, l.OzonQty) > 0 |
| | 0 | 936 | | AND l.OzonProductId IS NOT NULL AND l.OzonProductId > 0 |
| | 0 | 937 | | AND l.OfferId IS NOT NULL AND l.OfferId != ''"; |
| | 0 | 938 | | |
| | 0 | 939 | | if (articles is { Count: > 0 }) |
| | 0 | 940 | | { |
| | | 941 | | var artIn = string.Join(", ", articles.Select((_, i) => $"@art{i}")); |
| | 0 | 942 | | sql += $" AND l.Article IN ({artIn})"; |
| | 0 | 943 | | } |
| | 0 | 944 | | |
| | 0 | 945 | | if (!string.IsNullOrWhiteSpace(search)) |
| | 0 | 946 | | sql += " AND l.Article LIKE @search"; |
| | 0 | 947 | | |
| | 0 | 948 | | sql += " ORDER BY l.OzonClientId, l.Article, l.OzonWarehouseId"; |
| | 0 | 949 | | |
| | 0 | 950 | | var cmd = connection.CreateCommand(); |
| | 0 | 951 | | cmd.CommandText = sql; |
| | 0 | 952 | | AddParam(cmd, "@reportId", reportId.Trim()); |
| | 0 | 953 | | if (articles is { Count: > 0 }) |
| | 0 | 954 | | { |
| | 0 | 955 | | for (var i = 0; i < articles.Count; i++) |
| | 0 | 956 | | AddParam(cmd, $"@art{i}", articles[i]); |
| | 0 | 957 | | } |
| | 0 | 958 | | |
| | 0 | 959 | | if (!string.IsNullOrWhiteSpace(search)) |
| | 0 | 960 | | AddParam(cmd, "@search", $"%{search.Trim()}%"); |
| | 0 | 961 | | |
| | 0 | 962 | | var list = new List<BitrixStockSyncCleanupReportLine>(); |
| | 0 | 963 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 964 | | while (await reader.ReadAsync(ct)) |
| | 0 | 965 | | { |
| | 0 | 966 | | list.Add(new BitrixStockSyncCleanupReportLine |
| | 0 | 967 | | { |
| | 0 | 968 | | LineId = reader.GetInt64(0), |
| | 0 | 969 | | OzonClientId = reader.GetInt32(1), |
| | 0 | 970 | | Article = reader.GetString(2), |
| | 0 | 971 | | OfferId = reader.IsDBNull(3) ? null : reader.GetString(3), |
| | 0 | 972 | | OzonProductId = reader.IsDBNull(4) ? null : reader.GetInt64(4), |
| | 0 | 973 | | OzonWarehouseId = reader.GetInt64(5), |
| | 0 | 974 | | OzonWarehouseName = reader.IsDBNull(6) ? string.Empty : reader.GetString(6), |
| | 0 | 975 | | EffectiveQty = reader.GetInt32(7), |
| | 0 | 976 | | OzonCleanupStatus = reader.IsDBNull(8) ? null : reader.GetString(8) |
| | 0 | 977 | | }); |
| | 0 | 978 | | } |
| | 0 | 979 | | |
| | 0 | 980 | | return list; |
| | 0 | 981 | | }, cancellationToken); |
| | 0 | 982 | | } |
| | | 983 | | |
| | | 984 | | private async Task WriteCleanupLogAsync( |
| | | 985 | | ApplicationDbContext db, |
| | | 986 | | string reportId, |
| | | 987 | | string logJobId, |
| | | 988 | | BitrixStockSyncCleanupProductDto product, |
| | | 989 | | BitrixStockSyncCleanupWarehouseDto wh, |
| | | 990 | | bool ok, |
| | | 991 | | string? error, |
| | | 992 | | CancellationToken cancellationToken) |
| | | 993 | | { |
| | | 994 | | try |
| | | 995 | | { |
| | 0 | 996 | | await _articleLog.WriteAsync(db, reportId, logJobId, new BitrixStockSyncApplyArticleLogEntry |
| | 0 | 997 | | { |
| | 0 | 998 | | Article = product.Article ?? "", |
| | 0 | 999 | | OneCStore = wh.WarehouseName ?? "", |
| | 0 | 1000 | | Client1 = new ApplyArticleClientEntry |
| | 0 | 1001 | | { |
| | 0 | 1002 | | ClientId = product.OzonClientId, |
| | 0 | 1003 | | ClientName = product.OzonClientName, |
| | 0 | 1004 | | QtyBefore = wh.Present, |
| | 0 | 1005 | | QtyAfter = ok ? 0 : null, |
| | 0 | 1006 | | ActionCode = ok ? ApplyArticleActionCodes.CleanupZero : "FAILED", |
| | 0 | 1007 | | ActionLabel = ok ? $"{wh.Present} → 0" : error |
| | 0 | 1008 | | }, |
| | 0 | 1009 | | Summary = ok |
| | 0 | 1010 | | ? $"⓪ {product.Article} ({wh.WarehouseName}): {wh.Present} → 0 [этап 3]" |
| | 0 | 1011 | | : $"✗ {product.Article} ({wh.WarehouseName}): {error}" |
| | 0 | 1012 | | }, cancellationToken); |
| | 0 | 1013 | | } |
| | 0 | 1014 | | catch |
| | | 1015 | | { |
| | | 1016 | | // журнал не должен прерывать cleanup |
| | 0 | 1017 | | } |
| | 0 | 1018 | | } |
| | | 1019 | | |
| | | 1020 | | private static async Task<bool> TryUpdateBatchAsync( |
| | | 1021 | | OzonApiDataManager apiManager, |
| | | 1022 | | List<StockItemForUpdate> stockPayload, |
| | | 1023 | | List<BitrixStockPushItemDto> batchItems, |
| | | 1024 | | Func<BitrixStockPushItemDto, string, Task> onFailed, |
| | | 1025 | | Func<BitrixStockPushItemDto, Task> onSuccess, |
| | | 1026 | | CancellationToken cancellationToken) |
| | | 1027 | | { |
| | | 1028 | | try |
| | | 1029 | | { |
| | 0 | 1030 | | var result = await apiManager.UpdateProductStocks(stockPayload); |
| | 0 | 1031 | | if (result?.Result == null || result.Result.Count == 0) |
| | 0 | 1032 | | return false; |
| | | 1033 | | |
| | | 1034 | | var byKey = batchItems.ToDictionary(i => $"{i.ProductId}_{i.WarehouseId}", i => i); |
| | 0 | 1035 | | var allOk = true; |
| | 0 | 1036 | | foreach (var row in result.Result) |
| | | 1037 | | { |
| | 0 | 1038 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 1039 | | var key = $"{row.ProductId}_{row.WarehouseId}"; |
| | 0 | 1040 | | if (!byKey.TryGetValue(key, out var item)) |
| | | 1041 | | continue; |
| | | 1042 | | |
| | 0 | 1043 | | if (row.Updated && (row.Errors == null || !row.Errors.Any())) |
| | 0 | 1044 | | await onSuccess(item); |
| | | 1045 | | else |
| | | 1046 | | { |
| | 0 | 1047 | | allOk = false; |
| | 0 | 1048 | | await onFailed(item, FormatStockErrors(row)); |
| | | 1049 | | } |
| | | 1050 | | } |
| | | 1051 | | |
| | 0 | 1052 | | return allOk; |
| | | 1053 | | } |
| | 0 | 1054 | | catch |
| | | 1055 | | { |
| | 0 | 1056 | | return false; |
| | | 1057 | | } |
| | 0 | 1058 | | } |
| | | 1059 | | |
| | | 1060 | | private static string FormatStockErrors(UpdateStockResult? row) |
| | | 1061 | | { |
| | 0 | 1062 | | if (row?.Errors != null && row.Errors.Any()) |
| | 0 | 1063 | | return string.Join("; ", row.Errors.Select(e => BitrixOzonStockErrorMapper.Map(e.Message ?? e.Code))); |
| | 0 | 1064 | | return row?.Updated == true ? string.Empty : "Ozon не обновил остаток"; |
| | | 1065 | | } |
| | | 1066 | | |
| | | 1067 | | private static async Task<T> WithOpenConnectionAsync<T>( |
| | | 1068 | | ApplicationDbContext db, |
| | | 1069 | | Func<System.Data.Common.DbConnection, CancellationToken, Task<T>> action, |
| | | 1070 | | CancellationToken cancellationToken) |
| | | 1071 | | { |
| | 0 | 1072 | | await db.Database.OpenConnectionAsync(cancellationToken); |
| | | 1073 | | try |
| | | 1074 | | { |
| | 0 | 1075 | | return await action(db.Database.GetDbConnection(), cancellationToken); |
| | | 1076 | | } |
| | | 1077 | | finally |
| | | 1078 | | { |
| | 0 | 1079 | | await db.Database.CloseConnectionAsync(); |
| | | 1080 | | } |
| | 0 | 1081 | | } |
| | | 1082 | | |
| | | 1083 | | private static void AddParam(System.Data.Common.DbCommand cmd, string name, object? value) |
| | | 1084 | | { |
| | 0 | 1085 | | var p = cmd.CreateParameter(); |
| | 0 | 1086 | | p.ParameterName = name; |
| | 0 | 1087 | | p.Value = value ?? DBNull.Value; |
| | 0 | 1088 | | cmd.Parameters.Add(p); |
| | 0 | 1089 | | } |
| | | 1090 | | } |