< Summary

Line coverage
0%
Covered lines: 0
Uncovered lines: 764
Coverable lines: 764
Total lines: 1415
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 352
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: .cctor()100%210%
File 1: .ctor(...)100%210%
File 1: Index()100%210%
File 1: GetRecentChanges()100%210%
File 1: GetHandoverPhoto()0%2040%
File 1: GetOrderHistory()0%812280%
File 1: LoadOrderProblemsAsync()0%2040%
File 1: LoadSupplierStatusTimelineAsync()0%620%
File 1: MapCalculationSnapshot(...)100%210%
File 1: Number()0%2040%
File 1: NullableNumber()0%4260%
File 1: GetCalculationSnapshot()0%2040%
File 1: GetSupplierQuotationHistorySummary()0%7280%
File 1: LoadTransactionsInScopeAsync()0%620%
File 1: LoadShipmentTotalsHistoryInScopeAsync()100%210%
File 1: LoadOneCPostingsInScopeAsync()100%210%
File 1: LoadHandoverItemsInScopeAsync()100%210%
File 1: LoadLabelPrintsInScopeAsync()100%210%
File 1: GetCreatedNomenclature(...)0%4260%
File 1: GetShipmentTotals()0%812280%
File 1: GetShipmentTotalsBatch(...)0%156120%
File 1: FormatDate(...)0%2040%
File 1: FormatDateTime(...)0%2040%
File 1: BuildOperationsDto(...)0%156120%
File 1: BuildTotalsDto(...)0%620%
File 1: EnqueueSaveShipmentTotalsToDb(...)0%2040%
File 1: EnqueueSaveShipmentTotalsToDbFromCachedPayload(...)100%210%
File 1: SaveShipmentTotalsToDbAsync()0%272160%
File 1: SaveShipmentTotalsToDbFromCachedPayloadAsync()0%1056320%
File 1: SafeTotal()0%420200%
File 1: ResolveOzonClientAsync()0%156120%
File 1: LoadFinanceAccrualSummaryInScopeAsync()100%210%
File 1: LoadFinanceAccrualSummaryAsync()0%4260%
File 1: CollectTransactionIds(...)0%506220%
File 1: ExportBitrixSyncHistoryExcel()0%7280%
File 1: GetFeedPriceStock()0%620%
File 1: GetFeedPriceExplanation()0%4260%
File 1: GetRollbackPreview()0%7280%
File 1: ApplyRollback()0%156120%
File 1: GetAccessibleOrderAsync()0%620%
File 1: BuildAccessibleOrdersQueryAsync()0%110100%
File 1: BuildHistoryUserProfilesAsync()0%342180%
File 2: .cctor()100%210%
File 2: QuotationGridRows()0%272160%
File 2: QuotationGridFacets()0%156120%
File 2: QuotationOffers(...)100%210%
File 2: FailedQuotationRequests(...)100%210%
File 2: GetBitrixSyncHistory()0%2040%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OrderHistoryController.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Authorization;
 2using Microsoft.AspNetCore.Identity;
 3using Microsoft.AspNetCore.Mvc;
 4using Microsoft.AspNetCore.SignalR;
 5using Microsoft.EntityFrameworkCore;
 6using Microsoft.Extensions.Caching.Memory;
 7using Microsoft.Extensions.DependencyInjection;
 8using Newtonsoft.Json;
 9using OzonDomains;
 10using OzonDomains.Models;
 11using Services.ApiServices.OzonApi;
 12using Services.ApiServices.OzonApi.Filters;
 13using OzonOrdersWeb.Areas.Studio2.ViewModels.OrderHistory;
 14using OzonOrdersWeb.WebServices.Orders;
 15using OzonOrdersWeb.WebServices.Orders.Grid;
 16using OzonOrdersWeb.Authorization;
 17using OzonRepositories.Context;
 18using OzonRepositories.Context.Identity;
 19using Services.Authorization.Services;
 20using Services.DataServices;
 21using Services.DataServices.OrderRollback;
 22using OzonDomains.Models.Authorization.Enums;
 23using OzonDomains.Models.SupplierStatuses;
 24using Services.ParserServices.HelpDictEnum;
 25using Services.SignalRServices;
 26using Services.BitrixStockServices;
 27using Services.ApiServices._1CApi.Models;
 28using System.Collections.Concurrent;
 29using System.Security.Claims;
 30using System.Text.Json;
 31using OzonRepositories.Data;
 32using Services.OzonHandoverServices;
 33using Services.LabelServices;
 34
 35[Authorize(Roles = "User,Admin")]
 36[Area("Studio2")]
 37public partial class OrderHistoryController : Controller
 38{
 39    private readonly IOrderHistoryDataService _orderHistoryService;
 40    private readonly IOrderHistoryOneCPostingService _oneCPostingService;
 41    private readonly OrdersDataServices _ordersDataServices;
 42    private readonly TransactionDataServices _transactionDataServices;
 43    private readonly OzonApiDataManager _ozonApiDataManager;
 44    private readonly OzonClientServices _ozonClientServices;
 45    private readonly IHubContext<OrderHistoryHub> _hubContext;
 46    private readonly IMemoryCache _memoryCache;
 47    private readonly IServiceScopeFactory _scopeFactory;
 48    private readonly IBitrixStockSyncArticleHistoryService _bitrixSyncArticleHistoryService;
 49    private readonly IOrdersGridFeedPriceService _feedPriceService;
 50    private readonly ApplicationDbContext _context;
 51    private readonly IDataScopeService _dataScopeService;
 52    private readonly IScopeFilterValuesService _scopeFilterValuesService;
 53    private readonly IOrderHistoryRollbackService _rollbackService;
 54    private readonly UserManager<CustomIdentityUser> _userManager;
 55    private readonly IOzonHandoverPhotoStorage _handoverPhotoStorage;
 56
 57    private const string ShipmentTotalsCacheKeyPrefix = "shipment_totals_v1_order_";
 058    private static readonly TimeSpan ShipmentTotalsCacheAbsoluteExpiration = TimeSpan.FromMinutes(20);
 059    private static readonly TimeSpan ShipmentTotalsCacheSlidingExpiration = TimeSpan.FromMinutes(5);
 60
 061    public OrderHistoryController(
 062        IOrderHistoryDataService orderHistoryService,
 063        IOrderHistoryOneCPostingService oneCPostingService,
 064        OrdersDataServices ordersDataServices,
 065        TransactionDataServices transactionDataServices,
 066        OzonApiDataManager ozonApiDataManager,
 067        OzonClientServices ozonClientServices,
 068        IHubContext<OrderHistoryHub> hubContext,
 069        IMemoryCache memoryCache,
 070        IServiceScopeFactory scopeFactory,
 071        IBitrixStockSyncArticleHistoryService bitrixSyncArticleHistoryService,
 072        IOrdersGridFeedPriceService feedPriceService,
 073        ApplicationDbContext context,
 074        IDataScopeService dataScopeService,
 075        IScopeFilterValuesService scopeFilterValuesService,
 076        IOrderHistoryRollbackService rollbackService,
 077        UserManager<CustomIdentityUser> userManager,
 078        IOzonHandoverPhotoStorage handoverPhotoStorage)
 79    {
 080        _orderHistoryService = orderHistoryService;
 081        _oneCPostingService = oneCPostingService;
 082        _ordersDataServices = ordersDataServices;
 083        _transactionDataServices = transactionDataServices;
 084        _ozonApiDataManager = ozonApiDataManager;
 085        _ozonClientServices = ozonClientServices;
 086        _hubContext = hubContext;
 087        _memoryCache = memoryCache;
 088        _scopeFactory = scopeFactory;
 089        _bitrixSyncArticleHistoryService = bitrixSyncArticleHistoryService;
 090        _feedPriceService = feedPriceService;
 091        _context = context;
 092        _dataScopeService = dataScopeService;
 093        _scopeFilterValuesService = scopeFilterValuesService;
 094        _rollbackService = rollbackService;
 095        _userManager = userManager;
 096        _handoverPhotoStorage = handoverPhotoStorage;
 097    }
 98
 99    public async Task<IActionResult> Index()
 100    {
 0101        var recentChanges = await _orderHistoryService.GetRecentChangesAsync(100);
 0102        return View(recentChanges);
 0103    }
 104
 105    [HttpGet]
 106    public async Task<IActionResult> GetRecentChanges()
 107    {
 0108        var changes = await _orderHistoryService.GetRecentChangesAsync(50);
 0109        return Ok(changes);
 0110    }
 111
 112    [HttpGet]
 113    public async Task<IActionResult> GetHandoverPhoto(int orderId, long photoId, CancellationToken ct)
 114    {
 0115        var photo = await _context.OzonHandoverActItemPhotos
 0116            .AsNoTracking()
 0117            .Where(p => p.Id == photoId && p.ActItem != null && p.ActItem.OrderId == orderId)
 0118            .Select(p => new { p.StoragePath, p.ContentType })
 0119            .FirstOrDefaultAsync(ct);
 0120        if (photo == null)
 0121            return NotFound();
 122
 123        try
 124        {
 0125            var stream = await _handoverPhotoStorage.OpenReadAsync(photo.StoragePath, ct).ConfigureAwait(false);
 0126            return File(stream, photo.ContentType ?? "image/jpeg", enableRangeProcessing: true);
 127        }
 0128        catch (FileNotFoundException)
 129        {
 0130            return NotFound();
 131        }
 0132    }
 133
 134    [HttpGet]
 135    public async Task<IActionResult> GetOrderHistory(int orderId)
 136    {
 137        // Независимы от order/histories — стартуют сразу, каждый в своём scope (свой
 138        // ApplicationDbContext), чтобы безопасно выполняться параллельно с загрузкой истории ниже.
 0139        var shipmentTotalsHistoryTask = LoadShipmentTotalsHistoryInScopeAsync(orderId);
 0140        var oneCPostingsTask = LoadOneCPostingsInScopeAsync(orderId);
 0141        var handoverItemsTask = LoadHandoverItemsInScopeAsync(orderId);
 0142        var labelPrintsTask = LoadLabelPrintsInScopeAsync(orderId);
 143
 0144        var histories = await _orderHistoryService.GetByOrderIdAsync(orderId) ?? new List<OrderHistory>();
 145
 0146        var order = histories.FirstOrDefault()?.Order;
 0147        if (order == null)
 148        {
 0149            order = await _ordersDataServices.GetAsyncNoTracking(orderId).ConfigureAwait(false);
 0150            if (order == null)
 151            {
 152                // Не оставляем уже запущенные задачи "висящими" без await.
 0153                await Task.WhenAll(shipmentTotalsHistoryTask, oneCPostingsTask, handoverItemsTask, labelPrintsTask).Conf
 0154                return NotFound($"Заказ {orderId} не найден.");
 155            }
 156        }
 157
 0158        var transactionIds = CollectTransactionIds(order, histories);
 0159        var transactionsTask = LoadTransactionsInScopeAsync(transactionIds);
 0160        var userProfilesTask = BuildHistoryUserProfilesAsync(histories);
 161
 0162        await Task.WhenAll(transactionsTask, shipmentTotalsHistoryTask, oneCPostingsTask, userProfilesTask, handoverItem
 0163            .ConfigureAwait(false);
 164
 0165        var transactions = transactionsTask.Result;
 0166        var shipmentTotalsHistory = shipmentTotalsHistoryTask.Result;
 0167        var oneCPostings = oneCPostingsTask.Result;
 0168        var userProfiles = userProfilesTask.Result;
 0169        var handoverItems = handoverItemsTask.Result;
 0170        var labelPrints = labelPrintsTask.Result;
 0171        var supplierStatusTimelineByHistoryId = await LoadSupplierStatusTimelineAsync(orderId, HttpContext.RequestAborte
 0172        var calculationSnapshots = await _context.OrderCalculationSnapshots.AsNoTracking()
 0173            .Where(x => x.OrderId == orderId)
 0174            .OrderByDescending(x => x.CapturedAt)
 0175            .ToListAsync(HttpContext.RequestAborted);
 0176        var quotationSummary = order.ProductInfoId is int quotationProductId
 0177            ? await _context.SupplierQuotationRequests.AsNoTracking()
 0178                .Where(x => x.ProductId == quotationProductId)
 0179                .GroupBy(_ => 1)
 0180                .Select(g => new { Requests = g.Count(), Offers = g.Sum(x => x.OffersCount) })
 0181                .SingleOrDefaultAsync(HttpContext.RequestAborted)
 0182            : null;
 183
 0184        var problems = await LoadOrderProblemsAsync(order, HttpContext.RequestAborted);
 185
 0186        var viewModel = new OrderHistoryViewModel
 0187        {
 0188            OrderId = orderId,
 0189            Problems = problems,
 0190            ProductId = order.ProductInfoId,
 0191            QuotationRequestsCount = quotationSummary?.Requests ?? 0,
 0192            QuotationOffersCount = quotationSummary?.Offers ?? 0,
 0193            ShipmentNumber = order?.ShipmentNumber,
 0194            ProductName = order?.ProductName,
 0195            ProductImageUrl = order?.ProductInfo?.ImageUrl,
 0196            Article = order?.Article,
 0197            ClientName = order?.OzonClient?.Name,
 0198            SupplierStatusTimelineByHistoryId = supplierStatusTimelineByHistoryId,
 0199            Histories = histories
 200                .GroupBy(h => h.ChangedAt.Date)
 201                .OrderByDescending(g => g.Key)
 202                .ToDictionary(g => g.Key, g => g.ToList()),
 0203            Transactions = transactions,
 0204            // Начисления Ozon больше не грузятся синхронно в SSR — вкладка "Начисления Ozon"
 0205            // подгружает их лениво через GetShipmentTotals (см. order-history-finance.js).
 0206            FinanceOperations = new List<FinanceTransactionOperation>(),
 0207            FinanceTotals = null,
 0208            ShipmentTotalsHistory = shipmentTotalsHistory,
 0209            OneCPostings = oneCPostings,
 0210            HandoverItems = handoverItems,
 0211            LabelPrints = labelPrints,
 0212            UserProfiles = userProfiles,
 0213            CalculationSnapshotIdsByOperationId = calculationSnapshots
 214                .GroupBy(x => x.OperationId.ToString())
 215                .ToDictionary(x => x.Key, x => x.First().Id, StringComparer.OrdinalIgnoreCase),
 0216            CalculationSnapshots = calculationSnapshots.Select(MapCalculationSnapshot).ToList()
 0217        };
 218
 0219        return View(viewModel);
 0220    }
 221
 222    /// <summary>Проблемы одного заказа для панели на странице истории; сбой сбора не должен ломать страницу.</summary>
 223    private async Task<IReadOnlyList<OrderProblem>> LoadOrderProblemsAsync(Order order, CancellationToken cancellationTo
 224    {
 225        try
 226        {
 0227            var service = HttpContext.RequestServices.GetRequiredService<IOrderProblemInfoService>();
 0228            await service.PopulateForOrdersAsync([order], cancellationToken);
 0229            return order.Problems ?? [];
 230        }
 0231        catch (Exception ex) when (ex is not OperationCanceledException)
 232        {
 0233            HttpContext.RequestServices.GetService<ILogger<OrderHistoryController>>()
 0234                ?.LogWarning(ex, "Не удалось собрать проблемы заказа {OrderId} для страницы истории", order.Id);
 0235            return [];
 236        }
 0237    }
 238
 239    /// <summary>
 240    /// Обогащает строки общей ленты orders_history цветом, поставщиком и стадией.
 241    /// Связь OrderHistoryId исключает эвристику по времени и повторное отображение перехода.
 242    /// </summary>
 243    private async Task<Dictionary<int, OrderSupplierStatusTimelineViewModel>> LoadSupplierStatusTimelineAsync(
 244        int orderId,
 245        CancellationToken cancellationToken)
 246    {
 0247        var transitions = await (
 0248                from history in _context.OrderSupplierStateHistory.AsNoTracking()
 0249                join state in _context.OrderSupplierStates.AsNoTracking()
 0250                    on history.OrderSupplierStateId equals state.Id
 0251                where history.OrderId == orderId && history.OrderHistoryId.HasValue
 0252                select new
 0253                {
 0254                    TimelineHistoryId = history.OrderHistoryId!.Value,
 0255                    state.ProviderKey,
 0256                    history.FromSupplierStatusId,
 0257                    history.ToSupplierStatusId,
 0258                    history.FromStage,
 0259                    history.ToStage,
 0260                    history.Source,
 0261                    history.Comment
 0262                })
 0263            .ToListAsync(cancellationToken);
 264
 0265        if (transitions.Count == 0)
 266        {
 0267            return new Dictionary<int, OrderSupplierStatusTimelineViewModel>();
 268        }
 269
 0270        var statusIds = transitions
 271            .SelectMany(item => new[] { item.FromSupplierStatusId, item.ToSupplierStatusId })
 0272            .OfType<int>()
 0273            .Distinct()
 0274            .ToList();
 275
 0276        var statuses = await _context.SupplierStatuses
 0277            .AsNoTracking()
 0278            .Include(status => status.StatusColor)
 0279            .Where(status => statusIds.Contains(status.Id))
 280            .ToDictionaryAsync(status => status.Id, cancellationToken);
 281
 0282        return transitions.ToDictionary(
 283            item => item.TimelineHistoryId,
 0284            item =>
 0285            {
 0286                statuses.TryGetValue(item.FromSupplierStatusId ?? 0, out var fromStatus);
 0287                statuses.TryGetValue(item.ToSupplierStatusId ?? 0, out var toStatus);
 0288
 0289                return new OrderSupplierStatusTimelineViewModel
 0290                {
 0291                    ProviderKey = item.ProviderKey,
 0292                    FromStatusName = fromStatus?.Name,
 0293                    FromStatusColor = fromStatus?.GetStatusColor(),
 0294                    ToStatusName = toStatus?.Name ?? "Статус не указан",
 0295                    ToStatusColor = toStatus?.GetStatusColor(),
 0296                    FromStage = item.FromStage,
 0297                    ToStage = item.ToStage,
 0298                    Source = item.Source,
 0299                    Comment = item.Comment
 0300                };
 0301            });
 0302    }
 303
 304    private static OrderCalculationSnapshotHistoryViewModel MapCalculationSnapshot(OrderCalculationSnapshot snapshot)
 305    {
 0306        using var document = JsonDocument.Parse(snapshot.PayloadJson);
 0307        var root = document.RootElement;
 0308        static decimal Number(JsonElement root, string name) => root.TryGetProperty(name, out var value) && value.TryGet
 0309        static decimal? NullableNumber(JsonElement root, string name) => root.TryGetProperty(name, out var value) && val
 0310        return new OrderCalculationSnapshotHistoryViewModel
 0311        {
 0312            Id = snapshot.Id,
 0313            CapturedAt = snapshot.CapturedAt,
 0314            Source = snapshot.Source,
 0315            ShipmentAmount = Number(root, "shipmentAmount"),
 0316            CostPrice = Number(root, "costPrice"),
 0317            MinProfit = Number(root, "minProfit"),
 0318            MaxProfit = Number(root, "maxProfit"),
 0319            MinMarkup = NullableNumber(root, "minMarkup"),
 0320            MaxMarkup = NullableNumber(root, "maxMarkup")
 0321        };
 0322    }
 323
 324    [HttpGet]
 325    [PageAuthorize("Studio2.OrderHistory.GetOrderHistory")]
 326    public async Task<IActionResult> GetCalculationSnapshot(int orderId, long snapshotId, CancellationToken cancellation
 327    {
 0328        var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 0329        if (order == null) return NotFound();
 330
 0331        var snapshot = await _context.OrderCalculationSnapshots.AsNoTracking()
 0332            .FirstOrDefaultAsync(x => x.Id == snapshotId && x.OrderId == orderId, cancellationToken);
 0333        if (snapshot == null) return NotFound();
 334
 0335        return Content(snapshot.PayloadJson, "application/json");
 0336    }
 337
 338    [HttpGet]
 339    [PageAuthorize("Studio2.OrderHistory.GetOrderHistory")]
 340    public async Task<IActionResult> GetSupplierQuotationHistorySummary(int orderId, CancellationToken cancellationToken
 341    {
 0342        var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 0343        if (order?.ProductInfoId is not int productId)
 0344            return Ok(new { requests = 0, offers = 0, historyUrl = string.Empty });
 345
 0346        var summary = await _context.SupplierQuotationRequests.AsNoTracking()
 0347            .Where(x => x.ProductId == productId)
 0348            .GroupBy(_ => 1)
 0349            .Select(g => new { Requests = g.Count(), Offers = g.Sum(x => x.OffersCount) })
 0350            .SingleOrDefaultAsync(cancellationToken);
 0351        return Ok(new
 0352        {
 0353            requests = summary?.Requests ?? 0,
 0354            offers = summary?.Offers ?? 0,
 0355            historyUrl = Url.Action(nameof(GetOrderHistory), new { area = "Studio2", orderId }) + "#tab-quotations"
 0356        });
 0357    }
 358
 359    private async Task<List<Transaction>> LoadTransactionsInScopeAsync(List<int> transactionIds)
 360    {
 0361        await using var scope = _scopeFactory.CreateAsyncScope();
 0362        var svc = scope.ServiceProvider.GetRequiredService<TransactionDataServices>();
 0363        return await svc.GetTransactionsByIdsAsync(transactionIds).ConfigureAwait(false) ?? new List<Transaction>();
 0364    }
 365
 366    private async Task<List<OrderShipmentTotalsHistoryRowViewModel>> LoadShipmentTotalsHistoryInScopeAsync(int orderId)
 367    {
 368        try
 369        {
 0370            await using var scope = _scopeFactory.CreateAsyncScope();
 0371            var svc = scope.ServiceProvider.GetRequiredService<IOrderShipmentTotalsDataService>();
 0372            var entities = await svc.GetHistoryByOrderIdAsync(orderId).ConfigureAwait(false);
 0373            return entities
 374                .Select(e => new OrderShipmentTotalsHistoryRowViewModel
 375                {
 376                    UpdatedAt = e.UpdatedAt,
 377                    ListAmount = e.ListAmount,
 378                    AccrualsForSale = e.AccrualsForSale,
 379                    SaleCommission = e.SaleCommission,
 380                    ProcessingAndDelivery = e.ProcessingAndDelivery,
 381                    RefundsAndCancellations = e.RefundsAndCancellations
 382                })
 0383                .ToList();
 0384        }
 0385        catch
 386        {
 0387            return new List<OrderShipmentTotalsHistoryRowViewModel>();
 388        }
 0389    }
 390
 391    private async Task<List<OrderHistoryOneCPostingViewModel>> LoadOneCPostingsInScopeAsync(int orderId)
 392    {
 393        try
 394        {
 0395            await using var scope = _scopeFactory.CreateAsyncScope();
 0396            var svc = scope.ServiceProvider.GetRequiredService<IOrderHistoryOneCPostingService>();
 0397            var postings = await svc.GetByOrderIdAsync(orderId).ConfigureAwait(false);
 0398            return postings
 399                .Select(p => new OrderHistoryOneCPostingViewModel
 400                {
 401                    Id = p.Id,
 402                    OrderId = p.OrderId,
 403                    TransactionId = p.TransactionId,
 404                    OperationKind = p.OperationKind,
 405                    Success = p.Success,
 406                    PostedAt = p.PostedAt,
 407                    ChangedBy = p.ChangedBy,
 408                    HistoryBatchKey = p.HistoryBatchKey,
 409                    LinkedHistoryIds = p.HistoryLinks?.Select(l => l.OrderHistoryId).ToList() ?? new List<int>(),
 410                    OrganizationName = p.OrganizationName,
 411                    OrganizationInn = p.OrganizationInn,
 412                    SenderName = p.SenderName,
 413                    SenderInn = p.SenderInn,
 414                    WarehouseSender = p.WarehouseSender,
 415                    WarehouseRecipient = p.WarehouseRecipient,
 416                    OrdersCount = p.OrdersCount,
 417                    ProductsCount = p.ProductsCount,
 418                    ArticlesSummary = p.ArticlesSummary,
 419                    DocumentsCount = p.DocumentsCount,
 420                    DocumentNumbers = p.DocumentNumbers,
 421                    DocumentGuids = p.DocumentGuids,
 422                    IsPosted = p.IsPosted,
 423                    ErrorSummary = p.ErrorSummary,
 424                    CreatedNomenclature = GetCreatedNomenclature(p.DetailsJson)
 425                })
 0426                .ToList();
 0427        }
 0428        catch
 429        {
 0430            return new List<OrderHistoryOneCPostingViewModel>();
 431        }
 0432    }
 433
 434    private async Task<List<OrderHistoryHandoverItemViewModel>> LoadHandoverItemsInScopeAsync(int orderId)
 435    {
 436        try
 437        {
 0438            await using var scope = _scopeFactory.CreateAsyncScope();
 0439            var repository = scope.ServiceProvider.GetRequiredService<OzonHandoverRepository>();
 0440            var items = await repository.GetItemsByOrderIdsAsync(new[] { orderId }).ConfigureAwait(false);
 0441            return items
 442                .OrderByDescending(i => i.MarkedAt ?? i.Act?.ActDate ?? DateTime.MinValue)
 443                .Select(i => new OrderHistoryHandoverItemViewModel
 444                {
 445                    GroupName = i.Act?.GroupName,
 446                    ActNo = i.Act?.ActNo,
 447                    ActDate = i.Act?.ActDate ?? default,
 448                    ActType = i.Act?.ActType,
 449                    Pvz = i.Act?.Pvz,
 450                    ActStatus = i.Act?.Status,
 451                    ProductName = i.ProductName,
 452                    Barcode = i.Barcode,
 453                    Value = i.Value,
 454                    ValueApi = i.ValueApi,
 455                    WeightG = i.WeightG,
 456                    Status = i.Status,
 457                    Defect = i.Defect,
 458                    Missort = i.Missort,
 459                    Comment = i.Comment,
 460                    MarkedAt = i.MarkedAt,
 461                    Sku = i.Sku,
 462                    OfferId = i.OfferId,
 463                    ReturnId = i.ReturnId,
 464                    OzonStatus = i.OzonStatus,
 465                    OzonStatusName = i.OzonStatusName,
 466                    ReturnType = i.ReturnType ?? i.Act?.ReturnType,
 467                    BoxBarcode = i.BoxBarcode ?? i.Act?.BoxBarcode,
 468                    Reason = i.Reason,
 469                    ReasonIsDefect = i.ReasonIsDefect,
 470                    ClientComment = i.ClientComment,
 471                    RejectionComment = i.RejectionComment,
 472                    RejectionReason = i.RejectionReason,
 473                    RuPostTracking = i.RuPostTracking,
 474                    ReturnNumber = i.ReturnNumber,
 475                    OrderNumber = i.OrderNumber,
 476                    ClientReturnMethod = i.ClientReturnMethod,
 477                    WarehouseId = i.WarehouseId,
 478                    PhotosError = i.PhotosError,
 479                    Photos = i.Photos.OrderBy(p => p.Sequence).Select(p => new OrderHistoryHandoverPhotoViewModel
 480                    {
 481                        Id = p.Id,
 482                        Sequence = p.Sequence
 483                    }).ToList()
 484                })
 0485                .ToList();
 0486        }
 0487        catch
 488        {
 0489            return new List<OrderHistoryHandoverItemViewModel>();
 490        }
 0491    }
 492
 493    private async Task<List<OrderHistoryLabelPrintViewModel>> LoadLabelPrintsInScopeAsync(int orderId)
 494    {
 0495        await using var scope = _scopeFactory.CreateAsyncScope();
 0496        var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 0497        var entries = await context.LabelPrintLogs.AsNoTracking()
 0498            .Where(x => x.OrderId == orderId)
 0499            .OrderByDescending(x => x.OccurredAtUtc)
 0500            .Select(x => new OrderHistoryLabelPrintViewModel
 0501            {
 0502                Id = x.Id,
 0503                FileName = x.FileName,
 0504                OccurredAtUtc = x.OccurredAtUtc,
 0505                ActorName = x.ActorName,
 0506                Source = x.Source
 0507            })
 0508            .ToListAsync()
 0509            .ConfigureAwait(false);
 510
 511        var result = entries.Select(x => new OrderHistoryLabelPrintViewModel
 512        {
 513            Id = x.Id,
 514            FileName = x.FileName,
 515            OccurredAtUtc = DateTime.SpecifyKind(x.OccurredAtUtc, DateTimeKind.Utc),
 516            ActorName = x.ActorName,
 517            Source = x.Source
 518        }).ToList();
 519
 520        // Старые PDF существовали до появления журнала. Добавляем их в ленту по дате изменения
 521        // файла, не выдавая эту дату за подтверждённую печать и не выдумывая исполнителя.
 0522        var catalog = scope.ServiceProvider.GetRequiredService<ILabelCatalogService>();
 0523        var snapshot = await catalog.GetSnapshotAsync(cancellationToken: HttpContext.RequestAborted).ConfigureAwait(fals
 0524        result.AddRange(snapshot.Rows
 0525            .Where(x => x.UsesFileDate && x.RelatedOrderIds.Contains(orderId))
 526            .Select(x => new OrderHistoryLabelPrintViewModel
 527            {
 528                Id = -x.Id,
 529                FileName = x.FileName,
 530                OccurredAtUtc = x.PrintedAtUtc.UtcDateTime,
 531                ActorName = "—",
 532                Source = LabelPrintSource.Studio2,
 533                UsesFileDate = true
 534            }));
 535
 536        return result.OrderByDescending(x => x.OccurredAtUtc).ThenByDescending(x => x.Id).ToList();
 0537    }
 538
 539    private static string? GetCreatedNomenclature(string? detailsJson)
 540    {
 0541        if (string.IsNullOrWhiteSpace(detailsJson))
 0542            return null;
 543
 544        try
 545        {
 0546            var details = JsonConvert.DeserializeAnonymousType(detailsJson,
 0547                new { responses = Array.Empty<OneCResponse>() });
 0548            return details?.responses
 0549                .Select(r => r.CreatedNomenclature)
 0550                .Where(x => !string.IsNullOrWhiteSpace(x))
 0551                .Distinct()
 0552                .DefaultIfEmpty()
 0553                .Aggregate((a, b) => string.IsNullOrWhiteSpace(a) ? b : $"{a}; {b}");
 554        }
 0555        catch (Newtonsoft.Json.JsonException)
 556        {
 0557            return null;
 558        }
 0559    }
 560
 561    /// <summary>
 562    /// Подгружает начисления Ozon для указанного заказа (по номеру отправления) и возвращает
 563    /// подробные операции для отображения в модальном окне на странице заказов.
 564    /// </summary>
 565    [HttpGet]
 566    public async Task<IActionResult> GetShipmentTotals(int orderId)
 567    {
 0568        var cacheKey = $"{ShipmentTotalsCacheKeyPrefix}{orderId}";
 0569        if (_memoryCache.TryGetValue(cacheKey, out object? cached) && cached != null)
 570        {
 571            // В случае кеша данные уже были сформированы ранее, но требование — сохранять при каждом запросе.
 572            try
 573            {
 0574                EnqueueSaveShipmentTotalsToDbFromCachedPayload(orderId, cached);
 0575            }
 0576            catch
 577            {
 578                // Без ошибок на UI.
 0579            }
 580
 0581            return Ok(cached);
 582        }
 583
 0584        var order = await _ordersDataServices.GetAsyncNoTracking(orderId).ConfigureAwait(false);
 0585        if (order == null)
 0586            return NotFound($"Заказ {orderId} не найден.");
 587
 0588        var clientWithDecrypted = await ResolveOzonClientAsync(order).ConfigureAwait(false);
 0589        if (clientWithDecrypted == null)
 590        {
 0591            var emptyPayload = new
 0592            {
 0593                orderId,
 0594                shipmentNumber = order?.ShipmentNumber,
 0595                clientName = order?.OzonClient?.Name,
 0596                listAmount = 0d,
 0597                operations = new List<object>(),
 0598                totals = (object?)null
 0599            };
 0600            return Ok(emptyPayload);
 601        }
 602
 603        // Seller API требует последовательных вызовов новых методов finance/accrual.
 0604        var financeSummary = await LoadFinanceAccrualSummaryInScopeAsync(order, clientWithDecrypted).ConfigureAwait(fals
 0605        var operations = financeSummary.Operations;
 0606        var financeTotals = financeSummary.Totals;
 607
 608        double listAmount = operations?.Sum(o => o.Amount) ?? 0d;
 0609        var operationsDto = BuildOperationsDto(operations ?? new List<FinanceTransactionOperation>());
 0610        var totalsDto = BuildTotalsDto(financeTotals);
 611
 0612        var payload = new
 0613        {
 0614            orderId,
 0615            shipmentNumber = order?.ShipmentNumber,
 0616            clientName = order?.OzonClient?.Name,
 0617            listAmount,
 0618            operations = operationsDto,
 0619            totals = totalsDto
 0620        };
 621
 0622        _memoryCache.Set(cacheKey, payload, new MemoryCacheEntryOptions
 0623        {
 0624            AbsoluteExpirationRelativeToNow = ShipmentTotalsCacheAbsoluteExpiration,
 0625            SlidingExpiration = ShipmentTotalsCacheSlidingExpiration
 0626        });
 627
 628        // Upsert в БД (операции + totals) в фоне.
 629        try
 630        {
 0631            EnqueueSaveShipmentTotalsToDb(
 0632                orderId,
 0633                order?.ShipmentNumber,
 0634                listAmount,
 0635                operationsDto,
 0636                financeTotals,
 0637                operations?.Count ?? 0);
 0638        }
 0639        catch
 640        {
 641            // Без ошибок на UI.
 0642        }
 643
 0644        return Ok(payload);
 0645    }
 646
 647    /// <summary>
 648    /// Batch для preload на странице заказов: только закэшированные listAmount (без вызовов Ozon API).
 649    /// </summary>
 650    [HttpGet]
 651    public IActionResult GetShipmentTotalsBatch([FromQuery] string orderIds)
 652    {
 0653        if (string.IsNullOrWhiteSpace(orderIds))
 654        {
 0655            return Ok(new { results = Array.Empty<object>() });
 656        }
 657
 0658        var ids = orderIds
 0659            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 0660            .Select(s => int.TryParse(s, out var id) ? id : 0)
 0661            .Where(id => id > 0)
 0662            .Distinct()
 0663            .Take(50)
 0664            .ToList();
 665
 0666        var results = new List<object>();
 0667        foreach (var id in ids)
 668        {
 0669            var cacheKey = $"{ShipmentTotalsCacheKeyPrefix}{id}";
 0670            if (!_memoryCache.TryGetValue(cacheKey, out var cached) || cached is null)
 671            {
 672                continue;
 673            }
 674
 675            try
 676            {
 0677                var jo = Newtonsoft.Json.Linq.JObject.FromObject(cached);
 0678                results.Add(new
 0679                {
 0680                    orderId = id,
 0681                    listAmount = jo["listAmount"]?.ToObject<double?>() ?? 0d
 0682                });
 0683            }
 0684            catch
 685            {
 686                // Пропускаем битые записи кэша.
 0687            }
 688        }
 689
 0690        return Ok(new { results });
 691    }
 692
 693    private static string? FormatDate(string? s)
 694    {
 0695        if (string.IsNullOrWhiteSpace(s))
 0696            return null;
 697
 0698        return DateTime.TryParse(
 0699                   s,
 0700                   null,
 0701                   System.Globalization.DateTimeStyles.RoundtripKind,
 0702                   out var d)
 0703            ? d.ToString("dd.MM.yyyy")
 0704            : s;
 705    }
 706
 707    private static string? FormatDateTime(string? s)
 708    {
 0709        if (string.IsNullOrWhiteSpace(s))
 0710            return null;
 711
 0712        return DateTime.TryParse(
 0713                   s,
 0714                   null,
 0715                   System.Globalization.DateTimeStyles.RoundtripKind,
 0716                   out var d)
 0717            ? d.ToString("dd.MM.yyyy HH:mm")
 0718            : s;
 719    }
 720
 721    private static object BuildOperationsDto(List<FinanceTransactionOperation> operations)
 722    {
 0723        return (operations ?? new List<FinanceTransactionOperation>())
 0724            .Select(op => new
 0725            {
 0726                operationId = op.OperationId,
 0727                operationType = op.OperationType,
 0728                operationTypeName = OzonStatus.GetFinanceOperationType(op.OperationType ?? string.Empty),
 0729                operationDate = FormatDate(op.OperationDate),
 0730
 0731                // В истории поле "Категория" берётся из op.Type -> OzonStatus.GetFinanceTransactionType(...)
 0732                type = op.Type,
 0733                transactionTypeName = OzonStatus.GetFinanceTransactionType(op.Type ?? string.Empty),
 0734
 0735                amount = op.Amount,
 0736                accrualsForSale = op.AccrualsForSale,
 0737                saleCommission = op.SaleCommission,
 0738                deliveryCharge = op.DeliveryCharge,
 0739                returnDeliveryCharge = op.ReturnDeliveryCharge,
 0740
 0741                posting = op.Posting == null
 0742                    ? null
 0743                    : new
 0744                    {
 0745                        deliverySchema = op.Posting.DeliverySchema,
 0746                        postingNumber = op.Posting.PostingNumber,
 0747                        warehouseId = op.Posting.WarehouseId,
 0748                        orderDate = FormatDateTime(op.Posting.OrderDate)
 0749                    },
 0750
 0751                items = (op.Items ?? new List<FinanceTransactionItem>())
 0752                    .Select(i => new { name = i.Name, sku = i.Sku })
 0753                    .ToList(),
 0754
 0755                // В истории названия услуг переводятся через OzonStatus.GetFinanceServiceName(...)
 0756                services = (op.Services ?? new List<FinanceTransactionService>())
 0757                    .Select(s => new
 0758                    {
 0759                        name = OzonStatus.GetFinanceServiceName(s.Name),
 0760                        price = s.Price
 0761                    })
 0762                    .ToList()
 0763            })
 0764            .ToList();
 765    }
 766
 767    private static object? BuildTotalsDto(FinanceTransactionTotalsResult? financeTotals)
 768    {
 0769        return financeTotals == null
 0770            ? null
 0771            : new
 0772            {
 0773                accrualsForSale = financeTotals.AccrualsForSale,
 0774                saleCommission = financeTotals.SaleCommission,
 0775                processingAndDelivery = financeTotals.ProcessingAndDelivery,
 0776                refundsAndCancellations = financeTotals.RefundsAndCancellations,
 0777                servicesAmount = financeTotals.ServicesAmount,
 0778                compensationAmount = financeTotals.CompensationAmount,
 0779                moneyTransfer = financeTotals.MoneyTransfer,
 0780                othersAmount = financeTotals.OthersAmount
 0781            };
 782    }
 783
 784    private void EnqueueSaveShipmentTotalsToDb(
 785        int orderId,
 786        string? shipmentNumber,
 787        double listAmount,
 788        object operationsDto,
 789        FinanceTransactionTotalsResult? financeTotals,
 790        int operationsCount)
 791    {
 792        // Не создаём фоновые задачи для пустых/нулевых результатов.
 0793        if (operationsCount <= 0 || Math.Abs(listAmount) < 0.000001)
 0794            return;
 795
 0796        _ = Task.Run(async () =>
 0797        {
 0798            try
 0799            {
 0800                using var scope = _scopeFactory.CreateScope();
 0801                var svc = scope.ServiceProvider.GetRequiredService<IOrderShipmentTotalsDataService>();
 0802
 0803                await SaveShipmentTotalsToDbAsync(
 0804                        svc,
 0805                        orderId,
 0806                        shipmentNumber,
 0807                        listAmount,
 0808                        operationsDto,
 0809                        financeTotals,
 0810                        operationsCount)
 0811                    .ConfigureAwait(false);
 0812            }
 0813            catch
 0814            {
 0815                // Не ломаем UI из-за фоновых сохранений.
 0816            }
 0817        });
 0818    }
 819
 820    private void EnqueueSaveShipmentTotalsToDbFromCachedPayload(
 821        int orderId,
 822        object cachedPayload)
 823    {
 0824        _ = Task.Run(async () =>
 0825        {
 0826            try
 0827            {
 0828                using var scope = _scopeFactory.CreateScope();
 0829                var svc = scope.ServiceProvider.GetRequiredService<IOrderShipmentTotalsDataService>();
 0830
 0831                await SaveShipmentTotalsToDbFromCachedPayloadAsync(
 0832                        svc,
 0833                        orderId,
 0834                        cachedPayload)
 0835                    .ConfigureAwait(false);
 0836            }
 0837            catch
 0838            {
 0839                // Не ломаем UI из-за фоновых сохранений.
 0840            }
 0841        });
 0842    }
 843
 844    private async Task SaveShipmentTotalsToDbAsync(
 845        IOrderShipmentTotalsDataService svc,
 846        int orderId,
 847        string? shipmentNumber,
 848        double listAmount,
 849        object operationsDto,
 850        FinanceTransactionTotalsResult? financeTotals,
 851        int operationsCount)
 852    {
 0853        var entity = new OrderShipmentTotals
 0854        {
 0855            OrderId = orderId,
 0856            ShipmentNumber = shipmentNumber,
 0857            ListAmount = listAmount,
 0858
 0859            AccrualsForSale = financeTotals?.AccrualsForSale ?? 0d,
 0860            SaleCommission = financeTotals?.SaleCommission ?? 0d,
 0861            ProcessingAndDelivery = financeTotals?.ProcessingAndDelivery ?? 0d,
 0862            RefundsAndCancellations = financeTotals?.RefundsAndCancellations ?? 0d,
 0863            ServicesAmount = financeTotals?.ServicesAmount ?? 0d,
 0864            CompensationAmount = financeTotals?.CompensationAmount ?? 0d,
 0865            MoneyTransfer = financeTotals?.MoneyTransfer ?? 0d,
 0866            OthersAmount = financeTotals?.OthersAmount ?? 0d,
 0867
 0868            OperationsCount = operationsCount,
 0869            OperationsJson = JsonConvert.SerializeObject(operationsDto)
 0870        };
 871
 0872        await svc.SetOrUpdateAsync(entity).ConfigureAwait(false);
 0873    }
 874
 875    private async Task SaveShipmentTotalsToDbFromCachedPayloadAsync(
 876        IOrderShipmentTotalsDataService svc,
 877        int orderId,
 878        object cachedPayload)
 879    {
 0880        dynamic payload = cachedPayload;
 881
 0882        var operationsDto = payload.operations;
 0883        int operationsCount = operationsDto?.Count ?? 0;
 884
 0885        if (operationsCount <= 0 || Math.Abs((double)payload.listAmount) < 0.000001)
 0886            return;
 887
 888        // totalsDto уже отсортирован/назван как в ответе GetShipmentTotals.
 0889        dynamic totals = payload.totals;
 890
 891        static double SafeTotal(dynamic t, string propName)
 892        {
 0893            if (t == null)
 0894                return 0d;
 895
 896            try
 897            {
 0898                var prop = ((object)t).GetType().GetProperty(propName);
 0899                if (prop == null) return 0d;
 0900                var v = prop.GetValue(t);
 0901                return v == null ? 0d : Convert.ToDouble(v);
 902            }
 0903            catch
 904            {
 0905                return 0d;
 906            }
 0907        }
 908
 0909        var entity = new OrderShipmentTotals
 0910        {
 0911            OrderId = orderId,
 0912            ShipmentNumber = payload.shipmentNumber,
 0913            ListAmount = payload.listAmount,
 0914
 0915            AccrualsForSale = SafeTotal(totals, "accrualsForSale"),
 0916            SaleCommission = SafeTotal(totals, "saleCommission"),
 0917            ProcessingAndDelivery = SafeTotal(totals, "processingAndDelivery"),
 0918            RefundsAndCancellations = SafeTotal(totals, "refundsAndCancellations"),
 0919            ServicesAmount = SafeTotal(totals, "servicesAmount"),
 0920            CompensationAmount = SafeTotal(totals, "compensationAmount"),
 0921            MoneyTransfer = SafeTotal(totals, "moneyTransfer"),
 0922            OthersAmount = SafeTotal(totals, "othersAmount"),
 0923
 0924            OperationsCount = operationsCount,
 0925            OperationsJson = JsonConvert.SerializeObject(operationsDto)
 0926        };
 927
 0928        await svc.SetOrUpdateAsync(entity).ConfigureAwait(false);
 0929    }
 930
 931    private async Task<OzonClient?> ResolveOzonClientAsync(Order? order)
 932    {
 0933        if (order?.OzonClientId == null || string.IsNullOrWhiteSpace(order.ShipmentNumber))
 0934            return null;
 935
 936        try
 937        {
 0938            var client = await _ozonClientServices.GetOzonClientAsync(order.OzonClientId.Value).ConfigureAwait(false);
 0939            if (client == null || string.IsNullOrWhiteSpace(client.DecryptClientId) || string.IsNullOrWhiteSpace(client.
 0940                return null;
 0941            return client;
 942        }
 0943        catch
 944        {
 0945            return null;
 946        }
 0947    }
 948
 949    private async Task<FinanceAccrualSummary> LoadFinanceAccrualSummaryInScopeAsync(Order? order, OzonClient clientWithD
 950    {
 0951        await using var scope = _scopeFactory.CreateAsyncScope();
 0952        var ozonApi = scope.ServiceProvider.GetRequiredService<OzonApiDataManager>();
 0953        return await LoadFinanceAccrualSummaryAsync(order, clientWithDecrypted, ozonApi).ConfigureAwait(false);
 0954    }
 955
 956    private async Task<FinanceAccrualSummary> LoadFinanceAccrualSummaryAsync(
 957        Order? order,
 958        OzonClient clientWithDecrypted,
 959        OzonApiDataManager? ozonApiDataManager = null)
 960    {
 0961        var ozonApi = ozonApiDataManager ?? _ozonApiDataManager;
 0962        var empty = new FinanceAccrualSummary();
 0963        if (order == null || string.IsNullOrWhiteSpace(order.ShipmentNumber))
 0964            return empty;
 965
 966        try
 967        {
 0968            ozonApi.SetClient(clientWithDecrypted.DecryptClientId!, clientWithDecrypted.DecryptApiKey!);
 0969            var accruals = await ozonApi.GetFinanceAccrualPostingsAsync(new FinanceAccrualPostingsRequest
 0970            {
 0971                PostingNumbers = new List<string> { order.ShipmentNumber }
 0972            }).ConfigureAwait(false);
 0973            var types = await ozonApi.GetFinanceAccrualTypesAsync().ConfigureAwait(false);
 0974            return FinanceAccrualSummaryBuilder.Build(accruals, types);
 975        }
 0976        catch
 977        {
 978            // Начисления по API могут быть недоступны — не ломаем страницу истории.
 0979            return empty;
 980        }
 0981    }
 982
 983    /// <summary>
 984    /// Собирает ID транзакций: текущая транзакция заказа + из истории изменений поля TransactionId.
 985    /// </summary>
 986    private static List<int> CollectTransactionIds(Order? order, List<OrderHistory> histories)
 987    {
 0988        var ids = new HashSet<int>();
 989
 0990        if (order?.TransactionId.HasValue == true && order.TransactionId.Value > 0)
 0991            ids.Add(order.TransactionId.Value);
 992
 0993        foreach (var h in histories)
 994        {
 0995            var isTransactionColumn = string.Equals(h.ColumnName, "TransactionId", StringComparison.OrdinalIgnoreCase) |
 0996                                      (h.ColumnDisplayName != null && h.ColumnDisplayName.IndexOf("Транзакция", StringCo
 0997            if (!isTransactionColumn) continue;
 998
 0999            if (int.TryParse(h.NewValue, out var newId) && newId > 0) ids.Add(newId);
 01000            if (int.TryParse(h.OldValue, out var oldId) && oldId > 0) ids.Add(oldId);
 1001        }
 1002
 01003        return ids.ToList();
 1004    }
 1005
 1006  [HttpGet]
 1007  [PageAuthorize("Studio2.OrderHistory.ExportBitrixSyncHistoryExcel")]
 1008  public async Task<IActionResult> ExportBitrixSyncHistoryExcel(int orderId, CancellationToken cancellationToken = defau
 1009  {
 01010      var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 01011      if (order == null)
 01012          return NotFound();
 1013
 01014      if (string.IsNullOrWhiteSpace(order.Article))
 01015          return NotFound();
 1016
 01017      var (bytes, filename) = await BitrixStockSyncReportExcelBuilder.BuildArticleHistoryAsync(
 01018          _bitrixSyncArticleHistoryService,
 01019          order.Article.Trim(),
 01020          cancellationToken);
 1021
 01022      if (bytes == null || bytes.Length == 0)
 01023          return NotFound();
 1024
 01025      return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename);
 01026  }
 1027
 1028  /// <summary>Вкладка «Фид»: цена и остатки артикула заказа в фиде и история цены по прогонам.</summary>
 1029  [HttpGet]
 1030  [PageAuthorize("Studio2.OrderHistory.GetOrderHistory")]
 1031  public async Task<IActionResult> GetFeedPriceStock(int orderId, CancellationToken cancellationToken = default)
 1032  {
 01033      var result = await _feedPriceService.GetAsync(orderId, User, cancellationToken);
 01034      return result is null ? NotFound() : Json(result);
 01035  }
 1036
 1037  /// <summary>Вкладка «Фид»: как получилась цена позиции заказа (расшифровка по сборкам кабинетов, выбранная — по клиен
 1038  [HttpGet]
 1039  [PageAuthorize("Studio2.OrderHistory.GetOrderHistory")]
 1040  public async Task<IActionResult> GetFeedPriceExplanation(int orderId, long? runId, CancellationToken cancellationToken
 1041  {
 01042      if (runId is <= 0) return BadRequest(new { message = "runId: положительное целое число." });
 1043
 01044      var result = await _feedPriceService.GetExplanationAsync(orderId, User, runId, cancellationToken);
 01045      return result is null ? NotFound() : Json(result);
 01046  }
 1047
 1048  /// <summary>
 1049  /// Превью восстановления заказа к точке во времени (момент операции в истории).
 1050  /// Возвращает список полей, которые изменятся: текущее → целевое значение.
 1051  /// </summary>
 1052  [HttpGet]
 1053  [Authorize(Roles = "Admin")]
 1054  [PageAuthorize("Studio2.OrderHistory.GetRollbackPreview", AccessType.View)]
 1055  public async Task<IActionResult> GetRollbackPreview(int orderId, long pointTicks, CancellationToken cancellationToken 
 1056  {
 01057      var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 01058      if (order == null)
 01059          return Forbid();
 1060
 01061      if (pointTicks <= 0 || pointTicks > DateTime.MaxValue.Ticks)
 01062          return BadRequest("Некорректная точка восстановления.");
 1063
 01064      var point = new DateTime(pointTicks);
 01065      var preview = await _rollbackService.BuildPreviewAsync(orderId, point, cancellationToken);
 01066      if (!preview.OrderFound)
 01067          return NotFound();
 1068
 01069      return Json(new
 01070      {
 01071          orderId = preview.OrderId,
 01072          pointLabel = point.ToString("dd.MM.yyyy HH:mm:ss"),
 01073          hasChanges = preview.HasChanges,
 01074          applicableCount = preview.ApplicableCount,
 1075          changes = preview.Changes.Select(c => new
 1076          {
 1077              field = c.DisplayName,
 1078              current = c.CurrentDisplay,
 1079              target = c.TargetDisplay,
 1080              applicable = c.Applicable,
 1081              warning = c.Warning
 1082          })
 01083      });
 01084  }
 1085
 1086  /// <summary>
 1087  /// Применяет восстановление заказа к выбранной точке. Изменения попадают в историю
 1088  /// автоматически (триггер) и помечаются как <c>rollback:&lt;пользователь&gt;</c>.
 1089  /// </summary>
 1090  [HttpPost]
 1091  [ValidateAntiForgeryToken]
 1092  [Authorize(Roles = "Admin")]
 1093  [PageAuthorize("Studio2.OrderHistory.ApplyRollback", AccessType.Edit)]
 1094  public async Task<IActionResult> ApplyRollback(int orderId, long pointTicks, CancellationToken cancellationToken = def
 1095  {
 01096      var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 01097      if (order == null)
 01098          return Forbid();
 1099
 01100      if (pointTicks <= 0 || pointTicks > DateTime.MaxValue.Ticks)
 01101          return BadRequest("Некорректная точка восстановления.");
 1102
 01103      var point = new DateTime(pointTicks);
 01104      var marker = $"rollback:{User?.Identity?.Name ?? "unknown"}";
 01105      var result = await _rollbackService.ApplyAsync(orderId, point, marker, cancellationToken);
 1106
 01107      return Json(new
 01108      {
 01109          success = result.Success,
 01110          applied = result.AppliedCount,
 01111          message = result.Message
 01112      });
 01113  }
 1114
 1115  private async Task<Order?> GetAccessibleOrderAsync(int orderId, CancellationToken cancellationToken)
 1116  {
 01117      var query = await BuildAccessibleOrdersQueryAsync(o => o.Id == orderId, cancellationToken);
 01118      return query is null ? null : await query.FirstOrDefaultAsync(cancellationToken);
 01119  }
 1120
 1121  /// <summary>Заказы, доступные пользователю по data scope и разрешённым складам. Null — пользователь не определён.</su
 1122  private async Task<IQueryable<Order>?> BuildAccessibleOrdersQueryAsync(
 1123      System.Linq.Expressions.Expression<Func<Order, bool>> predicate, CancellationToken cancellationToken)
 1124  {
 01125      var userId = User?.FindFirstValue(ClaimTypes.NameIdentifier);
 01126      if (string.IsNullOrWhiteSpace(userId))
 01127          return null;
 1128
 01129      var query = _context.Orders.AsNoTracking().Where(predicate);
 01130      query = await _dataScopeService.ApplyDataScopeAsync(query, userId, "Order", null, cancellationToken);
 1131
 01132      var allowed = await _scopeFilterValuesService.GetAllowedValuesAsync(userId, "Order", cancellationToken);
 01133      if (allowed.TryGetValue("ShipmentWarehouseId", out var allowedWarehouseIdsRaw) &&
 01134          allowedWarehouseIdsRaw.Count > 0)
 1135      {
 01136          var allowedWarehouseIds = allowedWarehouseIdsRaw
 1137              .Select(x => int.TryParse(x, out var id) ? id : (int?)null)
 1138              .Where(x => x.HasValue)
 1139              .Select(x => x!.Value)
 01140              .Distinct()
 01141              .ToArray();
 1142
 01143          if (allowedWarehouseIds.Length > 0)
 1144          {
 01145              query = query.Where(o =>
 01146                  o.ShipmentWarehouseId.HasValue &&
 01147                  allowedWarehouseIds.Contains(o.ShipmentWarehouseId.Value));
 1148          }
 1149      }
 1150
 01151      return query;
 01152  }
 1153
 1154    private async Task<Dictionary<string, OrderHistoryUserProfileViewModel>> BuildHistoryUserProfilesAsync(List<OrderHis
 1155    {
 01156        var result = new Dictionary<string, OrderHistoryUserProfileViewModel>(StringComparer.OrdinalIgnoreCase);
 1157
 01158        var displayLogins = histories
 1159            .Select(h => h.ChangedBy?.Trim())
 1160            .Where(v => !string.IsNullOrWhiteSpace(v))
 1161            .Select(v => v!.StartsWith("rollback:", StringComparison.OrdinalIgnoreCase) ? v["rollback:".Length..].Trim()
 1162            .Where(v => !string.IsNullOrWhiteSpace(v))
 01163            .Distinct(StringComparer.OrdinalIgnoreCase)
 01164            .ToList();
 1165
 01166        if (displayLogins.Count == 0)
 1167        {
 01168            return result;
 1169        }
 1170
 1171        // Identity сравнивает по NormalizedUserName/NormalizedEmail (не по сырым полям) —
 1172        // повторяем ту же нормализацию, иначе батч-запрос может не найти то, что находил
 1173        // последовательный FindByNameAsync/FindByEmailAsync.
 01174        var normalizedNames = displayLogins.Select(v => _userManager.NormalizeName(v!)).ToList();
 01175        var normalizedEmails = displayLogins.Select(v => _userManager.NormalizeEmail(v!)).ToList();
 1176
 01177        var users = await _userManager.Users
 01178            .Where(u => normalizedNames.Contains(u.NormalizedUserName!) || normalizedEmails.Contains(u.NormalizedEmail!)
 01179            .ToListAsync()
 01180            .ConfigureAwait(false);
 1181
 01182        foreach (var displayLogin in displayLogins)
 1183        {
 01184            if (result.ContainsKey(displayLogin!))
 1185            {
 1186                continue;
 1187            }
 1188
 01189            var normalizedName = _userManager.NormalizeName(displayLogin!);
 01190            var normalizedEmail = _userManager.NormalizeEmail(displayLogin!);
 01191            var user = users.FirstOrDefault(u =>
 01192                string.Equals(u.NormalizedUserName, normalizedName, StringComparison.Ordinal) ||
 01193                string.Equals(u.NormalizedEmail, normalizedEmail, StringComparison.Ordinal));
 01194            if (user == null)
 1195            {
 1196                continue;
 1197            }
 1198
 01199            result[displayLogin!] = new OrderHistoryUserProfileViewModel
 01200            {
 01201                LoginOrEmail = user.Email ?? user.UserName ?? displayLogin!,
 01202                DisplayName = user.GoogleDisplayName ?? user.UserName ?? user.Email ?? displayLogin!,
 01203                AvatarUrl = user.GoogleAvatarUrl
 01204            };
 1205        }
 1206
 01207        return result;
 01208    }
 1209}

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OrderHistoryController.Grids.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Mvc;
 2using Microsoft.EntityFrameworkCore;
 3using OzonDomains.Models;
 4using OzonOrdersWeb.Authorization;
 5using OzonOrdersWeb.WebServices.Grid;
 6using OzonOrdersWeb.WebServices.OrderHistory;
 7using Services.BitrixStockServices;
 8
 9/// <summary>
 10/// Гриды AG Grid вкладок истории заказа: история проценок (серверные гриды поверх <see cref="ServerGridQuery"/>)
 11/// и строки синхронизаций BitrixStock (JSON вместо HTML-партиала).
 12/// </summary>
 13public partial class OrderHistoryController
 14{
 15    private const string OrderHistoryPageCode = "Studio2.OrderHistory.GetOrderHistory";
 16
 17    /// <summary>Предложения поставщиков по товару заказа: колонки — colId грида «Проценки».</summary>
 018    private static readonly ServerGridDefinition<SupplierQuotationOffer> QuotationOffersGrid =
 019        new ServerGridDefinition<SupplierQuotationOffer>
 020            {
 021                DefaultOrder = q => q.OrderByDescending(x => x.Request.RequestedAtUtc).ThenBy(x => x.Price),
 022                TieBreaker = x => x.Id
 023            }
 024            .Col("requestedAtUtc", x => x.Request.RequestedAtUtc)
 025            .Col("providerKey", x => x.Request.ProviderKey)
 026            .Col("orderId", x => x.Request.OrderId)
 027            .Col("caption", x => x.Caption)
 028            .Col("direction", x => x.Direction)
 029            .Col("producer", x => x.Producer)
 030            .Col("rest", x => x.Rest)
 031            .Col("sourcePrice", x => x.SourcePrice)
 032            .Col("price", x => x.Price)
 033            .Col("costPrice", x => x.CostPriceFormatted ?? x.CostPrice)
 034            .Col("deliveryDaysMin", x => x.DeliveryDaysMin)
 035            .Col("deliveryDaysMax", x => x.DeliveryDaysMax)
 036            .Search(x => x.Caption, x => x.Direction, x => x.Producer, x => x.Code);
 37
 38    /// <summary>Неуспешные запросы проценки по товару заказа.</summary>
 039    private static readonly ServerGridDefinition<SupplierQuotationRequest> QuotationFailedGrid =
 040        new ServerGridDefinition<SupplierQuotationRequest>
 041            {
 042                DefaultOrder = q => q.OrderByDescending(x => x.RequestedAtUtc),
 043                TieBreaker = x => x.Id
 044            }
 045            .Col("requestedAtUtc", x => x.RequestedAtUtc)
 046            .Col("providerKey", x => x.ProviderKey)
 047            .Col("article", x => x.Article)
 048            .Col("brand", x => x.Brand)
 049            .Col("errorMessage", x => x.ErrorMessage)
 050            .Col("durationMs", x => x.DurationMs)
 051            .Col("orderId", x => x.OrderId)
 052            .Search(x => x.Article, x => x.Brand, x => x.ErrorMessage);
 53
 54    [HttpPost("Studio2/OrderHistory/quotations/{kind}/grid")]
 55    [ValidateAntiForgeryToken]
 56    [PageAuthorize(OrderHistoryPageCode)]
 57    public async Task<IActionResult> QuotationGridRows(string kind, int orderId, [FromBody] ServerGridRequest? request, 
 58    {
 059        var error = ServerGridQuery.Validate(request);
 060        if (error != null) return BadRequest(new { success = false, error });
 061        var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 062        if (order?.ProductInfoId is not int productId) return Ok(new { success = true, rows = Array.Empty<object>(), tot
 63
 064        var start = Math.Max(0, request!.StartRow);
 065        var take = Math.Clamp(request.EndRow - start, 1, ServerGridQuery.MaxBlockSize);
 066        if (kind == "failed")
 67        {
 068            var filtered = ServerGridQuery.ApplyFilters(FailedQuotationRequests(productId), QuotationFailedGrid, request
 069            var total = await filtered.CountAsync(cancellationToken);
 070            var rows = start >= total
 071                ? []
 072                : await ServerGridQuery.ApplySort(filtered, QuotationFailedGrid, request.SortModel).Skip(start).Take(tak
 073                    .Select(x => new
 074                    {
 075                        x.Id, x.RequestedAtUtc, x.DurationMs, x.OrderId, x.ProviderKey,
 076                        SupplierName = x.Supplier != null ? x.Supplier.Name : null, x.Article, x.Brand, x.ErrorMessage
 077                    })
 078                    .ToListAsync(cancellationToken);
 079            return Ok(new { success = true, rows, totalCount = total, lastRow = start + rows.Count >= total ? total : -1
 80        }
 81        else
 82        {
 083            var filtered = ServerGridQuery.ApplyFilters(QuotationOffers(productId), QuotationOffersGrid, request);
 084            var total = await filtered.CountAsync(cancellationToken);
 085            var rows = start >= total
 086                ? []
 087                : await ServerGridQuery.ApplySort(filtered, QuotationOffersGrid, request.SortModel).Skip(start).Take(tak
 088                    .Select(x => new
 089                    {
 090                        x.Id, x.Request.RequestedAtUtc, x.Request.DurationMs, x.Request.OrderId, x.Request.ProviderKey,
 091                        SupplierName = x.Request.Supplier != null ? x.Request.Supplier.Name : null,
 092                        x.Caption, x.Description, x.PriceDescription, x.Direction, x.Producer, x.Rest,
 093                        x.SourcePrice, x.SourceCurrencyCode, x.Price, CostPrice = x.CostPriceFormatted ?? x.CostPrice,
 094                        x.DeliveryDaysMin, x.DeliveryDaysMax, x.ExtraDeliveryHours
 095                    })
 096                    .ToListAsync(cancellationToken);
 097            return Ok(new { success = true, rows, totalCount = total, lastRow = start + rows.Count >= total ? total : -1
 98        }
 099    }
 100
 101    [HttpPost("Studio2/OrderHistory/quotations/{kind}/facets")]
 102    [ValidateAntiForgeryToken]
 103    [PageAuthorize(OrderHistoryPageCode)]
 104    public async Task<IActionResult> QuotationGridFacets(string kind, int orderId, [FromBody] ServerGridRequest? request
 105    {
 0106        if (request is null || string.IsNullOrWhiteSpace(request.ColId)) return BadRequest(new { success = false, error 
 0107        request.StartRow = 0;
 0108        request.EndRow = 1;
 0109        var error = ServerGridQuery.Validate(request);
 0110        if (error != null) return BadRequest(new { success = false, error });
 0111        var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 0112        if (order?.ProductInfoId is not int productId) return Ok(new { success = true, values = Array.Empty<object>() })
 113
 0114        var values = kind == "failed"
 0115            ? await ServerGridQuery.FacetsAsync(FailedQuotationRequests(productId), QuotationFailedGrid, request, cancel
 0116            : await ServerGridQuery.FacetsAsync(QuotationOffers(productId), QuotationOffersGrid, request, cancellationTo
 0117        return Ok(new { success = true, values });
 0118    }
 119
 120    private IQueryable<SupplierQuotationOffer> QuotationOffers(int productId) =>
 0121        _context.SupplierQuotationOffers.AsNoTracking().Where(x => x.Request.ProductId == productId);
 122
 123    private IQueryable<SupplierQuotationRequest> FailedQuotationRequests(int productId) =>
 0124        _context.SupplierQuotationRequests.AsNoTracking().Where(x => x.ProductId == productId && x.Status == "Failed");
 125
 126    /// <summary>
 127    /// История синхронизаций BitrixStock по артикулу заказа — плоские строки для грида (отчёт — колонками строки).
 128    /// Подписи и CSS-классы считает <see cref="BitrixStockSyncReportRowPresenter"/>, как раньше партиал.
 129    /// </summary>
 130    [HttpGet]
 131    [PageAuthorize("Studio2.OrderHistory.GetBitrixSyncHistory")]
 132    public async Task<IActionResult> GetBitrixSyncHistory(int orderId, int page = 1, CancellationToken cancellationToken
 133    {
 0134        var order = await GetAccessibleOrderAsync(orderId, cancellationToken);
 0135        if (order == null)
 0136            return NotFound();
 137
 0138        if (string.IsNullOrWhiteSpace(order.Article))
 0139            return Ok(new { rows = Array.Empty<object>(), hasMore = false, page, hasPrices = false, hasPushes = false, t
 140
 0141        var pageDto = await _bitrixSyncArticleHistoryService.GetPageAsync(order.Article.Trim(), page, pageSize: 20, canc
 0142        var rows = pageDto.Reports.SelectMany(report =>
 0143        {
 144            var clientLabel = report.IsMultiClient
 145                ? string.Join(", ", report.MultiClientNames.Values.DefaultIfEmpty(report.OzonClientName).Distinct())
 146                : report.OzonClientName;
 147            return report.Rows.Select((row, index) =>
 148            {
 149                var w = row.Line;
 150                var pi = row.Price;
 151                var deltaItog = BitrixStockSyncReportRowPresenter.FormatDeltaItog(w, out var deltaCss);
 152                return new
 153                {
 154                    id = $"{report.ReportId}:{index}",
 155                    reportId = report.ReportId,
 156                    generatedAtUtc = report.GeneratedAtUtc,
 157                    generatedAtMsk = BitrixStockMskDateTime.Format(report.GeneratedAtUtc),
 158                    client = clientLabel,
 159                    article = w.Article,
 160                    manufacturer = w.ManufacturerName,
 161                    oneCPrice = w.OneCPrice,
 162                    deltaC0 = BitrixStockSyncReportRowPresenter.FormatDeltaC0(w.OneCPrice, w.PrevOneCPrice),
 163                    deltaC0Css = BitrixStockSyncReportRowPresenter.DeltaC0CssClass(w.OneCPrice, w.PrevOneCPrice),
 164                    truePrice = pi?.TruePrice,
 165                    sellingPrice = pi?.SellingPrice,
 166                    priceIndex = string.IsNullOrEmpty(pi?.ColorIndex) ? null : BitrixStockSyncReportRowPresenter.FormatP
 167                    priceIndexCss = string.IsNullOrEmpty(pi?.ColorIndex) ? null : BitrixStockSyncReportRowPresenter.Pric
 168                    pushedOld = pi?.PricePushedOld,
 169                    pushedNew = BitrixStockSyncReportRowPresenter.FormatPushedNew(pi),
 170                    pushedNewCss = BitrixStockSyncReportRowPresenter.PushedNewCssClass(pi),
 171                    competitorPrice = pi?.PushedCompetitorPrice,
 172                    indexBefore = string.IsNullOrEmpty(pi?.PushedColorIndexBefore) ? null : BitrixStockSyncReportRowPres
 173                    indexBeforeCss = string.IsNullOrEmpty(pi?.PushedColorIndexBefore) ? null : BitrixStockSyncReportRowP
 174                    indexAfter = string.IsNullOrEmpty(pi?.PushedColorIndexAfter) ? null : BitrixStockSyncReportRowPresen
 175                    indexAfterCss = string.IsNullOrEmpty(pi?.PushedColorIndexAfter) ? null : BitrixStockSyncReportRowPre
 176                    markupPct = pi?.PushedMarkupPct,
 177                    pushedAtMsk = pi?.PricePushedAtUtc is { } pushedAt ? BitrixStockMskDateTime.Format(pushedAt) : null,
 178                    oneCStore = w.OneCStore,
 179                    ozonWarehouse = w.OzonWarehouseName,
 180                    oneCQty = w.OneCQty,
 181                    ozonQty = w.OzonQty,
 182                    targetQty = BitrixStockSyncReportRowPresenter.GetTargetQty(w),
 183                    ozonQtyAfter = w.OzonQtyAfter,
 184                    deltaItog,
 185                    deltaItogCss = deltaCss,
 186                    actionIcon = w.ActionIcon,
 187                    actionLabel = w.ActionLabel,
 188                    rowClass = w.RowClass,
 189                    applyStatus = BitrixStockSyncReportRowPresenter.FormatOzonApplyStatus(w),
 190                    cleanupStatus = BitrixStockSyncReportRowPresenter.FormatOzonCleanupStatus(w),
 191                    note = w.Note
 192                };
 193            });
 0194        }).ToList();
 195
 0196        return Ok(new
 0197        {
 0198            rows,
 0199            hasMore = pageDto.HasMore,
 0200            page = pageDto.Page,
 0201            hasPrices = pageDto.HasPrices,
 0202            hasPushes = pageDto.HasPushes,
 0203            totalReports = pageDto.TotalReportCount
 0204        });
 0205    }
 206}

Methods/Properties

.cctor()
.ctor(IOrderHistoryDataService,Services.DataServices.IOrderHistoryOneCPostingService,Services.DataServices.OrdersDataServices,Services.DataServices.TransactionDataServices,Services.ApiServices.OzonApi.OzonApiDataManager,Services.DataServices.OzonClientServices,Microsoft.AspNetCore.SignalR.IHubContext`1<Services.SignalRServices.OrderHistoryHub>,Microsoft.Extensions.Caching.Memory.IMemoryCache,Microsoft.Extensions.DependencyInjection.IServiceScopeFactory,Services.BitrixStockServices.IBitrixStockSyncArticleHistoryService,OzonOrdersWeb.WebServices.Orders.Grid.IOrdersGridFeedPriceService,OzonRepositories.Context.ApplicationDbContext,Services.Authorization.Services.IDataScopeService,Services.Authorization.Services.IScopeFilterValuesService,Services.DataServices.OrderRollback.IOrderHistoryRollbackService,Microsoft.AspNetCore.Identity.UserManager`1<OzonRepositories.Context.Identity.CustomIdentityUser>,Services.OzonHandoverServices.IOzonHandoverPhotoStorage)
Index()
GetRecentChanges()
GetHandoverPhoto()
GetOrderHistory()
LoadOrderProblemsAsync()
LoadSupplierStatusTimelineAsync()
MapCalculationSnapshot(OzonDomains.Models.OrderCalculationSnapshot)
Number()
NullableNumber()
GetCalculationSnapshot()
GetSupplierQuotationHistorySummary()
LoadTransactionsInScopeAsync()
LoadShipmentTotalsHistoryInScopeAsync()
LoadOneCPostingsInScopeAsync()
LoadHandoverItemsInScopeAsync()
LoadLabelPrintsInScopeAsync()
GetCreatedNomenclature(System.String)
GetShipmentTotals()
GetShipmentTotalsBatch(System.String)
FormatDate(System.String)
FormatDateTime(System.String)
BuildOperationsDto(System.Collections.Generic.List`1<Services.ApiServices.OzonApi.Filters.FinanceTransactionOperation>)
BuildTotalsDto(Services.ApiServices.OzonApi.Filters.FinanceTransactionTotalsResult)
EnqueueSaveShipmentTotalsToDb(System.Int32,System.String,System.Double,System.Object,Services.ApiServices.OzonApi.Filters.FinanceTransactionTotalsResult,System.Int32)
EnqueueSaveShipmentTotalsToDbFromCachedPayload(System.Int32,System.Object)
SaveShipmentTotalsToDbAsync()
SaveShipmentTotalsToDbFromCachedPayloadAsync()
SafeTotal()
ResolveOzonClientAsync()
LoadFinanceAccrualSummaryInScopeAsync()
LoadFinanceAccrualSummaryAsync()
CollectTransactionIds(OzonDomains.Models.Order,System.Collections.Generic.List`1<OzonDomains.Models.OrderHistory>)
ExportBitrixSyncHistoryExcel()
GetFeedPriceStock()
GetFeedPriceExplanation()
GetRollbackPreview()
ApplyRollback()
GetAccessibleOrderAsync()
BuildAccessibleOrdersQueryAsync()
BuildHistoryUserProfilesAsync()
.cctor()
QuotationGridRows()
QuotationGridFacets()
QuotationOffers(System.Int32)
FailedQuotationRequests(System.Int32)
GetBitrixSyncHistory()