| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using Hangfire; |
| | | 4 | | using Hangfire.Storage; |
| | | 5 | | using Microsoft.AspNetCore.SignalR; |
| | | 6 | | using Microsoft.EntityFrameworkCore; |
| | | 7 | | using Microsoft.Extensions.Logging; |
| | | 8 | | using Newtonsoft.Json.Linq; |
| | | 9 | | using OfficeOpenXml; |
| | | 10 | | using OzonRepositories.Context; |
| | | 11 | | using OzonRepositories.Data; |
| | | 12 | | using Services.ApiServices.OzonApi; |
| | | 13 | | using Services.ApiServices.OzonApi.Filters; |
| | | 14 | | using Services.DataServices; |
| | | 15 | | using Services.HangfireService; |
| | | 16 | | using Services.SignalRServices; |
| | | 17 | | using SlqStudio.Application.Services.EmailService; |
| | | 18 | | |
| | | 19 | | namespace Services.BitrixStockServices; |
| | | 20 | | |
| | | 21 | | public sealed partial class BitrixStockOzonOrphanCleanupService : IBitrixStockOzonOrphanCleanupService |
| | | 22 | | { |
| | | 23 | | private const int StockBatchSize = 100; |
| | | 24 | | /// <summary>Ozon warehouse/stock отчёт может готовиться дольше 3 мин.</summary> |
| | | 25 | | private const int OzonReportWaitMs = 600_000; |
| | | 26 | | private const string OzonFilesTable = "BitrixStockSyncReportOzonFiles"; |
| | 0 | 27 | | private static readonly string OzonFilesStorageRoot = |
| | 0 | 28 | | Path.Combine(AppContext.BaseDirectory, "OzonStockFiles"); |
| | | 29 | | // Regex: захватывает код бренда (2-5 символов) до опционального [суффикса] |
| | 0 | 30 | | private static readonly Regex BrandCodeRegex = |
| | 0 | 31 | | new(@"^([A-Z0-9]{2,5})(?:\[.*\])?$", RegexOptions.Compiled); |
| | | 32 | | |
| | | 33 | | /// <summary>Что Ozon ответил на попытку обнулить одну пару offer_id × склад.</summary> |
| | 0 | 34 | | internal readonly record struct OrphanZeroResult(bool Updated, string? Error); |
| | | 35 | | |
| | | 36 | | /// <summary>Ключ сопоставления строки-сироты с ответом Ozon: offer_id + склад.</summary> |
| | | 37 | | internal static string OrphanZeroKey(string? offerId, string? warehouseId) => |
| | 0 | 38 | | $"{offerId?.Trim()}|{warehouseId?.Trim()}"; |
| | | 39 | | |
| | | 40 | | private readonly IDbContextFactory<ApplicationDbContext> _dbFactory; |
| | | 41 | | private readonly OzonClientServices _ozonClientServices; |
| | | 42 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 43 | | private readonly IEmailService? _emailService; |
| | | 44 | | private readonly IHubContext<JobProgressHub> _jobProgressHub; |
| | | 45 | | private readonly BitrixStockActiveStageStore _stageStore; |
| | | 46 | | private readonly IOzonProductsCatalogService _catalogService; |
| | | 47 | | private readonly IOzonCatalogStockEnricher _stockEnricher; |
| | | 48 | | private readonly EtProducerRepository _etProducerRepository; |
| | | 49 | | private readonly IBitrixStockLiveStockGuard _liveStockGuard; |
| | | 50 | | private readonly ILogger<BitrixStockOzonOrphanCleanupService> _logger; |
| | | 51 | | |
| | 0 | 52 | | public BitrixStockOzonOrphanCleanupService( |
| | 0 | 53 | | IDbContextFactory<ApplicationDbContext> dbFactory, |
| | 0 | 54 | | OzonClientServices ozonClientServices, |
| | 0 | 55 | | IHttpClientFactory httpClientFactory, |
| | 0 | 56 | | IEmailService? emailService, |
| | 0 | 57 | | IHubContext<JobProgressHub> jobProgressHub, |
| | 0 | 58 | | BitrixStockActiveStageStore stageStore, |
| | 0 | 59 | | IOzonProductsCatalogService catalogService, |
| | 0 | 60 | | IOzonCatalogStockEnricher stockEnricher, |
| | 0 | 61 | | EtProducerRepository etProducerRepository, |
| | 0 | 62 | | IBitrixStockLiveStockGuard liveStockGuard, |
| | 0 | 63 | | ILogger<BitrixStockOzonOrphanCleanupService> logger) |
| | | 64 | | { |
| | 0 | 65 | | _dbFactory = dbFactory; |
| | 0 | 66 | | _ozonClientServices = ozonClientServices; |
| | 0 | 67 | | _httpClientFactory = httpClientFactory; |
| | 0 | 68 | | _emailService = emailService; |
| | 0 | 69 | | _jobProgressHub = jobProgressHub; |
| | 0 | 70 | | _stageStore = stageStore; |
| | 0 | 71 | | _catalogService = catalogService; |
| | 0 | 72 | | _stockEnricher = stockEnricher; |
| | 0 | 73 | | _etProducerRepository = etProducerRepository; |
| | 0 | 74 | | _liveStockGuard = liveStockGuard; |
| | 0 | 75 | | _logger = logger; |
| | 0 | 76 | | } |
| | | 77 | | |
| | | 78 | | // ─── GetOrphanPreviewAsync ──────────────────────────────────────────────── |
| | | 79 | | |
| | | 80 | | public async Task<OzonOrphanPreviewDto> GetOrphanPreviewAsync( |
| | | 81 | | string reportId, |
| | | 82 | | CancellationToken cancellationToken = default) |
| | | 83 | | { |
| | 0 | 84 | | await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); |
| | 0 | 85 | | var files = await LoadReportFilesAsync(db, reportId, cancellationToken); |
| | 0 | 86 | | var orphans = await LoadOrphanLinesAsync(db, reportId, cancellationToken); |
| | 0 | 87 | | return new OzonOrphanPreviewDto |
| | 0 | 88 | | { |
| | 0 | 89 | | ReportFiles = files, |
| | 0 | 90 | | Orphans = orphans, |
| | 0 | 91 | | TotalOrphans = orphans.Count, |
| | 0 | 92 | | IsAlreadyRun = orphans.Any(o => o.OzonOrphanStatus != null) |
| | 0 | 93 | | }; |
| | 0 | 94 | | } |
| | | 95 | | |
| | | 96 | | public async Task<List<OzonReportFileDto>> GetReportFilesAsync( |
| | | 97 | | string reportId, |
| | | 98 | | CancellationToken cancellationToken = default) |
| | | 99 | | { |
| | 0 | 100 | | await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); |
| | 0 | 101 | | return await LoadReportFilesAsync(db, reportId, cancellationToken); |
| | 0 | 102 | | } |
| | | 103 | | |
| | | 104 | | // ─── RunOrphanCleanupAsync ──────────────────────────────────────────────── |
| | | 105 | | |
| | | 106 | | [Queue(HangfireQueueNames.Main)] |
| | | 107 | | public async Task RunOrphanCleanupAsync( |
| | | 108 | | string reportId, |
| | | 109 | | BitrixStockOzonOrphanCleanupRequest request, |
| | | 110 | | string? progressJobId, |
| | | 111 | | CancellationToken cancellationToken = default) |
| | | 112 | | { |
| | 0 | 113 | | ExcelPackage.LicenseContext = LicenseContext.NonCommercial; |
| | | 114 | | |
| | 0 | 115 | | var hubId = progressJobId ?? reportId + "_s5"; |
| | 0 | 116 | | var hasHub = !string.IsNullOrWhiteSpace(hubId); |
| | 0 | 117 | | var dryRun = request.DryRun; |
| | | 118 | | |
| | | 119 | | async Task Log(string msg) |
| | | 120 | | { |
| | 0 | 121 | | if (hasHub) |
| | 0 | 122 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Log", msg, cancellationToken); |
| | 0 | 123 | | } |
| | | 124 | | |
| | | 125 | | async Task Progress(int cur, int total, string text) |
| | | 126 | | { |
| | 0 | 127 | | if (!hasHub) return; |
| | 0 | 128 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Progress", cur, total, text, cancellationToken); |
| | 0 | 129 | | } |
| | | 130 | | |
| | | 131 | | async Task Complete(bool ok, object? payload) |
| | | 132 | | { |
| | 0 | 133 | | if (hasHub) |
| | 0 | 134 | | await _jobProgressHub.Clients.Group(hubId).SendAsync("Complete", ok, payload, cancellationToken); |
| | 0 | 135 | | } |
| | | 136 | | |
| | 0 | 137 | | IDisposable? distLock = null; |
| | 0 | 138 | | IStorageConnection? lockConn = null; |
| | | 139 | | |
| | | 140 | | try |
| | | 141 | | { |
| | 0 | 142 | | if (!dryRun) |
| | | 143 | | { |
| | | 144 | | try |
| | | 145 | | { |
| | 0 | 146 | | lockConn = JobStorage.Current.GetConnection(); |
| | 0 | 147 | | distLock = lockConn.AcquireDistributedLock( |
| | 0 | 148 | | BitrixStockPushJobService.PushLockKey, |
| | 0 | 149 | | TimeSpan.FromMilliseconds(1)); |
| | 0 | 150 | | } |
| | | 151 | | catch (DistributedLockTimeoutException) |
| | | 152 | | { |
| | | 153 | | const string msg = "Обновление остатков Ozon уже выполняется. Повторный запуск отменён."; |
| | 0 | 154 | | await Log(msg); |
| | 0 | 155 | | await Complete(false, msg); |
| | 0 | 156 | | return; |
| | | 157 | | } |
| | | 158 | | } |
| | | 159 | | |
| | 0 | 160 | | if (!dryRun && !request.Chained) |
| | | 161 | | { |
| | 0 | 162 | | _stageStore.Set(reportId, 5); |
| | | 163 | | // Ожидаем, пока JS-клиент успеет подключиться к SignalR-группе (ручной запуск этапа) |
| | 0 | 164 | | if (hasHub) await Task.Delay(800, cancellationToken); |
| | | 165 | | } |
| | | 166 | | |
| | 0 | 167 | | await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); |
| | | 168 | | |
| | | 169 | | // Загружаем метаданные отчёта |
| | 0 | 170 | | var reportMeta = await db.Database.SqlQueryRaw<ReportMeta>( |
| | 0 | 171 | | "SELECT OzonClientId, MultiClientIdsJson FROM BitrixStockSyncReports WHERE ReportId = {0}", |
| | 0 | 172 | | reportId).SingleOrDefaultAsync(cancellationToken); |
| | | 173 | | |
| | 0 | 174 | | if (reportMeta == null) |
| | | 175 | | { |
| | 0 | 176 | | await Log("Отчёт не найден."); |
| | 0 | 177 | | await Complete(false, "Отчёт не найден"); |
| | 0 | 178 | | return; |
| | | 179 | | } |
| | | 180 | | |
| | | 181 | | // Определяем список clientIds из отчёта |
| | 0 | 182 | | var clientIds = BitrixStockMultiClientParser.ParseMultiClientIds(reportMeta.MultiClientIdsJson); |
| | 0 | 183 | | if (clientIds.Count == 0 && reportMeta.OzonClientId > 0) |
| | 0 | 184 | | clientIds.Add(reportMeta.OzonClientId); |
| | | 185 | | |
| | 0 | 186 | | if (clientIds.Count == 0) |
| | | 187 | | { |
| | 0 | 188 | | await Log("Нет клиентов Ozon в отчёте."); |
| | 0 | 189 | | await Complete(false, "Нет клиентов"); |
| | 0 | 190 | | return; |
| | | 191 | | } |
| | | 192 | | |
| | 0 | 193 | | await Log($"Этап 5: очистка сирот для {clientIds.Count} клиент(ов)…"); |
| | | 194 | | |
| | 0 | 195 | | var catalogSource = Stage5CatalogSourceExtensions.Parse(request.StageSettings?.Stage5CatalogSource); |
| | 0 | 196 | | await Log($"Источник каталога этапа 5: {catalogSource.ToLogLabel()} (Stage5CatalogSource={request.StageSetti |
| | 0 | 197 | | if (catalogSource == Stage5CatalogSource.OzonProductsDb) |
| | 0 | 198 | | await Log("Каталог: БД OzonApi.ozon_products (фильтр account_id = Ozon Client-Id). Остатки по складам: l |
| | | 199 | | else |
| | 0 | 200 | | await Log("Каталог: legacy xlsx POST /v1/report/warehouse/stock → poll /v1/report/info → скачивание файл |
| | | 201 | | |
| | | 202 | | // Загружаем справочник производителей (Code → Name) для логирования и отчёта |
| | 0 | 203 | | var manufacturers = await LoadManufacturersAsync(db, cancellationToken); |
| | | 204 | | |
| | | 205 | | // Резерв заказов — партии [N] считаются от свободного остатка базового артикула, как строки этапа 1. |
| | 0 | 206 | | var reserveStatusIds = await BitrixStockSyncReportService.ResolveReserveStatusIdsAsync( |
| | 0 | 207 | | db, request.StageSettings?.ReserveStatusIds, cancellationToken); |
| | 0 | 208 | | var reserveMap = await BitrixStockSyncReportService.LoadOrderReservesAsync(db, reserveStatusIds, cancellatio |
| | 0 | 209 | | await Log(reserveMap.Count > 0 |
| | 0 | 210 | | ? $"Резервы заказов для партий: {reserveMap.Count} арт. с ненулевым резервом." |
| | 0 | 211 | | : "Резервы заказов для партий: нет (статусы резерва не найдены или заказов нет)."); |
| | | 212 | | |
| | 0 | 213 | | var reportKeys = await db.Database.SqlQueryRaw<OfferWarehouseArticleKey>( |
| | 0 | 214 | | "SELECT LOWER(TRIM(OfferId)) AS OfferId, CAST(OzonWarehouseId AS CHAR) AS WarehouseId, " + |
| | 0 | 215 | | "LOWER(TRIM(Article)) AS Article " + |
| | 0 | 216 | | "FROM BitrixStockSyncReportLines WHERE ReportId = {0} AND IsOzonOrphan = 0 AND OfferId IS NOT NULL A |
| | 0 | 217 | | reportId) |
| | 0 | 218 | | .ToListAsync(cancellationToken); |
| | 0 | 219 | | var reportKeyIndex = BitrixStockOrphanOfferMatching.BuildReportKeyIndex( |
| | 0 | 220 | | reportKeys.Select(k => new BitrixStockOrphanOfferMatching.ReportLineKey |
| | 0 | 221 | | { |
| | 0 | 222 | | OfferId = k.OfferId, |
| | 0 | 223 | | WarehouseId = k.WarehouseId, |
| | 0 | 224 | | Article = k.Article |
| | 0 | 225 | | })); |
| | | 226 | | |
| | | 227 | | // Склады, которые реально фигурируют в строках отчёта (OzonWarehouseId > 0) |
| | 0 | 228 | | var reportWarehouseIdSet = reportKeys |
| | 0 | 229 | | .Select(k => k.WarehouseId) |
| | 0 | 230 | | .Where(id => long.TryParse(id, out var v) && v > 0) |
| | 0 | 231 | | .Select(id => long.Parse(id)) |
| | 0 | 232 | | .ToHashSet(); |
| | | 233 | | |
| | | 234 | | // 1С-склады из отчёта (M2, M3, B1, B2 и т.п.) — для выбора правильного маппинга |
| | 0 | 235 | | var reportOneCStores = (await db.Database.SqlQueryRaw<StringValue>( |
| | 0 | 236 | | "SELECT DISTINCT OneCStore AS Value FROM BitrixStockSyncReportLines " + |
| | 0 | 237 | | "WHERE ReportId = {0} AND OneCStore IS NOT NULL AND OneCStore != ''", |
| | 0 | 238 | | reportId) |
| | 0 | 239 | | .ToListAsync(cancellationToken)) |
| | 0 | 240 | | .Select(r => r.Value) |
| | 0 | 241 | | .ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | | 242 | | |
| | 0 | 243 | | var httpClient = _httpClientFactory.CreateClient(); |
| | 0 | 244 | | httpClient.Timeout = TimeSpan.FromSeconds(60); |
| | | 245 | | |
| | 0 | 246 | | var totalOrphansFound = 0; |
| | 0 | 247 | | var totalOrphansZeroed = 0; |
| | 0 | 248 | | var clientsFilesSaved = 0; |
| | 0 | 249 | | var clientIndex = 0; |
| | | 250 | | |
| | 0 | 251 | | foreach (var clientId in clientIds) |
| | | 252 | | { |
| | 0 | 253 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 254 | | clientIndex++; |
| | | 255 | | |
| | | 256 | | // Один клиент не должен обрывать этап 5 для остальных — раньше необработанное |
| | | 257 | | // исключение здесь (Ozon API, БД) прерывало весь метод целиком (см. внешний catch), |
| | | 258 | | // OzonOrphanLastAtUtc никогда не проставлялся, и живой remainder на отключённых |
| | | 259 | | // складах не обнулялся ни для этого, ни для последующих клиентов. |
| | | 260 | | try |
| | | 261 | | { |
| | | 262 | | |
| | 0 | 263 | | var client = await _ozonClientServices.GetOzonClientAsync(clientId); |
| | 0 | 264 | | if (client == null) |
| | | 265 | | { |
| | 0 | 266 | | await Log($"Клиент {clientId} не найден — пропуск."); |
| | 0 | 267 | | continue; |
| | | 268 | | } |
| | | 269 | | |
| | 0 | 270 | | var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client); |
| | | 271 | | |
| | | 272 | | // Склады клиента из маппингов |
| | 0 | 273 | | var mappings = await db.WarehouseMappings |
| | 0 | 274 | | .Where(m => m.OzonClientId == clientId && m.OzonWarehouseId != null) |
| | 0 | 275 | | .OrderBy(m => m.Id) |
| | 0 | 276 | | .ToListAsync(cancellationToken); |
| | | 277 | | |
| | | 278 | | // Этап 5: исключаем отключённые в пресете связи складов (напр. M2out → Cars), |
| | | 279 | | // чтобы такие склады не запрашивались у Ozon и не обнулялись. Пусто = все склады. |
| | 0 | 280 | | var orphanDisabledMappingIds = request.StageSettings?.OrphanDisabledWarehouseMappingIds; |
| | 0 | 281 | | if (orphanDisabledMappingIds is { Count: > 0 }) |
| | | 282 | | { |
| | 0 | 283 | | var beforeCount = mappings.Count; |
| | 0 | 284 | | mappings = mappings.Where(m => !orphanDisabledMappingIds.Contains(m.Id)).ToList(); |
| | 0 | 285 | | if (beforeCount != mappings.Count) |
| | 0 | 286 | | await Log($"[{client.Name}] Исключено {beforeCount - mappings.Count} связ(и) складов по настройк |
| | | 287 | | } |
| | | 288 | | |
| | | 289 | | // «Снятые связи» этапа 2 (ApplyDisabledWarehouseMappingIds) — для batch-вариантов: |
| | | 290 | | // на эти склады в Stage 5 пушим 0, как это делает Stage 2 через «⓪ Обнулено (связь)». |
| | 0 | 291 | | var applyDisabledMappingIds = (request.StageSettings?.ApplyDisabledWarehouseMappingIds ?? new List<int>( |
| | 0 | 292 | | var applyDisabledWarehouseIds = mappings |
| | 0 | 293 | | .Where(m => m.OzonWarehouseId != null && applyDisabledMappingIds.Contains(m.Id)) |
| | 0 | 294 | | .Select(m => m.OzonWarehouseId!.Trim()) |
| | 0 | 295 | | .ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | 0 | 296 | | if (applyDisabledWarehouseIds.Count > 0) |
| | 0 | 297 | | await Log($"[{client.Name}] Снятые связи (этап 2) для batch-вариантов: склады Ozon будут обнулены — |
| | | 298 | | // Персистентно (ILogger), в отличие от Log() выше — тот виден только при живом |
| | | 299 | | // SignalR-браузере, а плановые Hangfire-прогоны его теряют без следа. |
| | 0 | 300 | | _logger.LogInformation( |
| | 0 | 301 | | "BitrixStock этап 5 [{ClientName}]: applyDisabledWarehouseIds = [{WarehouseIds}]", |
| | 0 | 302 | | client.Name, string.Join(",", applyDisabledWarehouseIds)); |
| | | 303 | | |
| | 0 | 304 | | var warehouseDisplayByOzonId = BuildWarehouseDisplayByOzonId(mappings); |
| | | 305 | | |
| | 0 | 306 | | var warehouseIds = mappings |
| | 0 | 307 | | .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out var wid) |
| | 0 | 308 | | && reportWarehouseIdSet.Contains(wid)) |
| | 0 | 309 | | .Select(m => long.Parse(m.OzonWarehouseId!.Trim())) |
| | 0 | 310 | | .Distinct() |
| | 0 | 311 | | .ToList(); |
| | | 312 | | |
| | | 313 | | // Fallback: если у reportKeys нет складов (пустой отчёт) — берём все маппинги |
| | 0 | 314 | | if (warehouseIds.Count == 0 && reportWarehouseIdSet.Count == 0) |
| | | 315 | | { |
| | 0 | 316 | | warehouseIds = mappings |
| | 0 | 317 | | .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out _)) |
| | 0 | 318 | | .Select(m => long.Parse(m.OzonWarehouseId!.Trim())) |
| | 0 | 319 | | .Distinct() |
| | 0 | 320 | | .ToList(); |
| | 0 | 321 | | if (warehouseIds.Count > 0) |
| | 0 | 322 | | await Log($"[{client.Name}] Отчёт не содержит складов — используем все {warehouseIds.Count} из м |
| | | 323 | | } |
| | | 324 | | |
| | 0 | 325 | | if (warehouseIds.Count == 0) |
| | | 326 | | { |
| | 0 | 327 | | await Log($"[{client.Name}] Нет складов в WarehouseMapping — пропуск."); |
| | 0 | 328 | | continue; |
| | | 329 | | } |
| | | 330 | | |
| | 0 | 331 | | var whDesc = string.Join(", ", mappings |
| | 0 | 332 | | .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out var wid) && warehouseIds.Contains(wid)) |
| | 0 | 333 | | .DistinctBy(m => m.OzonWarehouseId) |
| | 0 | 334 | | .Select(m => $"{m.OzonWarehouseName} ({m.OzonWarehouseId})")); |
| | | 335 | | |
| | 0 | 336 | | var warehouseNameById = mappings |
| | 0 | 337 | | .Where(m => m.OzonWarehouseId != null |
| | 0 | 338 | | && m.BitrixWarehouseName != null |
| | 0 | 339 | | && (reportOneCStores.Count == 0 || reportOneCStores.Contains(m.BitrixWarehouseName))) |
| | 0 | 340 | | .GroupBy(m => m.OzonWarehouseId!.Trim()) |
| | 0 | 341 | | .ToDictionary(g => g.Key, g => g.First().BitrixWarehouseName!); |
| | 0 | 342 | | var warehouseStoresById = mappings |
| | 0 | 343 | | .Where(m => m.OzonWarehouseId != null |
| | 0 | 344 | | && m.BitrixWarehouseName != null |
| | 0 | 345 | | && (reportOneCStores.Count == 0 || reportOneCStores.Contains(m.BitrixWarehouseName))) |
| | 0 | 346 | | .GroupBy(m => m.OzonWarehouseId!.Trim(), StringComparer.OrdinalIgnoreCase) |
| | 0 | 347 | | .ToDictionary( |
| | 0 | 348 | | g => g.Key, |
| | 0 | 349 | | g => (IReadOnlyCollection<string>)g.Select(m => m.BitrixWarehouseName!.Trim()) |
| | 0 | 350 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 351 | | .ToArray(), |
| | 0 | 352 | | StringComparer.OrdinalIgnoreCase); |
| | | 353 | | |
| | | 354 | | List<OzonCatalogStockRow> ozonRows; |
| | | 355 | | string reportCode; |
| | | 356 | | string? fileUrl; |
| | 0 | 357 | | IReadOnlyDictionary<string, OzonProductCatalogRow> catalogByOfferId = |
| | 0 | 358 | | new Dictionary<string, OzonProductCatalogRow>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 359 | | IReadOnlyList<OzonProductCatalogRow> batchCatalogFor5b = Array.Empty<OzonProductCatalogRow>(); |
| | 0 | 360 | | List<OzonCatalogStockRow> batchRowsForMeta = new(); |
| | 0 | 361 | | List<BatchVariantItem>? batchVariantsPrebuilt = null; |
| | 0 | 362 | | var batchProcessedEarly = false; |
| | | 363 | | |
| | 0 | 364 | | if (catalogSource == Stage5CatalogSource.WarehouseStockReport) |
| | | 365 | | { |
| | 0 | 366 | | await Log($"[{client.Name}] Источник: Ozon xlsx-отчёт по {warehouseIds.Count} складу(ам): {whDesc}…" |
| | 0 | 367 | | await Progress(clientIndex - 1, clientIds.Count, $"Запрос Ozon-отчёта для {client.Name}…"); |
| | | 368 | | |
| | | 369 | | try |
| | | 370 | | { |
| | 0 | 371 | | reportCode = await apiManager.GetWarehouseStockReportCodeAsync(warehouseIds); |
| | 0 | 372 | | await Log($"[{client.Name}] Код отчёта: {reportCode}, ожидаем готовности…"); |
| | 0 | 373 | | fileUrl = await apiManager.WaitForReportReady( |
| | 0 | 374 | | reportCode, |
| | 0 | 375 | | maxWaitTimeMs: OzonReportWaitMs, |
| | 0 | 376 | | pollIntervalMs: 5_000, |
| | 0 | 377 | | onStatusLog: Log); |
| | 0 | 378 | | await Log($"[{client.Name}] Отчёт готов: {fileUrl}"); |
| | 0 | 379 | | } |
| | 0 | 380 | | catch (Exception ex) |
| | | 381 | | { |
| | 0 | 382 | | await Log($"[{client.Name}] Ошибка получения Ozon-отчёта: {ex.Message}"); |
| | 0 | 383 | | continue; |
| | | 384 | | } |
| | | 385 | | |
| | | 386 | | byte[] xlsxBytes; |
| | | 387 | | try |
| | | 388 | | { |
| | 0 | 389 | | xlsxBytes = await OzonReportDownloadHelper.DownloadBytesWithRetryAsync(httpClient, fileUrl, canc |
| | 0 | 390 | | } |
| | 0 | 391 | | catch (Exception ex) |
| | | 392 | | { |
| | 0 | 393 | | await Log($"[{client.Name}] Ошибка скачивания файла: {ex.Message}"); |
| | 0 | 394 | | continue; |
| | | 395 | | } |
| | | 396 | | |
| | 0 | 397 | | _ = SaveOzonFileBytesAsync(xlsxBytes, reportId, reportCode, cancellationToken); |
| | 0 | 398 | | ozonRows = ParseWarehouseStockXlsx(xlsxBytes); |
| | 0 | 399 | | } |
| | | 400 | | else |
| | | 401 | | { |
| | 0 | 402 | | var accountId = client.DecryptClientId?.Trim(); |
| | 0 | 403 | | if (string.IsNullOrWhiteSpace(accountId)) |
| | | 404 | | { |
| | 0 | 405 | | await Log($"[{client.Name}] account_id (DecryptClientId) не задан — пропуск каталога OzonApi."); |
| | 0 | 406 | | continue; |
| | | 407 | | } |
| | | 408 | | |
| | 0 | 409 | | await Log( |
| | 0 | 410 | | $"[{client.Name}] Источник: OzonApi.ozon_products (фаза 5a партии → фаза 5b сироты) " + |
| | 0 | 411 | | $"по {warehouseIds.Count} складу(ам): {whDesc}…"); |
| | 0 | 412 | | await Progress(clientIndex - 1, clientIds.Count, $"Партии [N] из OzonApi для {client.Name}…"); |
| | | 413 | | |
| | 0 | 414 | | reportCode = $"ozon_products_db_{accountId}_{DateTime.UtcNow:yyyyMMddHHmmss}"; |
| | 0 | 415 | | fileUrl = null; |
| | | 416 | | |
| | 0 | 417 | | var batchPhase = await LoadBatchCatalogPhaseAsync( |
| | 0 | 418 | | client, accountId, warehouseIds, warehouseDisplayByOzonId, Log, cancellationToken); |
| | 0 | 419 | | batchCatalogFor5b = batchPhase.BatchCatalog; |
| | 0 | 420 | | batchRowsForMeta = batchPhase.BatchRows; |
| | 0 | 421 | | catalogByOfferId = new Dictionary<string, OzonProductCatalogRow>(batchPhase.CatalogByOfferId); |
| | | 422 | | |
| | 0 | 423 | | batchVariantsPrebuilt = await BuildBatchVariantItemsAsync( |
| | 0 | 424 | | db, batchPhase.BatchRows, warehouseStoresById, applyDisabledWarehouseIds, reserveMap, Log, cance |
| | 0 | 425 | | await Log($"[{client.Name}] Партий к накату (есть WarehouseStock): {batchVariantsPrebuilt.Count}"); |
| | | 426 | | |
| | 0 | 427 | | if (!dryRun && batchVariantsPrebuilt.Count > 0) |
| | | 428 | | { |
| | 0 | 429 | | await ProcessBatchVariantsAsync( |
| | 0 | 430 | | apiManager, db, reportId, clientId, client.Name, batchVariantsPrebuilt, |
| | 0 | 431 | | catalogByOfferId, warehouseNameById, manufacturers, applyDisabledWarehouseIds, |
| | 0 | 432 | | Log, Progress, clientIndex, clientIds.Count, cancellationToken); |
| | 0 | 433 | | batchProcessedEarly = true; |
| | | 434 | | } |
| | | 435 | | |
| | 0 | 436 | | await Progress(clientIndex - 1, clientIds.Count, $"Поиск сирот OzonApi для {client.Name}…"); |
| | 0 | 437 | | var orphanPhase = await LoadOrphanStockPhaseAsync( |
| | 0 | 438 | | client, accountId, apiManager, warehouseIds, warehouseDisplayByOzonId, Log, cancellationToken); |
| | | 439 | | |
| | 0 | 440 | | var mergedCatalog = new Dictionary<string, OzonProductCatalogRow>(catalogByOfferId, StringComparer.O |
| | 0 | 441 | | foreach (var kv in orphanPhase.CatalogByOfferId) |
| | 0 | 442 | | mergedCatalog[kv.Key] = kv.Value; |
| | 0 | 443 | | catalogByOfferId = mergedCatalog; |
| | 0 | 444 | | ozonRows = orphanPhase.Rows; |
| | 0 | 445 | | } |
| | | 446 | | |
| | 0 | 447 | | var rowsWithStock = ozonRows.Where(r => r.AvailableQty > 0).ToList(); |
| | 0 | 448 | | var withBrand = ozonRows.Count(r => r.BrandCode != null); |
| | 0 | 449 | | var strippedCount = ozonRows.Count(r => r.OriginalOfferId != r.OfferId); |
| | 0 | 450 | | var totalSourceRows = catalogSource == Stage5CatalogSource.OzonProductsDb |
| | 0 | 451 | | ? ozonRows.Count + batchRowsForMeta.Count |
| | 0 | 452 | | : ozonRows.Count; |
| | 0 | 453 | | await Log( |
| | 0 | 454 | | $"[{client.Name}] Строк в источнике: {totalSourceRows} " + |
| | 0 | 455 | | $"(non-batch с остатком: {rowsWithStock.Count}, batch×склад: {batchRowsForMeta.Count}, " + |
| | 0 | 456 | | $"с брендом: {withBrand})"); |
| | | 457 | | |
| | | 458 | | // ── Шаг 1: выделяем партийные [N]-товары (legacy xlsx) или берём из фазы 5a ── |
| | | 459 | | List<BatchVariantItem> batchVariants; |
| | | 460 | | List<OzonCatalogStockRow> nonBatchRows; |
| | | 461 | | |
| | 0 | 462 | | if (batchVariantsPrebuilt != null) |
| | | 463 | | { |
| | 0 | 464 | | batchVariants = batchVariantsPrebuilt; |
| | 0 | 465 | | nonBatchRows = rowsWithStock; |
| | | 466 | | } |
| | | 467 | | else |
| | | 468 | | { |
| | 0 | 469 | | batchVariants = new List<BatchVariantItem>(); |
| | 0 | 470 | | nonBatchRows = new List<OzonCatalogStockRow>(); |
| | | 471 | | |
| | 0 | 472 | | foreach (var row in ozonRows) |
| | | 473 | | { |
| | 0 | 474 | | var multiplier = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(row.OriginalOfferId); |
| | 0 | 475 | | if (multiplier <= 1) |
| | | 476 | | { |
| | 0 | 477 | | if (row.AvailableQty > 0) |
| | 0 | 478 | | nonBatchRows.Add(row); |
| | 0 | 479 | | continue; |
| | | 480 | | } |
| | | 481 | | |
| | 0 | 482 | | var baseArticle = BitrixStockOrphanOfferMatching.ExtractArticle(row.OriginalOfferId); |
| | 0 | 483 | | var snapshot = await GetArticleSnapshotAsync(db, row.OriginalOfferId, cancellationToken); |
| | 0 | 484 | | var totalQty = BitrixStockBatchVariantStockResolver.ResolveWarehouseQuantity( |
| | 0 | 485 | | snapshot.Quantities, |
| | 0 | 486 | | warehouseStoresById.GetValueOrDefault(row.WarehouseId)); |
| | 0 | 487 | | var (c0, c1) = (snapshot.C0, snapshot.C1); |
| | 0 | 488 | | if (c0 == null && totalQty == 0 && string.IsNullOrEmpty(snapshot.Note)) |
| | | 489 | | { |
| | 0 | 490 | | if (row.AvailableQty > 0) |
| | 0 | 491 | | nonBatchRows.Add(row); |
| | 0 | 492 | | await Log($" [BatchCheck] {row.OriginalOfferId}: базовый «{baseArticle}» не найден в снэпшо |
| | | 493 | | } |
| | | 494 | | else |
| | | 495 | | { |
| | 0 | 496 | | var baseOfferId = BitrixStockOrphanOfferMatching.NormalizeOfferId(row.OriginalOfferId); |
| | 0 | 497 | | batchVariants.Add(new BatchVariantItem(row, multiplier, baseOfferId, c0, c1, totalQty, snaps |
| | 0 | 498 | | { |
| | 0 | 499 | | Manufacturer = snapshot.Manufacturer |
| | 0 | 500 | | }); |
| | 0 | 501 | | await Log(string.IsNullOrEmpty(snapshot.Note) |
| | 0 | 502 | | ? $" [Партия ×{multiplier}] {row.OriginalOfferId}: базовый «{baseArticle}» в 1С={totalQ |
| | 0 | 503 | | : $" [⓪ Несовпадение производителя] {row.OriginalOfferId}: {snapshot.Note}"); |
| | | 504 | | } |
| | 0 | 505 | | } |
| | | 506 | | |
| | 0 | 507 | | batchVariants = await ApplyFreeStockAsync( |
| | 0 | 508 | | batchVariants, warehouseStoresById, applyDisabledWarehouseIds, reserveMap, Log, cancellationToke |
| | | 509 | | } |
| | | 510 | | |
| | | 511 | | // ── Шаг 2: из не-партийных строк (available>0) находим сирот ───────────── |
| | | 512 | | // Сирота: offer_id + warehouse_id есть в Ozon-отчёте, но НЕ покрыт строками этапа 1. |
| | | 513 | | // Склад из ApplyDisabledWarehouseMappingIds («снять галку = обнулить») зануляется |
| | | 514 | | // безусловно, даже если IsCoveredByReport() ошибочно считает offer_id «покрытым» — |
| | | 515 | | // это происходит, когда несколько РАЗНЫХ товаров в Ozon делят один базовый артикул, |
| | | 516 | | // отличаясь только суффиксом [N] (ExtractArticle/NormalizeOfferId его отбрасывают), |
| | | 517 | | // и строка отчёта другого варианта того же артикула маскирует настоящую сироту. |
| | 0 | 518 | | var potentialOrphans = nonBatchRows |
| | 0 | 519 | | .Where(r => applyDisabledWarehouseIds.Contains(r.WarehouseId) |
| | 0 | 520 | | || !BitrixStockOrphanOfferMatching.IsCoveredByReport( |
| | 0 | 521 | | reportKeyIndex, r.OriginalOfferId, r.WarehouseId)) |
| | 0 | 522 | | .ToList(); |
| | | 523 | | |
| | | 524 | | // Полностью зарезервированные строки (present > 0, free_stock = 0) сиротами не считаем: |
| | | 525 | | // покупатель такой товар уже не закажет, а Ozon не опускает остаток ниже резерва — раньше |
| | | 526 | | // такие позиции попадали в отчёт каждый прогон, «обнулялись» со статусом Success и |
| | | 527 | | // возвращались через два часа (напр. 9030104004=TYT на M2: present 2 / reserved 2). |
| | 0 | 528 | | var (trulyOrphans, fullyReserved) = |
| | 0 | 529 | | BitrixStockOrphanOfferMatching.SplitByFreeStock(potentialOrphans); |
| | | 530 | | |
| | 0 | 531 | | await Log($"[{client.Name}] «Сирот» найдено: {trulyOrphans.Count}, партий (BatchVariant): {batchVariants |
| | 0 | 532 | | if (fullyReserved.Count > 0) |
| | 0 | 533 | | await Log( |
| | 0 | 534 | | $"[{client.Name}] Пропущено полностью зарезервированных: {fullyReserved.Count} " + |
| | 0 | 535 | | $"(остаток на Ozon есть, но свободного нет) — {string.Join(", ", fullyReserved.Take(5).Select(r |
| | 0 | 536 | | (fullyReserved.Count > 5 ? $" … и ещё {fullyReserved.Count - 5}" : string.Empty)); |
| | | 537 | | // Логируем первые 5 сирот для диагностики |
| | 0 | 538 | | foreach (var o in trulyOrphans.Take(5)) |
| | | 539 | | { |
| | 0 | 540 | | var mfr = o.BrandCode != null && manufacturers.TryGetValue(o.BrandCode, out var mn) ? mn : o.BrandCo |
| | 0 | 541 | | var mfrStr = mfr != null ? $" [{mfr}]" : string.Empty; |
| | 0 | 542 | | var origStr = o.OriginalOfferId != o.OfferId ? $" (исходный: {o.OriginalOfferId})" : string.Empty; |
| | 0 | 543 | | await Log($" → {o.OfferId}{origStr}{mfrStr} qty={o.AvailableQty} (свободно {o.FreeQty}) склад={o.Wa |
| | | 544 | | } |
| | 0 | 545 | | if (trulyOrphans.Count > 5) await Log($" … и ещё {trulyOrphans.Count - 5}"); |
| | 0 | 546 | | totalOrphansFound += trulyOrphans.Count; |
| | | 547 | | |
| | 0 | 548 | | var orphansZeroed = 0; |
| | | 549 | | |
| | 0 | 550 | | if (!dryRun && trulyOrphans.Count > 0) |
| | | 551 | | { |
| | | 552 | | // Фактический итог обнуления по каждой строке — идёт в OzonOrphanStatus/Note. |
| | | 553 | | // Раньше всем строкам писался безусловный "Success", поэтому непрошедшее обнуление |
| | | 554 | | // выглядело в UI как выполненное и повторялось прогон за прогоном незамеченным. |
| | 0 | 555 | | var zeroResults = new Dictionary<string, OrphanZeroResult>(StringComparer.OrdinalIgnoreCase); |
| | | 556 | | |
| | 0 | 557 | | for (var i = 0; i < trulyOrphans.Count; i += StockBatchSize) |
| | | 558 | | { |
| | 0 | 559 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 560 | | var batch = trulyOrphans.Skip(i).Take(StockBatchSize).ToList(); |
| | | 561 | | |
| | | 562 | | // Обнуляем по исходному артикулу из xlsx (с суффиксом бренда, напр. "KSB0108MG=KRU") |
| | 0 | 563 | | var stockItems = batch.Select(o => new StockItemForUpdate |
| | 0 | 564 | | { |
| | 0 | 565 | | OfferId = o.OriginalOfferId, |
| | 0 | 566 | | ProductId = o.ProductId ?? 0, |
| | 0 | 567 | | Stock = 0, |
| | 0 | 568 | | WarehouseId = long.Parse(o.WarehouseId) |
| | 0 | 569 | | }).ToList(); |
| | | 570 | | try |
| | | 571 | | { |
| | 0 | 572 | | var result = await apiManager.UpdateProductStocks(stockItems); |
| | 0 | 573 | | var successes = result.Result?.Count(r => r.Updated) ?? 0; |
| | 0 | 574 | | var errors = result.Result?.Where(r => !r.Updated).ToList() ?? new(); |
| | 0 | 575 | | orphansZeroed += successes; |
| | | 576 | | |
| | 0 | 577 | | foreach (var r in result.Result ?? new List<UpdateStockResult>()) |
| | 0 | 578 | | zeroResults[OrphanZeroKey(r.OfferId, r.WarehouseId.ToString())] = new OrphanZeroResult( |
| | 0 | 579 | | r.Updated, |
| | 0 | 580 | | r.Updated ? null : r.Errors?.FirstOrDefault()?.Message ?? "неизвестная ошибка"); |
| | | 581 | | |
| | 0 | 582 | | foreach (var err in errors) |
| | | 583 | | { |
| | 0 | 584 | | var firstErrMsg = err.Errors?.FirstOrDefault()?.Message ?? "неизвестная ошибка"; |
| | 0 | 585 | | await Log($"[{client.Name}] Ошибка обнуления {err.OfferId}: {firstErrMsg}"); |
| | 0 | 586 | | _logger.LogWarning( |
| | 0 | 587 | | "BitrixStock этап 5 [{ClientName}]: не удалось обнулить сироту {OfferId} — {Error}", |
| | 0 | 588 | | client.Name, err.OfferId, firstErrMsg); |
| | 0 | 589 | | } |
| | 0 | 590 | | await Progress(clientIndex, clientIds.Count, |
| | 0 | 591 | | $"[{client.Name}] Обнуляем: {Math.Min(i + StockBatchSize, trulyOrphans.Count)}/{trulyOrp |
| | 0 | 592 | | } |
| | 0 | 593 | | catch (Exception ex) |
| | | 594 | | { |
| | 0 | 595 | | await Log($"[{client.Name}] Ошибка пакета UpdateProductStocks: {ex.Message}"); |
| | 0 | 596 | | _logger.LogWarning(ex, |
| | 0 | 597 | | "BitrixStock этап 5 [{ClientName}]: ошибка пакета UpdateProductStocks (сироты)", |
| | 0 | 598 | | client.Name); |
| | 0 | 599 | | foreach (var o in batch) |
| | 0 | 600 | | zeroResults[OrphanZeroKey(o.OriginalOfferId, o.WarehouseId)] = |
| | 0 | 601 | | new OrphanZeroResult(false, $"пакет не отправлен: {ex.Message}"); |
| | 0 | 602 | | } |
| | 0 | 603 | | } |
| | | 604 | | |
| | 0 | 605 | | await Log($"[{client.Name}] Обнулено: {orphansZeroed}/{trulyOrphans.Count}"); |
| | 0 | 606 | | _logger.LogInformation( |
| | 0 | 607 | | "BitrixStock этап 5 [{ClientName}]: сирот обнулено {Zeroed}/{Total}", |
| | 0 | 608 | | client.Name, orphansZeroed, trulyOrphans.Count); |
| | | 609 | | |
| | | 610 | | // Записываем строки-сироты в отчёт |
| | 0 | 611 | | await InsertOrphanLinesAsync(db, reportId, clientId, client.Name, trulyOrphans, |
| | 0 | 612 | | warehouseNameById, manufacturers, zeroResults, cancellationToken); |
| | 0 | 613 | | } |
| | | 614 | | |
| | | 615 | | // ── Обработка batch-вариантов (партийные товары [N]) ──────────────── |
| | 0 | 616 | | if (!batchProcessedEarly && !dryRun && batchVariants.Count > 0) |
| | | 617 | | { |
| | 0 | 618 | | await ProcessBatchVariantsAsync( |
| | 0 | 619 | | apiManager, db, reportId, clientId, client.Name, batchVariants, |
| | 0 | 620 | | catalogByOfferId, warehouseNameById, manufacturers, applyDisabledWarehouseIds, |
| | 0 | 621 | | Log, Progress, clientIndex, clientIds.Count, cancellationToken); |
| | | 622 | | } |
| | | 623 | | |
| | 0 | 624 | | totalOrphansZeroed += orphansZeroed; |
| | | 625 | | |
| | | 626 | | // ── Этап 5b: batch-варианты, отсутствующие в источнике остатков ────── |
| | 0 | 627 | | var sourceOfferIds = ozonRows |
| | 0 | 628 | | .Select(r => r.OriginalOfferId) |
| | 0 | 629 | | .ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | 0 | 630 | | foreach (var batchOffer in batchCatalogFor5b) |
| | 0 | 631 | | sourceOfferIds.Add(batchOffer.OfferId); |
| | | 632 | | |
| | | 633 | | HashSet<string> allOzonBatchIds; |
| | 0 | 634 | | if (catalogSource == Stage5CatalogSource.WarehouseStockReport) |
| | | 635 | | { |
| | | 636 | | try |
| | | 637 | | { |
| | 0 | 638 | | allOzonBatchIds = await apiManager.GetAllBatchOfferIdsAsync(cancellationToken); |
| | 0 | 639 | | await Log($"[{client.Name}] Получено batch offer_ids из Ozon API: {allOzonBatchIds.Count}, в ист |
| | 0 | 640 | | } |
| | 0 | 641 | | catch (Exception ex) |
| | | 642 | | { |
| | 0 | 643 | | await Log($"[{client.Name}] Не удалось получить batch offer_ids из Ozon (Этап 5b пропущен): {ex. |
| | 0 | 644 | | allOzonBatchIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | | 645 | | } |
| | | 646 | | } |
| | | 647 | | else |
| | | 648 | | { |
| | 0 | 649 | | allOzonBatchIds = batchCatalogFor5b |
| | 0 | 650 | | .Select(b => b.OfferId) |
| | 0 | 651 | | .ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | 0 | 652 | | await Log($"[{client.Name}] Этап 5b: batch из фазы 5a — {allOzonBatchIds.Count} offer_id, в источник |
| | | 653 | | } |
| | | 654 | | |
| | 0 | 655 | | var missingBatchOfferIds = allOzonBatchIds |
| | 0 | 656 | | .Where(id => !sourceOfferIds.Contains(id)) |
| | 0 | 657 | | .ToList(); |
| | | 658 | | |
| | 0 | 659 | | if (missingBatchOfferIds.Count > 0) |
| | | 660 | | { |
| | 0 | 661 | | await Log($"[{client.Name}] Batch-вариантов не в источнике: {missingBatchOfferIds.Count} — проверяем |
| | | 662 | | |
| | | 663 | | // Для каждого отсутствующего offer_id ищем базовый артикул в snapshot |
| | 0 | 664 | | var missingWithSnapshot = new List<(string OfferId, int Multiplier, string BaseArticle, decimal? C0, |
| | 0 | 665 | | foreach (var offerId in missingBatchOfferIds) |
| | | 666 | | { |
| | 0 | 667 | | var baseArticle = BitrixStockOrphanOfferMatching.ExtractArticle(offerId); |
| | 0 | 668 | | var snapshot = await GetArticleSnapshotAsync(db, offerId, cancellationToken); |
| | 0 | 669 | | var (c0, c1) = (snapshot.C0, snapshot.C1); |
| | 0 | 670 | | var totalQty = snapshot.Quantities.Values.Sum(); |
| | 0 | 671 | | if (c0 != null || totalQty > 0) |
| | | 672 | | { |
| | 0 | 673 | | var mult = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(offerId); |
| | 0 | 674 | | missingWithSnapshot.Add((offerId, mult, baseArticle, c0, c1, totalQty)); |
| | | 675 | | } |
| | 0 | 676 | | } |
| | | 677 | | |
| | 0 | 678 | | if (missingWithSnapshot.Count > 0) |
| | | 679 | | { |
| | 0 | 680 | | await Log($"[{client.Name}] Из них с данными в 1С: {missingWithSnapshot.Count} — запрашиваем ста |
| | | 681 | | |
| | | 682 | | // Определяем is_created через /v3/product/info/list |
| | 0 | 683 | | var isCreatedMap = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 684 | | var ozonMissingErrors = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 685 | | foreach (var chunk in missingWithSnapshot.Chunk(100)) |
| | | 686 | | { |
| | 0 | 687 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 0 | 688 | | var offerIds = chunk.Select(x => x.OfferId).ToArray(); |
| | | 689 | | try |
| | | 690 | | { |
| | 0 | 691 | | var infoResp = await apiManager.GetProductInfoListAsync(offerIds: offerIds); |
| | 0 | 692 | | var items = infoResp?["items"]; |
| | 0 | 693 | | if (items != null) |
| | | 694 | | { |
| | 0 | 695 | | foreach (var item in items) |
| | | 696 | | { |
| | 0 | 697 | | var oid = item["offer_id"]?.ToString(); |
| | 0 | 698 | | if (string.IsNullOrEmpty(oid)) continue; |
| | 0 | 699 | | isCreatedMap[oid] = item["statuses"]?["is_created"]?.Value<bool>() ?? false; |
| | | 700 | | // Собираем ошибки валидации для is_created=false |
| | 0 | 701 | | if (!isCreatedMap[oid]) |
| | | 702 | | { |
| | 0 | 703 | | var errors = item["errors"]; |
| | 0 | 704 | | if (errors != null) |
| | | 705 | | { |
| | 0 | 706 | | var parts = new List<string>(); |
| | 0 | 707 | | foreach (var e in errors) |
| | | 708 | | { |
| | 0 | 709 | | var attrName = e["texts"]?["attribute_name"]?.ToString(); |
| | 0 | 710 | | var desc = e["texts"]?["description"]?.ToString(); |
| | 0 | 711 | | parts.Add(string.IsNullOrEmpty(attrName) |
| | 0 | 712 | | ? desc ?? e.ToString() |
| | 0 | 713 | | : $"атрибут «{attrName}»: {desc}"); |
| | | 714 | | } |
| | 0 | 715 | | if (parts.Count > 0) |
| | 0 | 716 | | ozonMissingErrors[oid] = string.Join("; ", parts); |
| | | 717 | | } |
| | | 718 | | } |
| | | 719 | | } |
| | | 720 | | } |
| | 0 | 721 | | } |
| | 0 | 722 | | catch (Exception ex) |
| | | 723 | | { |
| | 0 | 724 | | await Log($"[{client.Name}] Ошибка GetProductInfoList для отсутствующих batch: {ex.Messa |
| | | 725 | | } |
| | | 726 | | } |
| | | 727 | | |
| | 0 | 728 | | await InsertMissingBatchVariantLinesAsync(db, reportId, clientId, client.Name, |
| | 0 | 729 | | missingWithSnapshot, isCreatedMap, ozonMissingErrors, warehouseNameById, cancellationToken); |
| | | 730 | | |
| | 0 | 731 | | var notInFbsCount = missingWithSnapshot.Count(x => |
| | 0 | 732 | | isCreatedMap.TryGetValue(x.OfferId, out var ic) && ic); |
| | 0 | 733 | | var notCreatedCount = missingWithSnapshot.Count(x => |
| | 0 | 734 | | isCreatedMap.TryGetValue(x.OfferId, out var ic) && !ic); |
| | 0 | 735 | | await Log($"[{client.Name}] Этап 5b: не в FBS управлении: {notInFbsCount}, ошибка карточки: {not |
| | 0 | 736 | | } |
| | 0 | 737 | | } |
| | | 738 | | |
| | | 739 | | // Сохраняем метаданные файла (один файл на клиента — покрывает все запрошенные склады) |
| | 0 | 740 | | await SaveReportFileAsync(db, reportId, clientId, fileUrl ?? string.Empty, reportCode, mappings, warehou |
| | 0 | 741 | | totalSourceRows, rowsWithStock.Count, trulyOrphans.Count + batchVariants.Count, |
| | 0 | 742 | | dryRun ? 0 : orphansZeroed, cancellationToken); |
| | 0 | 743 | | var sourceNote = catalogSource == Stage5CatalogSource.OzonProductsDb |
| | 0 | 744 | | ? $"OzonApi DB ({reportCode})" |
| | 0 | 745 | | : $"xlsx ({reportCode})"; |
| | 0 | 746 | | await Log($"[{client.Name}] Метаданные источника сохранены: {sourceNote}, строк={totalSourceRows}, non-b |
| | 0 | 747 | | clientsFilesSaved++; |
| | 0 | 748 | | } |
| | 0 | 749 | | catch (OperationCanceledException) { throw; } |
| | 0 | 750 | | catch (Exception ex) |
| | | 751 | | { |
| | 0 | 752 | | await Log($"Клиент {clientId}: критическая ошибка этапа 5, клиент пропущен — {ex.Message}"); |
| | | 753 | | try |
| | | 754 | | { |
| | | 755 | | const string warnSql = |
| | | 756 | | "UPDATE BitrixStockSyncReports SET Warning = {0} WHERE ReportId = {1}"; |
| | 0 | 757 | | var warnParams = EfSqlRawParameterGuard.Materialize( |
| | 0 | 758 | | $"Этап 5 (клиент {clientId}): {ex.Message}", reportId); |
| | 0 | 759 | | EfSqlRawParameterGuard.Validate(warnSql, warnParams, "orphan.persist_client_error"); |
| | 0 | 760 | | await using var warnDb = await _dbFactory.CreateDbContextAsync(cancellationToken); |
| | 0 | 761 | | await warnDb.Database.ExecuteSqlRawAsync(warnSql, warnParams, cancellationToken); |
| | 0 | 762 | | } |
| | 0 | 763 | | catch { /* лучшая попытка — не даём вторичной ошибке скрыть первую */ } |
| | 0 | 764 | | } |
| | 0 | 765 | | } |
| | | 766 | | |
| | 0 | 767 | | if (clientsFilesSaved == 0) |
| | | 768 | | { |
| | 0 | 769 | | var noFilesMsg = catalogSource == Stage5CatalogSource.OzonProductsDb |
| | 0 | 770 | | ? "Каталог OzonApi.ozon_products не загружен ни для одного клиента (нет account_id, ошибка БД/API ил |
| | 0 | 771 | | : "Ozon-отчёт не загружен ни для одного клиента (таймаут API, ошибка скачивания или нет складов). За |
| | 0 | 772 | | await Log(noFilesMsg); |
| | 0 | 773 | | await Complete(false, noFilesMsg); |
| | 0 | 774 | | return; |
| | | 775 | | } |
| | | 776 | | |
| | 0 | 777 | | if (!dryRun) |
| | | 778 | | { |
| | | 779 | | const string updateSql = |
| | | 780 | | "UPDATE BitrixStockSyncReports SET OzonOrphanLastAtUtc = {0} WHERE ReportId = {1}"; |
| | 0 | 781 | | var updateParams = EfSqlRawParameterGuard.Materialize(DateTime.UtcNow, reportId); |
| | 0 | 782 | | EfSqlRawParameterGuard.Validate(updateSql, updateParams, "orphan.update_timestamp"); |
| | 0 | 783 | | await db.Database.ExecuteSqlRawAsync(updateSql, updateParams, cancellationToken); |
| | | 784 | | } |
| | | 785 | | |
| | 0 | 786 | | var summary = dryRun |
| | 0 | 787 | | ? $"Предпросмотр: найдено {totalOrphansFound} сирот." |
| | 0 | 788 | | : $"Завершено. Сирот найдено: {totalOrphansFound}, обнулено: {totalOrphansZeroed}."; |
| | 0 | 789 | | await Log(summary); |
| | 0 | 790 | | await Complete(true, new { reportId, totalOrphansFound, totalOrphansZeroed, dryRun }); |
| | 0 | 791 | | } |
| | 0 | 792 | | catch (OperationCanceledException) { throw; } |
| | 0 | 793 | | catch (Exception ex) |
| | | 794 | | { |
| | 0 | 795 | | await Log($"Критическая ошибка этапа 5: {ex.Message}"); |
| | 0 | 796 | | await Complete(false, ex.Message); |
| | 0 | 797 | | } |
| | | 798 | | finally |
| | | 799 | | { |
| | 0 | 800 | | HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConn, _logger); |
| | 0 | 801 | | if (!dryRun && !request.Chained) _stageStore.Clear(reportId); |
| | | 802 | | } |
| | 0 | 803 | | } |
| | | 804 | | |
| | | 805 | | // ─── Вспомогательные методы ─────────────────────────────────────────────── |
| | | 806 | | |
| | | 807 | | private static List<OzonCatalogStockRow> ParseWarehouseStockXlsx(byte[] bytes) |
| | | 808 | | { |
| | 0 | 809 | | var result = new List<OzonCatalogStockRow>(); |
| | 0 | 810 | | using var ms = new MemoryStream(bytes); |
| | 0 | 811 | | using var pkg = new ExcelPackage(ms); |
| | 0 | 812 | | var ws = pkg.Workbook.Worksheets.FirstOrDefault(); |
| | 0 | 813 | | if (ws == null) return result; |
| | | 814 | | |
| | 0 | 815 | | var rowCount = ws.Dimension?.Rows ?? 0; |
| | 0 | 816 | | for (var row = 2; row <= rowCount; row++) // row 1 = header |
| | | 817 | | { |
| | | 818 | | // A=1:WarehouseId, B=2:WarehouseName, C=3:Артикул, D=4:Название товара, E=5:Доступно, F=6:Зарезервировано |
| | 0 | 819 | | var warehouseId = ws.Cells[row, 1].Text?.Trim() ?? string.Empty; |
| | 0 | 820 | | var rawOfferId = ws.Cells[row, 3].Text?.Trim() ?? string.Empty; |
| | 0 | 821 | | if (string.IsNullOrWhiteSpace(warehouseId) || string.IsNullOrWhiteSpace(rawOfferId)) continue; |
| | | 822 | | |
| | 0 | 823 | | var productName = ws.Cells[row, 4].Text?.Trim(); |
| | | 824 | | |
| | | 825 | | // Артикул Ozon: "ADA100107=BLP" или "1102601M02=NSS[3]" или "JB05001A" (без =) |
| | | 826 | | // Для сравнения со снэпшотом берём часть до знака = (реальный offer_id) |
| | | 827 | | string cleanOfferId; |
| | 0 | 828 | | string? brandCode = null; |
| | 0 | 829 | | var eqIdx = rawOfferId.IndexOf('='); |
| | 0 | 830 | | if (eqIdx > 0) |
| | | 831 | | { |
| | 0 | 832 | | cleanOfferId = rawOfferId[..eqIdx].Trim(); |
| | 0 | 833 | | var brandPart = rawOfferId[(eqIdx + 1)..].Trim(); |
| | 0 | 834 | | var m = BrandCodeRegex.Match(brandPart); |
| | 0 | 835 | | if (m.Success) brandCode = m.Groups[1].Value; |
| | | 836 | | } |
| | | 837 | | else |
| | | 838 | | { |
| | 0 | 839 | | cleanOfferId = rawOfferId; |
| | | 840 | | } |
| | | 841 | | |
| | 0 | 842 | | var availableRaw = ws.Cells[row, 5].Text?.Trim() ?? "0"; |
| | 0 | 843 | | var available = int.TryParse(availableRaw, out var av) ? av : 0; |
| | 0 | 844 | | var reservedRaw = ws.Cells[row, 6].Text?.Trim() ?? "0"; |
| | 0 | 845 | | var reserved = int.TryParse(reservedRaw, out var rv) ? rv : 0; |
| | | 846 | | |
| | 0 | 847 | | result.Add(new OzonCatalogStockRow |
| | 0 | 848 | | { |
| | 0 | 849 | | WarehouseId = warehouseId, |
| | 0 | 850 | | WarehouseName = ws.Cells[row, 2].Text?.Trim() ?? string.Empty, |
| | 0 | 851 | | OfferId = cleanOfferId, |
| | 0 | 852 | | OriginalOfferId = rawOfferId, |
| | 0 | 853 | | BrandCode = brandCode, |
| | 0 | 854 | | ProductName = productName, |
| | 0 | 855 | | AvailableQty = available, |
| | 0 | 856 | | ReservedQty = reserved, |
| | 0 | 857 | | FreeQty = Math.Max(0, available - reserved) |
| | 0 | 858 | | }); |
| | | 859 | | } |
| | 0 | 860 | | return result; |
| | 0 | 861 | | } |
| | | 862 | | |
| | | 863 | | private static async Task InsertOrphanLinesAsync( |
| | | 864 | | ApplicationDbContext db, |
| | | 865 | | string reportId, |
| | | 866 | | int clientId, |
| | | 867 | | string clientName, |
| | | 868 | | List<OzonCatalogStockRow> orphans, |
| | | 869 | | Dictionary<string, string> warehouseNameById, |
| | | 870 | | Dictionary<string, string> manufacturers, |
| | | 871 | | IReadOnlyDictionary<string, OrphanZeroResult> zeroResults, |
| | | 872 | | CancellationToken ct) |
| | | 873 | | { |
| | 0 | 874 | | var now = DateTime.UtcNow; |
| | 0 | 875 | | foreach (var batch in orphans.Chunk(100)) |
| | | 876 | | { |
| | 0 | 877 | | var values = new List<object?>(); |
| | 0 | 878 | | var sb = new System.Text.StringBuilder(); |
| | 0 | 879 | | sb.Append("INSERT IGNORE INTO BitrixStockSyncReportLines " + |
| | 0 | 880 | | "(ReportId,ProductId,Article,OfferId,OneCStore,OzonWarehouseName,OzonWarehouseId," + |
| | 0 | 881 | | "MappingDescription,OneCQty,OzonQty,Delta,Action,ActionLabel,ActionIcon,RowClass," + |
| | 0 | 882 | | "Note,OzonProductId,OzonApplyStatus,IsArchived,IsAutoArchived,OzonClientId,OzonClientName," + |
| | 0 | 883 | | "OneCPrice,PrevOneCPrice,ReservedQty,IsOzonOrphan,OzonOrphanStatus,OzonOrphanAtUtc,CreatedAtUtc) V |
| | 0 | 884 | | var pIdx = 0; |
| | 0 | 885 | | for (var i = 0; i < batch.Length; i++) |
| | | 886 | | { |
| | 0 | 887 | | var o = batch[i]; |
| | 0 | 888 | | var bitrixName = warehouseNameById.GetValueOrDefault(o.WarehouseId, o.WarehouseName); |
| | | 889 | | |
| | | 890 | | // Производитель из справочника по коду бренда |
| | 0 | 891 | | var mfrName = o.BrandCode != null && manufacturers.TryGetValue(o.BrandCode, out var mn) |
| | 0 | 892 | | ? $"{mn} ({o.BrandCode})" |
| | 0 | 893 | | : o.BrandCode ?? "—"; |
| | 0 | 894 | | var mappingDesc = $"Сирота — {mfrName}"; |
| | | 895 | | |
| | | 896 | | // Фактический итог обнуления по этой паре offer×склад. Строки без ответа Ozon |
| | | 897 | | // (пакет не дошёл, товар не вернулся в result) помечаем Failed, а не Success — |
| | | 898 | | // иначе непрошедшее обнуление молча повторяется каждый прогон. |
| | 0 | 899 | | var zero = zeroResults.GetValueOrDefault( |
| | 0 | 900 | | OrphanZeroKey(o.OriginalOfferId, o.WarehouseId), |
| | 0 | 901 | | new OrphanZeroResult(false, "Ozon не подтвердил обнуление")); |
| | | 902 | | |
| | | 903 | | // В Note: исходный артикул из xlsx (если отличается) + название товара |
| | 0 | 904 | | var noteParts = new List<string>(); |
| | 0 | 905 | | if (o.OriginalOfferId != o.OfferId) noteParts.Add($"xlsx: {o.OriginalOfferId}"); |
| | 0 | 906 | | if (!zero.Updated) noteParts.Add($"Не обнулено: {zero.Error}"); |
| | 0 | 907 | | if (o.ReservedQty > 0) noteParts.Add($"в резерве {o.ReservedQty} из {o.AvailableQty}"); |
| | 0 | 908 | | if (!string.IsNullOrWhiteSpace(o.ProductName)) |
| | 0 | 909 | | noteParts.Add(o.ProductName.Length > 120 ? o.ProductName[..120] + "…" : o.ProductName); |
| | 0 | 910 | | object? note = noteParts.Count > 0 ? string.Join(" | ", noteParts) : null; |
| | | 911 | | |
| | 0 | 912 | | if (i > 0) sb.Append(','); |
| | 0 | 913 | | var b = pIdx; |
| | 0 | 914 | | sb.Append($"({{{b}}},{{{b+1}}},{{{b+2}}},{{{b+3}}},{{{b+4}}},{{{b+5}}},{{{b+6}}}," + |
| | 0 | 915 | | $"{{{b+7}}},{{{b+8}}},{{{b+9}}},{{{b+10}}},{{{b+11}}},{{{b+12}}},{{{b+13}}},{{{b+14}}}," + |
| | 0 | 916 | | $"{{{b+15}}},{{{b+16}}},{{{b+17}}},{{{b+18}}},{{{b+19}}},{{{b+20}}},{{{b+21}}}," + |
| | 0 | 917 | | $"{{{b+22}}},{{{b+23}}},{{{b+24}}},{{{b+25}}},{{{b+26}}},{{{b+27}}},{{{b+28}}})"); |
| | 0 | 918 | | values.Add(reportId); |
| | 0 | 919 | | values.Add(0); |
| | 0 | 920 | | values.Add(o.OfferId); // Article = чистый артикул |
| | 0 | 921 | | values.Add(o.OriginalOfferId); // OfferId = как в Ozon (для API и сопоставления) |
| | 0 | 922 | | values.Add(bitrixName); |
| | 0 | 923 | | values.Add(o.WarehouseName); |
| | 0 | 924 | | values.Add(o.WarehouseId); |
| | 0 | 925 | | values.Add(mappingDesc); // Производитель |
| | 0 | 926 | | values.Add(0); // OneCQty |
| | 0 | 927 | | values.Add(o.AvailableQty); |
| | 0 | 928 | | values.Add(-o.AvailableQty); |
| | 0 | 929 | | values.Add(BitrixStockSyncReportActions.OzonZero); |
| | 0 | 930 | | values.Add("Обнулить (сирота)"); |
| | 0 | 931 | | values.Add("fa-trash"); |
| | 0 | 932 | | values.Add("table-danger"); |
| | 0 | 933 | | values.Add(note); // исходный xlsx-артикул + название товара |
| | 0 | 934 | | values.Add((object?)null); |
| | 0 | 935 | | values.Add((object?)null); |
| | 0 | 936 | | values.Add(false); |
| | 0 | 937 | | values.Add(false); |
| | 0 | 938 | | values.Add(clientId); |
| | 0 | 939 | | values.Add(clientName); |
| | 0 | 940 | | values.Add((object?)null); |
| | 0 | 941 | | values.Add((object?)null); |
| | 0 | 942 | | values.Add(o.ReservedQty); |
| | 0 | 943 | | values.Add(true); // IsOzonOrphan |
| | 0 | 944 | | values.Add(zero.Updated ? "Success" : "Failed"); |
| | 0 | 945 | | values.Add(now); |
| | 0 | 946 | | values.Add(now); |
| | 0 | 947 | | pIdx += 29; |
| | | 948 | | } |
| | 0 | 949 | | EfSqlRawParameterGuard.Validate(sb.ToString(), values, "orphan.insert_line"); |
| | 0 | 950 | | await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(values), ct); |
| | | 951 | | } |
| | 0 | 952 | | } |
| | | 953 | | |
| | | 954 | | private static async Task InsertBatchVariantLinesAsync( |
| | | 955 | | ApplicationDbContext db, |
| | | 956 | | string reportId, |
| | | 957 | | int clientId, |
| | | 958 | | string clientName, |
| | | 959 | | List<BatchVariantItem> batchVariants, |
| | | 960 | | Dictionary<string, string> warehouseNameById, |
| | | 961 | | Dictionary<string, string> manufacturers, |
| | | 962 | | IReadOnlySet<string> applyDisabledWarehouseIds, |
| | | 963 | | Dictionary<string, decimal> pushedPrices, |
| | | 964 | | Dictionary<string, string> ozonProductErrors, |
| | | 965 | | CancellationToken ct) |
| | | 966 | | { |
| | 0 | 967 | | var now = DateTime.UtcNow; |
| | 0 | 968 | | foreach (var chunk in batchVariants.Chunk(100)) |
| | | 969 | | { |
| | 0 | 970 | | var values = new List<object?>(); |
| | 0 | 971 | | var sb = new System.Text.StringBuilder(); |
| | 0 | 972 | | sb.Append("INSERT IGNORE INTO BitrixStockSyncReportLines " + |
| | 0 | 973 | | "(ReportId,ProductId,Article,OfferId,OneCStore,OzonWarehouseName,OzonWarehouseId," + |
| | 0 | 974 | | "MappingDescription,OneCQty,OzonQty,Delta,Action,ActionLabel,ActionIcon,RowClass," + |
| | 0 | 975 | | "Note,OzonProductId,OzonApplyStatus,IsArchived,IsAutoArchived,OzonClientId,OzonClientName," + |
| | 0 | 976 | | "OneCPrice,PrevOneCPrice,ReservedQty,IsOzonOrphan,OzonOrphanStatus,OzonOrphanAtUtc,CreatedAtUtc," |
| | 0 | 977 | | "BatchMultiplier,BatchBaseArticle,BatchPushedPrice) VALUES "); |
| | 0 | 978 | | var pIdx = 0; |
| | 0 | 979 | | for (var i = 0; i < chunk.Length; i++) |
| | | 980 | | { |
| | 0 | 981 | | var bv = chunk[i]; |
| | 0 | 982 | | var o = bv.Row; |
| | 0 | 983 | | var bitrixName = warehouseNameById.GetValueOrDefault(o.WarehouseId, o.WarehouseName); |
| | | 984 | | |
| | 0 | 985 | | var mfrName = o.BrandCode != null && manufacturers.TryGetValue(o.BrandCode, out var mn) |
| | 0 | 986 | | ? $"{mn} ({o.BrandCode})" |
| | 0 | 987 | | : o.BrandCode ?? "—"; |
| | | 988 | | |
| | 0 | 989 | | var isDisabledWarehouse = applyDisabledWarehouseIds.Contains(o.WarehouseId); |
| | 0 | 990 | | ozonProductErrors.TryGetValue(o.OriginalOfferId, out var ozonError); |
| | 0 | 991 | | var isNotCreated = !isDisabledWarehouse && !string.IsNullOrEmpty(ozonError); |
| | 0 | 992 | | var isManufacturerMismatch = !string.IsNullOrWhiteSpace(bv.SnapshotNote); |
| | 0 | 993 | | var isZeroStock = !isDisabledWarehouse && !isNotCreated && bv.BatchStock == 0 && |
| | 0 | 994 | | (bv.TotalQty > 0 || isManufacturerMismatch); |
| | 0 | 995 | | var effectiveStock = isDisabledWarehouse ? 0 : bv.BatchStock; |
| | 0 | 996 | | pushedPrices.TryGetValue(o.OriginalOfferId, out var pushedPrice); |
| | | 997 | | |
| | 0 | 998 | | var noteParts = new List<string> |
| | 0 | 999 | | { |
| | 0 | 1000 | | isNotCreated |
| | 0 | 1001 | | ? $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | Товар не создан в Ozon FBS" |
| | 0 | 1002 | | : isZeroStock |
| | 0 | 1003 | | ? isManufacturerMismatch |
| | 0 | 1004 | | ? $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | Несовпадение производителя → 0 уп |
| | 0 | 1005 | | : $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | 1С: {bv.TotalQty} шт. < {bv.Multi |
| | 0 | 1006 | | : $"×{bv.Multiplier} шт. | Базовый: {bv.BaseOfferId} | 1С: {bv.TotalQty} шт. → {effectiveSto |
| | 0 | 1007 | | }; |
| | 0 | 1008 | | if (isNotCreated) |
| | 0 | 1009 | | noteParts.Add($"Проблема на стороне Ozon: {ozonError} Нужно зайти в ЛК → найти этот товар → открыть |
| | 0 | 1010 | | if (!isDisabledWarehouse && !isZeroStock && !isNotCreated && pushedPrice > 0) |
| | 0 | 1011 | | noteParts.Add($"Цена Ozon: {(bv.C0.HasValue ? $"{bv.C0:F0} ₽ × {bv.Multiplier} = " : "")}{pushedPric |
| | 0 | 1012 | | if (isDisabledWarehouse) |
| | 0 | 1013 | | noteParts.Add("Снятая связь (этап 2) → обнулено"); |
| | 0 | 1014 | | if (!isDisabledWarehouse && bv.ReservedQty > 0) |
| | 0 | 1015 | | noteParts.Add($"В резерве {bv.ReservedQty} шт. из {bv.RawQty}"); |
| | 0 | 1016 | | if (!isDisabledWarehouse && bv.LiveClampedTo is { } liveFree) |
| | 0 | 1017 | | noteParts.Add($"Остаток ограничен по 1С онлайн: свободно {liveFree} шт."); |
| | 0 | 1018 | | if (!string.IsNullOrWhiteSpace(bv.SnapshotNote)) |
| | 0 | 1019 | | noteParts.Add(bv.SnapshotNote); |
| | 0 | 1020 | | if (!string.IsNullOrWhiteSpace(o.ProductName)) |
| | 0 | 1021 | | noteParts.Add(o.ProductName.Length > 100 ? o.ProductName[..100] + "…" : o.ProductName); |
| | 0 | 1022 | | object? note = string.Join(" | ", noteParts); |
| | | 1023 | | |
| | 0 | 1024 | | if (i > 0) sb.Append(','); |
| | 0 | 1025 | | var b = pIdx; |
| | 0 | 1026 | | sb.Append($"({{{b}}},{{{b+1}}},{{{b+2}}},{{{b+3}}},{{{b+4}}},{{{b+5}}},{{{b+6}}}," + |
| | 0 | 1027 | | $"{{{b+7}}},{{{b+8}}},{{{b+9}}},{{{b+10}}},{{{b+11}}},{{{b+12}}},{{{b+13}}},{{{b+14}}}," + |
| | 0 | 1028 | | $"{{{b+15}}},{{{b+16}}},{{{b+17}}},{{{b+18}}},{{{b+19}}},{{{b+20}}},{{{b+21}}}," + |
| | 0 | 1029 | | $"{{{b+22}}},{{{b+23}}},{{{b+24}}},{{{b+25}}},{{{b+26}}},{{{b+27}}},{{{b+28}}}," + |
| | 0 | 1030 | | $"{{{b+29}}},{{{b+30}}},{{{b+31}}})"); |
| | 0 | 1031 | | values.Add(reportId); |
| | 0 | 1032 | | values.Add(0); |
| | 0 | 1033 | | values.Add(o.OfferId); // Article = чистый артикул |
| | 0 | 1034 | | values.Add(o.OriginalOfferId); // OfferId = как в Ozon (с [N]) |
| | 0 | 1035 | | values.Add(bitrixName); |
| | 0 | 1036 | | values.Add(o.WarehouseName); |
| | 0 | 1037 | | values.Add(o.WarehouseId); |
| | 0 | 1038 | | values.Add($"Партия ×{bv.Multiplier} — {mfrName}"); |
| | 0 | 1039 | | values.Add(bv.TotalQty); // OneCQty = свободный остаток базового артикула (без резерва, не вы |
| | 0 | 1040 | | values.Add(o.AvailableQty); // OzonQty = было в Ozon |
| | 0 | 1041 | | values.Add(effectiveStock - o.AvailableQty); // Delta |
| | 0 | 1042 | | var action = isDisabledWarehouse |
| | 0 | 1043 | | ? BitrixStockSyncReportActions.BatchVariantDisabledLink |
| | 0 | 1044 | | : isNotCreated |
| | 0 | 1045 | | ? BitrixStockSyncReportActions.BatchVariantNotCreated |
| | 0 | 1046 | | : isZeroStock |
| | 0 | 1047 | | ? BitrixStockSyncReportActions.BatchVariantZeroStock |
| | 0 | 1048 | | : BitrixStockSyncReportActions.BatchVariant; |
| | 0 | 1049 | | values.Add(action); |
| | 0 | 1050 | | values.Add(isDisabledWarehouse ? "⓪ Обнулено (связь)" : isNotCreated ? "⚠ Не создан" : isZeroStock ? isM |
| | 0 | 1051 | | values.Add(isDisabledWarehouse ? "fa-link-slash" : isNotCreated ? "fa-triangle-exclamation" : isZeroStoc |
| | 0 | 1052 | | values.Add(isDisabledWarehouse ? "table-secondary" : isNotCreated ? "table-danger" : isZeroStock ? "tabl |
| | 0 | 1053 | | values.Add(note); |
| | 0 | 1054 | | values.Add((object?)null); // OzonProductId |
| | 0 | 1055 | | values.Add((object?)null); // OzonApplyStatus |
| | 0 | 1056 | | values.Add(false); |
| | 0 | 1057 | | values.Add(false); |
| | 0 | 1058 | | values.Add(clientId); |
| | 0 | 1059 | | values.Add(clientName); |
| | 0 | 1060 | | values.Add(bv.C0); // OneCPrice = C0 |
| | 0 | 1061 | | values.Add((object?)null); // PrevOneCPrice |
| | 0 | 1062 | | values.Add(bv.ReservedQty); // ReservedQty |
| | 0 | 1063 | | values.Add(false); // IsOzonOrphan = false (не сирота!) |
| | 0 | 1064 | | values.Add((object?)null); // OzonOrphanStatus |
| | 0 | 1065 | | values.Add((object?)null); // OzonOrphanAtUtc |
| | 0 | 1066 | | values.Add(now); |
| | 0 | 1067 | | values.Add(bv.Multiplier); // BatchMultiplier |
| | 0 | 1068 | | values.Add(bv.BaseOfferId); // BatchBaseArticle |
| | 0 | 1069 | | values.Add(isDisabledWarehouse || isZeroStock || isNotCreated || pushedPrice == 0 ? (object?)null : (dec |
| | 0 | 1070 | | pIdx += 32; |
| | | 1071 | | } |
| | 0 | 1072 | | EfSqlRawParameterGuard.Validate(sb.ToString(), values, "batch_variant.insert_line"); |
| | 0 | 1073 | | await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(values), ct); |
| | | 1074 | | } |
| | 0 | 1075 | | } |
| | | 1076 | | |
| | | 1077 | | private static async Task InsertMissingBatchVariantLinesAsync( |
| | | 1078 | | ApplicationDbContext db, |
| | | 1079 | | string reportId, |
| | | 1080 | | int clientId, |
| | | 1081 | | string clientName, |
| | | 1082 | | List<(string OfferId, int Multiplier, string BaseArticle, decimal? C0, decimal? C1, int TotalQty)> items, |
| | | 1083 | | Dictionary<string, bool> isCreatedMap, |
| | | 1084 | | Dictionary<string, string> ozonMissingErrors, |
| | | 1085 | | Dictionary<string, string> warehouseNameById, |
| | | 1086 | | CancellationToken ct) |
| | | 1087 | | { |
| | 0 | 1088 | | var now = DateTime.UtcNow; |
| | 0 | 1089 | | foreach (var chunk in items.Chunk(100)) |
| | | 1090 | | { |
| | 0 | 1091 | | var values = new List<object?>(); |
| | 0 | 1092 | | var sb = new System.Text.StringBuilder(); |
| | 0 | 1093 | | sb.Append("INSERT IGNORE INTO BitrixStockSyncReportLines " + |
| | 0 | 1094 | | "(ReportId,ProductId,Article,OfferId,OneCStore,OzonWarehouseName,OzonWarehouseId," + |
| | 0 | 1095 | | "MappingDescription,OneCQty,OzonQty,Delta,Action,ActionLabel,ActionIcon,RowClass," + |
| | 0 | 1096 | | "Note,OzonProductId,OzonApplyStatus,IsArchived,IsAutoArchived,OzonClientId,OzonClientName," + |
| | 0 | 1097 | | "OneCPrice,PrevOneCPrice,ReservedQty,IsOzonOrphan,OzonOrphanStatus,OzonOrphanAtUtc,CreatedAtUtc," |
| | 0 | 1098 | | "BatchMultiplier,BatchBaseArticle,BatchPushedPrice) VALUES "); |
| | 0 | 1099 | | var pIdx = 0; |
| | 0 | 1100 | | for (var i = 0; i < chunk.Length; i++) |
| | | 1101 | | { |
| | 0 | 1102 | | var (offerId, mult, baseArticle, c0, c1, totalQty) = chunk[i]; |
| | 0 | 1103 | | isCreatedMap.TryGetValue(offerId, out var isCreated); |
| | 0 | 1104 | | ozonMissingErrors.TryGetValue(offerId, out var ozonError); |
| | | 1105 | | |
| | 0 | 1106 | | var action = isCreated |
| | 0 | 1107 | | ? BitrixStockSyncReportActions.BatchVariantNotInFbs |
| | 0 | 1108 | | : BitrixStockSyncReportActions.BatchVariantNotCreated; |
| | 0 | 1109 | | var label = isCreated ? "⊘ Нет в FBS" : "⚠ Не создан"; |
| | 0 | 1110 | | var icon = isCreated ? "fa-store-slash" : "fa-triangle-exclamation"; |
| | 0 | 1111 | | var rowClass = isCreated ? "table-warning" : "table-danger"; |
| | | 1112 | | |
| | 0 | 1113 | | var noteParts = new List<string>(); |
| | 0 | 1114 | | noteParts.Add($"×{mult} шт. | Базовый: {baseArticle} | 1С: {totalQty} шт."); |
| | 0 | 1115 | | if (isCreated) |
| | 0 | 1116 | | noteParts.Add("Товар есть в каталоге Ozon, но не добавлен в FBS управление остатками. Зайти в ЛК → F |
| | | 1117 | | else |
| | | 1118 | | { |
| | 0 | 1119 | | noteParts.Add("Товар не создан в Ozon FBS — ошибка валидации карточки."); |
| | 0 | 1120 | | if (!string.IsNullOrEmpty(ozonError)) |
| | 0 | 1121 | | noteParts.Add($"Проблема на стороне Ozon: {ozonError} Нужно зайти в ЛК → найти этот товар → откр |
| | | 1122 | | } |
| | 0 | 1123 | | var note = string.Join(" ", noteParts); |
| | | 1124 | | |
| | 0 | 1125 | | if (i > 0) sb.Append(','); |
| | 0 | 1126 | | sb.Append($"(@p{pIdx},@p{pIdx+1},@p{pIdx+2},@p{pIdx+3},@p{pIdx+4},@p{pIdx+5},@p{pIdx+6}," + |
| | 0 | 1127 | | $"@p{pIdx+7},@p{pIdx+8},@p{pIdx+9},@p{pIdx+10},@p{pIdx+11},@p{pIdx+12},@p{pIdx+13},@p{pIdx+14} |
| | 0 | 1128 | | $"@p{pIdx+15},@p{pIdx+16},@p{pIdx+17},@p{pIdx+18},@p{pIdx+19},@p{pIdx+20},@p{pIdx+21}," + |
| | 0 | 1129 | | $"@p{pIdx+22},@p{pIdx+23},@p{pIdx+24},@p{pIdx+25},@p{pIdx+26},@p{pIdx+27},@p{pIdx+28}," + |
| | 0 | 1130 | | $"@p{pIdx+29},@p{pIdx+30},@p{pIdx+31})"); |
| | 0 | 1131 | | values.Add(reportId); // ReportId |
| | 0 | 1132 | | values.Add(clientId); // ProductId (ClientId) |
| | 0 | 1133 | | values.Add(baseArticle); // Article |
| | 0 | 1134 | | values.Add(offerId); // OfferId |
| | 0 | 1135 | | values.Add((object?)null); // OneCStore |
| | 0 | 1136 | | values.Add("—"); // OzonWarehouseName |
| | 0 | 1137 | | values.Add(0L); // OzonWarehouseId |
| | 0 | 1138 | | values.Add((object?)null); // MappingDescription |
| | 0 | 1139 | | values.Add(totalQty); // OneCQty |
| | 0 | 1140 | | values.Add(0); // OzonQty |
| | 0 | 1141 | | values.Add(0); // Delta |
| | 0 | 1142 | | values.Add(action); // Action |
| | 0 | 1143 | | values.Add(label); // ActionLabel |
| | 0 | 1144 | | values.Add(icon); // ActionIcon |
| | 0 | 1145 | | values.Add(rowClass); // RowClass |
| | 0 | 1146 | | values.Add(note); // Note |
| | 0 | 1147 | | values.Add((object?)null); // OzonProductId |
| | 0 | 1148 | | values.Add("Не в отчёте"); // OzonApplyStatus |
| | 0 | 1149 | | values.Add(false); // IsArchived |
| | 0 | 1150 | | values.Add(false); // IsAutoArchived |
| | 0 | 1151 | | values.Add(clientId); // OzonClientId |
| | 0 | 1152 | | values.Add(clientName); // OzonClientName |
| | 0 | 1153 | | values.Add(c0); // OneCPrice |
| | 0 | 1154 | | values.Add((object?)null); // PrevOneCPrice |
| | 0 | 1155 | | values.Add(0); // ReservedQty |
| | 0 | 1156 | | values.Add(false); // IsOzonOrphan |
| | 0 | 1157 | | values.Add((object?)null); // OzonOrphanStatus |
| | 0 | 1158 | | values.Add((object?)null); // OzonOrphanAtUtc |
| | 0 | 1159 | | values.Add(now); // CreatedAtUtc |
| | 0 | 1160 | | values.Add(mult); // BatchMultiplier |
| | 0 | 1161 | | values.Add(baseArticle); // BatchBaseArticle |
| | 0 | 1162 | | values.Add((object?)null); // BatchPushedPrice |
| | 0 | 1163 | | pIdx += 32; |
| | | 1164 | | } |
| | 0 | 1165 | | EfSqlRawParameterGuard.Validate(sb.ToString(), values, "missing_batch.insert_line"); |
| | 0 | 1166 | | await db.Database.ExecuteSqlRawAsync(sb.ToString(), EfSqlRawParameterGuard.Materialize(values), ct); |
| | | 1167 | | } |
| | 0 | 1168 | | } |
| | | 1169 | | |
| | | 1170 | | private async Task<Dictionary<string, decimal>> PushBatchVariantPricesAsync( |
| | | 1171 | | OzonApiDataManager apiManager, |
| | | 1172 | | List<BatchVariantItem> batchVariants, |
| | | 1173 | | string reportId, |
| | | 1174 | | int clientId, |
| | | 1175 | | string clientName, |
| | | 1176 | | IReadOnlyDictionary<string, OzonProductCatalogRow> catalogByOfferId, |
| | | 1177 | | Func<string, Task> log, |
| | | 1178 | | Func<int, int, string, Task> progress, |
| | | 1179 | | int clientIndex, |
| | | 1180 | | int clientsTotal, |
| | | 1181 | | CancellationToken ct) |
| | | 1182 | | { |
| | | 1183 | | // Загружаем настройки процента наценки из отчёта (fallback: 5% без акции, 10% с акцией) |
| | 0 | 1184 | | decimal noPromoMarkup = 5m, promoMarkup = 10m; |
| | | 1185 | | |
| | | 1186 | | const int priceBatchSize = 200; |
| | 0 | 1187 | | var offerIds = batchVariants.Select(b => b.Row.OriginalOfferId).ToArray(); |
| | | 1188 | | |
| | 0 | 1189 | | var priceByOfferId = new Dictionary<string, JObject>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 1190 | | var offersNeedingApi = new List<string>(); |
| | 0 | 1191 | | foreach (var offerId in offerIds) |
| | | 1192 | | { |
| | 0 | 1193 | | if (catalogByOfferId.TryGetValue(offerId, out var catalogRow)) |
| | 0 | 1194 | | priceByOfferId[offerId] = OzonProductCatalogPriceJsonBuilder.Build(catalogRow); |
| | | 1195 | | else |
| | 0 | 1196 | | offersNeedingApi.Add(offerId); |
| | | 1197 | | } |
| | | 1198 | | |
| | 0 | 1199 | | await log($"[{clientName}] Партии — цены: из OzonApi.ozon_products {priceByOfferId.Count}, через Ozon API GetPro |
| | | 1200 | | |
| | 0 | 1201 | | foreach (var batchOffers in offersNeedingApi.Chunk(priceBatchSize)) |
| | | 1202 | | { |
| | | 1203 | | try |
| | | 1204 | | { |
| | 0 | 1205 | | await log($"[{clientName}] Ozon API GetProductPrices: {batchOffers.Length} offer_id (fallback, нет в кат |
| | 0 | 1206 | | var raw = await apiManager.GetProductPricesByArticles(batchOffers); |
| | 0 | 1207 | | var items = raw?["items"] as JArray; |
| | 0 | 1208 | | if (items == null) continue; |
| | 0 | 1209 | | foreach (var item in items.OfType<JObject>()) |
| | | 1210 | | { |
| | 0 | 1211 | | var oid = item["offer_id"]?.Value<string>(); |
| | 0 | 1212 | | if (!string.IsNullOrEmpty(oid)) |
| | 0 | 1213 | | priceByOfferId[oid] = item; |
| | | 1214 | | } |
| | 0 | 1215 | | } |
| | 0 | 1216 | | catch (Exception ex) |
| | | 1217 | | { |
| | 0 | 1218 | | await log($"[{clientName}] Партии — ошибка получения цен Ozon: {ex.Message}"); |
| | | 1219 | | } |
| | 0 | 1220 | | } |
| | | 1221 | | |
| | 0 | 1222 | | var priceItems = new List<ProductPriceItem>(); |
| | 0 | 1223 | | var pushedPrices = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase); |
| | | 1224 | | |
| | 0 | 1225 | | foreach (var bv in batchVariants) |
| | | 1226 | | { |
| | 0 | 1227 | | if (!priceByOfferId.TryGetValue(bv.Row.OriginalOfferId, out var priceJson)) |
| | | 1228 | | { |
| | 0 | 1229 | | await log($" [Цена] {bv.Row.OriginalOfferId}: нет данных Ozon/каталога — пропуск цены"); |
| | 0 | 1230 | | continue; |
| | | 1231 | | } |
| | | 1232 | | |
| | 0 | 1233 | | var productId = bv.Row.ProductId ?? priceJson["product_id"]?.Value<long?>(); |
| | 0 | 1234 | | if (productId == null) |
| | | 1235 | | { |
| | 0 | 1236 | | await log($" [Цена] {bv.Row.OriginalOfferId}: product_id не найден — пропуск цены"); |
| | 0 | 1237 | | continue; |
| | | 1238 | | } |
| | | 1239 | | |
| | 0 | 1240 | | var vat = priceJson["price"]?["vat"]?.Value<string>() ?? "0"; |
| | | 1241 | | |
| | | 1242 | | PriceOptimizationResult optResult; |
| | | 1243 | | try |
| | | 1244 | | { |
| | 0 | 1245 | | optResult = PriceOptimizationManager.Calculate(new PriceOptimizationInput |
| | 0 | 1246 | | { |
| | 0 | 1247 | | C0 = bv.C0, |
| | 0 | 1248 | | C1 = bv.C1, |
| | 0 | 1249 | | PriceJson = priceJson, |
| | 0 | 1250 | | InPromo = false, |
| | 0 | 1251 | | NoPromoMarkupPercent = noPromoMarkup, |
| | 0 | 1252 | | PromoMarkupPercent = promoMarkup |
| | 0 | 1253 | | }); |
| | 0 | 1254 | | } |
| | | 1255 | | catch (Exception ex) |
| | | 1256 | | { |
| | 0 | 1257 | | await log($" [Цена] {bv.Row.OriginalOfferId}: ошибка расчёта — {ex.Message}"); |
| | 0 | 1258 | | continue; |
| | | 1259 | | } |
| | | 1260 | | |
| | 0 | 1261 | | if (optResult.Error != null) |
| | | 1262 | | { |
| | 0 | 1263 | | await log($" [Цена] {bv.Row.OriginalOfferId}: {optResult.Error} — пропуск"); |
| | 0 | 1264 | | continue; |
| | | 1265 | | } |
| | | 1266 | | |
| | 0 | 1267 | | if (optResult.WinnerPrice == null) |
| | | 1268 | | { |
| | 0 | 1269 | | await log($" [Цена] {bv.Row.OriginalOfferId}: PriceOptimizationManager вернул null — пропуск"); |
| | 0 | 1270 | | continue; |
| | | 1271 | | } |
| | | 1272 | | |
| | 0 | 1273 | | var winner = optResult.WinnerPrice.Value * bv.Multiplier; |
| | 0 | 1274 | | var oldPrice = BitrixStockSyncReportPricePushService.ComputeOldPrice(winner); |
| | 0 | 1275 | | await log($" [Цена] {bv.Row.OriginalOfferId} ×{bv.Multiplier}: " + |
| | 0 | 1276 | | $"{optResult.WinnerPrice.Value:F0} × {bv.Multiplier} = {winner:F0} ₽ → Ozon"); |
| | | 1277 | | |
| | 0 | 1278 | | pushedPrices[bv.Row.OriginalOfferId] = winner; |
| | | 1279 | | |
| | 0 | 1280 | | priceItems.Add(new ProductPriceItem |
| | 0 | 1281 | | { |
| | 0 | 1282 | | OfferId = bv.Row.OriginalOfferId, |
| | 0 | 1283 | | ProductId = productId.Value, |
| | 0 | 1284 | | Price = ((long)winner).ToString(CultureInfo.InvariantCulture), |
| | 0 | 1285 | | OldPrice = ((long)oldPrice).ToString(CultureInfo.InvariantCulture), |
| | 0 | 1286 | | MinPrice = ((long)winner).ToString(CultureInfo.InvariantCulture), |
| | 0 | 1287 | | NetPrice = "0", |
| | 0 | 1288 | | Vat = vat |
| | 0 | 1289 | | }); |
| | 0 | 1290 | | } |
| | | 1291 | | |
| | 0 | 1292 | | if (priceItems.Count == 0) return pushedPrices; |
| | | 1293 | | |
| | | 1294 | | // Дедуплицируем по OfferId: в Ozon API цена привязана к товару целиком, а не к складу |
| | 0 | 1295 | | var distinctPriceItems = priceItems |
| | 0 | 1296 | | .GroupBy(p => p.OfferId, StringComparer.OrdinalIgnoreCase) |
| | 0 | 1297 | | .Select(g => g.Last()) |
| | 0 | 1298 | | .ToList(); |
| | | 1299 | | |
| | | 1300 | | try |
| | | 1301 | | { |
| | 0 | 1302 | | var result = await apiManager.ImportProductPrices(distinctPriceItems); |
| | 0 | 1303 | | var pushed = result.Result?.Count(r => r.Updated) ?? 0; |
| | 0 | 1304 | | var errors = result.Result?.Where(r => !r.Updated).ToList() ?? new(); |
| | | 1305 | | |
| | | 1306 | | // Проверяем ошибки НДС и делаем точечный ретрай с корректной ставкой |
| | 0 | 1307 | | var vatRetryItems = new List<ProductPriceItem>(); |
| | 0 | 1308 | | foreach (var err in errors) |
| | | 1309 | | { |
| | 0 | 1310 | | var errMsg = string.Join("; ", err.Errors?.Select(e => e.Message) ?? Enumerable.Empty<string>()); |
| | 0 | 1311 | | var fixedVat = BitrixStockSyncReportPricePushService.ParseAvailableVat(errMsg); |
| | 0 | 1312 | | if (fixedVat != null && !string.IsNullOrEmpty(err.OfferId)) |
| | | 1313 | | { |
| | 0 | 1314 | | var original = distinctPriceItems.FirstOrDefault(p => |
| | 0 | 1315 | | string.Equals(p.OfferId, err.OfferId, StringComparison.OrdinalIgnoreCase)); |
| | 0 | 1316 | | if (original != null) |
| | | 1317 | | { |
| | 0 | 1318 | | vatRetryItems.Add(new ProductPriceItem |
| | 0 | 1319 | | { |
| | 0 | 1320 | | OfferId = original.OfferId, |
| | 0 | 1321 | | ProductId = original.ProductId, |
| | 0 | 1322 | | Price = original.Price, |
| | 0 | 1323 | | OldPrice = original.OldPrice, |
| | 0 | 1324 | | MinPrice = original.MinPrice, |
| | 0 | 1325 | | NetPrice = original.NetPrice, |
| | 0 | 1326 | | Vat = fixedVat |
| | 0 | 1327 | | }); |
| | | 1328 | | } |
| | | 1329 | | } |
| | | 1330 | | } |
| | | 1331 | | |
| | 0 | 1332 | | if (vatRetryItems.Count > 0) |
| | | 1333 | | { |
| | 0 | 1334 | | await log($"[{clientName}] Партии — ретрай НДС: {vatRetryItems.Count} товаров…"); |
| | | 1335 | | try |
| | | 1336 | | { |
| | 0 | 1337 | | var retryResult = await apiManager.ImportProductPrices(vatRetryItems); |
| | 0 | 1338 | | foreach (var r in retryResult.Result ?? new()) |
| | | 1339 | | { |
| | 0 | 1340 | | if (r.Updated) |
| | | 1341 | | { |
| | 0 | 1342 | | pushed++; |
| | 0 | 1343 | | errors.RemoveAll(e => string.Equals(e.OfferId, r.OfferId, StringComparison.OrdinalIgnoreCase |
| | | 1344 | | } |
| | | 1345 | | } |
| | 0 | 1346 | | } |
| | 0 | 1347 | | catch (Exception retryEx) |
| | | 1348 | | { |
| | 0 | 1349 | | await log($"[{clientName}] Партии — ошибка ретрая НДС: {retryEx.Message}"); |
| | | 1350 | | } |
| | | 1351 | | } |
| | | 1352 | | |
| | 0 | 1353 | | await log($"[{clientName}] Партии — цены накачены: {pushed}/{distinctPriceItems.Count}"); |
| | 0 | 1354 | | foreach (var err in errors) |
| | | 1355 | | { |
| | 0 | 1356 | | var msg = err.Errors?.FirstOrDefault()?.Message ?? "неизвестная ошибка"; |
| | 0 | 1357 | | await log($" [Цена] Ошибка {err.OfferId}: {msg}"); |
| | 0 | 1358 | | pushedPrices.Remove(err.OfferId ?? string.Empty); |
| | 0 | 1359 | | } |
| | 0 | 1360 | | } |
| | 0 | 1361 | | catch (Exception ex) |
| | | 1362 | | { |
| | 0 | 1363 | | await log($"[{clientName}] Партии — ошибка пуша цен: {ex.Message}"); |
| | 0 | 1364 | | pushedPrices.Clear(); |
| | | 1365 | | } |
| | | 1366 | | |
| | 0 | 1367 | | return pushedPrices; |
| | 0 | 1368 | | } |
| | | 1369 | | |
| | 0 | 1370 | | private sealed record BatchArticleSnapshot( |
| | 0 | 1371 | | decimal? C0, |
| | 0 | 1372 | | decimal? C1, |
| | 0 | 1373 | | IReadOnlyDictionary<string, int> Quantities, |
| | 0 | 1374 | | string? Note = null, |
| | 0 | 1375 | | string? Manufacturer = null); |
| | | 1376 | | |
| | 0 | 1377 | | private sealed record SnapshotRow( |
| | 0 | 1378 | | string Manufacturer, |
| | 0 | 1379 | | decimal? C0, |
| | 0 | 1380 | | decimal? C1, |
| | 0 | 1381 | | int M1, |
| | 0 | 1382 | | int M2, |
| | 0 | 1383 | | int M3, |
| | 0 | 1384 | | int B1, |
| | 0 | 1385 | | int B2); |
| | | 1386 | | |
| | | 1387 | | private async Task<BatchArticleSnapshot> GetArticleSnapshotAsync( |
| | | 1388 | | ApplicationDbContext db, string offerId, CancellationToken ct) |
| | | 1389 | | { |
| | 0 | 1390 | | var article = BitrixStockOrphanOfferMatching.ExtractArticle(offerId); |
| | 0 | 1391 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 1392 | | return new BatchArticleSnapshot(null, null, EmptyQuantities(), "Остаток → 0: пустой артикул Ozon."); |
| | | 1393 | | |
| | | 1394 | | try |
| | | 1395 | | { |
| | | 1396 | | // GetDbConnection() возвращает соединение, которым владеет EF-контекст. |
| | | 1397 | | // Нельзя оборачивать в using — это закроет соединение и сломает дальнейшие операции db. |
| | 0 | 1398 | | var conn = db.Database.GetDbConnection(); |
| | 0 | 1399 | | if (conn.State != System.Data.ConnectionState.Open) |
| | 0 | 1400 | | await conn.OpenAsync(ct); |
| | 0 | 1401 | | using var cmd = conn.CreateCommand(); |
| | | 1402 | | // Сначала ищем точное совпадение; если не нашли — расширяем: Article начинается с «article=» |
| | | 1403 | | // Это нужно, когда снэпшот хранит «9018906140=TYT» или «9018906140=TYT[50]», |
| | | 1404 | | // а мы ищем по базовому «9018906140» (после ExtractArticle). |
| | 0 | 1405 | | cmd.CommandText = |
| | 0 | 1406 | | "SELECT Manufacturer, C0, C1ExistMinus5Percent, M1Qty, M2Qty, M3Qty, B1Qty, B2Qty " + |
| | 0 | 1407 | | "FROM WarehouseStockSnapshot " + |
| | 0 | 1408 | | "WHERE Article = @article COLLATE utf8mb4_unicode_ci " + |
| | 0 | 1409 | | " OR Article LIKE @articlePrefix COLLATE utf8mb4_unicode_ci"; |
| | 0 | 1410 | | var pArticle = cmd.CreateParameter(); |
| | 0 | 1411 | | pArticle.ParameterName = "@article"; |
| | 0 | 1412 | | pArticle.Value = article; |
| | 0 | 1413 | | cmd.Parameters.Add(pArticle); |
| | 0 | 1414 | | var pPrefix = cmd.CreateParameter(); |
| | 0 | 1415 | | pPrefix.ParameterName = "@articlePrefix"; |
| | | 1416 | | // Экранируем спецсимволы LIKE-паттерна в самом артикуле |
| | 0 | 1417 | | pPrefix.Value = article.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_") + "=%"; |
| | 0 | 1418 | | cmd.Parameters.Add(pPrefix); |
| | 0 | 1419 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 1420 | | var rows = new List<SnapshotRow>(); |
| | 0 | 1421 | | while (await reader.ReadAsync(ct)) |
| | | 1422 | | { |
| | 0 | 1423 | | rows.Add(new SnapshotRow( |
| | 0 | 1424 | | reader.IsDBNull(0) ? string.Empty : reader.GetString(0), |
| | 0 | 1425 | | reader.IsDBNull(1) ? null : Convert.ToDecimal(reader.GetValue(1)), |
| | 0 | 1426 | | reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2)), |
| | 0 | 1427 | | ReadQty(reader, 3), ReadQty(reader, 4), ReadQty(reader, 5), |
| | 0 | 1428 | | ReadQty(reader, 6), ReadQty(reader, 7))); |
| | | 1429 | | } |
| | | 1430 | | |
| | 0 | 1431 | | var manufacturerNames = rows.Select(r => r.Manufacturer) |
| | 0 | 1432 | | .Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); |
| | 0 | 1433 | | var prefixes = await _etProducerRepository.GetMarketPrefixesByNamesAsync(manufacturerNames); |
| | 0 | 1434 | | var match = BitrixStockWarehouseSnapshotMatching.Match( |
| | 0 | 1435 | | offerId, |
| | 0 | 1436 | | rows.Select(r => new BitrixStockWarehouseSnapshotMatching.SnapshotRow<SnapshotRow>( |
| | 0 | 1437 | | r.Manufacturer, prefixes.GetValueOrDefault(r.Manufacturer), r))); |
| | 0 | 1438 | | if (match.IsMatch) |
| | | 1439 | | { |
| | 0 | 1440 | | var value = match.Value!; |
| | 0 | 1441 | | return new BatchArticleSnapshot(value.C0, value.C1, new Dictionary<string, int>(StringComparer.OrdinalIg |
| | 0 | 1442 | | { |
| | 0 | 1443 | | ["M1"] = value.M1, ["M2"] = value.M2, ["M3"] = value.M3, |
| | 0 | 1444 | | ["B1"] = value.B1, ["B2"] = value.B2 |
| | 0 | 1445 | | }, Manufacturer: value.Manufacturer); |
| | | 1446 | | } |
| | 0 | 1447 | | return new BatchArticleSnapshot(null, null, EmptyQuantities(), match.Note); |
| | 0 | 1448 | | } |
| | 0 | 1449 | | catch { return new BatchArticleSnapshot(null, null, EmptyQuantities()); } |
| | 0 | 1450 | | } |
| | | 1451 | | |
| | | 1452 | | private static IReadOnlyDictionary<string, int> EmptyQuantities() => |
| | 0 | 1453 | | new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); |
| | | 1454 | | |
| | | 1455 | | private static int ReadQty(System.Data.Common.DbDataReader reader, int ordinal) => |
| | 0 | 1456 | | reader.IsDBNull(ordinal) ? 0 : Math.Max(0, Convert.ToInt32(reader.GetValue(ordinal))); |
| | | 1457 | | |
| | | 1458 | | private static async Task SaveReportFileAsync( |
| | | 1459 | | ApplicationDbContext db, |
| | | 1460 | | string reportId, |
| | | 1461 | | int clientId, |
| | | 1462 | | string fileUrl, |
| | | 1463 | | string reportCode, |
| | | 1464 | | List<OzonDomains.Models.WarehouseMapping> mappings, |
| | | 1465 | | List<long> queriedWarehouseIds, |
| | | 1466 | | int rowsTotal, |
| | | 1467 | | int rowsWithStock, |
| | | 1468 | | int orphansFound, |
| | | 1469 | | int orphansZeroed, |
| | | 1470 | | CancellationToken ct) |
| | | 1471 | | { |
| | | 1472 | | // Этап 5 скачивает ОДИН xlsx на клиента (покрывает все запрошенные склады). |
| | | 1473 | | // Сохраняем ровно одну запись на файл, а не по строке на каждый склад-маппинг. |
| | 0 | 1474 | | var queriedMappings = mappings |
| | 0 | 1475 | | .Where(m => long.TryParse(m.OzonWarehouseId?.Trim(), out var wid) && queriedWarehouseIds.Contains(wid)) |
| | 0 | 1476 | | .DistinctBy(m => m.OzonWarehouseId) |
| | 0 | 1477 | | .ToList(); |
| | | 1478 | | |
| | | 1479 | | // Список складов в файле — для колонки «Склад» в UI (напр. "M2, M3, M2S, B12") |
| | 0 | 1480 | | var warehouseLabel = queriedMappings.Count > 0 |
| | 0 | 1481 | | ? string.Join(", ", queriedMappings.Select(m => m.OzonWarehouseName)) |
| | 0 | 1482 | | : "—"; |
| | 0 | 1483 | | if (warehouseLabel.Length > 120) warehouseLabel = warehouseLabel[..120]; |
| | | 1484 | | |
| | 0 | 1485 | | var firstWarehouseId = queriedMappings.FirstOrDefault()?.OzonWarehouseId |
| | 0 | 1486 | | ?? queriedWarehouseIds.FirstOrDefault().ToString(); |
| | | 1487 | | |
| | | 1488 | | const string insertSql = |
| | | 1489 | | $"INSERT INTO {OzonFilesTable} " + |
| | | 1490 | | "(ReportId,OzonClientId,OzonWarehouseId,OzonWarehouseName,BitrixWarehouseName," + |
| | | 1491 | | "OzonReportCode,FileUrl,RowsTotal,RowsWithStock,OrphansFound,OrphansZeroed,CreatedAtUtc) " + |
| | | 1492 | | "VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})"; |
| | 0 | 1493 | | var insertParams = EfSqlRawParameterGuard.Materialize( |
| | 0 | 1494 | | reportId, clientId, firstWarehouseId, warehouseLabel, warehouseLabel, |
| | 0 | 1495 | | reportCode, fileUrl, rowsTotal, rowsWithStock, orphansFound, orphansZeroed, |
| | 0 | 1496 | | DateTime.UtcNow); |
| | 0 | 1497 | | EfSqlRawParameterGuard.Validate(insertSql, insertParams, "orphan.save_report_file"); |
| | 0 | 1498 | | await db.Database.ExecuteSqlRawAsync(insertSql, insertParams, ct); |
| | 0 | 1499 | | } |
| | | 1500 | | |
| | | 1501 | | private static async Task<List<OzonReportFileDto>> LoadReportFilesAsync( |
| | | 1502 | | ApplicationDbContext db, |
| | | 1503 | | string reportId, |
| | | 1504 | | CancellationToken ct) |
| | | 1505 | | { |
| | | 1506 | | // Проверяем, существует ли таблица |
| | | 1507 | | try |
| | | 1508 | | { |
| | 0 | 1509 | | return await db.Database.SqlQueryRaw<OzonReportFileDto>( |
| | 0 | 1510 | | $"SELECT Id,ReportId,OzonClientId,OzonWarehouseId,OzonWarehouseName,BitrixWarehouseName," + |
| | 0 | 1511 | | $"OzonReportCode,FileUrl,RowsTotal,RowsWithStock,OrphansFound,OrphansZeroed,CreatedAtUtc " + |
| | 0 | 1512 | | $"FROM {OzonFilesTable} WHERE ReportId = {{0}} ORDER BY CreatedAtUtc DESC", |
| | 0 | 1513 | | reportId).ToListAsync(ct); |
| | | 1514 | | } |
| | 0 | 1515 | | catch { return new List<OzonReportFileDto>(); } |
| | 0 | 1516 | | } |
| | | 1517 | | |
| | | 1518 | | private static async Task<List<OzonOrphanPreviewLine>> LoadOrphanLinesAsync( |
| | | 1519 | | ApplicationDbContext db, |
| | | 1520 | | string reportId, |
| | | 1521 | | CancellationToken ct) |
| | | 1522 | | { |
| | | 1523 | | try |
| | | 1524 | | { |
| | 0 | 1525 | | return await db.Database.SqlQueryRaw<OzonOrphanPreviewLine>( |
| | 0 | 1526 | | "SELECT OfferId, OzonWarehouseName, OneCStore AS BitrixWarehouseName, " + |
| | 0 | 1527 | | "OzonQty AS AvailableQty, OzonOrphanStatus " + |
| | 0 | 1528 | | "FROM BitrixStockSyncReportLines " + |
| | 0 | 1529 | | "WHERE ReportId = {0} AND IsOzonOrphan = 1 ORDER BY OneCStore, OfferId", |
| | 0 | 1530 | | reportId).ToListAsync(ct); |
| | | 1531 | | } |
| | 0 | 1532 | | catch { return new List<OzonOrphanPreviewLine>(); } |
| | 0 | 1533 | | } |
| | | 1534 | | |
| | | 1535 | | private static async Task<Dictionary<string, string>> LoadManufacturersAsync( |
| | | 1536 | | ApplicationDbContext db, CancellationToken ct) |
| | | 1537 | | { |
| | | 1538 | | try |
| | | 1539 | | { |
| | 0 | 1540 | | var rows = await db.Database.SqlQueryRaw<ManufacturerRow>( |
| | 0 | 1541 | | "SELECT Code, Name FROM Manufacturers WHERE Code IS NOT NULL AND Name IS NOT NULL" |
| | 0 | 1542 | | ).ToListAsync(ct); |
| | 0 | 1543 | | return rows.ToDictionary(r => r.Code, r => r.Name, StringComparer.OrdinalIgnoreCase); |
| | | 1544 | | } |
| | 0 | 1545 | | catch { return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); } |
| | 0 | 1546 | | } |
| | | 1547 | | |
| | | 1548 | | private static async Task SaveOzonFileBytesAsync(byte[] bytes, string reportId, string reportCode, |
| | | 1549 | | CancellationToken ct) |
| | | 1550 | | { |
| | | 1551 | | try |
| | | 1552 | | { |
| | 0 | 1553 | | var dir = Path.Combine(OzonFilesStorageRoot, reportId); |
| | 0 | 1554 | | Directory.CreateDirectory(dir); |
| | 0 | 1555 | | var path = Path.Combine(dir, $"{reportCode}.xlsx"); |
| | 0 | 1556 | | await File.WriteAllBytesAsync(path, bytes, ct); |
| | 0 | 1557 | | } |
| | 0 | 1558 | | catch { /* fire-and-forget: ошибка сохранения не останавливает этап */ } |
| | 0 | 1559 | | } |
| | | 1560 | | |
| | | 1561 | | // ─── Вспомогательные типы ───────────────────────────────────────────────── |
| | | 1562 | | |
| | | 1563 | | private static Dictionary<long, string> BuildWarehouseDisplayByOzonId( |
| | | 1564 | | IEnumerable<OzonDomains.Models.WarehouseMapping> mappings) |
| | | 1565 | | { |
| | 0 | 1566 | | var byOzonId = mappings |
| | 0 | 1567 | | .Where(m => m.OzonWarehouseId != null) |
| | 0 | 1568 | | .GroupBy(m => m.OzonWarehouseId!.Trim(), StringComparer.OrdinalIgnoreCase) |
| | 0 | 1569 | | .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); |
| | | 1570 | | |
| | 0 | 1571 | | var result = new Dictionary<long, string>(); |
| | 0 | 1572 | | foreach (var (ozonId, mapping) in byOzonId) |
| | | 1573 | | { |
| | 0 | 1574 | | if (!long.TryParse(ozonId, out var wid)) |
| | | 1575 | | continue; |
| | 0 | 1576 | | var ozonName = mapping.OzonWarehouseName ?? ozonId; |
| | 0 | 1577 | | var label = string.IsNullOrWhiteSpace(mapping.BitrixWarehouseName) |
| | 0 | 1578 | | ? ozonName |
| | 0 | 1579 | | : $"{ozonName} (1С:{mapping.BitrixWarehouseName})"; |
| | 0 | 1580 | | result[wid] = label; |
| | | 1581 | | } |
| | | 1582 | | |
| | 0 | 1583 | | return result; |
| | | 1584 | | } |
| | | 1585 | | |
| | | 1586 | | /// <summary>Позиция-упаковка [N]: базовый артикул найден в WarehouseStockSnapshot, не является сиротой.</summary> |
| | 0 | 1587 | | private sealed record BatchVariantItem( |
| | 0 | 1588 | | OzonCatalogStockRow Row, |
| | 0 | 1589 | | int Multiplier, |
| | 0 | 1590 | | string BaseOfferId, |
| | 0 | 1591 | | decimal? C0, |
| | 0 | 1592 | | decimal? C1, |
| | 0 | 1593 | | int TotalQty, |
| | 0 | 1594 | | string? SnapshotNote = null) |
| | | 1595 | | { |
| | | 1596 | | /// <summary>Производитель из снимка таблицы — нужен, чтобы найти карточку в 1С при сверке остатка.</summary> |
| | 0 | 1597 | | public string? Manufacturer { get; init; } |
| | | 1598 | | |
| | | 1599 | | /// <summary>Остаток базового артикула по снимку таблицы до вычета резерва и сверки с 1С.</summary> |
| | 0 | 1600 | | public int RawQty { get; init; } |
| | | 1601 | | |
| | | 1602 | | /// <summary>Резерв заказов по базовому артикулу на складах партии, вычтенный из <see cref="TotalQty"/>.</summar |
| | 0 | 1603 | | public int ReservedQty { get; init; } |
| | | 1604 | | |
| | | 1605 | | /// <summary>Свободно по 1С онлайн, если оно оказалось меньше остатка снимка за вычетом резерва.</summary> |
| | 0 | 1606 | | public int? LiveClampedTo { get; init; } |
| | | 1607 | | |
| | | 1608 | | /// <summary>Целевой остаток в упаковках: floor(TotalQty / Multiplier); TotalQty — свободный остаток (без резерв |
| | 0 | 1609 | | public int BatchStock => TotalQty > 0 ? Math.Max(0, TotalQty / Multiplier) : 0; |
| | | 1610 | | } |
| | | 1611 | | |
| | | 1612 | | private sealed class ManufacturerRow |
| | | 1613 | | { |
| | 0 | 1614 | | public string Code { get; set; } = string.Empty; |
| | 0 | 1615 | | public string Name { get; set; } = string.Empty; |
| | | 1616 | | } |
| | | 1617 | | |
| | | 1618 | | internal sealed class ReportMeta |
| | | 1619 | | { |
| | 1 | 1620 | | public int OzonClientId { get; set; } |
| | 0 | 1621 | | public string? MultiClientIdsJson { get; set; } |
| | | 1622 | | } |
| | | 1623 | | |
| | | 1624 | | private sealed class OfferWarehouseArticleKey |
| | | 1625 | | { |
| | 0 | 1626 | | public string OfferId { get; set; } = string.Empty; |
| | 0 | 1627 | | public string WarehouseId { get; set; } = string.Empty; |
| | 0 | 1628 | | public string? Article { get; set; } |
| | | 1629 | | } |
| | | 1630 | | |
| | | 1631 | | private sealed class StringValue |
| | | 1632 | | { |
| | 0 | 1633 | | public string Value { get; set; } = string.Empty; |
| | | 1634 | | } |
| | | 1635 | | } |