| | | 1 | | using Hangfire; |
| | | 2 | | using Hangfire.Storage; |
| | | 3 | | using Microsoft.AspNetCore.SignalR; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Http; |
| | | 6 | | using OzonDomains; |
| | | 7 | | using OzonDomains.Models; |
| | | 8 | | using OzonRepositories.Context; |
| | | 9 | | using Services.ApiServices.OzonApi; |
| | | 10 | | using Services.ApiServices.OzonApi.Filters; |
| | | 11 | | using Services.DataServices; |
| | | 12 | | using Services.HangfireService; |
| | | 13 | | using Services.SignalRServices; |
| | | 14 | | using SlqStudio.Application.Services.EmailService; |
| | | 15 | | |
| | | 16 | | namespace Services.BitrixStockServices; |
| | | 17 | | |
| | | 18 | | public partial class BitrixStockSyncReportApplyService : IBitrixStockSyncReportApplyService |
| | | 19 | | { |
| | | 20 | | private const int BatchSize = 100; |
| | | 21 | | private const int DelayBetweenBatchesMs = 400; |
| | | 22 | | |
| | | 23 | | /// <summary>JOIN WarehouseMapping по OzonWarehouseId (коллизия нескольких складов 1С → один Ozon).</summary> |
| | | 24 | | private const string ReportLineWarehouseMappingJoinSql = |
| | | 25 | | "LEFT JOIN WarehouseMapping m ON m.Id = (" + |
| | | 26 | | "SELECT m2.Id FROM WarehouseMapping m2 " + |
| | | 27 | | "WHERE m2.OzonClientId = l.OzonClientId " + |
| | | 28 | | "AND CAST(m2.OzonWarehouseId AS SIGNED) = l.OzonWarehouseId " + |
| | | 29 | | "ORDER BY m2.Priority ASC, m2.Id ASC LIMIT 1)"; |
| | | 30 | | |
| | | 31 | | private readonly IDbContextFactory<ApplicationDbContext> _dbFactory; |
| | | 32 | | private readonly OzonClientServices _ozonClientServices; |
| | | 33 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 34 | | private readonly IEmailService? _emailService; |
| | | 35 | | private readonly IHubContext<JobProgressHub> _jobProgressHub; |
| | | 36 | | private readonly BitrixStockSyncApplyArticleLogService _articleLog; |
| | | 37 | | private readonly BitrixStockActiveStageStore _stageStore; |
| | | 38 | | private readonly IBitrixStockExceptionService _exceptions; |
| | | 39 | | private readonly IBitrixStockLiveStockGuard _liveStockGuard; |
| | | 40 | | |
| | 0 | 41 | | public BitrixStockSyncReportApplyService( |
| | 0 | 42 | | IDbContextFactory<ApplicationDbContext> dbFactory, |
| | 0 | 43 | | OzonClientServices ozonClientServices, |
| | 0 | 44 | | IHttpClientFactory httpClientFactory, |
| | 0 | 45 | | IEmailService? emailService, |
| | 0 | 46 | | IHubContext<JobProgressHub> jobProgressHub, |
| | 0 | 47 | | BitrixStockSyncApplyArticleLogService articleLog, |
| | 0 | 48 | | BitrixStockActiveStageStore stageStore, |
| | 0 | 49 | | IBitrixStockExceptionService exceptions, |
| | 0 | 50 | | IBitrixStockLiveStockGuard liveStockGuard) |
| | | 51 | | { |
| | 0 | 52 | | _dbFactory = dbFactory; |
| | 0 | 53 | | _ozonClientServices = ozonClientServices; |
| | 0 | 54 | | _httpClientFactory = httpClientFactory; |
| | 0 | 55 | | _emailService = emailService; |
| | 0 | 56 | | _jobProgressHub = jobProgressHub; |
| | 0 | 57 | | _articleLog = articleLog; |
| | 0 | 58 | | _stageStore = stageStore; |
| | 0 | 59 | | _exceptions = exceptions; |
| | 0 | 60 | | _liveStockGuard = liveStockGuard; |
| | 0 | 61 | | } |
| | | 62 | | |
| | | 63 | | [Queue(HangfireQueueNames.Main)] |
| | | 64 | | public async Task<BitrixStockApplyResult> ApplyReportAsync( |
| | | 65 | | string reportId, |
| | | 66 | | BitrixStockSyncReportApplyStartRequest request, |
| | | 67 | | string? progressJobId, |
| | | 68 | | CancellationToken cancellationToken = default) |
| | | 69 | | { |
| | 0 | 70 | | var applyResult = new BitrixStockApplyResult(); |
| | 0 | 71 | | var hubId = progressJobId?.Trim() ?? string.Empty; |
| | 0 | 72 | | var hasHub = !string.IsNullOrWhiteSpace(hubId); |
| | | 73 | | |
| | | 74 | | async Task Log(string message) |
| | | 75 | | { |
| | 0 | 76 | | if (hasHub) |
| | | 77 | | { |
| | 0 | 78 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", message, cancellationToken); |
| | | 79 | | } |
| | 0 | 80 | | } |
| | | 81 | | |
| | 0 | 82 | | var lastProgressSentAt = DateTime.MinValue; |
| | | 83 | | async Task Progress(int current, int total, string text) |
| | | 84 | | { |
| | 0 | 85 | | if (!hasHub) return; |
| | 0 | 86 | | var now = DateTime.UtcNow; |
| | | 87 | | // Отправляем не чаще раза в 250 мс, кроме финального события |
| | 0 | 88 | | if (current < total && (now - lastProgressSentAt).TotalMilliseconds < 250) return; |
| | 0 | 89 | | lastProgressSentAt = now; |
| | 0 | 90 | | await _jobProgressHub.Clients.Group(hubId) |
| | 0 | 91 | | .SendAsync("Progress", current, total, text ?? "", cancellationToken); |
| | 0 | 92 | | } |
| | | 93 | | |
| | | 94 | | async Task Complete(bool ok, object? payload) |
| | | 95 | | { |
| | 0 | 96 | | if (hasHub) |
| | | 97 | | { |
| | 0 | 98 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken); |
| | | 99 | | } |
| | 0 | 100 | | } |
| | | 101 | | |
| | 0 | 102 | | IDisposable? distLock = null; |
| | 0 | 103 | | IStorageConnection? lockConnection = null; |
| | | 104 | | |
| | | 105 | | try |
| | | 106 | | { |
| | | 107 | | try |
| | | 108 | | { |
| | 0 | 109 | | lockConnection = JobStorage.Current.GetConnection(); |
| | 0 | 110 | | distLock = lockConnection.AcquireDistributedLock( |
| | 0 | 111 | | BitrixStockPushJobService.PushLockKey, |
| | 0 | 112 | | TimeSpan.FromMilliseconds(1)); |
| | 0 | 113 | | } |
| | | 114 | | catch (DistributedLockTimeoutException) |
| | | 115 | | { |
| | | 116 | | const string msg = "Обновление остатков Ozon уже выполняется. Повторный запуск отменён."; |
| | 0 | 117 | | await Log(msg); |
| | 0 | 118 | | await Complete(false, msg); |
| | 0 | 119 | | applyResult.LockBusy = true; |
| | 0 | 120 | | return applyResult; |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | // В режиме цепочки stage-store и SignalR-задержкой управляет оркестратор. |
| | 0 | 124 | | if (!request.Chained) |
| | | 125 | | { |
| | 0 | 126 | | _stageStore.Set(reportId, 2); |
| | | 127 | | // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа) |
| | 0 | 128 | | if (hasHub) await Task.Delay(800, cancellationToken); |
| | | 129 | | } |
| | 0 | 130 | | await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); |
| | 0 | 131 | | var logJobId = (progressJobId ?? request.JobId ?? Guid.NewGuid().ToString("N")).Trim(); |
| | | 132 | | |
| | 0 | 133 | | var meta = await LoadReportMetaAsync(db, reportId, cancellationToken); |
| | 0 | 134 | | if (meta == null) |
| | | 135 | | { |
| | 0 | 136 | | await Log("Отчёт не найден"); |
| | 0 | 137 | | await Complete(false, "Отчёт не найден"); |
| | 0 | 138 | | return applyResult; |
| | | 139 | | } |
| | | 140 | | |
| | 0 | 141 | | if (!string.Equals(meta.Status, "Completed", StringComparison.OrdinalIgnoreCase)) |
| | | 142 | | { |
| | 0 | 143 | | await Log($"Отчёт в статусе «{meta.Status}» — дождитесь завершения формирования"); |
| | 0 | 144 | | await Complete(false, "Отчёт ещё не готов"); |
| | 0 | 145 | | return applyResult; |
| | | 146 | | } |
| | | 147 | | |
| | 0 | 148 | | var actions = ResolveApplyActions(request.Actions); |
| | 0 | 149 | | var disabledMappingIds = request.DisabledWarehouseMappingIds? |
| | | 150 | | .Where(id => id > 0) |
| | 0 | 151 | | .ToHashSet() ?? new HashSet<int>(); |
| | | 152 | | |
| | | 153 | | var articleFilter = request.Articles?.Where(a => !string.IsNullOrWhiteSpace(a)).ToList(); |
| | 0 | 154 | | var lines = await LoadLinesToApplyAsync(db, reportId, actions, request.OnlyPending, request.MaxLines, |
| | 0 | 155 | | disabledMappingIds, articleFilter, cancellationToken); |
| | 0 | 156 | | var skipStockArticlesApply = await _exceptions.GetSkipStockArticlesSetAsync(cancellationToken); |
| | 0 | 157 | | var orderedClientIds = ParseMultiClientIds(meta.MultiClientIdsJson); |
| | 0 | 158 | | var priorityClient1Id = orderedClientIds.Count > 0 ? orderedClientIds[0] : 0; |
| | 0 | 159 | | var priorityClient2Id = orderedClientIds.Count > 1 ? orderedClientIds[1] : 0; |
| | | 160 | | // Для multi-client отчётов К2 может иметь Action=Match (остатки совпадают с 1С) или |
| | | 161 | | // отключённую связь и не попасть в lines. Но раз К1 найден — К2 всё равно нужно обнулить. |
| | | 162 | | // Загружаем ВСЕ К2-строки с OzonQty>0 напрямую, независимо от того есть ли К2 в lines. |
| | 0 | 163 | | Dictionary<string, ApplyLineRow>? k1RefMap = null; |
| | 0 | 164 | | if (orderedClientIds.Count >= 2) |
| | | 165 | | { |
| | 0 | 166 | | var k1Id = orderedClientIds[0]; |
| | 0 | 167 | | var k2Id = orderedClientIds[1]; |
| | | 168 | | |
| | | 169 | | // Все К2-строки с OzonQty>0 — кандидаты на принудительное обнуление приоритетом К1 |
| | 0 | 170 | | var k2Candidates = await LoadClientLinesWithPositiveStockAsync( |
| | 0 | 171 | | db, reportId, k2Id, request.OnlyPending, articleFilter, cancellationToken); |
| | | 172 | | |
| | | 173 | | // Объединяем пары из К2-кандидатов и уже загруженных К2-строк для запроса К1-ref |
| | 0 | 174 | | var pairsForK1Ref = k2Candidates |
| | | 175 | | .Select(l => $"{l.Article}||{l.OneCStore}") |
| | | 176 | | .Concat(lines.Where(l => l.OzonClientId == k2Id).Select(l => $"{l.Article}||{l.OneCStore}")) |
| | 0 | 177 | | .ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | | 178 | | |
| | 0 | 179 | | if (pairsForK1Ref.Count > 0) |
| | | 180 | | { |
| | 0 | 181 | | var k1RefLines = await LoadClientReferenceLinesAsync(db, reportId, k1Id, pairsForK1Ref, cancellation |
| | 0 | 182 | | k1RefMap = k1RefLines |
| | | 183 | | .GroupBy(l => $"{l.Article}||{l.OneCStore}", StringComparer.OrdinalIgnoreCase) |
| | | 184 | | .ToDictionary(g => g.Key, g => g.OrderBy(l => l.LineId).First(), StringComparer.OrdinalIgnoreCas |
| | 0 | 185 | | if (k1RefMap.Count > 0) |
| | 0 | 186 | | await Log($"Приоритет К1→К2: {k1RefMap.Count} ref-строк К1 для {pairsForK1Ref.Count} пар"); |
| | | 187 | | } |
| | | 188 | | |
| | | 189 | | // Добавляем К2-кандидатов где К1 найден и строка ещё не в основном списке |
| | | 190 | | var existingK2Ids = lines.Where(l => l.OzonClientId == k2Id).Select(l => l.LineId).ToHashSet(); |
| | 0 | 191 | | var k2ToAdd = k2Candidates |
| | 0 | 192 | | .Where(l => !existingK2Ids.Contains(l.LineId) && |
| | 0 | 193 | | k1RefMap?.ContainsKey($"{l.Article}||{l.OneCStore}") == true) |
| | 0 | 194 | | .ToList(); |
| | 0 | 195 | | lines.AddRange(k2ToAdd); |
| | 0 | 196 | | if (k2ToAdd.Count > 0) |
| | 0 | 197 | | await Log($"Приоритет К2: {k2ToAdd.Count} строк К2 с OzonQty>0 добавлены для принудительного обнулен |
| | 0 | 198 | | } |
| | 0 | 199 | | ApplyPriorityForTwoClients(lines, meta, k1RefMap); |
| | 0 | 200 | | var k1NotSellableKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | | 201 | | |
| | 0 | 202 | | if (disabledMappingIds.Count > 0) |
| | | 203 | | { |
| | 0 | 204 | | await Log($"Отключено связей: {disabledMappingIds.Count}"); |
| | | 205 | | } |
| | | 206 | | |
| | 0 | 207 | | var archivedMode = request.ArchivedMode; |
| | | 208 | | |
| | 0 | 209 | | await Log($"Синхронизация в Ozon: клиент {meta.OzonClientName}, строк к обработке: {lines.Count}"); |
| | 0 | 210 | | if (lines.Count == 0 && disabledMappingIds.Count == 0) |
| | | 211 | | { |
| | 0 | 212 | | await Log("Нечего применять — все строки совпадают или пропущены."); |
| | 0 | 213 | | await Complete(true, new { reportId, success = true, processed = 0 }); |
| | | 214 | | // SuccessCount = 0: оркестратор сам решит пропустить этап 3. |
| | 0 | 215 | | return applyResult; |
| | | 216 | | } |
| | | 217 | | |
| | | 218 | | // Определяем всех уникальных клиентов из строк (поддержка мультиклиентного отчёта) |
| | 0 | 219 | | var allLineClientIds = lines |
| | | 220 | | .Select(l => l.OzonClientId) |
| | 0 | 221 | | .Distinct() |
| | | 222 | | .Where(id => id > 0) |
| | 0 | 223 | | .ToList(); |
| | 0 | 224 | | if (!allLineClientIds.Any()) allLineClientIds = new List<int> { meta.OzonClientId }; |
| | | 225 | | |
| | 0 | 226 | | var allOzonClients = await _ozonClientServices.GetOzonClients(); |
| | 0 | 227 | | var clientMap = allLineClientIds |
| | 0 | 228 | | .Select(id => allOzonClients.FirstOrDefault(c => c.Id == id && c.ClientType == ClientType.OZON)) |
| | | 229 | | .Where(c => c != null && !string.IsNullOrEmpty(c.DecryptClientId) && !string.IsNullOrEmpty(c.DecryptApiK |
| | | 230 | | .ToDictionary(c => c!.Id, c => c!); |
| | | 231 | | |
| | 0 | 232 | | if (!clientMap.Any()) |
| | | 233 | | { |
| | 0 | 234 | | await Log("Клиент Ozon не найден или не настроен"); |
| | 0 | 235 | | await Complete(false, "Клиент Ozon не найден"); |
| | 0 | 236 | | return applyResult; |
| | | 237 | | } |
| | | 238 | | |
| | | 239 | | // Загружаем актуальный архивный статус из Ozon API отдельно по каждому клиенту |
| | 0 | 240 | | var archivedOfferIdsByClient = new Dictionary<int, HashSet<string>>(); |
| | 0 | 241 | | var archivedOfferIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 242 | | if (archivedMode != ArchivedProductMode.Include) |
| | | 243 | | { |
| | | 244 | | // Для UnarchiveFirst берём ВСЕ OfferIds из отчёта, иначе только накатываемые |
| | 0 | 245 | | var allOfferIdsToCheck = archivedMode == ArchivedProductMode.UnarchiveFirst |
| | 0 | 246 | | ? await LoadAllReportOfferIdsAsync(db, reportId, cancellationToken) |
| | | 247 | | : lines.Where(l => !string.IsNullOrEmpty(l.OfferId)) |
| | | 248 | | .Select(l => l.OfferId!) |
| | 0 | 249 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 250 | | .ToArray(); |
| | | 251 | | |
| | 0 | 252 | | if (allOfferIdsToCheck.Length > 0) |
| | | 253 | | { |
| | 0 | 254 | | await Log($"Проверка архивного статуса {allOfferIdsToCheck.Length} товаров в Ozon…"); |
| | | 255 | | |
| | 0 | 256 | | foreach (var (clientId, client) in clientMap) |
| | | 257 | | { |
| | 0 | 258 | | var clientArchived = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 259 | | archivedOfferIdsByClient[clientId] = clientArchived; |
| | 0 | 260 | | var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | | 261 | | try |
| | | 262 | | { |
| | 0 | 263 | | foreach (var chunk in allOfferIdsToCheck.Chunk(100)) |
| | | 264 | | { |
| | 0 | 265 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 266 | | var resp = await apiManager.GetProductInfoListAsync(offerIds: chunk); |
| | 0 | 267 | | if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items) continue; |
| | 0 | 268 | | foreach (var item in items) |
| | | 269 | | { |
| | 0 | 270 | | var isArch = item["is_archived"]?.ToObject<bool>() == true; |
| | 0 | 271 | | if (!isArch) continue; |
| | 0 | 272 | | var oid = item["offer_id"]?.ToString(); |
| | 0 | 273 | | if (string.IsNullOrEmpty(oid)) continue; |
| | 0 | 274 | | clientArchived.Add(oid); |
| | 0 | 275 | | archivedOfferIds.Add(oid); |
| | | 276 | | } |
| | 0 | 277 | | await Task.Delay(100, cancellationToken); |
| | | 278 | | } |
| | 0 | 279 | | } |
| | 0 | 280 | | catch (Exception ex) |
| | | 281 | | { |
| | 0 | 282 | | await Log($"Предупреждение: не удалось получить статус архива для клиента {client.Name} — {e |
| | | 283 | | } |
| | 0 | 284 | | } |
| | | 285 | | |
| | 0 | 286 | | await Log(archivedOfferIds.Count > 0 |
| | 0 | 287 | | ? $"Найдено архивных товаров: {archivedOfferIds.Count}" |
| | 0 | 288 | | : "Архивных товаров не найдено"); |
| | | 289 | | } |
| | 0 | 290 | | } |
| | | 291 | | |
| | 0 | 292 | | if (archivedMode == ArchivedProductMode.Skip && archivedOfferIds.Count > 0) |
| | | 293 | | { |
| | 0 | 294 | | await Log($"Пропуск {archivedOfferIds.Count} архивных товаров"); |
| | 0 | 295 | | lines = lines.Where(l => string.IsNullOrEmpty(l.OfferId) || !archivedOfferIds.Contains(l.OfferId!)).ToLi |
| | | 296 | | } |
| | | 297 | | |
| | | 298 | | // Разархивирование перед накатом: все архивные из отчёта, не только накатываемые |
| | 0 | 299 | | if (archivedMode == ArchivedProductMode.UnarchiveFirst && archivedOfferIds.Count > 0) |
| | | 300 | | { |
| | | 301 | | // Применяем фильтр по типу архива (авто / ручной / все) |
| | 0 | 302 | | var unarchiveFilter = request.UnarchiveFilter; |
| | 0 | 303 | | if (unarchiveFilter != UnarchiveFilterMode.All) |
| | | 304 | | { |
| | 0 | 305 | | var offerArchiveTypes = await LoadOfferArchiveTypesAsync(db, reportId, cancellationToken); |
| | 0 | 306 | | var before = archivedOfferIds.Count; |
| | 0 | 307 | | archivedOfferIds.RemoveWhere(offerId => |
| | 0 | 308 | | { |
| | 0 | 309 | | if (!offerArchiveTypes.TryGetValue(offerId, out var isAuto)) |
| | 0 | 310 | | return false; |
| | 0 | 311 | | return unarchiveFilter == UnarchiveFilterMode.AutoOnly ? !isAuto : isAuto; |
| | 0 | 312 | | }); |
| | 0 | 313 | | var excluded = before - archivedOfferIds.Count; |
| | 0 | 314 | | if (excluded > 0) |
| | | 315 | | { |
| | 0 | 316 | | var filterLabel = unarchiveFilter == UnarchiveFilterMode.AutoOnly ? "только авто-архив" : "тольк |
| | 0 | 317 | | await Log($"Фильтр архива: {filterLabel}, исключено {excluded} товаров, к разархивации: {archive |
| | | 318 | | } |
| | | 319 | | } |
| | | 320 | | |
| | 0 | 321 | | if (archivedOfferIds.Count == 0) |
| | | 322 | | { |
| | 0 | 323 | | await Log("После применения фильтра архива нет товаров для разархивации"); |
| | | 324 | | } |
| | | 325 | | else |
| | | 326 | | { |
| | | 327 | | // Собираем product_id по offer_id из всех строк отчёта (lines + дополнительная выборка) |
| | 0 | 328 | | await Log($"Разархивирование {archivedOfferIds.Count} товаров перед накатом…"); |
| | | 329 | | |
| | | 330 | | // product_id для archived строк собираем отдельно по клиентам |
| | 0 | 331 | | var unarchiveIdsByClient = new Dictionary<int, HashSet<long>>(); |
| | 0 | 332 | | foreach (var line in lines) |
| | | 333 | | { |
| | 0 | 334 | | if (line.OzonClientId <= 0 || |
| | 0 | 335 | | string.IsNullOrEmpty(line.OfferId) || |
| | 0 | 336 | | !archivedOfferIds.Contains(line.OfferId) || |
| | 0 | 337 | | line.OzonProductId is not > 0) |
| | | 338 | | { |
| | | 339 | | continue; |
| | | 340 | | } |
| | | 341 | | |
| | 0 | 342 | | if (!unarchiveIdsByClient.TryGetValue(line.OzonClientId, out var set)) |
| | | 343 | | { |
| | 0 | 344 | | set = new HashSet<long>(); |
| | 0 | 345 | | unarchiveIdsByClient[line.OzonClientId] = set; |
| | | 346 | | } |
| | 0 | 347 | | set.Add(line.OzonProductId.Value); |
| | | 348 | | } |
| | | 349 | | |
| | | 350 | | // product_id для Match-строк получаем из API отдельно по каждому клиенту |
| | | 351 | | try |
| | | 352 | | { |
| | 0 | 353 | | foreach (var (clientId, clientArchivedOfferIds) in archivedOfferIdsByClient) |
| | | 354 | | { |
| | 0 | 355 | | if (clientArchivedOfferIds.Count == 0 || !clientMap.TryGetValue(clientId, out var client)) |
| | | 356 | | continue; |
| | | 357 | | |
| | 0 | 358 | | var clientApiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | 0 | 359 | | foreach (var chunk in clientArchivedOfferIds.Chunk(100)) |
| | | 360 | | { |
| | 0 | 361 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 362 | | var resp = await clientApiManager.GetProductInfoListAsync(offerIds: chunk.ToArray()); |
| | 0 | 363 | | if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items2) continue; |
| | 0 | 364 | | foreach (var item in items2) |
| | | 365 | | { |
| | 0 | 366 | | var pid = item["id"]?.ToObject<long>(); |
| | 0 | 367 | | if (pid is > 0) |
| | | 368 | | { |
| | 0 | 369 | | if (!unarchiveIdsByClient.TryGetValue(clientId, out var set)) |
| | | 370 | | { |
| | 0 | 371 | | set = new HashSet<long>(); |
| | 0 | 372 | | unarchiveIdsByClient[clientId] = set; |
| | | 373 | | } |
| | 0 | 374 | | set.Add(pid.Value); |
| | | 375 | | } |
| | | 376 | | } |
| | | 377 | | } |
| | 0 | 378 | | } |
| | 0 | 379 | | } |
| | 0 | 380 | | catch (OperationCanceledException) { throw; } |
| | 0 | 381 | | catch (Exception ex) { await Log($"Предупреждение: не удалось получить product_id из Ozon API для разарх |
| | | 382 | | |
| | | 383 | | var totalToUnarchive = unarchiveIdsByClient.Values.Sum(s => s.Count); |
| | 0 | 384 | | if (totalToUnarchive > 0) |
| | | 385 | | { |
| | 0 | 386 | | var unarchiveOk = 0; |
| | 0 | 387 | | var unarchiveFail = 0; |
| | 0 | 388 | | foreach (var (clientId, idsSet) in unarchiveIdsByClient) |
| | | 389 | | { |
| | 0 | 390 | | if (!clientMap.TryGetValue(clientId, out var client)) |
| | | 391 | | { |
| | 0 | 392 | | unarchiveFail += idsSet.Count; |
| | 0 | 393 | | continue; |
| | | 394 | | } |
| | | 395 | | |
| | 0 | 396 | | var clientApi = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | 0 | 397 | | foreach (var chunk in idsSet.Chunk(100)) |
| | | 398 | | { |
| | 0 | 399 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 400 | | try |
| | | 401 | | { |
| | 0 | 402 | | var ok = await clientApi.UnarchiveProductsAsync(chunk); |
| | 0 | 403 | | if (ok) unarchiveOk += chunk.Length; |
| | 0 | 404 | | else unarchiveFail += chunk.Length; |
| | 0 | 405 | | } |
| | 0 | 406 | | catch (Exception ex) |
| | | 407 | | { |
| | 0 | 408 | | unarchiveFail += chunk.Length; |
| | 0 | 409 | | await Log($"Ошибка разархивирования батча (клиент {client.Name}): {BitrixOzonStockErrorM |
| | | 410 | | } |
| | 0 | 411 | | await Task.Delay(300, cancellationToken); |
| | 0 | 412 | | } |
| | 0 | 413 | | } |
| | 0 | 414 | | await Log($"Разархивировано: {unarchiveOk}, ошибок: {unarchiveFail}"); |
| | | 415 | | // Небольшая пауза чтобы Ozon успел обработать изменение статуса |
| | 0 | 416 | | await Task.Delay(2000, cancellationToken); |
| | | 417 | | } |
| | 0 | 418 | | } // else (archivedOfferIds.Count > 0 after filter) |
| | 0 | 419 | | } |
| | | 420 | | |
| | | 421 | | // Match: отчёт считает «совпало» по снимку таблицы, а таблица может держать остаток, которого в 1С уже нет |
| | | 422 | | // (кейс 2820: M1 = 7 в таблице, в 1С на M1 пусто). Такие строки сверяем с 1С онлайн и, если свободно меньше |
| | | 423 | | // превращаем в Decrease. Без снижения остатка в настройках пресета сверка Match не нужна. |
| | 0 | 424 | | var matchProbes = new List<ApplyLineRow>(); |
| | 0 | 425 | | if (actions.Contains(BitrixStockSyncReportActions.Decrease, StringComparer.OrdinalIgnoreCase)) |
| | | 426 | | { |
| | 0 | 427 | | var loadedKeys = lines |
| | | 428 | | .Select(l => (l.OzonClientId, l.Article.ToLowerInvariant(), l.OneCStore.ToLowerInvariant())) |
| | 0 | 429 | | .ToHashSet(); |
| | 0 | 430 | | var matchRows = await LoadLinesToApplyAsync(db, reportId, [BitrixStockSyncReportActions.Match], |
| | 0 | 431 | | request.OnlyPending, null, disabledMappingIds, articleFilter, cancellationToken); |
| | 0 | 432 | | matchProbes = matchRows |
| | 0 | 433 | | .Where(l => l.OzonQty > 0 && l.OneCQty > 0 && l.BatchMultiplier <= 1 && !l.IsArchived |
| | 0 | 434 | | && (orderedClientIds.Count < 2 || l.OzonClientId == priorityClient1Id) |
| | 0 | 435 | | && !loadedKeys.Contains((l.OzonClientId, l.Article.ToLowerInvariant(), l.OneCStore.ToLow |
| | 0 | 436 | | .ToList(); |
| | 0 | 437 | | } |
| | | 438 | | |
| | 0 | 439 | | lines.AddRange(await ApplyLiveStockGuardAsync(db, lines, matchProbes, Log, cancellationToken)); |
| | | 440 | | |
| | 0 | 441 | | var total = lines.Count; |
| | 0 | 442 | | var processed = 0; |
| | 0 | 443 | | var successCount = 0; |
| | 0 | 444 | | var failedCount = 0; |
| | | 445 | | |
| | | 446 | | // Хелпер записи в журнал по строке — пишем fire-and-forget, не блокируем накат |
| | | 447 | | async Task LogLine( |
| | | 448 | | ApplyLineRow line, |
| | | 449 | | int clientId, |
| | | 450 | | string clientName, |
| | | 451 | | bool ok, |
| | | 452 | | string? error, |
| | | 453 | | string? actionCodeOverride = null, |
| | | 454 | | string? actionLabelOverride = null) |
| | | 455 | | { |
| | | 456 | | try |
| | | 457 | | { |
| | 0 | 458 | | var targetQty = GetTargetStock(line); |
| | 0 | 459 | | var actionCode = actionCodeOverride ?? (ok |
| | 0 | 460 | | ? (line.PriorityForceClear || string.Equals(line.Action, BitrixStockSyncReportActions.Clear, Str |
| | 0 | 461 | | ? ApplyArticleActionCodes.Clear |
| | 0 | 462 | | : (line.PriorityNeedUnarchive || line.IsArchived |
| | 0 | 463 | | ? ApplyArticleActionCodes.UnarchiveApply |
| | 0 | 464 | | : ApplyArticleActionCodes.Apply)) |
| | 0 | 465 | | : "FAILED"); |
| | 0 | 466 | | await _articleLog.WriteAsync(db, reportId, logJobId, new BitrixStockSyncApplyArticleLogEntry |
| | 0 | 467 | | { |
| | 0 | 468 | | Article = line.Article, |
| | 0 | 469 | | OneCStore = line.OneCStore, |
| | 0 | 470 | | OneCQty = line.OneCQty, |
| | 0 | 471 | | Client1 = new ApplyArticleClientEntry |
| | 0 | 472 | | { |
| | 0 | 473 | | ClientId = clientId, |
| | 0 | 474 | | ClientName = clientName, |
| | 0 | 475 | | QtyBefore = line.OzonQty, |
| | 0 | 476 | | QtyAfter = ok ? targetQty : null, |
| | 0 | 477 | | WasArchived = line.IsArchived, |
| | 0 | 478 | | ActionCode = actionCode, |
| | 0 | 479 | | ActionLabel = actionLabelOverride ?? (ok ? $"{line.OzonQty} → {targetQty}" : error) |
| | 0 | 480 | | }, |
| | 0 | 481 | | Summary = ok |
| | 0 | 482 | | ? $"✓ {line.Article} ({line.OneCStore}): {line.OzonQty} → {targetQty} Ozon" |
| | 0 | 483 | | : $"✗ {line.Article} ({line.OneCStore}): {error}" |
| | 0 | 484 | | }, cancellationToken); |
| | 0 | 485 | | } |
| | 0 | 486 | | catch (OperationCanceledException) { throw; } |
| | 0 | 487 | | catch (Exception) { /* журнал не должен прерывать накат */ } |
| | 0 | 488 | | } |
| | | 489 | | |
| | | 490 | | // Группируем строки по клиенту Ozon для мультиклиентного наката |
| | 0 | 491 | | var linesByClient = lines |
| | 0 | 492 | | .GroupBy(l => l.OzonClientId > 0 ? l.OzonClientId : meta.OzonClientId) |
| | 0 | 493 | | .OrderBy(g => |
| | 0 | 494 | | { |
| | 0 | 495 | | var idx = orderedClientIds.IndexOf(g.Key); |
| | 0 | 496 | | return idx >= 0 ? idx : int.MaxValue; |
| | 0 | 497 | | }) |
| | 0 | 498 | | .ToList(); |
| | | 499 | | |
| | | 500 | | // К1 ref-строки (Action=Match, не обрабатываются напрямую) помечаем когда К2 обнуляется приоритетом |
| | 0 | 501 | | var markedK1LineIds = new HashSet<long>(); |
| | | 502 | | |
| | | 503 | | // Строки, на которых Ozon ответил «остаток обновляется слишком часто»: после пакетов повторяем их |
| | | 504 | | // с паузой в этом же цикле (Ozon сам просит подождать 1–2 минуты). |
| | 0 | 505 | | var rateLimited = new List<ApplyLineRow>(); |
| | | 506 | | |
| | | 507 | | async Task RetryRateLimitedAsync(OzonApiDataManager api, int clientId, string clientName) |
| | | 508 | | { |
| | 0 | 509 | | TimeSpan[] pauses = [TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(90)]; |
| | 0 | 510 | | for (var round = 0; round < pauses.Length && rateLimited.Count > 0; round++) |
| | | 511 | | { |
| | 0 | 512 | | await Log($"Повтор {rateLimited.Count} позиций после «слишком часто» (попытка {round + 1}/{pauses.Le |
| | 0 | 513 | | await Task.Delay(pauses[round], cancellationToken); |
| | | 514 | | |
| | 0 | 515 | | foreach (var line in rateLimited.ToList()) |
| | | 516 | | { |
| | 0 | 517 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 518 | | var (ok, err) = await ApplySingleAsync(db, api, line, cancellationToken); |
| | 0 | 519 | | if (ok) |
| | | 520 | | { |
| | 0 | 521 | | rateLimited.Remove(line); |
| | 0 | 522 | | failedCount--; |
| | 0 | 523 | | successCount++; |
| | 0 | 524 | | await Log($"✓ {line.Article} ({line.OneCStore}): обновлено повтором после паузы"); |
| | 0 | 525 | | await LogLine(line, clientId, clientName, true, null); |
| | | 526 | | } |
| | 0 | 527 | | else if (!BitrixStockApplyBatchMatcher.IsTooFrequentError(err)) |
| | | 528 | | { |
| | 0 | 529 | | rateLimited.Remove(line); // другая ошибка — повтор бессмысленен, строка остаётся Failed с э |
| | 0 | 530 | | await Log($"✗ {line.Article} ({line.OneCStore}): {err}"); |
| | | 531 | | } |
| | | 532 | | |
| | 0 | 533 | | await Task.Delay(200, cancellationToken); |
| | 0 | 534 | | } |
| | | 535 | | } |
| | | 536 | | |
| | 0 | 537 | | foreach (var line in rateLimited) |
| | 0 | 538 | | await Log($"✗ {line.Article} ({line.OneCStore}): Ozon всё ещё отвечает «слишком часто» — останется н |
| | 0 | 539 | | rateLimited.Clear(); |
| | 0 | 540 | | } |
| | | 541 | | |
| | 0 | 542 | | foreach (var clientGroup in linesByClient) |
| | | 543 | | { |
| | 0 | 544 | | var clientId = clientGroup.Key; |
| | 0 | 545 | | var clientLines = clientGroup.ToList(); |
| | | 546 | | |
| | | 547 | | // Получаем API-менеджер для этого клиента |
| | | 548 | | OzonApiDataManager clientApiManager; |
| | 0 | 549 | | if (clientMap.TryGetValue(clientId, out var groupClient)) |
| | | 550 | | { |
| | 0 | 551 | | clientApiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, groupClient!); |
| | 0 | 552 | | await Log($"--- Клиент: {groupClient.Name} ({clientLines.Count} строк) ---"); |
| | | 553 | | } |
| | | 554 | | else |
| | | 555 | | { |
| | | 556 | | // Клиент не найден — помечаем строки как ошибочные |
| | 0 | 557 | | foreach (var line in clientLines) |
| | | 558 | | { |
| | 0 | 559 | | processed++; |
| | 0 | 560 | | failedCount++; |
| | 0 | 561 | | await MarkLineAsync(db, line.LineId, false, $"Клиент Ozon ID {clientId} не найден", cancellation |
| | 0 | 562 | | await Log($"✗ {line.Article}: клиент Ozon ID {clientId} не найден"); |
| | 0 | 563 | | await Progress(processed, total, line.Article); |
| | 0 | 564 | | } |
| | 0 | 565 | | continue; |
| | | 566 | | } |
| | | 567 | | |
| | 0 | 568 | | var clientOzonClientName = groupClient?.Name ?? meta.OzonClientName; |
| | | 569 | | |
| | 0 | 570 | | for (var offset = 0; offset < clientLines.Count; offset += BatchSize) |
| | | 571 | | { |
| | 0 | 572 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 573 | | var batch = clientLines.Skip(offset).Take(BatchSize).ToList(); |
| | 0 | 574 | | await Log($"Батч {offset / BatchSize + 1}: {batch.Count} позиций"); |
| | | 575 | | |
| | 0 | 576 | | var applicable = new List<ApplyLineRow>(); |
| | 0 | 577 | | foreach (var line in batch) |
| | | 578 | | { |
| | 0 | 579 | | if (line.PrioritySkip) |
| | | 580 | | { |
| | 0 | 581 | | processed++; |
| | 0 | 582 | | successCount++; |
| | 0 | 583 | | var skipReason = line.PrioritySkipReason; |
| | 0 | 584 | | if (priorityClient2Id > 0 && |
| | 0 | 585 | | line.OzonClientId == priorityClient2Id && |
| | 0 | 586 | | k1NotSellableKeys.Contains(GetPriorityKey(line))) |
| | | 587 | | { |
| | 0 | 588 | | skipReason = "К2 пропущен: К1 не продается (приоритет)"; |
| | | 589 | | } |
| | | 590 | | |
| | 0 | 591 | | var skipCode = skipReason?.Contains("К1 не продается", StringComparison.OrdinalIgnoreCase) = |
| | 0 | 592 | | ? ApplyArticleActionCodes.SkipPriorityK1Failed |
| | 0 | 593 | | : skipReason?.Contains("архив", StringComparison.OrdinalIgnoreCase) == true |
| | 0 | 594 | | ? ApplyArticleActionCodes.SkipArchived |
| | 0 | 595 | | : (skipReason?.Contains("уже 0", StringComparison.OrdinalIgnoreCase) == true |
| | 0 | 596 | | ? ApplyArticleActionCodes.SkipAlreadyZero |
| | 0 | 597 | | : (skipReason?.Contains("не найден", StringComparison.OrdinalIgnoreCase) == true |
| | 0 | 598 | | ? ApplyArticleActionCodes.SkipNotFound |
| | 0 | 599 | | : ApplyArticleActionCodes.SkipMatch)); |
| | 0 | 600 | | await MarkLineAsync(db, line.LineId, true, skipReason, cancellationToken); |
| | 0 | 601 | | await Log($"• {line.Article} ({line.OneCStore}): {skipReason}"); |
| | 0 | 602 | | await LogLine(line, clientId, clientOzonClientName, true, null, skipCode, skipReason); |
| | 0 | 603 | | await Progress(processed, total, line.Article); |
| | 0 | 604 | | continue; |
| | | 605 | | } |
| | | 606 | | |
| | 0 | 607 | | if (line.EffectiveWarehouseId <= 0 || line.OzonProductId is not > 0 || |
| | 0 | 608 | | string.IsNullOrWhiteSpace(line.OfferId)) |
| | | 609 | | { |
| | 0 | 610 | | processed++; |
| | 0 | 611 | | successCount++; |
| | 0 | 612 | | var skipErr = line.EffectiveWarehouseId <= 0 |
| | 0 | 613 | | ? "Нет склада Ozon (настройте WarehouseMapping) — пропущено" |
| | 0 | 614 | | : "Нет product_id или offer_id — пропущено"; |
| | 0 | 615 | | await MarkLineAsync(db, line.LineId, true, skipErr, cancellationToken); |
| | 0 | 616 | | await Log($"• {line.Article}: {skipErr}"); |
| | 0 | 617 | | await LogLine(line, clientId, clientOzonClientName, true, skipErr, |
| | 0 | 618 | | ApplyArticleActionCodes.SkipNotFound); |
| | 0 | 619 | | await Progress(processed, total, line.Article); |
| | 0 | 620 | | continue; |
| | | 621 | | } |
| | | 622 | | |
| | | 623 | | // Целевой остаток уже совпадает с текущим — запрос в Ozon бессмысленен. |
| | | 624 | | // Исключение: PriorityForceClear при OzonQty=0 — отчёт мог подтянуть устаревший |
| | | 625 | | // остаток (товары "Убран из продажи" API возвращает 0, физически может быть > 0). |
| | 0 | 626 | | var targetQty = GetTargetStock(line); |
| | 0 | 627 | | if (targetQty == line.OzonQty && !line.PriorityForceClear) |
| | | 628 | | { |
| | 0 | 629 | | processed++; |
| | 0 | 630 | | successCount++; |
| | 0 | 631 | | var matchReason = line.LiveStockNote ?? "Уже совпадает — пропущено"; |
| | 0 | 632 | | await MarkLineAsync(db, line.LineId, true, matchReason, cancellationToken, targetQty); |
| | 0 | 633 | | await Log($"• {line.Article} ({line.OneCStore}): {line.OzonQty} = {targetQty} — {(line.LiveS |
| | 0 | 634 | | await LogLine(line, clientId, clientOzonClientName, true, matchReason, |
| | 0 | 635 | | ApplyArticleActionCodes.SkipAlreadyMatches, matchReason); |
| | 0 | 636 | | await Progress(processed, total, line.Article); |
| | 0 | 637 | | continue; |
| | | 638 | | } |
| | | 639 | | |
| | | 640 | | // Проверка справочника исключений (добавленных после построения отчёта) |
| | 0 | 641 | | if (!string.IsNullOrEmpty(line.Article) && |
| | 0 | 642 | | (skipStockArticlesApply.Contains(line.Article) || |
| | 0 | 643 | | (await _exceptions.FindStockRuleAsync(line.Article, clientId, cancellationToken))?.StockMod |
| | | 644 | | { |
| | 0 | 645 | | processed++; |
| | 0 | 646 | | successCount++; |
| | | 647 | | const string excReason = "Пропуск (справочник исключений)"; |
| | 0 | 648 | | await MarkLineAsync(db, line.LineId, true, excReason, cancellationToken); |
| | 0 | 649 | | await Log($"• {line.Article} ({line.OneCStore}): {excReason}"); |
| | 0 | 650 | | await Progress(processed, total, line.Article); |
| | 0 | 651 | | continue; |
| | | 652 | | } |
| | | 653 | | |
| | 0 | 654 | | applicable.Add(line); |
| | 0 | 655 | | } |
| | | 656 | | |
| | 0 | 657 | | if (applicable.Count > 0) |
| | | 658 | | { |
| | 0 | 659 | | var needUnarchive = applicable |
| | | 660 | | .Where(l => l.PriorityNeedUnarchive && l.OzonProductId is > 0) |
| | | 661 | | .Select(l => l.OzonProductId!.Value) |
| | 0 | 662 | | .Distinct() |
| | 0 | 663 | | .ToArray(); |
| | 0 | 664 | | if (needUnarchive.Length > 0) |
| | | 665 | | { |
| | 0 | 666 | | if (archivedMode == ArchivedProductMode.Skip) |
| | | 667 | | { |
| | | 668 | | foreach (var line in applicable.Where(l => l.PriorityNeedUnarchive).ToList()) |
| | | 669 | | { |
| | 0 | 670 | | processed++; |
| | 0 | 671 | | successCount++; |
| | 0 | 672 | | line.PrioritySkip = true; |
| | 0 | 673 | | line.PrioritySkipReason = "Архивный товар (режим Skip)"; |
| | 0 | 674 | | await MarkLineAsync(db, line.LineId, true, line.PrioritySkipReason, cancellationToke |
| | 0 | 675 | | await Log($"• {line.Article} ({line.OneCStore}): {line.PrioritySkipReason}"); |
| | 0 | 676 | | await LogLine(line, clientId, clientOzonClientName, true, null, ApplyArticleActionCo |
| | 0 | 677 | | await Progress(processed, total, line.Article); |
| | 0 | 678 | | } |
| | | 679 | | applicable = applicable.Where(l => !l.PriorityNeedUnarchive).ToList(); |
| | | 680 | | } |
| | | 681 | | else |
| | | 682 | | { |
| | | 683 | | try |
| | | 684 | | { |
| | 0 | 685 | | var unarchiveOk = await clientApiManager.UnarchiveProductsAsync(needUnarchive); |
| | 0 | 686 | | if (!unarchiveOk) |
| | 0 | 687 | | await Log($"Предупреждение: не удалось разархивировать часть товаров (клиент {cl |
| | 0 | 688 | | } |
| | 0 | 689 | | catch (Exception ex) |
| | | 690 | | { |
| | 0 | 691 | | await Log($"Предупреждение: ошибка разархивирования перед накатом ({clientOzonClient |
| | | 692 | | } |
| | | 693 | | } |
| | | 694 | | } |
| | | 695 | | |
| | 0 | 696 | | if (applicable.Count == 0) |
| | | 697 | | continue; |
| | | 698 | | |
| | | 699 | | var stockPayload = applicable.Select(l => new StockItemForUpdate |
| | | 700 | | { |
| | | 701 | | OfferId = l.OfferId!, |
| | | 702 | | ProductId = l.OzonProductId!.Value, |
| | | 703 | | Stock = GetTargetStock(l), |
| | | 704 | | WarehouseId = l.EffectiveWarehouseId |
| | | 705 | | }).ToList(); |
| | | 706 | | |
| | 0 | 707 | | var pushItems = applicable.Select(l => new BitrixStockPushItemDto |
| | 0 | 708 | | { |
| | 0 | 709 | | Article = l.Article, |
| | 0 | 710 | | OfferId = l.OfferId!, |
| | 0 | 711 | | ProductId = l.OzonProductId!.Value, |
| | 0 | 712 | | WarehouseId = l.EffectiveWarehouseId, |
| | 0 | 713 | | WarehouseName = l.OzonWarehouseName, |
| | 0 | 714 | | OzonClient = clientOzonClientName, |
| | 0 | 715 | | LinkedStoreName = l.OneCStore, |
| | 0 | 716 | | PreviousStock = l.OzonQty, |
| | 0 | 717 | | TargetStock = GetTargetStock(l) |
| | 0 | 718 | | }).ToList(); |
| | | 719 | | |
| | 0 | 720 | | var handled = new HashSet<long>(); |
| | 0 | 721 | | var batchOk = await TryUpdateBatchAsync(clientApiManager, stockPayload, pushItems, |
| | 0 | 722 | | async (item, error) => |
| | 0 | 723 | | { |
| | 0 | 724 | | var line = applicable.FirstOrDefault(l => |
| | 0 | 725 | | l.OzonProductId == item.ProductId && l.EffectiveWarehouseId == item.WarehouseId); |
| | 0 | 726 | | if (line == null) return; |
| | 0 | 727 | | handled.Add(line.LineId); |
| | 0 | 728 | | processed++; |
| | 0 | 729 | | failedCount++; |
| | 0 | 730 | | if (BitrixStockApplyBatchMatcher.IsTooFrequentError(error)) |
| | 0 | 731 | | rateLimited.Add(line); |
| | 0 | 732 | | await MarkLineAsync(db, line.LineId, false, error, cancellationToken); |
| | 0 | 733 | | await Log($"✗ {line.Article} ({line.OneCStore}): {error}"); |
| | 0 | 734 | | if (priorityClient1Id > 0 && |
| | 0 | 735 | | line.OzonClientId == priorityClient1Id && |
| | 0 | 736 | | IsNotSellableError(error)) |
| | 0 | 737 | | { |
| | 0 | 738 | | k1NotSellableKeys.Add(GetPriorityKey(line)); |
| | 0 | 739 | | } |
| | 0 | 740 | | await LogLine(line, clientId, clientOzonClientName, false, error); |
| | 0 | 741 | | await Progress(processed, total, line.Article); |
| | 0 | 742 | | }, |
| | 0 | 743 | | async item => |
| | 0 | 744 | | { |
| | 0 | 745 | | var line = applicable.FirstOrDefault(l => |
| | 0 | 746 | | l.OzonProductId == item.ProductId && l.EffectiveWarehouseId == item.WarehouseId); |
| | 0 | 747 | | if (line == null) return; |
| | 0 | 748 | | handled.Add(line.LineId); |
| | 0 | 749 | | processed++; |
| | 0 | 750 | | successCount++; |
| | 0 | 751 | | var targetQty = GetTargetStock(line); |
| | 0 | 752 | | var successNote = line.PriorityForceClear ? "К2 обнулено (приоритет К1)" : null; |
| | 0 | 753 | | await MarkLineAsync(db, line.LineId, true, successNote, cancellationToken, targetQty); |
| | 0 | 754 | | // Маркируем К1 ref-строку чтобы она отображалась в отчёте (иначе OzonApplyStatus=NULL) |
| | 0 | 755 | | if (line.PriorityForceClear && k1RefMap != null && |
| | 0 | 756 | | k1RefMap.TryGetValue(GetPriorityKey(line), out var k1RefLine) && |
| | 0 | 757 | | markedK1LineIds.Add(k1RefLine.LineId)) |
| | 0 | 758 | | { |
| | 0 | 759 | | await MarkLineAsync(db, k1RefLine.LineId, true, "К1 — К2 обнулён (приоритет)", cance |
| | 0 | 760 | | } |
| | 0 | 761 | | await Log($"✓ {line.Article} ({line.OneCStore}): {line.OzonQty} → {targetQty} Ozon{(line |
| | 0 | 762 | | await LogLine(line, clientId, clientOzonClientName, true, null); |
| | 0 | 763 | | await Progress(processed, total, line.Article); |
| | 0 | 764 | | }, |
| | 0 | 765 | | cancellationToken); |
| | | 766 | | |
| | 0 | 767 | | if (!batchOk) |
| | | 768 | | { |
| | 0 | 769 | | foreach (var line in applicable.Where(l => !handled.Contains(l.LineId))) |
| | | 770 | | { |
| | 0 | 771 | | var (ok, err) = await ApplySingleAsync(db, clientApiManager, line, cancellationToken); |
| | 0 | 772 | | processed++; |
| | 0 | 773 | | if (ok) |
| | | 774 | | { |
| | 0 | 775 | | successCount++; |
| | 0 | 776 | | await Log($"✓ {line.Article}: повтор по одной"); |
| | 0 | 777 | | await LogLine(line, clientId, clientOzonClientName, true, null); |
| | | 778 | | } |
| | | 779 | | else |
| | | 780 | | { |
| | 0 | 781 | | failedCount++; |
| | 0 | 782 | | if (BitrixStockApplyBatchMatcher.IsTooFrequentError(err)) |
| | 0 | 783 | | rateLimited.Add(line); |
| | 0 | 784 | | await Log($"✗ {line.Article}: {err}"); |
| | 0 | 785 | | if (priorityClient1Id > 0 && |
| | 0 | 786 | | line.OzonClientId == priorityClient1Id && |
| | 0 | 787 | | IsNotSellableError(err)) |
| | | 788 | | { |
| | 0 | 789 | | k1NotSellableKeys.Add(GetPriorityKey(line)); |
| | | 790 | | } |
| | 0 | 791 | | await LogLine(line, clientId, clientOzonClientName, false, err); |
| | | 792 | | } |
| | 0 | 793 | | await Progress(processed, total, line.Article); |
| | 0 | 794 | | await Task.Delay(200, cancellationToken); |
| | 0 | 795 | | } |
| | | 796 | | } |
| | | 797 | | } |
| | | 798 | | |
| | 0 | 799 | | if (offset + BatchSize < clientLines.Count) |
| | 0 | 800 | | await Task.Delay(DelayBetweenBatchesMs, cancellationToken); |
| | 0 | 801 | | } |
| | | 802 | | |
| | 0 | 803 | | if (rateLimited.Count > 0) |
| | 0 | 804 | | await RetryRateLimitedAsync(clientApiManager, clientId, clientOzonClientName); |
| | 0 | 805 | | } |
| | | 806 | | |
| | | 807 | | // ── Обнуление остатков для отключённых связей (live Ozon по Warehouse ID) ── |
| | 0 | 808 | | if (disabledMappingIds.Count > 0) |
| | | 809 | | { |
| | 0 | 810 | | var disabledResult = await ZeroDisabledWarehouseStocksAsync( |
| | 0 | 811 | | db, |
| | 0 | 812 | | reportId, |
| | 0 | 813 | | disabledMappingIds, |
| | 0 | 814 | | meta, |
| | 0 | 815 | | clientMap, |
| | 0 | 816 | | request.OnlyPending, |
| | 0 | 817 | | articleFilter, |
| | 0 | 818 | | processed, |
| | 0 | 819 | | total, |
| | 0 | 820 | | successCount, |
| | 0 | 821 | | failedCount, |
| | 0 | 822 | | Log, |
| | 0 | 823 | | Progress, |
| | 0 | 824 | | LogLine, |
| | 0 | 825 | | cancellationToken); |
| | 0 | 826 | | processed = disabledResult.Processed; |
| | 0 | 827 | | total = disabledResult.Total; |
| | 0 | 828 | | successCount = disabledResult.SuccessCount; |
| | 0 | 829 | | failedCount = disabledResult.FailedCount; |
| | | 830 | | } |
| | | 831 | | |
| | | 832 | | // Пометить строки «Не найден» как пропущенные — не участвуют в синхронизации |
| | 0 | 833 | | var notFoundMarked = await db.Database.ExecuteSqlRawAsync( |
| | 0 | 834 | | @"UPDATE BitrixStockSyncReportLines |
| | 0 | 835 | | SET OzonApplyStatus = {1}, OzonAppliedUtc = UTC_TIMESTAMP(6), OzonApplyError = {2} |
| | 0 | 836 | | WHERE ReportId = {0} AND Action = {3} |
| | 0 | 837 | | AND (OzonApplyStatus IS NULL OR OzonApplyStatus = 'Pending')", |
| | 0 | 838 | | EfSqlRawParameterGuard.Materialize( |
| | 0 | 839 | | reportId, |
| | 0 | 840 | | BitrixStockSyncReportApplyStatuses.Skipped, |
| | 0 | 841 | | "Не найден — не участвует в синхронизации", |
| | 0 | 842 | | BitrixStockSyncReportActions.NotFound), |
| | 0 | 843 | | cancellationToken); |
| | 0 | 844 | | if (notFoundMarked > 0) |
| | 0 | 845 | | await Log($"Пропущено строк «Не найден»: {notFoundMarked} (нет склада Ozon или товар не найден в API)"); |
| | | 846 | | |
| | 0 | 847 | | await BitrixStockSyncReportService.RefreshReportOzonApplyCountsAsync(db, reportId, cancellationToken); |
| | 0 | 848 | | await Log($"Готово: успешно {successCount}, ошибок {failedCount} из {total}"); |
| | 0 | 849 | | await Progress(total, total, "Синхронизация завершена"); |
| | 0 | 850 | | await Complete(true, new |
| | 0 | 851 | | { |
| | 0 | 852 | | reportId, |
| | 0 | 853 | | success = true, |
| | 0 | 854 | | total, |
| | 0 | 855 | | successCount, |
| | 0 | 856 | | failedCount |
| | 0 | 857 | | }); |
| | | 858 | | |
| | | 859 | | // Цепочкой этапов (2→3→4→5) управляет BitrixStockSyncPipelineOrchestrator. |
| | 0 | 860 | | applyResult.SuccessCount = successCount; |
| | 0 | 861 | | applyResult.FailedCount = failedCount; |
| | 0 | 862 | | applyResult.Total = total; |
| | 0 | 863 | | return applyResult; |
| | 0 | 864 | | } |
| | 0 | 865 | | catch (Exception ex) |
| | | 866 | | { |
| | 0 | 867 | | await Log($"Ошибка: {ex.Message}"); |
| | 0 | 868 | | await Complete(false, ex.Message); |
| | 0 | 869 | | throw; |
| | 0 | 870 | | } |
| | | 871 | | finally |
| | | 872 | | { |
| | 0 | 873 | | if (!request.Chained) |
| | 0 | 874 | | _stageStore.Clear(reportId); |
| | 0 | 875 | | HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection); |
| | | 876 | | } |
| | 0 | 877 | | } |
| | | 878 | | |
| | | 879 | | private static async Task<(bool Ok, string? Error)> ApplySingleAsync( |
| | | 880 | | ApplicationDbContext db, |
| | | 881 | | OzonApiDataManager apiManager, |
| | | 882 | | ApplyLineRow line, |
| | | 883 | | CancellationToken cancellationToken) |
| | | 884 | | { |
| | | 885 | | try |
| | | 886 | | { |
| | 0 | 887 | | var targetStock = GetTargetStock(line); |
| | 0 | 888 | | var single = new List<StockItemForUpdate> |
| | 0 | 889 | | { |
| | 0 | 890 | | new() |
| | 0 | 891 | | { |
| | 0 | 892 | | OfferId = line.OfferId!, |
| | 0 | 893 | | ProductId = line.OzonProductId!.Value, |
| | 0 | 894 | | Stock = targetStock, |
| | 0 | 895 | | WarehouseId = line.EffectiveWarehouseId |
| | 0 | 896 | | } |
| | 0 | 897 | | }; |
| | 0 | 898 | | var result = await apiManager.UpdateProductStocks(single); |
| | 0 | 899 | | var row = result?.Result?.FirstOrDefault(); |
| | 0 | 900 | | if (row != null && row.Updated && (row.Errors == null || !row.Errors.Any())) |
| | | 901 | | { |
| | 0 | 902 | | await MarkLineAsync(db, line.LineId, true, null, cancellationToken, targetStock); |
| | 0 | 903 | | return (true, null); |
| | | 904 | | } |
| | | 905 | | |
| | 0 | 906 | | var err = FormatStockErrors(row); |
| | 0 | 907 | | await MarkLineAsync(db, line.LineId, false, err, cancellationToken); |
| | 0 | 908 | | return (false, err); |
| | | 909 | | } |
| | | 910 | | catch (Exception ex) |
| | | 911 | | { |
| | 0 | 912 | | await MarkLineAsync(db, line.LineId, false, ex.Message, cancellationToken); |
| | 0 | 913 | | return (false, ex.Message); |
| | | 914 | | } |
| | 0 | 915 | | } |
| | | 916 | | |
| | | 917 | | /// <summary> |
| | | 918 | | /// Остатки отчёта взяты из снимка Google-таблицы, а таблица может отставать от 1С (товар уже перемещён, |
| | | 919 | | /// в таблице всё ещё числится). Перед накатом остатка вверх сверяем его с регистром 1С онлайн и не даём |
| | | 920 | | /// выставить на Ozon больше, чем свободно в 1С. Строки, которые не удалось проверить (карточка, склад |
| | | 921 | | /// или производитель не определены, 1С недоступна), накатываются как раньше. |
| | | 922 | | /// </summary> |
| | | 923 | | /// <summary> |
| | | 924 | | /// Сверка строк наката с 1С онлайн. Проверяются Push и Decrease (остаток к накату ограничивается сверху живым |
| | | 925 | | /// остатком 1С) и строки Match из <paramref name="matchProbes"/>: если по 1С свободно меньше, чем на Ozon, |
| | | 926 | | /// строка превращается в Decrease и возвращается вызывающему для добавления к накату. |
| | | 927 | | /// </summary> |
| | | 928 | | private async Task<List<ApplyLineRow>> ApplyLiveStockGuardAsync( |
| | | 929 | | ApplicationDbContext db, |
| | | 930 | | List<ApplyLineRow> lines, |
| | | 931 | | List<ApplyLineRow> matchProbes, |
| | | 932 | | Func<string, Task> log, |
| | | 933 | | CancellationToken cancellationToken) |
| | | 934 | | { |
| | 0 | 935 | | var converted = new List<ApplyLineRow>(); |
| | 0 | 936 | | var candidates = lines |
| | | 937 | | .Where(l => (string.Equals(l.Action, BitrixStockSyncReportActions.Push, StringComparison.OrdinalIgnoreCase) |
| | | 938 | | || string.Equals(l.Action, BitrixStockSyncReportActions.Decrease, StringComparison.OrdinalIgnor |
| | | 939 | | && !l.PriorityForceClear && !l.PrioritySkip && l.OneCQty > 0 && !string.IsNullOrWhiteSpace(l.Art |
| | | 940 | | .Concat(matchProbes.Where(l => !string.IsNullOrWhiteSpace(l.Article))) |
| | 0 | 941 | | .ToList(); |
| | 0 | 942 | | if (candidates.Count == 0) |
| | 0 | 943 | | return converted; |
| | | 944 | | |
| | | 945 | | try |
| | | 946 | | { |
| | | 947 | | // Производитель нужен, чтобы найти карточку в 1С; в строке отчёта его нет — берём из снимка. |
| | | 948 | | var articles = candidates.Select(l => l.Article).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); |
| | 0 | 949 | | var manufacturers = new List<(string Article, string Manufacturer)>(); |
| | 0 | 950 | | foreach (var chunk in articles.Chunk(500)) |
| | | 951 | | { |
| | 0 | 952 | | var part = await db.WarehouseStockSnapshots.AsNoTracking() |
| | 0 | 953 | | .Where(s => chunk.Contains(s.Article) && s.Manufacturer != null && s.Manufacturer != "") |
| | 0 | 954 | | .Select(s => new { s.Article, s.Manufacturer }) |
| | 0 | 955 | | .Distinct() |
| | 0 | 956 | | .ToListAsync(cancellationToken); |
| | | 957 | | manufacturers.AddRange(part.Select(p => (p.Article ?? string.Empty, p.Manufacturer!))); |
| | | 958 | | } |
| | | 959 | | |
| | 0 | 960 | | var producerByArticle = manufacturers |
| | | 961 | | .GroupBy(m => m.Article, StringComparer.OrdinalIgnoreCase) |
| | | 962 | | .Select(g => (Article: g.Key, Producers: g.Select(x => x.Manufacturer).Distinct(StringComparer.OrdinalIg |
| | | 963 | | .Where(x => x.Producers.Count == 1) // один артикул у нескольких производителей — карточку не угадать |
| | | 964 | | .ToDictionary(x => x.Article, x => x.Producers[0], StringComparer.OrdinalIgnoreCase); |
| | | 965 | | |
| | 0 | 966 | | var toCheck = candidates |
| | 0 | 967 | | .Where(l => producerByArticle.ContainsKey(l.Article) && BitrixStockLiveStockClamp.SplitStores(l.OneCStor |
| | 0 | 968 | | .Select(l => new LiveStockLine(l.LineId, l.Article, producerByArticle[l.Article], |
| | 0 | 969 | | BitrixStockLiveStockClamp.SplitStores(l.OneCStore), l.ReservedQty)) |
| | 0 | 970 | | .ToList(); |
| | 0 | 971 | | if (toCheck.Count == 0) |
| | 0 | 972 | | return converted; |
| | | 973 | | |
| | 0 | 974 | | var freeByLine = await _liveStockGuard.GetLiveFreeStockAsync(toCheck, cancellationToken); |
| | 0 | 975 | | var clamped = 0; |
| | 0 | 976 | | foreach (var line in candidates) |
| | | 977 | | { |
| | 0 | 978 | | if (!freeByLine.TryGetValue(line.LineId, out var free) || free >= line.OneCQty) |
| | | 979 | | continue; |
| | | 980 | | |
| | 0 | 981 | | var newQty = BitrixStockLiveStockClamp.Clamp(line.OneCQty, free); |
| | 0 | 982 | | line.LiveStockNote = $"Остаток ограничен по 1С онлайн: в отчёте {line.OneCQty}, свободно в 1С {free}"; |
| | 0 | 983 | | await log($"⚠ {line.Article} ({line.OneCStore}): в отчёте 1С {line.OneCQty}, по 1С онлайн свободно {free |
| | 0 | 984 | | line.OneCQty = newQty; |
| | 0 | 985 | | clamped++; |
| | | 986 | | |
| | 0 | 987 | | if (matchProbes.Contains(line)) |
| | | 988 | | { |
| | 0 | 989 | | line.Action = BitrixStockSyncReportActions.Decrease; |
| | 0 | 990 | | converted.Add(line); |
| | | 991 | | } |
| | 0 | 992 | | } |
| | | 993 | | |
| | 0 | 994 | | await log(clamped > 0 |
| | 0 | 995 | | ? $"Сверка с 1С онлайн: проверено {freeByLine.Count} из {candidates.Count} строк, ограничено {clamped}" |
| | 0 | 996 | | : $"Сверка с 1С онлайн: проверено {freeByLine.Count} из {candidates.Count} строк, расхождений нет"); |
| | 0 | 997 | | } |
| | 0 | 998 | | catch (OperationCanceledException) { throw; } |
| | 0 | 999 | | catch (Exception ex) |
| | | 1000 | | { |
| | | 1001 | | // Fail-open: недоступность 1С не должна останавливать накат остатков. |
| | 0 | 1002 | | await log($"Предупреждение: сверка остатков с 1С онлайн не выполнена — {ex.Message}"); |
| | | 1003 | | } |
| | | 1004 | | |
| | 0 | 1005 | | return converted; |
| | 0 | 1006 | | } |
| | | 1007 | | |
| | | 1008 | | private static int GetTargetStock(ApplyLineRow line) => |
| | 0 | 1009 | | BitrixStockApplyCalculator.GetTargetStock( |
| | 0 | 1010 | | line.PriorityForceClear, line.Action, line.OneCQty, line.BatchMultiplier); |
| | | 1011 | | |
| | | 1012 | | private static List<string> ResolveApplyActions(List<string>? requested) |
| | | 1013 | | { |
| | 0 | 1014 | | if (requested == null || requested.Count == 0) |
| | | 1015 | | { |
| | 0 | 1016 | | return BitrixStockSyncReportActions.ApplicableToOzon.ToList(); |
| | | 1017 | | } |
| | | 1018 | | |
| | 0 | 1019 | | return requested |
| | 0 | 1020 | | .Where(a => !string.IsNullOrWhiteSpace(a)) |
| | 0 | 1021 | | .Select(a => a.Trim()) |
| | 0 | 1022 | | .Where(a => BitrixStockSyncReportActions.ApplicableToOzon.Contains(a)) |
| | 0 | 1023 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 1024 | | .ToList(); |
| | | 1025 | | } |
| | | 1026 | | |
| | | 1027 | | private static async Task<ReportMetaRow?> LoadReportMetaAsync( |
| | | 1028 | | ApplicationDbContext db, |
| | | 1029 | | string reportId, |
| | | 1030 | | CancellationToken cancellationToken) |
| | | 1031 | | { |
| | 0 | 1032 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | 0 | 1033 | | { |
| | 0 | 1034 | | var cmd = connection.CreateCommand(); |
| | 0 | 1035 | | cmd.CommandText = |
| | 0 | 1036 | | @"SELECT OzonClientId, OzonClientName, Status, COALESCE(MultiClientIdsJson, '[]') |
| | 0 | 1037 | | FROM BitrixStockSyncReports WHERE ReportId = @id LIMIT 1;"; |
| | 0 | 1038 | | AddParam(cmd, "@id", reportId); |
| | 0 | 1039 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 1040 | | if (!await reader.ReadAsync(ct)) |
| | 0 | 1041 | | { |
| | 0 | 1042 | | return null; |
| | 0 | 1043 | | } |
| | 0 | 1044 | | |
| | 0 | 1045 | | return new ReportMetaRow(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), |
| | 0 | 1046 | | reader.IsDBNull(3) ? "[]" : reader.GetString(3)); |
| | 0 | 1047 | | }, cancellationToken); |
| | 0 | 1048 | | } |
| | | 1049 | | |
| | | 1050 | | private static async Task<List<ApplyLineRow>> LoadClientReferenceLinesAsync( |
| | | 1051 | | ApplicationDbContext db, |
| | | 1052 | | string reportId, |
| | | 1053 | | int clientId, |
| | | 1054 | | ISet<string> articleStorePairs, |
| | | 1055 | | CancellationToken cancellationToken) |
| | | 1056 | | { |
| | 0 | 1057 | | if (articleStorePairs.Count == 0) return new List<ApplyLineRow>(); |
| | | 1058 | | |
| | 0 | 1059 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | 0 | 1060 | | { |
| | 0 | 1061 | | var pairList = articleStorePairs.ToList(); |
| | | 1062 | | var pairParams = string.Join(", ", pairList.Select((_, i) => $"@pair{i}")); |
| | 0 | 1063 | | var sql = $@" |
| | 0 | 1064 | | SELECT l.Id, l.ProductId, l.Article, l.OfferId, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId, |
| | 0 | 1065 | | l.OneCQty, l.OzonQty, l.Action, l.OzonProductId, m.Id as MappingId, |
| | 0 | 1066 | | COALESCE(l.IsArchived, 0) as IsArchived, COALESCE(l.IsAutoArchived, 0) as IsAutoArchived, |
| | 0 | 1067 | | COALESCE(l.OzonClientId, 0) as OzonClientId, |
| | 0 | 1068 | | COALESCE(CAST(mfbs.OzonWarehouseId AS SIGNED), 0) as FbsMappedWarehouseId, |
| | 0 | 1069 | | COALESCE(l.BatchMultiplier, 1) as BatchMultiplier |
| | 0 | 1070 | | FROM BitrixStockSyncReportLines l |
| | 0 | 1071 | | {ReportLineWarehouseMappingJoinSql} |
| | 0 | 1072 | | LEFT JOIN WarehouseMapping mfbs ON l.Action = 'FbsNoWarehouse' |
| | 0 | 1073 | | AND l.OneCStore COLLATE utf8mb4_unicode_ci = mfbs.BitrixWarehouseName COLLATE utf8mb4_unicode_ci |
| | 0 | 1074 | | AND mfbs.OzonClientId = l.OzonClientId |
| | 0 | 1075 | | AND mfbs.OzonWarehouseId IS NOT NULL AND mfbs.OzonWarehouseId != '' |
| | 0 | 1076 | | WHERE l.ReportId = @reportId |
| | 0 | 1077 | | AND l.OzonClientId = @clientId |
| | 0 | 1078 | | AND CONCAT(l.Article, '||', COALESCE(l.OneCStore, '')) IN ({pairParams}) |
| | 0 | 1079 | | ORDER BY l.Id"; |
| | 0 | 1080 | | |
| | 0 | 1081 | | var cmd = connection.CreateCommand(); |
| | 0 | 1082 | | cmd.CommandText = sql; |
| | 0 | 1083 | | AddParam(cmd, "@reportId", reportId); |
| | 0 | 1084 | | AddParam(cmd, "@clientId", clientId); |
| | 0 | 1085 | | for (var i = 0; i < pairList.Count; i++) |
| | 0 | 1086 | | AddParam(cmd, $"@pair{i}", pairList[i]); |
| | 0 | 1087 | | |
| | 0 | 1088 | | var list = new List<ApplyLineRow>(); |
| | 0 | 1089 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 1090 | | while (await reader.ReadAsync(ct)) |
| | 0 | 1091 | | { |
| | 0 | 1092 | | list.Add(new ApplyLineRow |
| | 0 | 1093 | | { |
| | 0 | 1094 | | LineId = reader.GetInt64(0), |
| | 0 | 1095 | | ProductId = reader.GetInt32(1), |
| | 0 | 1096 | | Article = reader.GetString(2), |
| | 0 | 1097 | | OfferId = reader.IsDBNull(3) ? null : reader.GetString(3), |
| | 0 | 1098 | | OneCStore = reader.GetString(4), |
| | 0 | 1099 | | OzonWarehouseName = reader.GetString(5), |
| | 0 | 1100 | | OzonWarehouseId = reader.GetInt64(6), |
| | 0 | 1101 | | OneCQty = reader.GetInt32(7), |
| | 0 | 1102 | | OzonQty = reader.GetInt32(8), |
| | 0 | 1103 | | Action = reader.GetString(9), |
| | 0 | 1104 | | OzonProductId = reader.IsDBNull(10) ? null : reader.GetInt64(10), |
| | 0 | 1105 | | IsArchived = !reader.IsDBNull(12) && reader.GetInt32(12) != 0, |
| | 0 | 1106 | | IsAutoArchived = !reader.IsDBNull(13) && reader.GetInt32(13) != 0, |
| | 0 | 1107 | | OzonClientId = reader.IsDBNull(14) ? 0 : reader.GetInt32(14), |
| | 0 | 1108 | | FbsMappedWarehouseId = reader.IsDBNull(15) ? 0 : reader.GetInt64(15), |
| | 0 | 1109 | | BatchMultiplier = reader.IsDBNull(16) ? 1 : reader.GetInt32(16) |
| | 0 | 1110 | | }); |
| | 0 | 1111 | | } |
| | 0 | 1112 | | |
| | 0 | 1113 | | return list; |
| | 0 | 1114 | | }, cancellationToken); |
| | 0 | 1115 | | } |
| | | 1116 | | |
| | | 1117 | | private static async Task<List<ApplyLineRow>> LoadClientLinesWithPositiveStockAsync( |
| | | 1118 | | ApplicationDbContext db, |
| | | 1119 | | string reportId, |
| | | 1120 | | int clientId, |
| | | 1121 | | bool onlyPending, |
| | | 1122 | | IReadOnlyList<string>? articleFilter, |
| | | 1123 | | CancellationToken cancellationToken) |
| | | 1124 | | { |
| | 0 | 1125 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | 0 | 1126 | | { |
| | 0 | 1127 | | var sql = @" |
| | 0 | 1128 | | SELECT l.Id, l.ProductId, l.Article, l.OfferId, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId, |
| | 0 | 1129 | | l.OneCQty, l.OzonQty, l.Action, l.OzonProductId, m.Id as MappingId, |
| | 0 | 1130 | | COALESCE(l.IsArchived, 0) as IsArchived, COALESCE(l.IsAutoArchived, 0) as IsAutoArchived, |
| | 0 | 1131 | | COALESCE(l.OzonClientId, 0) as OzonClientId, |
| | 0 | 1132 | | COALESCE(CAST(mfbs.OzonWarehouseId AS SIGNED), 0) as FbsMappedWarehouseId, |
| | 0 | 1133 | | COALESCE(l.BatchMultiplier, 1) as BatchMultiplier |
| | 0 | 1134 | | FROM BitrixStockSyncReportLines l |
| | 0 | 1135 | | {ReportLineWarehouseMappingJoinSql} |
| | 0 | 1136 | | LEFT JOIN WarehouseMapping mfbs ON l.Action = 'FbsNoWarehouse' |
| | 0 | 1137 | | AND l.OneCStore COLLATE utf8mb4_unicode_ci = mfbs.BitrixWarehouseName COLLATE utf8mb4_unicode_ci |
| | 0 | 1138 | | AND mfbs.OzonClientId = l.OzonClientId |
| | 0 | 1139 | | AND mfbs.OzonWarehouseId IS NOT NULL AND mfbs.OzonWarehouseId != '' |
| | 0 | 1140 | | WHERE l.ReportId = @reportId |
| | 0 | 1141 | | AND l.OzonClientId = @clientId |
| | 0 | 1142 | | AND l.OzonQty > 0"; |
| | 0 | 1143 | | |
| | 0 | 1144 | | if (onlyPending) |
| | 0 | 1145 | | sql += " AND (l.OzonApplyStatus IS NULL OR l.OzonApplyStatus IN ('Pending','Failed'))"; |
| | 0 | 1146 | | |
| | 0 | 1147 | | if (articleFilter is { Count: > 0 }) |
| | 0 | 1148 | | { |
| | | 1149 | | var artIn = string.Join(", ", articleFilter.Select((_, i) => $"@art{i}")); |
| | 0 | 1150 | | sql += $" AND l.Article IN ({artIn})"; |
| | 0 | 1151 | | } |
| | 0 | 1152 | | |
| | 0 | 1153 | | sql += " ORDER BY l.Id"; |
| | 0 | 1154 | | |
| | 0 | 1155 | | var cmd = connection.CreateCommand(); |
| | 0 | 1156 | | cmd.CommandText = sql; |
| | 0 | 1157 | | AddParam(cmd, "@reportId", reportId); |
| | 0 | 1158 | | AddParam(cmd, "@clientId", clientId); |
| | 0 | 1159 | | if (articleFilter is { Count: > 0 }) |
| | 0 | 1160 | | for (var i = 0; i < articleFilter.Count; i++) |
| | 0 | 1161 | | AddParam(cmd, $"@art{i}", articleFilter[i]); |
| | 0 | 1162 | | |
| | 0 | 1163 | | var list = new List<ApplyLineRow>(); |
| | 0 | 1164 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 1165 | | while (await reader.ReadAsync(ct)) |
| | 0 | 1166 | | { |
| | 0 | 1167 | | list.Add(new ApplyLineRow |
| | 0 | 1168 | | { |
| | 0 | 1169 | | LineId = reader.GetInt64(0), |
| | 0 | 1170 | | ProductId = reader.GetInt32(1), |
| | 0 | 1171 | | Article = reader.GetString(2), |
| | 0 | 1172 | | OfferId = reader.IsDBNull(3) ? null : reader.GetString(3), |
| | 0 | 1173 | | OneCStore = reader.GetString(4), |
| | 0 | 1174 | | OzonWarehouseName = reader.GetString(5), |
| | 0 | 1175 | | OzonWarehouseId = reader.GetInt64(6), |
| | 0 | 1176 | | OneCQty = reader.GetInt32(7), |
| | 0 | 1177 | | OzonQty = reader.GetInt32(8), |
| | 0 | 1178 | | Action = reader.GetString(9), |
| | 0 | 1179 | | OzonProductId = reader.IsDBNull(10) ? null : reader.GetInt64(10), |
| | 0 | 1180 | | IsArchived = !reader.IsDBNull(12) && reader.GetInt32(12) != 0, |
| | 0 | 1181 | | IsAutoArchived = !reader.IsDBNull(13) && reader.GetInt32(13) != 0, |
| | 0 | 1182 | | OzonClientId = reader.IsDBNull(14) ? 0 : reader.GetInt32(14), |
| | 0 | 1183 | | FbsMappedWarehouseId = reader.IsDBNull(15) ? 0 : reader.GetInt64(15), |
| | 0 | 1184 | | BatchMultiplier = reader.IsDBNull(16) ? 1 : reader.GetInt32(16) |
| | 0 | 1185 | | }); |
| | 0 | 1186 | | } |
| | 0 | 1187 | | |
| | 0 | 1188 | | return list; |
| | 0 | 1189 | | }, cancellationToken); |
| | 0 | 1190 | | } |
| | | 1191 | | |
| | | 1192 | | private static async Task<List<ApplyLineRow>> LoadLinesToApplyAsync( |
| | | 1193 | | ApplicationDbContext db, |
| | | 1194 | | string reportId, |
| | | 1195 | | IReadOnlyList<string> actions, |
| | | 1196 | | bool onlyPending, |
| | | 1197 | | int? maxLines, |
| | | 1198 | | ISet<int> disabledMappingIds, |
| | | 1199 | | IReadOnlyList<string>? articles, |
| | | 1200 | | CancellationToken cancellationToken) |
| | | 1201 | | { |
| | 0 | 1202 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | 0 | 1203 | | { |
| | | 1204 | | var actionIn = string.Join(", ", actions.Select((_, i) => $"@a{i}")); |
| | 0 | 1205 | | var sql = $@" |
| | 0 | 1206 | | SELECT l.Id, l.ProductId, l.Article, l.OfferId, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId, |
| | 0 | 1207 | | l.OneCQty, l.OzonQty, l.Action, l.OzonProductId, m.Id as MappingId, |
| | 0 | 1208 | | COALESCE(l.IsArchived, 0) as IsArchived, COALESCE(l.IsAutoArchived, 0) as IsAutoArchived, |
| | 0 | 1209 | | COALESCE(l.OzonClientId, 0) as OzonClientId, |
| | 0 | 1210 | | COALESCE(CAST(mfbs.OzonWarehouseId AS SIGNED), 0) as FbsMappedWarehouseId, |
| | 0 | 1211 | | COALESCE(l.BatchMultiplier, 1) as BatchMultiplier, |
| | 0 | 1212 | | COALESCE(l.ReservedQty, 0) as ReservedQty |
| | 0 | 1213 | | FROM BitrixStockSyncReportLines l |
| | 0 | 1214 | | {ReportLineWarehouseMappingJoinSql} |
| | 0 | 1215 | | LEFT JOIN WarehouseMapping mfbs ON l.Action = 'FbsNoWarehouse' |
| | 0 | 1216 | | AND l.OneCStore COLLATE utf8mb4_unicode_ci = mfbs.BitrixWarehouseName COLLATE utf8mb4_unicode_ci |
| | 0 | 1217 | | AND mfbs.OzonClientId = l.OzonClientId |
| | 0 | 1218 | | AND mfbs.OzonWarehouseId IS NOT NULL AND mfbs.OzonWarehouseId != '' |
| | 0 | 1219 | | WHERE l.ReportId = @reportId |
| | 0 | 1220 | | AND l.Action IN ({actionIn})"; |
| | 0 | 1221 | | if (onlyPending) |
| | 0 | 1222 | | { |
| | 0 | 1223 | | sql += " AND (l.OzonApplyStatus IS NULL OR l.OzonApplyStatus IN ('Pending','Failed'))"; |
| | 0 | 1224 | | } |
| | 0 | 1225 | | |
| | 0 | 1226 | | if (articles is { Count: > 0 }) |
| | 0 | 1227 | | { |
| | | 1228 | | var artIn = string.Join(", ", articles.Select((_, i) => $"@art{i}")); |
| | 0 | 1229 | | sql += $" AND l.Article IN ({artIn})"; |
| | 0 | 1230 | | } |
| | 0 | 1231 | | |
| | 0 | 1232 | | sql += " ORDER BY l.Id"; |
| | 0 | 1233 | | if (maxLines is > 0) |
| | 0 | 1234 | | { |
| | 0 | 1235 | | sql += " LIMIT @take"; |
| | 0 | 1236 | | } |
| | 0 | 1237 | | |
| | 0 | 1238 | | var cmd = connection.CreateCommand(); |
| | 0 | 1239 | | cmd.CommandText = sql; |
| | 0 | 1240 | | AddParam(cmd, "@reportId", reportId); |
| | 0 | 1241 | | for (var i = 0; i < actions.Count; i++) |
| | 0 | 1242 | | { |
| | 0 | 1243 | | AddParam(cmd, $"@a{i}", actions[i]); |
| | 0 | 1244 | | } |
| | 0 | 1245 | | |
| | 0 | 1246 | | if (articles is { Count: > 0 }) |
| | 0 | 1247 | | { |
| | 0 | 1248 | | for (var i = 0; i < articles.Count; i++) |
| | 0 | 1249 | | AddParam(cmd, $"@art{i}", articles[i]); |
| | 0 | 1250 | | } |
| | 0 | 1251 | | |
| | 0 | 1252 | | if (maxLines is > 0) |
| | 0 | 1253 | | { |
| | 0 | 1254 | | AddParam(cmd, "@take", maxLines.Value); |
| | 0 | 1255 | | } |
| | 0 | 1256 | | |
| | 0 | 1257 | | var list = new List<ApplyLineRow>(); |
| | 0 | 1258 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 1259 | | while (await reader.ReadAsync(ct)) |
| | 0 | 1260 | | { |
| | 0 | 1261 | | int? mappingId = reader.IsDBNull(11) ? (int?)null : reader.GetInt32(11); |
| | 0 | 1262 | | |
| | 0 | 1263 | | // Пропустить если связь отключена |
| | 0 | 1264 | | if (mappingId.HasValue && disabledMappingIds.Contains(mappingId.Value)) |
| | 0 | 1265 | | { |
| | 0 | 1266 | | continue; |
| | 0 | 1267 | | } |
| | 0 | 1268 | | |
| | 0 | 1269 | | list.Add(new ApplyLineRow |
| | 0 | 1270 | | { |
| | 0 | 1271 | | LineId = reader.GetInt64(0), |
| | 0 | 1272 | | ProductId = reader.GetInt32(1), |
| | 0 | 1273 | | Article = reader.GetString(2), |
| | 0 | 1274 | | OfferId = reader.IsDBNull(3) ? null : reader.GetString(3), |
| | 0 | 1275 | | OneCStore = reader.GetString(4), |
| | 0 | 1276 | | OzonWarehouseName = reader.GetString(5), |
| | 0 | 1277 | | OzonWarehouseId = reader.GetInt64(6), |
| | 0 | 1278 | | OneCQty = reader.GetInt32(7), |
| | 0 | 1279 | | OzonQty = reader.GetInt32(8), |
| | 0 | 1280 | | Action = reader.GetString(9), |
| | 0 | 1281 | | OzonProductId = reader.IsDBNull(10) ? null : reader.GetInt64(10), |
| | 0 | 1282 | | IsArchived = !reader.IsDBNull(12) && reader.GetInt32(12) != 0, |
| | 0 | 1283 | | IsAutoArchived = !reader.IsDBNull(13) && reader.GetInt32(13) != 0, |
| | 0 | 1284 | | OzonClientId = reader.IsDBNull(14) ? 0 : reader.GetInt32(14), |
| | 0 | 1285 | | FbsMappedWarehouseId = reader.IsDBNull(15) ? 0 : reader.GetInt64(15), |
| | 0 | 1286 | | BatchMultiplier = reader.IsDBNull(16) ? 1 : reader.GetInt32(16), |
| | 0 | 1287 | | ReservedQty = reader.IsDBNull(17) ? 0 : reader.GetInt32(17) |
| | 0 | 1288 | | }); |
| | 0 | 1289 | | } |
| | 0 | 1290 | | |
| | 0 | 1291 | | return list; |
| | 0 | 1292 | | }, cancellationToken); |
| | 0 | 1293 | | } |
| | | 1294 | | |
| | | 1295 | | private static async Task MarkLineAsync( |
| | | 1296 | | ApplicationDbContext db, |
| | | 1297 | | long lineId, |
| | | 1298 | | bool success, |
| | | 1299 | | string? error, |
| | | 1300 | | CancellationToken cancellationToken, |
| | | 1301 | | int? qtyAfter = null) |
| | | 1302 | | { |
| | 0 | 1303 | | await db.Database.ExecuteSqlRawAsync( |
| | 0 | 1304 | | @"UPDATE BitrixStockSyncReportLines |
| | 0 | 1305 | | SET OzonApplyStatus = {1}, OzonAppliedUtc = UTC_TIMESTAMP(6), OzonApplyError = {2}, OzonQtyAfter = {3} |
| | 0 | 1306 | | WHERE Id = {0};", |
| | 0 | 1307 | | EfSqlRawParameterGuard.Materialize(lineId, success ? BitrixStockSyncReportApplyStatuses.Success : BitrixStoc |
| | 0 | 1308 | | cancellationToken); |
| | 0 | 1309 | | } |
| | | 1310 | | |
| | | 1311 | | private static async Task<bool> TryUpdateBatchAsync( |
| | | 1312 | | OzonApiDataManager apiManager, |
| | | 1313 | | List<StockItemForUpdate> stockPayload, |
| | | 1314 | | List<BitrixStockPushItemDto> batchItems, |
| | | 1315 | | Func<BitrixStockPushItemDto, string, Task> onFailed, |
| | | 1316 | | Func<BitrixStockPushItemDto, Task> onSuccess, |
| | | 1317 | | CancellationToken cancellationToken) |
| | | 1318 | | { |
| | | 1319 | | try |
| | | 1320 | | { |
| | 0 | 1321 | | var result = await apiManager.UpdateProductStocks(stockPayload); |
| | 0 | 1322 | | if (result?.Result == null || result.Result.Count == 0) |
| | | 1323 | | { |
| | 0 | 1324 | | return false; |
| | | 1325 | | } |
| | | 1326 | | |
| | 0 | 1327 | | var allOk = true; |
| | 0 | 1328 | | foreach (var row in result.Result) |
| | | 1329 | | { |
| | 0 | 1330 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 1331 | | // По offer_id + складу, а не только по product_id: в отчёте он может быть устаревшим. |
| | 0 | 1332 | | var item = BitrixStockApplyBatchMatcher.FindItem(batchItems, row); |
| | 0 | 1333 | | if (item == null) |
| | | 1334 | | { |
| | | 1335 | | continue; |
| | | 1336 | | } |
| | | 1337 | | |
| | 0 | 1338 | | if (row.Updated && (row.Errors == null || !row.Errors.Any())) |
| | | 1339 | | { |
| | 0 | 1340 | | await onSuccess(item); |
| | | 1341 | | } |
| | | 1342 | | else |
| | | 1343 | | { |
| | 0 | 1344 | | allOk = false; |
| | 0 | 1345 | | await onFailed(item, FormatStockErrors(row)); |
| | | 1346 | | } |
| | | 1347 | | } |
| | | 1348 | | |
| | 0 | 1349 | | return allOk; |
| | | 1350 | | } |
| | 0 | 1351 | | catch |
| | | 1352 | | { |
| | 0 | 1353 | | return false; |
| | | 1354 | | } |
| | 0 | 1355 | | } |
| | | 1356 | | |
| | | 1357 | | private static string FormatStockErrors(UpdateStockResult? row) |
| | | 1358 | | { |
| | 0 | 1359 | | if (row?.Errors != null && row.Errors.Any()) |
| | | 1360 | | { |
| | 0 | 1361 | | return string.Join("; ", row.Errors.Select(e => BitrixOzonStockErrorMapper.Map(e.Message ?? e.Code))); |
| | | 1362 | | } |
| | | 1363 | | |
| | 0 | 1364 | | return row?.Updated == true ? string.Empty : "Ozon не обновил остаток"; |
| | | 1365 | | } |
| | | 1366 | | |
| | | 1367 | | private static async Task<string[]> LoadAllReportOfferIdsAsync( |
| | | 1368 | | ApplicationDbContext db, |
| | | 1369 | | string reportId, |
| | | 1370 | | CancellationToken cancellationToken) |
| | | 1371 | | { |
| | 0 | 1372 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | 0 | 1373 | | { |
| | 0 | 1374 | | var cmd = connection.CreateCommand(); |
| | 0 | 1375 | | cmd.CommandText = "SELECT DISTINCT OfferId FROM BitrixStockSyncReportLines WHERE ReportId = @reportId AND Of |
| | 0 | 1376 | | AddParam(cmd, "@reportId", reportId); |
| | 0 | 1377 | | var list = new List<string>(); |
| | 0 | 1378 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 1379 | | while (await reader.ReadAsync(ct)) |
| | 0 | 1380 | | list.Add(reader.GetString(0)); |
| | 0 | 1381 | | return list.ToArray(); |
| | 0 | 1382 | | }, cancellationToken); |
| | 0 | 1383 | | } |
| | | 1384 | | |
| | | 1385 | | /// <summary>Возвращает словарь OfferId → IsAutoArchived для всех архивных строк отчёта.</summary> |
| | | 1386 | | private static async Task<Dictionary<string, bool>> LoadOfferArchiveTypesAsync( |
| | | 1387 | | ApplicationDbContext db, |
| | | 1388 | | string reportId, |
| | | 1389 | | CancellationToken cancellationToken) |
| | | 1390 | | { |
| | 0 | 1391 | | return await WithOpenConnectionAsync(db, async (connection, ct) => |
| | 0 | 1392 | | { |
| | 0 | 1393 | | var cmd = connection.CreateCommand(); |
| | 0 | 1394 | | cmd.CommandText = @" |
| | 0 | 1395 | | SELECT OfferId, MAX(IsAutoArchived) AS IsAutoArchived |
| | 0 | 1396 | | FROM BitrixStockSyncReportLines |
| | 0 | 1397 | | WHERE ReportId = @reportId AND IsArchived = 1 AND OfferId IS NOT NULL AND OfferId != '' |
| | 0 | 1398 | | GROUP BY OfferId;"; |
| | 0 | 1399 | | AddParam(cmd, "@reportId", reportId); |
| | 0 | 1400 | | var dict = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 1401 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 1402 | | while (await reader.ReadAsync(ct)) |
| | 0 | 1403 | | { |
| | 0 | 1404 | | var offerId = reader.GetString(0); |
| | 0 | 1405 | | var isAuto = !reader.IsDBNull(1) && Convert.ToInt32(reader.GetValue(1)) != 0; |
| | 0 | 1406 | | dict[offerId] = isAuto; |
| | 0 | 1407 | | } |
| | 0 | 1408 | | return dict; |
| | 0 | 1409 | | }, cancellationToken); |
| | 0 | 1410 | | } |
| | | 1411 | | |
| | | 1412 | | private static async Task<T> WithOpenConnectionAsync<T>( |
| | | 1413 | | ApplicationDbContext db, |
| | | 1414 | | Func<System.Data.Common.DbConnection, CancellationToken, Task<T>> action, |
| | | 1415 | | CancellationToken cancellationToken) |
| | | 1416 | | { |
| | 0 | 1417 | | await db.Database.OpenConnectionAsync(cancellationToken); |
| | | 1418 | | try |
| | | 1419 | | { |
| | 0 | 1420 | | return await action(db.Database.GetDbConnection(), cancellationToken); |
| | | 1421 | | } |
| | | 1422 | | finally |
| | | 1423 | | { |
| | 0 | 1424 | | await db.Database.CloseConnectionAsync(); |
| | | 1425 | | } |
| | 0 | 1426 | | } |
| | | 1427 | | |
| | | 1428 | | private static void AddParam(System.Data.Common.DbCommand command, string name, object? value) |
| | | 1429 | | { |
| | 0 | 1430 | | var parameter = command.CreateParameter(); |
| | 0 | 1431 | | parameter.ParameterName = name; |
| | 0 | 1432 | | parameter.Value = value ?? DBNull.Value; |
| | 0 | 1433 | | command.Parameters.Add(parameter); |
| | 0 | 1434 | | } |
| | | 1435 | | |
| | 12 | 1436 | | private sealed record ReportMetaRow(int OzonClientId, string OzonClientName, string Status, string MultiClientIdsJso |
| | | 1437 | | |
| | | 1438 | | private static void ApplyPriorityForTwoClients(List<ApplyLineRow> lines, ReportMetaRow meta, Dictionary<string, Appl |
| | | 1439 | | { |
| | 6 | 1440 | | var orderedClients = ParseMultiClientIds(meta.MultiClientIdsJson); |
| | 6 | 1441 | | if (orderedClients.Count < 2) |
| | 0 | 1442 | | return; |
| | | 1443 | | |
| | 6 | 1444 | | var k1Id = orderedClients[0]; |
| | 6 | 1445 | | var k2Id = orderedClients[1]; |
| | 17 | 1446 | | var groups = lines.GroupBy(l => $"{l.Article}||{l.OneCStore}", StringComparer.OrdinalIgnoreCase); |
| | 24 | 1447 | | foreach (var group in groups) |
| | | 1448 | | { |
| | 21 | 1449 | | var k1 = group.Where(l => l.OzonClientId == k1Id).OrderBy(l => l.LineId).FirstOrDefault(); |
| | | 1450 | | // Если K1 не загружен (Action=Match не входит в ApplicableToOzon) — берём из ref-карты |
| | 6 | 1451 | | if (k1 == null && k1RefMap != null) |
| | 1 | 1452 | | k1RefMap.TryGetValue(group.Key, out k1); |
| | | 1453 | | // Все строки К2 в группе — один 1С-склад может иметь несколько Ozon-складов (M2→M2, M2→M2S) |
| | 24 | 1454 | | var k2Lines = group.Where(l => l.OzonClientId == k2Id).OrderBy(l => l.LineId).ToList(); |
| | 6 | 1455 | | var k1Found = IsFoundForPriority(k1); |
| | | 1456 | | |
| | 6 | 1457 | | if (!k1Found) |
| | | 1458 | | { |
| | 1 | 1459 | | if (k1 != null) MarkSkip(k1, "К1 не найден"); |
| | 1 | 1460 | | var anyK2Found = k2Lines.Any(IsFoundForPriority); |
| | 1 | 1461 | | if (!anyK2Found) { foreach (var l in k2Lines) MarkSkip(l, "К2 не найден"); continue; } |
| | 5 | 1462 | | foreach (var l in k2Lines) if (l.IsArchived) l.PriorityNeedUnarchive = true; |
| | | 1463 | | continue; |
| | | 1464 | | } |
| | | 1465 | | |
| | 5 | 1466 | | if (k1!.IsArchived) k1.PriorityNeedUnarchive = true; |
| | | 1467 | | |
| | 5 | 1468 | | if (k2Lines.Count == 0) continue; |
| | | 1469 | | |
| | | 1470 | | // Применяем приоритет ко ВСЕМ строкам К2 в группе |
| | 22 | 1471 | | foreach (var k2 in k2Lines) |
| | | 1472 | | { |
| | 6 | 1473 | | if (!IsFoundForPriority(k2)) |
| | | 1474 | | { |
| | 0 | 1475 | | MarkSkip(k2, "К2 не найден"); |
| | 0 | 1476 | | continue; |
| | | 1477 | | } |
| | | 1478 | | |
| | 6 | 1479 | | if (k2.IsArchived) |
| | | 1480 | | { |
| | 0 | 1481 | | MarkSkip(k2, "К2 архивный — не трогаем"); |
| | 0 | 1482 | | continue; |
| | | 1483 | | } |
| | | 1484 | | |
| | 6 | 1485 | | if (k2.OzonQty == 0) |
| | | 1486 | | { |
| | | 1487 | | // Для Action=Push: отчёт может содержать устаревший остаток (e.g. товары "Убран из продажи" |
| | | 1488 | | // возвращают present=0 из API даже при физическом FBS-остатке > 0). |
| | | 1489 | | // Явно отправляем 0, чтобы гарантированно обнулить К2. |
| | 5 | 1490 | | if (string.Equals(k2.Action, BitrixStockSyncReportActions.Push, StringComparison.OrdinalIgnoreCase)) |
| | 4 | 1491 | | k2.PriorityForceClear = true; |
| | | 1492 | | else |
| | 1 | 1493 | | MarkSkip(k2, "К2 уже 0 — пропуск"); |
| | | 1494 | | } |
| | | 1495 | | else |
| | | 1496 | | { |
| | 1 | 1497 | | k2.PriorityForceClear = true; |
| | | 1498 | | } |
| | | 1499 | | } |
| | | 1500 | | } |
| | 6 | 1501 | | } |
| | | 1502 | | |
| | | 1503 | | private static bool IsFoundForPriority(ApplyLineRow? line) |
| | | 1504 | | { |
| | 14 | 1505 | | if (line == null) return false; |
| | 12 | 1506 | | if (string.Equals(line.Action, BitrixStockSyncReportActions.NotFound, StringComparison.OrdinalIgnoreCase)) retur |
| | 12 | 1507 | | if (line.EffectiveWarehouseId <= 0 || line.OzonProductId is not > 0 || string.IsNullOrWhiteSpace(line.OfferId)) |
| | 12 | 1508 | | return true; |
| | | 1509 | | } |
| | | 1510 | | |
| | | 1511 | | private static void MarkSkip(ApplyLineRow line, string reason) |
| | | 1512 | | { |
| | 1 | 1513 | | line.PrioritySkip = true; |
| | 1 | 1514 | | line.PrioritySkipReason = reason; |
| | 1 | 1515 | | line.PriorityForceClear = false; |
| | 1 | 1516 | | line.PriorityNeedUnarchive = false; |
| | 1 | 1517 | | } |
| | | 1518 | | |
| | | 1519 | | private static string GetPriorityKey(ApplyLineRow line) => |
| | 0 | 1520 | | $"{line.Article}||{line.OneCStore}"; |
| | | 1521 | | |
| | | 1522 | | private static bool IsNotSellableError(string? error) => |
| | 0 | 1523 | | !string.IsNullOrWhiteSpace(error) && |
| | 0 | 1524 | | error.Contains("Не продается", StringComparison.OrdinalIgnoreCase); |
| | | 1525 | | |
| | | 1526 | | private static List<int> ParseMultiClientIds(string json) => |
| | 6 | 1527 | | BitrixStockMultiClientParser.ParseMultiClientIds(json); |
| | | 1528 | | |
| | | 1529 | | private sealed class ApplyLineRow |
| | | 1530 | | { |
| | 23 | 1531 | | public long LineId { get; init; } |
| | 0 | 1532 | | public int ProductId { get; init; } |
| | 35 | 1533 | | public string Article { get; init; } = string.Empty; |
| | 24 | 1534 | | public string? OfferId { get; init; } |
| | 35 | 1535 | | public string OneCStore { get; init; } = string.Empty; |
| | 12 | 1536 | | public string OzonWarehouseName { get; init; } = string.Empty; |
| | 36 | 1537 | | public long OzonWarehouseId { get; init; } |
| | | 1538 | | /// <summary>Для FbsNoWarehouse строк: warehouse_id из WarehouseMapping по OneCStore+OzonClientId.</summary> |
| | 0 | 1539 | | public long FbsMappedWarehouseId { get; init; } |
| | 12 | 1540 | | public long EffectiveWarehouseId => OzonWarehouseId > 0 ? OzonWarehouseId : FbsMappedWarehouseId; |
| | | 1541 | | /// <summary>Остаток 1С к накату. Меняется только защитой по живому остатку 1С (ApplyLiveStockGuardAsync).</summ |
| | 12 | 1542 | | public int OneCQty { get; set; } |
| | 18 | 1543 | | public int OzonQty { get; init; } |
| | 41 | 1544 | | public string Action { get; set; } = string.Empty; |
| | 24 | 1545 | | public long? OzonProductId { get; init; } |
| | 24 | 1546 | | public bool IsArchived { get; init; } |
| | 0 | 1547 | | public bool IsAutoArchived { get; init; } |
| | 34 | 1548 | | public int OzonClientId { get; init; } |
| | 6 | 1549 | | public bool PrioritySkip { get; set; } |
| | 1 | 1550 | | public string? PrioritySkipReason { get; set; } |
| | 13 | 1551 | | public bool PriorityForceClear { get; set; } |
| | 1 | 1552 | | public bool PriorityNeedUnarchive { get; set; } |
| | | 1553 | | /// <summary>Размер партии [N] из OfferId (например 5 для [5]). 1 если не партия.</summary> |
| | 12 | 1554 | | public int BatchMultiplier { get; init; } = 1; |
| | | 1555 | | /// <summary>Резерв заказов, уже вычтенный из OneCQty при построении отчёта.</summary> |
| | 0 | 1556 | | public int ReservedQty { get; init; } |
| | | 1557 | | /// <summary>Причина, по которой остаток ограничен по живому остатку 1С (пишется в примечание строки).</summary> |
| | 0 | 1558 | | public string? LiveStockNote { get; set; } |
| | | 1559 | | } |
| | | 1560 | | } |