| | | 1 | | using ClosedXML.Excel; |
| | | 2 | | using System.Collections.Concurrent; |
| | | 3 | | using System.Diagnostics; |
| | | 4 | | using System.Security.Cryptography; |
| | | 5 | | using System.Security.Claims; |
| | | 6 | | using System.Text; |
| | | 7 | | using System.Text.Json; |
| | | 8 | | using Hangfire; |
| | | 9 | | using Hangfire.Common; |
| | | 10 | | using Hangfire.States; |
| | | 11 | | using Hangfire.Storage; |
| | | 12 | | using Microsoft.AspNetCore.Authorization; |
| | | 13 | | using Microsoft.AspNetCore.Mvc; |
| | | 14 | | using Microsoft.Extensions.Caching.Memory; |
| | | 15 | | using Microsoft.Extensions.Http; |
| | | 16 | | using MySqlConnector; |
| | | 17 | | using SlqStudio.Application.Services.EmailService; |
| | | 18 | | using Newtonsoft.Json.Linq; |
| | | 19 | | using OzonDomains; |
| | | 20 | | using OzonDomains.Models; |
| | | 21 | | using OzonDomains.Models.BitrixModels; |
| | | 22 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix; |
| | | 23 | | using OzonOrdersWeb.Areas.Studio2.Views; |
| | | 24 | | using OzonRepositories.Data; |
| | | 25 | | using OzonRepositories.Data.Bitrix; |
| | | 26 | | using Services.ApiServices; |
| | | 27 | | using Services.ApiServices._1CApi; |
| | | 28 | | using Services.ApiServices._1CApi.Models; |
| | | 29 | | using Services.ApiServices.OzonApi; |
| | | 30 | | using Services.ApiServices.OzonApi.Filters; |
| | | 31 | | using Services.ApiServices.YandexApi; |
| | | 32 | | using Services.BackgroundJobsServices; |
| | | 33 | | using Services.ChannelTaskServices; |
| | | 34 | | using Services.BitrixStockServices; |
| | | 35 | | using Services.DataServices; |
| | | 36 | | using Services.ImportProductPricesServices; |
| | | 37 | | using Services.PriceСlculationServices; |
| | | 38 | | using Services.SignalRServices; |
| | | 39 | | using Services.StockSyncServices; |
| | | 40 | | using Services.HangfireService; |
| | | 41 | | using OzonOrdersWeb.Extensions; |
| | | 42 | | using OzonOrdersWeb.Authorization; |
| | | 43 | | |
| | | 44 | | [Area("Studio2")] |
| | | 45 | | [Authorize(Roles = "User,Admin")] |
| | | 46 | | [Route("BitrixStock")] |
| | | 47 | | [Route("Studio2/BitrixStock")] |
| | | 48 | | public partial class BitrixStockController : Controller |
| | | 49 | | { |
| | | 50 | | private const int MaxArticlesForOzonStocksLoad = 500; |
| | | 51 | | private const int MaxOfferIdsForOzonArchiveBatch = 100; |
| | | 52 | | private const int OzonProductInfoListChunkSize = 500; |
| | | 53 | | private const string GoogleSyncRecurringJobId = "warehouse-stock-sync-google-auto"; |
| | | 54 | | private const string GoogleSyncTriggeredBy = "BitrixStockGoogleAutoSchedule"; |
| | | 55 | | |
| | 0 | 56 | | private readonly List<string> _ozonLoadWarnings = new(); |
| | | 57 | | private BitrixOzonStockSyncSummary? _ozonLinkageSyncReport; |
| | | 58 | | private readonly BitrixStockRepository _repository; |
| | | 59 | | private readonly EtProducerRepository _etProducerRepository; |
| | | 60 | | private readonly OzonClientServices _ozonClientServices; |
| | | 61 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 62 | | private readonly IEmailService? _emailService; |
| | | 63 | | private OzonApiDataManager _ozonApiDataManager = null!; |
| | 0 | 64 | | private List<OzonClient> _ozonClients = new List<OzonClient>(); |
| | | 65 | | private CurrencyRateFetcher _currencyRateFetcher; |
| | | 66 | | private SupplierDataServices _supplierDataServices; |
| | | 67 | | private readonly IPriceHistoryDataService _priceHistoryService; |
| | | 68 | | private readonly WarehouseMappingDataServices _warehouseMappingDataServices; |
| | | 69 | | private readonly IMemoryCache _memoryCache; |
| | | 70 | | private readonly ImportProductPricesManager _importProductPricesManager; |
| | | 71 | | private readonly BitrixStockSavedFilterDataServices _savedFilterDataServices; |
| | | 72 | | private readonly OneCDataManager _oneCDataManager; |
| | | 73 | | private readonly IBitrixStockClearJobService _bitrixStockClearJobService; |
| | | 74 | | private readonly IBitrixStockClearJobRepository _bitrixStockClearJobRepository; |
| | | 75 | | private readonly IBitrixStockArchiveJobService _bitrixStockArchiveJobService; |
| | | 76 | | private readonly IBitrixStockArchiveJobRepository _bitrixStockArchiveJobRepository; |
| | | 77 | | private readonly IBitrixStockUnarchiveJobService _bitrixStockUnarchiveJobService; |
| | | 78 | | private readonly IBitrixStockUnarchiveJobRepository _bitrixStockUnarchiveJobRepository; |
| | | 79 | | private readonly IBitrixStockPushJobService _bitrixStockPushJobService; |
| | | 80 | | private readonly IBitrixStockPushJobRepository _bitrixStockPushJobRepository; |
| | | 81 | | private readonly IBitrixStockAutoScheduleService _bitrixStockAutoScheduleService; |
| | | 82 | | private readonly IBitrixStockInventoryService _bitrixStockInventoryService; |
| | | 83 | | private readonly IBitrixOzonLinkedStockApiSyncService _bitrixOzonLinkedStockApiSyncService; |
| | | 84 | | private readonly IWarehouseStockBitrixViewService _warehouseStockBitrixViewService; |
| | | 85 | | private readonly IBitrixStockSyncReportService _bitrixStockSyncReportService; |
| | | 86 | | private readonly IBitrixStockSyncReportApplyService _bitrixStockSyncReportApplyService; |
| | | 87 | | private readonly IBitrixStockSyncReportCleanupService _bitrixStockSyncReportCleanupService; |
| | | 88 | | private readonly IBitrixStockSyncReportPricePushService _bitrixStockSyncReportPricePushService; |
| | | 89 | | private readonly IBitrixStockSyncPresetService _bitrixStockSyncPresetService; |
| | | 90 | | private readonly IBitrixStockOzonOrphanCleanupService _bitrixStockOzonOrphanCleanupService; |
| | | 91 | | private readonly BitrixStockSyncApplyArticleLogService _applyArticleLogService; |
| | | 92 | | private readonly BitrixStockActiveStageStore _activeStageStore; |
| | | 93 | | private readonly IBitrixStockExceptionService _exceptionService; |
| | | 94 | | private readonly IChannelTaskService _channelTasks; |
| | | 95 | | private readonly IRecurringJobManager _recurringJobManager; |
| | | 96 | | |
| | 0 | 97 | | private record BitrixCacheEntry(RemainingStockViewModel ViewModel, DateTime? MaxTimestamp); |
| | | 98 | | private sealed class OzonPerfCounters |
| | | 99 | | { |
| | | 100 | | public int ItemsChecked; |
| | | 101 | | public int ItemStocksFound; |
| | | 102 | | public int WarehouseInfoCalls; |
| | | 103 | | public long WarehouseInfoMs; |
| | | 104 | | } |
| | | 105 | | |
| | 0 | 106 | | public BitrixStockController(BitrixStockRepository repository, |
| | 0 | 107 | | EtProducerRepository etProducerRepository, |
| | 0 | 108 | | OzonClientServices ozonClientServices, |
| | 0 | 109 | | IHttpClientFactory httpClientFactory, |
| | 0 | 110 | | IEmailService? emailService, |
| | 0 | 111 | | CurrencyRateFetcher currencyRateFetcher, |
| | 0 | 112 | | SupplierDataServices supplierDataServices, |
| | 0 | 113 | | IPriceHistoryDataService priceHistoryService, |
| | 0 | 114 | | WarehouseMappingDataServices warehouseMappingDataServices, |
| | 0 | 115 | | IMemoryCache memoryCache, |
| | 0 | 116 | | ImportProductPricesManager importProductPricesManager, |
| | 0 | 117 | | BitrixStockSavedFilterDataServices savedFilterDataServices, |
| | 0 | 118 | | OneCDataManager oneCDataManager, |
| | 0 | 119 | | IBitrixStockClearJobService bitrixStockClearJobService, |
| | 0 | 120 | | IBitrixStockClearJobRepository bitrixStockClearJobRepository, |
| | 0 | 121 | | IBitrixStockArchiveJobService bitrixStockArchiveJobService, |
| | 0 | 122 | | IBitrixStockArchiveJobRepository bitrixStockArchiveJobRepository, |
| | 0 | 123 | | IBitrixStockUnarchiveJobService bitrixStockUnarchiveJobService, |
| | 0 | 124 | | IBitrixStockUnarchiveJobRepository bitrixStockUnarchiveJobRepository, |
| | 0 | 125 | | IBitrixStockPushJobService bitrixStockPushJobService, |
| | 0 | 126 | | IBitrixStockPushJobRepository bitrixStockPushJobRepository, |
| | 0 | 127 | | IBitrixStockAutoScheduleService bitrixStockAutoScheduleService, |
| | 0 | 128 | | IBitrixStockInventoryService bitrixStockInventoryService, |
| | 0 | 129 | | IBitrixOzonLinkedStockApiSyncService bitrixOzonLinkedStockApiSyncService, |
| | 0 | 130 | | IWarehouseStockBitrixViewService warehouseStockBitrixViewService, |
| | 0 | 131 | | IBitrixStockSyncReportService bitrixStockSyncReportService, |
| | 0 | 132 | | IBitrixStockSyncReportApplyService bitrixStockSyncReportApplyService, |
| | 0 | 133 | | IBitrixStockSyncReportCleanupService bitrixStockSyncReportCleanupService, |
| | 0 | 134 | | IBitrixStockSyncReportPricePushService bitrixStockSyncReportPricePushService, |
| | 0 | 135 | | IBitrixStockSyncPresetService bitrixStockSyncPresetService, |
| | 0 | 136 | | IBitrixStockOzonOrphanCleanupService bitrixStockOzonOrphanCleanupService, |
| | 0 | 137 | | BitrixStockSyncApplyArticleLogService applyArticleLogService, |
| | 0 | 138 | | BitrixStockActiveStageStore activeStageStore, |
| | 0 | 139 | | IBitrixStockExceptionService exceptionService, |
| | 0 | 140 | | IChannelTaskService channelTasks, |
| | 0 | 141 | | IRecurringJobManager recurringJobManager) |
| | | 142 | | { |
| | 0 | 143 | | _repository = repository; |
| | 0 | 144 | | _etProducerRepository = etProducerRepository; |
| | 0 | 145 | | _ozonClientServices = ozonClientServices; |
| | 0 | 146 | | _httpClientFactory = httpClientFactory; |
| | 0 | 147 | | _emailService = emailService; |
| | 0 | 148 | | _currencyRateFetcher = currencyRateFetcher; |
| | 0 | 149 | | _supplierDataServices = supplierDataServices; |
| | 0 | 150 | | _priceHistoryService = priceHistoryService; |
| | 0 | 151 | | _warehouseMappingDataServices = warehouseMappingDataServices; |
| | 0 | 152 | | _memoryCache = memoryCache; |
| | 0 | 153 | | _importProductPricesManager = importProductPricesManager; |
| | 0 | 154 | | _savedFilterDataServices = savedFilterDataServices; |
| | 0 | 155 | | _oneCDataManager = oneCDataManager; |
| | 0 | 156 | | _bitrixStockClearJobService = bitrixStockClearJobService; |
| | 0 | 157 | | _bitrixStockClearJobRepository = bitrixStockClearJobRepository; |
| | 0 | 158 | | _bitrixStockArchiveJobService = bitrixStockArchiveJobService; |
| | 0 | 159 | | _bitrixStockArchiveJobRepository = bitrixStockArchiveJobRepository; |
| | 0 | 160 | | _bitrixStockUnarchiveJobService = bitrixStockUnarchiveJobService; |
| | 0 | 161 | | _bitrixStockUnarchiveJobRepository = bitrixStockUnarchiveJobRepository; |
| | 0 | 162 | | _bitrixStockPushJobService = bitrixStockPushJobService; |
| | 0 | 163 | | _bitrixStockPushJobRepository = bitrixStockPushJobRepository; |
| | 0 | 164 | | _bitrixStockAutoScheduleService = bitrixStockAutoScheduleService; |
| | 0 | 165 | | _bitrixStockInventoryService = bitrixStockInventoryService; |
| | 0 | 166 | | _bitrixOzonLinkedStockApiSyncService = bitrixOzonLinkedStockApiSyncService; |
| | 0 | 167 | | _warehouseStockBitrixViewService = warehouseStockBitrixViewService; |
| | 0 | 168 | | _bitrixStockSyncReportService = bitrixStockSyncReportService; |
| | 0 | 169 | | _bitrixStockSyncReportApplyService = bitrixStockSyncReportApplyService; |
| | 0 | 170 | | _bitrixStockSyncReportCleanupService = bitrixStockSyncReportCleanupService; |
| | 0 | 171 | | _bitrixStockSyncReportPricePushService = bitrixStockSyncReportPricePushService; |
| | 0 | 172 | | _bitrixStockSyncPresetService = bitrixStockSyncPresetService; |
| | 0 | 173 | | _bitrixStockOzonOrphanCleanupService = bitrixStockOzonOrphanCleanupService; |
| | 0 | 174 | | _applyArticleLogService = applyArticleLogService; |
| | 0 | 175 | | _activeStageStore = activeStageStore; |
| | 0 | 176 | | _exceptionService = exceptionService; |
| | 0 | 177 | | _channelTasks = channelTasks; |
| | 0 | 178 | | _recurringJobManager = recurringJobManager; |
| | 0 | 179 | | } |
| | | 180 | | |
| | | 181 | | public async Task<IActionResult> Index( |
| | | 182 | | [FromQuery] RemainingStockFilter filter, |
| | | 183 | | [FromQuery] string tab = "stocks", |
| | | 184 | | [FromQuery] string? syncSubTab = null) |
| | | 185 | | { |
| | 0 | 186 | | if (filter.Page <= 0) filter.Page = 1; |
| | 0 | 187 | | if (filter.PageSize <= 0) filter.PageSize = 100; |
| | 0 | 188 | | _ozonLinkageSyncReport = null; |
| | | 189 | | |
| | 0 | 190 | | if (string.Equals(tab, "tasks", StringComparison.OrdinalIgnoreCase)) |
| | | 191 | | { |
| | 0 | 192 | | return RedirectToAction(nameof(Index), new { area = "Studio2", tab = "stocks" }); |
| | | 193 | | } |
| | | 194 | | |
| | 0 | 195 | | var activeTab = string.Equals(tab, "clearHistory", StringComparison.OrdinalIgnoreCase) |
| | 0 | 196 | | ? "clearHistory" |
| | 0 | 197 | | : string.Equals(tab, "pushHistory", StringComparison.OrdinalIgnoreCase) |
| | 0 | 198 | | ? "pushHistory" |
| | 0 | 199 | | : string.Equals(tab, "sync", StringComparison.OrdinalIgnoreCase) |
| | 0 | 200 | | ? "sync" |
| | 0 | 201 | | : string.Equals(tab, "exceptions", StringComparison.OrdinalIgnoreCase) |
| | 0 | 202 | | ? "exceptions" |
| | 0 | 203 | | : "stocks"; |
| | | 204 | | |
| | | 205 | | // История запусков (обнуление/накат) и sync/exceptions не читают остатки — |
| | | 206 | | // им хватает лёгкой VM без выборки WarehouseStock. |
| | 0 | 207 | | if (activeTab is "sync" or "exceptions" or "clearHistory" or "pushHistory") |
| | | 208 | | { |
| | 0 | 209 | | var isSyncLike = activeTab is "sync" or "exceptions"; |
| | 0 | 210 | | if (isSyncLike) |
| | | 211 | | { |
| | 0 | 212 | | ViewBag.SyncSubTab = string.Equals(syncSubTab, "google-auto", StringComparison.OrdinalIgnoreCase) |
| | 0 | 213 | | ? "google-auto" |
| | 0 | 214 | | : "pipeline"; |
| | | 215 | | } |
| | | 216 | | |
| | 0 | 217 | | var ozonClients = (await _ozonClientServices.GetOzonClients()) |
| | | 218 | | .Where(c => c.ClientType == ClientType.OZON) |
| | 0 | 219 | | .ToList(); |
| | | 220 | | |
| | 0 | 221 | | var auxVm = new RemainingStockViewModel |
| | 0 | 222 | | { |
| | 0 | 223 | | Result = new PagedResult<RemainingStockBitrix> |
| | 0 | 224 | | { |
| | 0 | 225 | | Items = new List<RemainingStockBitrix>(), |
| | 0 | 226 | | Page = 1, |
| | 0 | 227 | | PageSize = filter.PageSize, |
| | 0 | 228 | | TotalCount = 0 |
| | 0 | 229 | | }, |
| | 0 | 230 | | Filter = filter, |
| | 0 | 231 | | Suppliers = new List<Supplier>(), |
| | 0 | 232 | | AllSuppliers = new List<Supplier>(), |
| | 0 | 233 | | WarehousesmMappings = await _warehouseMappingDataServices.GetWarehouseMappings(), |
| | 0 | 234 | | ActiveTab = activeTab, |
| | 0 | 235 | | OzonClients = ozonClients, |
| | 0 | 236 | | AutoSchedules = new List<BitrixStockAutoSchedule>(), |
| | 0 | 237 | | ActiveStores = await _warehouseStockBitrixViewService.GetActiveStoresAsync() |
| | 0 | 238 | | }; |
| | 0 | 239 | | if (isSyncLike) |
| | 0 | 240 | | ViewBag.ReserveStatusOptions = await _bitrixStockSyncReportService.GetReserveStatusOptionsAsync(); |
| | 0 | 241 | | return View(auxVm); |
| | | 242 | | } |
| | | 243 | | |
| | 0 | 244 | | var vm = await BuildStocksViewModelAsync(filter, loadItems: false); |
| | 0 | 245 | | return View(vm); |
| | 0 | 246 | | } |
| | | 247 | | |
| | | 248 | | /// <summary> |
| | | 249 | | /// Вкладка «Остатки»: нормализация фильтра (пресеты, склады по умолчанию, лимиты) и модель страницы. |
| | | 250 | | /// <paramref name="loadItems"/> = false — только каркас страницы (фильтры, справочники), строки грузит грид. |
| | | 251 | | /// </summary> |
| | | 252 | | private async Task<RemainingStockViewModel> BuildStocksViewModelAsync(RemainingStockFilter filter, bool loadItems) |
| | | 253 | | { |
| | 0 | 254 | | if (filter.SelectedClientId == null && |
| | 0 | 255 | | Request.Query.TryGetValue(nameof(filter.SelectedClientId), out var selectedClientRawValues)) |
| | | 256 | | { |
| | 0 | 257 | | var firstValidClientId = selectedClientRawValues |
| | | 258 | | .Select(v => v?.Trim()) |
| | | 259 | | .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v) && int.TryParse(v, out _)); |
| | 0 | 260 | | if (!string.IsNullOrWhiteSpace(firstValidClientId) && int.TryParse(firstValidClientId, out var parsedClientI |
| | | 261 | | { |
| | 0 | 262 | | filter.SelectedClientId = parsedClientId; |
| | | 263 | | } |
| | | 264 | | } |
| | | 265 | | |
| | 0 | 266 | | var hasAnyBusinessFilter = |
| | 0 | 267 | | filter.ProductId.HasValue || |
| | 0 | 268 | | !string.IsNullOrWhiteSpace(filter.Article) || |
| | 0 | 269 | | !string.IsNullOrWhiteSpace(filter.Supplier) || |
| | 0 | 270 | | !string.IsNullOrWhiteSpace(filter.PreviewText) || |
| | 0 | 271 | | !string.IsNullOrWhiteSpace(filter.Active) || |
| | 0 | 272 | | !string.IsNullOrWhiteSpace(filter.Available) || |
| | 0 | 273 | | filter.QuantityFrom.HasValue || |
| | 0 | 274 | | filter.QuantityTo.HasValue || |
| | 0 | 275 | | filter.DateFrom.HasValue || |
| | 0 | 276 | | filter.DateTo.HasValue || |
| | 0 | 277 | | filter.PriceValueFrom.HasValue || |
| | 0 | 278 | | filter.PriceValueTo.HasValue || |
| | 0 | 279 | | filter.RegistrationPriceFrom.HasValue || |
| | 0 | 280 | | filter.RegistrationPriceTo.HasValue || |
| | 0 | 281 | | filter.StoreId.HasValue || |
| | 0 | 282 | | !string.IsNullOrWhiteSpace(filter.StoreTitle) || |
| | 0 | 283 | | (filter.SelectedStoreTitles?.Any() == true) || |
| | 0 | 284 | | filter.AmountFrom.HasValue || |
| | 0 | 285 | | filter.AmountTo.HasValue || |
| | 0 | 286 | | filter.SelectedClientId.HasValue || |
| | 0 | 287 | | !string.IsNullOrWhiteSpace(filter.OzonStoreTitle) || |
| | 0 | 288 | | filter.OzonAmountFrom.HasValue || |
| | 0 | 289 | | filter.OzonAmountTo.HasValue || |
| | 0 | 290 | | filter.PriceIndexValueFrom.HasValue || |
| | 0 | 291 | | filter.PriceIndexValueTo.HasValue || |
| | 0 | 292 | | filter.MinPriceFrom.HasValue || |
| | 0 | 293 | | filter.MinPriceTo.HasValue || |
| | 0 | 294 | | filter.LoadOzonWarehouses || |
| | 0 | 295 | | filter.LoadOzonPriceIndexes || |
| | 0 | 296 | | filter.DiffOzonWarehouses || |
| | 0 | 297 | | filter.RedOzonPriceIndexes || |
| | 0 | 298 | | filter.OnlyActive || |
| | 0 | 299 | | filter.OnlyInactive; |
| | | 300 | | |
| | | 301 | | // Для WarehouseStock источника держим пагинацию всегда включенной, |
| | | 302 | | // иначе поиск при фильтрах может вытягивать слишком много строк. |
| | 0 | 303 | | filter.DisablePagination = false; |
| | 0 | 304 | | if (!Request.Query.ContainsKey(nameof(filter.PageSize))) |
| | | 305 | | { |
| | 0 | 306 | | filter.PageSize = 100; |
| | | 307 | | } |
| | 0 | 308 | | else if (filter.PageSize > 500) |
| | | 309 | | { |
| | 0 | 310 | | filter.PageSize = 500; |
| | | 311 | | } |
| | | 312 | | |
| | 0 | 313 | | var activeStores = await _warehouseStockBitrixViewService.GetActiveStoresAsync(); |
| | | 314 | | |
| | 0 | 315 | | var hasLoadOzonWarehousesParam = Request.Query.ContainsKey(nameof(filter.LoadOzonWarehouses)); |
| | 0 | 316 | | var hasDiffOzonWarehousesParam = Request.Query.ContainsKey(nameof(filter.DiffOzonWarehouses)); |
| | | 317 | | |
| | | 318 | | //Пресет фильтрации |
| | 0 | 319 | | if (filter.OnlyInactive) |
| | | 320 | | { |
| | 0 | 321 | | if (!hasLoadOzonWarehousesParam) |
| | | 322 | | { |
| | 0 | 323 | | filter.LoadOzonWarehouses = true; |
| | | 324 | | } |
| | | 325 | | |
| | 0 | 326 | | filter.Active = "N"; |
| | 0 | 327 | | filter.OzonAmountFrom = 1; |
| | | 328 | | } |
| | | 329 | | |
| | 0 | 330 | | if (filter.OnlyActive) |
| | | 331 | | { |
| | 0 | 332 | | if (!hasLoadOzonWarehousesParam) |
| | | 333 | | { |
| | 0 | 334 | | filter.LoadOzonWarehouses = true; |
| | | 335 | | } |
| | | 336 | | |
| | 0 | 337 | | filter.Active = "Y"; |
| | | 338 | | } |
| | | 339 | | |
| | | 340 | | const string storesFilterAppliedKey = "StoresFilterApplied"; |
| | 0 | 341 | | var hasSelectedStoreTitlesKey = Request.Query.ContainsKey(nameof(filter.SelectedStoreTitles)); |
| | 0 | 342 | | var storesFilterApplied = Request.Query.ContainsKey(storesFilterAppliedKey); |
| | | 343 | | |
| | 0 | 344 | | if (!hasSelectedStoreTitlesKey) |
| | | 345 | | { |
| | 0 | 346 | | if (storesFilterApplied) |
| | | 347 | | { |
| | 0 | 348 | | filter.SelectedStoreTitles = new List<string>(); |
| | | 349 | | } |
| | 0 | 350 | | else if (!string.IsNullOrWhiteSpace(filter.StoreTitle)) |
| | | 351 | | { |
| | 0 | 352 | | filter.SelectedStoreTitles = new List<string> { filter.StoreTitle }; |
| | | 353 | | } |
| | 0 | 354 | | else if (IsBareBitrixStockPageLoad()) |
| | | 355 | | { |
| | 0 | 356 | | filter.SelectedStoreTitles = BitrixStockFilterNormalizer.DefaultStoreTitlesFor(activeStores).ToList(); |
| | | 357 | | } |
| | | 358 | | } |
| | | 359 | | |
| | 0 | 360 | | if (IsWideOzonBrowsing(filter) && filter.PageSize > MaxArticlesForOzonStocksLoad) |
| | | 361 | | { |
| | 0 | 362 | | filter.PageSize = MaxArticlesForOzonStocksLoad; |
| | 0 | 363 | | ViewData["PerformanceNotice"] = |
| | 0 | 364 | | "Для ускорения загрузки в режиме Ozon без поиска размер страницы ограничен 500 записями."; |
| | | 365 | | } |
| | | 366 | | |
| | 0 | 367 | | await SetClients(); |
| | 0 | 368 | | if (filter.SelectedClientId != null) |
| | | 369 | | { |
| | 0 | 370 | | _ozonClients = _ozonClients.Where(oz => oz.Id == filter.SelectedClientId.Value).ToList(); |
| | | 371 | | } |
| | | 372 | | |
| | | 373 | | // Страница рисует только каркас: строки грузит грид (stocks-grid) тем же конвейером. |
| | 0 | 374 | | var pagedResult = loadItems |
| | 0 | 375 | | ? await _warehouseStockBitrixViewService.GetRemainingStockAsync(filter) |
| | 0 | 376 | | : new PagedResult<RemainingStockBitrix> { Items = new List<RemainingStockBitrix>(), Page = filter.Page, Page |
| | | 377 | | |
| | 0 | 378 | | var filteredItems = loadItems |
| | 0 | 379 | | ? await ApplyFiltersAsync(pagedResult.Items, filter) |
| | 0 | 380 | | : new List<RemainingStockBitrix>(); |
| | | 381 | | |
| | 0 | 382 | | var useSingleResultPage = filter.DisablePagination && HasNarrowingSearch(filter); |
| | 0 | 383 | | var result = new PagedResult<RemainingStockBitrix> |
| | 0 | 384 | | { |
| | 0 | 385 | | Items = filteredItems, |
| | 0 | 386 | | Page = useSingleResultPage ? 1 : filter.Page, |
| | 0 | 387 | | PageSize = useSingleResultPage |
| | 0 | 388 | | ? Math.Max(filteredItems.Count, 1) |
| | 0 | 389 | | : filter.PageSize, |
| | 0 | 390 | | TotalCount = useSingleResultPage |
| | 0 | 391 | | ? filteredItems.Count |
| | 0 | 392 | | : pagedResult.TotalCount |
| | 0 | 393 | | }; |
| | | 394 | | |
| | 0 | 395 | | if (_ozonLoadWarnings.Count > 0) |
| | | 396 | | { |
| | 0 | 397 | | var ozonWarning = string.Join(" ", _ozonLoadWarnings); |
| | 0 | 398 | | ViewData["PerformanceNotice"] = ViewData["PerformanceNotice"] is string existing && !string.IsNullOrWhiteSpa |
| | 0 | 399 | | ? $"{existing} {ozonWarning}" |
| | 0 | 400 | | : ozonWarning; |
| | | 401 | | } |
| | | 402 | | |
| | 0 | 403 | | if (_ozonLinkageSyncReport is { Attempted: > 0 }) |
| | | 404 | | { |
| | 0 | 405 | | ViewData["OzonLinkageSyncReport"] = _ozonLinkageSyncReport; |
| | | 406 | | } |
| | | 407 | | |
| | 0 | 408 | | var vm = new RemainingStockViewModel |
| | 0 | 409 | | { |
| | 0 | 410 | | Result = result, |
| | 0 | 411 | | Filter = filter, |
| | 0 | 412 | | Suppliers = await _supplierDataServices.GetSuppliersWithUrlAsync(), |
| | 0 | 413 | | AllSuppliers = await _supplierDataServices.GetSuppliers(), |
| | 0 | 414 | | WarehousesmMappings = await _warehouseMappingDataServices.GetWarehouseMappings(), |
| | 0 | 415 | | ActiveStores = activeStores, |
| | 0 | 416 | | RateEUR = await _currencyRateFetcher.GetEURRateAsync(), |
| | 0 | 417 | | RateUSD = await _currencyRateFetcher.GetUSDRateAsync(), |
| | 0 | 418 | | RateBYN = await _currencyRateFetcher.GetBYNRateAsync(), |
| | 0 | 419 | | OzonClients = _ozonClients, |
| | 0 | 420 | | SelectedClient = _ozonClients.FirstOrDefault(c => c.Id == filter.SelectedClientId), |
| | 0 | 421 | | SavedFilters = await _savedFilterDataServices.GetSavedFilters(), |
| | 0 | 422 | | ActiveTab = "stocks" |
| | 0 | 423 | | }; |
| | 0 | 424 | | ViewBag.ReserveStatusOptions = await _bitrixStockSyncReportService.GetReserveStatusOptionsAsync(); |
| | 0 | 425 | | return vm; |
| | 0 | 426 | | } |
| | | 427 | | |
| | | 428 | | |
| | | 429 | | [HttpGet("/Studio2/BitrixStock/sync/google-automation/status")] |
| | | 430 | | public IActionResult GetGoogleSyncAutomationStatus() |
| | | 431 | | { |
| | 0 | 432 | | using var connection = JobStorage.Current.GetConnection(); |
| | 0 | 433 | | var recurring = connection.GetRecurringJobs().FirstOrDefault(x => x.Id == GoogleSyncRecurringJobId); |
| | 0 | 434 | | var hours = ParseHoursFromCron(recurring?.Cron); |
| | | 435 | | |
| | 0 | 436 | | return Ok(new |
| | 0 | 437 | | { |
| | 0 | 438 | | success = true, |
| | 0 | 439 | | isEnabled = recurring != null, |
| | 0 | 440 | | intervalHours = hours ?? 6, |
| | 0 | 441 | | cronExpression = recurring?.Cron, |
| | 0 | 442 | | lastExecution = recurring?.LastExecution, |
| | 0 | 443 | | nextExecution = recurring?.NextExecution, |
| | 0 | 444 | | lastJobState = recurring?.LastJobState |
| | 0 | 445 | | }); |
| | 0 | 446 | | } |
| | | 447 | | |
| | | 448 | | [HttpPost("/Studio2/BitrixStock/sync/google-automation/save")] |
| | | 449 | | [IgnoreAntiforgeryToken] |
| | | 450 | | public async Task<IActionResult> SaveGoogleSyncAutomation([FromBody] GoogleSyncAutomationRequest? request) |
| | | 451 | | { |
| | 0 | 452 | | if (request is null) |
| | 0 | 453 | | return BadRequest(new { success = false, error = "Не передано тело запроса." }); |
| | 0 | 454 | | if (request.IntervalHours < 1 || request.IntervalHours > 24) |
| | | 455 | | { |
| | 0 | 456 | | return BadRequest(new { success = false, error = "Интервал должен быть от 1 до 24 часов." }); |
| | | 457 | | } |
| | | 458 | | |
| | | 459 | | try |
| | | 460 | | { |
| | 0 | 461 | | if (!request.IsEnabled) |
| | | 462 | | { |
| | 0 | 463 | | _recurringJobManager.RemoveIfExists(GoogleSyncRecurringJobId); |
| | 0 | 464 | | return Ok(new { success = true }); |
| | | 465 | | } |
| | | 466 | | |
| | 0 | 467 | | var cron = BuildHoursCron(request.IntervalHours); |
| | 0 | 468 | | await RetryHangfireLockWaitTimeoutAsync(() => |
| | 0 | 469 | | { |
| | 0 | 470 | | _recurringJobManager.AddOrUpdate<WarehouseStockSyncJob>( |
| | 0 | 471 | | GoogleSyncRecurringJobId, |
| | 0 | 472 | | x => x.ExecuteAsync(GoogleSyncTriggeredBy), |
| | 0 | 473 | | cron, |
| | 0 | 474 | | new RecurringJobOptions |
| | 0 | 475 | | { |
| | 0 | 476 | | TimeZone = RussianTimeZoneHelper.Zone |
| | 0 | 477 | | }); |
| | 0 | 478 | | }); |
| | 0 | 479 | | } |
| | 0 | 480 | | catch (Exception ex) |
| | | 481 | | { |
| | 0 | 482 | | return StatusCode(503, new |
| | 0 | 483 | | { |
| | 0 | 484 | | success = false, |
| | 0 | 485 | | error = $"Hangfire временно занят: {ex.Message}" |
| | 0 | 486 | | }); |
| | | 487 | | } |
| | | 488 | | |
| | 0 | 489 | | return Ok(new { success = true }); |
| | 0 | 490 | | } |
| | | 491 | | |
| | | 492 | | [HttpGet("/Studio2/BitrixStock/sync/google-automation/history")] |
| | | 493 | | public IActionResult GetGoogleSyncAutomationHistory([FromQuery] int take = 20) |
| | | 494 | | { |
| | 0 | 495 | | take = Math.Clamp(take, 1, 100); |
| | 0 | 496 | | var api = JobStorage.Current.GetMonitoringApi(); |
| | 0 | 497 | | var rows = new List<GoogleSyncHistoryRow>(); |
| | | 498 | | |
| | 0 | 499 | | var succeeded = api.SucceededJobs(0, 200) |
| | 0 | 500 | | .Where(x => IsGoogleAutoJob(x.Value?.Job?.Method?.DeclaringType?.Name, x.Value?.Job?.Method?.Name, x.Value?. |
| | 0 | 501 | | .Select(x => new GoogleSyncHistoryRow |
| | 0 | 502 | | { |
| | 0 | 503 | | JobId = x.Key, |
| | 0 | 504 | | Status = "Succeeded", |
| | 0 | 505 | | StartedAt = x.Value?.SucceededAt?.AddMilliseconds(-(x.Value?.TotalDuration ?? 0)), |
| | 0 | 506 | | FinishedAt = x.Value?.SucceededAt, |
| | 0 | 507 | | DurationMs = x.Value?.TotalDuration, |
| | 0 | 508 | | Error = (string?)null |
| | 0 | 509 | | }); |
| | | 510 | | |
| | 0 | 511 | | var failed = api.FailedJobs(0, 200) |
| | 0 | 512 | | .Where(x => IsGoogleAutoJob(x.Value?.Job?.Method?.DeclaringType?.Name, x.Value?.Job?.Method?.Name, x.Value?. |
| | 0 | 513 | | .Select(x => new GoogleSyncHistoryRow |
| | 0 | 514 | | { |
| | 0 | 515 | | JobId = x.Key, |
| | 0 | 516 | | Status = "Failed", |
| | 0 | 517 | | StartedAt = x.Value?.FailedAt, |
| | 0 | 518 | | FinishedAt = x.Value?.FailedAt, |
| | 0 | 519 | | DurationMs = (long?)null, |
| | 0 | 520 | | Error = x.Value?.ExceptionMessage |
| | 0 | 521 | | }); |
| | | 522 | | |
| | 0 | 523 | | var processing = api.ProcessingJobs(0, 50) |
| | 0 | 524 | | .Where(x => IsGoogleAutoJob(x.Value?.Job?.Method?.DeclaringType?.Name, x.Value?.Job?.Method?.Name, x.Value?. |
| | 0 | 525 | | .Select(x => new GoogleSyncHistoryRow |
| | 0 | 526 | | { |
| | 0 | 527 | | JobId = x.Key, |
| | 0 | 528 | | Status = "Processing", |
| | 0 | 529 | | StartedAt = x.Value?.StartedAt, |
| | 0 | 530 | | FinishedAt = (DateTime?)null, |
| | 0 | 531 | | DurationMs = (long?)null, |
| | 0 | 532 | | Error = (string?)null |
| | 0 | 533 | | }); |
| | | 534 | | |
| | 0 | 535 | | rows.AddRange(succeeded.OrderByDescending(x => x.FinishedAt ?? x.StartedAt).Take(take)); |
| | 0 | 536 | | rows.AddRange(failed.OrderByDescending(x => x.FinishedAt ?? x.StartedAt).Take(take)); |
| | 0 | 537 | | rows.AddRange(processing.OrderByDescending(x => x.StartedAt).Take(take)); |
| | | 538 | | |
| | 0 | 539 | | var result = rows |
| | 0 | 540 | | .OrderByDescending(x => x.FinishedAt ?? x.StartedAt) |
| | 0 | 541 | | .Take(take) |
| | 0 | 542 | | .ToList(); |
| | | 543 | | |
| | 0 | 544 | | return Ok(new { success = true, items = result }); |
| | | 545 | | } |
| | | 546 | | |
| | | 547 | | [HttpPost("/Studio2/BitrixStock/sync/google-automation/trigger")] |
| | | 548 | | [IgnoreAntiforgeryToken] |
| | | 549 | | public IActionResult TriggerGoogleSyncAutomationNow() |
| | | 550 | | { |
| | 0 | 551 | | BackgroundJob.Enqueue<WarehouseStockSyncJob>(x => x.ExecuteAsync(GoogleSyncTriggeredBy)); |
| | 0 | 552 | | return Ok(new { success = true }); |
| | | 553 | | } |
| | | 554 | | |
| | | 555 | | private static bool IsGoogleAutoJob(string? typeName, string? methodName, IReadOnlyList<object>? args) |
| | | 556 | | { |
| | 0 | 557 | | if (!string.Equals(typeName, nameof(WarehouseStockSyncJob), StringComparison.Ordinal) || |
| | 0 | 558 | | !string.Equals(methodName, nameof(WarehouseStockSyncJob.ExecuteAsync), StringComparison.Ordinal)) |
| | | 559 | | { |
| | 0 | 560 | | return false; |
| | | 561 | | } |
| | | 562 | | |
| | 0 | 563 | | return args?.Any(a => string.Equals(a?.ToString(), GoogleSyncTriggeredBy, StringComparison.Ordinal)) == true; |
| | | 564 | | } |
| | | 565 | | |
| | | 566 | | private static string BuildHoursCron(int hours) => |
| | 0 | 567 | | hours >= 24 ? "0 0 * * *" : $"0 */{hours} * * *"; |
| | | 568 | | |
| | | 569 | | private static int? ParseHoursFromCron(string? cron) |
| | | 570 | | { |
| | 0 | 571 | | if (string.IsNullOrWhiteSpace(cron)) |
| | 0 | 572 | | return null; |
| | | 573 | | |
| | 0 | 574 | | if (string.Equals(cron.Trim(), "0 0 * * *", StringComparison.Ordinal)) |
| | 0 | 575 | | return 24; |
| | | 576 | | |
| | | 577 | | const string marker = "0 */"; |
| | 0 | 578 | | if (!cron.StartsWith(marker, StringComparison.Ordinal)) |
| | 0 | 579 | | return null; |
| | | 580 | | |
| | 0 | 581 | | var parts = cron.Split(' ', StringSplitOptions.RemoveEmptyEntries); |
| | 0 | 582 | | if (parts.Length < 2 || !parts[1].StartsWith("*/", StringComparison.Ordinal)) |
| | 0 | 583 | | return null; |
| | | 584 | | |
| | 0 | 585 | | return int.TryParse(parts[1].Substring(2), out var h) ? h : null; |
| | | 586 | | } |
| | | 587 | | |
| | | 588 | | private static async Task RetryHangfireLockWaitTimeoutAsync(Action action) |
| | | 589 | | { |
| | | 590 | | const int maxAttempts = 3; |
| | 0 | 591 | | for (var attempt = 1; attempt <= maxAttempts; attempt++) |
| | | 592 | | { |
| | | 593 | | try |
| | | 594 | | { |
| | 0 | 595 | | action(); |
| | 0 | 596 | | return; |
| | | 597 | | } |
| | 0 | 598 | | catch (Exception ex) when (IsLockWaitTimeout(ex) && attempt < maxAttempts) |
| | | 599 | | { |
| | 0 | 600 | | await Task.Delay(200 * attempt); |
| | | 601 | | } |
| | | 602 | | } |
| | 0 | 603 | | } |
| | | 604 | | |
| | | 605 | | private static bool IsLockWaitTimeout(Exception ex) => |
| | 0 | 606 | | ex.Message.Contains("Lock wait timeout exceeded", StringComparison.OrdinalIgnoreCase); |
| | | 607 | | |
| | | 608 | | public sealed class GoogleSyncAutomationRequest |
| | | 609 | | { |
| | 0 | 610 | | public bool IsEnabled { get; set; } |
| | 0 | 611 | | public int IntervalHours { get; set; } = 6; |
| | | 612 | | } |
| | | 613 | | |
| | | 614 | | private sealed class GoogleSyncHistoryRow |
| | | 615 | | { |
| | 0 | 616 | | public string JobId { get; set; } = string.Empty; |
| | 0 | 617 | | public string Status { get; set; } = string.Empty; |
| | 0 | 618 | | public DateTime? StartedAt { get; set; } |
| | 0 | 619 | | public DateTime? FinishedAt { get; set; } |
| | 0 | 620 | | public long? DurationMs { get; set; } |
| | 0 | 621 | | public string? Error { get; set; } |
| | | 622 | | } |
| | | 623 | | |
| | | 624 | | [HttpPost("SaveFilter")] |
| | | 625 | | [IgnoreAntiforgeryToken] |
| | | 626 | | public async Task<IActionResult> SaveFilter([FromBody] SaveFilterRequest request) |
| | | 627 | | { |
| | 0 | 628 | | if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.FilterData)) |
| | | 629 | | { |
| | 0 | 630 | | return BadRequest(new { success = false, message = "Имя и данные фильтра обязательны" }); |
| | | 631 | | } |
| | | 632 | | |
| | | 633 | | try |
| | | 634 | | { |
| | 0 | 635 | | var savedFilter = new BitrixStockSavedFilter |
| | 0 | 636 | | { |
| | 0 | 637 | | Name = request.Name, |
| | 0 | 638 | | FilterData = request.FilterData, |
| | 0 | 639 | | CreatedBy = User.Identity?.Name |
| | 0 | 640 | | }; |
| | | 641 | | |
| | 0 | 642 | | await _savedFilterDataServices.AddSavedFilter(savedFilter); |
| | | 643 | | |
| | 0 | 644 | | return Ok(new { success = true, message = "Фильтр успешно сохранен" }); |
| | | 645 | | } |
| | 0 | 646 | | catch (Exception ex) |
| | | 647 | | { |
| | 0 | 648 | | return StatusCode(500, new { success = false, message = $"Ошибка при сохранении фильтра: {ex.Message}" }); |
| | | 649 | | } |
| | 0 | 650 | | } |
| | | 651 | | |
| | | 652 | | [HttpPost("DeleteFilter")] |
| | | 653 | | [IgnoreAntiforgeryToken] |
| | | 654 | | public async Task<IActionResult> DeleteFilter(int id) |
| | | 655 | | { |
| | | 656 | | try |
| | | 657 | | { |
| | 0 | 658 | | var filter = await _savedFilterDataServices.GetSavedFilterAsync(id); |
| | 0 | 659 | | if (filter == null) |
| | | 660 | | { |
| | 0 | 661 | | return NotFound(new { success = false, message = "Фильтр не найден" }); |
| | | 662 | | } |
| | | 663 | | |
| | 0 | 664 | | await _savedFilterDataServices.DeleteSavedFilter(filter); |
| | 0 | 665 | | return Ok(new { success = true, message = "Фильтр успешно удален" }); |
| | | 666 | | } |
| | 0 | 667 | | catch (Exception ex) |
| | | 668 | | { |
| | 0 | 669 | | return StatusCode(500, new { success = false, message = $"Ошибка при удалении фильтра: {ex.Message}" }); |
| | | 670 | | } |
| | 0 | 671 | | } |
| | | 672 | | |
| | | 673 | | [HttpGet("GetFilter")] |
| | | 674 | | public async Task<IActionResult> GetFilter(int id) |
| | | 675 | | { |
| | | 676 | | try |
| | | 677 | | { |
| | 0 | 678 | | var filter = await _savedFilterDataServices.GetSavedFilterAsync(id); |
| | 0 | 679 | | if (filter == null) |
| | | 680 | | { |
| | 0 | 681 | | return NotFound(new { success = false, message = "Фильтр не найден" }); |
| | | 682 | | } |
| | | 683 | | |
| | 0 | 684 | | return Ok(new { success = true, filterData = filter.FilterData }); |
| | | 685 | | } |
| | 0 | 686 | | catch (Exception ex) |
| | | 687 | | { |
| | 0 | 688 | | return StatusCode(500, new { success = false, message = $"Ошибка при загрузке фильтра: {ex.Message}" }); |
| | | 689 | | } |
| | 0 | 690 | | } |
| | | 691 | | |
| | | 692 | | public class SaveFilterRequest |
| | | 693 | | { |
| | 0 | 694 | | public string Name { get; set; } = string.Empty; |
| | 0 | 695 | | public string FilterData { get; set; } = string.Empty; |
| | | 696 | | } |
| | | 697 | | |
| | | 698 | | public class EnsureWarehouseMappingsRequest |
| | | 699 | | { |
| | 0 | 700 | | public string ClientName { get; set; } = string.Empty; |
| | 0 | 701 | | public List<string> BitrixWarehouseNames { get; set; } = new(); |
| | | 702 | | } |
| | | 703 | | |
| | | 704 | | public class GetOneCStocksBatchRequest |
| | | 705 | | { |
| | 0 | 706 | | public List<string> Articles { get; set; } = new(); |
| | | 707 | | } |
| | | 708 | | |
| | | 709 | | [HttpPost("GetOneCStocksByArticles")] |
| | | 710 | | [IgnoreAntiforgeryToken] |
| | | 711 | | public async Task<IActionResult> GetOneCStocksByArticles([FromBody] JsonElement body) |
| | | 712 | | { |
| | 0 | 713 | | var articles = ParseOneCStockArticles(body); |
| | 0 | 714 | | if (articles.Count == 0) |
| | | 715 | | { |
| | 0 | 716 | | return BadRequest(new { success = false, error = "Не переданы артикулы (ожидается {\"Articles\":[\"...\"]} и |
| | | 717 | | } |
| | | 718 | | |
| | 0 | 719 | | if (articles.Count > 100) |
| | | 720 | | { |
| | 0 | 721 | | return BadRequest(new { success = false, error = "Максимум 100 артикулов в одном запросе" }); |
| | | 722 | | } |
| | | 723 | | |
| | | 724 | | try |
| | | 725 | | { |
| | 0 | 726 | | var response = await _oneCDataManager.GetStockByArticlesAsync(articles); |
| | 0 | 727 | | return Ok(response); |
| | | 728 | | } |
| | 0 | 729 | | catch (Exception ex) |
| | | 730 | | { |
| | 0 | 731 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 732 | | } |
| | 0 | 733 | | } |
| | | 734 | | |
| | | 735 | | private static List<string> ParseOneCStockArticles(JsonElement body) |
| | | 736 | | { |
| | | 737 | | static List<string> FromArray(JsonElement array) => |
| | 0 | 738 | | array.EnumerateArray() |
| | 0 | 739 | | .Where(e => e.ValueKind == JsonValueKind.String) |
| | 0 | 740 | | .Select(e => e.GetString()?.Trim()) |
| | 0 | 741 | | .Where(s => !string.IsNullOrWhiteSpace(s)) |
| | 0 | 742 | | .Select(s => s!) |
| | 0 | 743 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 744 | | .ToList(); |
| | | 745 | | |
| | 0 | 746 | | if (body.ValueKind == JsonValueKind.Array) |
| | | 747 | | { |
| | 0 | 748 | | return FromArray(body); |
| | | 749 | | } |
| | | 750 | | |
| | 0 | 751 | | if (body.ValueKind != JsonValueKind.Object) |
| | | 752 | | { |
| | 0 | 753 | | return new List<string>(); |
| | | 754 | | } |
| | | 755 | | |
| | 0 | 756 | | foreach (var propName in new[] { "Articles", "articles" }) |
| | | 757 | | { |
| | 0 | 758 | | if (body.TryGetProperty(propName, out var articlesProp) && |
| | 0 | 759 | | articlesProp.ValueKind == JsonValueKind.Array) |
| | | 760 | | { |
| | 0 | 761 | | return FromArray(articlesProp); |
| | | 762 | | } |
| | | 763 | | } |
| | | 764 | | |
| | 0 | 765 | | return new List<string>(); |
| | | 766 | | } |
| | | 767 | | |
| | | 768 | | [HttpPost("GetOzonProductArchiveByOfferIds")] |
| | | 769 | | [IgnoreAntiforgeryToken] |
| | | 770 | | public async Task<IActionResult> GetOzonProductArchiveByOfferIds([FromBody] JsonElement body) |
| | | 771 | | { |
| | 0 | 772 | | var offerIds = ParseOzonArchiveOfferIds(body); |
| | 0 | 773 | | if (offerIds.Count == 0) |
| | | 774 | | { |
| | 0 | 775 | | return BadRequest(new |
| | 0 | 776 | | { |
| | 0 | 777 | | success = false, |
| | 0 | 778 | | error = "Не переданы offer_id (ожидается {\"OfferIds\":[\"артикул=префикс\"]} или массив строк)" |
| | 0 | 779 | | }); |
| | | 780 | | } |
| | | 781 | | |
| | 0 | 782 | | if (offerIds.Count > MaxOfferIdsForOzonArchiveBatch) |
| | | 783 | | { |
| | 0 | 784 | | return BadRequest(new |
| | 0 | 785 | | { |
| | 0 | 786 | | success = false, |
| | 0 | 787 | | error = $"Максимум {MaxOfferIdsForOzonArchiveBatch} offer_id в одном запросе" |
| | 0 | 788 | | }); |
| | | 789 | | } |
| | | 790 | | |
| | 0 | 791 | | int? clientId = null; |
| | 0 | 792 | | if (body.ValueKind == JsonValueKind.Object) |
| | | 793 | | { |
| | 0 | 794 | | foreach (var propName in new[] { "ClientId", "clientId", "SelectedClientId", "selectedClientId" }) |
| | | 795 | | { |
| | 0 | 796 | | if (body.TryGetProperty(propName, out var clientProp) && |
| | 0 | 797 | | clientProp.ValueKind == JsonValueKind.Number && |
| | 0 | 798 | | clientProp.TryGetInt32(out var parsed) && |
| | 0 | 799 | | parsed > 0) |
| | | 800 | | { |
| | 0 | 801 | | clientId = parsed; |
| | 0 | 802 | | break; |
| | | 803 | | } |
| | | 804 | | } |
| | | 805 | | } |
| | | 806 | | |
| | | 807 | | try |
| | | 808 | | { |
| | 0 | 809 | | await SetClients(); |
| | 0 | 810 | | var items = await LoadOzonProductArchiveStatusesAsync(offerIds, clientId); |
| | 0 | 811 | | return Ok(new { success = true, items }); |
| | | 812 | | } |
| | 0 | 813 | | catch (Exception ex) |
| | | 814 | | { |
| | 0 | 815 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 816 | | } |
| | 0 | 817 | | } |
| | | 818 | | |
| | | 819 | | private static List<string> ParseOzonArchiveOfferIds(JsonElement body) |
| | | 820 | | { |
| | | 821 | | static List<string> FromArray(JsonElement array) => |
| | 0 | 822 | | array.EnumerateArray() |
| | 0 | 823 | | .Where(e => e.ValueKind == JsonValueKind.String) |
| | 0 | 824 | | .Select(e => e.GetString()?.Trim()) |
| | 0 | 825 | | .Where(s => !string.IsNullOrWhiteSpace(s)) |
| | 0 | 826 | | .Select(s => s!) |
| | 0 | 827 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 828 | | .ToList(); |
| | | 829 | | |
| | 0 | 830 | | if (body.ValueKind == JsonValueKind.Array) |
| | | 831 | | { |
| | 0 | 832 | | return FromArray(body); |
| | | 833 | | } |
| | | 834 | | |
| | 0 | 835 | | if (body.ValueKind != JsonValueKind.Object) |
| | | 836 | | { |
| | 0 | 837 | | return new List<string>(); |
| | | 838 | | } |
| | | 839 | | |
| | 0 | 840 | | foreach (var propName in new[] { "OfferIds", "offerIds", "Articles", "articles" }) |
| | | 841 | | { |
| | 0 | 842 | | if (body.TryGetProperty(propName, out var offerProp) && |
| | 0 | 843 | | offerProp.ValueKind == JsonValueKind.Array) |
| | | 844 | | { |
| | 0 | 845 | | return FromArray(offerProp); |
| | | 846 | | } |
| | | 847 | | } |
| | | 848 | | |
| | 0 | 849 | | return new List<string>(); |
| | | 850 | | } |
| | | 851 | | |
| | | 852 | | private async Task<List<OzonProductArchiveStatus>> LoadOzonProductArchiveStatusesAsync( |
| | | 853 | | IReadOnlyList<string> offerIds, |
| | | 854 | | int? selectedClientId) |
| | | 855 | | { |
| | 0 | 856 | | var clients = _ozonClients; |
| | 0 | 857 | | if (selectedClientId.HasValue) |
| | | 858 | | { |
| | 0 | 859 | | clients = clients |
| | 0 | 860 | | .Where(c => c.Id == selectedClientId.Value) |
| | 0 | 861 | | .ToList(); |
| | | 862 | | } |
| | | 863 | | |
| | 0 | 864 | | if (clients.Count == 0) |
| | | 865 | | { |
| | 0 | 866 | | return new List<OzonProductArchiveStatus>(); |
| | | 867 | | } |
| | | 868 | | |
| | 0 | 869 | | var results = new ConcurrentBag<OzonProductArchiveStatus>(); |
| | 0 | 870 | | var tasks = clients.Select(async client => |
| | 0 | 871 | | { |
| | 0 | 872 | | if (!TryGetOzonCredentials(client, out var clientId, out var apiKey)) |
| | 0 | 873 | | return; |
| | 0 | 874 | | |
| | 0 | 875 | | var mgr = CreateOzonApiDataManager(clientId, apiKey); |
| | 0 | 876 | | |
| | 0 | 877 | | foreach (var chunk in offerIds.Chunk(OzonProductInfoListChunkSize)) |
| | 0 | 878 | | { |
| | 0 | 879 | | try |
| | 0 | 880 | | { |
| | 0 | 881 | | var response = await mgr.GetProductInfoListAsync(chunk.ToArray()); |
| | 0 | 882 | | if (response?["items"] is not JArray items) |
| | 0 | 883 | | { |
| | 0 | 884 | | continue; |
| | 0 | 885 | | } |
| | 0 | 886 | | |
| | 0 | 887 | | foreach (var item in items) |
| | 0 | 888 | | { |
| | 0 | 889 | | var offerId = item["offer_id"]?.ToString(); |
| | 0 | 890 | | if (string.IsNullOrWhiteSpace(offerId)) |
| | 0 | 891 | | { |
| | 0 | 892 | | continue; |
| | 0 | 893 | | } |
| | 0 | 894 | | |
| | 0 | 895 | | var ozonProductId = item["id"]?.Value<long>() ?? 0; |
| | 0 | 896 | | results.Add(new OzonProductArchiveStatus |
| | 0 | 897 | | { |
| | 0 | 898 | | ClientName = client.Name, |
| | 0 | 899 | | OfferId = offerId, |
| | 0 | 900 | | OzonProductId = ozonProductId, |
| | 0 | 901 | | IsArchived = item["is_archived"]?.Value<bool>() ?? false, |
| | 0 | 902 | | IsAutoArchived = item["is_autoarchived"]?.Value<bool>() ?? false |
| | 0 | 903 | | }); |
| | 0 | 904 | | } |
| | 0 | 905 | | } |
| | 0 | 906 | | catch |
| | 0 | 907 | | { |
| | 0 | 908 | | // пропускаем сбой клиента/чанка — ячейка покажет «—» |
| | 0 | 909 | | } |
| | 0 | 910 | | } |
| | 0 | 911 | | }); |
| | | 912 | | |
| | 0 | 913 | | await Task.WhenAll(tasks); |
| | 0 | 914 | | return results.ToList(); |
| | 0 | 915 | | } |
| | | 916 | | |
| | | 917 | | [HttpPost("clear-jobs/start")] |
| | | 918 | | [IgnoreAntiforgeryToken] |
| | | 919 | | public async Task<IActionResult> StartClearJob([FromBody] BitrixStockClearStartRequest request) |
| | | 920 | | { |
| | 0 | 921 | | if (request == null || string.IsNullOrWhiteSpace(request.JobId)) |
| | | 922 | | { |
| | 0 | 923 | | return BadRequest(new { success = false, error = "jobId обязателен" }); |
| | | 924 | | } |
| | | 925 | | |
| | 0 | 926 | | if (request.Items == null || request.Items.Count == 0) |
| | | 927 | | { |
| | 0 | 928 | | return BadRequest(new { success = false, error = "Не переданы позиции для обнуления" }); |
| | | 929 | | } |
| | | 930 | | |
| | 0 | 931 | | if (request.Items.Count > 500) |
| | | 932 | | { |
| | 0 | 933 | | return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" }); |
| | | 934 | | } |
| | | 935 | | |
| | 0 | 936 | | foreach (var item in request.Items) |
| | | 937 | | { |
| | 0 | 938 | | if (string.IsNullOrWhiteSpace(item.OzonClient) || |
| | 0 | 939 | | string.IsNullOrWhiteSpace(item.OfferId) || |
| | 0 | 940 | | item.ProductId <= 0 || |
| | 0 | 941 | | item.WarehouseId <= 0) |
| | | 942 | | { |
| | 0 | 943 | | return BadRequest(new { success = false, error = "Некорректные данные позиции (клиент, offer, product, с |
| | | 944 | | } |
| | | 945 | | } |
| | | 946 | | |
| | 0 | 947 | | var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); |
| | 0 | 948 | | var userName = User.Identity?.Name; |
| | | 949 | | |
| | 0 | 950 | | var execution = await _bitrixStockClearJobService.CreateExecutionAsync( |
| | 0 | 951 | | request.JobId.Trim(), |
| | 0 | 952 | | request.Items, |
| | 0 | 953 | | userId, |
| | 0 | 954 | | userName); |
| | | 955 | | |
| | 0 | 956 | | var hangfireJob = Job.FromExpression<IBitrixStockClearJobService>(service => |
| | 0 | 957 | | service.ProcessClearAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None)); |
| | 0 | 958 | | var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQueue |
| | | 959 | | |
| | 0 | 960 | | await _bitrixStockClearJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId); |
| | | 961 | | |
| | 0 | 962 | | return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id }); |
| | 0 | 963 | | } |
| | | 964 | | |
| | | 965 | | [HttpGet("clear-jobs")] |
| | | 966 | | public async Task<IActionResult> GetClearJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20) |
| | | 967 | | { |
| | 0 | 968 | | var (items, total) = await _bitrixStockClearJobService.GetExecutionsPageAsync(page, pageSize); |
| | 0 | 969 | | return Ok(new |
| | 0 | 970 | | { |
| | 0 | 971 | | success = true, |
| | 0 | 972 | | page, |
| | 0 | 973 | | pageSize, |
| | 0 | 974 | | total, |
| | | 975 | | items = items.Select(e => new |
| | | 976 | | { |
| | | 977 | | e.Id, |
| | | 978 | | e.JobId, |
| | | 979 | | e.Status, |
| | | 980 | | e.TriggerType, |
| | | 981 | | e.StartedAt, |
| | | 982 | | e.CompletedAt, |
| | | 983 | | e.DurationMs, |
| | | 984 | | e.TotalItems, |
| | | 985 | | e.SuccessCount, |
| | | 986 | | e.FailedCount, |
| | | 987 | | e.TriggeredByUserName, |
| | | 988 | | e.ErrorMessage |
| | | 989 | | }) |
| | 0 | 990 | | }); |
| | 0 | 991 | | } |
| | | 992 | | |
| | | 993 | | [HttpGet("clear-jobs/{id:long}")] |
| | | 994 | | public async Task<IActionResult> GetClearJobDetails(long id) |
| | | 995 | | { |
| | 0 | 996 | | var execution = await _bitrixStockClearJobService.GetExecutionDetailsAsync(id); |
| | 0 | 997 | | if (execution == null) |
| | | 998 | | { |
| | 0 | 999 | | return NotFound(new { success = false, error = "Задание не найдено" }); |
| | | 1000 | | } |
| | | 1001 | | |
| | 0 | 1002 | | return Ok(new |
| | 0 | 1003 | | { |
| | 0 | 1004 | | success = true, |
| | 0 | 1005 | | execution = new |
| | 0 | 1006 | | { |
| | 0 | 1007 | | execution.Id, |
| | 0 | 1008 | | execution.JobId, |
| | 0 | 1009 | | execution.HangfireJobId, |
| | 0 | 1010 | | execution.Status, |
| | 0 | 1011 | | execution.StartedAt, |
| | 0 | 1012 | | execution.CompletedAt, |
| | 0 | 1013 | | execution.DurationMs, |
| | 0 | 1014 | | execution.TotalItems, |
| | 0 | 1015 | | execution.SuccessCount, |
| | 0 | 1016 | | execution.FailedCount, |
| | 0 | 1017 | | execution.TriggeredByUserName, |
| | 0 | 1018 | | execution.ErrorMessage, |
| | | 1019 | | items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new |
| | | 1020 | | { |
| | | 1021 | | i.Id, |
| | | 1022 | | i.Article, |
| | | 1023 | | i.OfferId, |
| | | 1024 | | i.ProductId, |
| | | 1025 | | i.WarehouseId, |
| | | 1026 | | i.WarehouseName, |
| | | 1027 | | i.OzonClient, |
| | | 1028 | | i.PreviousStock, |
| | | 1029 | | i.TargetStock, |
| | | 1030 | | i.Status, |
| | | 1031 | | i.ErrorMessage, |
| | | 1032 | | i.ProcessedAt |
| | | 1033 | | }) |
| | 0 | 1034 | | } |
| | 0 | 1035 | | }); |
| | 0 | 1036 | | } |
| | | 1037 | | |
| | | 1038 | | [HttpGet("sync-reports/warehouse-mappings/{clientId}")] |
| | | 1039 | | public async Task<IActionResult> GetWarehouseMappingsForClient(int clientId) |
| | | 1040 | | { |
| | 0 | 1041 | | if (clientId <= 0) |
| | | 1042 | | { |
| | 0 | 1043 | | return BadRequest(new { success = false, error = "Некорректный ID клиента" }); |
| | | 1044 | | } |
| | | 1045 | | |
| | | 1046 | | try |
| | | 1047 | | { |
| | 0 | 1048 | | var allMappings = await _warehouseMappingDataServices.GetByClientIdAsync(clientId); |
| | 0 | 1049 | | var result = allMappings |
| | | 1050 | | .Select(m => new |
| | | 1051 | | { |
| | | 1052 | | id = m.Id, |
| | | 1053 | | ozonWarehouseId = m.OzonWarehouseId, |
| | | 1054 | | bitrixWarehouse = m.BitrixWarehouseName, |
| | | 1055 | | ozonWarehouse = m.OzonWarehouseName, |
| | | 1056 | | description = $"{m.BitrixWarehouseName} → {m.OzonWarehouseName}" |
| | | 1057 | | }) |
| | 0 | 1058 | | .ToList(); |
| | | 1059 | | |
| | 0 | 1060 | | return Ok(new { success = true, mappings = result }); |
| | | 1061 | | } |
| | 0 | 1062 | | catch (Exception ex) |
| | | 1063 | | { |
| | 0 | 1064 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1065 | | } |
| | 0 | 1066 | | } |
| | | 1067 | | |
| | | 1068 | | [HttpPost("sync-reports/start")] |
| | | 1069 | | [IgnoreAntiforgeryToken] |
| | | 1070 | | public IActionResult StartSyncReport([FromBody] BitrixStockSyncReportStartRequest request) |
| | | 1071 | | { |
| | 0 | 1072 | | if (request == null) |
| | | 1073 | | { |
| | 0 | 1074 | | return BadRequest(new { success = false, error = "Пустой запрос" }); |
| | | 1075 | | } |
| | | 1076 | | |
| | 0 | 1077 | | if (!request.GetOrderedClientIds().Any()) |
| | | 1078 | | { |
| | 0 | 1079 | | return BadRequest(new { success = false, error = "Выберите хотя бы одного клиента Ozon" }); |
| | | 1080 | | } |
| | | 1081 | | |
| | 0 | 1082 | | var stores = request.SelectedStoreTitles? |
| | 0 | 1083 | | .Where(s => !string.IsNullOrWhiteSpace(s)) |
| | 0 | 1084 | | .Select(s => s.Trim()) |
| | 0 | 1085 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 1086 | | .ToList(); |
| | 0 | 1087 | | if (stores == null || stores.Count == 0) |
| | | 1088 | | { |
| | 0 | 1089 | | return BadRequest(new { success = false, error = "Выберите хотя бы один склад 1С" }); |
| | | 1090 | | } |
| | | 1091 | | |
| | 0 | 1092 | | request.SelectedStoreTitles = stores; |
| | | 1093 | | |
| | 0 | 1094 | | var jobId = string.IsNullOrWhiteSpace(request.JobId) |
| | 0 | 1095 | | ? Guid.NewGuid().ToString("N") |
| | 0 | 1096 | | : request.JobId.Trim(); |
| | 0 | 1097 | | var reportId = jobId; |
| | | 1098 | | |
| | | 1099 | | try |
| | | 1100 | | { |
| | 0 | 1101 | | if (_activeStageStore.Get(reportId) != 0) |
| | 0 | 1102 | | return Conflict(new { success = false, error = "Цепочка уже выполняется", activeReportId = reportId }); |
| | | 1103 | | |
| | 0 | 1104 | | var otherActive = _activeStageStore.GetAnyActiveReportId(); |
| | 0 | 1105 | | if (otherActive != null && !string.Equals(otherActive, reportId, StringComparison.Ordinal)) |
| | 0 | 1106 | | return Conflict(new { success = false, error = $"Уже выполняется цепочка {otherActive}", activeReportId |
| | | 1107 | | |
| | | 1108 | | // Единая точка входа: оркестратор ведёт всю цепочку этапов 1→5 как одно Hangfire-задание. |
| | | 1109 | | // Без пресета (StageSettings == null) выполнится только этап 1. |
| | 0 | 1110 | | BackgroundJob.Enqueue<IBitrixStockSyncPipelineOrchestrator>(orchestrator => |
| | 0 | 1111 | | orchestrator.RunPipelineAsync(reportId, request, jobId, CancellationToken.None)); |
| | | 1112 | | |
| | 0 | 1113 | | return Ok(new { success = true, reportId, jobId }); |
| | | 1114 | | } |
| | 0 | 1115 | | catch (Exception ex) |
| | | 1116 | | { |
| | 0 | 1117 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1118 | | } |
| | 0 | 1119 | | } |
| | | 1120 | | |
| | | 1121 | | [HttpPost("sync-reports/{reportId}/apply/start")] |
| | | 1122 | | [IgnoreAntiforgeryToken] |
| | | 1123 | | public IActionResult StartSyncReportApply(string reportId, [FromBody] BitrixStockSyncReportApplyStartRequest request |
| | | 1124 | | { |
| | 0 | 1125 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | | 1126 | | { |
| | 0 | 1127 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1128 | | } |
| | | 1129 | | |
| | 0 | 1130 | | if (request == null) |
| | | 1131 | | { |
| | 0 | 1132 | | return BadRequest(new { success = false, error = "Пустой запрос" }); |
| | | 1133 | | } |
| | | 1134 | | |
| | 0 | 1135 | | request.ReportId = reportId.Trim(); |
| | | 1136 | | |
| | 0 | 1137 | | var actions = request.Actions? |
| | 0 | 1138 | | .Where(a => !string.IsNullOrWhiteSpace(a)) |
| | 0 | 1139 | | .Select(a => a.Trim()) |
| | 0 | 1140 | | .Where(a => BitrixStockSyncReportActions.ApplicableToOzon.Contains(a)) |
| | 0 | 1141 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 1142 | | .ToList(); |
| | 0 | 1143 | | request.Actions = actions is { Count: > 0 } ? actions : null; |
| | | 1144 | | |
| | 0 | 1145 | | if (request.MaxLines is <= 0) |
| | | 1146 | | { |
| | 0 | 1147 | | request.MaxLines = null; |
| | | 1148 | | } |
| | | 1149 | | |
| | 0 | 1150 | | var jobId = string.IsNullOrWhiteSpace(request.JobId) |
| | 0 | 1151 | | ? Guid.NewGuid().ToString("N") |
| | 0 | 1152 | | : request.JobId.Trim(); |
| | | 1153 | | |
| | | 1154 | | try |
| | | 1155 | | { |
| | 0 | 1156 | | BackgroundJob.Enqueue<IBitrixStockSyncReportApplyService>(service => |
| | 0 | 1157 | | service.ApplyReportAsync(reportId.Trim(), request, jobId, CancellationToken.None)); |
| | | 1158 | | |
| | 0 | 1159 | | return Ok(new { success = true, reportId = reportId.Trim(), jobId }); |
| | | 1160 | | } |
| | 0 | 1161 | | catch (Exception ex) |
| | | 1162 | | { |
| | 0 | 1163 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1164 | | } |
| | 0 | 1165 | | } |
| | | 1166 | | |
| | | 1167 | | [HttpPost("sync-reports/{reportId}/price-push/start")] |
| | | 1168 | | [IgnoreAntiforgeryToken] |
| | | 1169 | | public IActionResult StartSyncReportPricePush(string reportId, |
| | | 1170 | | [FromBody] BitrixStockSyncReportPricePushStartRequest request) |
| | | 1171 | | { |
| | 0 | 1172 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1173 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | 0 | 1174 | | if (request == null) |
| | 0 | 1175 | | return BadRequest(new { success = false, error = "Пустой запрос" }); |
| | | 1176 | | |
| | 0 | 1177 | | request.ReportId = reportId.Trim(); |
| | 0 | 1178 | | var jobId = string.IsNullOrWhiteSpace(request.JobId) |
| | 0 | 1179 | | ? Guid.NewGuid().ToString("N") |
| | 0 | 1180 | | : request.JobId.Trim(); |
| | | 1181 | | |
| | | 1182 | | try |
| | | 1183 | | { |
| | 0 | 1184 | | BackgroundJob.Enqueue<IBitrixStockSyncReportPricePushService>(svc => |
| | 0 | 1185 | | svc.PushPricesAsync(reportId.Trim(), request, jobId, CancellationToken.None)); |
| | 0 | 1186 | | return Ok(new { success = true, reportId = reportId.Trim(), jobId }); |
| | | 1187 | | } |
| | 0 | 1188 | | catch (Exception ex) |
| | | 1189 | | { |
| | 0 | 1190 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1191 | | } |
| | 0 | 1192 | | } |
| | | 1193 | | |
| | | 1194 | | [HttpGet("sync-reports/{reportId}/cleanup/preview")] |
| | | 1195 | | [ServiceEndpoint] |
| | | 1196 | | [IgnoreAntiforgeryToken] |
| | | 1197 | | public async Task<IActionResult> GetCleanupPreview( |
| | | 1198 | | string reportId, |
| | | 1199 | | [FromQuery] bool onlyPending = true, |
| | | 1200 | | [FromQuery] int? maxProducts = null, |
| | | 1201 | | [FromQuery] string? search = null, |
| | | 1202 | | [FromQuery] int skipGroups = 0, |
| | | 1203 | | [FromQuery] string[]? articles = null) |
| | | 1204 | | { |
| | 0 | 1205 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1206 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1207 | | |
| | 0 | 1208 | | if (maxProducts is <= 0) |
| | 0 | 1209 | | maxProducts = 15; |
| | 0 | 1210 | | if (skipGroups < 0) |
| | 0 | 1211 | | skipGroups = 0; |
| | | 1212 | | |
| | | 1213 | | try |
| | | 1214 | | { |
| | 0 | 1215 | | var articleFilter = articles? |
| | | 1216 | | .Where(a => !string.IsNullOrWhiteSpace(a)) |
| | | 1217 | | .Select(a => a.Trim()) |
| | 0 | 1218 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 1219 | | .ToList(); |
| | 0 | 1220 | | var preview = await _bitrixStockSyncReportCleanupService.GetCleanupPreviewAsync( |
| | 0 | 1221 | | reportId.Trim(), onlyPending, maxProducts, articleFilter, search: search, skipGroups: skipGroups, |
| | 0 | 1222 | | cancellationToken: HttpContext.RequestAborted); |
| | 0 | 1223 | | return Ok(new { success = true, preview }); |
| | | 1224 | | } |
| | 0 | 1225 | | catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) |
| | | 1226 | | { |
| | 0 | 1227 | | throw; |
| | | 1228 | | } |
| | 0 | 1229 | | catch (Exception ex) |
| | | 1230 | | { |
| | 0 | 1231 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1232 | | } |
| | 0 | 1233 | | } |
| | | 1234 | | |
| | | 1235 | | [HttpPost("sync-reports/{reportId}/cleanup/zero")] |
| | | 1236 | | [ServiceEndpoint] |
| | | 1237 | | [IgnoreAntiforgeryToken] |
| | | 1238 | | public async Task<IActionResult> ZeroCleanupWarehouse( |
| | | 1239 | | string reportId, |
| | | 1240 | | [FromBody] BitrixStockSyncCleanupZeroRequest request) |
| | | 1241 | | { |
| | 0 | 1242 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1243 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | 0 | 1244 | | if (request == null) |
| | 0 | 1245 | | return BadRequest(new { success = false, error = "Пустой запрос" }); |
| | | 1246 | | |
| | | 1247 | | try |
| | | 1248 | | { |
| | 0 | 1249 | | var result = await _bitrixStockSyncReportCleanupService.ZeroWarehouseAsync( |
| | 0 | 1250 | | reportId.Trim(), request, HttpContext.RequestAborted); |
| | 0 | 1251 | | if (!result.Success) |
| | 0 | 1252 | | return BadRequest(new { success = false, error = result.Error, result }); |
| | 0 | 1253 | | return Ok(new { success = true, result }); |
| | | 1254 | | } |
| | 0 | 1255 | | catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) |
| | | 1256 | | { |
| | 0 | 1257 | | throw; |
| | | 1258 | | } |
| | 0 | 1259 | | catch (Exception ex) |
| | | 1260 | | { |
| | 0 | 1261 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1262 | | } |
| | 0 | 1263 | | } |
| | | 1264 | | |
| | | 1265 | | [HttpPost("sync-reports/{reportId}/cleanup/start")] |
| | | 1266 | | [ServiceEndpoint] |
| | | 1267 | | [IgnoreAntiforgeryToken] |
| | | 1268 | | public IActionResult StartCleanup(string reportId, [FromBody] BitrixStockSyncCleanupStartRequest request) |
| | | 1269 | | { |
| | 0 | 1270 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1271 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1272 | | |
| | 0 | 1273 | | if (request == null) |
| | 0 | 1274 | | return BadRequest(new { success = false, error = "Пустой запрос" }); |
| | | 1275 | | |
| | 0 | 1276 | | request.ReportId = reportId.Trim(); |
| | | 1277 | | |
| | 0 | 1278 | | if (request.MaxProducts is <= 0) |
| | 0 | 1279 | | request.MaxProducts = null; |
| | | 1280 | | |
| | 0 | 1281 | | var jobId = string.IsNullOrWhiteSpace(request.JobId) |
| | 0 | 1282 | | ? Guid.NewGuid().ToString("N") |
| | 0 | 1283 | | : request.JobId.Trim(); |
| | | 1284 | | |
| | | 1285 | | try |
| | | 1286 | | { |
| | 0 | 1287 | | BackgroundJob.Enqueue<IBitrixStockSyncReportCleanupService>(service => |
| | 0 | 1288 | | service.RunCleanupAsync(reportId.Trim(), request, jobId, CancellationToken.None)); |
| | | 1289 | | |
| | 0 | 1290 | | return Ok(new { success = true, reportId = reportId.Trim(), jobId }); |
| | | 1291 | | } |
| | 0 | 1292 | | catch (Exception ex) |
| | | 1293 | | { |
| | 0 | 1294 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1295 | | } |
| | 0 | 1296 | | } |
| | | 1297 | | |
| | | 1298 | | // ─── Stage 5: Ozon Orphan Cleanup ──────────────────────────────────────── |
| | | 1299 | | |
| | | 1300 | | [HttpGet("sync-reports/{reportId}/ozon-orphan-preview")] |
| | | 1301 | | [IgnoreAntiforgeryToken] |
| | | 1302 | | public async Task<IActionResult> GetOzonOrphanPreview(string reportId) |
| | | 1303 | | { |
| | 0 | 1304 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1305 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1306 | | try |
| | | 1307 | | { |
| | 0 | 1308 | | var preview = await _bitrixStockOzonOrphanCleanupService.GetOrphanPreviewAsync( |
| | 0 | 1309 | | reportId.Trim(), HttpContext.RequestAborted); |
| | 0 | 1310 | | return Ok(new { success = true, preview }); |
| | | 1311 | | } |
| | 0 | 1312 | | catch (Exception ex) |
| | | 1313 | | { |
| | 0 | 1314 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1315 | | } |
| | 0 | 1316 | | } |
| | | 1317 | | |
| | | 1318 | | [HttpPost("sync-reports/{reportId}/ozon-orphan-cleanup/start")] |
| | | 1319 | | [IgnoreAntiforgeryToken] |
| | | 1320 | | public IActionResult StartOzonOrphanCleanup(string reportId, |
| | | 1321 | | [FromBody] BitrixStockOzonOrphanCleanupRequest request) |
| | | 1322 | | { |
| | 0 | 1323 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1324 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | 0 | 1325 | | if (request == null) |
| | 0 | 1326 | | return BadRequest(new { success = false, error = "Пустой запрос" }); |
| | | 1327 | | |
| | 0 | 1328 | | request.ReportId = reportId.Trim(); |
| | 0 | 1329 | | var jobId = string.IsNullOrWhiteSpace(request.JobId) |
| | 0 | 1330 | | ? Guid.NewGuid().ToString("N") |
| | 0 | 1331 | | : request.JobId.Trim(); |
| | 0 | 1332 | | request.JobId = jobId; |
| | | 1333 | | |
| | | 1334 | | try |
| | | 1335 | | { |
| | 0 | 1336 | | BackgroundJob.Enqueue<IBitrixStockOzonOrphanCleanupService>(svc => |
| | 0 | 1337 | | svc.RunOrphanCleanupAsync(reportId.Trim(), request, jobId, CancellationToken.None)); |
| | 0 | 1338 | | return Ok(new { success = true, reportId = reportId.Trim(), jobId }); |
| | | 1339 | | } |
| | 0 | 1340 | | catch (Exception ex) |
| | | 1341 | | { |
| | 0 | 1342 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1343 | | } |
| | 0 | 1344 | | } |
| | | 1345 | | |
| | | 1346 | | [HttpGet("sync-reports/{reportId}/ozon-files")] |
| | | 1347 | | [IgnoreAntiforgeryToken] |
| | | 1348 | | public async Task<IActionResult> GetOzonReportFiles(string reportId) |
| | | 1349 | | { |
| | 0 | 1350 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1351 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1352 | | try |
| | | 1353 | | { |
| | 0 | 1354 | | var files = await _bitrixStockOzonOrphanCleanupService.GetReportFilesAsync( |
| | 0 | 1355 | | reportId.Trim(), HttpContext.RequestAborted); |
| | 0 | 1356 | | return Ok(new { success = true, files }); |
| | | 1357 | | } |
| | 0 | 1358 | | catch (Exception ex) |
| | | 1359 | | { |
| | 0 | 1360 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 1361 | | } |
| | 0 | 1362 | | } |
| | | 1363 | | |
| | | 1364 | | /// <summary> |
| | | 1365 | | /// Скачать xlsx-файл Ozon-отчёта по остаткам. |
| | | 1366 | | /// Сначала ищет локальную копию (сохранённую при этапе 5), при отсутствии — редиректит на Ozon URL. |
| | | 1367 | | /// </summary> |
| | | 1368 | | [HttpGet("sync-reports/{reportId}/ozon-file/{fileId:long}/download")] |
| | | 1369 | | [IgnoreAntiforgeryToken] |
| | | 1370 | | public async Task<IActionResult> DownloadOzonReportFile(string reportId, long fileId) |
| | | 1371 | | { |
| | 0 | 1372 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1373 | | return BadRequest(new { error = "reportId обязателен" }); |
| | | 1374 | | try |
| | | 1375 | | { |
| | 0 | 1376 | | var files = await _bitrixStockOzonOrphanCleanupService.GetReportFilesAsync( |
| | 0 | 1377 | | reportId.Trim(), HttpContext.RequestAborted); |
| | 0 | 1378 | | var file = files.FirstOrDefault(f => f.Id == fileId); |
| | 0 | 1379 | | if (file == null) return NotFound(new { error = "Файл не найден" }); |
| | | 1380 | | |
| | | 1381 | | // Ищем локально сохранённый файл |
| | 0 | 1382 | | var localPath = Path.Combine( |
| | 0 | 1383 | | AppContext.BaseDirectory, "OzonStockFiles", reportId.Trim(), |
| | 0 | 1384 | | $"{file.OzonReportCode}.xlsx"); |
| | 0 | 1385 | | if (System.IO.File.Exists(localPath)) |
| | | 1386 | | { |
| | 0 | 1387 | | var reportCode = file.OzonReportCode ?? "report"; |
| | 0 | 1388 | | var fileName = $"ozon_stock_{file.BitrixWarehouseName ?? "warehouse"}_{reportCode[..Math.Min(12, reportC |
| | 0 | 1389 | | .Replace(' ', '_'); |
| | 0 | 1390 | | return PhysicalFile(localPath, |
| | 0 | 1391 | | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| | 0 | 1392 | | fileName); |
| | | 1393 | | } |
| | | 1394 | | |
| | | 1395 | | // Fallback: редиректим на Ozon URL (истекает через 30 мин после генерации) |
| | 0 | 1396 | | if (!string.IsNullOrWhiteSpace(file.FileUrl)) |
| | 0 | 1397 | | return Redirect(file.FileUrl); |
| | | 1398 | | |
| | 0 | 1399 | | return NotFound(new { error = "Локальный файл отсутствует и Ozon URL не сохранён" }); |
| | | 1400 | | } |
| | 0 | 1401 | | catch (Exception ex) |
| | | 1402 | | { |
| | 0 | 1403 | | return StatusCode(500, new { error = ex.Message }); |
| | | 1404 | | } |
| | 0 | 1405 | | } |
| | | 1406 | | |
| | | 1407 | | [HttpGet("sync-reports/{reportId}/archived-preview")] |
| | | 1408 | | [IgnoreAntiforgeryToken] |
| | | 1409 | | public async Task<IActionResult> GetArchivedPreview(string reportId) |
| | | 1410 | | { |
| | 0 | 1411 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1412 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1413 | | |
| | 0 | 1414 | | var preview = await _bitrixStockSyncReportService.GetArchivedPreviewAsync(reportId.Trim()); |
| | 0 | 1415 | | return Ok(new |
| | 0 | 1416 | | { |
| | 0 | 1417 | | success = true, |
| | 0 | 1418 | | clients = preview.Clients, |
| | 0 | 1419 | | items = preview.Items, |
| | 0 | 1420 | | totalArchived = preview.TotalArchived, |
| | 0 | 1421 | | totalAuto = preview.TotalAuto, |
| | 0 | 1422 | | totalManual = preview.TotalManual |
| | 0 | 1423 | | }); |
| | 0 | 1424 | | } |
| | | 1425 | | |
| | | 1426 | | [HttpGet("sync-reports")] |
| | | 1427 | | public async Task<IActionResult> ListSyncReports( |
| | | 1428 | | [FromQuery] int page = 1, |
| | | 1429 | | [FromQuery] int pageSize = 20, |
| | | 1430 | | [FromQuery] int? ozonClientId = null, |
| | | 1431 | | [FromQuery] string? status = null) |
| | | 1432 | | { |
| | | 1433 | | try |
| | | 1434 | | { |
| | 0 | 1435 | | var result = await _bitrixStockSyncReportService.ListReportsAsync( |
| | 0 | 1436 | | new BitrixStockSyncReportRunsQuery |
| | 0 | 1437 | | { |
| | 0 | 1438 | | Page = page, |
| | 0 | 1439 | | PageSize = pageSize, |
| | 0 | 1440 | | OzonClientId = ozonClientId, |
| | 0 | 1441 | | Status = status |
| | 0 | 1442 | | }); |
| | 0 | 1443 | | foreach (var item in result.Items) |
| | 0 | 1444 | | item.ActiveStage = _activeStageStore.Get(item.ReportId ?? string.Empty); |
| | 0 | 1445 | | return Ok(new { success = true, result }); |
| | | 1446 | | } |
| | 0 | 1447 | | catch (MySqlException ex) when (ex.Message.Contains("Timeout", StringComparison.OrdinalIgnoreCase)) |
| | | 1448 | | { |
| | 0 | 1449 | | return StatusCode(StatusCodes.Status503ServiceUnavailable, |
| | 0 | 1450 | | new { success = false, error = "База данных временно занята. Повторите загрузку отчётов через несколько |
| | | 1451 | | } |
| | 0 | 1452 | | } |
| | | 1453 | | |
| | | 1454 | | // ── Пресеты синхронизации ─────────────────────────────────────────── |
| | | 1455 | | |
| | | 1456 | | [HttpGet("sync-presets")] |
| | | 1457 | | public async Task<IActionResult> GetSyncPresets() |
| | | 1458 | | { |
| | 0 | 1459 | | var presets = await _bitrixStockSyncPresetService.GetAllAsync(); |
| | 0 | 1460 | | return Ok(new { success = true, presets }); |
| | 0 | 1461 | | } |
| | | 1462 | | |
| | | 1463 | | [HttpPost("sync-presets")] |
| | | 1464 | | [IgnoreAntiforgeryToken] |
| | | 1465 | | public async Task<IActionResult> CreateSyncPreset([FromBody] BitrixStockSyncPresetCreateRequest request) |
| | | 1466 | | { |
| | 0 | 1467 | | if (string.IsNullOrWhiteSpace(request?.Name)) |
| | 0 | 1468 | | return BadRequest(new { success = false, error = "Название пресета обязательно" }); |
| | 0 | 1469 | | if (request.OzonClientId <= 0) |
| | 0 | 1470 | | return BadRequest(new { success = false, error = "Клиент Ozon обязателен" }); |
| | 0 | 1471 | | if (request.SelectedStores == null || request.SelectedStores.Count == 0) |
| | 0 | 1472 | | return BadRequest(new { success = false, error = "Выберите хотя бы один склад" }); |
| | | 1473 | | |
| | | 1474 | | // Подтягиваем имя клиента и вставляем в запрос |
| | 0 | 1475 | | var client = await _ozonClientServices.GetOzonClientAsync(request.OzonClientId); |
| | 0 | 1476 | | request.OzonClientName = client?.Name ?? $"ID {request.OzonClientId}"; |
| | | 1477 | | |
| | 0 | 1478 | | var preset = await _bitrixStockSyncPresetService.CreateAsync(request, User.Identity?.Name); |
| | 0 | 1479 | | return Ok(new { success = true, preset }); |
| | 0 | 1480 | | } |
| | | 1481 | | |
| | | 1482 | | [HttpPut("sync-presets/{id:int}")] |
| | | 1483 | | [IgnoreAntiforgeryToken] |
| | | 1484 | | public async Task<IActionResult> UpdateSyncPreset(int id, [FromBody] BitrixStockSyncPresetUpdateRequest request) |
| | | 1485 | | { |
| | 0 | 1486 | | if (string.IsNullOrWhiteSpace(request?.Name)) |
| | 0 | 1487 | | return BadRequest(new { success = false, error = "Название обязательно" }); |
| | | 1488 | | |
| | 0 | 1489 | | var client = await _ozonClientServices.GetOzonClientAsync(request.OzonClientId); |
| | 0 | 1490 | | request.OzonClientName = client?.Name ?? $"ID {request.OzonClientId}"; |
| | | 1491 | | |
| | 0 | 1492 | | var updated = await _bitrixStockSyncPresetService.UpdateAsync(id, request); |
| | 0 | 1493 | | if (updated == null) return NotFound(new { success = false, error = "Пресет не найден" }); |
| | | 1494 | | |
| | 0 | 1495 | | return Ok(new { success = true, preset = updated }); |
| | 0 | 1496 | | } |
| | | 1497 | | |
| | | 1498 | | [HttpDelete("sync-presets/{id:int}")] |
| | | 1499 | | [IgnoreAntiforgeryToken] |
| | | 1500 | | public async Task<IActionResult> DeleteSyncPreset(int id) |
| | | 1501 | | { |
| | 0 | 1502 | | await _bitrixStockSyncPresetService.DeleteAsync(id); |
| | 0 | 1503 | | return Ok(new { success = true }); |
| | 0 | 1504 | | } |
| | | 1505 | | |
| | | 1506 | | // ──────────────────────────────────────────────────────────────────── |
| | | 1507 | | |
| | | 1508 | | [HttpGet("sync-reports/{reportId}/one-c-stores")] |
| | | 1509 | | public async Task<IActionResult> GetSyncReportOneCStores(string reportId) |
| | | 1510 | | { |
| | 0 | 1511 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | | 1512 | | { |
| | 0 | 1513 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1514 | | } |
| | | 1515 | | |
| | 0 | 1516 | | var stores = await _bitrixStockSyncReportService.GetReportOneCStoresAsync(reportId.Trim()); |
| | 0 | 1517 | | return Ok(new { success = true, stores }); |
| | 0 | 1518 | | } |
| | | 1519 | | |
| | | 1520 | | [HttpGet("sync-reports/{reportId}/mapping-combos")] |
| | | 1521 | | public async Task<IActionResult> GetSyncReportMappingCombos(string reportId) |
| | | 1522 | | { |
| | 0 | 1523 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1524 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1525 | | |
| | 0 | 1526 | | var combos = await _bitrixStockSyncReportService.GetReportMappingCombosAsync(reportId.Trim()); |
| | | 1527 | | var result = combos.Select(c => new { clientId = c.ClientId, oneCStore = c.OneCStore, ozonWarehouse = c.OzonWare |
| | 0 | 1528 | | return Ok(new { success = true, combos = result }); |
| | 0 | 1529 | | } |
| | | 1530 | | |
| | | 1531 | | [HttpGet("sync-reports/{reportId}")] |
| | | 1532 | | public async Task<IActionResult> GetSyncReport( |
| | | 1533 | | string reportId, |
| | | 1534 | | [FromQuery] int page = 1, |
| | | 1535 | | [FromQuery] int pageSize = 100, |
| | | 1536 | | [FromQuery] string? article = null, |
| | | 1537 | | [FromQuery] List<string>? oneCStores = null, |
| | | 1538 | | [FromQuery] string? ozonWarehouse = null, |
| | | 1539 | | [FromQuery] string? action = null, |
| | | 1540 | | [FromQuery] List<string>? actions = null, |
| | | 1541 | | [FromQuery] bool onlyRequiresAction = false, |
| | | 1542 | | [FromQuery] bool onlyOzonAppliedSuccess = false, |
| | | 1543 | | [FromQuery] bool onlyOzonAppliedFailed = false, |
| | | 1544 | | [FromQuery] bool onlyArchived = false, |
| | | 1545 | | [FromQuery] bool showZero = false, |
| | | 1546 | | [FromQuery] List<int>? clientIds = null, |
| | | 1547 | | [FromQuery] bool onlyOzonOrphan = false) |
| | | 1548 | | { |
| | 0 | 1549 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | | 1550 | | { |
| | 0 | 1551 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1552 | | } |
| | | 1553 | | |
| | 0 | 1554 | | var report = await _bitrixStockSyncReportService.GetReportAsync( |
| | 0 | 1555 | | reportId.Trim(), |
| | 0 | 1556 | | new BitrixStockSyncReportQuery |
| | 0 | 1557 | | { |
| | 0 | 1558 | | Page = page, |
| | 0 | 1559 | | PageSize = pageSize, |
| | 0 | 1560 | | Article = article, |
| | 0 | 1561 | | OneCStores = oneCStores, |
| | 0 | 1562 | | OzonWarehouse = ozonWarehouse, |
| | 0 | 1563 | | Action = action, |
| | 0 | 1564 | | Actions = actions, |
| | 0 | 1565 | | OnlyRequiresAction = onlyRequiresAction, |
| | 0 | 1566 | | OnlyOzonAppliedSuccess = onlyOzonAppliedSuccess, |
| | 0 | 1567 | | OnlyOzonAppliedFailed = onlyOzonAppliedFailed, |
| | 0 | 1568 | | OnlyArchived = onlyArchived, |
| | 0 | 1569 | | ShowZeroRows = showZero, |
| | 0 | 1570 | | ClientIds = clientIds, |
| | 0 | 1571 | | OnlyOzonOrphan = onlyOzonOrphan |
| | 0 | 1572 | | }); |
| | 0 | 1573 | | if (report == null) |
| | | 1574 | | { |
| | 0 | 1575 | | return NotFound(new { success = false, error = "Отчёт ещё формируется или не найден" }); |
| | | 1576 | | } |
| | | 1577 | | |
| | 0 | 1578 | | return Ok(new { success = true, report }); |
| | 0 | 1579 | | } |
| | | 1580 | | |
| | | 1581 | | [HttpGet("sync-reports/{reportId}/log")] |
| | | 1582 | | public async Task<IActionResult> DownloadSyncReportLog(string reportId) |
| | | 1583 | | { |
| | 0 | 1584 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1585 | | return BadRequest("reportId обязателен"); |
| | | 1586 | | |
| | 0 | 1587 | | var logText = await _bitrixStockSyncReportService.GetReportLogTextAsync(reportId.Trim()); |
| | 0 | 1588 | | if (logText == null) |
| | 0 | 1589 | | return NotFound("Отчёт не найден"); |
| | | 1590 | | |
| | 0 | 1591 | | var filename = $"sync-log-{reportId[..Math.Min(8, reportId.Length)]}.txt"; |
| | 0 | 1592 | | return File(System.Text.Encoding.UTF8.GetBytes(logText), "text/plain; charset=utf-8", filename); |
| | 0 | 1593 | | } |
| | | 1594 | | |
| | | 1595 | | /// <summary> |
| | | 1596 | | /// Возвращает словарь offerId → название товара через Ozon API /v3/product/info/list. |
| | | 1597 | | /// Принимает список offer_id с текущей страницы отчёта (до 100 штук). |
| | | 1598 | | /// </summary> |
| | | 1599 | | [HttpGet("sync-reports/{reportId}/product-names")] |
| | | 1600 | | public async Task<IActionResult> GetSyncReportProductNames( |
| | | 1601 | | string reportId, |
| | | 1602 | | [FromQuery] List<string>? offerIds = null) |
| | | 1603 | | { |
| | 0 | 1604 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1605 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 1606 | | |
| | 0 | 1607 | | if (offerIds == null || offerIds.Count == 0) |
| | 0 | 1608 | | return Ok(new { success = true, names = new Dictionary<string, string>() }); |
| | | 1609 | | |
| | | 1610 | | // Получаем OzonClientId из заголовка отчёта |
| | 0 | 1611 | | var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(), |
| | 0 | 1612 | | new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 }); |
| | 0 | 1613 | | if (report == null) |
| | 0 | 1614 | | return NotFound(new { success = false, error = "Отчёт не найден" }); |
| | | 1615 | | |
| | 0 | 1616 | | var client = await _ozonClientServices.GetOzonClientAsync(report.OzonClientId); |
| | 0 | 1617 | | if (client == null || !TryGetOzonCredentials(client, out var ozonClientId, out var ozonApiKey)) |
| | 0 | 1618 | | return Ok(new { success = true, names = new Dictionary<string, string>() }); |
| | | 1619 | | |
| | 0 | 1620 | | var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey); |
| | | 1621 | | |
| | 0 | 1622 | | var names = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | | 1623 | | try |
| | | 1624 | | { |
| | 0 | 1625 | | foreach (var chunk in offerIds.Distinct(StringComparer.OrdinalIgnoreCase).Chunk(100)) |
| | | 1626 | | { |
| | 0 | 1627 | | var response = await apiManager.GetProductInfoListAsync(offerIds: chunk); |
| | 0 | 1628 | | if (response?["items"] is not Newtonsoft.Json.Linq.JArray items) |
| | | 1629 | | continue; |
| | 0 | 1630 | | foreach (var item in items) |
| | | 1631 | | { |
| | 0 | 1632 | | var id = item["offer_id"]?.ToString(); |
| | 0 | 1633 | | var name = item["name"]?.ToString(); |
| | 0 | 1634 | | if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name)) |
| | 0 | 1635 | | names[id] = name; |
| | | 1636 | | } |
| | | 1637 | | } |
| | 0 | 1638 | | } |
| | 0 | 1639 | | catch |
| | | 1640 | | { |
| | | 1641 | | // Если API недоступно — возвращаем пустой словарь, не ломаем UI |
| | 0 | 1642 | | } |
| | | 1643 | | |
| | 0 | 1644 | | return Ok(new { success = true, names }); |
| | 0 | 1645 | | } |
| | | 1646 | | |
| | | 1647 | | /// <summary> |
| | | 1648 | | /// Диагностика: вызывает Ozon API напрямую для указанного артикула и возвращает что нашлось. |
| | | 1649 | | /// GET /sync-reports/{reportId}/debug-ozon?article=0035310SX&articleWithKey=0035310SX%3DSLX |
| | | 1650 | | /// </summary> |
| | | 1651 | | [HttpGet("sync-reports/{reportId}/debug-ozon")] |
| | | 1652 | | [IgnoreAntiforgeryToken] |
| | | 1653 | | [Authorize(Roles = "Admin")] |
| | | 1654 | | public async Task<IActionResult> DebugOzonArticleLookup( |
| | | 1655 | | string reportId, |
| | | 1656 | | [FromQuery] string? article = null, |
| | | 1657 | | [FromQuery] string? articleWithKey = null) |
| | | 1658 | | { |
| | 0 | 1659 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1660 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | 0 | 1661 | | if (string.IsNullOrWhiteSpace(article) && string.IsNullOrWhiteSpace(articleWithKey)) |
| | 0 | 1662 | | return BadRequest(new { success = false, error = "Нужен article или articleWithKey" }); |
| | | 1663 | | |
| | 0 | 1664 | | var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(), |
| | 0 | 1665 | | new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 }); |
| | 0 | 1666 | | if (report == null) |
| | 0 | 1667 | | return NotFound(new { success = false, error = "Отчёт не найден" }); |
| | | 1668 | | |
| | 0 | 1669 | | var clientIds = report.MultiClientIds.Any() ? report.MultiClientIds : new List<int> { report.OzonClientId }; |
| | 0 | 1670 | | var candidates = Services.BitrixStockServices.BitrixOzonStockLinkage.BuildOfferIdCandidates(articleWithKey, arti |
| | | 1671 | | |
| | 0 | 1672 | | var resultsJson = new Newtonsoft.Json.Linq.JArray(); |
| | 0 | 1673 | | foreach (var clientId in clientIds) |
| | | 1674 | | { |
| | 0 | 1675 | | var client = await _ozonClientServices.GetOzonClientAsync(clientId); |
| | 0 | 1676 | | if (client == null || !TryGetOzonCredentials(client, out var ozonClientId, out var ozonApiKey)) continue; |
| | | 1677 | | |
| | 0 | 1678 | | var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey); |
| | 0 | 1679 | | var clientResult = new Newtonsoft.Json.Linq.JObject |
| | 0 | 1680 | | { |
| | 0 | 1681 | | ["clientId"] = clientId, |
| | 0 | 1682 | | ["clientName"] = client.Name, |
| | 0 | 1683 | | ["candidates"] = new Newtonsoft.Json.Linq.JArray(candidates) |
| | 0 | 1684 | | }; |
| | | 1685 | | |
| | | 1686 | | // GetProductStocks — остатки по FBO складам |
| | | 1687 | | try |
| | | 1688 | | { |
| | 0 | 1689 | | var stocks = await apiManager.GetProductStocks(candidates.ToArray()); |
| | 0 | 1690 | | clientResult["stocksItems"] = stocks?["items"] ?? new Newtonsoft.Json.Linq.JArray(); |
| | 0 | 1691 | | } |
| | 0 | 1692 | | catch (Exception ex) { clientResult["stocksError"] = ex.Message; } |
| | | 1693 | | |
| | | 1694 | | // GetProductInfoList — offer_id, название, статус архива |
| | | 1695 | | try |
| | | 1696 | | { |
| | 0 | 1697 | | var info = await apiManager.GetProductInfoListAsync(offerIds: candidates.ToArray()); |
| | | 1698 | | // Возвращаем только ключевые поля чтобы не захламлять ответ |
| | 0 | 1699 | | var infoItems = new Newtonsoft.Json.Linq.JArray(); |
| | 0 | 1700 | | if (info?["items"] is Newtonsoft.Json.Linq.JArray items) |
| | | 1701 | | { |
| | 0 | 1702 | | foreach (var item in items) |
| | | 1703 | | { |
| | 0 | 1704 | | infoItems.Add(new Newtonsoft.Json.Linq.JObject |
| | 0 | 1705 | | { |
| | 0 | 1706 | | ["offer_id"] = item["offer_id"], |
| | 0 | 1707 | | ["id"] = item["id"], |
| | 0 | 1708 | | ["name"] = item["name"], |
| | 0 | 1709 | | ["is_archived"] = item["is_archived"], |
| | 0 | 1710 | | ["is_autoarchived"] = item["is_autoarchived"], |
| | 0 | 1711 | | ["stocks"] = item["stocks"], |
| | 0 | 1712 | | ["sources"] = item["sources"] |
| | 0 | 1713 | | }); |
| | | 1714 | | } |
| | | 1715 | | } |
| | 0 | 1716 | | clientResult["infoItems"] = infoItems; |
| | 0 | 1717 | | } |
| | 0 | 1718 | | catch (Exception ex) { clientResult["infoError"] = ex.Message; } |
| | | 1719 | | |
| | | 1720 | | // GetProductWarehousesForBitrix — /v2/product/info/stocks-by-warehouse/fbs |
| | | 1721 | | // Извлекаем SKU из stocksItems и смотрим что возвращает FBS-склад API |
| | | 1722 | | try |
| | | 1723 | | { |
| | 0 | 1724 | | var skus = new List<string>(); |
| | 0 | 1725 | | if (clientResult["stocksItems"] is Newtonsoft.Json.Linq.JArray stocksArr) |
| | | 1726 | | { |
| | 0 | 1727 | | foreach (var si in stocksArr) |
| | 0 | 1728 | | foreach (var s in si["stocks"] ?? new Newtonsoft.Json.Linq.JArray()) |
| | | 1729 | | { |
| | 0 | 1730 | | var sku = s["sku"]?.ToString(); |
| | 0 | 1731 | | if (!string.IsNullOrEmpty(sku)) skus.Add(sku); |
| | | 1732 | | } |
| | | 1733 | | } |
| | 0 | 1734 | | clientResult["fbsWarehouseSkus"] = new Newtonsoft.Json.Linq.JArray(skus); |
| | 0 | 1735 | | if (skus.Count > 0) |
| | | 1736 | | { |
| | 0 | 1737 | | var warehouseData = await apiManager.GetProductWarehousesForBitrix(skus.ToArray(), System.Array.Empt |
| | 0 | 1738 | | clientResult["fbsWarehouseResult"] = warehouseData; |
| | | 1739 | | } |
| | | 1740 | | else |
| | | 1741 | | { |
| | 0 | 1742 | | clientResult["fbsWarehouseResult"] = "Нет SKU в stocks — FBS-склад API не вызван"; |
| | | 1743 | | } |
| | 0 | 1744 | | } |
| | 0 | 1745 | | catch (Exception ex) { clientResult["fbsWarehouseError"] = ex.Message; } |
| | | 1746 | | |
| | 0 | 1747 | | resultsJson.Add(clientResult); |
| | 0 | 1748 | | } |
| | | 1749 | | |
| | 0 | 1750 | | var json = new Newtonsoft.Json.Linq.JObject |
| | 0 | 1751 | | { |
| | 0 | 1752 | | ["success"] = true, |
| | 0 | 1753 | | ["article"] = article, |
| | 0 | 1754 | | ["articleWithKey"] = articleWithKey, |
| | 0 | 1755 | | ["candidates"] = new Newtonsoft.Json.Linq.JArray(candidates), |
| | 0 | 1756 | | ["results"] = resultsJson |
| | 0 | 1757 | | }; |
| | 0 | 1758 | | return Content(json.ToString(Newtonsoft.Json.Formatting.Indented), "application/json"); |
| | 0 | 1759 | | } |
| | | 1760 | | |
| | | 1761 | | /// <summary> |
| | | 1762 | | /// Диагностика: пробует накатить FBS-остаток через /v2/products/stocks. |
| | | 1763 | | /// GET /sync-reports/{reportId}/debug-fbs-push?article=0035310SX&warehouse=M2&stock=1&dryRun=true |
| | | 1764 | | /// По умолчанию dryRun=true — только показывает что будет отправлено, без реального вызова. |
| | | 1765 | | /// </summary> |
| | | 1766 | | [HttpGet("sync-reports/{reportId}/debug-fbs-push")] |
| | | 1767 | | [IgnoreAntiforgeryToken] |
| | | 1768 | | [Authorize(Roles = "Admin")] |
| | | 1769 | | public async Task<IActionResult> DebugFbsStockPush( |
| | | 1770 | | string reportId, |
| | | 1771 | | [FromQuery] string? article = null, |
| | | 1772 | | [FromQuery] string? articleWithKey = null, |
| | | 1773 | | [FromQuery] string? warehouse = null, |
| | | 1774 | | [FromQuery] int stock = 1, |
| | | 1775 | | [FromQuery] bool dryRun = true) |
| | | 1776 | | { |
| | | 1777 | | // Debug endpoint — live writes are permanently disabled regardless of query param |
| | 0 | 1778 | | dryRun = true; |
| | 0 | 1779 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 1780 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | 0 | 1781 | | if (string.IsNullOrWhiteSpace(article) && string.IsNullOrWhiteSpace(articleWithKey)) |
| | 0 | 1782 | | return BadRequest(new { success = false, error = "Нужен article или articleWithKey" }); |
| | | 1783 | | |
| | 0 | 1784 | | var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(), |
| | 0 | 1785 | | new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 }); |
| | 0 | 1786 | | if (report == null) |
| | 0 | 1787 | | return NotFound(new { success = false, error = "Отчёт не найден" }); |
| | | 1788 | | |
| | 0 | 1789 | | var clientIds = report.MultiClientIds.Any() ? report.MultiClientIds : new List<int> { report.OzonClientId }; |
| | 0 | 1790 | | var candidates = Services.BitrixStockServices.BitrixOzonStockLinkage.BuildOfferIdCandidates(articleWithKey, arti |
| | | 1791 | | |
| | 0 | 1792 | | var resultsJson = new Newtonsoft.Json.Linq.JArray(); |
| | 0 | 1793 | | foreach (var clientId in clientIds) |
| | | 1794 | | { |
| | 0 | 1795 | | var client = await _ozonClientServices.GetOzonClientAsync(clientId); |
| | 0 | 1796 | | if (client == null || !TryGetOzonCredentials(client, out var ozonClientId, out var ozonApiKey)) continue; |
| | | 1797 | | |
| | 0 | 1798 | | var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey); |
| | 0 | 1799 | | var clientResult = new Newtonsoft.Json.Linq.JObject |
| | 0 | 1800 | | { |
| | 0 | 1801 | | ["clientId"] = clientId, |
| | 0 | 1802 | | ["clientName"] = client.Name |
| | 0 | 1803 | | }; |
| | | 1804 | | |
| | | 1805 | | // 1. Список FBS-складов клиента |
| | 0 | 1806 | | var warehouseList = await apiManager.GetWarehouseList(); |
| | 0 | 1807 | | var warehousesJson = new Newtonsoft.Json.Linq.JArray( |
| | | 1808 | | warehouseList.Select(w => new Newtonsoft.Json.Linq.JObject |
| | | 1809 | | { |
| | | 1810 | | ["warehouse_id"] = w.WarehouseId, |
| | | 1811 | | ["name"] = w.Name, |
| | | 1812 | | ["status"] = w.Status |
| | | 1813 | | })); |
| | 0 | 1814 | | clientResult["allWarehouses"] = warehousesJson; |
| | | 1815 | | |
| | | 1816 | | // 2. Найти склад по имени (M2) |
| | 0 | 1817 | | WarehouseOzon? matchedWarehouse = null; |
| | 0 | 1818 | | if (!string.IsNullOrWhiteSpace(warehouse)) |
| | | 1819 | | { |
| | | 1820 | | // Сначала точное совпадение, затем prefix match |
| | 0 | 1821 | | matchedWarehouse = |
| | 0 | 1822 | | warehouseList.FirstOrDefault(w => string.Equals(w.Name?.Trim(), warehouse.Trim(), StringComparison.O |
| | 0 | 1823 | | warehouseList.FirstOrDefault(w => w.Name?.Trim().StartsWith(warehouse.Trim(), StringComparison.Ordin |
| | | 1824 | | } |
| | 0 | 1825 | | clientResult["matchedWarehouse"] = matchedWarehouse == null ? null : new Newtonsoft.Json.Linq.JObject |
| | 0 | 1826 | | { |
| | 0 | 1827 | | ["warehouse_id"] = matchedWarehouse.WarehouseId, |
| | 0 | 1828 | | ["name"] = matchedWarehouse.Name |
| | 0 | 1829 | | }; |
| | | 1830 | | |
| | | 1831 | | // 3. product_id из /v4/product/info/stocks |
| | 0 | 1832 | | long productId = 0; |
| | 0 | 1833 | | string? foundOfferId = null; |
| | | 1834 | | try |
| | | 1835 | | { |
| | 0 | 1836 | | var stocks = await apiManager.GetProductStocks(candidates.ToArray()); |
| | 0 | 1837 | | if (stocks?["items"] is Newtonsoft.Json.Linq.JArray items && items.Count > 0) |
| | | 1838 | | { |
| | 0 | 1839 | | productId = items[0]["product_id"]?.Value<long>() ?? 0; |
| | 0 | 1840 | | foundOfferId = items[0]["offer_id"]?.ToString(); |
| | | 1841 | | } |
| | 0 | 1842 | | clientResult["productId"] = productId; |
| | 0 | 1843 | | clientResult["foundOfferId"] = foundOfferId; |
| | 0 | 1844 | | } |
| | 0 | 1845 | | catch (Exception ex) { clientResult["stocksError"] = ex.Message; } |
| | | 1846 | | |
| | | 1847 | | // 4. Формируем payload для /v2/products/stocks |
| | 0 | 1848 | | if (matchedWarehouse != null && productId > 0 && !string.IsNullOrWhiteSpace(foundOfferId)) |
| | | 1849 | | { |
| | 0 | 1850 | | var payload = new Newtonsoft.Json.Linq.JObject |
| | 0 | 1851 | | { |
| | 0 | 1852 | | ["offer_id"] = foundOfferId, |
| | 0 | 1853 | | ["product_id"] = productId, |
| | 0 | 1854 | | ["stock"] = stock, |
| | 0 | 1855 | | ["warehouse_id"] = matchedWarehouse.WarehouseId |
| | 0 | 1856 | | }; |
| | 0 | 1857 | | clientResult["payload"] = payload; |
| | 0 | 1858 | | clientResult["dryRun"] = dryRun; |
| | | 1859 | | |
| | 0 | 1860 | | if (!dryRun) |
| | | 1861 | | { |
| | | 1862 | | try |
| | | 1863 | | { |
| | 0 | 1864 | | var updateResult = await apiManager.UpdateProductStocks(new List<StockItemForUpdate> |
| | 0 | 1865 | | { |
| | 0 | 1866 | | new() |
| | 0 | 1867 | | { |
| | 0 | 1868 | | OfferId = foundOfferId, |
| | 0 | 1869 | | ProductId = productId, |
| | 0 | 1870 | | Stock = stock, |
| | 0 | 1871 | | WarehouseId = matchedWarehouse.WarehouseId |
| | 0 | 1872 | | } |
| | 0 | 1873 | | }); |
| | 0 | 1874 | | var row = updateResult?.Result?.FirstOrDefault(); |
| | 0 | 1875 | | var errorsArr = new Newtonsoft.Json.Linq.JArray(); |
| | 0 | 1876 | | foreach (var e in row?.Errors ?? new List<StockUpdateError>()) |
| | 0 | 1877 | | errorsArr.Add(new Newtonsoft.Json.Linq.JObject |
| | 0 | 1878 | | { |
| | 0 | 1879 | | ["code"] = e.Code, ["message"] = e.Message |
| | 0 | 1880 | | }); |
| | 0 | 1881 | | clientResult["updateResult"] = new Newtonsoft.Json.Linq.JObject |
| | 0 | 1882 | | { |
| | 0 | 1883 | | ["updated"] = row?.Updated ?? false, |
| | 0 | 1884 | | ["warehouse_id"] = row?.WarehouseId ?? 0, |
| | 0 | 1885 | | ["errors"] = errorsArr |
| | 0 | 1886 | | }; |
| | 0 | 1887 | | } |
| | 0 | 1888 | | catch (Exception ex) { clientResult["updateError"] = ex.Message; } |
| | | 1889 | | } |
| | | 1890 | | } |
| | | 1891 | | else |
| | | 1892 | | { |
| | 0 | 1893 | | var reasons = new List<string>(); |
| | 0 | 1894 | | if (matchedWarehouse == null) reasons.Add($"Склад '{warehouse}' не найден в Ozon warehouse list"); |
| | 0 | 1895 | | if (productId <= 0) reasons.Add("product_id не получен из /v4/product/info/stocks"); |
| | 0 | 1896 | | if (string.IsNullOrWhiteSpace(foundOfferId)) reasons.Add("offer_id не найден"); |
| | 0 | 1897 | | clientResult["cannotPush"] = string.Join("; ", reasons); |
| | | 1898 | | } |
| | | 1899 | | |
| | 0 | 1900 | | resultsJson.Add(clientResult); |
| | 0 | 1901 | | } |
| | | 1902 | | |
| | 0 | 1903 | | var json = new Newtonsoft.Json.Linq.JObject |
| | 0 | 1904 | | { |
| | 0 | 1905 | | ["success"] = true, |
| | 0 | 1906 | | ["article"] = article, |
| | 0 | 1907 | | ["articleWithKey"] = articleWithKey, |
| | 0 | 1908 | | ["candidates"] = new Newtonsoft.Json.Linq.JArray(candidates), |
| | 0 | 1909 | | ["warehouse"] = warehouse, |
| | 0 | 1910 | | ["stock"] = stock, |
| | 0 | 1911 | | ["dryRun"] = dryRun, |
| | 0 | 1912 | | ["results"] = resultsJson |
| | 0 | 1913 | | }; |
| | 0 | 1914 | | return Content(json.ToString(Newtonsoft.Json.Formatting.Indented), "application/json"); |
| | 0 | 1915 | | } |
| | | 1916 | | |
| | | 1917 | | [HttpGet("order-reserve-map")] |
| | | 1918 | | public async Task<IActionResult> GetOrderReserveMap( |
| | | 1919 | | [FromQuery] string? article = null, |
| | | 1920 | | [FromQuery] string? stores = null, |
| | | 1921 | | [FromQuery] string? statusIds = null) |
| | | 1922 | | { |
| | 0 | 1923 | | var selectedStores = string.IsNullOrWhiteSpace(stores) |
| | 0 | 1924 | | ? null |
| | 0 | 1925 | | : stores.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); |
| | 0 | 1926 | | var reserveStatusIds = string.IsNullOrWhiteSpace(statusIds) |
| | 0 | 1927 | | ? null |
| | 0 | 1928 | | : statusIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) |
| | | 1929 | | .Select(s => int.TryParse(s, out var id) ? id : 0) |
| | | 1930 | | .Where(id => id > 0) |
| | 0 | 1931 | | .Distinct() |
| | 0 | 1932 | | .ToList(); |
| | | 1933 | | |
| | 0 | 1934 | | var map = await _bitrixStockSyncReportService.GetOrderReserveMapAsync(selectedStores, article, reserveStatusIds, |
| | 0 | 1935 | | return Ok(map); |
| | 0 | 1936 | | } |
| | | 1937 | | |
| | | 1938 | | [HttpGet("order-reserve-map/export/excel")] |
| | | 1939 | | public async Task<IActionResult> ExportOrderReserveMapExcel( |
| | | 1940 | | [FromQuery] string? article = null, |
| | | 1941 | | [FromQuery] string? stores = null, |
| | | 1942 | | [FromQuery] string? statusIds = null) |
| | | 1943 | | { |
| | 0 | 1944 | | var selectedStores = string.IsNullOrWhiteSpace(stores) |
| | 0 | 1945 | | ? null |
| | 0 | 1946 | | : stores.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); |
| | 0 | 1947 | | var reserveStatusIds = string.IsNullOrWhiteSpace(statusIds) |
| | 0 | 1948 | | ? null |
| | 0 | 1949 | | : statusIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) |
| | | 1950 | | .Select(s => int.TryParse(s, out var id) ? id : 0) |
| | | 1951 | | .Where(id => id > 0) |
| | 0 | 1952 | | .Distinct() |
| | 0 | 1953 | | .ToList(); |
| | | 1954 | | |
| | 0 | 1955 | | var map = await _bitrixStockSyncReportService.GetOrderReserveMapAsync(selectedStores, article, reserveStatusIds, |
| | 0 | 1956 | | var bytes = BuildReserveMapExcel(map, selectedStores, article); |
| | 0 | 1957 | | var fileName = $"reserve-map-{DateTime.UtcNow:yyyyMMdd-HHmm}.xlsx"; |
| | 0 | 1958 | | return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); |
| | 0 | 1959 | | } |
| | | 1960 | | |
| | | 1961 | | private static byte[] BuildReserveMapExcel(OrderReserveMapDto map, List<string>? stores, string? articleFilter) |
| | | 1962 | | { |
| | 0 | 1963 | | using var wb = new XLWorkbook(); |
| | 0 | 1964 | | var ws = wb.Worksheets.Add("Резерв заказов"); |
| | | 1965 | | |
| | 0 | 1966 | | var headerRow = 1; |
| | 0 | 1967 | | var headers = new[] { "Артикул", "Склад", "Резерв (ед.)", "Кол-во заказов", "Номера отправлений", "Детали заказо |
| | 0 | 1968 | | for (var i = 0; i < headers.Length; i++) |
| | | 1969 | | { |
| | 0 | 1970 | | var cell = ws.Cell(headerRow, i + 1); |
| | 0 | 1971 | | cell.Value = headers[i]; |
| | 0 | 1972 | | cell.Style.Font.Bold = true; |
| | 0 | 1973 | | cell.Style.Fill.BackgroundColor = XLColor.FromArgb(0xD0, 0xE4, 0xFF); |
| | | 1974 | | } |
| | | 1975 | | |
| | 0 | 1976 | | var row = 2; |
| | 0 | 1977 | | foreach (var line in map.Lines) |
| | | 1978 | | { |
| | 0 | 1979 | | ws.Cell(row, 1).Value = line.Article; |
| | 0 | 1980 | | ws.Cell(row, 2).Value = line.Store; |
| | 0 | 1981 | | ws.Cell(row, 3).Value = line.ReservedQty; |
| | 0 | 1982 | | ws.Cell(row, 4).Value = line.OrderCount; |
| | 0 | 1983 | | ws.Cell(row, 5).Value = line.Orders?.Count > 0 |
| | 0 | 1984 | | ? string.Join(", ", line.Orders.Select(o => o.ShipmentNumber ?? o.OrderNumber)) |
| | 0 | 1985 | | : string.Join(", ", line.OrderNumbers); |
| | 0 | 1986 | | ws.Cell(row, 6).Value = string.Join(", ", |
| | 0 | 1987 | | (line.Orders?.Count > 0 |
| | 0 | 1988 | | ? line.Orders.Select(o => |
| | 0 | 1989 | | { |
| | 0 | 1990 | | var num = o.ShipmentNumber ?? o.OrderNumber; |
| | 0 | 1991 | | var date = o.ProcessingDate.HasValue |
| | 0 | 1992 | | ? o.ProcessingDate.Value.ToString("dd.MM.yyyy") |
| | 0 | 1993 | | : ""; |
| | 0 | 1994 | | return string.IsNullOrEmpty(date) |
| | 0 | 1995 | | ? $"{num} ({o.StatusName})" |
| | 0 | 1996 | | : $"{num} {date} ({o.StatusName})"; |
| | 0 | 1997 | | }) |
| | 0 | 1998 | | : line.OrderNumbers)); |
| | 0 | 1999 | | row++; |
| | | 2000 | | } |
| | | 2001 | | |
| | 0 | 2002 | | ws.Column(1).Width = 28; |
| | 0 | 2003 | | ws.Column(2).Width = 10; |
| | 0 | 2004 | | ws.Column(3).Width = 16; |
| | 0 | 2005 | | ws.Column(4).Width = 18; |
| | 0 | 2006 | | ws.Column(5).Width = 48; |
| | 0 | 2007 | | ws.Column(6).Width = 60; |
| | | 2008 | | |
| | 0 | 2009 | | var infoRow = row + 1; |
| | 0 | 2010 | | ws.Cell(infoRow, 1).Value = $"Итого: {map.TotalArticles} арт., {map.TotalOrders} заказов. Статусы: «Не указан», |
| | 0 | 2011 | | ws.Cell(infoRow, 1).Style.Font.Italic = true; |
| | 0 | 2012 | | ws.Cell(infoRow, 1).Style.Font.FontColor = XLColor.Gray; |
| | | 2013 | | |
| | 0 | 2014 | | if (!string.IsNullOrWhiteSpace(articleFilter)) |
| | | 2015 | | { |
| | 0 | 2016 | | var filterRow = infoRow + 1; |
| | 0 | 2017 | | ws.Cell(filterRow, 1).Value = $"Фильтр по артикулу: {articleFilter}"; |
| | 0 | 2018 | | ws.Cell(filterRow, 1).Style.Font.Italic = true; |
| | 0 | 2019 | | ws.Cell(filterRow, 1).Style.Font.FontColor = XLColor.Gray; |
| | | 2020 | | } |
| | | 2021 | | |
| | 0 | 2022 | | if (stores?.Count > 0) |
| | | 2023 | | { |
| | 0 | 2024 | | var storeRow = infoRow + (string.IsNullOrWhiteSpace(articleFilter) ? 1 : 2); |
| | 0 | 2025 | | ws.Cell(storeRow, 1).Value = $"Склады: {string.Join(", ", stores)}"; |
| | 0 | 2026 | | ws.Cell(storeRow, 1).Style.Font.Italic = true; |
| | 0 | 2027 | | ws.Cell(storeRow, 1).Style.Font.FontColor = XLColor.Gray; |
| | | 2028 | | } |
| | | 2029 | | |
| | 0 | 2030 | | using var ms = new System.IO.MemoryStream(); |
| | 0 | 2031 | | wb.SaveAs(ms); |
| | 0 | 2032 | | return ms.ToArray(); |
| | 0 | 2033 | | } |
| | | 2034 | | |
| | | 2035 | | [HttpGet("sync-reports/{reportId}/export/excel")] |
| | | 2036 | | public IActionResult ExportSyncReportExcel( |
| | | 2037 | | string reportId, |
| | | 2038 | | [FromQuery] string? article = null, |
| | | 2039 | | [FromQuery] List<string>? oneCStores = null, |
| | | 2040 | | [FromQuery] string? ozonWarehouse = null, |
| | | 2041 | | [FromQuery] string? action = null, |
| | | 2042 | | [FromQuery] List<string>? actions = null, |
| | | 2043 | | [FromQuery] bool onlyRequiresAction = false, |
| | | 2044 | | [FromQuery] bool onlyOzonAppliedSuccess = false, |
| | | 2045 | | [FromQuery] bool onlyOzonAppliedFailed = false, |
| | | 2046 | | [FromQuery] bool onlyOzonOrphan = false) |
| | 0 | 2047 | | => StartSyncReportExcelExportTask( |
| | 0 | 2048 | | reportId, article, oneCStores, ozonWarehouse, action, actions, |
| | 0 | 2049 | | onlyRequiresAction, onlyOzonAppliedSuccess, onlyOzonAppliedFailed, onlyOzonOrphan); |
| | | 2050 | | |
| | | 2051 | | [HttpPost("sync-reports/{reportId}/export/excel-tasks/start")] |
| | | 2052 | | [IgnoreAntiforgeryToken] |
| | | 2053 | | public IActionResult StartSyncReportExcelExportTask( |
| | | 2054 | | string reportId, |
| | | 2055 | | [FromQuery] string? article = null, |
| | | 2056 | | [FromQuery] List<string>? oneCStores = null, |
| | | 2057 | | [FromQuery] string? ozonWarehouse = null, |
| | | 2058 | | [FromQuery] string? action = null, |
| | | 2059 | | [FromQuery] List<string>? actions = null, |
| | | 2060 | | [FromQuery] bool onlyRequiresAction = false, |
| | | 2061 | | [FromQuery] bool onlyOzonAppliedSuccess = false, |
| | | 2062 | | [FromQuery] bool onlyOzonAppliedFailed = false, |
| | | 2063 | | [FromQuery] bool onlyOzonOrphan = false) |
| | | 2064 | | { |
| | 0 | 2065 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 2066 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 2067 | | |
| | 0 | 2068 | | var task = _channelTasks.Submit(ChannelTaskType.BitrixSyncReportExcel, new BitrixSyncReportTaskPayload |
| | 0 | 2069 | | { |
| | 0 | 2070 | | ReportId = reportId.Trim(), |
| | 0 | 2071 | | Query = BuildSyncReportQuery(article, oneCStores, ozonWarehouse, action, actions, |
| | 0 | 2072 | | onlyRequiresAction, onlyOzonAppliedSuccess, onlyOzonAppliedFailed, onlyOzonOrphan) |
| | 0 | 2073 | | }); |
| | | 2074 | | |
| | 0 | 2075 | | return Ok(ChannelTaskResponses.ToStartResponse(task)); |
| | | 2076 | | } |
| | | 2077 | | |
| | | 2078 | | [HttpGet("sync-reports/export/excel-tasks/{taskId}")] |
| | | 2079 | | public IActionResult GetSyncReportExcelExportTaskStatus(string taskId) |
| | | 2080 | | { |
| | 0 | 2081 | | if (string.IsNullOrWhiteSpace(taskId)) |
| | 0 | 2082 | | return BadRequest(new { success = false, error = "taskId обязателен" }); |
| | | 2083 | | |
| | 0 | 2084 | | if (!_channelTasks.TryGet(taskId.Trim(), out var task) || task == null) |
| | 0 | 2085 | | return NotFound(new { success = false, error = "Задача не найдена" }); |
| | | 2086 | | |
| | 0 | 2087 | | return Ok(ChannelTaskResponses.ToStatusResponse(task)); |
| | | 2088 | | } |
| | | 2089 | | |
| | | 2090 | | [HttpGet("sync-reports/export/excel-tasks/{taskId}/download")] |
| | | 2091 | | public IActionResult DownloadSyncReportExcelExportTaskResult(string taskId) |
| | | 2092 | | { |
| | 0 | 2093 | | if (string.IsNullOrWhiteSpace(taskId)) |
| | 0 | 2094 | | return BadRequest("taskId обязателен"); |
| | | 2095 | | |
| | 0 | 2096 | | if (!_channelTasks.TryGetCompletedFile(taskId.Trim(), out var filePath, out var fileName) |
| | 0 | 2097 | | || string.IsNullOrWhiteSpace(filePath) |
| | 0 | 2098 | | || string.IsNullOrWhiteSpace(fileName)) |
| | | 2099 | | { |
| | 0 | 2100 | | return NotFound("Файл ещё не готов или задача не найдена"); |
| | | 2101 | | } |
| | | 2102 | | |
| | 0 | 2103 | | var bytes = System.IO.File.ReadAllBytes(filePath); |
| | 0 | 2104 | | return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); |
| | | 2105 | | } |
| | | 2106 | | |
| | | 2107 | | [HttpPost("sync-reports/{reportId}/send-email")] |
| | | 2108 | | [HttpPost("sync-reports/{reportId}/send-email-tasks/start")] |
| | | 2109 | | [IgnoreAntiforgeryToken] |
| | | 2110 | | public IActionResult SendSyncReportEmail( |
| | | 2111 | | string reportId, |
| | | 2112 | | [FromQuery] string? article = null, |
| | | 2113 | | [FromQuery] List<string>? oneCStores = null, |
| | | 2114 | | [FromQuery] string? ozonWarehouse = null, |
| | | 2115 | | [FromQuery] string? action = null, |
| | | 2116 | | [FromQuery] List<string>? actions = null, |
| | | 2117 | | [FromQuery] bool onlyRequiresAction = false, |
| | | 2118 | | [FromQuery] bool onlyOzonAppliedSuccess = false, |
| | | 2119 | | [FromQuery] bool onlyOzonAppliedFailed = false, |
| | | 2120 | | [FromQuery] bool onlyOzonOrphan = false) |
| | | 2121 | | { |
| | 0 | 2122 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 2123 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 2124 | | |
| | 0 | 2125 | | var task = _channelTasks.Submit(ChannelTaskType.BitrixSyncReportEmail, new BitrixSyncReportTaskPayload |
| | 0 | 2126 | | { |
| | 0 | 2127 | | ReportId = reportId.Trim(), |
| | 0 | 2128 | | Query = BuildSyncReportQuery(article, oneCStores, ozonWarehouse, action, actions, |
| | 0 | 2129 | | onlyRequiresAction, onlyOzonAppliedSuccess, onlyOzonAppliedFailed, onlyOzonOrphan) |
| | 0 | 2130 | | }); |
| | | 2131 | | |
| | 0 | 2132 | | return Ok(ChannelTaskResponses.ToStartResponse(task)); |
| | | 2133 | | } |
| | | 2134 | | |
| | | 2135 | | private static BitrixStockSyncReportQuery BuildSyncReportQuery( |
| | | 2136 | | string? article, |
| | | 2137 | | List<string>? oneCStores, |
| | | 2138 | | string? ozonWarehouse, |
| | | 2139 | | string? action, |
| | | 2140 | | List<string>? actions, |
| | | 2141 | | bool onlyRequiresAction, |
| | | 2142 | | bool onlyOzonAppliedSuccess, |
| | | 2143 | | bool onlyOzonAppliedFailed, |
| | | 2144 | | bool onlyOzonOrphan) |
| | 0 | 2145 | | => new() |
| | 0 | 2146 | | { |
| | 0 | 2147 | | Article = article, |
| | 0 | 2148 | | OneCStores = oneCStores, |
| | 0 | 2149 | | OzonWarehouse = ozonWarehouse, |
| | 0 | 2150 | | Action = action, |
| | 0 | 2151 | | Actions = actions, |
| | 0 | 2152 | | OnlyRequiresAction = onlyRequiresAction, |
| | 0 | 2153 | | OnlyOzonAppliedSuccess = onlyOzonAppliedSuccess, |
| | 0 | 2154 | | OnlyOzonAppliedFailed = onlyOzonAppliedFailed, |
| | 0 | 2155 | | OnlyOzonOrphan = onlyOzonOrphan |
| | 0 | 2156 | | }; |
| | | 2157 | | |
| | | 2158 | | [HttpGet("sync-reports/{reportId}/apply-log")] |
| | | 2159 | | public async Task<IActionResult> GetApplyArticleLog( |
| | | 2160 | | string reportId, |
| | | 2161 | | [FromQuery] string? jobId = null, |
| | | 2162 | | [FromQuery] string? article = null, |
| | | 2163 | | [FromQuery] int page = 1, |
| | | 2164 | | [FromQuery] int pageSize = 50) |
| | | 2165 | | { |
| | 0 | 2166 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 2167 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 2168 | | |
| | 0 | 2169 | | var result = await _applyArticleLogService.GetPageAsync( |
| | 0 | 2170 | | reportId.Trim(), jobId?.Trim(), article?.Trim(), page, pageSize); |
| | 0 | 2171 | | return Ok(new { success = true, log = result }); |
| | 0 | 2172 | | } |
| | | 2173 | | |
| | | 2174 | | [HttpGet("sync-reports/{reportId}/apply-log/jobs")] |
| | | 2175 | | public async Task<IActionResult> GetApplyLogJobs(string reportId) |
| | | 2176 | | { |
| | 0 | 2177 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 2178 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 2179 | | |
| | 0 | 2180 | | var jobs = await _applyArticleLogService.GetJobIdsAsync(reportId.Trim()); |
| | | 2181 | | return Ok(new { success = true, jobs = jobs.Select(j => new { jobId = j.JobId, appliedAt = j.AppliedAt }) }); |
| | 0 | 2182 | | } |
| | | 2183 | | |
| | | 2184 | | [HttpGet("sync-reports/{reportId}/apply-log/analysis")] |
| | | 2185 | | public async Task<IActionResult> GetApplyLogAnalysis( |
| | | 2186 | | string reportId, |
| | | 2187 | | [FromQuery] string? jobId = null, |
| | | 2188 | | [FromQuery] string? article = null) |
| | | 2189 | | { |
| | 0 | 2190 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 2191 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | | 2192 | | |
| | 0 | 2193 | | var analysis = await _applyArticleLogService.GetAnalysisAsync( |
| | 0 | 2194 | | reportId.Trim(), |
| | 0 | 2195 | | jobId?.Trim(), |
| | 0 | 2196 | | article?.Trim()); |
| | 0 | 2197 | | return Ok(new { success = true, analysis }); |
| | 0 | 2198 | | } |
| | | 2199 | | |
| | | 2200 | | public class SyncReportArchiveRequest |
| | | 2201 | | { |
| | 0 | 2202 | | public List<string> OfferIds { get; set; } = new(); |
| | | 2203 | | } |
| | | 2204 | | |
| | | 2205 | | [HttpPost("sync-reports/{reportId}/unarchive-products")] |
| | | 2206 | | [IgnoreAntiforgeryToken] |
| | | 2207 | | public async Task<IActionResult> UnarchiveReportProducts(string reportId, [FromBody] SyncReportArchiveRequest reques |
| | | 2208 | | { |
| | 0 | 2209 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 2210 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | 0 | 2211 | | if (request?.OfferIds == null || request.OfferIds.Count == 0) |
| | 0 | 2212 | | return BadRequest(new { success = false, error = "OfferIds обязателен" }); |
| | | 2213 | | |
| | | 2214 | | // Получить OzonClientId из отчёта |
| | 0 | 2215 | | var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(), |
| | 0 | 2216 | | new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 }); |
| | 0 | 2217 | | if (report == null) return NotFound(new { success = false, error = "Отчёт не найден" }); |
| | | 2218 | | |
| | 0 | 2219 | | var allClients = (await _ozonClientServices.GetOzonClients()) |
| | | 2220 | | .Where(c => c.ClientType == ClientType.OZON) |
| | 0 | 2221 | | .ToList(); |
| | 0 | 2222 | | var reportClientIds = (report.MultiClientIds ?? new List<int>()) |
| | | 2223 | | .Where(id => id > 0) |
| | 0 | 2224 | | .Distinct() |
| | 0 | 2225 | | .ToList(); |
| | 0 | 2226 | | if (!reportClientIds.Contains(report.OzonClientId)) |
| | | 2227 | | { |
| | 0 | 2228 | | reportClientIds.Add(report.OzonClientId); |
| | | 2229 | | } |
| | 0 | 2230 | | var targetClients = allClients.Where(c => reportClientIds.Contains(c.Id)).ToList(); |
| | 0 | 2231 | | if (targetClients.Count == 0) return BadRequest(new { success = false, error = "Клиенты Ozon не найдены" }); |
| | | 2232 | | |
| | | 2233 | | // Получить OzonProductId по OfferIds по всем клиентам отчёта |
| | 0 | 2234 | | var productIds = new List<long>(); |
| | 0 | 2235 | | foreach (var targetClient in targetClients) |
| | | 2236 | | { |
| | 0 | 2237 | | if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey)) |
| | | 2238 | | continue; |
| | | 2239 | | |
| | 0 | 2240 | | var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey); |
| | 0 | 2241 | | foreach (var chunk in request.OfferIds.Chunk(100)) |
| | | 2242 | | { |
| | 0 | 2243 | | var resp = await apiManager.GetProductInfoListAsync(offerIds: chunk.ToArray()); |
| | 0 | 2244 | | if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items) continue; |
| | 0 | 2245 | | foreach (var item in items) |
| | | 2246 | | { |
| | 0 | 2247 | | var pid = item["id"]?.Value<long>(); |
| | 0 | 2248 | | if (pid is > 0) productIds.Add(pid.Value); |
| | | 2249 | | } |
| | | 2250 | | } |
| | 0 | 2251 | | } |
| | | 2252 | | |
| | 0 | 2253 | | if (productIds.Count == 0) return Ok(new { success = true, unarchived = 0 }); |
| | | 2254 | | |
| | 0 | 2255 | | var uniqueProductIds = productIds.Distinct().ToArray(); |
| | 0 | 2256 | | var unarchiveOk = false; |
| | 0 | 2257 | | foreach (var targetClient in targetClients) |
| | | 2258 | | { |
| | 0 | 2259 | | if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey)) |
| | | 2260 | | continue; |
| | | 2261 | | |
| | 0 | 2262 | | var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey); |
| | 0 | 2263 | | if (await apiManager.UnarchiveProductsAsync(uniqueProductIds)) |
| | | 2264 | | { |
| | 0 | 2265 | | unarchiveOk = true; |
| | | 2266 | | } |
| | | 2267 | | } |
| | 0 | 2268 | | return Ok(new { success = unarchiveOk, unarchived = uniqueProductIds.Length }); |
| | 0 | 2269 | | } |
| | | 2270 | | |
| | | 2271 | | [HttpPost("sync-reports/{reportId}/archive-products")] |
| | | 2272 | | [IgnoreAntiforgeryToken] |
| | | 2273 | | public async Task<IActionResult> ArchiveReportProducts(string reportId, [FromBody] SyncReportArchiveRequest request) |
| | | 2274 | | { |
| | 0 | 2275 | | if (string.IsNullOrWhiteSpace(reportId)) |
| | 0 | 2276 | | return BadRequest(new { success = false, error = "reportId обязателен" }); |
| | 0 | 2277 | | if (request?.OfferIds == null || request.OfferIds.Count == 0) |
| | 0 | 2278 | | return BadRequest(new { success = false, error = "OfferIds обязателен" }); |
| | | 2279 | | |
| | 0 | 2280 | | var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(), |
| | 0 | 2281 | | new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 }); |
| | 0 | 2282 | | if (report == null) return NotFound(new { success = false, error = "Отчёт не найден" }); |
| | | 2283 | | |
| | 0 | 2284 | | var allClients = (await _ozonClientServices.GetOzonClients()) |
| | | 2285 | | .Where(c => c.ClientType == ClientType.OZON) |
| | 0 | 2286 | | .ToList(); |
| | 0 | 2287 | | var reportClientIds = (report.MultiClientIds ?? new List<int>()) |
| | | 2288 | | .Where(id => id > 0) |
| | 0 | 2289 | | .Distinct() |
| | 0 | 2290 | | .ToList(); |
| | 0 | 2291 | | if (!reportClientIds.Contains(report.OzonClientId)) |
| | | 2292 | | { |
| | 0 | 2293 | | reportClientIds.Add(report.OzonClientId); |
| | | 2294 | | } |
| | 0 | 2295 | | var targetClients = allClients.Where(c => reportClientIds.Contains(c.Id)).ToList(); |
| | 0 | 2296 | | if (targetClients.Count == 0) return BadRequest(new { success = false, error = "Клиенты Ozon не найдены" }); |
| | | 2297 | | |
| | 0 | 2298 | | var productIds = new List<long>(); |
| | 0 | 2299 | | foreach (var targetClient in targetClients) |
| | | 2300 | | { |
| | 0 | 2301 | | if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey)) |
| | | 2302 | | continue; |
| | | 2303 | | |
| | 0 | 2304 | | var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey); |
| | 0 | 2305 | | foreach (var chunk in request.OfferIds.Chunk(100)) |
| | | 2306 | | { |
| | 0 | 2307 | | var resp = await apiManager.GetProductInfoListAsync(offerIds: chunk.ToArray()); |
| | 0 | 2308 | | if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items) continue; |
| | 0 | 2309 | | foreach (var item in items) |
| | | 2310 | | { |
| | 0 | 2311 | | var pid = item["id"]?.Value<long>(); |
| | 0 | 2312 | | if (pid is > 0) productIds.Add(pid.Value); |
| | | 2313 | | } |
| | | 2314 | | } |
| | 0 | 2315 | | } |
| | | 2316 | | |
| | 0 | 2317 | | if (productIds.Count == 0) return Ok(new { success = true, archived = 0 }); |
| | | 2318 | | |
| | 0 | 2319 | | var uniqueProductIds = productIds.Distinct().ToArray(); |
| | 0 | 2320 | | var archiveOk = false; |
| | 0 | 2321 | | foreach (var targetClient in targetClients) |
| | | 2322 | | { |
| | 0 | 2323 | | if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey)) |
| | | 2324 | | continue; |
| | | 2325 | | |
| | 0 | 2326 | | var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey); |
| | 0 | 2327 | | if (await apiManager.ArchiveProductsAsync(uniqueProductIds)) |
| | | 2328 | | { |
| | 0 | 2329 | | archiveOk = true; |
| | | 2330 | | } |
| | | 2331 | | } |
| | 0 | 2332 | | return Ok(new { success = archiveOk, archived = uniqueProductIds.Length }); |
| | 0 | 2333 | | } |
| | | 2334 | | |
| | | 2335 | | /// <summary>Загружает все строки отчёта постранично и строит Excel-файл.</summary> |
| | | 2336 | | private Task<(byte[]? bytes, string filename)> BuildSyncReportExcelAsync( |
| | | 2337 | | string reportId, BitrixStockSyncReportQuery filterQuery) |
| | 0 | 2338 | | => BitrixStockSyncReportExcelBuilder.BuildAsync(_bitrixStockSyncReportService, reportId, filterQuery); |
| | | 2339 | | |
| | | 2340 | | [HttpPost("push-jobs/start")] |
| | | 2341 | | [IgnoreAntiforgeryToken] |
| | | 2342 | | public async Task<IActionResult> StartPushJob([FromBody] BitrixStockPushStartRequest request) |
| | | 2343 | | { |
| | 0 | 2344 | | if (request == null || string.IsNullOrWhiteSpace(request.JobId)) |
| | | 2345 | | { |
| | 0 | 2346 | | return BadRequest(new { success = false, error = "jobId обязателен" }); |
| | | 2347 | | } |
| | | 2348 | | |
| | 0 | 2349 | | if (request.Items == null || request.Items.Count == 0) |
| | | 2350 | | { |
| | 0 | 2351 | | return BadRequest(new { success = false, error = "Не переданы позиции для наката" }); |
| | | 2352 | | } |
| | | 2353 | | |
| | 0 | 2354 | | if (request.Items.Count > 500) |
| | | 2355 | | { |
| | 0 | 2356 | | return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" }); |
| | | 2357 | | } |
| | | 2358 | | |
| | 0 | 2359 | | foreach (var item in request.Items) |
| | | 2360 | | { |
| | 0 | 2361 | | if (string.IsNullOrWhiteSpace(item.OzonClient) || |
| | 0 | 2362 | | string.IsNullOrWhiteSpace(item.OfferId) || |
| | 0 | 2363 | | item.ProductId <= 0 || |
| | 0 | 2364 | | item.WarehouseId <= 0) |
| | | 2365 | | { |
| | 0 | 2366 | | return BadRequest(new { success = false, error = "Некорректные данные позиции (клиент, offer, product, с |
| | | 2367 | | } |
| | | 2368 | | } |
| | | 2369 | | |
| | | 2370 | | try |
| | | 2371 | | { |
| | 0 | 2372 | | using var lockConnection = JobStorage.Current.GetConnection(); |
| | 0 | 2373 | | using var distLock = lockConnection.AcquireDistributedLock( |
| | 0 | 2374 | | BitrixStockPushJobService.PushLockKey, |
| | 0 | 2375 | | TimeSpan.FromMilliseconds(1)); |
| | 0 | 2376 | | } |
| | 0 | 2377 | | catch (DistributedLockTimeoutException) |
| | | 2378 | | { |
| | 0 | 2379 | | return StatusCode(409, new { success = false, error = "Накат остатков уже выполняется. Дождитесь завершения. |
| | | 2380 | | } |
| | | 2381 | | |
| | | 2382 | | try |
| | | 2383 | | { |
| | 0 | 2384 | | var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); |
| | 0 | 2385 | | var userName = User.Identity?.Name; |
| | | 2386 | | |
| | 0 | 2387 | | var execution = await _bitrixStockPushJobService.CreateExecutionAsync( |
| | 0 | 2388 | | request.JobId.Trim(), |
| | 0 | 2389 | | request.Items, |
| | 0 | 2390 | | userId, |
| | 0 | 2391 | | userName); |
| | | 2392 | | |
| | 0 | 2393 | | var hangfireJob = Job.FromExpression<IBitrixStockPushJobService>(service => |
| | 0 | 2394 | | service.ProcessPushAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None)); |
| | 0 | 2395 | | var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQ |
| | | 2396 | | |
| | 0 | 2397 | | await _bitrixStockPushJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId); |
| | | 2398 | | |
| | 0 | 2399 | | return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id }); |
| | | 2400 | | } |
| | 0 | 2401 | | catch (Exception ex) |
| | | 2402 | | { |
| | 0 | 2403 | | var message = ex.Message; |
| | 0 | 2404 | | if (message.Contains("BitrixStockPush", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 2405 | | message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase)) |
| | | 2406 | | { |
| | 0 | 2407 | | message = "Таблицы наката не созданы в БД. Выполните Scripts/Sql/create_bitrix_stock_push_jobs_tables.sq |
| | | 2408 | | } |
| | | 2409 | | |
| | 0 | 2410 | | return StatusCode(500, new { success = false, error = message }); |
| | | 2411 | | } |
| | 0 | 2412 | | } |
| | | 2413 | | |
| | | 2414 | | [HttpGet("push-jobs")] |
| | | 2415 | | public async Task<IActionResult> GetPushJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20) |
| | | 2416 | | { |
| | 0 | 2417 | | var (items, total) = await _bitrixStockPushJobService.GetExecutionsPageAsync(page, pageSize); |
| | 0 | 2418 | | return Ok(new |
| | 0 | 2419 | | { |
| | 0 | 2420 | | success = true, |
| | 0 | 2421 | | page, |
| | 0 | 2422 | | pageSize, |
| | 0 | 2423 | | total, |
| | | 2424 | | items = items.Select(e => new |
| | | 2425 | | { |
| | | 2426 | | e.Id, |
| | | 2427 | | e.JobId, |
| | | 2428 | | e.Status, |
| | | 2429 | | e.TriggerType, |
| | | 2430 | | e.StartedAt, |
| | | 2431 | | e.CompletedAt, |
| | | 2432 | | e.DurationMs, |
| | | 2433 | | e.TotalItems, |
| | | 2434 | | e.SuccessCount, |
| | | 2435 | | e.FailedCount, |
| | | 2436 | | e.TriggeredByUserName, |
| | | 2437 | | e.ErrorMessage |
| | | 2438 | | }) |
| | 0 | 2439 | | }); |
| | 0 | 2440 | | } |
| | | 2441 | | |
| | | 2442 | | [HttpGet("push-jobs/{id:long}")] |
| | | 2443 | | public async Task<IActionResult> GetPushJobDetails(long id) |
| | | 2444 | | { |
| | 0 | 2445 | | var execution = await _bitrixStockPushJobService.GetExecutionDetailsAsync(id); |
| | 0 | 2446 | | if (execution == null) |
| | | 2447 | | { |
| | 0 | 2448 | | return NotFound(new { success = false, error = "Задание не найдено" }); |
| | | 2449 | | } |
| | | 2450 | | |
| | 0 | 2451 | | return Ok(new |
| | 0 | 2452 | | { |
| | 0 | 2453 | | success = true, |
| | 0 | 2454 | | execution = new |
| | 0 | 2455 | | { |
| | 0 | 2456 | | execution.Id, |
| | 0 | 2457 | | execution.JobId, |
| | 0 | 2458 | | execution.HangfireJobId, |
| | 0 | 2459 | | execution.Status, |
| | 0 | 2460 | | execution.StartedAt, |
| | 0 | 2461 | | execution.CompletedAt, |
| | 0 | 2462 | | execution.DurationMs, |
| | 0 | 2463 | | execution.TotalItems, |
| | 0 | 2464 | | execution.SuccessCount, |
| | 0 | 2465 | | execution.FailedCount, |
| | 0 | 2466 | | execution.TriggeredByUserName, |
| | 0 | 2467 | | execution.ErrorMessage, |
| | | 2468 | | items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new |
| | | 2469 | | { |
| | | 2470 | | i.Id, |
| | | 2471 | | i.Article, |
| | | 2472 | | i.OfferId, |
| | | 2473 | | i.ProductId, |
| | | 2474 | | i.WarehouseId, |
| | | 2475 | | i.WarehouseName, |
| | | 2476 | | i.OzonClient, |
| | | 2477 | | i.LinkedStoreName, |
| | | 2478 | | i.PreviousStock, |
| | | 2479 | | i.TargetStock, |
| | | 2480 | | i.Status, |
| | | 2481 | | i.ErrorMessage, |
| | | 2482 | | i.ProcessedAt |
| | | 2483 | | }) |
| | 0 | 2484 | | } |
| | 0 | 2485 | | }); |
| | 0 | 2486 | | } |
| | | 2487 | | |
| | | 2488 | | [HttpPost("archive-jobs/start")] |
| | | 2489 | | [IgnoreAntiforgeryToken] |
| | | 2490 | | public async Task<IActionResult> StartArchiveJob([FromBody] BitrixStockArchiveStartRequest request) |
| | | 2491 | | { |
| | 0 | 2492 | | if (request == null || string.IsNullOrWhiteSpace(request.JobId)) |
| | | 2493 | | { |
| | 0 | 2494 | | return BadRequest(new { success = false, error = "jobId обязателен" }); |
| | | 2495 | | } |
| | | 2496 | | |
| | 0 | 2497 | | if (request.Items == null || request.Items.Count == 0) |
| | | 2498 | | { |
| | 0 | 2499 | | return BadRequest(new { success = false, error = "Не переданы позиции для архивации" }); |
| | | 2500 | | } |
| | | 2501 | | |
| | 0 | 2502 | | if (request.Items.Count > 500) |
| | | 2503 | | { |
| | 0 | 2504 | | return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" }); |
| | | 2505 | | } |
| | | 2506 | | |
| | 0 | 2507 | | foreach (var item in request.Items) |
| | | 2508 | | { |
| | 0 | 2509 | | if (string.IsNullOrWhiteSpace(item.OzonClient) || |
| | 0 | 2510 | | string.IsNullOrWhiteSpace(item.OfferId) || |
| | 0 | 2511 | | item.OzonProductId <= 0) |
| | | 2512 | | { |
| | 0 | 2513 | | return BadRequest(new { success = false, error = "Некорректные данные (клиент, offer, product_id Ozon)" |
| | | 2514 | | } |
| | | 2515 | | } |
| | | 2516 | | |
| | | 2517 | | try |
| | | 2518 | | { |
| | 0 | 2519 | | using var lockConnection = JobStorage.Current.GetConnection(); |
| | 0 | 2520 | | using var distLock = lockConnection.AcquireDistributedLock( |
| | 0 | 2521 | | BitrixStockArchiveJobService.ArchiveLockKey, |
| | 0 | 2522 | | TimeSpan.FromMilliseconds(1)); |
| | 0 | 2523 | | } |
| | 0 | 2524 | | catch (DistributedLockTimeoutException) |
| | | 2525 | | { |
| | 0 | 2526 | | return StatusCode(409, new { success = false, error = "Архивация уже выполняется. Дождитесь завершения." }); |
| | | 2527 | | } |
| | | 2528 | | |
| | | 2529 | | try |
| | | 2530 | | { |
| | 0 | 2531 | | var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); |
| | 0 | 2532 | | var userName = User.Identity?.Name; |
| | | 2533 | | |
| | 0 | 2534 | | var execution = await _bitrixStockArchiveJobService.CreateExecutionAsync( |
| | 0 | 2535 | | request.JobId.Trim(), |
| | 0 | 2536 | | request.Items, |
| | 0 | 2537 | | userId, |
| | 0 | 2538 | | userName); |
| | | 2539 | | |
| | 0 | 2540 | | var hangfireJob = Job.FromExpression<IBitrixStockArchiveJobService>(service => |
| | 0 | 2541 | | service.ProcessArchiveAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None)); |
| | 0 | 2542 | | var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQ |
| | | 2543 | | |
| | 0 | 2544 | | await _bitrixStockArchiveJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId); |
| | | 2545 | | |
| | 0 | 2546 | | return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id }); |
| | | 2547 | | } |
| | 0 | 2548 | | catch (Exception ex) |
| | | 2549 | | { |
| | 0 | 2550 | | var message = GetFullExceptionMessage(ex); |
| | 0 | 2551 | | if (message.Contains("BitrixStockArchive", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 2552 | | message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 2553 | | message.Contains("Unknown table", StringComparison.OrdinalIgnoreCase)) |
| | | 2554 | | { |
| | 0 | 2555 | | message = "Таблицы архивации не созданы в БД. Выполните Scripts/Sql/create_bitrix_stock_archive_jobs_tab |
| | | 2556 | | } |
| | | 2557 | | |
| | 0 | 2558 | | return StatusCode(500, new { success = false, error = message }); |
| | | 2559 | | } |
| | 0 | 2560 | | } |
| | | 2561 | | |
| | | 2562 | | [HttpGet("archive-jobs")] |
| | | 2563 | | public async Task<IActionResult> GetArchiveJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20) |
| | | 2564 | | { |
| | 0 | 2565 | | var (items, total) = await _bitrixStockArchiveJobService.GetExecutionsPageAsync(page, pageSize); |
| | 0 | 2566 | | return Ok(new |
| | 0 | 2567 | | { |
| | 0 | 2568 | | success = true, |
| | 0 | 2569 | | page, |
| | 0 | 2570 | | pageSize, |
| | 0 | 2571 | | total, |
| | | 2572 | | items = items.Select(e => new |
| | | 2573 | | { |
| | | 2574 | | e.Id, |
| | | 2575 | | e.JobId, |
| | | 2576 | | e.Status, |
| | | 2577 | | e.TriggerType, |
| | | 2578 | | e.StartedAt, |
| | | 2579 | | e.CompletedAt, |
| | | 2580 | | e.DurationMs, |
| | | 2581 | | e.TotalItems, |
| | | 2582 | | e.SuccessCount, |
| | | 2583 | | e.FailedCount, |
| | | 2584 | | e.TriggeredByUserName, |
| | | 2585 | | e.ErrorMessage |
| | | 2586 | | }) |
| | 0 | 2587 | | }); |
| | 0 | 2588 | | } |
| | | 2589 | | |
| | | 2590 | | [HttpGet("archive-jobs/{id:long}")] |
| | | 2591 | | public async Task<IActionResult> GetArchiveJobDetails(long id) |
| | | 2592 | | { |
| | 0 | 2593 | | var execution = await _bitrixStockArchiveJobService.GetExecutionDetailsAsync(id); |
| | 0 | 2594 | | if (execution == null) |
| | | 2595 | | { |
| | 0 | 2596 | | return NotFound(new { success = false, error = "Задание не найдено" }); |
| | | 2597 | | } |
| | | 2598 | | |
| | 0 | 2599 | | return Ok(new |
| | 0 | 2600 | | { |
| | 0 | 2601 | | success = true, |
| | 0 | 2602 | | execution = new |
| | 0 | 2603 | | { |
| | 0 | 2604 | | execution.Id, |
| | 0 | 2605 | | execution.JobId, |
| | 0 | 2606 | | execution.HangfireJobId, |
| | 0 | 2607 | | execution.Status, |
| | 0 | 2608 | | execution.StartedAt, |
| | 0 | 2609 | | execution.CompletedAt, |
| | 0 | 2610 | | execution.DurationMs, |
| | 0 | 2611 | | execution.TotalItems, |
| | 0 | 2612 | | execution.SuccessCount, |
| | 0 | 2613 | | execution.FailedCount, |
| | 0 | 2614 | | execution.TriggeredByUserName, |
| | 0 | 2615 | | execution.ErrorMessage, |
| | | 2616 | | items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new |
| | | 2617 | | { |
| | | 2618 | | i.Id, |
| | | 2619 | | i.Article, |
| | | 2620 | | i.OfferId, |
| | | 2621 | | i.OzonProductId, |
| | | 2622 | | i.OzonClient, |
| | | 2623 | | i.WasArchived, |
| | | 2624 | | i.Status, |
| | | 2625 | | i.ErrorMessage, |
| | | 2626 | | i.ProcessedAt |
| | | 2627 | | }) |
| | 0 | 2628 | | } |
| | 0 | 2629 | | }); |
| | 0 | 2630 | | } |
| | | 2631 | | |
| | | 2632 | | [HttpPost("unarchive-jobs/start")] |
| | | 2633 | | [IgnoreAntiforgeryToken] |
| | | 2634 | | public async Task<IActionResult> StartUnarchiveJob([FromBody] BitrixStockUnarchiveStartRequest request) |
| | | 2635 | | { |
| | 0 | 2636 | | if (request == null || string.IsNullOrWhiteSpace(request.JobId)) |
| | | 2637 | | { |
| | 0 | 2638 | | return BadRequest(new { success = false, error = "jobId обязателен" }); |
| | | 2639 | | } |
| | | 2640 | | |
| | 0 | 2641 | | if (request.Items == null || request.Items.Count == 0) |
| | | 2642 | | { |
| | 0 | 2643 | | return BadRequest(new { success = false, error = "Не переданы позиции для разархивации" }); |
| | | 2644 | | } |
| | | 2645 | | |
| | 0 | 2646 | | if (request.Items.Count > 500) |
| | | 2647 | | { |
| | 0 | 2648 | | return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" }); |
| | | 2649 | | } |
| | | 2650 | | |
| | 0 | 2651 | | foreach (var item in request.Items) |
| | | 2652 | | { |
| | 0 | 2653 | | if (string.IsNullOrWhiteSpace(item.OzonClient) || |
| | 0 | 2654 | | string.IsNullOrWhiteSpace(item.OfferId) || |
| | 0 | 2655 | | item.OzonProductId <= 0) |
| | | 2656 | | { |
| | 0 | 2657 | | return BadRequest(new { success = false, error = "Некорректные данные (клиент, offer, product_id Ozon)" |
| | | 2658 | | } |
| | | 2659 | | } |
| | | 2660 | | |
| | | 2661 | | try |
| | | 2662 | | { |
| | 0 | 2663 | | using var lockConnection = JobStorage.Current.GetConnection(); |
| | 0 | 2664 | | using var distLock = lockConnection.AcquireDistributedLock( |
| | 0 | 2665 | | BitrixStockUnarchiveJobService.UnarchiveLockKey, |
| | 0 | 2666 | | TimeSpan.FromMilliseconds(1)); |
| | 0 | 2667 | | } |
| | 0 | 2668 | | catch (DistributedLockTimeoutException) |
| | | 2669 | | { |
| | 0 | 2670 | | return StatusCode(409, new { success = false, error = "Разархивация уже выполняется. Дождитесь завершения." |
| | | 2671 | | } |
| | | 2672 | | |
| | | 2673 | | try |
| | | 2674 | | { |
| | 0 | 2675 | | var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); |
| | 0 | 2676 | | var userName = User.Identity?.Name; |
| | | 2677 | | |
| | 0 | 2678 | | var execution = await _bitrixStockUnarchiveJobService.CreateExecutionAsync( |
| | 0 | 2679 | | request.JobId.Trim(), |
| | 0 | 2680 | | request.Items, |
| | 0 | 2681 | | userId, |
| | 0 | 2682 | | userName); |
| | | 2683 | | |
| | 0 | 2684 | | var hangfireJob = Job.FromExpression<IBitrixStockUnarchiveJobService>(service => |
| | 0 | 2685 | | service.ProcessUnarchiveAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None)); |
| | 0 | 2686 | | var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQ |
| | | 2687 | | |
| | 0 | 2688 | | await _bitrixStockUnarchiveJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId); |
| | | 2689 | | |
| | 0 | 2690 | | return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id }); |
| | | 2691 | | } |
| | 0 | 2692 | | catch (Exception ex) |
| | | 2693 | | { |
| | 0 | 2694 | | var message = GetFullExceptionMessage(ex); |
| | 0 | 2695 | | if (message.Contains("BitrixStockUnarchive", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 2696 | | message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 2697 | | message.Contains("Unknown table", StringComparison.OrdinalIgnoreCase)) |
| | | 2698 | | { |
| | 0 | 2699 | | message = "Таблицы разархивации не созданы в БД. Выполните Scripts/Sql/create_bitrix_stock_unarchive_job |
| | | 2700 | | } |
| | | 2701 | | |
| | 0 | 2702 | | return StatusCode(500, new { success = false, error = message }); |
| | | 2703 | | } |
| | 0 | 2704 | | } |
| | | 2705 | | |
| | | 2706 | | [HttpGet("unarchive-jobs")] |
| | | 2707 | | public async Task<IActionResult> GetUnarchiveJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20) |
| | | 2708 | | { |
| | 0 | 2709 | | var (items, total) = await _bitrixStockUnarchiveJobService.GetExecutionsPageAsync(page, pageSize); |
| | 0 | 2710 | | return Ok(new |
| | 0 | 2711 | | { |
| | 0 | 2712 | | success = true, |
| | 0 | 2713 | | page, |
| | 0 | 2714 | | pageSize, |
| | 0 | 2715 | | total, |
| | | 2716 | | items = items.Select(e => new |
| | | 2717 | | { |
| | | 2718 | | e.Id, |
| | | 2719 | | e.JobId, |
| | | 2720 | | e.Status, |
| | | 2721 | | e.TriggerType, |
| | | 2722 | | e.StartedAt, |
| | | 2723 | | e.CompletedAt, |
| | | 2724 | | e.DurationMs, |
| | | 2725 | | e.TotalItems, |
| | | 2726 | | e.SuccessCount, |
| | | 2727 | | e.FailedCount, |
| | | 2728 | | e.TriggeredByUserName, |
| | | 2729 | | e.ErrorMessage |
| | | 2730 | | }) |
| | 0 | 2731 | | }); |
| | 0 | 2732 | | } |
| | | 2733 | | |
| | | 2734 | | [HttpGet("unarchive-jobs/{id:long}")] |
| | | 2735 | | public async Task<IActionResult> GetUnarchiveJobDetails(long id) |
| | | 2736 | | { |
| | 0 | 2737 | | var execution = await _bitrixStockUnarchiveJobService.GetExecutionDetailsAsync(id); |
| | 0 | 2738 | | if (execution == null) |
| | | 2739 | | { |
| | 0 | 2740 | | return NotFound(new { success = false, error = "Задание не найдено" }); |
| | | 2741 | | } |
| | | 2742 | | |
| | 0 | 2743 | | return Ok(new |
| | 0 | 2744 | | { |
| | 0 | 2745 | | success = true, |
| | 0 | 2746 | | execution = new |
| | 0 | 2747 | | { |
| | 0 | 2748 | | execution.Id, |
| | 0 | 2749 | | execution.JobId, |
| | 0 | 2750 | | execution.HangfireJobId, |
| | 0 | 2751 | | execution.Status, |
| | 0 | 2752 | | execution.StartedAt, |
| | 0 | 2753 | | execution.CompletedAt, |
| | 0 | 2754 | | execution.DurationMs, |
| | 0 | 2755 | | execution.TotalItems, |
| | 0 | 2756 | | execution.SuccessCount, |
| | 0 | 2757 | | execution.FailedCount, |
| | 0 | 2758 | | execution.TriggeredByUserName, |
| | 0 | 2759 | | execution.ErrorMessage, |
| | | 2760 | | items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new |
| | | 2761 | | { |
| | | 2762 | | i.Id, |
| | | 2763 | | i.Article, |
| | | 2764 | | i.OfferId, |
| | | 2765 | | i.OzonProductId, |
| | | 2766 | | i.OzonClient, |
| | | 2767 | | i.IsAutoArchived, |
| | | 2768 | | i.IsManualArchived, |
| | | 2769 | | i.WasInArchive, |
| | | 2770 | | i.Status, |
| | | 2771 | | i.ErrorMessage, |
| | | 2772 | | i.ProcessedAt |
| | | 2773 | | }) |
| | 0 | 2774 | | } |
| | 0 | 2775 | | }); |
| | 0 | 2776 | | } |
| | | 2777 | | |
| | | 2778 | | [HttpPost("auto-schedules/parse-filter")] |
| | | 2779 | | [IgnoreAntiforgeryToken] |
| | | 2780 | | public async Task<IActionResult> ParseAutoScheduleFilter([FromBody] BitrixStockAutoParseFilterRequest request) |
| | | 2781 | | { |
| | 0 | 2782 | | if (request.OzonClientId <= 0) |
| | | 2783 | | { |
| | 0 | 2784 | | return BadRequest(new { success = false, error = "Выберите клиента Ozon" }); |
| | | 2785 | | } |
| | | 2786 | | |
| | 0 | 2787 | | var action = string.IsNullOrWhiteSpace(request.ActionType) |
| | 0 | 2788 | | ? BitrixStockAutoActionTypes.Clear |
| | 0 | 2789 | | : request.ActionType.Trim(); |
| | 0 | 2790 | | var parseRequest = new BitrixStockAutoScheduleRequest |
| | 0 | 2791 | | { |
| | 0 | 2792 | | OzonClientId = request.OzonClientId, |
| | 0 | 2793 | | ActionType = action, |
| | 0 | 2794 | | FilterQuery = request.QueryString, |
| | 0 | 2795 | | SelectedStoreTitles = request.SelectedStoreTitles |
| | 0 | 2796 | | }; |
| | 0 | 2797 | | var filterJson = await BuildAutoScheduleFilterJsonAsync(parseRequest, action); |
| | 0 | 2798 | | var filter = BitrixStockInventoryService.DeserializeFilter(filterJson); |
| | | 2799 | | |
| | 0 | 2800 | | return Ok(new |
| | 0 | 2801 | | { |
| | 0 | 2802 | | success = true, |
| | 0 | 2803 | | filterJson, |
| | 0 | 2804 | | description = BitrixStockAutoFilterFactory.DescribeFilter(filter, action) |
| | 0 | 2805 | | }); |
| | 0 | 2806 | | } |
| | | 2807 | | |
| | | 2808 | | [HttpPost("auto-schedules/preview")] |
| | | 2809 | | [IgnoreAntiforgeryToken] |
| | | 2810 | | public async Task<IActionResult> PreviewAutoSchedule([FromBody] BitrixStockAutoScheduleRequest request) |
| | | 2811 | | { |
| | 0 | 2812 | | var error = ValidateAutoScheduleRequest(request); |
| | 0 | 2813 | | if (error != null) |
| | | 2814 | | { |
| | 0 | 2815 | | return BadRequest(new { success = false, error }); |
| | | 2816 | | } |
| | | 2817 | | |
| | | 2818 | | try |
| | | 2819 | | { |
| | 0 | 2820 | | var entity = await MapAutoScheduleEntityAsync(request); |
| | 0 | 2821 | | var collected = await _bitrixStockInventoryService.CollectForScheduleAsync(entity); |
| | 0 | 2822 | | var isClear = string.Equals(entity.ActionType, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgn |
| | 0 | 2823 | | var items = isClear ? collected.ClearItems.Count : collected.PushItems.Count; |
| | 0 | 2824 | | return Ok(new |
| | 0 | 2825 | | { |
| | 0 | 2826 | | success = true, |
| | 0 | 2827 | | productsScanned = collected.ProductsScanned, |
| | 0 | 2828 | | itemsToProcess = items, |
| | 0 | 2829 | | warning = collected.Warning |
| | 0 | 2830 | | }); |
| | | 2831 | | } |
| | 0 | 2832 | | catch (Exception ex) |
| | | 2833 | | { |
| | 0 | 2834 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 2835 | | } |
| | 0 | 2836 | | } |
| | | 2837 | | |
| | | 2838 | | [HttpGet("auto-schedules")] |
| | | 2839 | | public async Task<IActionResult> GetAutoSchedules() |
| | | 2840 | | { |
| | 0 | 2841 | | var list = await _bitrixStockAutoScheduleService.GetAllAsync(); |
| | 0 | 2842 | | return Ok(new |
| | 0 | 2843 | | { |
| | 0 | 2844 | | success = true, |
| | | 2845 | | items = list.Select(s => MapAutoScheduleDto(s)) |
| | 0 | 2846 | | }); |
| | 0 | 2847 | | } |
| | | 2848 | | |
| | | 2849 | | [HttpPost("auto-schedules")] |
| | | 2850 | | public async Task<IActionResult> CreateAutoSchedule([FromBody] BitrixStockAutoScheduleRequest request) |
| | | 2851 | | { |
| | 0 | 2852 | | var error = ValidateAutoScheduleRequest(request); |
| | 0 | 2853 | | if (error != null) |
| | | 2854 | | { |
| | 0 | 2855 | | return BadRequest(new { success = false, error }); |
| | | 2856 | | } |
| | | 2857 | | |
| | | 2858 | | try |
| | | 2859 | | { |
| | 0 | 2860 | | var id = await _bitrixStockAutoScheduleService.CreateAsync(await MapAutoScheduleEntityAsync(request)); |
| | 0 | 2861 | | return Ok(new { success = true, id }); |
| | | 2862 | | } |
| | 0 | 2863 | | catch (Exception ex) |
| | | 2864 | | { |
| | 0 | 2865 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 2866 | | } |
| | 0 | 2867 | | } |
| | | 2868 | | |
| | | 2869 | | [HttpPut("auto-schedules/{id:int}")] |
| | | 2870 | | public async Task<IActionResult> UpdateAutoSchedule(int id, [FromBody] BitrixStockAutoScheduleRequest request) |
| | | 2871 | | { |
| | 0 | 2872 | | var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id); |
| | 0 | 2873 | | if (existing == null) |
| | | 2874 | | { |
| | 0 | 2875 | | return NotFound(new { success = false, error = "Расписание не найдено" }); |
| | | 2876 | | } |
| | | 2877 | | |
| | 0 | 2878 | | request.Id = id; |
| | 0 | 2879 | | var error = ValidateAutoScheduleRequest(request); |
| | 0 | 2880 | | if (error != null) |
| | | 2881 | | { |
| | 0 | 2882 | | return BadRequest(new { success = false, error }); |
| | | 2883 | | } |
| | | 2884 | | |
| | | 2885 | | try |
| | | 2886 | | { |
| | 0 | 2887 | | var entity = await MapAutoScheduleEntityAsync(request); |
| | 0 | 2888 | | entity.Id = id; |
| | 0 | 2889 | | entity.CreatedAt = existing.CreatedAt; |
| | 0 | 2890 | | await _bitrixStockAutoScheduleService.UpdateAsync(entity); |
| | 0 | 2891 | | return Ok(new { success = true }); |
| | | 2892 | | } |
| | 0 | 2893 | | catch (Exception ex) |
| | | 2894 | | { |
| | 0 | 2895 | | return StatusCode(500, new { success = false, error = ex.Message }); |
| | | 2896 | | } |
| | 0 | 2897 | | } |
| | | 2898 | | |
| | | 2899 | | [HttpDelete("auto-schedules/{id:int}")] |
| | | 2900 | | public async Task<IActionResult> DeleteAutoSchedule(int id) |
| | | 2901 | | { |
| | 0 | 2902 | | var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id); |
| | 0 | 2903 | | if (existing == null) |
| | | 2904 | | { |
| | 0 | 2905 | | return NotFound(new { success = false, error = "Расписание не найдено" }); |
| | | 2906 | | } |
| | | 2907 | | |
| | 0 | 2908 | | await _bitrixStockAutoScheduleService.DeleteAsync(id); |
| | 0 | 2909 | | return Ok(new { success = true }); |
| | 0 | 2910 | | } |
| | | 2911 | | |
| | | 2912 | | [HttpPost("auto-schedules/{id:int}/trigger")] |
| | | 2913 | | [IgnoreAntiforgeryToken] |
| | | 2914 | | public async Task<IActionResult> TriggerAutoSchedule(int id, [FromBody] BitrixStockAutoTriggerRequest? request) |
| | | 2915 | | { |
| | 0 | 2916 | | if (request == null || string.IsNullOrWhiteSpace(request.JobId)) |
| | | 2917 | | { |
| | 0 | 2918 | | return BadRequest(new { success = false, error = "jobId обязателен" }); |
| | | 2919 | | } |
| | | 2920 | | |
| | 0 | 2921 | | var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id); |
| | 0 | 2922 | | if (existing == null) |
| | | 2923 | | { |
| | 0 | 2924 | | return NotFound(new { success = false, error = "Расписание не найдено" }); |
| | | 2925 | | } |
| | | 2926 | | |
| | 0 | 2927 | | var isClear = string.Equals(existing.ActionType, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgnor |
| | 0 | 2928 | | var lockKey = isClear ? BitrixStockClearJobService.ClearLockKey : BitrixStockPushJobService.PushLockKey; |
| | | 2929 | | |
| | | 2930 | | try |
| | | 2931 | | { |
| | 0 | 2932 | | using var lockConnection = JobStorage.Current.GetConnection(); |
| | 0 | 2933 | | using var distLock = lockConnection.AcquireDistributedLock(lockKey, TimeSpan.FromMilliseconds(1)); |
| | 0 | 2934 | | } |
| | 0 | 2935 | | catch (DistributedLockTimeoutException) |
| | | 2936 | | { |
| | 0 | 2937 | | return StatusCode(409, new { success = false, error = "Операция Ozon уже выполняется. Дождитесь завершения." |
| | | 2938 | | } |
| | | 2939 | | |
| | 0 | 2940 | | var jobId = request.JobId.Trim(); |
| | 0 | 2941 | | var userName = User.Identity?.Name; |
| | | 2942 | | |
| | 0 | 2943 | | await _bitrixStockAutoScheduleService.TriggerManualAsync(id, jobId, userName); |
| | | 2944 | | |
| | 0 | 2945 | | return Ok(new |
| | 0 | 2946 | | { |
| | 0 | 2947 | | success = true, |
| | 0 | 2948 | | jobId, |
| | 0 | 2949 | | scheduleId = id, |
| | 0 | 2950 | | actionType = existing.ActionType, |
| | 0 | 2951 | | scheduleName = existing.Name |
| | 0 | 2952 | | }); |
| | 0 | 2953 | | } |
| | | 2954 | | |
| | | 2955 | | [HttpGet("auto-schedules/{id:int}/runs")] |
| | | 2956 | | public async Task<IActionResult> GetAutoScheduleRuns(int id) |
| | | 2957 | | { |
| | 0 | 2958 | | var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id); |
| | 0 | 2959 | | if (existing == null) |
| | | 2960 | | { |
| | 0 | 2961 | | return NotFound(new { success = false, error = "Расписание не найдено" }); |
| | | 2962 | | } |
| | | 2963 | | |
| | 0 | 2964 | | var runs = await _bitrixStockAutoScheduleService.GetRunsAsync(id); |
| | 0 | 2965 | | return Ok(new |
| | 0 | 2966 | | { |
| | 0 | 2967 | | success = true, |
| | | 2968 | | items = runs.Select(r => new |
| | | 2969 | | { |
| | | 2970 | | r.Id, |
| | | 2971 | | r.Status, |
| | | 2972 | | r.StartedAt, |
| | | 2973 | | r.CompletedAt, |
| | | 2974 | | r.CandidatesFound, |
| | | 2975 | | r.ItemsQueued, |
| | | 2976 | | r.ClearExecutionId, |
| | | 2977 | | r.PushExecutionId, |
| | | 2978 | | r.ErrorMessage, |
| | | 2979 | | r.RunLog |
| | | 2980 | | }) |
| | 0 | 2981 | | }); |
| | 0 | 2982 | | } |
| | | 2983 | | |
| | | 2984 | | private static string? ValidateAutoScheduleRequest(BitrixStockAutoScheduleRequest request) |
| | | 2985 | | { |
| | 0 | 2986 | | if (string.IsNullOrWhiteSpace(request.Name)) |
| | | 2987 | | { |
| | 0 | 2988 | | return "Укажите название задачи"; |
| | | 2989 | | } |
| | | 2990 | | |
| | 0 | 2991 | | if (request.OzonClientId <= 0) |
| | | 2992 | | { |
| | 0 | 2993 | | return "Выберите клиента Ozon"; |
| | | 2994 | | } |
| | | 2995 | | |
| | 0 | 2996 | | var action = request.ActionType?.Trim(); |
| | 0 | 2997 | | if (!string.Equals(action, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgnoreCase) && |
| | 0 | 2998 | | !string.Equals(action, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCase)) |
| | | 2999 | | { |
| | 0 | 3000 | | return "Тип задачи: Clear или Push"; |
| | | 3001 | | } |
| | | 3002 | | |
| | | 3003 | | try |
| | | 3004 | | { |
| | 0 | 3005 | | Cronos.CronExpression.Parse(request.CronExpression.Trim()); |
| | 0 | 3006 | | } |
| | 0 | 3007 | | catch |
| | | 3008 | | { |
| | 0 | 3009 | | return "Некорректное cron-выражение"; |
| | | 3010 | | } |
| | | 3011 | | |
| | 0 | 3012 | | return null; |
| | 0 | 3013 | | } |
| | | 3014 | | |
| | | 3015 | | [HttpGet("auto-schedules/default-filter")] |
| | | 3016 | | public async Task<IActionResult> GetAutoScheduleDefaultFilter( |
| | | 3017 | | [FromQuery] string actionType, |
| | | 3018 | | [FromQuery] int ozonClientId) |
| | | 3019 | | { |
| | 0 | 3020 | | if (ozonClientId <= 0) |
| | | 3021 | | { |
| | 0 | 3022 | | return BadRequest(new { success = false, error = "Выберите клиента Ozon" }); |
| | | 3023 | | } |
| | | 3024 | | |
| | 0 | 3025 | | var action = string.Equals(actionType, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCase) |
| | 0 | 3026 | | ? BitrixStockAutoActionTypes.Push |
| | 0 | 3027 | | : BitrixStockAutoActionTypes.Clear; |
| | 0 | 3028 | | var activeStores = await _repository.GetActiveStoresAsync(); |
| | 0 | 3029 | | var filter = BitrixStockAutoFilterFactory.BuildForSave(action, ozonClientId, activeStores, null, null); |
| | 0 | 3030 | | return Ok(new |
| | 0 | 3031 | | { |
| | 0 | 3032 | | success = true, |
| | 0 | 3033 | | filterJson = BitrixStockFilterSerializer.ToJson(filter), |
| | 0 | 3034 | | description = BitrixStockAutoFilterFactory.DescribeFilter(filter, action) |
| | 0 | 3035 | | }); |
| | 0 | 3036 | | } |
| | | 3037 | | |
| | | 3038 | | private async Task<string> BuildAutoScheduleFilterJsonAsync(BitrixStockAutoScheduleRequest request, string actionTyp |
| | | 3039 | | { |
| | 0 | 3040 | | var activeStores = await _repository.GetActiveStoresAsync(); |
| | 0 | 3041 | | var filter = BitrixStockAutoFilterFactory.BuildForSave( |
| | 0 | 3042 | | actionType, |
| | 0 | 3043 | | request.OzonClientId, |
| | 0 | 3044 | | activeStores, |
| | 0 | 3045 | | request.SelectedStoreTitles, |
| | 0 | 3046 | | userExtras: null); |
| | | 3047 | | |
| | 0 | 3048 | | return BitrixStockFilterSerializer.ToJson(filter); |
| | 0 | 3049 | | } |
| | | 3050 | | |
| | | 3051 | | private async Task<BitrixStockAutoSchedule> MapAutoScheduleEntityAsync(BitrixStockAutoScheduleRequest request) |
| | | 3052 | | { |
| | 0 | 3053 | | var action = string.Equals(request.ActionType, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCa |
| | 0 | 3054 | | ? BitrixStockAutoActionTypes.Push |
| | 0 | 3055 | | : BitrixStockAutoActionTypes.Clear; |
| | 0 | 3056 | | var filterJson = await BuildAutoScheduleFilterJsonAsync(request, action); |
| | | 3057 | | |
| | 0 | 3058 | | return new BitrixStockAutoSchedule |
| | 0 | 3059 | | { |
| | 0 | 3060 | | Id = request.Id, |
| | 0 | 3061 | | Name = request.Name.Trim(), |
| | 0 | 3062 | | ActionType = action, |
| | 0 | 3063 | | OzonClientId = request.OzonClientId, |
| | 0 | 3064 | | CronExpression = request.CronExpression.Trim(), |
| | 0 | 3065 | | IsActive = request.IsActive, |
| | 0 | 3066 | | MaxItemsPerRun = request.MaxItemsPerRun <= 0 ? 500 : Math.Min(request.MaxItemsPerRun, 500), |
| | 0 | 3067 | | FilterJson = filterJson |
| | 0 | 3068 | | }; |
| | 0 | 3069 | | } |
| | | 3070 | | |
| | 0 | 3071 | | private static object MapAutoScheduleDto(BitrixStockAutoSchedule s) => new |
| | 0 | 3072 | | { |
| | 0 | 3073 | | s.Id, |
| | 0 | 3074 | | s.Name, |
| | 0 | 3075 | | s.ActionType, |
| | 0 | 3076 | | actionLabel = string.Equals(s.ActionType, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCase) |
| | 0 | 3077 | | ? "Накат" |
| | 0 | 3078 | | : "Обнуление", |
| | 0 | 3079 | | s.OzonClientId, |
| | 0 | 3080 | | ozonClientName = s.OzonClient?.Name, |
| | 0 | 3081 | | s.CronExpression, |
| | 0 | 3082 | | s.IsActive, |
| | 0 | 3083 | | s.MaxItemsPerRun, |
| | 0 | 3084 | | filterJson = s.FilterJson, |
| | 0 | 3085 | | filterDescription = DescribeStoredScheduleFilter(s), |
| | 0 | 3086 | | s.LastRunAt, |
| | 0 | 3087 | | s.NextRunAt, |
| | 0 | 3088 | | s.CreatedAt, |
| | 0 | 3089 | | s.UpdatedAt |
| | 0 | 3090 | | }; |
| | | 3091 | | |
| | | 3092 | | private static string DescribeStoredScheduleFilter(BitrixStockAutoSchedule schedule) |
| | | 3093 | | { |
| | 0 | 3094 | | var filter = BitrixStockAutoFilterFactory.ResolveForSchedule(schedule, null); |
| | 0 | 3095 | | return BitrixStockAutoFilterFactory.DescribeFilter(filter, schedule.ActionType); |
| | | 3096 | | } |
| | | 3097 | | |
| | | 3098 | | private async Task<List<RemainingStockBitrix>> ApplyFiltersAsync(List<RemainingStockBitrix> items, |
| | | 3099 | | RemainingStockFilter filter) |
| | | 3100 | | { |
| | 0 | 3101 | | if (!items.Any()) return new List<RemainingStockBitrix>(); |
| | 0 | 3102 | | var totalSw = Stopwatch.StartNew(); |
| | | 3103 | | // suppliers & prefixes |
| | 0 | 3104 | | var suppliers = items |
| | | 3105 | | .Where(r => !string.IsNullOrWhiteSpace(r.Supplier)) |
| | | 3106 | | .Select(r => r.Supplier!) |
| | 0 | 3107 | | .Distinct() |
| | 0 | 3108 | | .ToList(); |
| | | 3109 | | Dictionary<string, string?> prefixes; |
| | | 3110 | | try |
| | | 3111 | | { |
| | 0 | 3112 | | prefixes = await _etProducerRepository.GetMarketPrefixesByNamesAsync(suppliers); |
| | 0 | 3113 | | } |
| | 0 | 3114 | | catch (Exception) |
| | | 3115 | | { |
| | | 3116 | | prefixes = suppliers.ToDictionary(s => s, _ => (string?)null); |
| | 0 | 3117 | | AppendPerformanceNotice("Справочник префиксов поставщиков (EtProducers) недоступен — страница без MarketPref |
| | 0 | 3118 | | } |
| | | 3119 | | |
| | | 3120 | | var productIds = items.Select(x => x.ProductId).ToList(); |
| | | 3121 | | Dictionary<int, PriceHistory> priceHistories; |
| | | 3122 | | try |
| | | 3123 | | { |
| | 0 | 3124 | | priceHistories = await _priceHistoryService.GetLastByBitrixIdsAsync(productIds); |
| | 0 | 3125 | | } |
| | 0 | 3126 | | catch (Exception) |
| | | 3127 | | { |
| | 0 | 3128 | | priceHistories = new Dictionary<int, PriceHistory>(); |
| | 0 | 3129 | | AppendPerformanceNotice("История цен временно недоступна — фильтры по RegistrationPrice могут не сработать." |
| | 0 | 3130 | | } |
| | | 3131 | | |
| | 0 | 3132 | | foreach (var item in items) |
| | | 3133 | | { |
| | 0 | 3134 | | if (!string.IsNullOrWhiteSpace(item.Supplier) && prefixes.TryGetValue(item.Supplier, out var prefix)) |
| | 0 | 3135 | | item.MarketPrefix = prefix; |
| | | 3136 | | |
| | 0 | 3137 | | if (priceHistories.TryGetValue(item.ProductId, out var priceHistory)) |
| | 0 | 3138 | | item.PriceHistory = priceHistory; |
| | | 3139 | | } |
| | | 3140 | | |
| | | 3141 | | // фильтры по RegistrationPrice |
| | 0 | 3142 | | if (filter.RegistrationPriceFrom.HasValue) |
| | 0 | 3143 | | items = items.Where(x => x.PriceHistory != null && |
| | 0 | 3144 | | x.PriceHistory.RegistrationPrice >= filter.RegistrationPriceFrom.Value).ToList(); |
| | | 3145 | | |
| | 0 | 3146 | | if (filter.RegistrationPriceTo.HasValue) |
| | 0 | 3147 | | items = items.Where(x => x.PriceHistory != null && |
| | 0 | 3148 | | x.PriceHistory.RegistrationPrice <= filter.RegistrationPriceTo.Value).ToList(); |
| | | 3149 | | |
| | | 3150 | | // OzonWarehouses |
| | 0 | 3151 | | if (filter.LoadOzonWarehouses) |
| | | 3152 | | { |
| | 0 | 3153 | | var ozonSw = Stopwatch.StartNew(); |
| | 0 | 3154 | | items = await LoadOzonWarehousesOptimizedAsync(items, filter); |
| | 0 | 3155 | | ozonSw.Stop(); |
| | | 3156 | | |
| | | 3157 | | // Остатки в Ozon меняются только вручную (кнопки наката/обнуления), не при загрузке страницы. |
| | 0 | 3158 | | foreach (var client in _ozonClients) |
| | | 3159 | | { |
| | 0 | 3160 | | BitrixOzonStockLinkage.PruneUnlistedOzonProductStores(items, client.Name); |
| | | 3161 | | } |
| | | 3162 | | |
| | 0 | 3163 | | if (!string.IsNullOrEmpty(filter.OzonStoreTitle)) |
| | 0 | 3164 | | items = items.Where(x => x.OzonStores.Any(s => |
| | 0 | 3165 | | s.Title.Contains(filter.OzonStoreTitle, StringComparison.OrdinalIgnoreCase))).ToList(); |
| | | 3166 | | |
| | 0 | 3167 | | if (filter.OzonAmountFrom.HasValue) |
| | 0 | 3168 | | items = items.Where(x => x.OzonStores.Any(s => s.Amount >= filter.OzonAmountFrom.Value)).ToList(); |
| | | 3169 | | |
| | 0 | 3170 | | if (filter.OzonAmountTo.HasValue) |
| | 0 | 3171 | | items = items.Where(x => x.OzonStores.Any(s => s.Amount <= filter.OzonAmountTo.Value)).ToList(); |
| | | 3172 | | |
| | 0 | 3173 | | if (filter.DiffOzonWarehouses) |
| | | 3174 | | { |
| | | 3175 | | items = items.Where(x => x.OzonStores.Any(oz => |
| | | 3176 | | { |
| | | 3177 | | var ozonAmount = oz.OzonPresentOnOzon ?? (int)Math.Round(oz.Amount); |
| | | 3178 | | |
| | | 3179 | | if (oz.LinkStores?.Any() == true) |
| | | 3180 | | { |
| | | 3181 | | var bitrixAmount = BitrixOzonStockLinkage.GetLinkedOneCQuantity(x, oz.LinkStores); |
| | | 3182 | | return bitrixAmount != ozonAmount; |
| | | 3183 | | } |
| | | 3184 | | |
| | | 3185 | | // Разница считается только по связанным складам Ozon↔1C. |
| | | 3186 | | // Несвязанные склады (links пустой) не должны включать строку в Diff-фильтр. |
| | | 3187 | | return false; |
| | | 3188 | | })).ToList(); |
| | | 3189 | | } |
| | | 3190 | | |
| | 0 | 3191 | | } |
| | | 3192 | | |
| | | 3193 | | // OzonPriceIndexes |
| | 0 | 3194 | | if (filter.LoadOzonPriceIndexes) |
| | | 3195 | | { |
| | 0 | 3196 | | items = await LoadOzonPriceIndexesAsync(items, filter.LoadOzonPriceIndexes); |
| | | 3197 | | |
| | 0 | 3198 | | if (!string.IsNullOrEmpty(filter.PriceIndexColor)) |
| | 0 | 3199 | | items = items.Where(x => x.OzonPriceIndexes != null && |
| | 0 | 3200 | | x.OzonPriceIndexes.Any(i => |
| | 0 | 3201 | | string.Equals(i.ColorIndex, filter.PriceIndexColor, |
| | 0 | 3202 | | StringComparison.OrdinalIgnoreCase))) |
| | 0 | 3203 | | .ToList(); |
| | | 3204 | | |
| | 0 | 3205 | | if (filter.PriceIndexValueFrom.HasValue || filter.PriceIndexValueTo.HasValue) |
| | 0 | 3206 | | items = items.Where(x => x.OzonPriceIndexes != null && |
| | 0 | 3207 | | x.OzonPriceIndexes.Any(i => |
| | 0 | 3208 | | { |
| | 0 | 3209 | | var value = i.OzonIndex?.PriceIndexValue; |
| | 0 | 3210 | | if (!value.HasValue) return false; |
| | 0 | 3211 | | bool match = true; |
| | 0 | 3212 | | if (filter.PriceIndexValueFrom.HasValue) |
| | 0 | 3213 | | match &= value.Value >= filter.PriceIndexValueFrom.Value; |
| | 0 | 3214 | | if (filter.PriceIndexValueTo.HasValue) |
| | 0 | 3215 | | match &= value.Value <= filter.PriceIndexValueTo.Value; |
| | 0 | 3216 | | return match; |
| | 0 | 3217 | | })).ToList(); |
| | | 3218 | | |
| | 0 | 3219 | | if (filter.MinPriceFrom.HasValue || filter.MinPriceTo.HasValue) |
| | 0 | 3220 | | items = items.Where(x => x.OzonPriceIndexes != null && |
| | 0 | 3221 | | x.OzonPriceIndexes.Any(i => |
| | 0 | 3222 | | { |
| | 0 | 3223 | | var minPrice = i.OzonIndex?.MinPrice; |
| | 0 | 3224 | | if (!minPrice.HasValue) return false; |
| | 0 | 3225 | | bool match = true; |
| | 0 | 3226 | | if (filter.MinPriceFrom.HasValue) |
| | 0 | 3227 | | match &= minPrice.Value >= filter.MinPriceFrom.Value; |
| | 0 | 3228 | | if (filter.MinPriceTo.HasValue) |
| | 0 | 3229 | | match &= minPrice.Value <= filter.MinPriceTo.Value; |
| | 0 | 3230 | | return match; |
| | 0 | 3231 | | })).ToList(); |
| | | 3232 | | |
| | 0 | 3233 | | if (filter.RedOzonPriceIndexes) |
| | | 3234 | | items = items.Where(x => x.OzonPriceIndexes != null && |
| | | 3235 | | x.OzonPriceIndexes.Any(p => |
| | | 3236 | | p.ColorIndex == "RED" && (p.OzonIndex?.MinPrice ?? 0) != 0)) |
| | 0 | 3237 | | .ToList(); |
| | | 3238 | | } |
| | 0 | 3239 | | totalSw.Stop(); |
| | 0 | 3240 | | return items; |
| | 0 | 3241 | | } |
| | | 3242 | | |
| | | 3243 | | private static string GetFullExceptionMessage(Exception ex) |
| | | 3244 | | { |
| | 0 | 3245 | | var sb = new StringBuilder(); |
| | 0 | 3246 | | for (var e = ex; e != null; e = e.InnerException) |
| | | 3247 | | { |
| | 0 | 3248 | | if (sb.Length > 0) |
| | | 3249 | | { |
| | 0 | 3250 | | sb.Append(" | "); |
| | | 3251 | | } |
| | | 3252 | | |
| | 0 | 3253 | | sb.Append(e.Message); |
| | | 3254 | | } |
| | | 3255 | | |
| | 0 | 3256 | | return sb.ToString(); |
| | | 3257 | | } |
| | | 3258 | | |
| | | 3259 | | private async Task<List<RemainingStockBitrix>> LoadOzonWarehousesOptimizedAsync( |
| | | 3260 | | List<RemainingStockBitrix> items, |
| | | 3261 | | RemainingStockFilter filter) |
| | | 3262 | | { |
| | 0 | 3263 | | var allArticles = items |
| | | 3264 | | .Select(x => x.ArticleWithKey) |
| | | 3265 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | 0 | 3266 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 3267 | | .ToArray(); |
| | | 3268 | | |
| | 0 | 3269 | | if (IsWideOzonBrowsing(filter) && allArticles.Length > MaxArticlesForOzonStocksLoad) |
| | | 3270 | | { |
| | 0 | 3271 | | _ozonLoadWarnings.Add( |
| | 0 | 3272 | | $"Остатки Ozon загружены только для первых {MaxArticlesForOzonStocksLoad} из {allArticles.Length} артику |
| | 0 | 3273 | | allArticles = allArticles.Take(MaxArticlesForOzonStocksLoad).ToArray(); |
| | | 3274 | | } |
| | | 3275 | | |
| | 0 | 3276 | | var clientWarehouseCache = new ConcurrentDictionary<string, List<WarehouseOzon>>(); |
| | 0 | 3277 | | var bitrixStoresToEnsure = GetBitrixStoreNamesForMapping(items, filter); |
| | | 3278 | | |
| | 0 | 3279 | | foreach (var client in _ozonClients) |
| | | 3280 | | { |
| | 0 | 3281 | | await ProcessClientWarehousesAsync(client, items, allArticles, clientWarehouseCache, filter, bitrixStoresToE |
| | | 3282 | | } |
| | | 3283 | | |
| | 0 | 3284 | | return items; |
| | 0 | 3285 | | } |
| | | 3286 | | |
| | | 3287 | | private static HashSet<string> GetBitrixStoreNamesForMapping( |
| | | 3288 | | List<RemainingStockBitrix> items, |
| | | 3289 | | RemainingStockFilter filter) |
| | | 3290 | | { |
| | 0 | 3291 | | var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | | 3292 | | |
| | 0 | 3293 | | if (filter.SelectedStoreTitles?.Any() == true) |
| | | 3294 | | { |
| | 0 | 3295 | | foreach (var title in filter.SelectedStoreTitles) |
| | | 3296 | | { |
| | 0 | 3297 | | if (!string.IsNullOrWhiteSpace(title)) |
| | | 3298 | | { |
| | 0 | 3299 | | names.Add(title.Trim()); |
| | | 3300 | | } |
| | | 3301 | | } |
| | | 3302 | | } |
| | | 3303 | | else |
| | | 3304 | | { |
| | 0 | 3305 | | foreach (var storeTitle in items |
| | 0 | 3306 | | .SelectMany(i => i.Stores ?? Enumerable.Empty<StockByStore>()) |
| | 0 | 3307 | | .Select(s => s.Title) |
| | 0 | 3308 | | .Where(t => !string.IsNullOrWhiteSpace(t))) |
| | | 3309 | | { |
| | 0 | 3310 | | names.Add(storeTitle.Trim()); |
| | | 3311 | | } |
| | | 3312 | | } |
| | | 3313 | | |
| | 0 | 3314 | | return names; |
| | | 3315 | | } |
| | | 3316 | | |
| | | 3317 | | private static HashSet<string> GetAllowedBitrixStoreNames( |
| | | 3318 | | RemainingStockFilter filter, |
| | | 3319 | | List<RemainingStockBitrix> items) |
| | | 3320 | | { |
| | 0 | 3321 | | return GetBitrixStoreNamesForMapping(items, filter); |
| | | 3322 | | } |
| | | 3323 | | |
| | | 3324 | | private async Task<ConcurrentDictionary<string, List<WarehouseOzon>>> PreloadAllWarehousesAsync() |
| | | 3325 | | { |
| | 0 | 3326 | | var cache = new ConcurrentDictionary<string, List<WarehouseOzon>>(); |
| | | 3327 | | |
| | 0 | 3328 | | var warehouseTasks = _ozonClients |
| | 0 | 3329 | | .Where(HasOzonCredentials) |
| | 0 | 3330 | | .Select(async client => |
| | 0 | 3331 | | { |
| | 0 | 3332 | | if (!TryGetOzonCredentials(client, out var clientId, out var apiKey)) |
| | 0 | 3333 | | return; |
| | 0 | 3334 | | |
| | 0 | 3335 | | var mgr = CreateOzonApiDataManager(clientId, apiKey); |
| | 0 | 3336 | | var warehouses = await GetOrCreateWarehouseCache(clientId, mgr); |
| | 0 | 3337 | | cache[clientId] = warehouses; |
| | 0 | 3338 | | }); |
| | | 3339 | | |
| | 0 | 3340 | | await Task.WhenAll(warehouseTasks); |
| | 0 | 3341 | | return cache; |
| | 0 | 3342 | | } |
| | | 3343 | | |
| | | 3344 | | private async Task ProcessClientWarehousesAsync( |
| | | 3345 | | OzonClient client, |
| | | 3346 | | List<RemainingStockBitrix> items, |
| | | 3347 | | string[] allArticles, |
| | | 3348 | | ConcurrentDictionary<string, List<WarehouseOzon>> warehouseCache, |
| | | 3349 | | RemainingStockFilter filter, |
| | | 3350 | | HashSet<string> bitrixStoresToEnsure) |
| | | 3351 | | { |
| | 0 | 3352 | | var totalSw = Stopwatch.StartNew(); |
| | 0 | 3353 | | if (!TryConfigureOzonApiClient(client)) |
| | 0 | 3354 | | return; |
| | | 3355 | | |
| | 0 | 3356 | | var clientId = client.DecryptClientId!; |
| | 0 | 3357 | | if (!warehouseCache.TryGetValue(clientId, out var warehouseList)) |
| | | 3358 | | { |
| | 0 | 3359 | | warehouseList = await GetOrCreateWarehouseCache(clientId); |
| | 0 | 3360 | | warehouseCache[clientId] = warehouseList; |
| | | 3361 | | } |
| | | 3362 | | |
| | 0 | 3363 | | if (bitrixStoresToEnsure.Count > 0) |
| | | 3364 | | { |
| | 0 | 3365 | | await EnsureWarehouseMappingsInternalAsync(client, bitrixStoresToEnsure.ToList(), warehouseList); |
| | | 3366 | | } |
| | | 3367 | | |
| | 0 | 3368 | | var stocksSw = Stopwatch.StartNew(); |
| | 0 | 3369 | | var stocksCacheKey = |
| | 0 | 3370 | | $"OzonProductStocks:{clientId}:{BuildArticlesCacheKeyPart(allArticles)}"; |
| | 0 | 3371 | | var stocksCacheHit = _memoryCache.TryGetValue(stocksCacheKey, out JObject? allStocks); |
| | 0 | 3372 | | if (!stocksCacheHit) |
| | | 3373 | | { |
| | | 3374 | | try |
| | | 3375 | | { |
| | 0 | 3376 | | allStocks = await _ozonApiDataManager.GetProductStocks(allArticles); |
| | 0 | 3377 | | if (allStocks != null) |
| | | 3378 | | { |
| | 0 | 3379 | | _memoryCache.Set(stocksCacheKey, allStocks, new MemoryCacheEntryOptions() |
| | 0 | 3380 | | .SetAbsoluteExpiration(TimeSpan.FromMinutes(3)) |
| | 0 | 3381 | | .SetSlidingExpiration(TimeSpan.FromMinutes(1))); |
| | | 3382 | | } |
| | 0 | 3383 | | } |
| | 0 | 3384 | | catch (Exception ex) when (IsOzonLoadFailure(ex)) |
| | | 3385 | | { |
| | 0 | 3386 | | _ozonLoadWarnings.Add( |
| | 0 | 3387 | | $"Не удалось загрузить остатки Ozon для «{client.Name}»: {DescribeOzonLoadFailure(ex)}"); |
| | 0 | 3388 | | stocksSw.Stop(); |
| | 0 | 3389 | | return; |
| | | 3390 | | } |
| | | 3391 | | } |
| | 0 | 3392 | | stocksSw.Stop(); |
| | | 3393 | | |
| | 0 | 3394 | | if (allStocks?["items"] == null) return; |
| | | 3395 | | |
| | 0 | 3396 | | var allClientMappings = await _warehouseMappingDataServices.GetByClientIdAsync(client.Id); |
| | 0 | 3397 | | var allowedBitrixStores = GetAllowedBitrixStoreNames(filter, items); |
| | 0 | 3398 | | var perfCounters = new OzonPerfCounters(); |
| | 0 | 3399 | | var itemTasksSw = Stopwatch.StartNew(); |
| | 0 | 3400 | | foreach (var item in items) |
| | | 3401 | | { |
| | 0 | 3402 | | await ProcessItemWarehousesAsync( |
| | 0 | 3403 | | item, client, allStocks, warehouseCache, allClientMappings, allowedBitrixStores, perfCounters); |
| | | 3404 | | } |
| | | 3405 | | |
| | 0 | 3406 | | itemTasksSw.Stop(); |
| | 0 | 3407 | | totalSw.Stop(); |
| | 0 | 3408 | | } |
| | | 3409 | | |
| | | 3410 | | private async Task ProcessItemWarehousesAsync( |
| | | 3411 | | RemainingStockBitrix item, |
| | | 3412 | | OzonClient client, |
| | | 3413 | | JObject allStocks, |
| | | 3414 | | ConcurrentDictionary<string, List<WarehouseOzon>> warehouseCache, |
| | | 3415 | | List<WarehouseMapping> clientMappings, |
| | | 3416 | | HashSet<string> allowedBitrixStores, |
| | | 3417 | | OzonPerfCounters perfCounters) |
| | | 3418 | | { |
| | 0 | 3419 | | Interlocked.Increment(ref perfCounters.ItemsChecked); |
| | 0 | 3420 | | if (clientMappings.Count == 0) |
| | | 3421 | | { |
| | 0 | 3422 | | return; |
| | | 3423 | | } |
| | | 3424 | | |
| | 0 | 3425 | | if (!HasOzonCredentials(client)) |
| | 0 | 3426 | | return; |
| | | 3427 | | |
| | 0 | 3428 | | var clientId = client.DecryptClientId!; |
| | 0 | 3429 | | if (!warehouseCache.TryGetValue(clientId, out var warehouseList)) |
| | | 3430 | | { |
| | 0 | 3431 | | warehouseList = await GetOrCreateWarehouseCache(clientId); |
| | 0 | 3432 | | warehouseCache[clientId] = warehouseList; |
| | | 3433 | | } |
| | | 3434 | | |
| | 0 | 3435 | | JToken? warehouseInfo = null; |
| | 0 | 3436 | | var stocksProductId = 0L; |
| | 0 | 3437 | | var itemStocks = BitrixOzonStockLinkage.FindStocksForArticle(allStocks, item); |
| | 0 | 3438 | | if (itemStocks != null) |
| | | 3439 | | { |
| | 0 | 3440 | | Interlocked.Increment(ref perfCounters.ItemStocksFound); |
| | 0 | 3441 | | stocksProductId = BitrixOzonStockLinkage.ExtractProductIdFromStocks(itemStocks); |
| | | 3442 | | |
| | 0 | 3443 | | var warehouseInfoSw = Stopwatch.StartNew(); |
| | 0 | 3444 | | warehouseInfo = await _ozonApiDataManager.GetWarehouseInfo( |
| | 0 | 3445 | | itemStocks, item.ArticleWithKey, client, warehouseList); |
| | 0 | 3446 | | warehouseInfoSw.Stop(); |
| | 0 | 3447 | | Interlocked.Increment(ref perfCounters.WarehouseInfoCalls); |
| | 0 | 3448 | | Interlocked.Add(ref perfCounters.WarehouseInfoMs, warehouseInfoSw.ElapsedMilliseconds); |
| | 0 | 3449 | | } |
| | | 3450 | | |
| | 0 | 3451 | | BitrixOzonStockLinkage.ApplyMappedStocks( |
| | 0 | 3452 | | item, |
| | 0 | 3453 | | client, |
| | 0 | 3454 | | warehouseInfo, |
| | 0 | 3455 | | clientMappings, |
| | 0 | 3456 | | warehouseList, |
| | 0 | 3457 | | allowedBitrixStores, |
| | 0 | 3458 | | item.ArticleWithKey, |
| | 0 | 3459 | | stocksProductId); |
| | 0 | 3460 | | } |
| | | 3461 | | |
| | | 3462 | | |
| | | 3463 | | private async Task<List<RemainingStockBitrix>> LoadOzonPriceIndexesAsync( |
| | | 3464 | | List<RemainingStockBitrix> items, |
| | | 3465 | | bool loadOzonPriceIndexes) |
| | | 3466 | | { |
| | 0 | 3467 | | if (!loadOzonPriceIndexes || items == null || !items.Any()) |
| | 0 | 3468 | | return items ?? new List<RemainingStockBitrix>(); |
| | | 3469 | | |
| | | 3470 | | // Получаем уникальные артикулы |
| | 0 | 3471 | | var articles = items |
| | | 3472 | | .Where(x => !string.IsNullOrEmpty(x.ArticleWithKey)) |
| | | 3473 | | .Select(x => x.ArticleWithKey) |
| | 0 | 3474 | | .Distinct() |
| | 0 | 3475 | | .ToArray(); |
| | | 3476 | | |
| | 0 | 3477 | | if (!articles.Any()) |
| | 0 | 3478 | | return items; |
| | | 3479 | | |
| | 0 | 3480 | | var allPriceIndexes = new ConcurrentBag<(string Article, OzonPriceIndexes Indexes, string ClientName)>(); |
| | | 3481 | | |
| | 0 | 3482 | | var tasks = _ozonClients.Select(async client => |
| | 0 | 3483 | | { |
| | 0 | 3484 | | if (!TryGetOzonCredentials(client, out var clientId, out var apiKey)) |
| | 0 | 3485 | | return; |
| | 0 | 3486 | | |
| | 0 | 3487 | | var mgr = CreateOzonApiDataManager(clientId, apiKey); |
| | 0 | 3488 | | |
| | 0 | 3489 | | try |
| | 0 | 3490 | | { |
| | 0 | 3491 | | // Получаем цены по всем артикулам сразу (по 500 штук за раз) |
| | 0 | 3492 | | var priceResponse = await mgr.GetProductPricesByArticles(articles); |
| | 0 | 3493 | | |
| | 0 | 3494 | | if (priceResponse?["items"] is not JArray priceItems) |
| | 0 | 3495 | | return; |
| | 0 | 3496 | | |
| | 0 | 3497 | | foreach (var item in priceItems) |
| | 0 | 3498 | | { |
| | 0 | 3499 | | var offerId = item["offer_id"]?.ToString(); |
| | 0 | 3500 | | var priceIndexes = item["price_indexes"]; |
| | 0 | 3501 | | if (string.IsNullOrEmpty(offerId) || priceIndexes == null) |
| | 0 | 3502 | | continue; |
| | 0 | 3503 | | |
| | 0 | 3504 | | var indexes = ParsePriceIndexes(priceIndexes, client.Name); |
| | 0 | 3505 | | if (indexes != null) |
| | 0 | 3506 | | { |
| | 0 | 3507 | | allPriceIndexes.Add((offerId, indexes, client.Name)); |
| | 0 | 3508 | | } |
| | 0 | 3509 | | } |
| | 0 | 3510 | | } |
| | 0 | 3511 | | catch (Exception) |
| | 0 | 3512 | | { |
| | 0 | 3513 | | // Логирование можно добавить |
| | 0 | 3514 | | } |
| | 0 | 3515 | | }); |
| | | 3516 | | |
| | 0 | 3517 | | await Task.WhenAll(tasks); |
| | | 3518 | | |
| | | 3519 | | // Присоединяем индексы к элементам |
| | 0 | 3520 | | var articleLookup = allPriceIndexes |
| | | 3521 | | .GroupBy(x => x.Article) |
| | | 3522 | | .ToDictionary(g => g.Key, g => g.Select(x => x.Indexes).ToList()); |
| | | 3523 | | |
| | 0 | 3524 | | foreach (var item in items) |
| | | 3525 | | { |
| | 0 | 3526 | | if (string.IsNullOrWhiteSpace(item.ArticleWithKey)) |
| | | 3527 | | continue; |
| | | 3528 | | |
| | 0 | 3529 | | if (articleLookup.TryGetValue(item.ArticleWithKey, out var indexes)) |
| | | 3530 | | { |
| | 0 | 3531 | | item.OzonPriceIndexes = indexes; |
| | | 3532 | | } |
| | | 3533 | | } |
| | | 3534 | | |
| | 0 | 3535 | | return items; |
| | 0 | 3536 | | } |
| | | 3537 | | |
| | | 3538 | | |
| | | 3539 | | private OzonPriceIndexes? ParsePriceIndexes(JToken priceIndexes, string clientName) |
| | | 3540 | | { |
| | 0 | 3541 | | if (priceIndexes == null) |
| | 0 | 3542 | | return null; |
| | | 3543 | | |
| | | 3544 | | try |
| | | 3545 | | { |
| | 0 | 3546 | | var indexes = new OzonPriceIndexes |
| | 0 | 3547 | | { |
| | 0 | 3548 | | ClientName = clientName, |
| | 0 | 3549 | | ColorIndex = priceIndexes["color_index"]?.ToString() |
| | 0 | 3550 | | }; |
| | | 3551 | | |
| | | 3552 | | // Парсим внешний индекс |
| | 0 | 3553 | | var externalIndex = priceIndexes["external_index_data"]; |
| | 0 | 3554 | | if (externalIndex != null && externalIndex.HasValues) |
| | | 3555 | | { |
| | 0 | 3556 | | indexes.ExternalIndex = new PriceIndexData |
| | 0 | 3557 | | { |
| | 0 | 3558 | | MinPrice = externalIndex["min_price"]?.Value<decimal>() ?? 0, |
| | 0 | 3559 | | MinPriceCurrency = externalIndex["min_price_currency"]?.ToString() ?? "RUB", |
| | 0 | 3560 | | PriceIndexValue = externalIndex["price_index_value"]?.Value<double>() ?? 0 |
| | 0 | 3561 | | }; |
| | | 3562 | | } |
| | | 3563 | | |
| | | 3564 | | // Парсим Ozon индекс |
| | 0 | 3565 | | var ozonIndex = priceIndexes["ozon_index_data"]; |
| | 0 | 3566 | | if (ozonIndex != null && ozonIndex.HasValues) |
| | | 3567 | | { |
| | 0 | 3568 | | indexes.OzonIndex = new PriceIndexData |
| | 0 | 3569 | | { |
| | 0 | 3570 | | MinPrice = ozonIndex["min_price"]?.Value<decimal>() ?? 0, |
| | 0 | 3571 | | MinPriceCurrency = ozonIndex["min_price_currency"]?.ToString() ?? "RUB", |
| | 0 | 3572 | | PriceIndexValue = ozonIndex["price_index_value"]?.Value<double>() ?? 0 |
| | 0 | 3573 | | }; |
| | | 3574 | | } |
| | | 3575 | | |
| | | 3576 | | // Парсим индекс собственных маркетплейсов |
| | 0 | 3577 | | var selfMarketplacesIndex = priceIndexes["self_marketplaces_index_data"]; |
| | 0 | 3578 | | if (selfMarketplacesIndex != null && selfMarketplacesIndex.HasValues) |
| | | 3579 | | { |
| | 0 | 3580 | | indexes.SelfMarketplacesIndex = new PriceIndexData |
| | 0 | 3581 | | { |
| | 0 | 3582 | | MinPrice = selfMarketplacesIndex["min_price"]?.Value<decimal>() ?? 0, |
| | 0 | 3583 | | MinPriceCurrency = selfMarketplacesIndex["min_price_currency"]?.ToString() ?? "RUB", |
| | 0 | 3584 | | PriceIndexValue = selfMarketplacesIndex["price_index_value"]?.Value<double>() ?? 0 |
| | 0 | 3585 | | }; |
| | | 3586 | | } |
| | | 3587 | | |
| | 0 | 3588 | | return indexes; |
| | | 3589 | | } |
| | 0 | 3590 | | catch (Exception) |
| | | 3591 | | { |
| | 0 | 3592 | | return null; |
| | | 3593 | | } |
| | 0 | 3594 | | } |
| | | 3595 | | |
| | | 3596 | | [HttpGet("GetOzonPrices")] |
| | | 3597 | | public async Task<IActionResult> GetOzonPrices(string article, List<string> storeNames) |
| | | 3598 | | { |
| | 0 | 3599 | | article = article?.Trim() ?? string.Empty; |
| | 0 | 3600 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 3601 | | return BadRequest(new { message = "Не указан артикул" }); |
| | | 3602 | | |
| | 0 | 3603 | | storeNames ??= new List<string>(); |
| | | 3604 | | |
| | 0 | 3605 | | await SetClients(); |
| | | 3606 | | |
| | 0 | 3607 | | var allItems = new JArray(); |
| | | 3608 | | |
| | 0 | 3609 | | foreach (var client in _ozonClients) |
| | | 3610 | | { |
| | 0 | 3611 | | if (!TryConfigureOzonApiClient(client)) |
| | | 3612 | | continue; |
| | | 3613 | | |
| | 0 | 3614 | | var clientId = client.DecryptClientId!; |
| | 0 | 3615 | | JObject result = await _ozonApiDataManager.GetProductPricesByArticles(new[] { article }); |
| | 0 | 3616 | | var stocks = await _ozonApiDataManager.GetProductStocks(new[] { article }); |
| | 0 | 3617 | | List<WarehouseOzon> warehouseList = await GetOrCreateWarehouseCache(clientId); |
| | | 3618 | | |
| | 0 | 3619 | | if (result?["items"] is JArray priceItems && priceItems.HasValues) |
| | | 3620 | | { |
| | 0 | 3621 | | foreach (var item in priceItems) |
| | | 3622 | | { |
| | 0 | 3623 | | var commissions = item["commissions"]; |
| | 0 | 3624 | | var priceObj = item["price"]; |
| | 0 | 3625 | | var typedItem = item.ToObject<ProductPriceItemResponse>(); |
| | | 3626 | | |
| | 0 | 3627 | | PriceDumpingPreview? recommendation = null; |
| | 0 | 3628 | | string? recommendationError = null; |
| | 0 | 3629 | | if (typedItem != null) |
| | | 3630 | | { |
| | | 3631 | | try |
| | | 3632 | | { |
| | 0 | 3633 | | recommendation = await _importProductPricesManager.GetPriceDumpingPreview(typedItem, client, |
| | 0 | 3634 | | } |
| | 0 | 3635 | | catch (Exception ex) |
| | | 3636 | | { |
| | 0 | 3637 | | recommendationError = FormatRecommendationError(ex); |
| | 0 | 3638 | | Console.WriteLine( |
| | 0 | 3639 | | $"[BitrixStock/GetOzonPrices] recommendation failed; client='{client.Name}', offer_id='{ |
| | 0 | 3640 | | } |
| | | 3641 | | } |
| | | 3642 | | |
| | 0 | 3643 | | if (commissions != null && priceObj != null) |
| | | 3644 | | { |
| | 0 | 3645 | | decimal fboDeliv = commissions["fbo_deliv_to_customer_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 3646 | | decimal fbsDirectMin = commissions["fbs_direct_flow_trans_min_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 3647 | | decimal fbsDirectMax = commissions["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 3648 | | decimal fbsFirstMileMin = commissions["fbs_first_mile_min_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 3649 | | decimal fbsFirstMileMax = commissions["fbs_first_mile_max_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 3650 | | decimal salesPercent = commissions["sales_percent_fbs"]?.Value<decimal>() ?? 0; |
| | 0 | 3651 | | decimal itemPrice = priceObj["price"]?.Value<decimal>() ?? 0; |
| | 0 | 3652 | | decimal acquiring = item["acquiring"]?.Value<decimal>() ?? 0; |
| | 0 | 3653 | | double index = 0.91; |
| | 0 | 3654 | | double marketingPrice = priceObj["marketing_seller_price"]?.Value<double>() ?? 0; |
| | | 3655 | | |
| | 0 | 3656 | | decimal ozonReward = itemPrice * salesPercent / 100; |
| | | 3657 | | |
| | 0 | 3658 | | decimal maxCommission = ozonReward + acquiring + fbsFirstMileMax + fbsDirectMax + fboDeliv; |
| | 0 | 3659 | | decimal minCommission = ozonReward + acquiring + fbsFirstMileMin + fbsDirectMin + fboDeliv; |
| | | 3660 | | |
| | 0 | 3661 | | double truePrice = recommendation?.CurrentCustomerPrice ?? _importProductPricesManager.GetTruePr |
| | | 3662 | | |
| | 0 | 3663 | | if (priceObj is JObject priceJObject) |
| | | 3664 | | { |
| | 0 | 3665 | | priceJObject["max_commission"] = Math.Round(maxCommission, 2); |
| | 0 | 3666 | | priceJObject["min_commission"] = Math.Round(minCommission, 2); |
| | 0 | 3667 | | priceJObject["ozon_reward"] = Math.Round(ozonReward, 2); |
| | 0 | 3668 | | priceJObject["true_price"] = Math.Round(truePrice, 2); |
| | 0 | 3669 | | if (recommendation != null) |
| | | 3670 | | { |
| | 0 | 3671 | | priceJObject["customer_price"] = Math.Round(recommendation.CurrentCustomerPrice, 2); |
| | 0 | 3672 | | priceJObject["customer_discount_percent"] = recommendation.DiscountPercent; |
| | 0 | 3673 | | priceJObject["green_target_customer_price"] = Math.Round(recommendation.TargetCustomerPr |
| | 0 | 3674 | | priceJObject["green_target_seller_price"] = Math.Round(recommendation.TargetSellerPrice, |
| | 0 | 3675 | | priceJObject["green_target_index"] = Math.Round(recommendation.TargetIndex, 2); |
| | 0 | 3676 | | priceJObject["green_target_source"] = recommendation.Source; |
| | 0 | 3677 | | priceJObject["green_target_source_price"] = Math.Round(recommendation.SourceMinPrice, 2) |
| | 0 | 3678 | | priceJObject["green_target_source_index"] = Math.Round(recommendation.SourceIndexValue, |
| | 0 | 3679 | | priceJObject["customer_price_factor"] = Math.Round(recommendation.CustomerPriceFactor, 4 |
| | 0 | 3680 | | priceJObject["customer_price_formula"] = recommendation.CurrentPriceExplanation; |
| | 0 | 3681 | | priceJObject["green_target_formula"] = recommendation.TargetPriceExplanation; |
| | 0 | 3682 | | priceJObject["green_target_candidates"] = recommendation.CandidatesExplanation; |
| | | 3683 | | } |
| | 0 | 3684 | | else if (!string.IsNullOrWhiteSpace(recommendationError)) |
| | | 3685 | | { |
| | 0 | 3686 | | priceJObject["green_target_error"] = recommendationError; |
| | | 3687 | | } |
| | | 3688 | | } |
| | | 3689 | | } |
| | | 3690 | | |
| | 0 | 3691 | | var wrapped = new JObject |
| | 0 | 3692 | | { |
| | 0 | 3693 | | ["Article"] = article, |
| | 0 | 3694 | | ["Client"] = client.Name, |
| | 0 | 3695 | | ["Item"] = JToken.FromObject(item) |
| | 0 | 3696 | | }; |
| | | 3697 | | |
| | | 3698 | | |
| | 0 | 3699 | | var bitrixStoreFilter = storeNames |
| | | 3700 | | .Where(s => !string.IsNullOrWhiteSpace(s)) |
| | | 3701 | | .Select(s => s.Trim()) |
| | 0 | 3702 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 3703 | | .ToList(); |
| | | 3704 | | |
| | 0 | 3705 | | await EnsureWarehouseMappingsInternalAsync(client, bitrixStoreFilter, warehouseList); |
| | | 3706 | | |
| | | 3707 | | List<string> allowedOzonWarehouseNames; |
| | 0 | 3708 | | if (bitrixStoreFilter.Count > 0) |
| | | 3709 | | { |
| | 0 | 3710 | | allowedOzonWarehouseNames = |
| | 0 | 3711 | | await _warehouseMappingDataServices.GetByNamByClient(client, bitrixStoreFilter); |
| | | 3712 | | } |
| | | 3713 | | else |
| | | 3714 | | { |
| | 0 | 3715 | | allowedOzonWarehouseNames = |
| | 0 | 3716 | | await _warehouseMappingDataServices.GetOzonWarehouseNamesByClient(client.Name); |
| | | 3717 | | } |
| | | 3718 | | |
| | 0 | 3719 | | var warehouseInfo = |
| | 0 | 3720 | | await _ozonApiDataManager.GetWarehouseInfo(stocks, article, client, warehouseList); |
| | | 3721 | | |
| | 0 | 3722 | | if (warehouseInfo == null) continue; |
| | | 3723 | | |
| | 0 | 3724 | | var warehousesArray = warehouseInfo["result"] as JArray ?? new JArray(); |
| | 0 | 3725 | | var allowedSet = new HashSet<string>( |
| | | 3726 | | allowedOzonWarehouseNames.Where(n => !string.IsNullOrWhiteSpace(n)), |
| | 0 | 3727 | | StringComparer.OrdinalIgnoreCase); |
| | | 3728 | | |
| | 0 | 3729 | | if (allowedSet.Count == 0) |
| | | 3730 | | { |
| | 0 | 3731 | | warehousesArray.Clear(); |
| | | 3732 | | } |
| | | 3733 | | |
| | 0 | 3734 | | var itemsToRemove = warehousesArray |
| | 0 | 3735 | | .Where(w => |
| | 0 | 3736 | | { |
| | 0 | 3737 | | var name = w["warehouse_name"]?.Value<string>(); |
| | 0 | 3738 | | var present = w["present"]?.Value<int>() ?? 0; |
| | 0 | 3739 | | return string.IsNullOrWhiteSpace(name) || |
| | 0 | 3740 | | !allowedSet.Contains(name) || |
| | 0 | 3741 | | present <= 0; |
| | 0 | 3742 | | }) |
| | 0 | 3743 | | .ToList(); |
| | | 3744 | | |
| | 0 | 3745 | | foreach (var jItem in itemsToRemove) |
| | | 3746 | | { |
| | 0 | 3747 | | jItem.Remove(); |
| | | 3748 | | } |
| | | 3749 | | |
| | 0 | 3750 | | if (warehouseInfo != null) |
| | | 3751 | | { |
| | 0 | 3752 | | wrapped["WarehouseInfo"] = warehouseInfo; |
| | | 3753 | | } |
| | | 3754 | | |
| | 0 | 3755 | | allItems.Add(wrapped); |
| | 0 | 3756 | | } |
| | | 3757 | | } |
| | 0 | 3758 | | } |
| | | 3759 | | |
| | 0 | 3760 | | return Content(allItems.ToString(), "application/json"); |
| | 0 | 3761 | | } |
| | | 3762 | | |
| | | 3763 | | [HttpGet("GetSyncReportPriceInfo")] |
| | | 3764 | | public async Task<IActionResult> GetSyncReportPriceInfo(string reportId, CancellationToken cancellationToken) |
| | | 3765 | | { |
| | 0 | 3766 | | if (string.IsNullOrWhiteSpace(reportId)) return BadRequest(); |
| | 0 | 3767 | | var info = await _bitrixStockSyncReportService.GetOzonPriceInfoAsync(reportId, cancellationToken); |
| | 0 | 3768 | | return Json(info); |
| | 0 | 3769 | | } |
| | | 3770 | | |
| | | 3771 | | [HttpGet("GetSyncReportAggregates")] |
| | | 3772 | | public async Task<IActionResult> GetSyncReportAggregates(string reportId, CancellationToken cancellationToken) |
| | | 3773 | | { |
| | 0 | 3774 | | if (string.IsNullOrWhiteSpace(reportId)) return BadRequest(); |
| | 0 | 3775 | | var agg = await _bitrixStockSyncReportService.GetReportAggregatesAsync(reportId.Trim(), cancellationToken); |
| | 0 | 3776 | | return Json(agg); |
| | 0 | 3777 | | } |
| | | 3778 | | |
| | | 3779 | | [HttpGet("GetSyncReportPricedArticles")] |
| | | 3780 | | public async Task<IActionResult> GetSyncReportPricedArticles(string reportId, CancellationToken cancellationToken) |
| | | 3781 | | { |
| | 0 | 3782 | | if (string.IsNullOrWhiteSpace(reportId)) return BadRequest(); |
| | 0 | 3783 | | var articles = await _bitrixStockSyncReportService.GetOzonPricedArticlesAsync(reportId, cancellationToken); |
| | 0 | 3784 | | return Json(articles); |
| | 0 | 3785 | | } |
| | | 3786 | | |
| | | 3787 | | [HttpGet("GetSyncReportPromos")] |
| | | 3788 | | public async Task<IActionResult> GetSyncReportPromos(string reportId, CancellationToken cancellationToken) |
| | | 3789 | | { |
| | 0 | 3790 | | if (string.IsNullOrWhiteSpace(reportId)) return BadRequest(); |
| | 0 | 3791 | | var promos = await _bitrixStockSyncReportService.GetReportPromosAsync(reportId, cancellationToken); |
| | 0 | 3792 | | return Json(promos); |
| | 0 | 3793 | | } |
| | | 3794 | | |
| | | 3795 | | [HttpGet("GetSyncReportPriceSettings")] |
| | | 3796 | | public async Task<IActionResult> GetSyncReportPriceSettings(string reportId, CancellationToken cancellationToken) |
| | | 3797 | | { |
| | 0 | 3798 | | if (string.IsNullOrWhiteSpace(reportId)) return BadRequest(); |
| | 0 | 3799 | | var settings = await _bitrixStockSyncReportService.GetReportPriceSettingsAsync(reportId, cancellationToken); |
| | 0 | 3800 | | return Json(settings ?? new Services.BitrixStockServices.ReportPriceSettingsDto()); |
| | 0 | 3801 | | } |
| | | 3802 | | |
| | | 3803 | | [HttpGet("GetArticleC1Price")] |
| | | 3804 | | public async Task<IActionResult> GetArticleC1Price(string article, CancellationToken cancellationToken) |
| | | 3805 | | { |
| | 0 | 3806 | | if (string.IsNullOrWhiteSpace(article)) return BadRequest(); |
| | 0 | 3807 | | var (c0, c1) = await _bitrixStockSyncReportService.GetArticleC1PriceAsync(article.Trim(), cancellationToken); |
| | 0 | 3808 | | return Json(new { c0, c1 }); |
| | 0 | 3809 | | } |
| | | 3810 | | |
| | | 3811 | | /// <summary> |
| | | 3812 | | /// Рассчитывает оптимальную цену по Модели 2 для артикула из отчёта. |
| | | 3813 | | /// Объединяет снимок цен Ozon, C0/C1 из 1С и настройки % из пресета. |
| | | 3814 | | /// </summary> |
| | | 3815 | | [HttpGet("GetPriceOptimization")] |
| | | 3816 | | public async Task<IActionResult> GetPriceOptimization(string reportId, string article, CancellationToken cancellatio |
| | | 3817 | | { |
| | 0 | 3818 | | if (string.IsNullOrWhiteSpace(reportId) || string.IsNullOrWhiteSpace(article)) |
| | 0 | 3819 | | return BadRequest(); |
| | | 3820 | | |
| | 0 | 3821 | | article = article.Trim(); |
| | | 3822 | | |
| | 0 | 3823 | | var (pricesTask, c1Task, settingsTask, promosTask) = ( |
| | 0 | 3824 | | _bitrixStockSyncReportService.GetOzonPriceSnapshotsAsync(reportId, article, cancellationToken), |
| | 0 | 3825 | | _bitrixStockSyncReportService.GetArticleC1PriceAsync(article, cancellationToken), |
| | 0 | 3826 | | _bitrixStockSyncReportService.GetReportPriceSettingsAsync(reportId, cancellationToken), |
| | 0 | 3827 | | _bitrixStockSyncReportService.GetReportPromosAsync(reportId, cancellationToken) |
| | 0 | 3828 | | ); |
| | | 3829 | | |
| | 0 | 3830 | | await Task.WhenAll(pricesTask, c1Task, settingsTask, promosTask); |
| | | 3831 | | |
| | 0 | 3832 | | var snapshots = pricesTask.Result; |
| | 0 | 3833 | | var (c0, c1) = c1Task.Result; |
| | 0 | 3834 | | var settings = settingsTask.Result; |
| | 0 | 3835 | | var promos = promosTask.Result; |
| | | 3836 | | |
| | 0 | 3837 | | if (snapshots.Count == 0) |
| | 0 | 3838 | | return Json(new { error = "Нет снимка цен Ozon для этого артикула." }); |
| | | 3839 | | |
| | | 3840 | | // promo — берём первую запись по артикулу (clientId не важен для флага) |
| | 0 | 3841 | | var promoEntry = promos.FirstOrDefault(p => |
| | 0 | 3842 | | string.Equals(p.Article, article, StringComparison.OrdinalIgnoreCase)); |
| | 0 | 3843 | | bool inPromo = promoEntry?.InPromo ?? false; |
| | 0 | 3844 | | string? promoName = promoEntry?.PromoName; |
| | | 3845 | | |
| | 0 | 3846 | | decimal noPromoMarkup = settings?.NoPromoMarkupPercent ?? 5m; |
| | 0 | 3847 | | decimal promoMarkup = settings?.PromoMarkupPercent ?? 10m; |
| | | 3848 | | |
| | | 3849 | | // Считаем по каждому клиенту (снимку), возвращаем массив результатов |
| | 0 | 3850 | | var results = new JArray(); |
| | 0 | 3851 | | foreach (var snap in snapshots) |
| | | 3852 | | { |
| | | 3853 | | JObject priceJson; |
| | 0 | 3854 | | try { priceJson = JObject.Parse(snap.PriceDataJson); } |
| | 0 | 3855 | | catch { continue; } |
| | | 3856 | | |
| | 0 | 3857 | | var optResult = PriceOptimizationManager.Calculate(new PriceOptimizationInput |
| | 0 | 3858 | | { |
| | 0 | 3859 | | C0 = c0, |
| | 0 | 3860 | | C1 = c1, |
| | 0 | 3861 | | PriceJson = priceJson, |
| | 0 | 3862 | | InPromo = inPromo, |
| | 0 | 3863 | | NoPromoMarkupPercent = noPromoMarkup, |
| | 0 | 3864 | | PromoMarkupPercent = promoMarkup |
| | 0 | 3865 | | }); |
| | | 3866 | | |
| | | 3867 | | // Поля из снимка — нужны для SetProductPrice без повторного запроса к Ozon |
| | 0 | 3868 | | long? snapProductId = priceJson["product_id"]?.Value<long?>(); |
| | 0 | 3869 | | string? snapOfferId = priceJson["offer_id"]?.Value<string>() ?? snap.OfferId; |
| | 0 | 3870 | | string? snapVat = priceJson["price"]?["vat"]?.Value<string>(); |
| | 0 | 3871 | | int batchMult = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(snapOfferId); |
| | | 3872 | | |
| | 0 | 3873 | | results.Add(new JObject |
| | 0 | 3874 | | { |
| | 0 | 3875 | | ["client"] = snap.ClientName, |
| | 0 | 3876 | | ["article"] = snap.Article, |
| | 0 | 3877 | | ["productId"] = snapProductId, |
| | 0 | 3878 | | ["offerId"] = snapOfferId, |
| | 0 | 3879 | | ["vat"] = snapVat, |
| | 0 | 3880 | | ["batchMultiplier"] = batchMult, |
| | 0 | 3881 | | ["inPromo"] = inPromo, |
| | 0 | 3882 | | ["promoName"] = promoName, |
| | 0 | 3883 | | ["c0"] = c0, |
| | 0 | 3884 | | ["c1"] = c1, |
| | 0 | 3885 | | ["error"] = optResult.Error, |
| | 0 | 3886 | | ["rate"] = optResult.HasData ? optResult.Rate : (decimal?)null, |
| | 0 | 3887 | | ["salesPctFbs"] = optResult.HasData ? optResult.SalesPctFbs : (decimal?)null, |
| | 0 | 3888 | | ["acquiring"] = optResult.HasData ? optResult.Acquiring : (decimal?)null, |
| | 0 | 3889 | | ["cFixedMax"] = optResult.HasData ? optResult.CFixedMax : (decimal?)null, |
| | 0 | 3890 | | ["firstMileMax"] = optResult.HasData ? optResult.FirstMileMax : (decimal?)null, |
| | 0 | 3891 | | ["directFlowMax"] = optResult.HasData ? optResult.DirectFlowMax : (decimal?)null, |
| | 0 | 3892 | | ["lastMile"] = optResult.HasData ? optResult.LastMile : (decimal?)null, |
| | 0 | 3893 | | ["pMin"] = optResult.PMin, |
| | 0 | 3894 | | ["p0"] = optResult.P0, |
| | 0 | 3895 | | ["p1"] = optResult.P1, |
| | 0 | 3896 | | ["p0Discounted"] = optResult.P0Discounted, |
| | 0 | 3897 | | ["p1Discounted"] = optResult.P1Discounted, |
| | 0 | 3898 | | ["winnerPrice"] = optResult.WinnerPrice, |
| | 0 | 3899 | | ["winnerSource"] = optResult.WinnerSource, |
| | 0 | 3900 | | ["markupPercentUsed"] = optResult.MarkupPercentUsed, |
| | 0 | 3901 | | ["noPromoMarkupPercent"] = noPromoMarkup, |
| | 0 | 3902 | | ["promoMarkupPercent"] = promoMarkup |
| | 0 | 3903 | | }); |
| | | 3904 | | } |
| | | 3905 | | |
| | 0 | 3906 | | return Content(results.ToString(), "application/json"); |
| | 0 | 3907 | | } |
| | | 3908 | | |
| | | 3909 | | [HttpGet("GetSyncReportOzonPrices")] |
| | | 3910 | | public async Task<IActionResult> GetSyncReportOzonPrices(string reportId, string? article, CancellationToken cancell |
| | | 3911 | | { |
| | 0 | 3912 | | if (string.IsNullOrWhiteSpace(reportId)) return BadRequest(); |
| | | 3913 | | |
| | 0 | 3914 | | var rows = await _bitrixStockSyncReportService.GetOzonPriceSnapshotsAsync(reportId, article, cancellationToken); |
| | | 3915 | | |
| | 0 | 3916 | | var result = new JArray(); |
| | 0 | 3917 | | foreach (var row in rows) |
| | | 3918 | | { |
| | | 3919 | | try |
| | | 3920 | | { |
| | 0 | 3921 | | var item = JObject.Parse(row.PriceDataJson); |
| | 0 | 3922 | | result.Add(new JObject { ["Article"] = row.Article, ["Client"] = row.ClientName, ["Item"] = item }); |
| | 0 | 3923 | | } |
| | 0 | 3924 | | catch { /* пропустить повреждённые записи */ } |
| | | 3925 | | } |
| | 0 | 3926 | | return Content(result.ToString(), "application/json"); |
| | 0 | 3927 | | } |
| | | 3928 | | |
| | | 3929 | | /// <summary> |
| | | 3930 | | /// Получает живые цены из Ozon, сохраняет снимок в БД и возвращает данные для модалки. |
| | | 3931 | | /// Формат ответа идентичен GetSyncReportOzonPrices. |
| | | 3932 | | /// </summary> |
| | | 3933 | | [HttpGet("GetLiveOzonPricesForModal")] |
| | | 3934 | | public async Task<IActionResult> GetLiveOzonPricesForModal( |
| | | 3935 | | string clientName, string article, |
| | | 3936 | | string? reportId, int ozonClientId, |
| | | 3937 | | CancellationToken cancellationToken) |
| | | 3938 | | { |
| | 0 | 3939 | | if (string.IsNullOrWhiteSpace(clientName) || string.IsNullOrWhiteSpace(article)) |
| | 0 | 3940 | | return BadRequest(); |
| | | 3941 | | |
| | 0 | 3942 | | await SetClients(); |
| | 0 | 3943 | | var client = _ozonClients.FirstOrDefault(o => |
| | 0 | 3944 | | string.Equals(o.Name, clientName, StringComparison.OrdinalIgnoreCase)); |
| | 0 | 3945 | | if (client == null) |
| | 0 | 3946 | | return Json(Array.Empty<object>()); |
| | | 3947 | | |
| | 0 | 3948 | | if (!TryGetOzonCredentials(client, out var apiClientId, out var apiKey)) |
| | 0 | 3949 | | return Json(Array.Empty<object>()); |
| | | 3950 | | |
| | 0 | 3951 | | var mgr = CreateOzonApiDataManager(apiClientId, apiKey); |
| | | 3952 | | |
| | | 3953 | | // Используем offer_id из снимка (например, "01019483B=CRT"), а не голый артикул — иначе Ozon не найдёт товар |
| | 0 | 3954 | | string[] searchIds = new[] { article }; |
| | 0 | 3955 | | if (!string.IsNullOrWhiteSpace(reportId) && ozonClientId > 0) |
| | | 3956 | | { |
| | 0 | 3957 | | var snapshots = await _bitrixStockSyncReportService.GetOzonPriceSnapshotsAsync(reportId, article, cancellati |
| | 0 | 3958 | | var snap = snapshots.FirstOrDefault(s => s.OzonClientId == ozonClientId); |
| | 0 | 3959 | | if (snap != null && !string.IsNullOrWhiteSpace(snap.OfferId)) |
| | 0 | 3960 | | searchIds = new[] { snap.OfferId }; |
| | | 3961 | | } |
| | | 3962 | | |
| | | 3963 | | // Используем raw-метод: возвращает полный Ozon JSON (snake_case) включая customer_price, true_price и др. |
| | | 3964 | | // GetProductPricesByArticlesModel → typed model → JObject.FromObject потерял бы customer_price и enriched поля |
| | 0 | 3965 | | var rawResponse = await mgr.GetProductPricesByArticles(searchIds); |
| | | 3966 | | |
| | 0 | 3967 | | var items = rawResponse?["items"] as JArray; |
| | 0 | 3968 | | if (items == null || items.Count == 0) |
| | 0 | 3969 | | return Json(Array.Empty<object>()); |
| | | 3970 | | |
| | 0 | 3971 | | var itemJson = items[0] as JObject; |
| | 0 | 3972 | | if (itemJson == null) |
| | 0 | 3973 | | return Json(Array.Empty<object>()); |
| | | 3974 | | |
| | | 3975 | | // Добавляем производные поля (max_commission, true_price) как при сохранении снимка |
| | 0 | 3976 | | EnrichOzonPriceItemInline(itemJson); |
| | | 3977 | | |
| | 0 | 3978 | | var freshJson = itemJson.ToString(Newtonsoft.Json.Formatting.None); |
| | | 3979 | | |
| | | 3980 | | // Сохраняем свежие данные в БД, чтобы после перезагрузки страницы отчёт показывал актуальную цену |
| | 0 | 3981 | | if (!string.IsNullOrWhiteSpace(reportId) && ozonClientId > 0) |
| | | 3982 | | { |
| | 0 | 3983 | | await _bitrixStockSyncReportService.UpdateOzonPriceSnapshotAsync( |
| | 0 | 3984 | | reportId, article, ozonClientId, freshJson, cancellationToken); |
| | | 3985 | | } |
| | | 3986 | | |
| | 0 | 3987 | | var result = new JArray |
| | 0 | 3988 | | { |
| | 0 | 3989 | | new JObject { ["Article"] = article, ["Client"] = clientName, ["Item"] = itemJson } |
| | 0 | 3990 | | }; |
| | 0 | 3991 | | return Content(result.ToString(), "application/json"); |
| | 0 | 3992 | | } |
| | | 3993 | | |
| | | 3994 | | |
| | | 3995 | | private static void EnrichOzonPriceItemInline(JObject item) |
| | | 3996 | | { |
| | 0 | 3997 | | var commissions = item["commissions"]; |
| | 0 | 3998 | | var priceObj = item["price"] as JObject; |
| | 0 | 3999 | | if (commissions == null || priceObj == null) return; |
| | | 4000 | | |
| | 0 | 4001 | | decimal salesPercent = commissions["sales_percent_fbs"]?.Value<decimal>() ?? 0; |
| | 0 | 4002 | | decimal itemPrice = priceObj["price"]?.Value<decimal>() ?? 0; |
| | 0 | 4003 | | decimal acquiring = item["acquiring"]?.Value<decimal>() ?? 0; |
| | 0 | 4004 | | decimal fbsFirstMileMin = commissions["fbs_first_mile_min_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 4005 | | decimal fbsFirstMileMax = commissions["fbs_first_mile_max_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 4006 | | decimal fbsDirectMin = commissions["fbs_direct_flow_trans_min_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 4007 | | decimal fbsDirectMax = commissions["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0; |
| | 0 | 4008 | | decimal fboDeliv = commissions["fbo_deliv_to_customer_amount"]?.Value<decimal>() ?? 0; |
| | | 4009 | | |
| | 0 | 4010 | | decimal ozonReward = itemPrice * salesPercent / 100; |
| | 0 | 4011 | | decimal maxCommission = ozonReward + acquiring + fbsFirstMileMax + fbsDirectMax + fboDeliv; |
| | 0 | 4012 | | decimal minCommission = ozonReward + acquiring + fbsFirstMileMin + fbsDirectMin + fboDeliv; |
| | 0 | 4013 | | double marketingPrice = priceObj["marketing_seller_price"]?.Value<double>() ?? 0; |
| | 0 | 4014 | | double truePrice = marketingPrice * 0.91; |
| | | 4015 | | |
| | 0 | 4016 | | priceObj["max_commission"] = Math.Round(maxCommission, 2); |
| | 0 | 4017 | | priceObj["min_commission"] = Math.Round(minCommission, 2); |
| | 0 | 4018 | | priceObj["ozon_reward"] = Math.Round(ozonReward, 2); |
| | 0 | 4019 | | priceObj["true_price"] = Math.Round(truePrice, 2); |
| | 0 | 4020 | | } |
| | | 4021 | | |
| | | 4022 | | private Task<List<WarehouseOzon>> GetOrCreateWarehouseCache(string clientId) => |
| | 0 | 4023 | | GetOrCreateWarehouseCache(clientId, _ozonApiDataManager); |
| | | 4024 | | |
| | | 4025 | | private async Task<List<WarehouseOzon>> GetOrCreateWarehouseCache(string clientId, OzonApiDataManager ozonApiDataMan |
| | | 4026 | | { |
| | 0 | 4027 | | var cacheKey = $"WarehouseList_{clientId}"; |
| | 0 | 4028 | | if (_memoryCache.TryGetValue(cacheKey, out List<WarehouseOzon>? cachedWarehouses) && cachedWarehouses != null) |
| | | 4029 | | { |
| | 0 | 4030 | | return cachedWarehouses; |
| | | 4031 | | } |
| | | 4032 | | |
| | 0 | 4033 | | var warehouseList = await ozonApiDataManager.GetWarehouseList(); |
| | | 4034 | | |
| | 0 | 4035 | | var cacheOptions = new MemoryCacheEntryOptions() |
| | 0 | 4036 | | .SetAbsoluteExpiration(TimeSpan.FromHours(1)) |
| | 0 | 4037 | | .SetSlidingExpiration(TimeSpan.FromMinutes(30)); |
| | | 4038 | | |
| | 0 | 4039 | | _memoryCache.Set(cacheKey, warehouseList, cacheOptions); |
| | | 4040 | | |
| | 0 | 4041 | | return warehouseList; |
| | 0 | 4042 | | } |
| | | 4043 | | |
| | | 4044 | | [HttpPost("UpdateProductStocks")] |
| | | 4045 | | public async Task<IActionResult> UpdateProductStocks( |
| | | 4046 | | [FromBody] UpdateProductStocksViewModel updateProductStocksViewModel) |
| | | 4047 | | { |
| | 0 | 4048 | | if (updateProductStocksViewModel == null) |
| | 0 | 4049 | | return BadRequest(new { Message = "Пустой запрос на обновление остатков" }); |
| | | 4050 | | |
| | 0 | 4051 | | if (string.IsNullOrWhiteSpace(updateProductStocksViewModel.OzonClient)) |
| | 0 | 4052 | | return BadRequest(new { Message = "Не указан клиент Ozon" }); |
| | | 4053 | | |
| | 0 | 4054 | | if (string.IsNullOrWhiteSpace(updateProductStocksViewModel.OfferId)) |
| | 0 | 4055 | | return BadRequest(new { Message = "Не указан offer_id товара" }); |
| | | 4056 | | |
| | 0 | 4057 | | if (updateProductStocksViewModel.WarehouseId <= 0) |
| | 0 | 4058 | | return BadRequest(new { Message = "Некорректный warehouse_id" }); |
| | | 4059 | | |
| | 0 | 4060 | | if (updateProductStocksViewModel.Stock < 0) |
| | 0 | 4061 | | return BadRequest(new { Message = "Остаток не может быть отрицательным" }); |
| | | 4062 | | |
| | 0 | 4063 | | if (string.Equals(updateProductStocksViewModel.BitrixActive, "N", StringComparison.OrdinalIgnoreCase)) |
| | | 4064 | | { |
| | 0 | 4065 | | return BadRequest(new |
| | 0 | 4066 | | { |
| | 0 | 4067 | | Message = "Товар неактивен (Активность = Нет). Накат остатков в Ozon для него запрещён — количество на с |
| | 0 | 4068 | | }); |
| | | 4069 | | } |
| | | 4070 | | |
| | 0 | 4071 | | await SetClients(); |
| | 0 | 4072 | | _memoryCache.Remove($"WarehouseList_{updateProductStocksViewModel.OzonClient}"); |
| | | 4073 | | |
| | 0 | 4074 | | var ozonClient = _ozonClients.Where(o => o.Name == updateProductStocksViewModel.OzonClient).FirstOrDefault(); |
| | 0 | 4075 | | if (ozonClient == null) |
| | 0 | 4076 | | return NotFound("Клиент не найден"); |
| | | 4077 | | |
| | | 4078 | | try |
| | | 4079 | | { |
| | 0 | 4080 | | if (!TryConfigureOzonApiClient(ozonClient)) |
| | 0 | 4081 | | return NotFound("Клиент не найден или не настроен"); |
| | | 4082 | | |
| | 0 | 4083 | | var productId = updateProductStocksViewModel.ProductId; |
| | 0 | 4084 | | if (productId <= 0) |
| | | 4085 | | { |
| | 0 | 4086 | | var articleBase = updateProductStocksViewModel.OfferId.Contains('=') |
| | 0 | 4087 | | ? updateProductStocksViewModel.OfferId.Split('=')[0] |
| | 0 | 4088 | | : updateProductStocksViewModel.OfferId; |
| | 0 | 4089 | | productId = await _ozonApiDataManager.TryResolveProductIdByOfferIdAsync( |
| | 0 | 4090 | | updateProductStocksViewModel.OfferId, |
| | 0 | 4091 | | articleBase); |
| | | 4092 | | } |
| | | 4093 | | |
| | 0 | 4094 | | if (productId <= 0) |
| | | 4095 | | { |
| | 0 | 4096 | | return BadRequest(new |
| | 0 | 4097 | | { |
| | 0 | 4098 | | Message = |
| | 0 | 4099 | | $"Не найден product_id в Ozon для offer_id «{updateProductStocksViewModel.OfferId}». " + |
| | 0 | 4100 | | "Проверьте, что товар заведён у этого клиента Ozon." |
| | 0 | 4101 | | }); |
| | | 4102 | | } |
| | | 4103 | | |
| | 0 | 4104 | | var result = await _ozonApiDataManager.UpdateProductStocks(new List<StockItemForUpdate> |
| | 0 | 4105 | | { |
| | 0 | 4106 | | new StockItemForUpdate |
| | 0 | 4107 | | { |
| | 0 | 4108 | | OfferId = updateProductStocksViewModel.OfferId, |
| | 0 | 4109 | | ProductId = productId, |
| | 0 | 4110 | | Stock = updateProductStocksViewModel.Stock, |
| | 0 | 4111 | | WarehouseId = updateProductStocksViewModel.WarehouseId |
| | 0 | 4112 | | } |
| | 0 | 4113 | | }); |
| | 0 | 4114 | | if (result?.Result == null || result.Result.Count == 0) |
| | | 4115 | | { |
| | 0 | 4116 | | return BadRequest("Ozon API не вернул результат."); |
| | | 4117 | | } |
| | | 4118 | | |
| | | 4119 | | var failedUpdates = result.Result.Where(r => r.Errors != null && r.Errors.Any()).ToList(); |
| | 0 | 4120 | | if (failedUpdates.Any()) |
| | | 4121 | | { |
| | 0 | 4122 | | return BadRequest(new |
| | 0 | 4123 | | { |
| | 0 | 4124 | | Message = "Обновление остатков выполнено с ошибками", |
| | | 4125 | | Errors = failedUpdates.SelectMany(f => f.Errors!) |
| | 0 | 4126 | | }); |
| | | 4127 | | } |
| | | 4128 | | |
| | 0 | 4129 | | if (result.Result.First().Updated) |
| | | 4130 | | { |
| | 0 | 4131 | | return Ok(new |
| | 0 | 4132 | | { |
| | 0 | 4133 | | Message = "Остатки успешно обновлены", |
| | 0 | 4134 | | ProductId = productId, |
| | 0 | 4135 | | Data = result.Result |
| | 0 | 4136 | | }); |
| | | 4137 | | } |
| | | 4138 | | else |
| | | 4139 | | { |
| | 0 | 4140 | | return Ok(new |
| | 0 | 4141 | | { |
| | 0 | 4142 | | Message = "Не удалось обработать остатки", |
| | 0 | 4143 | | ProductId = productId, |
| | 0 | 4144 | | Data = result.Result |
| | 0 | 4145 | | }); |
| | | 4146 | | } |
| | | 4147 | | } |
| | 0 | 4148 | | catch (Exception ex) |
| | | 4149 | | { |
| | 0 | 4150 | | return StatusCode(500, new { Message = "Ошибка при обработке запроса", Details = ex.Message }); |
| | | 4151 | | } |
| | 0 | 4152 | | } |
| | | 4153 | | |
| | | 4154 | | [HttpPost("UpdateProductPrice")] |
| | | 4155 | | public async Task<IActionResult> UpdateProductPrice([FromBody] UpdateProductPriceRequest request) |
| | | 4156 | | { |
| | 0 | 4157 | | if (request == null) |
| | 0 | 4158 | | return BadRequest(new { message = "Пустой запрос" }); |
| | 0 | 4159 | | if (string.IsNullOrWhiteSpace(request.Article)) |
| | 0 | 4160 | | return BadRequest(new { message = "Не указан артикул" }); |
| | 0 | 4161 | | if (string.IsNullOrWhiteSpace(request.ClientName)) |
| | 0 | 4162 | | return BadRequest(new { message = "Не указан клиент Ozon" }); |
| | 0 | 4163 | | if (request.ConstantIndex <= 0) |
| | 0 | 4164 | | return BadRequest(new { message = "ConstantIndex должен быть > 0" }); |
| | 0 | 4165 | | if (request.Percent <= 0 || request.Percent >= 100) |
| | 0 | 4166 | | return BadRequest(new { message = "Percent должен быть в диапазоне (0, 100)" }); |
| | | 4167 | | |
| | | 4168 | | try |
| | | 4169 | | { |
| | 0 | 4170 | | await SetClients(); |
| | 0 | 4171 | | var targetClient = _ozonClients.FirstOrDefault(o => o.Name == request.ClientName); |
| | 0 | 4172 | | if (targetClient == null) |
| | 0 | 4173 | | return NotFound(new { message = "Клиент не найден" }); |
| | | 4174 | | |
| | 0 | 4175 | | var initialIndex = await GetPriceIndexValue(request.Article, targetClient); |
| | | 4176 | | |
| | 0 | 4177 | | var result = await _importProductPricesManager.UpdateProductPrices( |
| | 0 | 4178 | | request.Article, |
| | 0 | 4179 | | targetClient, |
| | 0 | 4180 | | request.ConstantIndex, |
| | 0 | 4181 | | request.Percent); |
| | | 4182 | | |
| | 0 | 4183 | | result ??= new ProductImportPricesResponse(); |
| | 0 | 4184 | | result.Result ??= new List<ProductImportPriceResult>(); |
| | | 4185 | | |
| | 0 | 4186 | | var maxAttempts = 10; |
| | 0 | 4187 | | var delayMs = 2000; |
| | 0 | 4188 | | var indexChanged = await WaitForIndexChange( |
| | 0 | 4189 | | request.Article, |
| | 0 | 4190 | | targetClient, |
| | 0 | 4191 | | initialIndex, |
| | 0 | 4192 | | maxAttempts, |
| | 0 | 4193 | | delayMs |
| | 0 | 4194 | | ); |
| | | 4195 | | |
| | 0 | 4196 | | if (result.Result.Count == 0) |
| | | 4197 | | { |
| | 0 | 4198 | | result.Result.Add(new ProductImportPriceResult |
| | 0 | 4199 | | { |
| | 0 | 4200 | | OfferId = request.Article, |
| | 0 | 4201 | | Updated = false, |
| | 0 | 4202 | | Errors = new List<ProductImportPriceError> |
| | 0 | 4203 | | { |
| | 0 | 4204 | | new ProductImportPriceError |
| | 0 | 4205 | | { |
| | 0 | 4206 | | Code = "EMPTY_OZON_RESPONSE", |
| | 0 | 4207 | | Message = "Ozon API вернул пустой результат при импорте цен" |
| | 0 | 4208 | | } |
| | 0 | 4209 | | } |
| | 0 | 4210 | | }); |
| | | 4211 | | } |
| | | 4212 | | |
| | 0 | 4213 | | result.Result[0].Message = indexChanged; |
| | 0 | 4214 | | return Ok(result); |
| | | 4215 | | } |
| | 0 | 4216 | | catch (Exception ex) |
| | | 4217 | | { |
| | 0 | 4218 | | var errorText = ex.InnerException == null |
| | 0 | 4219 | | ? ex.Message |
| | 0 | 4220 | | : $"{ex.Message} | Inner: {ex.InnerException.Message}"; |
| | | 4221 | | |
| | 0 | 4222 | | return StatusCode(500, new |
| | 0 | 4223 | | { |
| | 0 | 4224 | | message = "Ошибка при обновлении цены через Ozon API", |
| | 0 | 4225 | | article = request.Article, |
| | 0 | 4226 | | client = request.ClientName, |
| | 0 | 4227 | | details = errorText |
| | 0 | 4228 | | }); |
| | | 4229 | | } |
| | 0 | 4230 | | } |
| | | 4231 | | |
| | | 4232 | | [HttpPost("SetProductPrice")] |
| | | 4233 | | public async Task<IActionResult> SetProductPrice([FromBody] SetProductPriceRequest request) |
| | | 4234 | | { |
| | 0 | 4235 | | if (request == null) |
| | 0 | 4236 | | return BadRequest(new { message = "Пустой запрос" }); |
| | 0 | 4237 | | if (string.IsNullOrWhiteSpace(request.Article)) |
| | 0 | 4238 | | return BadRequest(new { message = "Не указан артикул" }); |
| | 0 | 4239 | | if (string.IsNullOrWhiteSpace(request.ClientName)) |
| | 0 | 4240 | | return BadRequest(new { message = "Не указан клиент Ozon" }); |
| | | 4241 | | |
| | | 4242 | | try |
| | | 4243 | | { |
| | 0 | 4244 | | await SetClients(); |
| | 0 | 4245 | | var targetClient = _ozonClients.FirstOrDefault(o => o.Name == request.ClientName); |
| | 0 | 4246 | | if (targetClient == null) |
| | 0 | 4247 | | return NotFound(new { message = "Клиент не найден" }); |
| | | 4248 | | |
| | 0 | 4249 | | var initialIndex = await GetPriceIndexValue(request.Article, targetClient); |
| | | 4250 | | |
| | | 4251 | | ProductImportPricesResponse result; |
| | 0 | 4252 | | if (request.ProductId.HasValue && !string.IsNullOrWhiteSpace(request.OfferId)) |
| | | 4253 | | { |
| | | 4254 | | // Быстрый путь: данные уже есть из снимка — не запрашиваем Ozon повторно |
| | 0 | 4255 | | result = await _importProductPricesManager.SetProductPricesDirect( |
| | 0 | 4256 | | request.OfferId, |
| | 0 | 4257 | | request.ProductId.Value, |
| | 0 | 4258 | | request.Vat ?? "0", |
| | 0 | 4259 | | targetClient, |
| | 0 | 4260 | | request.YourPrice, |
| | 0 | 4261 | | request.OldPrice, |
| | 0 | 4262 | | request.MinPrice, |
| | 0 | 4263 | | request.CostPrice); |
| | | 4264 | | } |
| | | 4265 | | else |
| | | 4266 | | { |
| | 0 | 4267 | | result = await _importProductPricesManager.SetProductPrices( |
| | 0 | 4268 | | request.Article, |
| | 0 | 4269 | | targetClient, |
| | 0 | 4270 | | request.YourPrice, |
| | 0 | 4271 | | request.OldPrice, |
| | 0 | 4272 | | request.MinPrice, |
| | 0 | 4273 | | request.CostPrice); |
| | | 4274 | | } |
| | | 4275 | | |
| | 0 | 4276 | | result ??= new ProductImportPricesResponse(); |
| | 0 | 4277 | | result.Result ??= new List<ProductImportPriceResult>(); |
| | | 4278 | | |
| | | 4279 | | // 3️⃣ Настройки ожидания |
| | 0 | 4280 | | var maxAttempts = 10; |
| | 0 | 4281 | | var delayMs = 2000; |
| | 0 | 4282 | | var indexChanged = await WaitForIndexChange( |
| | 0 | 4283 | | request.Article, |
| | 0 | 4284 | | targetClient, |
| | 0 | 4285 | | initialIndex, |
| | 0 | 4286 | | maxAttempts, |
| | 0 | 4287 | | delayMs |
| | 0 | 4288 | | ); |
| | 0 | 4289 | | if (result.Result.Count == 0) |
| | | 4290 | | { |
| | 0 | 4291 | | result.Result.Add(new ProductImportPriceResult |
| | 0 | 4292 | | { |
| | 0 | 4293 | | OfferId = request.Article, |
| | 0 | 4294 | | Updated = false, |
| | 0 | 4295 | | Errors = new List<ProductImportPriceError> |
| | 0 | 4296 | | { |
| | 0 | 4297 | | new ProductImportPriceError |
| | 0 | 4298 | | { |
| | 0 | 4299 | | Code = "EMPTY_OZON_RESPONSE", |
| | 0 | 4300 | | Message = "Ozon API вернул пустой результат при установке цены" |
| | 0 | 4301 | | } |
| | 0 | 4302 | | } |
| | 0 | 4303 | | }); |
| | | 4304 | | } |
| | | 4305 | | |
| | 0 | 4306 | | result.Result[0].Message = indexChanged; |
| | | 4307 | | |
| | 0 | 4308 | | var pushErrors = result.Result[0].Errors ?? new List<ProductImportPriceError>(); |
| | 0 | 4309 | | if (pushErrors.Count == 0 |
| | 0 | 4310 | | && !string.IsNullOrWhiteSpace(request.ReportId) |
| | 0 | 4311 | | && request.OzonClientId > 0 |
| | 0 | 4312 | | && request.PreviousPrice.HasValue) |
| | | 4313 | | { |
| | 0 | 4314 | | _ = _bitrixStockSyncReportService.RecordPricePushAsync( |
| | 0 | 4315 | | request.ReportId, |
| | 0 | 4316 | | request.Article, |
| | 0 | 4317 | | request.OzonClientId, |
| | 0 | 4318 | | (decimal)request.PreviousPrice.Value, |
| | 0 | 4319 | | (decimal)request.YourPrice, |
| | 0 | 4320 | | request.MarkupPct, |
| | 0 | 4321 | | request.ColorIndexBefore, |
| | 0 | 4322 | | request.CompetitorPrice, |
| | 0 | 4323 | | CancellationToken.None); |
| | | 4324 | | } |
| | | 4325 | | |
| | 0 | 4326 | | return Ok(result); |
| | | 4327 | | } |
| | 0 | 4328 | | catch (Exception ex) |
| | | 4329 | | { |
| | 0 | 4330 | | var errorText = ex.InnerException == null |
| | 0 | 4331 | | ? ex.Message |
| | 0 | 4332 | | : $"{ex.Message} | Inner: {ex.InnerException.Message}"; |
| | | 4333 | | |
| | 0 | 4334 | | return StatusCode(500, new |
| | 0 | 4335 | | { |
| | 0 | 4336 | | message = "Ошибка при установке цены через Ozon API", |
| | 0 | 4337 | | article = request.Article, |
| | 0 | 4338 | | client = request.ClientName, |
| | 0 | 4339 | | details = errorText |
| | 0 | 4340 | | }); |
| | | 4341 | | } |
| | 0 | 4342 | | } |
| | | 4343 | | |
| | | 4344 | | [HttpPost("EnsureWarehouseMappings")] |
| | | 4345 | | public async Task<IActionResult> EnsureWarehouseMappings([FromBody] EnsureWarehouseMappingsRequest request) |
| | | 4346 | | { |
| | 0 | 4347 | | if (request == null || string.IsNullOrWhiteSpace(request.ClientName)) |
| | 0 | 4348 | | return BadRequest(new { message = "Не указан клиент Ozon" }); |
| | | 4349 | | |
| | 0 | 4350 | | await SetClients(); |
| | 0 | 4351 | | var client = _ozonClients.FirstOrDefault(c => |
| | 0 | 4352 | | string.Equals(c.Name, request.ClientName.Trim(), StringComparison.OrdinalIgnoreCase)); |
| | 0 | 4353 | | if (client == null) |
| | 0 | 4354 | | return NotFound(new { message = "Клиент не найден" }); |
| | | 4355 | | |
| | 0 | 4356 | | if (!TryConfigureOzonApiClient(client)) |
| | 0 | 4357 | | return BadRequest(new { Message = "Клиент Ozon не настроен" }); |
| | | 4358 | | |
| | 0 | 4359 | | var warehouseList = await GetOrCreateWarehouseCache(client.DecryptClientId!); |
| | 0 | 4360 | | var bitrixNames = request.BitrixWarehouseNames? |
| | | 4361 | | .Where(s => !string.IsNullOrWhiteSpace(s)) |
| | | 4362 | | .Select(s => s.Trim()) |
| | 0 | 4363 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 4364 | | .ToList() ?? new List<string>(); |
| | | 4365 | | |
| | 0 | 4366 | | var ensureResult = await BitrixWarehouseMappingEnsure.EnsureAsync( |
| | 0 | 4367 | | _warehouseMappingDataServices, client, bitrixNames, warehouseList); |
| | 0 | 4368 | | return Ok(new |
| | 0 | 4369 | | { |
| | 0 | 4370 | | created = ensureResult.CreatedCount, |
| | 0 | 4371 | | updatedIds = ensureResult.UpdatedIdCount, |
| | 0 | 4372 | | log = ensureResult.LogLines |
| | 0 | 4373 | | }); |
| | 0 | 4374 | | } |
| | | 4375 | | |
| | | 4376 | | [HttpPost("ToggleActiveStatus")] |
| | | 4377 | | public async Task<IActionResult> ToggleActiveStatus([FromBody] int productId) |
| | | 4378 | | { |
| | | 4379 | | try |
| | | 4380 | | { |
| | 0 | 4381 | | var result = await _repository.ToggleElementActiveStatus(productId); |
| | | 4382 | | |
| | 0 | 4383 | | if (result == null) |
| | | 4384 | | { |
| | 0 | 4385 | | return NotFound(new { success = false, message = "Элемент не найден" }); |
| | | 4386 | | } |
| | | 4387 | | |
| | 0 | 4388 | | return Ok(new |
| | 0 | 4389 | | { |
| | 0 | 4390 | | success = true, |
| | 0 | 4391 | | message = "Статус успешно обновлен", |
| | 0 | 4392 | | isActive = result.Value |
| | 0 | 4393 | | }); |
| | | 4394 | | } |
| | 0 | 4395 | | catch (Exception ex) |
| | | 4396 | | { |
| | 0 | 4397 | | return StatusCode(500, new |
| | 0 | 4398 | | { |
| | 0 | 4399 | | success = false, |
| | 0 | 4400 | | message = "Ошибка при обновлении статуса", |
| | 0 | 4401 | | error = ex.Message |
| | 0 | 4402 | | }); |
| | | 4403 | | } |
| | 0 | 4404 | | } |
| | | 4405 | | |
| | | 4406 | | |
| | | 4407 | | private async Task<List<object>> EnsureWarehouseMappingsInternalAsync( |
| | | 4408 | | OzonClient client, |
| | | 4409 | | List<string> bitrixWarehouseNames, |
| | | 4410 | | List<WarehouseOzon> warehouseList) |
| | | 4411 | | { |
| | 0 | 4412 | | var ensureResult = await BitrixWarehouseMappingEnsure.EnsureAsync( |
| | 0 | 4413 | | _warehouseMappingDataServices, |
| | 0 | 4414 | | client, |
| | 0 | 4415 | | bitrixWarehouseNames, |
| | 0 | 4416 | | warehouseList); |
| | 0 | 4417 | | return ensureResult.Created.ToList(); |
| | 0 | 4418 | | } |
| | | 4419 | | |
| | | 4420 | | private async Task SetClients() |
| | | 4421 | | { |
| | 0 | 4422 | | if (_ozonClients.Count == 0) |
| | | 4423 | | { |
| | | 4424 | | _ozonClients = (await _ozonClientServices.GetOzonClients()).Where(o => o.ClientType == ClientType.OZON) |
| | 0 | 4425 | | .ToList(); |
| | 0 | 4426 | | _ = PreloadWarehouseCachesAsync(); |
| | | 4427 | | } |
| | | 4428 | | |
| | 0 | 4429 | | var defaultClient = _ozonClients.FirstOrDefault(HasOzonCredentials); |
| | 0 | 4430 | | if (defaultClient == null || !TryGetOzonCredentials(defaultClient, out var defaultClientId, out var defaultApiKe |
| | 0 | 4431 | | throw new InvalidOperationException("Нет Ozon-клиентов с настроенными учётными данными."); |
| | | 4432 | | |
| | 0 | 4433 | | _ozonApiDataManager = CreateOzonApiDataManager(defaultClientId, defaultApiKey); |
| | 0 | 4434 | | } |
| | | 4435 | | |
| | | 4436 | | private async Task<decimal?> GetPriceIndexValue(string article, OzonClient targetClient) |
| | | 4437 | | { |
| | | 4438 | | try |
| | | 4439 | | { |
| | 0 | 4440 | | if (!TryConfigureOzonApiClient(targetClient)) |
| | 0 | 4441 | | return null; |
| | | 4442 | | |
| | 0 | 4443 | | JObject prices = await _ozonApiDataManager.GetProductPricesByArticles(new[] { article }); |
| | | 4444 | | |
| | 0 | 4445 | | var item = prices["items"]?.FirstOrDefault(); |
| | 0 | 4446 | | var priceIndexValue = item?["price_indexes"]?["ozon_index_data"]?["price_index_value"]?.Value<decimal>(); |
| | | 4447 | | |
| | 0 | 4448 | | return priceIndexValue; |
| | | 4449 | | } |
| | 0 | 4450 | | catch (Exception ex) |
| | | 4451 | | { |
| | 0 | 4452 | | await NotificationService.NotifyAllAsync($"Ошибка при получении индекса цены: {ex.Message}"); |
| | 0 | 4453 | | return null; |
| | | 4454 | | } |
| | 0 | 4455 | | } |
| | | 4456 | | |
| | | 4457 | | private async Task<string> WaitForIndexChange( |
| | | 4458 | | string article, |
| | | 4459 | | OzonClient targetClient, |
| | | 4460 | | decimal? initialIndex, |
| | | 4461 | | int maxAttempts, |
| | | 4462 | | int delayMs) |
| | | 4463 | | { |
| | 0 | 4464 | | var attemptsLog = new List<string>(); |
| | 0 | 4465 | | var startTime = DateTime.Now; // фиксируем время начала |
| | | 4466 | | |
| | 0 | 4467 | | for (int attempt = 1; attempt <= maxAttempts; attempt++) |
| | | 4468 | | { |
| | 0 | 4469 | | await Task.Delay(delayMs); |
| | | 4470 | | |
| | 0 | 4471 | | var currentIndex = await GetPriceIndexValue(article, targetClient); |
| | | 4472 | | |
| | | 4473 | | // Логируем состояние каждой попытки |
| | 0 | 4474 | | attemptsLog.Add( |
| | 0 | 4475 | | $"{DateTime.Now:dd.MM.yyyy HH:mm:ss} — Попытка {attempt}/{maxAttempts}: индекс = {currentIndex?.ToString |
| | | 4476 | | |
| | 0 | 4477 | | if (currentIndex.HasValue && initialIndex.HasValue && currentIndex.Value != initialIndex.Value) |
| | 0 | 4478 | | return await SendSummaryAndReturn(true); |
| | | 4479 | | |
| | 0 | 4480 | | if (currentIndex.HasValue != initialIndex.HasValue) |
| | 0 | 4481 | | return await SendSummaryAndReturn(true); |
| | | 4482 | | } |
| | | 4483 | | |
| | 0 | 4484 | | return await SendSummaryAndReturn(false); |
| | | 4485 | | |
| | | 4486 | | // Локальная функция для отправки итогового отчёта |
| | | 4487 | | async Task<string> SendSummaryAndReturn(bool changed) |
| | | 4488 | | { |
| | 0 | 4489 | | var status = changed ? "✅ Индекс изменился" : "❌ Индекс не изменился"; |
| | 0 | 4490 | | var textReport = status + "\n"; // добавляем переносы после статуса |
| | | 4491 | | |
| | 0 | 4492 | | foreach (var attempt in attemptsLog) |
| | | 4493 | | { |
| | 0 | 4494 | | textReport += attempt + "\n"; |
| | | 4495 | | } |
| | | 4496 | | |
| | | 4497 | | // Вычисляем общее время в секундах |
| | 0 | 4498 | | var totalSeconds = (DateTime.Now - startTime).TotalSeconds; |
| | 0 | 4499 | | textReport += $"\nОбщее затраченное время: {totalSeconds:0.##} секунд"; |
| | | 4500 | | |
| | 0 | 4501 | | await NotificationService.NotifyAllAsync(textReport); |
| | 0 | 4502 | | return $"{status} ({totalSeconds:0.##}) с."; |
| | 0 | 4503 | | } |
| | 0 | 4504 | | } |
| | | 4505 | | |
| | | 4506 | | private async Task PreloadWarehouseCachesAsync() |
| | | 4507 | | { |
| | | 4508 | | try |
| | | 4509 | | { |
| | 0 | 4510 | | foreach (var client in _ozonClients.Where(HasOzonCredentials)) |
| | | 4511 | | { |
| | 0 | 4512 | | _ = Task.Run(async () => |
| | 0 | 4513 | | { |
| | 0 | 4514 | | try |
| | 0 | 4515 | | { |
| | 0 | 4516 | | var cacheKey = $"WarehouseList_{client.DecryptClientId}"; |
| | 0 | 4517 | | |
| | 0 | 4518 | | if (_memoryCache.TryGetValue(cacheKey, out _)) |
| | 0 | 4519 | | { |
| | 0 | 4520 | | Console.WriteLine($"Склады уже в кэше для {client.Name}, пропускаем..."); |
| | 0 | 4521 | | return; |
| | 0 | 4522 | | } |
| | 0 | 4523 | | |
| | 0 | 4524 | | if (!TryConfigureOzonApiClient(client)) |
| | 0 | 4525 | | return; |
| | 0 | 4526 | | |
| | 0 | 4527 | | var tempManager = _ozonApiDataManager; |
| | 0 | 4528 | | var warehouses = await tempManager.GetWarehouseList(); |
| | 0 | 4529 | | |
| | 0 | 4530 | | var cacheOptions = new MemoryCacheEntryOptions() |
| | 0 | 4531 | | .SetAbsoluteExpiration(TimeSpan.FromHours(1)); |
| | 0 | 4532 | | |
| | 0 | 4533 | | _memoryCache.Set(cacheKey, warehouses, cacheOptions); |
| | 0 | 4534 | | Console.WriteLine($"Склады добавлены в кэш для {client.Name}"); |
| | 0 | 4535 | | } |
| | 0 | 4536 | | catch (Exception ex) |
| | 0 | 4537 | | { |
| | 0 | 4538 | | Console.WriteLine($"Background cache preload failed for client {client.Name}: {ex.Message}"); |
| | 0 | 4539 | | } |
| | 0 | 4540 | | }); |
| | | 4541 | | |
| | 0 | 4542 | | await Task.Delay(100); |
| | | 4543 | | } |
| | 0 | 4544 | | } |
| | 0 | 4545 | | catch (Exception ex) |
| | | 4546 | | { |
| | 0 | 4547 | | Console.WriteLine($"Cache preloading failed: {ex.Message}"); |
| | 0 | 4548 | | } |
| | 0 | 4549 | | } |
| | | 4550 | | |
| | | 4551 | | private static bool HasPointedProductSearch(RemainingStockFilter filter) => |
| | 0 | 4552 | | filter.ProductId.HasValue || !string.IsNullOrWhiteSpace(filter.Article); |
| | | 4553 | | |
| | | 4554 | | private static bool HasNarrowingSearch(RemainingStockFilter filter) => |
| | 0 | 4555 | | HasPointedProductSearch(filter) || |
| | 0 | 4556 | | !string.IsNullOrWhiteSpace(filter.Supplier) || |
| | 0 | 4557 | | !string.IsNullOrWhiteSpace(filter.PreviewText) || |
| | 0 | 4558 | | filter.OnlyInactive || |
| | 0 | 4559 | | filter.OnlyActive || |
| | 0 | 4560 | | !string.IsNullOrWhiteSpace(filter.Active) || |
| | 0 | 4561 | | !string.IsNullOrWhiteSpace(filter.Available) || |
| | 0 | 4562 | | filter.QuantityFrom.HasValue || |
| | 0 | 4563 | | filter.QuantityTo.HasValue || |
| | 0 | 4564 | | filter.OzonAmountFrom.HasValue || |
| | 0 | 4565 | | filter.OzonAmountTo.HasValue || |
| | 0 | 4566 | | !string.IsNullOrWhiteSpace(filter.OzonStoreTitle) || |
| | 0 | 4567 | | filter.DiffOzonWarehouses; |
| | | 4568 | | |
| | | 4569 | | private static bool IsWideOzonBrowsing(RemainingStockFilter filter) => |
| | 0 | 4570 | | filter.LoadOzonWarehouses && !HasNarrowingSearch(filter); |
| | | 4571 | | |
| | | 4572 | | /// <summary>Авто-push в Ozon при загрузке отключён — только ручные кнопки наката/обнуления.</summary> |
| | 0 | 4573 | | private static bool ShouldSyncLinkedStocksToOzonOnLoad(RemainingStockFilter filter, int itemCount) => false; |
| | | 4574 | | |
| | | 4575 | | private static bool IsOzonLoadFailure(Exception ex) |
| | | 4576 | | { |
| | 0 | 4577 | | for (var current = ex; current != null; current = current.InnerException) |
| | | 4578 | | { |
| | 0 | 4579 | | if (current is TaskCanceledException or TimeoutException or HttpRequestException) |
| | | 4580 | | { |
| | 0 | 4581 | | return true; |
| | | 4582 | | } |
| | | 4583 | | |
| | 0 | 4584 | | if (current.GetType().FullName == "Polly.CircuitBreaker.BrokenCircuitException") |
| | | 4585 | | { |
| | 0 | 4586 | | return true; |
| | | 4587 | | } |
| | | 4588 | | } |
| | | 4589 | | |
| | 0 | 4590 | | return false; |
| | | 4591 | | } |
| | | 4592 | | |
| | | 4593 | | private static string DescribeOzonLoadFailure(Exception ex) |
| | | 4594 | | { |
| | 0 | 4595 | | for (var current = ex; current != null; current = current.InnerException) |
| | | 4596 | | { |
| | 0 | 4597 | | if (current.GetType().FullName == "Polly.CircuitBreaker.BrokenCircuitException") |
| | | 4598 | | { |
| | 0 | 4599 | | return "сервис Ozon временно недоступен (circuit breaker), повторите через минуту"; |
| | | 4600 | | } |
| | | 4601 | | |
| | 0 | 4602 | | if (current is TaskCanceledException or TimeoutException) |
| | | 4603 | | { |
| | 0 | 4604 | | return "превышено время ожидания API"; |
| | | 4605 | | } |
| | | 4606 | | |
| | 0 | 4607 | | if (current is HttpRequestException httpEx) |
| | | 4608 | | { |
| | 0 | 4609 | | return string.IsNullOrWhiteSpace(httpEx.Message) |
| | 0 | 4610 | | ? "ошибка соединения с API Ozon" |
| | 0 | 4611 | | : httpEx.Message; |
| | | 4612 | | } |
| | | 4613 | | } |
| | | 4614 | | |
| | 0 | 4615 | | return ex.Message; |
| | | 4616 | | } |
| | | 4617 | | |
| | | 4618 | | private static string BuildArticlesCacheKeyPart(string[] articles) |
| | | 4619 | | { |
| | 0 | 4620 | | if (articles.Length == 0) |
| | | 4621 | | { |
| | 0 | 4622 | | return "empty"; |
| | | 4623 | | } |
| | | 4624 | | |
| | 0 | 4625 | | if (articles.Length <= 50) |
| | | 4626 | | { |
| | 0 | 4627 | | return string.Join("|", articles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)); |
| | | 4628 | | } |
| | | 4629 | | |
| | 0 | 4630 | | var bytes = SHA256.HashData(Encoding.UTF8.GetBytes( |
| | 0 | 4631 | | string.Join('\n', articles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)))); |
| | 0 | 4632 | | return Convert.ToHexString(bytes); |
| | | 4633 | | } |
| | | 4634 | | |
| | | 4635 | | private bool IsBareBitrixStockPageLoad() |
| | | 4636 | | { |
| | 0 | 4637 | | return !Request.Query.Keys.Any(key => |
| | 0 | 4638 | | !key.Equals("page", StringComparison.OrdinalIgnoreCase) && |
| | 0 | 4639 | | !key.Equals("pagesize", StringComparison.OrdinalIgnoreCase)); |
| | | 4640 | | } |
| | | 4641 | | |
| | | 4642 | | private void AppendPerformanceNotice(string message) |
| | | 4643 | | { |
| | 0 | 4644 | | if (string.IsNullOrWhiteSpace(message)) |
| | | 4645 | | { |
| | 0 | 4646 | | return; |
| | | 4647 | | } |
| | | 4648 | | |
| | 0 | 4649 | | ViewData["PerformanceNotice"] = ViewData["PerformanceNotice"] is string existing && !string.IsNullOrWhiteSpace(e |
| | 0 | 4650 | | ? $"{existing} {message}" |
| | 0 | 4651 | | : message; |
| | 0 | 4652 | | } |
| | | 4653 | | |
| | | 4654 | | // ══════════════════════════════════════════════════════════════ |
| | | 4655 | | // Справочник исключений |
| | | 4656 | | // ══════════════════════════════════════════════════════════════ |
| | | 4657 | | |
| | | 4658 | | [ServiceEndpoint] |
| | | 4659 | | [HttpGet("exceptions/articles")] |
| | | 4660 | | [HttpGet("/exceptions/articles")] |
| | | 4661 | | public async Task<IActionResult> GetExceptionArticles(CancellationToken ct) |
| | | 4662 | | { |
| | 0 | 4663 | | var items = await _exceptionService.GetExceptionArticlesAsync(ct); |
| | 0 | 4664 | | return Ok(items); |
| | 0 | 4665 | | } |
| | | 4666 | | |
| | | 4667 | | [ServiceEndpoint] |
| | | 4668 | | [HttpPost("exceptions/articles")] |
| | | 4669 | | [HttpPost("/exceptions/articles")] |
| | | 4670 | | [IgnoreAntiforgeryToken] |
| | | 4671 | | public async Task<IActionResult> UpsertExceptionArticle([FromBody] BitrixStockExceptionArticle entity, CancellationT |
| | | 4672 | | { |
| | 0 | 4673 | | entity.Id = 0; |
| | 0 | 4674 | | entity.CreatedBy = User.Identity?.Name; |
| | 0 | 4675 | | var result = await _exceptionService.UpsertExceptionArticleAsync(entity, ct); |
| | 0 | 4676 | | return Ok(result); |
| | 0 | 4677 | | } |
| | | 4678 | | |
| | | 4679 | | [ServiceEndpoint] |
| | | 4680 | | [HttpPut("exceptions/articles/{id:int}")] |
| | | 4681 | | [HttpPut("/exceptions/articles/{id:int}")] |
| | | 4682 | | [IgnoreAntiforgeryToken] |
| | | 4683 | | public async Task<IActionResult> UpdateExceptionArticle(int id, [FromBody] BitrixStockExceptionArticle entity, Cance |
| | | 4684 | | { |
| | 0 | 4685 | | entity.Id = id; |
| | 0 | 4686 | | entity.CreatedBy = User.Identity?.Name; |
| | 0 | 4687 | | var result = await _exceptionService.UpsertExceptionArticleAsync(entity, ct); |
| | 0 | 4688 | | return Ok(result); |
| | 0 | 4689 | | } |
| | | 4690 | | |
| | | 4691 | | [ServiceEndpoint] |
| | | 4692 | | [HttpDelete("exceptions/articles/{id:int}")] |
| | | 4693 | | [HttpDelete("/exceptions/articles/{id:int}")] |
| | | 4694 | | [IgnoreAntiforgeryToken] |
| | | 4695 | | public async Task<IActionResult> DeleteExceptionArticle(int id, CancellationToken ct) |
| | | 4696 | | { |
| | 0 | 4697 | | await _exceptionService.DeleteExceptionArticleAsync(id, ct); |
| | 0 | 4698 | | return Ok(new { deleted = true }); |
| | 0 | 4699 | | } |
| | | 4700 | | |
| | | 4701 | | [ServiceEndpoint] |
| | | 4702 | | [HttpGet("exceptions/client-prices")] |
| | | 4703 | | [HttpGet("/exceptions/client-prices")] |
| | | 4704 | | public async Task<IActionResult> GetClientPrices(CancellationToken ct) |
| | | 4705 | | { |
| | 0 | 4706 | | var items = await _exceptionService.GetClientPricesAsync(ct); |
| | 0 | 4707 | | return Ok(items); |
| | 0 | 4708 | | } |
| | | 4709 | | |
| | | 4710 | | [ServiceEndpoint] |
| | | 4711 | | [HttpPost("exceptions/client-prices")] |
| | | 4712 | | [HttpPost("/exceptions/client-prices")] |
| | | 4713 | | [IgnoreAntiforgeryToken] |
| | | 4714 | | public async Task<IActionResult> UpsertClientPrice([FromBody] BitrixStockExceptionClientPrice entity, CancellationTo |
| | | 4715 | | { |
| | 0 | 4716 | | entity.Id = 0; |
| | 0 | 4717 | | entity.CreatedBy = User.Identity?.Name; |
| | 0 | 4718 | | var result = await _exceptionService.UpsertClientPriceAsync(entity, ct); |
| | 0 | 4719 | | return Ok(result); |
| | 0 | 4720 | | } |
| | | 4721 | | |
| | | 4722 | | [ServiceEndpoint] |
| | | 4723 | | [HttpPut("exceptions/client-prices/{id:int}")] |
| | | 4724 | | [HttpPut("/exceptions/client-prices/{id:int}")] |
| | | 4725 | | [IgnoreAntiforgeryToken] |
| | | 4726 | | public async Task<IActionResult> UpdateClientPrice(int id, [FromBody] BitrixStockExceptionClientPrice entity, Cancel |
| | | 4727 | | { |
| | 0 | 4728 | | entity.Id = id; |
| | 0 | 4729 | | entity.CreatedBy = User.Identity?.Name; |
| | 0 | 4730 | | var result = await _exceptionService.UpsertClientPriceAsync(entity, ct); |
| | 0 | 4731 | | return Ok(result); |
| | 0 | 4732 | | } |
| | | 4733 | | |
| | | 4734 | | [ServiceEndpoint] |
| | | 4735 | | [HttpDelete("exceptions/client-prices/{id:int}")] |
| | | 4736 | | [HttpDelete("/exceptions/client-prices/{id:int}")] |
| | | 4737 | | [IgnoreAntiforgeryToken] |
| | | 4738 | | public async Task<IActionResult> DeleteClientPrice(int id, CancellationToken ct) |
| | | 4739 | | { |
| | 0 | 4740 | | await _exceptionService.DeleteClientPriceAsync(id, ct); |
| | 0 | 4741 | | return Ok(new { deleted = true }); |
| | 0 | 4742 | | } |
| | | 4743 | | |
| | | 4744 | | [ServiceEndpoint] |
| | | 4745 | | [HttpGet("exceptions/stock-rules")] |
| | | 4746 | | [HttpGet("/exceptions/stock-rules")] |
| | | 4747 | | public async Task<IActionResult> GetStockRules(CancellationToken ct) |
| | | 4748 | | { |
| | 0 | 4749 | | var items = await _exceptionService.GetStockRulesAsync(ct); |
| | 0 | 4750 | | return Ok(items); |
| | 0 | 4751 | | } |
| | | 4752 | | |
| | | 4753 | | [ServiceEndpoint] |
| | | 4754 | | [HttpPost("exceptions/stock-rules")] |
| | | 4755 | | [HttpPost("/exceptions/stock-rules")] |
| | | 4756 | | [IgnoreAntiforgeryToken] |
| | | 4757 | | public async Task<IActionResult> UpsertStockRule([FromBody] BitrixStockExceptionStockRule entity, CancellationToken |
| | | 4758 | | { |
| | 0 | 4759 | | entity.Id = 0; |
| | 0 | 4760 | | entity.CreatedBy = User.Identity?.Name; |
| | 0 | 4761 | | var result = await _exceptionService.UpsertStockRuleAsync(entity, ct); |
| | 0 | 4762 | | return Ok(result); |
| | 0 | 4763 | | } |
| | | 4764 | | |
| | | 4765 | | [ServiceEndpoint] |
| | | 4766 | | [HttpPut("exceptions/stock-rules/{id:int}")] |
| | | 4767 | | [HttpPut("/exceptions/stock-rules/{id:int}")] |
| | | 4768 | | [IgnoreAntiforgeryToken] |
| | | 4769 | | public async Task<IActionResult> UpdateStockRule(int id, [FromBody] BitrixStockExceptionStockRule entity, Cancellati |
| | | 4770 | | { |
| | 0 | 4771 | | entity.Id = id; |
| | 0 | 4772 | | entity.CreatedBy = User.Identity?.Name; |
| | 0 | 4773 | | var result = await _exceptionService.UpsertStockRuleAsync(entity, ct); |
| | 0 | 4774 | | return Ok(result); |
| | 0 | 4775 | | } |
| | | 4776 | | |
| | | 4777 | | [ServiceEndpoint] |
| | | 4778 | | [HttpDelete("exceptions/stock-rules/{id:int}")] |
| | | 4779 | | [HttpDelete("/exceptions/stock-rules/{id:int}")] |
| | | 4780 | | [IgnoreAntiforgeryToken] |
| | | 4781 | | public async Task<IActionResult> DeleteStockRule(int id, CancellationToken ct) |
| | | 4782 | | { |
| | 0 | 4783 | | await _exceptionService.DeleteStockRuleAsync(id, ct); |
| | 0 | 4784 | | return Ok(new { deleted = true }); |
| | 0 | 4785 | | } |
| | | 4786 | | |
| | | 4787 | | private static string FormatRecommendationError(Exception ex) |
| | | 4788 | | { |
| | 0 | 4789 | | var rawMessage = ex.Message ?? string.Empty; |
| | | 4790 | | |
| | 0 | 4791 | | if (rawMessage.Contains("no access to this method", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 4792 | | return "Для этого кабинета Ozon недоступен расчет детализированной цены. Рекомендация не рассчитана."; |
| | | 4793 | | |
| | 0 | 4794 | | if (rawMessage.Contains("Forbidden", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 4795 | | return "Ozon отклонил запрос на расчет рекомендации для этого кабинета."; |
| | | 4796 | | |
| | 0 | 4797 | | if (rawMessage.Contains("timed out", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 4798 | | rawMessage.Contains("timeout", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 4799 | | return "Ozon не ответил вовремя. Рекомендация не рассчитана."; |
| | | 4800 | | |
| | 0 | 4801 | | return "Не удалось рассчитать рекомендацию цены для этого кабинета Ozon."; |
| | | 4802 | | } |
| | | 4803 | | |
| | | 4804 | | private static bool HasOzonCredentials(OzonClient client) => |
| | 0 | 4805 | | !string.IsNullOrWhiteSpace(client.DecryptClientId) && !string.IsNullOrWhiteSpace(client.DecryptApiKey); |
| | | 4806 | | |
| | | 4807 | | private static bool TryGetOzonCredentials(OzonClient client, out string clientId, out string apiKey) |
| | | 4808 | | { |
| | 0 | 4809 | | clientId = client.DecryptClientId ?? string.Empty; |
| | 0 | 4810 | | apiKey = client.DecryptApiKey ?? string.Empty; |
| | 0 | 4811 | | return !string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(apiKey); |
| | | 4812 | | } |
| | | 4813 | | |
| | | 4814 | | private OzonApiDataManager CreateOzonApiDataManager(string clientId, string apiKey) => |
| | 0 | 4815 | | new(_httpClientFactory, _emailService, clientId, apiKey); |
| | | 4816 | | |
| | | 4817 | | private bool TryConfigureOzonApiClient(OzonClient client) |
| | | 4818 | | { |
| | 0 | 4819 | | if (!TryGetOzonCredentials(client, out var clientId, out var apiKey)) |
| | 0 | 4820 | | return false; |
| | | 4821 | | |
| | 0 | 4822 | | _ozonApiDataManager.SetClient(clientId, apiKey); |
| | 0 | 4823 | | return true; |
| | | 4824 | | } |
| | | 4825 | | |
| | | 4826 | | } |