< Summary

Information
Class: OzonRepositories.Data.Bitrix.BitrixStockRepository
Assembly: OzonRepositories
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/Bitrix/BitrixStockRepository.cs
Line coverage
3%
Covered lines: 16
Uncovered lines: 506
Coverable lines: 522
Total lines: 871
Line coverage: 3%
Branch coverage
0%
Covered branches: 2
Total branches: 254
Branch coverage: 0.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/Bitrix/BitrixStockRepository.cs

#LineLine coverage
 1using System.Linq.Expressions;
 2using System.Diagnostics;
 3using Microsoft.EntityFrameworkCore;
 4using MySqlConnector;
 5using Newtonsoft.Json;
 6using OzonDomains.Models;
 7using OzonDomains.Models.BitrixModels;
 8using OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix;
 9using OzonRepositories.Context;
 10using OzonRepositories.Services;
 11using Microsoft.Extensions.Caching.Memory;
 12using System.Data;
 13using JsonSerializer = System.Text.Json.JsonSerializer;
 14
 15namespace OzonRepositories.Data.Bitrix;
 16
 17public class BitrixStockRepository
 18{
 19    private readonly BitrixContext _context;
 20    private readonly SupplierRepository _supplierRepository;
 21    private readonly IBitrixConnectionService _connectionService;
 22    private readonly IMemoryCache _memoryCache;
 23    private const string DebugLogPath = "/Users/evgen/RiderProjects/Studio2Prod/.cursor/debug-8edffb.log";
 24
 125    public BitrixStockRepository(BitrixContext context,
 126        SupplierRepository supplierRepository,
 127        IBitrixConnectionService connectionService,
 128        IMemoryCache memoryCache)
 29    {
 130        _context = context;
 131        _supplierRepository = supplierRepository;
 132        _connectionService = connectionService;
 133        _memoryCache = memoryCache;
 134    }
 35
 36    public async Task<PagedResult<RemainingStockBitrix>> GetRemainingStockAsync(RemainingStockFilter filter)
 37    {
 038        var totalSw = Stopwatch.StartNew();
 39        // Проверяем доступность соединения с Bitrix (с кэшированием)
 040        if (!await _connectionService.IsConnectionAvailableAsync())
 41        {
 42            // Возвращаем пустой результат вместо падения страницы
 043            return new PagedResult<RemainingStockBitrix>
 044            {
 045                Items = new List<RemainingStockBitrix>(),
 046                TotalCount = 0,
 047                Page = filter.Page,
 048                PageSize = filter.PageSize
 049            };
 50        }
 51
 52        try
 53        {
 054            var sqlParameters = new List<MySqlParameter>();
 055            var sqlWhereClauses = new List<string>();
 56
 057            if (filter.ProductId.HasValue)
 58            {
 059                sqlWhereClauses.Add("e.ID = @filterProductId");
 060                sqlParameters.Add(new MySqlParameter("@filterProductId", filter.ProductId.Value));
 61            }
 62
 063            if (!string.IsNullOrWhiteSpace(filter.Article))
 64            {
 065                sqlWhereClauses.Add("p.VALUE LIKE @filterArticle");
 066                sqlParameters.Add(new MySqlParameter("@filterArticle", $"%{filter.Article}%"));
 67            }
 68
 069            if (filter.SelectedStoreTitles != null && filter.SelectedStoreTitles.Any())
 70            {
 071                var storeParameterNames = new List<string>();
 072                for (var i = 0; i < filter.SelectedStoreTitles.Count; i++)
 73                {
 074                    var parameterName = $"@storeTitle{i}";
 075                    storeParameterNames.Add(parameterName);
 076                    sqlParameters.Add(new MySqlParameter(parameterName, filter.SelectedStoreTitles[i]));
 77                }
 78
 079                sqlWhereClauses.Add($"s.TITLE IN ({string.Join(",", storeParameterNames)})");
 80            }
 81
 082            var dynamicWhereSql = sqlWhereClauses.Any()
 083                ? " AND " + string.Join(" AND ", sqlWhereClauses)
 084                : string.Empty;
 85
 86            // ==== SQL-запрос плоской выборки ====
 087            var sql = $@"
 088                SELECT
 089                    CAST(e.ID AS SIGNED) AS ProductId,
 090                    MAX(p.VALUE) AS Article,
 091                    e.PREVIEW_TEXT AS PreviewText,
 092                    e.ACTIVE AS Active,
 093                    e.TIMESTAMP_X AS TimestampX,
 094                    c.QUANTITY AS Quantity,
 095                    CAST(c.AVAILABLE AS CHAR) AS Available,
 096                    supplier_enum.VALUE AS Supplier,
 097                    price.PRICE AS Price,
 098                    sp.STORE_ID AS StoreId,
 099                    s.TITLE AS StoreTitle,
 0100                    sp.AMOUNT AS Amount
 0101                FROM b_iblock_element e
 0102                LEFT JOIN b_iblock_element_property p
 0103                    ON e.ID = p.IBLOCK_ELEMENT_ID AND p.DESCRIPTION = 'Артикул'
 0104                LEFT JOIN b_catalog_product c
 0105                    ON c.ID = e.ID
 0106                LEFT JOIN b_iblock_element_property prop_supplier
 0107                    ON e.ID = prop_supplier.IBLOCK_ELEMENT_ID AND prop_supplier.IBLOCK_PROPERTY_ID = 2458
 0108                LEFT JOIN b_iblock_property_enum supplier_enum
 0109                    ON prop_supplier.VALUE = supplier_enum.ID AND supplier_enum.PROPERTY_ID = 2458
 0110                LEFT JOIN b_catalog_price price
 0111                    ON price.PRODUCT_ID = e.ID AND price.CATALOG_GROUP_ID = 3
 0112                LEFT JOIN b_catalog_store_product sp
 0113                    ON sp.PRODUCT_ID = e.ID
 0114                LEFT JOIN b_catalog_store s
 0115                    ON sp.STORE_ID = s.ID
 0116                WHERE e.IBLOCK_ID = 93
 0117                  AND sp.AMOUNT >= 0
 0118                  {dynamicWhereSql}
 0119                GROUP BY e.ID, e.PREVIEW_TEXT, e.ACTIVE, e.TIMESTAMP_X,
 0120                         c.QUANTITY, c.AVAILABLE, supplier_enum.VALUE, price.PRICE,
 0121                         sp.STORE_ID, s.TITLE, sp.AMOUNT
 0122            ";
 123
 124
 0125            var flatQuery = _context.Set<RemainingStockFlat>().FromSqlRaw(sql, sqlParameters.ToArray()).AsNoTracking().A
 126
 127        // ==== Применяем фильтры ====
 0128        if (filter.ProductId.HasValue)
 0129            flatQuery = flatQuery.Where(x => x.ProductId == filter.ProductId.Value);
 130
 0131        if (!string.IsNullOrWhiteSpace(filter.Article))
 0132            flatQuery = flatQuery.Where(x => x.Article != null && x.Article.Contains(filter.Article));
 133
 0134        if (!string.IsNullOrWhiteSpace(filter.PreviewText))
 0135            flatQuery = flatQuery.Where(x => x.PreviewText != null && x.PreviewText.Contains(filter.PreviewText));
 136
 0137        if (!string.IsNullOrWhiteSpace(filter.Active))
 0138            flatQuery = flatQuery.Where(x => x.Active == filter.Active);
 139
 0140        if (!string.IsNullOrWhiteSpace(filter.Available))
 0141            flatQuery = flatQuery.Where(x => x.Available == filter.Available);
 142
 0143        if (!string.IsNullOrWhiteSpace(filter.Supplier))
 0144            flatQuery = flatQuery.Where(x => x.Supplier != null && x.Supplier.Contains(filter.Supplier));
 145
 0146        if (filter.QuantityFrom.HasValue)
 0147            flatQuery = flatQuery.Where(x => x.Quantity >= filter.QuantityFrom.Value);
 148
 0149        if (filter.QuantityTo.HasValue)
 0150            flatQuery = flatQuery.Where(x => x.Quantity <= filter.QuantityTo.Value);
 151
 0152        if (filter.DateFrom.HasValue)
 0153            flatQuery = flatQuery.Where(x => x.TimestampX >= filter.DateFrom.Value);
 154
 0155        if (filter.DateTo.HasValue)
 0156            flatQuery = flatQuery.Where(x => x.TimestampX < filter.DateTo.Value.AddDays(1));
 157
 0158        if (filter.PriceValueFrom.HasValue)
 0159            flatQuery = flatQuery.Where(x => x.Price >= (double)filter.PriceValueFrom.Value);
 160
 0161        if (filter.PriceValueTo.HasValue)
 0162            flatQuery = flatQuery.Where(x => x.Price <= (double)filter.PriceValueTo.Value);
 163
 0164        if (filter.StoreId.HasValue)
 0165            flatQuery = flatQuery.Where(x => x.StoreId == filter.StoreId.Value);
 166
 0167        if (filter.SelectedStoreTitles != null && filter.SelectedStoreTitles.Any())
 0168            flatQuery = flatQuery.Where(x => filter.SelectedStoreTitles.Contains(x.StoreTitle));
 0169        else if (!string.IsNullOrWhiteSpace(filter.StoreTitle))
 0170            flatQuery = flatQuery.Where(x => x.StoreTitle.Contains(filter.StoreTitle));
 171
 0172        if (filter.AmountFrom.HasValue)
 0173            flatQuery = flatQuery.Where(x => x.Amount >= filter.AmountFrom.Value);
 174
 0175        if (filter.AmountTo.HasValue)
 0176            flatQuery = flatQuery.Where(x => x.Amount <= filter.AmountTo.Value);
 177
 0178        var useSinglePassStrategy = filter.ProductId.HasValue || !string.IsNullOrWhiteSpace(filter.Article);
 179        // #region agent log
 0180        WriteDebugLog("run-empty-1", "E1", "BitrixStockRepository.GetRemainingStockAsync:strategy", "Strategy chosen", n
 0181        {
 0182            useSinglePassStrategy,
 0183            hasArticle = !string.IsNullOrWhiteSpace(filter.Article),
 0184            hasProductId = filter.ProductId.HasValue,
 0185            selectedStoresCount = filter.SelectedStoreTitles?.Count ?? 0
 0186        });
 187        // #endregion
 0188        if (useSinglePassStrategy)
 189        {
 0190            var flatItems = await flatQuery.ToListAsync();
 0191            var groupedItems = flatItems
 0192                .GroupBy(f => f.ProductId)
 0193                .Select(g => new RemainingStockBitrix
 0194                {
 0195                    ProductId = g.Key,
 0196                    Article = g.First().Article,
 0197                    PreviewText = g.First().PreviewText,
 0198                    Active = g.First().Active,
 0199                    Available = g.First().Available,
 0200                    Supplier = g.First().Supplier,
 0201                    TimestampX = g.First().TimestampX,
 0202                    Quantity = g.First().Quantity,
 0203                    Price = g.First().Price,
 0204                    Stores = g.Select(s => new StockByStore
 0205                    {
 0206                        StoreId = s.StoreId,
 0207                        Title = s.StoreTitle,
 0208                        Amount = s.Amount
 0209                    }).ToList()
 0210                })
 0211                .ToList();
 212
 0213            if (!string.IsNullOrEmpty(filter.SortColumn))
 214            {
 0215                groupedItems = ApplyClientSideSorting(groupedItems, filter.SortColumn, filter.SortDirection);
 216            }
 217            else
 218            {
 0219                groupedItems = groupedItems.OrderBy(x => x.ProductId).ToList();
 220            }
 221
 0222            var totalCountSinglePass = groupedItems.Count;
 0223            var pagedItems = filter.DisablePagination
 0224                ? groupedItems
 0225                : groupedItems
 0226                    .Skip((filter.Page - 1) * filter.PageSize)
 0227                    .Take(filter.PageSize)
 0228                    .ToList();
 229
 0230            return new PagedResult<RemainingStockBitrix>
 0231            {
 0232                Items = pagedItems,
 0233                TotalCount = totalCountSinglePass,
 0234                Page = filter.Page,
 0235                PageSize = filter.PageSize
 0236            };
 237        }
 238
 239        // ==== Получаем ID уникальных товаров с правильной сортировкой ====
 240        // Группируем один раз и применяем сортировку к одному IQueryable.
 0241        var grouped = flatQuery.GroupBy(f => f.ProductId);
 0242        var desc = string.Equals(filter.SortDirection, "desc", StringComparison.OrdinalIgnoreCase);
 243
 0244        IQueryable<int> productIdsQuery = (filter.SortColumn?.ToLowerInvariant()) switch
 0245        {
 0246            "article"     => desc ? grouped.OrderByDescending(g => g.Max(f => f.Article)).Select(g => g.Key)
 0247                                  : grouped.OrderBy(g => g.Max(f => f.Article)).Select(g => g.Key),
 0248            "previewtext" => desc ? grouped.OrderByDescending(g => g.Max(f => f.PreviewText)).Select(g => g.Key)
 0249                                  : grouped.OrderBy(g => g.Max(f => f.PreviewText)).Select(g => g.Key),
 0250            "supplier"    => desc ? grouped.OrderByDescending(g => g.Max(f => f.Supplier)).Select(g => g.Key)
 0251                                  : grouped.OrderBy(g => g.Max(f => f.Supplier)).Select(g => g.Key),
 0252            "quantity"    => desc ? grouped.OrderByDescending(g => g.Max(f => f.Quantity)).Select(g => g.Key)
 0253                                  : grouped.OrderBy(g => g.Max(f => f.Quantity)).Select(g => g.Key),
 0254            "price"       => desc ? grouped.OrderByDescending(g => g.Max(f => f.Price)).Select(g => g.Key)
 0255                                  : grouped.OrderBy(g => g.Max(f => f.Price)).Select(g => g.Key),
 0256            "timestampx"  => desc ? grouped.OrderByDescending(g => g.Max(f => f.TimestampX)).Select(g => g.Key)
 0257                                  : grouped.OrderBy(g => g.Max(f => f.TimestampX)).Select(g => g.Key),
 0258            _             => desc ? grouped.OrderByDescending(g => g.Key).Select(g => g.Key)
 0259                                  : grouped.OrderBy(g => g.Key).Select(g => g.Key),
 0260        };
 261
 0262        var totalCount = await productIdsQuery.CountAsync();
 263
 0264        var productIdsPage = filter.DisablePagination
 0265            ? await productIdsQuery.ToListAsync()
 0266            : await productIdsQuery
 0267                .Skip((filter.Page - 1) * filter.PageSize)
 0268                .Take(filter.PageSize)
 0269                .ToListAsync();
 270
 271        // ==== Получаем все flat записи для этих товаров ====
 0272        var flatItemsPage = await flatQuery
 0273            .Where(f => productIdsPage.Contains(f.ProductId))
 0274            .ToListAsync();
 275
 276        // ==== Группируем на клиенте в RemainingStockBitrix ====
 0277        var items = flatItemsPage
 0278            .GroupBy(f => f.ProductId)
 0279            .Select(g => new RemainingStockBitrix
 0280            {
 0281                ProductId = g.Key,
 0282                Article = g.First().Article,
 0283                PreviewText = g.First().PreviewText,
 0284                Active = g.First().Active,
 0285                Available = g.First().Available,
 0286                Supplier = g.First().Supplier,
 0287                TimestampX = g.First().TimestampX,
 0288                Quantity = g.First().Quantity,
 0289                Price = g.First().Price,
 0290                Stores = g.Select(s => new StockByStore
 0291                {
 0292                    StoreId = s.StoreId,
 0293                    Title = s.StoreTitle,
 0294                    Amount = s.Amount
 0295                }).ToList()
 0296            })
 0297            .ToList();
 298
 299        // Применяем окончательную сортировку на клиенте для гарантии
 0300        if (!string.IsNullOrEmpty(filter.SortColumn))
 301        {
 0302            items = ApplyClientSideSorting(items, filter.SortColumn, filter.SortDirection);
 303        }
 0304        totalSw.Stop();
 305        // #region agent log
 0306        WriteDebugLog("run-empty-1", "E2", "BitrixStockRepository.GetRemainingStockAsync:non-single-pass", "Non single-p
 0307        {
 0308            filter.Page,
 0309            filter.PageSize,
 0310            totalCount,
 0311            pageIdsCount = productIdsPage.Count,
 0312            flatItemsPageCount = flatItemsPage.Count,
 0313            groupedItemsCount = items.Count,
 0314            totalMs = totalSw.ElapsedMilliseconds
 0315        });
 316        // #endregion
 0317            return new PagedResult<RemainingStockBitrix>
 0318            {
 0319                Items = items,
 0320                TotalCount = totalCount,
 0321                Page = filter.Page,
 0322                PageSize = filter.PageSize
 0323            };
 324        }
 0325        catch (Exception)
 326        {
 327            // При ошибке возвращаем пустой результат вместо падения страницы
 0328            return new PagedResult<RemainingStockBitrix>
 0329            {
 0330                Items = new List<RemainingStockBitrix>(),
 0331                TotalCount = 0,
 0332                Page = filter.Page,
 0333                PageSize = filter.PageSize
 0334            };
 335        }
 0336    }
 337
 338// Вспомогательный метод для сортировки на клиенте
 339    private List<RemainingStockBitrix> ApplyClientSideSorting(
 340        List<RemainingStockBitrix> items,
 341        string sortColumn,
 342        string? sortDirection)
 343    {
 0344        var isDescending = sortDirection?.ToLower() == "desc";
 345
 0346        return sortColumn.ToLower() switch
 0347        {
 0348            "article" => isDescending
 0349                ? items.OrderByDescending(x => x.Article).ToList()
 0350                : items.OrderBy(x => x.Article).ToList(),
 0351            "previewtext" => isDescending
 0352                ? items.OrderByDescending(x => x.PreviewText).ToList()
 0353                : items.OrderBy(x => x.PreviewText).ToList(),
 0354            "supplier" => isDescending
 0355                ? items.OrderByDescending(x => x.Supplier).ToList()
 0356                : items.OrderBy(x => x.Supplier).ToList(),
 0357            "quantity" => isDescending
 0358                ? items.OrderByDescending(x => x.Quantity).ToList()
 0359                : items.OrderBy(x => x.Quantity).ToList(),
 0360            "price" => isDescending
 0361                ? items.OrderByDescending(x => x.Price).ToList()
 0362                : items.OrderBy(x => x.Price).ToList(),
 0363            "timestampx" => isDescending
 0364                ? items.OrderByDescending(x => x.TimestampX).ToList()
 0365                : items.OrderBy(x => x.TimestampX).ToList(),
 0366            _ => isDescending
 0367                ? items.OrderByDescending(x => x.ProductId).ToList()
 0368                : items.OrderBy(x => x.ProductId).ToList()
 0369        };
 370    }
 371
 372    private static void WriteDebugLog(string runId, string hypothesisId, string location, string message, object data)
 373    {
 374        try
 375        {
 0376            var payload = new
 0377            {
 0378                sessionId = "8edffb",
 0379                runId,
 0380                hypothesisId,
 0381                location,
 0382                message,
 0383                data,
 0384                timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
 0385            };
 0386            System.IO.File.AppendAllText(DebugLogPath, JsonSerializer.Serialize(payload) + Environment.NewLine);
 0387        }
 0388        catch
 389        {
 390            // ignore debug logging failures
 0391        }
 0392    }
 393
 394    public async Task<List<string>> GetActiveStoresAsync()
 395    {
 396        const string cacheKey = "Bitrix:ActiveStores";
 0397        if (_memoryCache.TryGetValue(cacheKey, out List<string>? cachedStores) && cachedStores != null)
 398        {
 0399            return cachedStores;
 400        }
 401
 402        // Проверяем доступность соединения с Bitrix (с кэшированием)
 0403        if (!await _connectionService.IsConnectionAvailableAsync())
 404        {
 405            // Возвращаем пустой список вместо падения страницы
 0406            return new List<string>();
 407        }
 408
 409        try
 410        {
 0411            var stores = await _context.Set<BCatalogStore>()
 0412                .Where(s => !string.IsNullOrEmpty(s.Title))
 0413                .Select(s => s.Title!)
 0414                .Distinct()
 0415                .OrderBy(t => t)
 0416                .ToListAsync();
 417
 0418            var result = stores
 0419                .Where(t => !string.IsNullOrEmpty(t))
 0420                .OrderBy(t => t)
 0421                .ToList();
 422
 0423            _memoryCache.Set(cacheKey, result, new MemoryCacheEntryOptions()
 0424                .SetAbsoluteExpiration(TimeSpan.FromMinutes(30))
 0425                .SetSlidingExpiration(TimeSpan.FromMinutes(10)));
 426
 0427            return result;
 428        }
 0429        catch (Exception)
 430        {
 431            // При ошибке возвращаем пустой список вместо падения страницы
 0432            return new List<string>();
 433        }
 0434    }
 435
 436    public async Task SetPricesForOrdersAsyncFromBitrix(List<Order> orders)
 437    {
 438        // Проверяем доступность соединения с Bitrix (с кэшированием)
 0439        if (!await _connectionService.IsConnectionAvailableAsync())
 440        {
 441            // Если соединение недоступно, просто не устанавливаем цены (заглушка)
 0442            return;
 443        }
 444
 445        try
 446        {
 447            // Берем артикулы из ProductKey (до "=")
 0448            var articles = orders
 0449                .Where(o => !string.IsNullOrWhiteSpace(o.ProductKey))
 0450                .Select(o => o.ProductKey!.Split('=')[0].Trim())
 0451                .Where(a => !string.IsNullOrWhiteSpace(a))
 0452                .Distinct()
 0453                .ToList();
 454
 0455            if (!articles.Any())
 456            {
 0457                return;
 458            }
 459
 0460            var sql = @"
 0461                SELECT
 0462                    p.VALUE AS Article,
 0463                    price.PRICE AS Price
 0464                FROM b_iblock_element e
 0465                LEFT JOIN b_iblock_element_property p
 0466                    ON e.ID = p.IBLOCK_ELEMENT_ID AND p.DESCRIPTION = 'Артикул'
 0467                LEFT JOIN b_catalog_price price
 0468                    ON price.PRODUCT_ID = e.ID AND price.CATALOG_GROUP_ID = 3
 0469                WHERE e.IBLOCK_ID = 93
 0470                  AND p.VALUE IN ({0})";
 471
 472            // Подготавливаем параметры для IN
 0473            var parameters = articles.Select((a, i) => new MySqlParameter($"@p{i}", a)).ToArray();
 0474            var inClause = string.Join(",", parameters.Select(p => p.ParameterName));
 0475            var formattedSql = string.Format(sql, inClause);
 476
 477            // Выполняем запрос
 0478            var results = await _context.Database
 0479                .SqlQueryRaw<ArticlePriceResult>(formattedSql, parameters)
 0480                .ToListAsync();
 481
 482            // Словарь "Артикул -> Цена"
 0483            var priceDictionary = results
 0484                .Where(r => !string.IsNullOrEmpty(r.Article))
 0485                .GroupBy(r => r.Article!)
 0486                .ToDictionary(
 0487                    g => g.Key,
 0488                    g => g.First().Price.HasValue
 0489                        ? (decimal?)Math.Round((decimal)g.First().Price!.Value, 2)
 0490                        : null
 0491                );
 492
 493            // Проставляем цены в заказы
 0494            foreach (var order in orders)
 495            {
 0496                if (string.IsNullOrWhiteSpace(order.ProductKey))
 497                    continue;
 498
 0499                var article = order.ProductKey.Split('=')[0].Trim();
 500
 0501                if (priceDictionary.TryGetValue(article, out var price))
 502                {
 0503                    order.PurchasePrice = price;
 504                }
 505            }
 0506        }
 0507        catch (Exception)
 508        {
 509            // При ошибке просто не устанавливаем цены (заглушка)
 510            // Страница продолжит работать без цен из Bitrix
 0511        }
 0512    }
 513
 514    public async Task<Dictionary<int, List<StockInfo>>> SetStocksForOrdersAsyncFromBitrix(List<Order> orders)
 515    {
 516
 517        // Кэшируем по ключу, основанному на артикулах
 0518        var articles = orders
 0519            .Where(o => !string.IsNullOrWhiteSpace(o.ProductKey))
 0520            .Select(o => o.ProductKey!.Split('=')[0].Trim())
 0521            .Where(a => !string.IsNullOrWhiteSpace(a))
 0522            .Distinct()
 0523            .ToList();
 524
 0525        if (!articles.Any())
 526        {
 0527            return new Dictionary<int, List<StockInfo>>();
 528        }
 529
 530        // Формируем ключ кэша на основе артикула (можно добавить сортировку для уникальности)
 0531        var cacheKey = $"BitrixStocks:{string.Join(",", articles.OrderBy(a => a))}";
 0532        if (_memoryCache.TryGetValue(cacheKey, out Dictionary<int, List<StockInfo>>? cachedStocks) && cachedStocks != nu
 533        {
 0534            return cachedStocks;
 535        }
 536
 537        // Проверяем доступность соединения с Bitrix (с кэшированием)
 0538        if (!await _connectionService.IsConnectionAvailableAsync())
 539        {
 540            // Возвращаем пустой словарь вместо падения страницы
 0541            return new Dictionary<int, List<StockInfo>>();
 542        }
 543
 544        try
 545        {
 546
 0547            var sql = @"
 0548                SELECT
 0549                    CAST(e.ID AS SIGNED) AS ProductId,
 0550                    p.VALUE AS Article,
 0551                    e.PREVIEW_TEXT AS PreviewText,
 0552                    e.ACTIVE AS Active,
 0553                    e.TIMESTAMP_X AS TimestampX,
 0554                    c.QUANTITY AS Quantity,
 0555                    CAST(c.AVAILABLE AS CHAR) AS Available,
 0556                    supplier_enum.VALUE AS Supplier,
 0557                    price.PRICE AS Price,
 0558                    sp.STORE_ID AS StoreId,
 0559                    s.TITLE AS StoreTitle,
 0560                    sp.AMOUNT AS Amount
 0561                FROM b_iblock_element e
 0562                LEFT JOIN b_iblock_element_property p
 0563                    ON e.ID = p.IBLOCK_ELEMENT_ID AND p.DESCRIPTION = 'Артикул'
 0564                LEFT JOIN b_catalog_product c
 0565                    ON c.ID = e.ID
 0566                LEFT JOIN b_iblock_element_property prop_supplier
 0567                    ON e.ID = prop_supplier.IBLOCK_ELEMENT_ID AND prop_supplier.IBLOCK_PROPERTY_ID = 2458
 0568                LEFT JOIN b_iblock_property_enum supplier_enum
 0569                    ON prop_supplier.VALUE = supplier_enum.ID AND supplier_enum.PROPERTY_ID = 2458
 0570                LEFT JOIN b_catalog_price price
 0571                    ON price.PRODUCT_ID = e.ID AND price.CATALOG_GROUP_ID = 3
 0572                LEFT JOIN b_catalog_store_product sp
 0573                    ON sp.PRODUCT_ID = e.ID
 0574                LEFT JOIN b_catalog_store s
 0575                    ON sp.STORE_ID = s.ID
 0576                WHERE e.IBLOCK_ID = 93
 0577                  AND p.VALUE IN ({0})
 0578                  AND sp.AMOUNT > 0
 0579                GROUP BY e.ID, e.PREVIEW_TEXT, e.ACTIVE, e.TIMESTAMP_X,
 0580                         c.QUANTITY, c.AVAILABLE, supplier_enum.VALUE, price.PRICE,
 0581                         sp.STORE_ID, s.TITLE, sp.AMOUNT";
 582
 583            // Подготавливаем параметры для IN
 0584            var parameters = articles.Select((a, i) => new MySqlParameter($"@p{i}", a)).ToArray();
 0585            var inClause = string.Join(",", parameters.Select(p => p.ParameterName));
 0586            var formattedSql = string.Format(sql, inClause);
 587
 588            // Выполняем запрос
 0589            var results = await _context.Set<RemainingStockFlat>()
 0590                .FromSqlRaw(formattedSql, parameters)
 0591                .ToListAsync();
 592
 593            // Группируем по артикулу и преобразуем в удобную структуру
 0594            var stocksDictionary = results
 0595                .Where(r => !string.IsNullOrEmpty(r.Article))
 0596                .GroupBy(r => r.Article!)
 0597                .ToDictionary(
 0598                    g => g.Key,
 0599                    g => new
 0600                    {
 0601                        TotalAmount = g.Sum(x => x.Amount), // Общий остаток по всем складам
 0602                        Stores = g.Select(x => new StockInfo
 0603                        {
 0604                            StoreId = x.StoreId,
 0605                            StoreTitle = x.StoreTitle,
 0606                            Amount = x.Amount
 0607                        }).ToList()
 0608                    }
 0609                );
 610
 0611            Dictionary<int, List<StockInfo>> result = new Dictionary<int, List<StockInfo>>();
 612            // Проставляем остатки в заказы
 0613            foreach (var order in orders)
 614            {
 0615                if (string.IsNullOrWhiteSpace(order.ProductKey))
 616                    continue;
 617
 0618                var article = order.ProductKey.Split('=')[0].Trim();
 619
 0620                if (stocksDictionary.TryGetValue(article, out var stockInfo))
 621                {
 0622                    if (stockInfo.Stores.Count() > 1)
 623                    {
 624                        continue;
 625                    }
 626
 0627                    var targetSupplier = await _supplierRepository.GetAsync(new Supplier()
 0628                        { Name = stockInfo.Stores.FirstOrDefault()?.StoreTitle });
 0629                    if (targetSupplier != null)
 630                    {
 0631                        order.Supplier = targetSupplier;
 0632                        order.SupplierId = targetSupplier.Id;
 633                    }
 634
 0635                    result.Add(order.Id, stockInfo.Stores);
 636                }
 0637            }
 638
 639            // Кэшируем результат на 10 минут
 0640            _memoryCache.Set(cacheKey, result, TimeSpan.FromMinutes(10));
 0641            return result;
 642        }
 0643        catch (Exception)
 644        {
 645            // При ошибке возвращаем пустой словарь вместо падения страницы
 0646            return new Dictionary<int, List<StockInfo>>();
 647        }
 0648    }
 649
 650    /// <summary>
 651    /// Возвращает максимальный TimestampX среди элементов Bitrix (для детекта изменений).
 652    /// </summary>
 653    public async Task<DateTime?> GetMaxTimestampAsync()
 654    {
 655        // Проверяем доступность соединения с Bitrix (с кэшированием)
 1656        if (!await _connectionService.IsConnectionAvailableAsync())
 657        {
 658            // Возвращаем null вместо падения страницы
 0659            return null;
 660        }
 661
 662        try
 663        {
 1664            var sql = "SELECT MAX(e.TIMESTAMP_X) AS MaxTimestamp FROM b_iblock_element e WHERE e.IBLOCK_ID = 93";
 1665            var result = await _context.Set<MaxTimestampResult>()
 1666                .FromSqlRaw(sql)
 1667                .SingleOrDefaultAsync();
 668
 1669            return result?.MaxTimestamp;
 670        }
 0671        catch (Exception)
 672        {
 673            // При ошибке возвращаем null вместо падения страницы
 0674            return null;
 675        }
 1676    }
 677
 678    /// <summary>
 679    /// Обновляет поле ACTIVE в таблице b_iblock_element по ID элемента
 680    /// </summary>
 681    /// <param name="elementId">ID элемента</param>
 682    /// <param name="isActive">true - активировать ("Y"), false - деактивировать ("N")</param>
 683    /// <returns>Количество обновленных записей</returns>
 684    public async Task<int> UpdateElementActiveStatus(int elementId, bool isActive)
 685    {
 686        // Проверяем доступность соединения с Bitrix (с кэшированием)
 0687        if (!await _connectionService.IsConnectionAvailableAsync())
 688        {
 689            // Возвращаем 0 вместо падения страницы
 0690            return 0;
 691        }
 692
 693        try
 694        {
 0695            var activeValue = isActive ? "Y" : "N";
 696
 0697            var sql = @"
 0698                UPDATE b_iblock_element
 0699                SET ACTIVE = {0}, TIMESTAMP_X = NOW()
 0700                WHERE ID = {1} AND IBLOCK_ID = 93
 0701            ";
 702
 0703            return await _context.Database.ExecuteSqlRawAsync(sql, activeValue, elementId);
 704        }
 0705        catch (Exception)
 706        {
 707            // При ошибке возвращаем 0 вместо падения страницы
 0708            return 0;
 709        }
 0710    }
 711
 712    /// <summary>
 713    /// Обновляет поле ACTIVE для нескольких элементов
 714    /// </summary>
 715    /// <param name="elementIds">Список ID элементов</param>
 716    /// <param name="isActive">true - активировать ("Y"), false - деактивировать ("N")</param>
 717    /// <returns>Количество обновленных записей</returns>
 718    public async Task<int> UpdateElementsActiveStatus(List<int> elementIds, bool isActive)
 719    {
 0720        if (elementIds == null || !elementIds.Any())
 721        {
 0722            return 0;
 723        }
 724
 725        // Проверяем доступность соединения с Bitrix (с кэшированием)
 0726        if (!await _connectionService.IsConnectionAvailableAsync())
 727        {
 728            // Возвращаем 0 вместо падения страницы
 0729            return 0;
 730        }
 731
 732        try
 733        {
 0734            var activeValue = isActive ? "Y" : "N";
 0735            var idsString = string.Join(",", elementIds);
 736
 0737            var sql = $@"
 0738                UPDATE b_iblock_element
 0739                SET ACTIVE = {{0}}, TIMESTAMP_X = NOW()
 0740                WHERE ID IN ({idsString}) AND IBLOCK_ID = 93
 0741            ";
 742
 0743            return await _context.Database.ExecuteSqlRawAsync(sql, activeValue);
 744        }
 0745        catch (Exception)
 746        {
 747            // При ошибке возвращаем 0 вместо падения страницы
 0748            return 0;
 749        }
 0750    }
 751
 752    /// <summary>
 753    /// Получает текущее значение ACTIVE для элемента как bool
 754    /// </summary>
 755    /// <param name="elementId">ID элемента</param>
 756    /// <returns>true если активен, false если не активен, null если элемент не найден</returns>
 757    public async Task<bool?> GetElementActiveStatus(int elementId)
 758    {
 759        // Проверяем доступность соединения с Bitrix (с кэшированием)
 0760        if (!await _connectionService.IsConnectionAvailableAsync())
 761        {
 762            // Возвращаем null вместо падения страницы
 0763            return null;
 764        }
 765
 766        try
 767        {
 0768            await using var command = _context.Database.GetDbConnection().CreateCommand();
 769
 0770            if (command.Connection?.State != ConnectionState.Open)
 771            {
 0772                await _context.Database.OpenConnectionAsync();
 773            }
 774
 0775            command.CommandText = @"
 0776                SELECT ACTIVE
 0777                FROM b_iblock_element
 0778                WHERE ID = @elementId AND IBLOCK_ID = 93
 0779            ";
 780
 781            // Добавляем параметр для безопасности от SQL-инъекций
 0782            var parameter = command.CreateParameter();
 0783            parameter.ParameterName = "@elementId";
 0784            parameter.Value = elementId;
 0785            command.Parameters.Add(parameter);
 786
 0787            var result = await command.ExecuteScalarAsync();
 0788            if (result == null || result == DBNull.Value)
 789            {
 0790                return null;
 791            }
 792
 0793            var activeValue = result.ToString();
 0794            return activeValue == "Y";
 0795        }
 0796        catch (Exception)
 797        {
 798            // При ошибке возвращаем null вместо падения страницы
 0799            return null;
 800        }
 801        finally
 802        {
 803            try
 804            {
 0805                await _context.Database.CloseConnectionAsync();
 0806            }
 0807            catch
 808            {
 809                // Игнорируем ошибки при закрытии соединения
 0810            }
 811        }
 0812    }
 813
 814    /// <summary>
 815    /// Переключает значение ACTIVE (true -> false, false -> true)
 816    /// </summary>
 817    /// <param name="elementId">ID элемента</param>
 818    /// <returns>Новое значение ACTIVE как bool или null если элемент не найден</returns>
 819    public async Task<bool?> ToggleElementActiveStatus(int elementId)
 820    {
 821        // Проверяем доступность соединения с Bitrix (с кэшированием)
 0822        if (!await _connectionService.IsConnectionAvailableAsync())
 823        {
 824            // Возвращаем null вместо падения страницы
 0825            return null;
 826        }
 827
 828        try
 829        {
 0830            var currentStatus = await GetElementActiveStatus(elementId);
 0831            if (currentStatus == null)
 832            {
 0833                return null;
 834            }
 835
 0836            var newStatus = !currentStatus.Value;
 0837            await UpdateElementActiveStatus(elementId, newStatus);
 838
 0839            return newStatus;
 840        }
 0841        catch (Exception)
 842        {
 843            // При ошибке возвращаем null вместо падения страницы
 0844            return null;
 845        }
 0846    }
 847
 848    private static Expression<Func<RemainingStockFlat, object>> GetSortPropertyFlat(string sortColumn)
 849    {
 0850        return sortColumn.ToLower() switch
 0851        {
 0852            "productid" => x => x.ProductId,
 0853            "article" => x => x.Article ?? "",
 0854            "previewtext" => x => x.PreviewText ?? "",
 0855            "active" => x => x.Active,
 0856            "available" => x => x.Available,
 0857            "supplier" => x => x.Supplier ?? "",
 0858            "timestampx" => x => x.TimestampX,
 0859            "quantity" => x => x.Quantity,
 0860            "price" => x => x.Price ?? 0,
 0861
 0862            // поля по складам
 0863            "storeid" => x => x.StoreId,
 0864            "storetitle" => x => x.StoreTitle ?? "",
 0865            "amount" => x => x.Amount,
 0866
 0867            _ => x => x.TimestampX // дефолтная сортировка
 0868        };
 869    }
 870
 871}