| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using OzonDomains; |
| | | 3 | | using OzonDomains.Models; |
| | | 4 | | using OzonRepositories.Context; |
| | | 5 | | using Services.ApiServices.OzonApi; |
| | | 6 | | using Services.ApiServices.OzonApi.Filters; |
| | | 7 | | |
| | | 8 | | namespace Services.BitrixStockServices; |
| | | 9 | | |
| | | 10 | | internal sealed class BitrixStockApplyDisabledZeroResult |
| | | 11 | | { |
| | 0 | 12 | | public int Processed { get; set; } |
| | 0 | 13 | | public int Total { get; set; } |
| | 0 | 14 | | public int SuccessCount { get; set; } |
| | 0 | 15 | | public int FailedCount { get; set; } |
| | | 16 | | } |
| | | 17 | | |
| | | 18 | | public partial class BitrixStockSyncReportApplyService |
| | | 19 | | { |
| | | 20 | | private async Task<BitrixStockApplyDisabledZeroResult> ZeroDisabledWarehouseStocksAsync( |
| | | 21 | | ApplicationDbContext db, |
| | | 22 | | string reportId, |
| | | 23 | | ISet<int> disabledMappingIds, |
| | | 24 | | ReportMetaRow meta, |
| | | 25 | | IReadOnlyDictionary<int, OzonClient> clientMap, |
| | | 26 | | bool onlyPending, |
| | | 27 | | IReadOnlyList<string>? articleFilter, |
| | | 28 | | int processed, |
| | | 29 | | int total, |
| | | 30 | | int successCount, |
| | | 31 | | int failedCount, |
| | | 32 | | Func<string, Task> log, |
| | | 33 | | Func<int, int, string, Task> progress, |
| | | 34 | | Func<ApplyLineRow, int, string, bool, string?, string?, string?, Task> logLine, |
| | | 35 | | CancellationToken cancellationToken) |
| | | 36 | | { |
| | | 37 | | var result = new BitrixStockApplyDisabledZeroResult |
| | | 38 | | { |
| | | 39 | | Processed = processed, |
| | | 40 | | Total = total, |
| | | 41 | | SuccessCount = successCount, |
| | | 42 | | FailedCount = failedCount |
| | | 43 | | }; |
| | | 44 | | |
| | | 45 | | if (disabledMappingIds.Count == 0) |
| | | 46 | | return result; |
| | | 47 | | |
| | | 48 | | var products = await LoadApplyDisabledReportProductsAsync(db, reportId, articleFilter, cancellationToken); |
| | | 49 | | if (products.Count == 0) |
| | | 50 | | { |
| | | 51 | | await log("Снятые связи: в отчёте нет товаров для live-обнуления."); |
| | | 52 | | return result; |
| | | 53 | | } |
| | | 54 | | |
| | | 55 | | var lineStates = await LoadApplyDisabledLineStatesAsync(db, reportId, cancellationToken); |
| | | 56 | | var productsByClient = products |
| | | 57 | | .GroupBy(p => p.OzonClientId > 0 ? p.OzonClientId : meta.OzonClientId) |
| | | 58 | | .ToList(); |
| | | 59 | | |
| | | 60 | | var allTargets = new List<BitrixStockSyncApplyDisabledZeroTarget>(); |
| | | 61 | | foreach (var clientGroup in productsByClient) |
| | | 62 | | { |
| | | 63 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 64 | | var clientId = clientGroup.Key; |
| | | 65 | | if (!clientMap.TryGetValue(clientId, out var client)) |
| | | 66 | | { |
| | | 67 | | await log($"Снятые связи: клиент Ozon ID {clientId} не найден — пропуск."); |
| | | 68 | | continue; |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | var mappings = await db.WarehouseMappings |
| | | 72 | | .Where(m => m.OzonClientId == clientId && m.OzonWarehouseId != null && m.OzonWarehouseId != "") |
| | | 73 | | .OrderBy(m => m.Id) |
| | | 74 | | .ToListAsync(cancellationToken); |
| | | 75 | | |
| | | 76 | | var warehouseSets = BitrixStockSyncApplyDisabledPlanner.BuildWarehouseSets(mappings, disabledMappingIds); |
| | | 77 | | if (warehouseSets.DisabledWarehouseIds.Count == 0) |
| | | 78 | | { |
| | | 79 | | await log($"[{client.Name}] Снятые связи: нет складов Ozon для обнуления (коллизия с включёнными связями |
| | | 80 | | continue; |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | var whList = warehouseSets.DisabledWarehouseIds |
| | | 84 | | .Select(id => warehouseSets.WarehouseNameById.TryGetValue(id, out var name) |
| | | 85 | | ? $"{name} ({id})" |
| | | 86 | | : id.ToString()) |
| | | 87 | | .ToList(); |
| | | 88 | | await log($"[{client.Name}] Снятые связи: {whList.Count} склад(ов) Ozon — {string.Join(", ", whList)}"); |
| | | 89 | | |
| | | 90 | | var offerIds = clientGroup |
| | | 91 | | .Select(p => p.OfferId) |
| | | 92 | | .Where(id => !string.IsNullOrWhiteSpace(id)) |
| | | 93 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | | 94 | | .ToList(); |
| | | 95 | | |
| | | 96 | | await log($"[{client.Name}] Live FBS Ozon: {offerIds.Count} offer_id…"); |
| | | 97 | | var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | | 98 | | var fbsByOfferId = await BitrixStockSyncFbsWarehouseBatchLoader.LoadByOfferIdsAsync( |
| | | 99 | | apiManager, |
| | | 100 | | offerIds, |
| | | 101 | | async msg => await log($"[{client.Name}] {msg}"), |
| | | 102 | | cancellationToken); |
| | | 103 | | |
| | | 104 | | var articleSet = articleFilter is { Count: > 0 } |
| | | 105 | | ? articleFilter.ToHashSet(StringComparer.OrdinalIgnoreCase) |
| | | 106 | | : null; |
| | | 107 | | |
| | | 108 | | var targets = BitrixStockSyncApplyDisabledPlanner.SelectTargets( |
| | | 109 | | clientGroup.ToList(), |
| | | 110 | | fbsByOfferId, |
| | | 111 | | warehouseSets, |
| | | 112 | | lineStates, |
| | | 113 | | onlyPending, |
| | | 114 | | articleSet, |
| | | 115 | | out var selectionStats); |
| | | 116 | | |
| | | 117 | | if (selectionStats.FallbackFromReportCount > 0) |
| | | 118 | | await log($"[{client.Name}] Снятые связи: {selectionStats.FallbackFromReportCount} поз. — live Ozon не в |
| | | 119 | | if (selectionStats.AlreadyZeroCount > 0) |
| | | 120 | | await log($"[{client.Name}] Снятые связи: уже 0 (обнулять нечего) — {selectionStats.AlreadyZeroCount} по |
| | | 121 | | if (selectionStats.AlreadyClearedSkipCount > 0) |
| | | 122 | | await log($"[{client.Name}] Снятые связи: пропущено — уже обнулено ранее (onlyPending) — {selectionStats |
| | | 123 | | |
| | | 124 | | allTargets.AddRange(targets); |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | if (allTargets.Count == 0) |
| | | 128 | | { |
| | | 129 | | await log("Снятые связи: live Ozon — нечего обнулять (остатки 0 или уже обработано)."); |
| | | 130 | | return result; |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | result.Total += allTargets.Count; |
| | | 134 | | await log($"Снятые связи: к обнулению {allTargets.Count} позиций (live Ozon)…"); |
| | | 135 | | |
| | | 136 | | foreach (var clientTargets in allTargets.GroupBy(t => t.OzonClientId)) |
| | | 137 | | { |
| | | 138 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 139 | | var clientId = clientTargets.Key; |
| | | 140 | | if (!clientMap.TryGetValue(clientId, out var client)) |
| | | 141 | | continue; |
| | | 142 | | |
| | | 143 | | var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | | 144 | | var clientName = client.Name ?? meta.OzonClientName; |
| | | 145 | | var targets = clientTargets.ToList(); |
| | | 146 | | |
| | | 147 | | for (var offset = 0; offset < targets.Count; offset += BatchSize) |
| | | 148 | | { |
| | | 149 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 150 | | var batch = targets.Skip(offset).Take(BatchSize).ToList(); |
| | | 151 | | var lookup = batch.ToDictionary(t => $"{t.OzonProductId}_{t.WarehouseId}"); |
| | | 152 | | |
| | | 153 | | var payload = batch.Select(t => new StockItemForUpdate |
| | | 154 | | { |
| | | 155 | | OfferId = t.OfferId, |
| | | 156 | | ProductId = t.OzonProductId, |
| | | 157 | | Stock = 0, |
| | | 158 | | WarehouseId = t.WarehouseId |
| | | 159 | | }).ToList(); |
| | | 160 | | |
| | | 161 | | var pushItems = batch.Select(t => new BitrixStockPushItemDto |
| | | 162 | | { |
| | | 163 | | Article = t.Article, |
| | | 164 | | OfferId = t.OfferId, |
| | | 165 | | ProductId = t.OzonProductId, |
| | | 166 | | WarehouseId = t.WarehouseId, |
| | | 167 | | WarehouseName = t.WarehouseName, |
| | | 168 | | OzonClient = clientName, |
| | | 169 | | LinkedStoreName = t.OneCStore, |
| | | 170 | | PreviousStock = t.Present, |
| | | 171 | | TargetStock = 0 |
| | | 172 | | }).ToList(); |
| | | 173 | | |
| | | 174 | | var handled = new HashSet<string>(StringComparer.Ordinal); |
| | | 175 | | var batchOk = await TryUpdateBatchAsync(apiManager, payload, pushItems, |
| | | 176 | | async (item, error) => |
| | | 177 | | { |
| | | 178 | | if (!lookup.TryGetValue($"{item.ProductId}_{item.WarehouseId}", out var target)) |
| | | 179 | | return; |
| | | 180 | | handled.Add($"{target.OzonProductId}_{target.WarehouseId}"); |
| | | 181 | | result.Processed++; |
| | | 182 | | result.FailedCount++; |
| | | 183 | | await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync( |
| | | 184 | | db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName, |
| | | 185 | | target.OneCStore, false, error, target.Present, target.OfferId, target.OzonProductId, |
| | | 186 | | cancellationToken); |
| | | 187 | | await log($"✗ {target.Article} ({target.OneCStore}→{target.WarehouseName}): {error} (связь отклю |
| | | 188 | | await LogDisabledTargetLine(target, clientId, clientName, false, error, logLine); |
| | | 189 | | await progress(result.Processed, result.Total, target.Article); |
| | | 190 | | }, |
| | | 191 | | async item => |
| | | 192 | | { |
| | | 193 | | if (!lookup.TryGetValue($"{item.ProductId}_{item.WarehouseId}", out var target)) |
| | | 194 | | return; |
| | | 195 | | handled.Add($"{target.OzonProductId}_{target.WarehouseId}"); |
| | | 196 | | result.Processed++; |
| | | 197 | | result.SuccessCount++; |
| | | 198 | | await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync( |
| | | 199 | | db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName, |
| | | 200 | | target.OneCStore, true, null, target.Present, target.OfferId, target.OzonProductId, |
| | | 201 | | cancellationToken); |
| | | 202 | | await log($"⓪ {target.Article} ({target.OneCStore}→{target.WarehouseName}): {target.Present} → 0 |
| | | 203 | | await LogDisabledTargetLine(target, clientId, clientName, true, null, logLine); |
| | | 204 | | await progress(result.Processed, result.Total, target.Article); |
| | | 205 | | }, |
| | | 206 | | cancellationToken); |
| | | 207 | | |
| | | 208 | | if (!batchOk) |
| | | 209 | | { |
| | | 210 | | foreach (var target in batch.Where(t => !handled.Contains($"{t.OzonProductId}_{t.WarehouseId}"))) |
| | | 211 | | { |
| | | 212 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 213 | | var single = new List<StockItemForUpdate> |
| | | 214 | | { |
| | | 215 | | new() |
| | | 216 | | { |
| | | 217 | | OfferId = target.OfferId, |
| | | 218 | | ProductId = target.OzonProductId, |
| | | 219 | | Stock = 0, |
| | | 220 | | WarehouseId = target.WarehouseId |
| | | 221 | | } |
| | | 222 | | }; |
| | | 223 | | try |
| | | 224 | | { |
| | | 225 | | var apiResult = await apiManager.UpdateProductStocks(single); |
| | | 226 | | var row = apiResult?.Result?.FirstOrDefault(); |
| | | 227 | | if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any())) |
| | | 228 | | { |
| | | 229 | | result.Processed++; |
| | | 230 | | result.SuccessCount++; |
| | | 231 | | await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync( |
| | | 232 | | db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName, |
| | | 233 | | target.OneCStore, true, null, target.Present, target.OfferId, target.OzonProductId, |
| | | 234 | | cancellationToken); |
| | | 235 | | await log($"⓪ {target.Article}: повтор по одной (связь отключена, Ozon ID {target.Wareho |
| | | 236 | | await LogDisabledTargetLine(target, clientId, clientName, true, null, logLine); |
| | | 237 | | } |
| | | 238 | | else |
| | | 239 | | { |
| | | 240 | | var err = FormatStockErrors(row); |
| | | 241 | | result.Processed++; |
| | | 242 | | result.FailedCount++; |
| | | 243 | | await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync( |
| | | 244 | | db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName, |
| | | 245 | | target.OneCStore, false, err, target.Present, target.OfferId, target.OzonProductId, |
| | | 246 | | cancellationToken); |
| | | 247 | | await log($"✗ {target.Article}: {err} (связь отключена){DisabledZeroFallbackSuffix(targe |
| | | 248 | | await LogDisabledTargetLine(target, clientId, clientName, false, err, logLine); |
| | | 249 | | } |
| | | 250 | | } |
| | | 251 | | catch (Exception ex) |
| | | 252 | | { |
| | | 253 | | result.Processed++; |
| | | 254 | | result.FailedCount++; |
| | | 255 | | await BitrixStockSyncApplyDisabledLineWriter.UpsertAsync( |
| | | 256 | | db, reportId, clientId, target.Article, target.WarehouseId, target.WarehouseName, |
| | | 257 | | target.OneCStore, false, ex.Message, target.Present, target.OfferId, target.OzonProductI |
| | | 258 | | cancellationToken); |
| | | 259 | | await log($"✗ {target.Article}: {ex.Message} (связь отключена){DisabledZeroFallbackSuffix(ta |
| | | 260 | | await LogDisabledTargetLine(target, clientId, clientName, false, ex.Message, logLine); |
| | | 261 | | } |
| | | 262 | | |
| | | 263 | | await progress(result.Processed, result.Total, target.Article); |
| | | 264 | | await Task.Delay(200, cancellationToken); |
| | | 265 | | } |
| | | 266 | | } |
| | | 267 | | |
| | | 268 | | if (offset + BatchSize < targets.Count) |
| | | 269 | | await Task.Delay(DelayBetweenBatchesMs, cancellationToken); |
| | | 270 | | } |
| | | 271 | | } |
| | | 272 | | |
| | | 273 | | return result; |
| | | 274 | | } |
| | | 275 | | |
| | | 276 | | private static string DisabledZeroFallbackSuffix(BitrixStockSyncApplyDisabledZeroTarget target) => |
| | | 277 | | target.FromReportFallback ? " [остаток из отчёта, live Ozon склад не вернул]" : string.Empty; |
| | | 278 | | |
| | | 279 | | private static async Task LogDisabledTargetLine( |
| | | 280 | | BitrixStockSyncApplyDisabledZeroTarget target, |
| | | 281 | | int clientId, |
| | | 282 | | string clientName, |
| | | 283 | | bool ok, |
| | | 284 | | string? error, |
| | | 285 | | Func<ApplyLineRow, int, string, bool, string?, string?, string?, Task> logLine) |
| | | 286 | | { |
| | | 287 | | var pseudoLine = new ApplyLineRow |
| | | 288 | | { |
| | | 289 | | Article = target.Article, |
| | | 290 | | OneCStore = target.OneCStore, |
| | | 291 | | OzonWarehouseName = target.WarehouseName, |
| | | 292 | | OzonQty = target.Present, |
| | | 293 | | OneCQty = 0 |
| | | 294 | | }; |
| | | 295 | | await logLine( |
| | | 296 | | pseudoLine, |
| | | 297 | | clientId, |
| | | 298 | | clientName, |
| | | 299 | | ok, |
| | | 300 | | error, |
| | | 301 | | ApplyArticleActionCodes.ClearDisabledLink, |
| | | 302 | | ok ? $"{target.Present} → 0 (связь отключена)" : error); |
| | | 303 | | } |
| | | 304 | | |
| | | 305 | | private static async Task<List<BitrixStockSyncApplyDisabledReportProduct>> LoadApplyDisabledReportProductsAsync( |
| | | 306 | | ApplicationDbContext db, |
| | | 307 | | string reportId, |
| | | 308 | | IReadOnlyList<string>? articleFilter, |
| | | 309 | | CancellationToken cancellationToken) |
| | | 310 | | { |
| | | 311 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | | 312 | | { |
| | | 313 | | // Построчно (не DISTINCT по продукту) — нужны OzonWarehouseId/OzonQty каждой строки отчёта |
| | | 314 | | // как fallback-остаток, когда live-запрос к Ozon на этапе 2 не вернёт разбивку по складу. |
| | | 315 | | var sql = @" |
| | | 316 | | SELECT COALESCE(l.OzonClientId, 0), l.Article, l.OfferId, l.OzonProductId, l.OzonWarehouseId, l.OzonQty |
| | | 317 | | FROM BitrixStockSyncReportLines l |
| | | 318 | | WHERE l.ReportId = @reportId |
| | | 319 | | AND l.OfferId IS NOT NULL AND l.OfferId != '' |
| | | 320 | | AND l.OzonProductId IS NOT NULL AND l.OzonProductId > 0"; |
| | | 321 | | |
| | | 322 | | if (articleFilter is { Count: > 0 }) |
| | | 323 | | { |
| | | 324 | | var artIn = string.Join(", ", articleFilter.Select((_, i) => $"@art{i}")); |
| | | 325 | | sql += $" AND l.Article IN ({artIn})"; |
| | | 326 | | } |
| | | 327 | | |
| | | 328 | | var cmd = connection.CreateCommand(); |
| | | 329 | | cmd.CommandText = sql; |
| | | 330 | | AddParam(cmd, "@reportId", reportId); |
| | | 331 | | if (articleFilter is { Count: > 0 }) |
| | | 332 | | { |
| | | 333 | | for (var i = 0; i < articleFilter.Count; i++) |
| | | 334 | | AddParam(cmd, $"@art{i}", articleFilter[i]); |
| | | 335 | | } |
| | | 336 | | |
| | | 337 | | var warehousesByKey = |
| | | 338 | | new Dictionary<(int ClientId, string Article, string OfferId, long ProductId), |
| | | 339 | | List<BitrixStockSyncApplyDisabledReportWarehouseQty>>(); |
| | | 340 | | |
| | | 341 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | | 342 | | while (await reader.ReadAsync(ct)) |
| | | 343 | | { |
| | | 344 | | var clientId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0); |
| | | 345 | | var article = reader.GetString(1); |
| | | 346 | | var offerId = reader.IsDBNull(2) ? string.Empty : reader.GetString(2); |
| | | 347 | | var productId = reader.IsDBNull(3) ? 0L : reader.GetInt64(3); |
| | | 348 | | var warehouseId = reader.IsDBNull(4) ? 0L : reader.GetInt64(4); |
| | | 349 | | var ozonQty = reader.IsDBNull(5) ? 0 : reader.GetInt32(5); |
| | | 350 | | |
| | | 351 | | var key = (clientId, article, offerId, productId); |
| | | 352 | | if (!warehousesByKey.TryGetValue(key, out var whList)) |
| | | 353 | | { |
| | | 354 | | whList = new List<BitrixStockSyncApplyDisabledReportWarehouseQty>(); |
| | | 355 | | warehousesByKey[key] = whList; |
| | | 356 | | } |
| | | 357 | | |
| | | 358 | | if (warehouseId > 0) |
| | | 359 | | whList.Add(new BitrixStockSyncApplyDisabledReportWarehouseQty { WarehouseId = warehouseId, OzonQty = |
| | | 360 | | } |
| | | 361 | | |
| | | 362 | | return warehousesByKey.Select(kv => new BitrixStockSyncApplyDisabledReportProduct |
| | | 363 | | { |
| | | 364 | | OzonClientId = kv.Key.ClientId, |
| | | 365 | | Article = kv.Key.Article, |
| | | 366 | | OfferId = kv.Key.OfferId, |
| | | 367 | | OzonProductId = kv.Key.ProductId, |
| | | 368 | | ReportWarehouses = kv.Value |
| | | 369 | | }).ToList(); |
| | | 370 | | }, cancellationToken); |
| | | 371 | | } |
| | | 372 | | |
| | | 373 | | private static async Task<Dictionary<string, BitrixStockSyncApplyDisabledLineState>> LoadApplyDisabledLineStatesAsyn |
| | | 374 | | ApplicationDbContext db, |
| | | 375 | | string reportId, |
| | | 376 | | CancellationToken cancellationToken) |
| | | 377 | | { |
| | | 378 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | | 379 | | { |
| | | 380 | | const string sql = @" |
| | | 381 | | SELECT l.Article, COALESCE(l.OzonClientId, 0), l.OzonWarehouseId, |
| | | 382 | | l.OzonApplyStatus, l.OzonApplyError, l.Action |
| | | 383 | | FROM BitrixStockSyncReportLines l |
| | | 384 | | WHERE l.ReportId = @reportId |
| | | 385 | | AND l.OzonWarehouseId > 0"; |
| | | 386 | | |
| | | 387 | | var cmd = connection.CreateCommand(); |
| | | 388 | | cmd.CommandText = sql; |
| | | 389 | | AddParam(cmd, "@reportId", reportId); |
| | | 390 | | |
| | | 391 | | var dict = new Dictionary<string, BitrixStockSyncApplyDisabledLineState>(StringComparer.OrdinalIgnoreCase); |
| | | 392 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | | 393 | | while (await reader.ReadAsync(ct)) |
| | | 394 | | { |
| | | 395 | | var article = reader.GetString(0); |
| | | 396 | | var clientId = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); |
| | | 397 | | var warehouseId = reader.GetInt64(2); |
| | | 398 | | var key = BitrixStockSyncApplyDisabledPlanner.BuildLineStateKey(article, clientId, warehouseId); |
| | | 399 | | dict[key] = new BitrixStockSyncApplyDisabledLineState |
| | | 400 | | { |
| | | 401 | | OzonApplyStatus = reader.IsDBNull(3) ? null : reader.GetString(3), |
| | | 402 | | OzonApplyError = reader.IsDBNull(4) ? null : reader.GetString(4), |
| | | 403 | | Action = reader.IsDBNull(5) ? null : reader.GetString(5) |
| | | 404 | | }; |
| | | 405 | | } |
| | | 406 | | |
| | | 407 | | return dict; |
| | | 408 | | }, cancellationToken); |
| | | 409 | | } |
| | | 410 | | } |