< Summary

Information
Class: OzonRepositories.Data.OrderHandoverMatchInfo
Assembly: OzonRepositories
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/OrderRepository.cs
Line coverage
100%
Covered lines: 8
Uncovered lines: 0
Coverable lines: 8
Total lines: 1437
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Id()100%11100%
get_ShipmentNumber()100%11100%
get_Article()100%11100%
get_ClientName()100%11100%
get_ClientStatusName()100%11100%
get_ClientStatusColor()100%11100%
get_AppStatusName()100%11100%
get_AppStatusColor()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/OrderRepository.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using OzonDomains;
 3using OzonDomains.Models;
 4using OzonRepositories.Context;
 5using System.Linq.Expressions;
 6using OzonDomains.Models.OrderCarts;
 7
 8namespace OzonRepositories.Data
 9{
 10    public class OrderRepository : MainRepository
 11    {
 12        private const string ZASYL_KEY = "Засыл";
 13        private const string TRANSACTION_DOCUMENT_KEY = "Документ транзакции";
 14
 15        private readonly IOrderChangeNotifier? _changeNotifier;
 16
 17        public OrderRepository(ApplicationDbContext context, IOrderChangeNotifier? changeNotifier = null) : base(context
 18        {
 19            _changeNotifier = changeNotifier;
 20        }
 21
 22        /// <summary>
 23        /// Стандартный набор Include/ThenInclude и AsSplitQuery без сортировки.
 24        /// Используется для IQueryable-путей, где сортировка назначается позднее
 25        /// (FilterByFilterDataQueryable / ApplyFinalSortQueryable).
 26        /// </summary>
 27        private static IQueryable<Order> ApplyIncludes(IQueryable<Order> query) =>
 28            query
 29                .Include(c => c.Сurrency)
 30                .Include(w => w.ShipmentWarehouse)
 31                .Include(p => p.ProductInfo!)
 32                .ThenInclude(r => r.ReplacementProduct)
 33                .Include(a => a.AppStatus!)
 34                .ThenInclude(s => s.StatusColor)
 35                .Include(cs => cs.ClientStatus)
 36                .Include(s => s.Supplier)
 37                .Include(o => o.OzonClient)
 38                .Include(m => m.Manufacturer)
 39                .Include(f => f.ExcelFileData)
 40                .Include(d => d.Delivery!)
 41                .ThenInclude(dp => dp.Provider)
 42                .Include(e => e.EtProducer)
 43                .Include(o => o.OrderPrice!)
 44                .ThenInclude(o => o.PriceIndexes)
 45                .Include(o => o.OzonPromotionSnapshot!)
 46                .ThenInclude(o => o.Conditions)
 47                .AsSplitQuery();
 48
 49        /// <summary>
 50        /// Стандартный набор Include + сортировка по умолчанию (ProcessingDate desc).
 51        /// Для путей, где результат материализуется напрямую (GetAsync, BaseQuery, BuildOrdersQuery).
 52        /// </summary>
 53        private static IQueryable<Order> ApplyStandardIncludes(IQueryable<Order> query) =>
 54            ApplyIncludes(query)
 55                .OrderByDescending(o => o.ProcessingDate);
 56
 57        /// <summary>
 58        /// Базовый конструктор запроса для Order с набором стандартных Include.
 59        /// Используйте includeArchived=true чтобы включить объекты с архивом.
 60        /// asNoTracking=true задаёт AsNoTracking для оптимизации чтения (по умолчанию true для операций чтения).
 61        /// </summary>
 62        private IQueryable<Order> BaseQuery(bool includeArchived = false, bool asNoTracking = true)
 63        {
 64            IQueryable<Order> query = _context.Orders;
 65
 66            if (!includeArchived)
 67                query = query.Where(o => o.OrderArchive == null);
 68
 69            if (asNoTracking)
 70                query = query.AsNoTracking();
 71
 72            query = ApplyStandardIncludes(query)
 73                .Include(o => o.OrderExtensions);
 74
 75            return query;
 76        }
 77
 78        /// <summary>
 79        /// Подгружает CartItemStatus и соответствующий StatusColor для одного заказа (если имеется CartItemId).
 80        /// </summary>
 81        private async Task PopulateCartItemStatusAsync(Order order)
 82        {
 83            if (order == null || !order.CartItemId.HasValue)
 84                return;
 85
 86            var orderItem = await _context.OrderItems
 87                .Include(o => o.ItemStatus!)
 88                .ThenInclude(s => s.StatusColor)
 89                .AsNoTracking()
 90                .FirstOrDefaultAsync(oi => oi.StudioOrderId == order.Id);
 91
 92            order.CartItemStatus = orderItem?.ItemStatus;
 93            order.CartItemStatusColor = orderItem?.ItemStatus?.StatusColor;
 94        }
 95
 96        /// <summary>
 97        /// Возвращает все активные заказы (IQueryable). Загружает ВСЕ заказы в память синхронно.
 98        /// </summary>
 99        [Obsolete("Загружает всю таблицу в память синхронно. Используйте GetQueryable() или GetOrdersWithPagination().")
 100        public IQueryable<Order> Get()
 101        {
 102            // Используем AsNoTracking для операций чтения
 103            // ВНИМАНИЕ: Синхронный ToList() - это антипаттерн для больших объемов данных
 104            var orderList = BaseQuery(includeArchived: false, asNoTracking: true).ToList();
 105            // Удаляем дубликаты по ID на случай, если они все еще появились
 106            orderList = orderList.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 107            LoadAdditionalData(orderList);
 108            return orderList.AsQueryable();
 109        }
 110
 111
 112        public List<Order> GetByProductKeyPrefixes(HashSet<string> productKeys)
 113        {
 114            if (productKeys == null || productKeys.Count == 0)
 115                return new List<Order>();
 116
 117            // Используем AsNoTracking для операций чтения
 118            IQueryable<Order> baseQuery = BaseQuery(includeArchived: false, asNoTracking: true);
 119
 120            // EF Core рекурсивно транслирует дерево Expression.OrElse — при большом числе ключей
 121            // это вызывает StackOverflow. Решение: разбиваем на батчи по 30 ключей и объединяем в памяти.
 122            const int batchSize = 30;
 123            var keyList = productKeys.ToList();
 124            var allOrders = new List<Order>();
 125
 126            for (int i = 0; i < keyList.Count; i += batchSize)
 127            {
 128                var batch = keyList.Skip(i).Take(batchSize).ToHashSet();
 129
 130                var parameter = Expression.Parameter(typeof(Order), "o");
 131                Expression? body = null;
 132
 133                foreach (var key in batch)
 134                {
 135                    var productKeyProp = Expression.Property(parameter, nameof(Order.ProductKey));
 136
 137                    var equals = Expression.Equal(
 138                        productKeyProp,
 139                        Expression.Constant(key)
 140                    );
 141
 142                    var startsWith = Expression.Call(
 143                        productKeyProp,
 144                        nameof(string.StartsWith),
 145                        Type.EmptyTypes,
 146                        Expression.Constant(key + "=")
 147                    );
 148
 149                    var condition = Expression.OrElse(equals, startsWith);
 150
 151                    body = body == null
 152                        ? condition
 153                        : Expression.OrElse(body, condition);
 154                }
 155
 156                var predicate = Expression.Lambda<Func<Order, bool>>(body!, parameter);
 157                allOrders.AddRange(baseQuery.Where(predicate).ToList());
 158            }
 159
 160            // Удаляем дубликаты по ID на случай, если они все еще появились
 161            var orders = allOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 162            LoadAdditionalData(orders);
 163            return orders;
 164        }
 165
 166        /// <summary>
 167        /// Возвращает заказы (асинхронно) с опцией включения архивных.
 168        /// Оптимизирован для производительности - загружает OrderExtensions отдельно для избежания дубликатов.
 169        /// </summary>
 170        public async Task<IQueryable<Order>> GetAsync(bool includeArchived)
 171        {
 172            // Загружаем заказы БЕЗ OrderExtensions (избегаем дубликатов от коллекции)
 173            var query = _context.Orders.AsNoTracking();
 174
 175            if (!includeArchived)
 176            {
 177                query = query.Where(o => o.OrderArchive == null);
 178            }
 179
 180            query = ApplyStandardIncludes(query);
 181
 182            if (includeArchived)
 183                query = query.Include(o => o.OrderArchive);
 184
 185            var orderList = await query.ToListAsync();
 186
 187            // Инициализируем пустые коллекции OrderExtensions для всех заказов
 188            foreach (var order in orderList)
 189            {
 190                order.OrderExtensions = new List<OrderExtension>();
 191            }
 192
 193            // OrderItems НЕ загружаются здесь, чтобы оптимизировать кэш
 194            // Они будут загружены позже только для заказов, которые попадут на страницу
 195            return orderList.AsQueryable();
 196        }
 197
 198        /// <summary>
 199        /// Возвращает IQueryable заказов БЕЗ загрузки данных в память и БЕЗ сортировки.
 200        /// Используется для применения фильтров на уровне БД.
 201        /// Сортировка назначается позже — FilterByFilterDataQueryable / ApplyFinalSortQueryable.
 202        /// ВАЖНО: Использует AsSplitQuery() для избежания дублирования заказов при Include коллекций.
 203        /// </summary>
 204        public IQueryable<Order> GetQueryable(bool includeArchived)
 205        {
 206            var query = _context.Orders.AsNoTracking();
 207
 208            if (!includeArchived)
 209            {
 210                query = query.Where(o => o.OrderArchive == null);
 211            }
 212
 213            query = ApplyIncludes(query);
 214
 215            if (includeArchived)
 216                query = query.Include(o => o.OrderArchive);
 217
 218            return query;
 219        }
 220
 221        /// <summary>
 222        /// Получить заказ по id (асинхронно) с заполнением CartItemStatus при необходимости.
 223        /// Использует трекинг для возможности обновления.
 224        /// </summary>
 225        /// <param name="id">Id заказа</param>
 226        public async Task<Order?> GetAsync(int id)
 227        {
 228            // Для операций чтения используем AsNoTracking, но если нужен трекинг для обновления - передаем false
 229            var order = await BaseQuery(includeArchived: false, asNoTracking: false)
 230                .FirstOrDefaultAsync(o => o.Id == id);
 231            if (order == null) return null;
 232            await PopulateCartItemStatusAsync(order);
 233            return order;
 234        }
 235
 236        /// <summary>
 237        /// Получить заказ по id без трекинга (асинхронно).
 238        /// </summary>
 239        public async Task<Order?> GetAsyncNoTracking(int id)
 240        {
 241            var order = await BaseQuery(includeArchived: false, asNoTracking: true)
 242                .FirstOrDefaultAsync(o => o.Id == id);
 243            if (order == null) return null;
 244            await PopulateCartItemStatusAsync(order);
 245            return order;
 246        }
 247
 248        /// <summary>
 249        /// Получить заказы по списку id без трекинга (асинхронно).
 250        /// Используется для GET методов транзакций - изменения в этих заказах НЕ сохранятся в БД.
 251        /// ids приводятся к уникальным — дубликаты не возвращаются (один заказ — одна строка).
 252        /// CartItemStatus и цвета статусов подгружаются одним пакетным запросом.
 253        /// Дедупликация по Id — вложенные коллекции (OrderExtensions, ExtraExpenses и т.д.)
 254        /// при Include/AsSplitQuery могут приводить к повторению одной и той же сущности Order.
 255        /// </summary>
 256        public async Task<List<Order>> GetOrdersNoTracking(int[] ids)
 257        {
 258            if (ids == null || ids.Length == 0)
 259                return new List<Order>();
 260
 261            var distinctIds = ids.Distinct().ToArray();
 262
 263            var ordersRaw = await BaseQuery(includeArchived: false, asNoTracking: true)
 264                .Where(o => distinctIds.Contains(o.Id))
 265                .ToListAsync();
 266
 267            // Один заказ — одна запись: вложенные модели с теми же заказами дают дубликаты при Include
 268            var orders = ordersRaw.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 269
 270            // Один пакетный запрос вместо N вызовов PopulateCartItemStatusAsync
 271            await LoadOrderItemsForOrders(orders);
 272
 273            return orders;
 274        }
 275
 276        public async Task<List<int>> GetOrderIdsByNumbersAndArticles(List<(string OrderNumber, string Article)> orders)
 277        {
 278            if (orders == null || orders.Count == 0)
 279                return new List<int>();
 280
 281            var normalizedPairs = orders
 282                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 283                .Select(o => (
 284                    OrderNumber: NormalizeShipmentNumberForMatching(o.OrderNumber),
 285                    Article: o.Article.Trim()))
 286                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 287                .Distinct()
 288                .ToHashSet();
 289
 290            if (normalizedPairs.Count == 0)
 291                return new List<int>();
 292
 293            var articles = normalizedPairs.Select(o => o.Article).Distinct().ToList();
 294
 295            // Сначала фильтруем по ProductKey в БД, остальное на клиенте
 296            var query = await _context.Orders
 297                .AsNoTracking()
 298                .Where(o => o.OrderArchive == null)
 299                .Where(o => o.ProductKey != null && articles.Contains(o.ProductKey))
 300                .Select(o => new { o.Id, o.ProductKey, o.ShipmentNumber })
 301                .ToListAsync();
 302
 303            var matching = query
 304                .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber) &&
 305                            normalizedPairs.Contains((NormalizeShipmentNumberForMatching(o.ShipmentNumber), o.ProductKey
 306                .Select(o => o.Id)
 307                .Distinct()
 308                .ToList();
 309
 310            return matching;
 311        }
 312
 313        public async Task<HashSet<(string OrderNumber, string Article)>> GetMatchedNumbersAndArticles(List<(string Order
 314        {
 315            if (orders == null || orders.Count == 0)
 316                return new HashSet<(string OrderNumber, string Article)>();
 317
 318            var normalizedPairs = orders
 319                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 320                .Select(o => (
 321                    OrderNumber: NormalizeShipmentNumberForMatching(o.OrderNumber),
 322                    Article: o.Article.Trim()))
 323                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 324                .Distinct()
 325                .ToHashSet();
 326
 327            if (normalizedPairs.Count == 0)
 328                return new HashSet<(string OrderNumber, string Article)>();
 329
 330            var articles = normalizedPairs.Select(o => o.Article).Distinct().ToList();
 331
 332            var query = await _context.Orders
 333                .AsNoTracking()
 334                .Where(o => o.OrderArchive == null)
 335                .Where(o => o.ProductKey != null && articles.Contains(o.ProductKey))
 336                .Select(o => new { o.ProductKey, o.ShipmentNumber })
 337                .ToListAsync();
 338
 339            return query
 340                .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber) && !string.IsNullOrWhiteSpace(o.ProductKey))
 341                .Select(o => (OrderNumber: NormalizeShipmentNumberForMatching(o.ShipmentNumber), Article: o.ProductKey!)
 342                .Where(normalizedPairs.Contains)
 343                .ToHashSet();
 344        }
 345
 346        private static string NormalizeShipmentNumberForMatching(string? shipmentNumber)
 347        {
 348            return ShipmentNumberMatching.Normalize(shipmentNumber);
 349        }
 350
 351        public async Task<Order?> GetAsyncForCart(int id)
 352        {
 353            // Используем AsNoTracking для операций чтения
 354            return await _context.Orders
 355                .AsNoTracking()
 356                .Where(o => o.OrderArchive == null)
 357                .Include(p => p.ProductInfo)
 358                .Include(a => a.AppStatus!)
 359                .ThenInclude(s => s.StatusColor)
 360                .Include(cs => cs.ClientStatus)
 361                .Include(s => s.Supplier)
 362                .Include(o => o.OzonClient)
 363                .Include(m => m.Manufacturer)
 364                .Include(d => d.Delivery!)
 365                .ThenInclude(dp => dp.Provider)
 366                .Include(e => e.EtProducer)
 367                .Include(o => o.OrderPrice!)
 368                .ThenInclude(o => o.PriceIndexes)
 369                .Include(o => o.OrderExtensions)
 370                .FirstOrDefaultAsync(o => o.Id == id);
 371        }
 372
 373        /// <summary>
 374        /// Получить заказ по объекту (по Key).
 375        /// Использует трекинг для возможности обновления.
 376        /// </summary>
 377        public async Task<Order?> GetAsync(Order value)
 378        {
 379            // Для операций чтения используем AsNoTracking, но если нужен трекинг для обновления - передаем false
 380            var order = await BaseQuery(includeArchived: false, asNoTracking: false)
 381                .FirstOrDefaultAsync(o => o.Key == value.Key);
 382            if (order == null) return null;
 383            await PopulateCartItemStatusAsync(order);
 384            return order;
 385        }
 386
 387        /// <summary>
 388        /// Возвращает заказы производителя по коду (IQueryable для дальнейшего фильтра/пейджинга).
 389        /// </summary>
 390        public IQueryable<Order> GetOrdersByManufacturerCode(string code)
 391        {
 392            return _context.Orders.Include(m => m.Manufacturer)
 393                .Where(o => o.Manufacturer != null && o.Manufacturer.Code == code);
 394        }
 395
 396        public async Task<int> Update(Order value)
 397        {
 398            // Заказ, уже трекаемый контекстом (например, загруженный через GetAsync с Include
 399            // навигаций), нельзя пропускать через ClearOrderNavigationsForUpdate: обнуление
 400            // reference-навигации на уже установленной EF-связи заставляет DetectChanges сбросить
 401            // FK-колонку в NULL при SaveChanges, ДАЖЕ если сам FK-скаляр восстановлен обратно —
 402            // EF в этом случае доверяет разрыву навигации больше, чем значению скаляра.
 403            // Обнуление навигаций нужно только для по-настоящему detached объектов
 404            // (после model binding из формы, с "мусорными" вложенными объектами вроде Name=null).
 405            if (_context.Entry(value).State == EntityState.Detached)
 406            {
 407                ClearOrderNavigationsForUpdate(value);
 408                _context.Orders.Update(value);
 409            }
 410
 411            var result = await _context.SaveChangesAsync();
 412            _changeNotifier?.NotifyOrderChanged(value.Id);
 413            return result;
 414        }
 415
 416        public async Task<int> UpdateRange(List<Order> orders)
 417        {
 418            if (orders == null || !orders.Any())
 419                return 0;
 420
 421            foreach (var order in orders)
 422            {
 423                // См. комментарий в Update(Order) — не трогаем навигации уже трекаемых заказов.
 424                if (_context.Entry(order).State == EntityState.Detached)
 425                {
 426                    ClearOrderNavigationsForUpdate(order);
 427                    _context.Orders.Update(order);
 428                }
 429            }
 430
 431            var result = await _context.SaveChangesAsync();
 432            _changeNotifier?.NotifyOrdersChanged(orders.Select(o => o.Id));
 433            return result;
 434        }
 435
 436        /// <summary>
 437        /// Отвязывает навигации перед Update/UpdateRange, чтобы EF не пытался сохранить
 438        /// связанные сущности из model binding (часто с null Name).
 439        /// Вызывается только для detached-заказов (см. Update/UpdateRange) — на них обнуление
 440        /// навигации безопасно, т.к. EF ещё не устанавливал связь и просто использует то,
 441        /// что окажется в объекте на момент Attach/Update. FK-скаляры на всякий случай
 442        /// сохраняются и восстанавливаются вокруг обнуления.
 443        /// </summary>
 444        private static void ClearOrderNavigationsForUpdate(Order order)
 445        {
 446            var appStatusId = order.AppStatusId;
 447            var clientStatusId = order.ClientStatusId;
 448            var supplierId = order.SupplierId;
 449            var shipmentWarehouseId = order.ShipmentWarehouseId;
 450            var etProducerId = order.EtProducerId;
 451            var manufacturerId = order.ManufacturerId;
 452            var ozonClientId = order.OzonClientId;
 453            var deliveryId = order.DeliveryId;
 454            var currencyId = order.СurrencyId;
 455            var productInfoId = order.ProductInfoId;
 456            var excelFileDataId = order.ExcelFileDataId;
 457
 458            order.AppStatus = null;
 459            order.ClientStatus = null;
 460            order.Supplier = null;
 461            order.ShipmentWarehouse = null;
 462            order.EtProducer = null;
 463            order.Manufacturer = null;
 464            order.OzonClient = null;
 465            order.Delivery = null;
 466            order.Сurrency = null;
 467            order.ProductInfo = null;
 468            order.ExcelFileData = null;
 469            order.OrderArchive = null;
 470            order.OrderPrice = null;
 471            order.ExtraExpenses = [];
 472            order.OrderExtensions = [];
 473
 474            order.AppStatusId = appStatusId;
 475            order.ClientStatusId = clientStatusId;
 476            order.SupplierId = supplierId;
 477            order.ShipmentWarehouseId = shipmentWarehouseId;
 478            order.EtProducerId = etProducerId;
 479            order.ManufacturerId = manufacturerId;
 480            order.OzonClientId = ozonClientId;
 481            order.DeliveryId = deliveryId;
 482            order.СurrencyId = currencyId;
 483            order.ProductInfoId = productInfoId;
 484            order.ExcelFileDataId = excelFileDataId;
 485        }
 486
 487        /// <summary>Установить флаг IsReturned для заказа.</summary>
 488        public async Task<int> SetIsReturnedAsync(int orderId, bool isReturned)
 489        {
 490            return await _context.Orders
 491                .Where(o => o.Id == orderId)
 492                .ExecuteUpdateAsync(s => s.SetProperty(o => o.IsReturned, isReturned));
 493        }
 494
 495        /// <summary>Батч-установка флага IsReturned для списка заказов. Один ExecuteUpdate вместо N вызовов.</summary>
 496        public async Task<int> SetIsReturnedForOrderIdsAsync(IEnumerable<int> orderIds, bool isReturned)
 497        {
 498            var idList = orderIds?.Distinct().ToList() ?? new List<int>();
 499            if (idList.Count == 0)
 500                return 0;
 501
 502            return await _context.Orders
 503                .Where(o => idList.Contains(o.Id))
 504                .ExecuteUpdateAsync(s => s.SetProperty(o => o.IsReturned, isReturned));
 505        }
 506
 507        /// <summary>
 508        /// Привести IsReturned в соответствие с его смыслом — «есть запись в OrderReturns».
 509        ///
 510        /// Флаг выставлял только импорт возвратов и только для заказов, затронутых текущим прогоном,
 511        /// а снимать его было некому. Расходится он в обе стороны: заказ остаётся помеченным, когда
 512        /// Ozon исправил номер отправления и возврат переехал на другой заказ; и наоборот, заказ с
 513        /// возвратами остаётся без флага, если его импортировали в обход прогона — а тогда возврат
 514        /// не показывается в списке заказов, потому что OrderReturnInfoService смотрит на этот флаг.
 515        /// </summary>
 516        /// <returns>(Сколько флагов снято, сколько выставлено).</returns>
 517        /// <remarks>
 518        /// Выборка Id написана на SQL (MySQL/MariaDB), а не через LINQ с Any(): вариант на LINQ
 519        /// разворачивался в коррелированный EXISTS с ведущей таблицей Orders — почти сто тысяч строк
 520        /// ради полутора десятков обновлений. Здесь обратное направление ведёт OrderReturns, которая
 521        /// на два порядка меньше, а прямое обходится одним LEFT JOIN вместо подзапроса на строку.
 522        /// Индекса по IsReturned нет, поэтому скан Orders в первом запросе всё равно неизбежен —
 523        /// он выполняется раз в сутки в ночной полной сверке.
 524        ///
 525        /// Сами обновления — точечные UPDATE по первичному ключу батчами, а не UPDATE … JOIN:
 526        /// один оператор с JOIN держал X-lock на всех ~15 тыс. заказов с возвратами до своего
 527        /// завершения, и 14.09.2026 при стопоре диска это на 98 с заблокировало все UPDATE Orders
 528        /// (таймауты, KILL QUERY, сломанный кеш триггера — см. docs/mariadb-trigger-self-healing.md).
 529        /// SELECT — consistent read без блокировок, UPDATE по Id блокирует только меняемые строки.
 530        /// </remarks>
 531        public async Task<(int Cleared, int Set)> ReconcileIsReturnedFlagAsync()
 532        {
 533            var previousTimeout = _context.Database.GetCommandTimeout();
 534            _context.Database.SetCommandTimeout(180);
 535            try
 536            {
 537                var toClear = await _context.Database.SqlQueryRaw<int>(
 538                    @"SELECT o.Id AS Value
 539                      FROM Orders o
 540                      LEFT JOIN OrderReturns r ON r.OrderId = o.Id
 541                      WHERE o.IsReturned = 1 AND r.Id IS NULL").ToListAsync();
 542
 543                var toSet = await _context.Database.SqlQueryRaw<int>(
 544                    @"SELECT o.Id AS Value
 545                      FROM Orders o
 546                      JOIN (SELECT DISTINCT OrderId FROM OrderReturns WHERE OrderId IS NOT NULL) r
 547                        ON r.OrderId = o.Id
 548                      WHERE o.IsReturned = 0").ToListAsync();
 549
 550                var cleared = await SetIsReturnedInChunksAsync(toClear, isReturned: false);
 551                var set = await SetIsReturnedInChunksAsync(toSet, isReturned: true);
 552
 553                return (cleared, set);
 554            }
 555            finally
 556            {
 557                _context.Database.SetCommandTimeout(previousTimeout);
 558            }
 559        }
 560
 561        private async Task<int> SetIsReturnedInChunksAsync(IReadOnlyCollection<int> orderIds, bool isReturned)
 562        {
 563            const int chunkSize = 500;
 564            var affected = 0;
 565            foreach (var chunk in orderIds.Chunk(chunkSize))
 566                affected += await SetIsReturnedForOrderIdsAsync(chunk, isReturned);
 567            return affected;
 568        }
 569
 570        /// <summary>Загрузить заказы для обновления статуса из Ozon — минимальный набор полей (Id, ShipmentNumber, Ozon
 571        public async Task<List<Order>> GetOrdersForStatusUpdateAsync(IEnumerable<int> orderIds)
 572        {
 573            var idList = orderIds?.Distinct().ToList() ?? new List<int>();
 574            if (idList.Count == 0)
 575                return new List<Order>();
 576
 577            return await _context.Orders
 578                .Where(o => o.OrderArchive == null && idList.Contains(o.Id))
 579                .Include(o => o.OzonClient)
 580                .Include(o => o.ClientStatus)
 581                .ToListAsync();
 582        }
 583
 584        public async Task<List<Order>> GetOrdersWithPagination(int skip, int take)
 585        {
 586            if (skip < 0) skip = 0;
 587            if (take <= 0) take = 15;
 588
 589            // Оптимизированный подход: сначала получаем ID заказов, затем загружаем данные
 590            // Это быстрее чем один большой запрос с множеством JOIN'ов
 591            var orderIds = await _context.Orders
 592                .AsNoTracking()
 593                .Where(o => o.OrderArchive == null)
 594                .OrderByDescending(o => o.ProcessingDate)
 595                .Select(o => o.Id)
 596                .Skip(skip)
 597                .Take(take)
 598                .ToListAsync();
 599
 600            if (!orderIds.Any())
 601                return new List<Order>();
 602
 603            // Загружаем полные данные только для нужных заказов БЕЗ OrderExtensions
 604            var orderList = await _context.Orders
 605                .AsNoTracking()
 606                .Where(o => orderIds.Contains(o.Id))
 607                .Include(c => c.Сurrency)
 608                .Include(w => w.ShipmentWarehouse)
 609                .Include(p => p.ProductInfo!)
 610                .ThenInclude(r => r.ReplacementProduct)
 611                .Include(a => a.AppStatus!)
 612                .ThenInclude(s => s.StatusColor)
 613                .Include(cs => cs.ClientStatus)
 614                .Include(s => s.Supplier)
 615                .Include(o => o.OzonClient)
 616                .Include(m => m.Manufacturer)
 617                .Include(f => f.ExcelFileData)
 618                .Include(d => d.Delivery!)
 619                .ThenInclude(dp => dp.Provider)
 620                .Include(e => e.EtProducer)
 621                .Include(o => o.OrderPrice!)
 622                .ThenInclude(o => o.PriceIndexes)
 623                .AsSplitQuery()
 624                .ToListAsync();
 625
 626            // Сортируем по исходному порядку orderIds
 627            // Удаляем дубликаты перед созданием словаря (на случай если они появились)
 628            orderList = orderList.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 629            var orderDict = orderList.ToDictionary(o => o.Id);
 630            orderList = orderIds.Select(id => orderDict.GetValueOrDefault(id)).OfType<Order>().ToList();
 631
 632            // OrderExtensions, ExtraExpenses и OrderItems НЕ загружаются здесь, чтобы оптимизировать загрузку страницы
 633            // Они будут загружены позже только для заказов, которые попадут на страницу (в контроллере)
 634            foreach (var order in orderList)
 635            {
 636                order.OrderExtensions = new List<OrderExtension>();
 637                order.ExtraExpenses ??= new List<OrderExtraExpense>();
 638            }
 639
 640            return orderList;
 641        }
 642
 643
 644        /// <summary>
 645        /// Возвращает общее количество заказов (асинхронно).
 646        /// </summary>
 647        public async Task<int> GetTotalOrderCount()
 648        {
 649            return await GetTotalCount();
 650        }
 651
 652        public async Task<int> GetReturnableCount()
 653        {
 654            return await _context.Orders
 655                .Where(o => o.IsReturnable == true)
 656                .CountAsync();
 657        }
 658
 659        /// <summary>
 660        /// Загружает полные данные заказов только для указанных ID (оптимизация для фильтрации).
 661        /// Использует батчинг для больших списков ID для улучшения производительности и избежания таймаутов.
 662        /// </summary>
 663        public async Task<List<Order>> GetOrdersByIds(List<int> orderIds, bool includeArchived)
 664        {
 665            if (orderIds == null || !orderIds.Any())
 666                return new List<Order>();
 667
 668            // ОПТИМИЗАЦИЯ: Используем батчинг для больших списков ID
 669            // MySQL имеет ограничение на размер WHERE IN запросов, поэтому разбиваем на батчи
 670            // Уменьшенный размер батча для лучшей производительности и избежания таймаутов
 671            const int
 672                batchSize = 200; // Размер батча для WHERE IN запросов (оптимальный баланс между производительностью и к
 673            var allOrders = new List<Order>();
 674
 675            // Разбиваем список ID на батчи для избежания больших WHERE IN запросов
 676            // Даже для небольших списков используем батчинг для единообразия
 677            for (int i = 0; i < orderIds.Count; i += batchSize)
 678            {
 679                var batchIds = orderIds.Skip(i).Take(batchSize).ToList();
 680
 681                var query = BuildOrdersQuery(batchIds, includeArchived);
 682                var batchOrders = await query.ToListAsync();
 683
 684                // Удаляем дубликаты в батче
 685                batchOrders = batchOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 686
 687                allOrders.AddRange(batchOrders);
 688            }
 689
 690            // Удаляем дубликаты из всего списка (на случай если они появились между батчами)
 691            allOrders = allOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 692
 693            // Инициализируем пустые коллекции OrderExtensions и ExtraExpenses (загружаются отдельно при необходимости)
 694            foreach (var order in allOrders)
 695            {
 696                order.OrderExtensions = new List<OrderExtension>();
 697                order.ExtraExpenses ??= new List<OrderExtraExpense>();
 698            }
 699
 700            return allOrders;
 701        }
 702
 703        /// <summary>
 704        /// Загружает полные данные заказов по списку ID с включённым трэкингом (для последующего обновления и SaveChang
 705        /// Использует батчинг для больших списков ID.
 706        /// </summary>
 707        public async Task<List<Order>> GetOrdersByIdsTracked(List<int> orderIds, bool includeArchived)
 708        {
 709            if (orderIds == null || !orderIds.Any())
 710                return new List<Order>();
 711
 712            const int batchSize = 200;
 713            var allOrders = new List<Order>();
 714
 715            for (int i = 0; i < orderIds.Count; i += batchSize)
 716            {
 717                var batchIds = orderIds.Skip(i).Take(batchSize).ToList();
 718                var query = BuildOrdersQueryTracked(batchIds, includeArchived);
 719                var batchOrders = await query.ToListAsync();
 720                batchOrders = batchOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 721                allOrders.AddRange(batchOrders);
 722            }
 723
 724            allOrders = allOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 725
 726            foreach (var order in allOrders)
 727            {
 728                order.OrderExtensions = new List<OrderExtension>();
 729                order.ExtraExpenses ??= new List<OrderExtraExpense>();
 730            }
 731
 732            return allOrders;
 733        }
 734
 735        /// <summary>
 736        /// Строит запрос для загрузки заказов по списку ID (без трэкинга).
 737        /// </summary>
 738        private IQueryable<Order> BuildOrdersQuery(List<int> orderIds, bool includeArchived)
 739        {
 740            var query = ApplyStandardIncludes(
 741                _context.Orders.AsNoTracking().Where(o => orderIds.Contains(o.Id)));
 742
 743            if (includeArchived)
 744                query = query.Include(o => o.OrderArchive);
 745
 746            return query;
 747        }
 748
 749        /// <summary>
 750        /// Строит запрос для загрузки заказов по списку ID с трэкингом (для обновления и SaveChanges).
 751        /// </summary>
 752        private IQueryable<Order> BuildOrdersQueryTracked(List<int> orderIds, bool includeArchived)
 753        {
 754            var query = ApplyStandardIncludes(
 755                _context.Orders.Where(o => orderIds.Contains(o.Id)));
 756
 757            if (includeArchived)
 758                query = query.Include(o => o.OrderArchive);
 759
 760            return query;
 761        }
 762
 763        /// <summary>
 764        /// Загружает OrderExtensions только для указанных заказов (оптимизация для страницы).
 765        /// </summary>
 766        public async Task LoadOrderExtensionsForOrders(List<Order> orders)
 767        {
 768            if (orders == null || !orders.Any())
 769                return;
 770
 771            var orderIds = orders.Select(o => o.Id).Distinct().ToList();
 772            if (!orderIds.Any())
 773                return;
 774
 775            // Загружаем расширения одним запросом для всех заказов на странице
 776            var orderExtensions = await _context.OrderExtensions
 777                .AsNoTracking()
 778                .Where(e => orderIds.Contains(e.OrderId))
 779                .ToListAsync();
 780
 781            var extensionsByOrderId = orderExtensions
 782                .GroupBy(e => e.OrderId)
 783                .ToDictionary(g => g.Key, g => g.ToList());
 784
 785            // Присваиваем расширения заказам
 786            foreach (var order in orders)
 787            {
 788                if (extensionsByOrderId.TryGetValue(order.Id, out var extensions))
 789                {
 790                    order.OrderExtensions = extensions;
 791                }
 792                else
 793                {
 794                    order.OrderExtensions = new List<OrderExtension>();
 795                }
 796            }
 797        }
 798
 799        public async Task<IQueryable<KeyValuePair<string, int>>> GetUniqueValues<T>(Expression<Func<Order, T>> selector,
 800            Expression<Func<Order, bool>>? predicate = null) where T : class
 801        {
 802            await Task.CompletedTask;
 803            IQueryable<Order> query = _context.Orders;
 804
 805            if (predicate != null)
 806            {
 807                query = query.Where(predicate);
 808            }
 809
 810            return query
 811                .Select(selector)
 812                .Where(value => value != null)
 813                .GroupBy(value => value)
 814                .Select(group => new KeyValuePair<string, int>(group.Key.ToString()!, group.Count()))
 815                .AsQueryable();
 816        }
 817
 818        public async Task<List<string>> GetShipmentNumbers()
 819        {
 820            return await _context.Orders
 821                .Where(o => o.ShipmentNumber != null)
 822                .Select(o => o.ShipmentNumber!)
 823                .ToListAsync();
 824        }
 825
 826        /// <summary>
 827        /// Возвращает уникальные базовые номера отправлений (до первого '-') с количеством.
 828        /// Оптимизация: загружает только ShipmentNumber (без Include), группировка в памяти.
 829        /// </summary>
 830        public async Task<Dictionary<string, int>> GetUniqueShipmentNumbersGrouped()
 831        {
 832            var numbers = await _context.Orders
 833                .AsNoTracking()
 834                .Where(o => o.ShipmentNumber != null)
 835                .Select(o => o.ShipmentNumber!)
 836                .ToListAsync();
 837
 838            return numbers
 839                .Select(sn => sn.Split('-')[0])
 840                .GroupBy(sn => sn)
 841                .ToDictionary(g => g.Key, g => g.Count());
 842        }
 843
 844        private void LoadAdditionalData(List<Order> orders)
 845        {
 846            // Загрузка CartItemStatus для всех заказов одним запросом
 847            var orderIdsWithCartItem = orders
 848                .Where(order => order.CartItemId.HasValue)
 849                .Select(order => order.Id)
 850                .Distinct()
 851                .ToList();
 852
 853            if (orderIdsWithCartItem.Any())
 854            {
 855                var orderItemsWithStatus = _context.OrderItems
 856                    .Include(oi => oi.ItemStatus!)
 857                    .ThenInclude(ist => ist.StatusColor)
 858                    .Where(oi => orderIdsWithCartItem.Contains(oi.StudioOrderId ?? 0))
 859                    .AsNoTracking()
 860                    .ToList();
 861
 862                var orderItemsByOrderId = orderItemsWithStatus
 863                    .GroupBy(oi => oi.StudioOrderId)
 864                    .ToDictionary(g => g.Key ?? 0, g => g.FirstOrDefault());
 865
 866                foreach (var order in orders)
 867                {
 868                    if (order.CartItemId.HasValue)
 869                    {
 870                        if (orderItemsByOrderId.TryGetValue(order.Id, out var orderItem))
 871                        {
 872                            order.CartItemStatus = orderItem?.ItemStatus;
 873                            order.CartItemStatusColor = orderItem?.ItemStatus?.StatusColor;
 874                        }
 875                        else
 876                        {
 877                            order.CartItemStatus = null;
 878                            order.CartItemStatusColor = null;
 879                        }
 880                    }
 881                }
 882            }
 883
 884            // Загрузка StatusColor для ClientStatus отдельным запросом (оптимизация производительности)
 885            var clientStatusIds = orders
 886                .Where(order => order.ClientStatusId.HasValue)
 887                .Select(order => order.ClientStatusId!.Value)
 888                .Distinct()
 889                .ToList();
 890
 891            if (clientStatusIds.Any())
 892            {
 893                var clientStatusesWithColors = _context.ClientStatuses
 894                    .Include(cs => cs.StatusColor)
 895                    .Where(cs => clientStatusIds.Contains(cs.Id))
 896                    .AsNoTracking()
 897                    .ToList();
 898
 899                var clientStatusesById = clientStatusesWithColors
 900                    .ToDictionary(cs => cs.Id, cs => cs);
 901
 902                foreach (var order in orders)
 903                {
 904                    if (order.ClientStatusId.HasValue &&
 905                        clientStatusesById.TryGetValue(order.ClientStatusId.Value, out var clientStatusWithColor))
 906                    {
 907                        order.ClientStatus = clientStatusWithColor;
 908                    }
 909                }
 910            }
 911        }
 912
 913        /// <summary>
 914        /// Загружает OrderItems (CartItemStatus) и StatusColor для ClientStatus только для указанных заказов (оптимизац
 915        /// </summary>
 916        public async Task LoadOrderItemsForOrders(List<Order> orders)
 917        {
 918            if (orders == null || !orders.Any())
 919                return;
 920
 921            // Загрузка CartItemStatus только для заказов на странице
 922            var orderIdsWithCartItem = orders
 923                .Where(order => order.CartItemId.HasValue)
 924                .Select(order => order.Id)
 925                .Distinct()
 926                .ToList();
 927
 928            if (orderIdsWithCartItem.Any())
 929            {
 930                // Загружаем OrderItems одним запросом для всех заказов на странице
 931                var orderItemsWithStatus = await _context.OrderItems
 932                    .Include(oi => oi.ItemStatus!)
 933                    .ThenInclude(ist => ist.StatusColor)
 934                    .Where(oi => orderIdsWithCartItem.Contains(oi.StudioOrderId ?? 0))
 935                    .AsNoTracking()
 936                    .ToListAsync();
 937
 938                var orderItemsByOrderId = orderItemsWithStatus
 939                    .GroupBy(oi => oi.StudioOrderId)
 940                    .ToDictionary(g => g.Key ?? 0, g => g.FirstOrDefault());
 941
 942                foreach (var order in orders)
 943                {
 944                    if (order.CartItemId.HasValue)
 945                    {
 946                        if (orderItemsByOrderId.TryGetValue(order.Id, out var orderItem))
 947                        {
 948                            order.CartItemStatus = orderItem?.ItemStatus;
 949                            order.CartItemStatusColor = orderItem?.ItemStatus?.StatusColor;
 950                        }
 951                        else
 952                        {
 953                            order.CartItemStatus = null;
 954                            order.CartItemStatusColor = null;
 955                        }
 956                    }
 957                }
 958            }
 959
 960            // Загрузка StatusColor для ClientStatus отдельным запросом (оптимизация производительности)
 961            // ВАЖНО: Загружаем цвета для всех заказов на странице, даже если нет CartItemId
 962            var clientStatusIds = orders
 963                .Where(order => order.ClientStatusId.HasValue)
 964                .Select(order => order.ClientStatusId!.Value)
 965                .Distinct()
 966                .ToList();
 967
 968            if (clientStatusIds.Any())
 969            {
 970                var clientStatusesWithColors = await _context.ClientStatuses
 971                    .Include(cs => cs.StatusColor)
 972                    .Where(cs => clientStatusIds.Contains(cs.Id))
 973                    .AsNoTracking()
 974                    .ToListAsync();
 975
 976                var clientStatusesById = clientStatusesWithColors
 977                    .ToDictionary(cs => cs.Id, cs => cs);
 978
 979                foreach (var order in orders)
 980                {
 981                    if (order.ClientStatusId.HasValue &&
 982                        clientStatusesById.TryGetValue(order.ClientStatusId.Value, out var clientStatusWithColor))
 983                    {
 984                        // Заменяем ClientStatus полностью, чтобы гарантировать наличие StatusColor
 985                        // Это работает даже если ClientStatus был загружен без StatusColor в основном запросе
 986                        order.ClientStatus = clientStatusWithColor;
 987                    }
 988                }
 989            }
 990        }
 991
 992        /// <summary>
 993        /// Загружает ExtraExpenses (дополнительные расходы) с ExpenseType только для указанных заказов (оптимизация для
 994        /// </summary>
 995        public async Task LoadExtraExpensesForOrders(List<Order> orders)
 996        {
 997            if (orders == null || !orders.Any())
 998                return;
 999
 1000            var orderIds = orders.Select(o => o.Id).Distinct().ToList();
 1001            if (!orderIds.Any())
 1002                return;
 1003
 1004            // Загружаем дополнительные расходы одним запросом для всех заказов на странице
 1005            var extraExpenses = await _context.OrderExtraExpenses
 1006                .AsNoTracking()
 1007                .Include(e => e.ExpenseType)
 1008                .Where(e => orderIds.Contains(e.OrderId))
 1009                .ToListAsync();
 1010
 1011            var expensesByOrderId = extraExpenses
 1012                .GroupBy(e => e.OrderId)
 1013                .ToDictionary(g => g.Key, g => g.ToList());
 1014
 1015            // Присваиваем расходы заказам
 1016            foreach (var order in orders)
 1017            {
 1018                if (expensesByOrderId.TryGetValue(order.Id, out var expenses))
 1019                {
 1020                    order.ExtraExpenses = expenses;
 1021                }
 1022                else
 1023                {
 1024                    order.ExtraExpenses = new List<OrderExtraExpense>();
 1025                }
 1026            }
 1027        }
 1028
 1029        public async Task<int> Delete(Order value)
 1030        {
 1031            _context.Orders.Remove(value);
 1032            return await _context.SaveChangesAsync();
 1033        }
 1034
 1035        public async Task<int> GetTotalCount()
 1036        {
 1037            return await _context.Orders.CountAsync();
 1038        }
 1039
 1040        public async Task<string?> GetZasylAsync(int orderId)
 1041        {
 1042            var item = await _context.OrderExtensions
 1043                .AsNoTracking()
 1044                .FirstOrDefaultAsync(x => x.OrderId == orderId && x.Key == ZASYL_KEY);
 1045            return item?.Value;
 1046        }
 1047
 1048        public async Task<bool> HasZasylValueAsync(string value)
 1049        {
 1050            if (string.IsNullOrEmpty(value))
 1051                return false;
 1052
 1053            return await _context.OrderExtensions
 1054                .AsNoTracking()
 1055                .AnyAsync(x => x.Key == ZASYL_KEY && x.Value == value);
 1056        }
 1057
 1058
 1059        public async Task SetZasylAsync(int orderId, string? value)
 1060        {
 1061            if (value == null)
 1062            {
 1063                await _context.Database.ExecuteSqlRawAsync(
 1064                    BuildDeleteZasylSql(), orderId, ZASYL_KEY);
 1065            }
 1066            else
 1067            {
 1068                await _context.Database.ExecuteSqlRawAsync(
 1069                    BuildUpsertZasylSql(), orderId, ZASYL_KEY, value);
 1070            }
 1071        }
 1072
 1073        public async Task SetZasylAsync(Dictionary<int, string?> orderSupplierMap)
 1074        {
 1075            if (orderSupplierMap == null || orderSupplierMap.Count == 0)
 1076                return;
 1077
 1078            var sql = BuildUpsertZasylSql();
 1079            foreach (var kvp in orderSupplierMap)
 1080            {
 1081                if (kvp.Value == null) continue;
 1082                await _context.Database.ExecuteSqlRawAsync(sql, kvp.Key, ZASYL_KEY, kvp.Value);
 1083            }
 1084        }
 1085
 1086        private string BuildUpsertZasylSql()
 1087        {
 1088            var provider = _context.Database.ProviderName ?? string.Empty;
 1089            if (provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
 1090            {
 1091                return "INSERT INTO order_extensions (OrderId, Key, Value, CreatedAt) " +
 1092                       "VALUES ({0}, {1}, {2}, CURRENT_TIMESTAMP) " +
 1093                       "ON CONFLICT(OrderId, Key) DO UPDATE SET Value = excluded.Value";
 1094            }
 1095            return "INSERT INTO order_extensions (OrderId, `Key`, Value, CreatedAt) " +
 1096                   "VALUES ({0}, {1}, {2}, UTC_TIMESTAMP()) " +
 1097                   "ON DUPLICATE KEY UPDATE Value = {2}";
 1098        }
 1099
 1100        private string BuildDeleteZasylSql()
 1101        {
 1102            var provider = _context.Database.ProviderName ?? string.Empty;
 1103            if (provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
 1104                return "DELETE FROM order_extensions WHERE OrderId = {0} AND Key = {1}";
 1105            return "DELETE FROM order_extensions WHERE OrderId = {0} AND `Key` = {1}";
 1106        }
 1107
 1108        public async Task<List<string>> GetAllZasylValuesAsync()
 1109        {
 1110            return await _context.OrderExtensions
 1111                .AsNoTracking()
 1112                .Where(e => e.Key == ZASYL_KEY && e.Value != null && e.Value != "")
 1113                .Select(e => e.Value!)
 1114                .Distinct()
 1115                .OrderBy(v => v)
 1116                .ToListAsync();
 1117        }
 1118
 1119        public async Task SetTransactionDocumentAsync(int orderId, string? value)
 1120        {
 1121            var item = await _context.OrderExtensions
 1122                .FirstOrDefaultAsync(x => x.OrderId == orderId && x.Key == TRANSACTION_DOCUMENT_KEY);
 1123
 1124            if (item == null)
 1125            {
 1126                item = new OrderExtension
 1127                {
 1128                    OrderId = orderId,
 1129                    Key = TRANSACTION_DOCUMENT_KEY,
 1130                    Value = value
 1131                };
 1132                _context.OrderExtensions.Add(item);
 1133            }
 1134            else
 1135            {
 1136                item.Value = value;
 1137                _context.OrderExtensions.Update(item);
 1138            }
 1139
 1140            await _context.SaveChangesAsync();
 1141        }
 1142
 1143        public async Task<string?> GetTransactionDocument(int orderId)
 1144        {
 1145            var item = await _context.OrderExtensions
 1146                .AsNoTracking()
 1147                .FirstOrDefaultAsync(x => x.OrderId == orderId && x.Key == TRANSACTION_DOCUMENT_KEY);
 1148            return item?.Value;
 1149        }
 1150
 1151        /// <summary>
 1152        /// Получить заказ по номеру отправления (ShipmentNumber) с учетом клиента
 1153        /// Поддерживает поиск как по полному номеру (69243155-0026-4), так и по частичному (69243155-0026)
 1154        /// </summary>
 1155        public async Task<Order?> GetOrderByShipmentNumberAsync(string shipmentNumber, int clientId)
 1156        {
 1157            if (string.IsNullOrEmpty(shipmentNumber) || clientId <= 0)
 1158                return null;
 1159
 1160            return await _context.Orders
 1161                .AsNoTracking()
 1162                .Where(o => o.OrderArchive == null && o.OzonClientId == clientId) // Только активные заказы конкретного 
 1163                .Include(o => o.OzonClient)
 1164                .Include(o => o.AppStatus)
 1165                .Include(o => o.ClientStatus)
 1166                .Include(o => o.ProductInfo)
 1167                .FirstOrDefaultAsync(o => o.ShipmentNumber == shipmentNumber ||
 1168                                          (o.ShipmentNumber != null && o.ShipmentNumber.StartsWith(shipmentNumber + "-")
 1169        }
 1170
 1171        private const int ShipmentNumberBatchSize = 100;
 1172        private const int PrefixBatchSize = 30;
 1173
 1174        /// <summary>
 1175        /// Батч-поиск OrderId по номерам отправления. Запросы батчами для избежания Stack Overflow при большом числе но
 1176        /// Возвращает словарь: номер → OrderId. Поддерживает точное совпадение и префикс (ShipmentNumber LIKE number + 
 1177        /// </summary>
 1178        public async Task<Dictionary<string, int>> GetOrderIdsByShipmentNumbersAsync(IEnumerable<string> numbers)
 1179        {
 1180            var numberList = numbers.Where(n => !string.IsNullOrEmpty(n)).Distinct().ToList();
 1181            if (numberList.Count == 0)
 1182                return new Dictionary<string, int>();
 1183
 1184            var result = new Dictionary<string, int>();
 1185
 1186            for (int i = 0; i < numberList.Count; i += ShipmentNumberBatchSize)
 1187            {
 1188                var batch = numberList.Skip(i).Take(ShipmentNumberBatchSize).ToList();
 1189                var orders = await _context.Orders
 1190                    .AsNoTracking()
 1191                    .Where(o => o.OrderArchive == null && batch.Contains(o.ShipmentNumber!))
 1192                    .Select(o => new { o.Id, o.ShipmentNumber })
 1193                    .ToListAsync();
 1194
 1195                foreach (var order in orders)
 1196                {
 1197                    if (order.ShipmentNumber != null)
 1198                        result[order.ShipmentNumber] = order.Id;
 1199                }
 1200            }
 1201
 1202            var notFound = numberList.Where(n => !result.ContainsKey(n)).ToList();
 1203            if (notFound.Count > 0)
 1204            {
 1205                var prefixOrders = await GetOrdersByShipmentNumberPrefixesBatchedAsync(notFound);
 1206                foreach (var number in notFound)
 1207                {
 1208                    var prefix = number + "-";
 1209                    var order = prefixOrders.FirstOrDefault(o =>
 1210                        o.ShipmentNumber != null && o.ShipmentNumber.StartsWith(prefix));
 1211                    if (order.ShipmentNumber != null)
 1212                        result[number] = order.Id;
 1213                }
 1214            }
 1215
 1216            return result;
 1217        }
 1218
 1219        /// <summary>
 1220        /// Батч-поиск OrderId по номерам отправления в рамках конкретного Ozon-клиента.
 1221        /// </summary>
 1222        public async Task<Dictionary<string, int>> GetOrderIdsByShipmentNumbersAsync(IEnumerable<string> numbers, int oz
 1223        {
 1224            var numberList = numbers.Where(n => !string.IsNullOrEmpty(n)).Distinct().ToList();
 1225            if (numberList.Count == 0 || ozonClientId <= 0)
 1226                return new Dictionary<string, int>();
 1227
 1228            var result = new Dictionary<string, int>();
 1229
 1230            for (int i = 0; i < numberList.Count; i += ShipmentNumberBatchSize)
 1231            {
 1232                var batch = numberList.Skip(i).Take(ShipmentNumberBatchSize).ToList();
 1233                var orders = await _context.Orders
 1234                    .AsNoTracking()
 1235                    .Where(o => o.OrderArchive == null && o.OzonClientId == ozonClientId &&
 1236                        batch.Contains(o.ShipmentNumber!))
 1237                    .Select(o => new { o.Id, o.ShipmentNumber })
 1238                    .ToListAsync();
 1239
 1240                foreach (var order in orders)
 1241                {
 1242                    if (order.ShipmentNumber != null)
 1243                        result[order.ShipmentNumber] = order.Id;
 1244                }
 1245            }
 1246
 1247            var notFound = numberList.Where(n => !result.ContainsKey(n)).ToList();
 1248            if (notFound.Count > 0)
 1249            {
 1250                var prefixOrders = await GetOrdersByShipmentNumberPrefixesBatchedAsync(notFound, ozonClientId);
 1251                foreach (var number in notFound)
 1252                {
 1253                    var prefix = number + "-";
 1254                    var order = prefixOrders.FirstOrDefault(o =>
 1255                        o.ShipmentNumber != null && o.ShipmentNumber.StartsWith(prefix));
 1256                    if (order.ShipmentNumber != null)
 1257                        result[number] = order.Id;
 1258                }
 1259            }
 1260
 1261            return result;
 1262        }
 1263
 1264        /// <summary>
 1265        /// Лёгкая карточная проекция Id → (ShipmentNumber, Article, клиент Ozon, статусы с их системными
 1266        /// цветами) для уже известных OrderId. Без лишних Include — только то, что нужно для отображения.
 1267        /// </summary>
 1268        public async Task<Dictionary<int, OrderHandoverMatchInfo>> GetHandoverMatchInfoByIdsAsync(IEnumerable<int> order
 1269        {
 1270            var ids = orderIds.Distinct().ToList();
 1271            if (ids.Count == 0)
 1272                return new Dictionary<int, OrderHandoverMatchInfo>();
 1273
 1274            var result = new Dictionary<int, OrderHandoverMatchInfo>();
 1275            for (int i = 0; i < ids.Count; i += ShipmentNumberBatchSize)
 1276            {
 1277                var batch = ids.Skip(i).Take(ShipmentNumberBatchSize).ToList();
 1278                var orders = await _context.Orders
 1279                    .AsNoTracking()
 1280                    .Where(o => batch.Contains(o.Id))
 1281                    .Select(o => new OrderHandoverMatchInfo
 1282                    {
 1283                        Id = o.Id,
 1284                        ShipmentNumber = o.ShipmentNumber,
 1285                        Article = o.Article,
 1286                        ClientName = o.OzonClient != null ? o.OzonClient.Name : null,
 1287                        ClientStatusName = o.ClientStatus != null ? o.ClientStatus.Name : null,
 1288                        ClientStatusColor = o.ClientStatus != null && o.ClientStatus.StatusColor != null ? o.ClientStatu
 1289                        AppStatusName = o.AppStatus != null ? o.AppStatus.Name : null,
 1290                        AppStatusColor = o.AppStatus != null && o.AppStatus.StatusColor != null ? o.AppStatus.StatusColo
 1291                    })
 1292                    .ToListAsync();
 1293
 1294                foreach (var order in orders)
 1295                    result[order.Id] = order;
 1296            }
 1297
 1298            return result;
 1299        }
 1300
 1301        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesBatchedAsync(List<st
 1302        {
 1303            if (numbers.Count == 0)
 1304                return new List<(int Id, string? ShipmentNumber)>();
 1305
 1306            var allResults = new List<(int Id, string? ShipmentNumber)>();
 1307            for (int i = 0; i < numbers.Count; i += PrefixBatchSize)
 1308            {
 1309                var batch = numbers.Skip(i).Take(PrefixBatchSize).Select(n => n + "-").ToList();
 1310                var batchResults = await GetOrdersByShipmentNumberPrefixesAsync(batch);
 1311                allResults.AddRange(batchResults);
 1312            }
 1313            return allResults;
 1314        }
 1315
 1316        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesBatchedAsync(List<st
 1317        {
 1318            if (numbers.Count == 0 || ozonClientId <= 0)
 1319                return new List<(int Id, string? ShipmentNumber)>();
 1320
 1321            var allResults = new List<(int Id, string? ShipmentNumber)>();
 1322            for (int i = 0; i < numbers.Count; i += PrefixBatchSize)
 1323            {
 1324                var batch = numbers.Skip(i).Take(PrefixBatchSize).Select(n => n + "-").ToList();
 1325                var batchResults = await GetOrdersByShipmentNumberPrefixesAsync(batch, ozonClientId);
 1326                allResults.AddRange(batchResults);
 1327            }
 1328            return allResults;
 1329        }
 1330
 1331        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesAsync(List<string> p
 1332        {
 1333            if (prefixes.Count == 0 || ozonClientId <= 0)
 1334                return new List<(int Id, string? ShipmentNumber)>();
 1335
 1336            var parameter = Expression.Parameter(typeof(Order), "o");
 1337            Expression? body = null;
 1338
 1339            foreach (var prefix in prefixes)
 1340            {
 1341                var shipmentNumberProp = Expression.Property(parameter, nameof(Order.ShipmentNumber));
 1342                var startsWithCall = Expression.Call(
 1343                    shipmentNumberProp,
 1344                    nameof(string.StartsWith),
 1345                    Type.EmptyTypes,
 1346                    Expression.Constant(prefix));
 1347
 1348                var notNullCheck = Expression.NotEqual(
 1349                    shipmentNumberProp,
 1350                    Expression.Constant(null, typeof(string)));
 1351
 1352                var condition = Expression.AndAlso(notNullCheck, startsWithCall);
 1353                body = body == null ? condition : Expression.OrElse(body, condition);
 1354            }
 1355
 1356            var predicate = Expression.Lambda<Func<Order, bool>>(body!, parameter);
 1357
 1358            var items = await _context.Orders
 1359                .AsNoTracking()
 1360                .Where(o => o.OrderArchive == null && o.OzonClientId == ozonClientId)
 1361                .Where(predicate)
 1362                .Select(o => new { o.Id, o.ShipmentNumber })
 1363                .ToListAsync();
 1364
 1365            return items.Select(x => (x.Id, x.ShipmentNumber)).ToList();
 1366        }
 1367
 1368        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesAsync(List<string> p
 1369        {
 1370            if (prefixes.Count == 0)
 1371                return new List<(int Id, string? ShipmentNumber)>();
 1372
 1373            var parameter = Expression.Parameter(typeof(Order), "o");
 1374            Expression? body = null;
 1375
 1376            foreach (var prefix in prefixes)
 1377            {
 1378                var shipmentNumberProp = Expression.Property(parameter, nameof(Order.ShipmentNumber));
 1379                var startsWithCall = Expression.Call(
 1380                    shipmentNumberProp,
 1381                    nameof(string.StartsWith),
 1382                    Type.EmptyTypes,
 1383                    Expression.Constant(prefix));
 1384
 1385                var notNullCheck = Expression.NotEqual(
 1386                    shipmentNumberProp,
 1387                    Expression.Constant(null, typeof(string)));
 1388
 1389                var condition = Expression.AndAlso(notNullCheck, startsWithCall);
 1390                body = body == null ? condition : Expression.OrElse(body, condition);
 1391            }
 1392
 1393            var predicate = Expression.Lambda<Func<Order, bool>>(body!, parameter);
 1394
 1395            var items = await _context.Orders
 1396                .AsNoTracking()
 1397                .Where(o => o.OrderArchive == null)
 1398                .Where(predicate)
 1399                .Select(o => new { o.Id, o.ShipmentNumber })
 1400                .ToListAsync();
 1401
 1402            return items.Select(x => (x.Id, x.ShipmentNumber)).ToList();
 1403        }
 1404
 1405        /// <summary>
 1406        /// Получить все заказы по Ozon SKU (FBS) с учетом клиента
 1407        /// </summary>
 1408        public async Task<List<Order>> GetOrdersBySkuAsync(string sku, int clientId)
 1409        {
 1410            if (string.IsNullOrEmpty(sku) || clientId <= 0)
 1411                return new List<Order>();
 1412
 1413            return await _context.Orders
 1414                .AsNoTracking()
 1415                .Where(o => o.OrderArchive == null && o.OzonClientId == clientId) // Только активные заказы конкретного 
 1416                .Include(o => o.OzonClient)
 1417                .Include(o => o.AppStatus)
 1418                .Include(o => o.ClientStatus)
 1419                .Include(o => o.ProductInfo)
 1420                .Where(o => o.ProductInfo != null && o.ProductInfo.FbsOzonSkuId == sku)
 1421                .ToListAsync();
 1422        }
 1423    }
 1424
 1425    /// <summary>Лёгкая проекция заказа для отображения в результатах сопоставления (импорт выдачи Ozon и т.п.).</summar
 1426    public sealed class OrderHandoverMatchInfo
 1427    {
 41428        public int Id { get; set; }
 41429        public string? ShipmentNumber { get; set; }
 41430        public string? Article { get; set; }
 41431        public string? ClientName { get; set; }
 41432        public string? ClientStatusName { get; set; }
 41433        public string? ClientStatusColor { get; set; }
 41434        public string? AppStatusName { get; set; }
 41435        public string? AppStatusColor { get; set; }
 1436    }
 1437}