| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | using Microsoft.AspNetCore.Authorization; |
| | | 3 | | using Microsoft.AspNetCore.Mvc; |
| | | 4 | | using Microsoft.AspNetCore.Mvc.Rendering; |
| | | 5 | | using Microsoft.EntityFrameworkCore; |
| | | 6 | | using Microsoft.Extensions.Caching.Memory; |
| | | 7 | | using Newtonsoft.Json; |
| | | 8 | | using OfficeOpenXml; |
| | | 9 | | using OzonDomains; |
| | | 10 | | using OzonDomains.Models; |
| | | 11 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.Percentage; |
| | | 12 | | using OzonOrdersWeb.ViewModels.OrderViewModels; |
| | | 13 | | using OzonRepositories.Context; |
| | | 14 | | using PartsInfo.HttpUtils; |
| | | 15 | | using Services.ApiServices.OzonApi; |
| | | 16 | | using Services.ApiServices.OzonApi.Filters; |
| | | 17 | | using Services.CacheServices.Cache.OzonOrdersCache; |
| | | 18 | | using Services.CacheServices.Cache.UserCacheService; |
| | | 19 | | using Services.DataServices; |
| | | 20 | | using Services.FiltersServices.SortModels; |
| | | 21 | | using Services.ParserServices.FileProcessing; |
| | | 22 | | using Services.PriceСlculationServices; |
| | | 23 | | using Services.SignalRServices; |
| | | 24 | | using Services.TransactionUtilsServices; |
| | | 25 | | using OzonOrdersWeb.TransactionServices; |
| | | 26 | | using Services.CacheServices.Cache; |
| | | 27 | | using CsvHelper; |
| | | 28 | | using OzonOrdersWeb.Areas.Studio2.Controllers.Base; |
| | | 29 | | |
| | | 30 | | namespace OzonOrdersWeb.Areas.Studio2.Controllers; |
| | | 31 | | |
| | | 32 | | [Authorize(Roles = "User,Admin")] |
| | | 33 | | [Area("Studio2")] |
| | | 34 | | public class PercentageController : Controller |
| | | 35 | | { |
| | | 36 | | private const string PreferredDeliveryDateColumn = "Фактическая дата передачи в доставку"; |
| | | 37 | | private const string PreferredReturnsStatusDateColumn = "Дата статуса"; |
| | | 38 | | |
| | | 39 | | private sealed class CachedApiReport |
| | | 40 | | { |
| | 0 | 41 | | public required byte[] Bytes { get; init; } |
| | 0 | 42 | | public required string FileName { get; init; } |
| | 0 | 43 | | public string ContentType { get; init; } = "text/csv"; |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | private static bool IsSellerReturns(UploadFileViewModel model) => |
| | 0 | 47 | | model.UploadDataKind == PercentageUploadDataKind.SellerReturns; |
| | | 48 | | |
| | | 49 | | private static string GetPreferredDateColumnName(UploadFileViewModel model) => |
| | 0 | 50 | | IsSellerReturns(model) ? PreferredReturnsStatusDateColumn : PreferredDeliveryDateColumn; |
| | | 51 | | |
| | | 52 | | private static bool IsTransactionTypeAllowedForUploadKind( |
| | | 53 | | TransactionType transactionType, |
| | | 54 | | PercentageUploadDataKind uploadDataKind) |
| | | 55 | | { |
| | 0 | 56 | | if (uploadDataKind == PercentageUploadDataKind.SellerReturns) |
| | 0 | 57 | | return transactionType == TransactionType.CustomerReturn; |
| | | 58 | | |
| | 0 | 59 | | return transactionType != TransactionType.CustomerReturn; |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | private static FormFile CreateFormFileFromCachedReport(CachedApiReport cachedReport) |
| | | 63 | | { |
| | 0 | 64 | | var cachedStream = new MemoryStream(cachedReport.Bytes); |
| | 0 | 65 | | var contentType = string.IsNullOrWhiteSpace(cachedReport.ContentType) |
| | 0 | 66 | | ? "application/octet-stream" |
| | 0 | 67 | | : cachedReport.ContentType; |
| | 0 | 68 | | return new FormFile(cachedStream, 0, cachedReport.Bytes.Length, "excelFile", cachedReport.FileName) |
| | 0 | 69 | | { |
| | 0 | 70 | | Headers = new HeaderDictionary(), |
| | 0 | 71 | | ContentType = contentType |
| | 0 | 72 | | }; |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | private readonly ApplicationDbContext _context; |
| | | 76 | | private readonly AppStatusDataServices _appStatusServices; |
| | | 77 | | private readonly OrdersDataServices _orderServices; |
| | | 78 | | private readonly TransactionManager _transaction; |
| | | 79 | | private readonly TransactionCache _transactionCache; |
| | | 80 | | |
| | | 81 | | private readonly SupplierDataServices _supplierDataServices; |
| | | 82 | | private readonly IUserCacheService _userCacheService; |
| | | 83 | | private readonly CurrencyRateFetcher _currencyRateFetcher; |
| | | 84 | | private readonly CacheUpdater<Order> _cacheUpdater; |
| | | 85 | | private readonly OrderCache _cache; |
| | | 86 | | private readonly ProxyHttpClientService _proxyHttpClientService; |
| | | 87 | | private readonly IFileProcessingService _fileProcessingService; |
| | | 88 | | private readonly OzonClientServices _ozonClientServices; |
| | | 89 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 90 | | private readonly SlqStudio.Application.Services.EmailService.IEmailService? _emailService; |
| | | 91 | | private readonly IMemoryCache _memoryCache; |
| | | 92 | | |
| | 0 | 93 | | public PercentageController(ApplicationDbContext context, |
| | 0 | 94 | | AppStatusDataServices appStatusServices, |
| | 0 | 95 | | OrdersDataServices orderServices, |
| | 0 | 96 | | TransactionManager transaction, |
| | 0 | 97 | | TransactionCache transactionCache, |
| | 0 | 98 | | SupplierDataServices supplierDataServices, |
| | 0 | 99 | | IUserCacheService userCacheService, |
| | 0 | 100 | | CurrencyRateFetcher currencyRateFetcher, |
| | 0 | 101 | | CacheUpdater<Order> cacheUpdater, |
| | 0 | 102 | | OrderCache cache, |
| | 0 | 103 | | ProxyHttpClientService proxyHttpClientService, |
| | 0 | 104 | | IFileProcessingService fileProcessingService, |
| | 0 | 105 | | OzonClientServices ozonClientServices, |
| | 0 | 106 | | IHttpClientFactory httpClientFactory, |
| | 0 | 107 | | SlqStudio.Application.Services.EmailService.IEmailService? emailService, |
| | 0 | 108 | | IMemoryCache memoryCache) |
| | | 109 | | { |
| | 0 | 110 | | _context = context; |
| | 0 | 111 | | _appStatusServices = appStatusServices; |
| | 0 | 112 | | _orderServices = orderServices; |
| | 0 | 113 | | _transaction = transaction; |
| | 0 | 114 | | _transactionCache = transactionCache; |
| | | 115 | | |
| | 0 | 116 | | _supplierDataServices = supplierDataServices; |
| | 0 | 117 | | _userCacheService = userCacheService; |
| | 0 | 118 | | _currencyRateFetcher = currencyRateFetcher; |
| | 0 | 119 | | _cacheUpdater = cacheUpdater; |
| | 0 | 120 | | _cache = cache; |
| | 0 | 121 | | _proxyHttpClientService = proxyHttpClientService; |
| | 0 | 122 | | _fileProcessingService = fileProcessingService; |
| | 0 | 123 | | _ozonClientServices = ozonClientServices; |
| | 0 | 124 | | _httpClientFactory = httpClientFactory; |
| | 0 | 125 | | _emailService = emailService; |
| | 0 | 126 | | _memoryCache = memoryCache; |
| | 0 | 127 | | } |
| | | 128 | | |
| | | 129 | | public async Task<IActionResult> UploadFile() |
| | | 130 | | { |
| | 0 | 131 | | return View(await BuildUploadFileViewModelAsync()); |
| | 0 | 132 | | } |
| | | 133 | | |
| | | 134 | | [HttpPost] |
| | | 135 | | [ValidateAntiForgeryToken] |
| | | 136 | | public async Task<IActionResult> UploadFile(UploadFileViewModel model) |
| | | 137 | | { |
| | 0 | 138 | | if (string.Equals(model.UploadSource, "file", StringComparison.OrdinalIgnoreCase)) |
| | | 139 | | { |
| | 0 | 140 | | if (model.ExcelFile == null || model.ExcelFile.Length == 0) |
| | | 141 | | { |
| | 0 | 142 | | ModelState.AddModelError(nameof(model.ExcelFile), "Выберите файл для загрузки."); |
| | | 143 | | } |
| | | 144 | | |
| | 0 | 145 | | if (string.IsNullOrWhiteSpace(model.SelectedDateColumn)) |
| | | 146 | | { |
| | 0 | 147 | | ModelState.AddModelError(nameof(model.SelectedDateColumn), "Выберите столбец даты в предпросмотре."); |
| | | 148 | | } |
| | | 149 | | |
| | 0 | 150 | | if (model.SelectedDateValues.Count == 0) |
| | | 151 | | { |
| | 0 | 152 | | ModelState.AddModelError(nameof(model.SelectedDateValues), "Выберите хотя бы одну дату, которую нужно об |
| | | 153 | | } |
| | | 154 | | } |
| | | 155 | | |
| | 0 | 156 | | if (string.Equals(model.UploadSource, "ozon-api", StringComparison.OrdinalIgnoreCase) |
| | 0 | 157 | | && !string.IsNullOrWhiteSpace(model.ApiReportKey)) |
| | | 158 | | { |
| | | 159 | | // API-отчёт уже загружен — требуем выбор дат |
| | 0 | 160 | | if (string.IsNullOrWhiteSpace(model.SelectedDateColumn)) |
| | | 161 | | { |
| | 0 | 162 | | ModelState.AddModelError(nameof(model.SelectedDateColumn), "Выберите столбец даты в предпросмотре."); |
| | | 163 | | } |
| | | 164 | | |
| | 0 | 165 | | if (model.SelectedDateValues.Count == 0) |
| | | 166 | | { |
| | 0 | 167 | | ModelState.AddModelError(nameof(model.SelectedDateValues), "Выберите хотя бы одну дату, которую нужно об |
| | | 168 | | } |
| | | 169 | | } |
| | | 170 | | |
| | 0 | 171 | | if (!ModelState.IsValid) |
| | | 172 | | { |
| | 0 | 173 | | return View(await PopulateUploadFileViewModelAsync(model)); |
| | | 174 | | } |
| | | 175 | | |
| | | 176 | | try |
| | | 177 | | { |
| | 0 | 178 | | if (IsSellerReturns(model)) |
| | | 179 | | { |
| | 0 | 180 | | var shipments = await ResolveShipmentNumbersAsync(model); |
| | 0 | 181 | | if (!shipments.Any()) |
| | | 182 | | { |
| | 0 | 183 | | TempData["Error"] = "Файл не содержит подходящих данных"; |
| | 0 | 184 | | return RedirectToAction("UploadFile"); |
| | | 185 | | } |
| | | 186 | | |
| | 0 | 187 | | var (shipmentOrderIds, _) = model.SelectedOzonClientId.HasValue |
| | 0 | 188 | | ? await _orderServices.GetOrderIdsAndMatchedShipmentsByShipmentListAsync( |
| | 0 | 189 | | shipments, |
| | 0 | 190 | | model.SelectedOzonClientId.Value) |
| | 0 | 191 | | : await _orderServices.GetOrderIdsAndMatchedShipmentsByShipmentListAsync(shipments); |
| | | 192 | | |
| | 0 | 193 | | if (!shipmentOrderIds.Any()) |
| | | 194 | | { |
| | 0 | 195 | | TempData["Success"] = $"Обработано {shipments.Count} записей, заказов не найдено."; |
| | 0 | 196 | | return RedirectToAction("UploadFile"); |
| | | 197 | | } |
| | | 198 | | |
| | 0 | 199 | | var idsCsv = string.Join(",", shipmentOrderIds); |
| | | 200 | | |
| | 0 | 201 | | return RedirectToAction("SelectTransactionType", new |
| | 0 | 202 | | { |
| | 0 | 203 | | ids = idsCsv, |
| | 0 | 204 | | processedCount = shipments.Count, |
| | 0 | 205 | | matchedCount = shipmentOrderIds.Count, |
| | 0 | 206 | | uploadDataKind = model.UploadDataKind |
| | 0 | 207 | | }); |
| | | 208 | | } |
| | | 209 | | |
| | | 210 | | { |
| | 0 | 211 | | var processedOrders = await ResolveProcessedOrdersAsync(model); |
| | | 212 | | |
| | 0 | 213 | | if (!processedOrders.Any()) |
| | | 214 | | { |
| | 0 | 215 | | TempData["Error"] = "Файл не содержит подходящих данных"; |
| | 0 | 216 | | return RedirectToAction("UploadFile"); |
| | | 217 | | } |
| | | 218 | | |
| | | 219 | | List<int> ids; |
| | | 220 | | try |
| | | 221 | | { |
| | 0 | 222 | | ids = await _orderServices.GetOrderIdsByNumbersAndArticles(processedOrders); |
| | 0 | 223 | | } |
| | 0 | 224 | | catch (Exception ex) |
| | | 225 | | { |
| | 0 | 226 | | TempData["Error"] = BuildUserFriendlyUploadError(ex, "Не удалось сопоставить строки файла с заказами |
| | 0 | 227 | | return RedirectToAction("UploadFile"); |
| | | 228 | | } |
| | | 229 | | |
| | 0 | 230 | | if (!ids.Any()) |
| | | 231 | | { |
| | 0 | 232 | | TempData["Success"] = $"Обработано {processedOrders.Count} записей, заказов не найдено."; |
| | 0 | 233 | | return RedirectToAction("UploadFile"); |
| | | 234 | | } |
| | | 235 | | |
| | 0 | 236 | | var idsCsv = string.Join(",", ids); |
| | | 237 | | |
| | 0 | 238 | | return RedirectToAction("SelectTransactionType", new |
| | 0 | 239 | | { |
| | 0 | 240 | | ids = idsCsv, |
| | 0 | 241 | | processedCount = processedOrders.Count, |
| | 0 | 242 | | matchedCount = ids.Count, |
| | 0 | 243 | | uploadDataKind = model.UploadDataKind |
| | 0 | 244 | | }); |
| | | 245 | | } |
| | | 246 | | } |
| | 0 | 247 | | catch (Exception ex) |
| | | 248 | | { |
| | 0 | 249 | | TempData["Error"] = BuildUserFriendlyUploadError(ex, "Не удалось обработать файл."); |
| | 0 | 250 | | return RedirectToAction("UploadFile"); |
| | | 251 | | } |
| | 0 | 252 | | } |
| | | 253 | | |
| | | 254 | | [HttpPost] |
| | | 255 | | [ValidateAntiForgeryToken] |
| | | 256 | | public async Task<IActionResult> PreviewUploadFile(UploadFileViewModel model) |
| | | 257 | | { |
| | 0 | 258 | | if (model.ExcelFile == null || model.ExcelFile.Length == 0) |
| | | 259 | | { |
| | 0 | 260 | | return BadRequest(new { error = "Выберите файл для предпросмотра." }); |
| | | 261 | | } |
| | | 262 | | |
| | | 263 | | try |
| | | 264 | | { |
| | | 265 | | FilePreviewResult preview; |
| | 0 | 266 | | var preferredDateColumnName = GetPreferredDateColumnName(model); |
| | 0 | 267 | | if (IsSellerReturns(model)) |
| | | 268 | | { |
| | 0 | 269 | | preview = await _fileProcessingService.BuildPreviewShipmentOnlyAsync(model.ExcelFile, preferredDateColum |
| | | 270 | | } |
| | | 271 | | else |
| | | 272 | | { |
| | 0 | 273 | | preview = await _fileProcessingService.BuildPreviewAsync(model.ExcelFile, preferredDateColumnName); |
| | | 274 | | } |
| | | 275 | | |
| | 0 | 276 | | if (!preview.HasRequiredColumns) |
| | | 277 | | { |
| | 0 | 278 | | return BadRequest(new { error = preview.ErrorMessage ?? "Не удалось разобрать файл." }); |
| | | 279 | | } |
| | | 280 | | |
| | 0 | 281 | | if (preview.DateColumns.Count == 0) |
| | | 282 | | { |
| | 0 | 283 | | return BadRequest(new { error = preview.ErrorMessage ?? "Не найден столбец с датами." }); |
| | | 284 | | } |
| | | 285 | | |
| | 0 | 286 | | var warning = preview.SelectedDateColumn == null |
| | 0 | 287 | | ? null |
| | 0 | 288 | | : string.Equals(preview.SelectedDateColumn, preferredDateColumnName, StringComparison.OrdinalIgnoreCase) |
| | 0 | 289 | | ? null |
| | 0 | 290 | | : $"Столбец '{preferredDateColumnName}' не найден. Используйте другой столбец с датами."; |
| | | 291 | | |
| | 0 | 292 | | return Json(new |
| | 0 | 293 | | { |
| | 0 | 294 | | preview.OrderNumberColumn, |
| | 0 | 295 | | preview.ArticleColumn, |
| | 0 | 296 | | preview.SelectedDateColumn, |
| | 0 | 297 | | preview.TotalRows, |
| | 0 | 298 | | preview.RowsWithRequiredColumns, |
| | 0 | 299 | | preview.DateColumns, |
| | 0 | 300 | | preview.PreviewHeaders, |
| | 0 | 301 | | preview.PreviewRows, |
| | 0 | 302 | | Warning = warning |
| | 0 | 303 | | }); |
| | | 304 | | } |
| | 0 | 305 | | catch (Exception ex) |
| | | 306 | | { |
| | 0 | 307 | | return BadRequest(new { error = BuildUserFriendlyUploadError(ex, "Не удалось построить предпросмотр файла.") |
| | | 308 | | } |
| | 0 | 309 | | } |
| | | 310 | | |
| | | 311 | | [HttpPost] |
| | | 312 | | [ValidateAntiForgeryToken] |
| | | 313 | | public async Task<IActionResult> FetchApiReportPreview(UploadFileViewModel model) |
| | | 314 | | { |
| | 0 | 315 | | if (model.SelectedOzonClientId is null) |
| | 0 | 316 | | return BadRequest(new { error = "Не выбран Ozon-клиент." }); |
| | | 317 | | |
| | 0 | 318 | | if (!model.ProcessedAtFrom.HasValue || !model.ProcessedAtTo.HasValue) |
| | 0 | 319 | | return BadRequest(new { error = "Укажите период формирования отчёта." }); |
| | | 320 | | |
| | 0 | 321 | | if (model.ProcessedAtFrom.Value > model.ProcessedAtTo.Value) |
| | 0 | 322 | | return BadRequest(new { error = "Дата начала периода не может быть больше даты окончания." }); |
| | | 323 | | |
| | 0 | 324 | | if (model.ProcessedAtTo.Value - model.ProcessedAtFrom.Value > TimeSpan.FromDays(92)) |
| | 0 | 325 | | return BadRequest(new { error = "Период отчёта не должен превышать 3 месяца." }); |
| | | 326 | | |
| | | 327 | | try |
| | | 328 | | { |
| | 0 | 329 | | var ozonClient = await _ozonClientServices.GetOzonClientAsync(model.SelectedOzonClientId.Value); |
| | 0 | 330 | | if (ozonClient == null || string.IsNullOrWhiteSpace(ozonClient.DecryptClientId) || |
| | 0 | 331 | | string.IsNullOrWhiteSpace(ozonClient.DecryptApiKey)) |
| | 0 | 332 | | return BadRequest(new { error = "Не удалось загрузить настройки выбранного Ozon-клиента." }); |
| | | 333 | | |
| | 0 | 334 | | var apiManager = new OzonApiDataManager( |
| | 0 | 335 | | _httpClientFactory, _emailService, |
| | 0 | 336 | | ozonClient.DecryptClientId, ozonClient.DecryptApiKey); |
| | | 337 | | |
| | | 338 | | string reportCode; |
| | | 339 | | byte[] fileBytes; |
| | | 340 | | |
| | 0 | 341 | | if (IsSellerReturns(model)) |
| | | 342 | | { |
| | 0 | 343 | | reportCode = await apiManager.CreateSellerReturnsReportAsync( |
| | 0 | 344 | | DateTime.SpecifyKind(model.ProcessedAtFrom!.Value, DateTimeKind.Utc), |
| | 0 | 345 | | DateTime.SpecifyKind(model.ProcessedAtTo!.Value, DateTimeKind.Utc)); |
| | 0 | 346 | | var returnsFileUrl = await apiManager.WaitForReportReady(reportCode); |
| | 0 | 347 | | fileBytes = await DownloadReportBytesAsync(returnsFileUrl); |
| | | 348 | | } |
| | | 349 | | else |
| | | 350 | | { |
| | 0 | 351 | | var reportRequest = new PostingsReportRequest |
| | 0 | 352 | | { |
| | 0 | 353 | | Filter = new PostingsReportRequestFilter |
| | 0 | 354 | | { |
| | 0 | 355 | | ProcessedAtFrom = DateTime.SpecifyKind(model.ProcessedAtFrom!.Value, DateTimeKind.Utc), |
| | 0 | 356 | | ProcessedAtTo = DateTime.SpecifyKind(model.ProcessedAtTo!.Value, DateTimeKind.Utc), |
| | 0 | 357 | | DeliverySchema = ["fbs"], |
| | 0 | 358 | | IsExpress = model.IsExpress, |
| | 0 | 359 | | Sku = ParseStringArray(model.SkuList), |
| | 0 | 360 | | CancelReasonId = [], |
| | 0 | 361 | | OfferId = model.OfferId?.Trim() ?? string.Empty, |
| | 0 | 362 | | StatusAlias = ParseStringArray(model.StatusAliasList), |
| | 0 | 363 | | Statuses = ParseStringArray(model.StatusesList), |
| | 0 | 364 | | Title = model.Title?.Trim() ?? string.Empty, |
| | 0 | 365 | | WarehouseId = ParseLongArray(model.WarehouseIds), |
| | 0 | 366 | | DeliveryMethodId = ParseLongArray(model.DeliveryMethodIds) |
| | 0 | 367 | | }, |
| | 0 | 368 | | Language = "DEFAULT", |
| | 0 | 369 | | With = new PostingsReportRequestWith |
| | 0 | 370 | | { |
| | 0 | 371 | | AdditionalData = model.IncludeAdditionalData, |
| | 0 | 372 | | AnalyticsData = model.IncludeAnalyticsData, |
| | 0 | 373 | | CustomerData = model.IncludeCustomerData, |
| | 0 | 374 | | JewelryCodes = model.IncludeJewelryCodes |
| | 0 | 375 | | } |
| | 0 | 376 | | }; |
| | | 377 | | |
| | 0 | 378 | | reportCode = await apiManager.GetReportCode(reportRequest); |
| | 0 | 379 | | var fileUrl = await apiManager.WaitForReportReady(reportCode); |
| | 0 | 380 | | fileBytes = await DownloadReportBytesAsync(fileUrl); |
| | | 381 | | } |
| | | 382 | | |
| | 0 | 383 | | var reportFileName = BuildReportFileName(reportCode); |
| | 0 | 384 | | var contentType = GuessContentTypeForReportFile(reportFileName); |
| | | 385 | | |
| | 0 | 386 | | await using var stream = new MemoryStream(fileBytes); |
| | 0 | 387 | | var reportFile = new FormFile(stream, 0, fileBytes.Length, "excelFile", reportFileName) |
| | 0 | 388 | | { |
| | 0 | 389 | | Headers = new HeaderDictionary(), |
| | 0 | 390 | | ContentType = contentType |
| | 0 | 391 | | }; |
| | | 392 | | |
| | 0 | 393 | | var preferredDateColumnName = GetPreferredDateColumnName(model); |
| | 0 | 394 | | var preview = IsSellerReturns(model) |
| | 0 | 395 | | ? await _fileProcessingService.BuildPreviewShipmentOnlyAsync(reportFile, preferredDateColumnName) |
| | 0 | 396 | | : await _fileProcessingService.BuildPreviewAsync(reportFile, preferredDateColumnName); |
| | | 397 | | |
| | 0 | 398 | | if (!preview.HasRequiredColumns) |
| | 0 | 399 | | return BadRequest(new { error = preview.ErrorMessage ?? "Не удалось разобрать файл отчёта." }); |
| | | 400 | | |
| | 0 | 401 | | var reportKey = Guid.NewGuid().ToString("N"); |
| | 0 | 402 | | _memoryCache.Set($"api_report_{reportKey}", new CachedApiReport |
| | 0 | 403 | | { |
| | 0 | 404 | | Bytes = fileBytes, |
| | 0 | 405 | | FileName = reportFile.FileName, |
| | 0 | 406 | | ContentType = contentType |
| | 0 | 407 | | }, TimeSpan.FromMinutes(15)); |
| | | 408 | | |
| | 0 | 409 | | var warning = preview.SelectedDateColumn == null |
| | 0 | 410 | | ? null |
| | 0 | 411 | | : string.Equals(preview.SelectedDateColumn, preferredDateColumnName, StringComparison.OrdinalIgnoreCase) |
| | 0 | 412 | | ? null |
| | 0 | 413 | | : $"Столбец '{preferredDateColumnName}' не найден. Используйте другой столбец с датами."; |
| | | 414 | | |
| | 0 | 415 | | return Json(new |
| | 0 | 416 | | { |
| | 0 | 417 | | reportKey, |
| | 0 | 418 | | fileName = reportFile.FileName, |
| | 0 | 419 | | preview.OrderNumberColumn, |
| | 0 | 420 | | preview.ArticleColumn, |
| | 0 | 421 | | preview.SelectedDateColumn, |
| | 0 | 422 | | preview.TotalRows, |
| | 0 | 423 | | preview.RowsWithRequiredColumns, |
| | 0 | 424 | | preview.DateColumns, |
| | 0 | 425 | | preview.PreviewHeaders, |
| | 0 | 426 | | preview.PreviewRows, |
| | 0 | 427 | | Warning = warning |
| | 0 | 428 | | }); |
| | 0 | 429 | | } |
| | 0 | 430 | | catch (Exception ex) |
| | | 431 | | { |
| | 0 | 432 | | return BadRequest(new { error = BuildUserFriendlyUploadError(ex, "Не удалось получить отчёт из Ozon API.") } |
| | | 433 | | } |
| | 0 | 434 | | } |
| | | 435 | | |
| | | 436 | | [HttpGet] |
| | | 437 | | public IActionResult DownloadApiReport(string reportKey) |
| | | 438 | | { |
| | 0 | 439 | | if (string.IsNullOrWhiteSpace(reportKey)) |
| | | 440 | | { |
| | 0 | 441 | | return BadRequest("Не указан ключ отчёта."); |
| | | 442 | | } |
| | | 443 | | |
| | | 444 | | try |
| | | 445 | | { |
| | 0 | 446 | | if (!_memoryCache.TryGetValue($"api_report_{reportKey}", out CachedApiReport? cachedReport) || cachedReport |
| | | 447 | | { |
| | 0 | 448 | | return NotFound("Сессия загрузки отчёта истекла. Загрузите отчёт заново."); |
| | | 449 | | } |
| | | 450 | | |
| | 0 | 451 | | return File(cachedReport.Bytes, cachedReport.ContentType, cachedReport.FileName); |
| | | 452 | | } |
| | 0 | 453 | | catch (Exception ex) |
| | | 454 | | { |
| | 0 | 455 | | return BadRequest(BuildUserFriendlyUploadError(ex, "Не удалось скачать сохранённый CSV-файл.")); |
| | | 456 | | } |
| | 0 | 457 | | } |
| | | 458 | | |
| | | 459 | | [HttpPost] |
| | | 460 | | [ValidateAntiForgeryToken] |
| | | 461 | | public async Task<IActionResult> AnalyzeUploadFile(UploadFileViewModel model) { |
| | 0 | 462 | | var isFileUpload = string.Equals(model.UploadSource, "file", StringComparison.OrdinalIgnoreCase); |
| | 0 | 463 | | var isApiUpload = string.Equals(model.UploadSource, "ozon-api", StringComparison.OrdinalIgnoreCase) |
| | 0 | 464 | | && !string.IsNullOrWhiteSpace(model.ApiReportKey); |
| | | 465 | | |
| | 0 | 466 | | if (!isFileUpload && !isApiUpload) |
| | | 467 | | { |
| | 0 | 468 | | return BadRequest(new { error = "Сначала загрузите файл или отчёт через API." }); |
| | | 469 | | } |
| | | 470 | | |
| | 0 | 471 | | if (isFileUpload && (model.ExcelFile == null || model.ExcelFile.Length == 0)) |
| | | 472 | | { |
| | 0 | 473 | | return BadRequest(new { error = "Выберите файл для анализа." }); |
| | | 474 | | } |
| | | 475 | | |
| | 0 | 476 | | if (string.IsNullOrWhiteSpace(model.SelectedDateColumn)) |
| | | 477 | | { |
| | 0 | 478 | | return BadRequest(new { error = "Выберите столбец даты в предпросмотре." }); |
| | | 479 | | } |
| | | 480 | | |
| | 0 | 481 | | if (model.SelectedDateValues.Count == 0) |
| | | 482 | | { |
| | 0 | 483 | | return BadRequest(new { error = "Выберите хотя бы одну дату, которую нужно обработать." }); |
| | | 484 | | } |
| | | 485 | | |
| | | 486 | | try |
| | | 487 | | { |
| | 0 | 488 | | if (IsSellerReturns(model)) |
| | | 489 | | { |
| | 0 | 490 | | var shipments = await ResolveShipmentNumbersAsync(model); |
| | 0 | 491 | | var (returnMatchedOrderIds, matchedShipments) = model.SelectedOzonClientId.HasValue |
| | 0 | 492 | | ? await _orderServices.GetOrderIdsAndMatchedShipmentsByShipmentListAsync( |
| | 0 | 493 | | shipments, |
| | 0 | 494 | | model.SelectedOzonClientId.Value) |
| | 0 | 495 | | : await _orderServices.GetOrderIdsAndMatchedShipmentsByShipmentListAsync(shipments); |
| | | 496 | | |
| | | 497 | | var returnMatchedRowKeys = matchedShipments.Select(s => $"{s}|||").ToList(); |
| | | 498 | | |
| | 0 | 499 | | return Json(new |
| | 0 | 500 | | { |
| | 0 | 501 | | processedCount = shipments.Count, |
| | 0 | 502 | | matchedCount = returnMatchedOrderIds.Count, |
| | 0 | 503 | | hasMatches = returnMatchedOrderIds.Count > 0, |
| | 0 | 504 | | matchedRowKeys = returnMatchedRowKeys, |
| | 0 | 505 | | message = returnMatchedOrderIds.Count > 0 |
| | 0 | 506 | | ? $"Найдено заказов: {returnMatchedOrderIds.Count} из {shipments.Count} строк. Можно сразу запус |
| | 0 | 507 | | : $"Обработано {shipments.Count} строк, заказов не найдено." |
| | 0 | 508 | | }); |
| | | 509 | | } |
| | | 510 | | |
| | 0 | 511 | | var processedOrders = await ResolveProcessedOrdersAsync(model); |
| | 0 | 512 | | var ids = await _orderServices.GetOrderIdsByNumbersAndArticles(processedOrders); |
| | 0 | 513 | | var matchedPairs = await _orderServices.GetMatchedNumbersAndArticles(processedOrders); |
| | | 514 | | |
| | 0 | 515 | | var matchedRowKeys = matchedPairs |
| | | 516 | | .Select(pair => $"{pair.OrderNumber}|||{pair.Article}") |
| | 0 | 517 | | .ToList(); |
| | | 518 | | |
| | 0 | 519 | | return Json(new |
| | 0 | 520 | | { |
| | 0 | 521 | | processedCount = processedOrders.Count, |
| | 0 | 522 | | matchedCount = ids.Count, |
| | 0 | 523 | | hasMatches = ids.Count > 0, |
| | 0 | 524 | | matchedRowKeys, |
| | 0 | 525 | | message = ids.Count > 0 |
| | 0 | 526 | | ? $"Найдено заказов: {ids.Count} из {processedOrders.Count} строк. Можно сразу запускать обработку." |
| | 0 | 527 | | : $"Обработано {processedOrders.Count} строк, заказов не найдено." |
| | 0 | 528 | | }); |
| | | 529 | | } |
| | 0 | 530 | | catch (Exception ex) |
| | | 531 | | { |
| | 0 | 532 | | return BadRequest(new { error = BuildUserFriendlyUploadError(ex, "Не удалось выполнить предварительный анали |
| | | 533 | | } |
| | 0 | 534 | | } |
| | | 535 | | |
| | | 536 | | [HttpGet] |
| | | 537 | | public IActionResult SelectTransactionType(string ids, int processedCount, int matchedCount, PercentageUploadDataKin |
| | | 538 | | { |
| | 0 | 539 | | if (string.IsNullOrWhiteSpace(ids)) |
| | | 540 | | { |
| | 0 | 541 | | TempData["Error"] = "После загрузки не удалось определить заказы для обработки."; |
| | 0 | 542 | | return RedirectToAction("UploadFile"); |
| | | 543 | | } |
| | | 544 | | |
| | 0 | 545 | | return View(new SelectTransactionTypeViewModel |
| | 0 | 546 | | { |
| | 0 | 547 | | Ids = ids, |
| | 0 | 548 | | ProcessedCount = processedCount, |
| | 0 | 549 | | MatchedCount = matchedCount, |
| | 0 | 550 | | UploadDataKind = uploadDataKind, |
| | 0 | 551 | | AvailableTypes = |
| | 0 | 552 | | [ |
| | 0 | 553 | | TransactionType.Percentage, |
| | 0 | 554 | | TransactionType.ShippedToClient, |
| | 0 | 555 | | TransactionType.ShippedToCommissioner, |
| | 0 | 556 | | TransactionType.CustomerReturn |
| | 0 | 557 | | ] |
| | 0 | 558 | | }); |
| | | 559 | | } |
| | | 560 | | |
| | | 561 | | [HttpPost] |
| | | 562 | | public IActionResult SelectTransactionType(SelectTransactionTypeViewModel model) |
| | | 563 | | { |
| | 0 | 564 | | if (string.IsNullOrWhiteSpace(model.Ids)) |
| | | 565 | | { |
| | 0 | 566 | | TempData["Error"] = "Не удалось получить список заказов для обработки."; |
| | 0 | 567 | | return RedirectToAction("UploadFile"); |
| | | 568 | | } |
| | | 569 | | |
| | 0 | 570 | | if (!IsTransactionTypeAllowedForUploadKind(model.SelectedTransactionType, model.UploadDataKind)) |
| | | 571 | | { |
| | 0 | 572 | | TempData["Error"] = "Выбранный тип документа недоступен для текущего типа данных."; |
| | 0 | 573 | | return RedirectToAction("SelectTransactionType", new |
| | 0 | 574 | | { |
| | 0 | 575 | | ids = model.Ids, |
| | 0 | 576 | | processedCount = model.ProcessedCount, |
| | 0 | 577 | | matchedCount = model.MatchedCount, |
| | 0 | 578 | | uploadDataKind = model.UploadDataKind |
| | 0 | 579 | | }); |
| | | 580 | | } |
| | | 581 | | |
| | 0 | 582 | | switch (model.SelectedTransactionType) |
| | | 583 | | { |
| | | 584 | | case TransactionType.ShippedToClient: |
| | 0 | 585 | | return RedirectToAction( |
| | 0 | 586 | | "CreateShippedToClientTransactionFromFile", |
| | 0 | 587 | | "ShippedToClientTransaction", |
| | 0 | 588 | | new { area = "Studio2", ids = model.Ids, page = 1 } |
| | 0 | 589 | | ); |
| | | 590 | | |
| | | 591 | | case TransactionType.ShippedToCommissioner: |
| | 0 | 592 | | return Redirect($"/Studio2/ShippedToCommissionerTransaction/CreateShippedToCommissionerTransactionFromFi |
| | | 593 | | |
| | | 594 | | case TransactionType.Percentage: |
| | 0 | 595 | | return RedirectToAction("Percentage", new { ids = model.Ids }); |
| | | 596 | | |
| | | 597 | | case TransactionType.CustomerReturn: |
| | 0 | 598 | | return RedirectToAction( |
| | 0 | 599 | | "CreateCustomerReturnTransactionFromFile", |
| | 0 | 600 | | "CustomerReturnTransaction", |
| | 0 | 601 | | new { area = "Studio2", ids = model.Ids, page = 1 }); |
| | | 602 | | |
| | | 603 | | default: |
| | 0 | 604 | | TempData["Error"] = "Неизвестный тип документа"; |
| | 0 | 605 | | return RedirectToAction("SelectTransactionType", new |
| | 0 | 606 | | { |
| | 0 | 607 | | ids = model.Ids, |
| | 0 | 608 | | processedCount = model.ProcessedCount, |
| | 0 | 609 | | matchedCount = model.MatchedCount |
| | 0 | 610 | | }); |
| | | 611 | | } |
| | | 612 | | } |
| | | 613 | | |
| | | 614 | | private async Task<List<(string OrderNumber, string Article)>> ResolveProcessedOrdersAsync(UploadFileViewModel model |
| | | 615 | | { |
| | 0 | 616 | | if (string.Equals(model.UploadSource, "ozon-api", StringComparison.OrdinalIgnoreCase)) |
| | | 617 | | { |
| | 0 | 618 | | return await GetProcessedOrdersFromOzonReportAsync(model); |
| | | 619 | | } |
| | | 620 | | |
| | 0 | 621 | | if (model.ExcelFile == null || model.ExcelFile.Length == 0) |
| | 0 | 622 | | throw new Exception("Файл не выбран или пуст"); |
| | | 623 | | |
| | 0 | 624 | | return await _fileProcessingService.ProcessFileAsync(model.ExcelFile, model.SelectedDateColumn, model.SelectedDa |
| | 0 | 625 | | } |
| | | 626 | | |
| | | 627 | | private async Task<List<(string OrderNumber, string Article)>> GetProcessedOrdersFromOzonReportAsync( |
| | | 628 | | UploadFileViewModel model) |
| | | 629 | | { |
| | | 630 | | // Если отчёт уже загружен на предыдущем шаге — берём из кэша |
| | 0 | 631 | | if (!string.IsNullOrWhiteSpace(model.ApiReportKey)) |
| | | 632 | | { |
| | 0 | 633 | | var cacheKey = $"api_report_{model.ApiReportKey}"; |
| | 0 | 634 | | if (!_memoryCache.TryGetValue(cacheKey, out CachedApiReport? cachedReport) || cachedReport == null) |
| | 0 | 635 | | throw new Exception("Сессия загрузки отчёта истекла. Пожалуйста, загрузите отчёт заново."); |
| | | 636 | | |
| | 0 | 637 | | var cachedFile = CreateFormFileFromCachedReport(cachedReport); |
| | 0 | 638 | | return await _fileProcessingService.ProcessFileAsync(cachedFile, model.SelectedDateColumn, model.SelectedDat |
| | | 639 | | } |
| | | 640 | | |
| | 0 | 641 | | Console.WriteLine($"[PercentageController/GetProcessedOrdersFromOzonReportAsync] start; clientId={model.Selected |
| | | 642 | | |
| | 0 | 643 | | if (model.SelectedOzonClientId is null) |
| | 0 | 644 | | throw new Exception("Не выбран Ozon-клиент"); |
| | | 645 | | |
| | 0 | 646 | | if (!model.ProcessedAtFrom.HasValue || !model.ProcessedAtTo.HasValue) |
| | 0 | 647 | | throw new Exception("Укажите период формирования отчета"); |
| | | 648 | | |
| | 0 | 649 | | var processedAtFrom = model.ProcessedAtFrom.Value; |
| | 0 | 650 | | var processedAtTo = model.ProcessedAtTo.Value; |
| | | 651 | | |
| | 0 | 652 | | if (processedAtFrom > processedAtTo) |
| | 0 | 653 | | throw new Exception("Дата начала периода не может быть больше даты окончания"); |
| | | 654 | | |
| | 0 | 655 | | if (processedAtTo - processedAtFrom > TimeSpan.FromDays(92)) |
| | 0 | 656 | | throw new Exception("Период отчета не должен превышать 3 месяца"); |
| | | 657 | | |
| | 0 | 658 | | var ozonClient = await _ozonClientServices.GetOzonClientAsync(model.SelectedOzonClientId.Value); |
| | 0 | 659 | | if (ozonClient == null || string.IsNullOrWhiteSpace(ozonClient.DecryptClientId) || |
| | 0 | 660 | | string.IsNullOrWhiteSpace(ozonClient.DecryptApiKey)) |
| | 0 | 661 | | throw new Exception("Не удалось загрузить настройки выбранного Ozon-клиента"); |
| | | 662 | | |
| | 0 | 663 | | Console.WriteLine($"[PercentageController/GetProcessedOrdersFromOzonReportAsync] client='{ozonClient.Name}', dec |
| | | 664 | | |
| | 0 | 665 | | var apiManager = new OzonApiDataManager( |
| | 0 | 666 | | _httpClientFactory, |
| | 0 | 667 | | _emailService, |
| | 0 | 668 | | ozonClient.DecryptClientId, |
| | 0 | 669 | | ozonClient.DecryptApiKey); |
| | | 670 | | |
| | 0 | 671 | | var reportRequest = new PostingsReportRequest |
| | 0 | 672 | | { |
| | 0 | 673 | | Filter = new PostingsReportRequestFilter |
| | 0 | 674 | | { |
| | 0 | 675 | | ProcessedAtFrom = processedAtFrom, |
| | 0 | 676 | | ProcessedAtTo = processedAtTo, |
| | 0 | 677 | | DeliverySchema = ["fbs"], |
| | 0 | 678 | | IsExpress = model.IsExpress, |
| | 0 | 679 | | Sku = ParseStringArray(model.SkuList), |
| | 0 | 680 | | CancelReasonId = [], |
| | 0 | 681 | | OfferId = model.OfferId?.Trim() ?? string.Empty, |
| | 0 | 682 | | StatusAlias = ParseStringArray(model.StatusAliasList), |
| | 0 | 683 | | Statuses = ParseStringArray(model.StatusesList), |
| | 0 | 684 | | Title = model.Title?.Trim() ?? string.Empty, |
| | 0 | 685 | | WarehouseId = ParseLongArray(model.WarehouseIds), |
| | 0 | 686 | | DeliveryMethodId = ParseLongArray(model.DeliveryMethodIds) |
| | 0 | 687 | | }, |
| | 0 | 688 | | Language = "DEFAULT", |
| | 0 | 689 | | With = new PostingsReportRequestWith |
| | 0 | 690 | | { |
| | 0 | 691 | | AdditionalData = model.IncludeAdditionalData, |
| | 0 | 692 | | AnalyticsData = model.IncludeAnalyticsData, |
| | 0 | 693 | | CustomerData = model.IncludeCustomerData, |
| | 0 | 694 | | JewelryCodes = model.IncludeJewelryCodes |
| | 0 | 695 | | } |
| | 0 | 696 | | }; |
| | | 697 | | |
| | 0 | 698 | | Console.WriteLine($"[PercentageController/GetProcessedOrdersFromOzonReportAsync] reportRequest={JsonConvert.Seri |
| | | 699 | | |
| | 0 | 700 | | Console.WriteLine("[PercentageController/GetProcessedOrdersFromOzonReportAsync] step=GetReportCode"); |
| | 0 | 701 | | var reportCode = await apiManager.GetReportCode(reportRequest); |
| | 0 | 702 | | Console.WriteLine($"[PercentageController/GetProcessedOrdersFromOzonReportAsync] reportCode={reportCode}"); |
| | | 703 | | |
| | 0 | 704 | | Console.WriteLine("[PercentageController/GetProcessedOrdersFromOzonReportAsync] step=WaitForReportReady"); |
| | 0 | 705 | | var fileUrl = await apiManager.WaitForReportReady(reportCode); |
| | 0 | 706 | | Console.WriteLine($"[PercentageController/GetProcessedOrdersFromOzonReportAsync] fileUrl={fileUrl}"); |
| | | 707 | | |
| | 0 | 708 | | Console.WriteLine("[PercentageController/GetProcessedOrdersFromOzonReportAsync] step=DownloadReportBytesAsync"); |
| | 0 | 709 | | var fileBytes = await DownloadReportBytesAsync(fileUrl); |
| | 0 | 710 | | Console.WriteLine($"[PercentageController/GetProcessedOrdersFromOzonReportAsync] downloadedBytes={fileBytes.Leng |
| | | 711 | | |
| | 0 | 712 | | await using var stream = new MemoryStream(fileBytes); |
| | 0 | 713 | | var reportFileName = BuildReportFileName(reportCode); |
| | 0 | 714 | | var reportFile = new FormFile(stream, 0, fileBytes.Length, "excelFile", reportFileName) |
| | 0 | 715 | | { |
| | 0 | 716 | | Headers = new HeaderDictionary(), |
| | 0 | 717 | | ContentType = GuessContentTypeForReportFile(reportFileName) |
| | 0 | 718 | | }; |
| | | 719 | | |
| | 0 | 720 | | Console.WriteLine("[PercentageController/GetProcessedOrdersFromOzonReportAsync] step=ProcessFileAsync"); |
| | 0 | 721 | | var processedOrders = await _fileProcessingService.ProcessFileAsync( |
| | 0 | 722 | | reportFile, |
| | 0 | 723 | | model.SelectedDateColumn, |
| | 0 | 724 | | model.SelectedDateValues); |
| | 0 | 725 | | Console.WriteLine($"[PercentageController/GetProcessedOrdersFromOzonReportAsync] processedOrdersCount={processed |
| | 0 | 726 | | return processedOrders; |
| | 0 | 727 | | } |
| | | 728 | | |
| | | 729 | | private async Task<List<string>> ResolveShipmentNumbersAsync(UploadFileViewModel model) |
| | | 730 | | { |
| | 0 | 731 | | if (string.Equals(model.UploadSource, "ozon-api", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 732 | | return await GetShipmentsFromOzonReportAsync(model); |
| | | 733 | | |
| | 0 | 734 | | if (model.ExcelFile == null || model.ExcelFile.Length == 0) |
| | 0 | 735 | | throw new Exception("Файл не выбран или пуст"); |
| | | 736 | | |
| | 0 | 737 | | return await _fileProcessingService.ProcessShipmentOnlyAsync( |
| | 0 | 738 | | model.ExcelFile, |
| | 0 | 739 | | model.SelectedDateColumn, |
| | 0 | 740 | | model.SelectedDateValues); |
| | 0 | 741 | | } |
| | | 742 | | |
| | | 743 | | private async Task<List<string>> GetShipmentsFromOzonReportAsync(UploadFileViewModel model) |
| | | 744 | | { |
| | 0 | 745 | | if (!string.IsNullOrWhiteSpace(model.ApiReportKey)) |
| | | 746 | | { |
| | 0 | 747 | | var cacheKey = $"api_report_{model.ApiReportKey}"; |
| | 0 | 748 | | if (!_memoryCache.TryGetValue(cacheKey, out CachedApiReport? cachedReport) || cachedReport == null) |
| | 0 | 749 | | throw new Exception("Сессия загрузки отчёта истекла. Пожалуйста, загрузите отчёт заново."); |
| | | 750 | | |
| | 0 | 751 | | var cachedFile = CreateFormFileFromCachedReport(cachedReport); |
| | 0 | 752 | | return await _fileProcessingService.ProcessShipmentOnlyAsync( |
| | 0 | 753 | | cachedFile, |
| | 0 | 754 | | model.SelectedDateColumn, |
| | 0 | 755 | | model.SelectedDateValues); |
| | | 756 | | } |
| | | 757 | | |
| | 0 | 758 | | if (model.SelectedOzonClientId is null) |
| | 0 | 759 | | throw new Exception("Не выбран Ozon-клиент"); |
| | | 760 | | |
| | 0 | 761 | | if (!model.ProcessedAtFrom.HasValue || !model.ProcessedAtTo.HasValue) |
| | 0 | 762 | | throw new Exception("Укажите период формирования отчета"); |
| | | 763 | | |
| | 0 | 764 | | var processedAtFrom = model.ProcessedAtFrom.Value; |
| | 0 | 765 | | var processedAtTo = model.ProcessedAtTo.Value; |
| | | 766 | | |
| | 0 | 767 | | if (processedAtFrom > processedAtTo) |
| | 0 | 768 | | throw new Exception("Дата начала периода не может быть больше даты окончания"); |
| | | 769 | | |
| | 0 | 770 | | if (processedAtTo - processedAtFrom > TimeSpan.FromDays(92)) |
| | 0 | 771 | | throw new Exception("Период отчета не должен превышать 3 месяца"); |
| | | 772 | | |
| | 0 | 773 | | var ozonClient = await _ozonClientServices.GetOzonClientAsync(model.SelectedOzonClientId.Value); |
| | 0 | 774 | | if (ozonClient == null || string.IsNullOrWhiteSpace(ozonClient.DecryptClientId) || |
| | 0 | 775 | | string.IsNullOrWhiteSpace(ozonClient.DecryptApiKey)) |
| | 0 | 776 | | throw new Exception("Не удалось загрузить настройки выбранного Ozon-клиента"); |
| | | 777 | | |
| | 0 | 778 | | var apiManager = new OzonApiDataManager( |
| | 0 | 779 | | _httpClientFactory, |
| | 0 | 780 | | _emailService, |
| | 0 | 781 | | ozonClient.DecryptClientId, |
| | 0 | 782 | | ozonClient.DecryptApiKey); |
| | | 783 | | |
| | 0 | 784 | | var reportCode = await apiManager.CreateSellerReturnsReportAsync( |
| | 0 | 785 | | DateTime.SpecifyKind(processedAtFrom, DateTimeKind.Utc), |
| | 0 | 786 | | DateTime.SpecifyKind(processedAtTo, DateTimeKind.Utc)); |
| | 0 | 787 | | var fileUrl = await apiManager.WaitForReportReady(reportCode); |
| | 0 | 788 | | var fileBytes = await DownloadReportBytesAsync(fileUrl); |
| | | 789 | | |
| | 0 | 790 | | var reportFileName = BuildReportFileName(reportCode); |
| | 0 | 791 | | await using var stream = new MemoryStream(fileBytes); |
| | 0 | 792 | | var reportFile = new FormFile(stream, 0, fileBytes.Length, "excelFile", reportFileName) |
| | 0 | 793 | | { |
| | 0 | 794 | | Headers = new HeaderDictionary(), |
| | 0 | 795 | | ContentType = GuessContentTypeForReportFile(reportFileName) |
| | 0 | 796 | | }; |
| | | 797 | | |
| | 0 | 798 | | return await _fileProcessingService.ProcessShipmentOnlyAsync( |
| | 0 | 799 | | reportFile, |
| | 0 | 800 | | model.SelectedDateColumn, |
| | 0 | 801 | | model.SelectedDateValues); |
| | 0 | 802 | | } |
| | | 803 | | |
| | | 804 | | private async Task<byte[]> DownloadReportBytesAsync(string fileUrl, CancellationToken cancellationToken = default) |
| | | 805 | | { |
| | 0 | 806 | | using var client = _httpClientFactory.CreateClient(); |
| | 0 | 807 | | return await OzonReportDownloadHelper.DownloadBytesWithRetryAsync(client, fileUrl, cancellationToken: cancellati |
| | 0 | 808 | | } |
| | | 809 | | |
| | | 810 | | private static string BuildUserFriendlyUploadError(Exception ex, string fallbackMessage) |
| | | 811 | | { |
| | 0 | 812 | | var message = ex.Message?.Trim(); |
| | 0 | 813 | | if (string.IsNullOrWhiteSpace(message)) |
| | | 814 | | { |
| | 0 | 815 | | return fallbackMessage; |
| | | 816 | | } |
| | | 817 | | |
| | 0 | 818 | | if (ex is ArgumentNullException) |
| | | 819 | | { |
| | 0 | 820 | | return "Файл не был передан. Выберите файл и повторите попытку."; |
| | | 821 | | } |
| | | 822 | | |
| | 0 | 823 | | if (ex is InvalidDataException) |
| | | 824 | | { |
| | 0 | 825 | | return "Файл повреждён или имеет неверную структуру. Проверьте содержимое и попробуйте снова."; |
| | | 826 | | } |
| | | 827 | | |
| | 0 | 828 | | if (ex is FormatException) |
| | | 829 | | { |
| | 0 | 830 | | return "Не удалось распознать формат файла или значения в нём. Проверьте файл и повторите попытку."; |
| | | 831 | | } |
| | | 832 | | |
| | 0 | 833 | | if (ex is HeaderValidationException || ex is ReaderException) |
| | | 834 | | { |
| | 0 | 835 | | return "CSV-файл содержит ошибки структуры или некорректные данные. Проверьте разделитель, кодировку и загол |
| | | 836 | | } |
| | | 837 | | |
| | 0 | 838 | | if (ex is LicenseException) |
| | | 839 | | { |
| | 0 | 840 | | return "Не удалось открыть Excel-файл. Попробуйте сохранить его заново или экспортировать в CSV."; |
| | | 841 | | } |
| | | 842 | | |
| | 0 | 843 | | if (message.Contains("central directory", StringComparison.OrdinalIgnoreCase) |
| | 0 | 844 | | || message.Contains("end of central directory", StringComparison.OrdinalIgnoreCase) |
| | 0 | 845 | | || message.Contains("invalid data", StringComparison.OrdinalIgnoreCase) |
| | 0 | 846 | | || message.Contains("corrupt", StringComparison.OrdinalIgnoreCase)) |
| | | 847 | | { |
| | 0 | 848 | | return "Файл повреждён или загружен не полностью. Загрузите корректный файл и повторите попытку."; |
| | | 849 | | } |
| | | 850 | | |
| | 0 | 851 | | if (message.Contains("timeout", StringComparison.OrdinalIgnoreCase) |
| | 0 | 852 | | || message.Contains("timed out", StringComparison.OrdinalIgnoreCase)) |
| | | 853 | | { |
| | 0 | 854 | | return "Операция заняла слишком много времени. Попробуйте повторить позже или уменьшить объём данных."; |
| | | 855 | | } |
| | | 856 | | |
| | 0 | 857 | | if (message.Contains("network", StringComparison.OrdinalIgnoreCase) |
| | 0 | 858 | | || message.Contains("connection", StringComparison.OrdinalIgnoreCase) |
| | 0 | 859 | | || ex is HttpRequestException) |
| | | 860 | | { |
| | 0 | 861 | | return "Не удалось связаться с внешним сервисом. Проверьте соединение и повторите попытку."; |
| | | 862 | | } |
| | | 863 | | |
| | 0 | 864 | | return message; |
| | | 865 | | } |
| | | 866 | | |
| | | 867 | | private async Task<UploadFileViewModel> BuildUploadFileViewModelAsync() |
| | | 868 | | { |
| | 0 | 869 | | var clients = (await _ozonClientServices.GetOzonClients()) |
| | | 870 | | .Where(x => x.ClientType == ClientType.OZON) |
| | | 871 | | .OrderBy(x => x.Name) |
| | | 872 | | .Select(x => new SelectListItem(x.Name, x.Id.ToString())) |
| | 0 | 873 | | .ToList(); |
| | | 874 | | |
| | 0 | 875 | | return new UploadFileViewModel |
| | 0 | 876 | | { |
| | 0 | 877 | | ProcessedAtFrom = DateTime.Today.AddDays(-7), |
| | 0 | 878 | | ProcessedAtTo = DateTime.Today, |
| | 0 | 879 | | OzonClients = clients |
| | 0 | 880 | | }; |
| | 0 | 881 | | } |
| | | 882 | | |
| | | 883 | | private async Task<UploadFileViewModel> PopulateUploadFileViewModelAsync(UploadFileViewModel model) |
| | | 884 | | { |
| | 0 | 885 | | model.OzonClients = (await _ozonClientServices.GetOzonClients()) |
| | | 886 | | .Where(x => x.ClientType == ClientType.OZON) |
| | | 887 | | .OrderBy(x => x.Name) |
| | | 888 | | .Select(x => new SelectListItem(x.Name, x.Id.ToString())) |
| | 0 | 889 | | .ToList(); |
| | | 890 | | |
| | 0 | 891 | | return model; |
| | 0 | 892 | | } |
| | | 893 | | |
| | | 894 | | private static string[] ParseStringArray(string? raw) |
| | | 895 | | { |
| | 0 | 896 | | return string.IsNullOrWhiteSpace(raw) |
| | 0 | 897 | | ? [] |
| | 0 | 898 | | : raw.Split(new[] { ',', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) |
| | 0 | 899 | | .Select(x => x.Trim()) |
| | 0 | 900 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | 0 | 901 | | .ToArray(); |
| | | 902 | | } |
| | | 903 | | |
| | | 904 | | private static long[] ParseLongArray(string? raw) |
| | | 905 | | { |
| | 0 | 906 | | return ParseStringArray(raw) |
| | 0 | 907 | | .Select(x => long.TryParse(x, out var value) ? value : (long?)null) |
| | 0 | 908 | | .Where(x => x.HasValue) |
| | 0 | 909 | | .Select(x => x!.Value) |
| | 0 | 910 | | .ToArray(); |
| | | 911 | | } |
| | | 912 | | |
| | | 913 | | private static string GuessContentTypeForReportFile(string fileName) |
| | | 914 | | { |
| | 0 | 915 | | if (fileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 916 | | return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; |
| | 0 | 917 | | if (fileName.EndsWith(".xls", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 918 | | return "application/vnd.ms-excel"; |
| | 0 | 919 | | return "text/csv"; |
| | | 920 | | } |
| | | 921 | | |
| | | 922 | | private static string BuildReportFileName(string reportCode) |
| | | 923 | | { |
| | 0 | 924 | | var safeCode = string.Concat(reportCode.Select(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' ? ch : '_')); |
| | 0 | 925 | | var ext = reportCode.Contains("returns", StringComparison.OrdinalIgnoreCase) ? ".xlsx" : ".csv"; |
| | 0 | 926 | | return $"{safeCode}{ext}"; |
| | | 927 | | } |
| | | 928 | | // GET |
| | | 929 | | public async Task<IActionResult> Percentage(string ids, int page) |
| | | 930 | | { |
| | 0 | 931 | | var filteredStatuses = _context.AppStatuses.Where(status => status.Name == "Отменен").ToList(); |
| | 0 | 932 | | ViewBag.AppStatuses = new SelectList(filteredStatuses, "Id", "Name"); |
| | | 933 | | ViewBag.StatusColors = filteredStatuses.ToDictionary(s => s.Id.ToString(), s => s.GetStatusColor()); |
| | | 934 | | |
| | | 935 | | // Проверка на пустой или null ids |
| | 0 | 936 | | if (string.IsNullOrWhiteSpace(ids)) |
| | | 937 | | { |
| | 0 | 938 | | ViewData["ErrorReason"] = "Ни один заказ не был выбран. Выберите заказы с помощью галочек."; |
| | | 939 | | |
| | 0 | 940 | | var emptyViewModel = new MultiplayEditOrderViewModel() |
| | 0 | 941 | | { |
| | 0 | 942 | | RedirectPage = page, |
| | 0 | 943 | | Orders = new List<Order>(), |
| | 0 | 944 | | User = (await _userCacheService.GetCachedUserAsync(User))!, |
| | | 945 | | Suppliers = (await _supplierDataServices.GetSuppliers()).OrderBy(s => s.Name).ToList(), |
| | 0 | 946 | | RateEUR = await _currencyRateFetcher.GetEURRateAsync(), |
| | 0 | 947 | | RateUSD = await _currencyRateFetcher.GetUSDRateAsync(), |
| | 0 | 948 | | RateBYN = await _currencyRateFetcher.GetBYNRateAsync(), |
| | 0 | 949 | | UniqueArticles = new Dictionary<string, int>(), |
| | 0 | 950 | | UniqueDeliveryCitys = new Dictionary<string, int>(), |
| | 0 | 951 | | UniqueNumbers = new Dictionary<string, int>(), |
| | 0 | 952 | | AppStatus = await _appStatusServices.GetAppStatusAsync(new AppStatus() { Name = "Заказан поставщику" }) |
| | 0 | 953 | | ?? new AppStatus { Name = "Заказан поставщику" } |
| | 0 | 954 | | }; |
| | | 955 | | |
| | 0 | 956 | | if (emptyViewModel.User?.AccessProfileId != null) |
| | | 957 | | { |
| | 0 | 958 | | emptyViewModel.User.AccessProfile = await _context.AccessProfiles.FindAsync(emptyViewModel.User.AccessPr |
| | | 959 | | } |
| | | 960 | | |
| | 0 | 961 | | return View(emptyViewModel); |
| | | 962 | | } |
| | | 963 | | |
| | 0 | 964 | | int[] idArray = ids.Split(',').Select(int.Parse).ToArray(); |
| | 0 | 965 | | List<Order> ordersToTransaction = new List<Order>(); |
| | | 966 | | |
| | 0 | 967 | | foreach (var orderId in idArray) |
| | | 968 | | { |
| | 0 | 969 | | var concretOrder = await _orderServices.GetOrder(orderId); |
| | 0 | 970 | | if (concretOrder == null) |
| | | 971 | | continue; |
| | | 972 | | |
| | 0 | 973 | | concretOrder.UpdatedBy = User.Identity?.Name; |
| | 0 | 974 | | ordersToTransaction.Add(concretOrder); |
| | | 975 | | } |
| | | 976 | | |
| | 0 | 977 | | var pageViewModel = new MultiplayEditOrderViewModel() |
| | 0 | 978 | | { |
| | 0 | 979 | | RedirectPage = page, |
| | | 980 | | Orders = ordersToTransaction.OrderBy(ot => ot.ShipmentNumber).ToList(), |
| | 0 | 981 | | User = (await _userCacheService.GetCachedUserAsync(User))!, |
| | | 982 | | Suppliers = (await _supplierDataServices.GetSuppliers()).OrderBy(s => s.Name).ToList(), |
| | 0 | 983 | | RateEUR = await _currencyRateFetcher.GetEURRateAsync(), |
| | 0 | 984 | | RateUSD = await _currencyRateFetcher.GetUSDRateAsync(), |
| | 0 | 985 | | RateBYN = await _currencyRateFetcher.GetBYNRateAsync(), |
| | 0 | 986 | | UniqueArticles = await _orderServices.GetArticleCountsForOrdersAsync(ordersToTransaction), |
| | 0 | 987 | | UniqueDeliveryCitys = await _orderServices.GetDeliveryCityCountsForOrdersAsync(ordersToTransaction), |
| | 0 | 988 | | UniqueNumbers = await _orderServices.GetShipmentBaseCountsForOrdersAsync(ordersToTransaction), |
| | 0 | 989 | | AppStatus = await _appStatusServices.GetAppStatusAsync(new AppStatus() { Name = "Заказан поставщику" }) |
| | 0 | 990 | | ?? new AppStatus { Name = "Заказан поставщику" } |
| | 0 | 991 | | }; |
| | | 992 | | |
| | 0 | 993 | | if (pageViewModel.User?.AccessProfileId != null) |
| | | 994 | | { |
| | 0 | 995 | | pageViewModel.User.AccessProfile = await _context.AccessProfiles.FindAsync(pageViewModel.User.AccessProfileI |
| | | 996 | | } |
| | | 997 | | |
| | 0 | 998 | | RunBackgroundCacheTask(pageViewModel.Orders); |
| | 0 | 999 | | return View(pageViewModel); |
| | 0 | 1000 | | } |
| | | 1001 | | |
| | | 1002 | | // фоновой прогрев кэша |
| | | 1003 | | private void RunBackgroundCacheTask(List<Order> orders) |
| | | 1004 | | { |
| | 0 | 1005 | | _ = Task.Run(async () => |
| | 0 | 1006 | | { |
| | 0 | 1007 | | foreach (var order in orders) |
| | 0 | 1008 | | { |
| | 0 | 1009 | | try |
| | 0 | 1010 | | { |
| | 0 | 1011 | | if (string.IsNullOrEmpty(order.EtProducer?.Name) || string.IsNullOrEmpty(order.Article)) |
| | 0 | 1012 | | continue; |
| | 0 | 1013 | | |
| | 0 | 1014 | | _ = await _proxyHttpClientService.GetJsonAsync( |
| | 0 | 1015 | | $"https://api.interparts.ru/detail-full-info/{order.EtProducer.Name}/{order.Article}"); |
| | 0 | 1016 | | |
| | 0 | 1017 | | _ = await _proxyHttpClientService.GetJsonAsync( |
| | 0 | 1018 | | $"https://api.interparts.ru/product-information/product/?article_number={order.Article}&manufact |
| | 0 | 1019 | | |
| | 0 | 1020 | | Console.WriteLine($"Кэширован для проведения: {order.EtProducer.Name}/{order.Article}"); |
| | 0 | 1021 | | } |
| | 0 | 1022 | | catch (Exception ex) |
| | 0 | 1023 | | { |
| | 0 | 1024 | | Console.WriteLine($"Ошибка в фоне: {ex.Message}"); |
| | 0 | 1025 | | } |
| | 0 | 1026 | | } |
| | 0 | 1027 | | }); |
| | 0 | 1028 | | } |
| | | 1029 | | |
| | | 1030 | | // POST |
| | | 1031 | | [HttpPost] |
| | | 1032 | | [ValidateAntiForgeryToken] |
| | | 1033 | | public async Task<IActionResult> Percentage( |
| | | 1034 | | List<Order> orders, |
| | | 1035 | | string userName, |
| | | 1036 | | string comment, |
| | | 1037 | | int page, |
| | | 1038 | | string deletedOrders) |
| | | 1039 | | { |
| | 0 | 1040 | | var aiFlow = TransactionCommentAiSchedulingHelper.BeginFlow( |
| | 0 | 1041 | | comment, |
| | 0 | 1042 | | TransactionType.Percentage, |
| | 0 | 1043 | | userName, |
| | 0 | 1044 | | processIn1C: false); |
| | 0 | 1045 | | comment = aiFlow.UserCommentOnly; |
| | | 1046 | | |
| | 0 | 1047 | | if (orders == null) |
| | | 1048 | | { |
| | 0 | 1049 | | TempData["ErorrResult"] = $"Не удалось провести выбранные заказы.<br>Было передано слишком большое количеств |
| | 0 | 1050 | | return RedirectToAction("Index"); |
| | | 1051 | | } |
| | | 1052 | | else |
| | | 1053 | | { |
| | 0 | 1054 | | if (deletedOrders != null) |
| | | 1055 | | { |
| | 0 | 1056 | | var deletedOrderIds = DeletedOrderIds.Parse(deletedOrders); |
| | 0 | 1057 | | orders = orders.Where(o => !deletedOrderIds.Contains(o.Id)).ToList(); |
| | | 1058 | | } |
| | | 1059 | | } |
| | | 1060 | | |
| | | 1061 | | try |
| | | 1062 | | { |
| | 0 | 1063 | | if (orders.Count != 0) |
| | | 1064 | | { |
| | 0 | 1065 | | DateTime createAt = DateTime.Now; |
| | 0 | 1066 | | var changeCount = 0; |
| | 0 | 1067 | | var dateTime = ""; |
| | 0 | 1068 | | var transactionId = 0; |
| | 0 | 1069 | | List<Order> ordersToUpdate = new List<Order>(); |
| | 0 | 1070 | | List<string> ordersNotFoundInOzone = []; |
| | | 1071 | | |
| | | 1072 | | var orderIds = orders.Select(o => o.Id).Distinct().ToList(); |
| | 0 | 1073 | | var fromStatusByOrderId = await _context.Orders.AsNoTracking() |
| | 0 | 1074 | | .Where(o => orderIds.Contains(o.Id)) |
| | 0 | 1075 | | .Select(o => new { o.Id, FromStatusId = o.AppStatusId ?? 0 }) |
| | | 1076 | | .ToDictionaryAsync(x => x.Id, x => x.FromStatusId); |
| | | 1077 | | |
| | 0 | 1078 | | var submittedFields = SubmittedOrderFields.FromForm(Request.HasFormContentType ? Request.Form : null); |
| | 0 | 1079 | | foreach (var order in orders) |
| | | 1080 | | { |
| | 0 | 1081 | | ordersToUpdate.Add(await _orderServices.TransactOrder(order, submittedFields.For(order.Id))); |
| | | 1082 | | } |
| | | 1083 | | |
| | 0 | 1084 | | var commentForDb = TransactionCommentAiSchedulingHelper.ResolveDatabaseComment(aiFlow, comment); |
| | 0 | 1085 | | (changeCount, dateTime, transactionId) = await _transaction.CreatePercentageTransaction(ordersToUpdate, |
| | 0 | 1086 | | userName, |
| | 0 | 1087 | | createAt, |
| | 0 | 1088 | | commentForDb); |
| | 0 | 1089 | | await _transactionCache.Update(); |
| | | 1090 | | |
| | 0 | 1091 | | if (aiFlow.ScheduleAiComment && aiFlow.Builder != null && transactionId > 0) |
| | | 1092 | | { |
| | 0 | 1093 | | await TransactionCommentAiSchedulingHelper.TryScheduleAsync( |
| | 0 | 1094 | | HttpContext.RequestServices, |
| | 0 | 1095 | | aiFlow.Builder, |
| | 0 | 1096 | | true, |
| | 0 | 1097 | | transactionId, |
| | 0 | 1098 | | ordersToUpdate, |
| | 0 | 1099 | | fromStatusByOrderId, |
| | 0 | 1100 | | _context, |
| | 0 | 1101 | | ct: HttpContext.RequestAborted); |
| | | 1102 | | } |
| | 0 | 1103 | | if (ordersNotFoundInOzone.Count > 0) |
| | | 1104 | | { |
| | | 1105 | | TempData["OrdersNotFoundInOzone"] = $"Заказы не найдены в системе Ozon - {ordersNotFoundInOzone.Aggr |
| | | 1106 | | } |
| | 0 | 1107 | | } |
| | | 1108 | | |
| | 0 | 1109 | | ClearSelectedIdsSession(); |
| | | 1110 | | |
| | 0 | 1111 | | foreach (var order in orders) |
| | | 1112 | | { |
| | 0 | 1113 | | var cookieKey = $"PurchasePrice_{order.Id}"; |
| | 0 | 1114 | | if (Request.Cookies.ContainsKey(cookieKey)) |
| | | 1115 | | { |
| | 0 | 1116 | | Response.Cookies.Delete(cookieKey); |
| | | 1117 | | } |
| | | 1118 | | } |
| | | 1119 | | |
| | 0 | 1120 | | return Json(new { redirectTo = Url.Action("Index", "Orders", new { sortOrder = GetSortStateCookie(), page = |
| | | 1121 | | } |
| | 0 | 1122 | | catch (Exception ex) |
| | | 1123 | | { |
| | 0 | 1124 | | TempData["ErorrResult"] = $"Не удалось провести выбранные заказы ({ex.Message})"; |
| | 0 | 1125 | | return Json(new { redirectTo = Url.Action("Index", "Orders", new { sortOrder = GetSortStateCookie(), page = |
| | | 1126 | | } |
| | 0 | 1127 | | } |
| | | 1128 | | |
| | | 1129 | | [HttpPost] |
| | | 1130 | | public IActionResult ClearSelectedIdsSession() |
| | | 1131 | | { |
| | 0 | 1132 | | HttpContext.Session.SetString("selectedIds", JsonConvert.SerializeObject(new List<int>())); |
| | 0 | 1133 | | return Ok(); |
| | | 1134 | | } |
| | | 1135 | | |
| | | 1136 | | private OrderSortState GetSortStateCookie() |
| | | 1137 | | { |
| | 0 | 1138 | | var sortStateCookie = Request.Cookies["SortState"]; |
| | 0 | 1139 | | if (!string.IsNullOrEmpty(sortStateCookie) && Enum.TryParse<OrderSortState>(sortStateCookie, out var savedSortSt |
| | | 1140 | | { |
| | 0 | 1141 | | return savedSortState; |
| | | 1142 | | } |
| | 0 | 1143 | | return OrderSortState.StandardState; |
| | | 1144 | | } |
| | | 1145 | | |
| | | 1146 | | |
| | | 1147 | | } |