< Summary

Information
Class: OzonRepositories.Data.OrderRepository
Assembly: OzonRepositories
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/OrderRepository.cs
Line coverage
30%
Covered lines: 235
Uncovered lines: 526
Coverable lines: 761
Total lines: 1437
Line coverage: 30.8%
Branch coverage
21%
Covered branches: 63
Total branches: 296
Branch coverage: 21.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ApplyIncludes(...)100%11100%
ApplyStandardIncludes(...)100%11100%
BaseQuery(...)100%44100%
PopulateCartItemStatusAsync()30%441030%
Get()100%210%
GetByProductKeyPrefixes(...)0%110100%
GetAsync()0%4260%
GetQueryable(...)0%2040%
GetAsync()50%22100%
GetAsyncNoTracking()100%22100%
GetOrdersNoTracking()50%4488.88%
GetOrderIdsByNumbersAndArticles()0%4260%
GetMatchedNumbersAndArticles()0%4260%
NormalizeShipmentNumberForMatching(...)100%210%
GetAsyncForCart()100%210%
GetAsync()0%620%
GetOrdersByManufacturerCode(...)100%210%
Update()50%4471.42%
UpdateRange()70%101088.88%
ClearOrderNavigationsForUpdate(...)100%11100%
SetIsReturnedAsync()100%210%
SetIsReturnedForOrderIdsAsync()0%4260%
ReconcileIsReturnedFlagAsync()100%210%
SetIsReturnedInChunksAsync()0%620%
GetOrdersForStatusUpdateAsync()0%4260%
GetOrdersWithPagination()30%381034.21%
GetTotalOrderCount()100%210%
GetReturnableCount()100%210%
GetOrdersByIds()0%110100%
GetOrdersByIdsTracked()0%110100%
BuildOrdersQuery(...)0%620%
BuildOrdersQueryTracked(...)0%620%
LoadOrderExtensionsForOrders()0%110100%
GetUniqueValues()0%620%
GetShipmentNumbers()100%210%
GetUniqueShipmentNumbersGrouped()100%210%
LoadAdditionalData(...)0%506220%
LoadOrderItemsForOrders()15.38%2722628.57%
LoadExtraExpensesForOrders()0%110100%
Delete()100%210%
GetTotalCount()100%210%
GetZasylAsync()50%22100%
HasZasylValueAsync()0%620%
SetZasylAsync()50%2266.66%
SetZasylAsync()75%8885.71%
BuildUpsertZasylSql()50%5462.5%
BuildDeleteZasylSql()0%2040%
GetAllZasylValuesAsync()100%210%
SetTransactionDocumentAsync()0%620%
GetTransactionDocument()0%620%
GetOrderByShipmentNumberAsync()0%2040%
GetOrderIdsByShipmentNumbersAsync()92.85%141495.45%
GetOrderIdsByShipmentNumbersAsync()0%272160%
GetHandoverMatchInfoByIdsAsync()100%66100%
GetOrdersByShipmentNumberPrefixesBatchedAsync()75%4487.5%
GetOrdersByShipmentNumberPrefixesBatchedAsync()0%4260%
GetOrdersByShipmentNumberPrefixesAsync()0%7280%
GetOrdersByShipmentNumberPrefixesAsync()66.66%6695.83%
GetOrdersBySkuAsync()0%2040%

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
 3417        public OrderRepository(ApplicationDbContext context, IOrderChangeNotifier? changeNotifier = null) : base(context
 18        {
 3419            _changeNotifier = changeNotifier;
 3420        }
 21
 22        /// <summary>
 23        /// Стандартный набор Include/ThenInclude и AsSplitQuery без сортировки.
 24        /// Используется для IQueryable-путей, где сортировка назначается позднее
 25        /// (FilterByFilterDataQueryable / ApplyFinalSortQueryable).
 26        /// </summary>
 27        private static IQueryable<Order> ApplyIncludes(IQueryable<Order> query) =>
 1328            query
 1329                .Include(c => c.Сurrency)
 1330                .Include(w => w.ShipmentWarehouse)
 1331                .Include(p => p.ProductInfo!)
 1332                .ThenInclude(r => r.ReplacementProduct)
 1333                .Include(a => a.AppStatus!)
 1334                .ThenInclude(s => s.StatusColor)
 1335                .Include(cs => cs.ClientStatus)
 1336                .Include(s => s.Supplier)
 1337                .Include(o => o.OzonClient)
 1338                .Include(m => m.Manufacturer)
 1339                .Include(f => f.ExcelFileData)
 1340                .Include(d => d.Delivery!)
 1341                .ThenInclude(dp => dp.Provider)
 1342                .Include(e => e.EtProducer)
 1343                .Include(o => o.OrderPrice!)
 1344                .ThenInclude(o => o.PriceIndexes)
 1345                .Include(o => o.OzonPromotionSnapshot!)
 1346                .ThenInclude(o => o.Conditions)
 1347                .AsSplitQuery();
 48
 49        /// <summary>
 50        /// Стандартный набор Include + сортировка по умолчанию (ProcessingDate desc).
 51        /// Для путей, где результат материализуется напрямую (GetAsync, BaseQuery, BuildOrdersQuery).
 52        /// </summary>
 53        private static IQueryable<Order> ApplyStandardIncludes(IQueryable<Order> query) =>
 1354            ApplyIncludes(query)
 1355                .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        {
 1364            IQueryable<Order> query = _context.Orders;
 65
 1366            if (!includeArchived)
 1367                query = query.Where(o => o.OrderArchive == null);
 68
 1369            if (asNoTracking)
 570                query = query.AsNoTracking();
 71
 1372            query = ApplyStandardIncludes(query)
 1373                .Include(o => o.OrderExtensions);
 74
 1375            return query;
 76        }
 77
 78        /// <summary>
 79        /// Подгружает CartItemStatus и соответствующий StatusColor для одного заказа (если имеется CartItemId).
 80        /// </summary>
 81        private async Task PopulateCartItemStatusAsync(Order order)
 82        {
 1183            if (order == null || !order.CartItemId.HasValue)
 1184                return;
 85
 086            var orderItem = await _context.OrderItems
 087                .Include(o => o.ItemStatus!)
 088                .ThenInclude(s => s.StatusColor)
 089                .AsNoTracking()
 090                .FirstOrDefaultAsync(oi => oi.StudioOrderId == order.Id);
 91
 092            order.CartItemStatus = orderItem?.ItemStatus;
 093            order.CartItemStatusColor = orderItem?.ItemStatus?.StatusColor;
 1194        }
 95
 96        /// <summary>
 97        /// Возвращает все активные заказы (IQueryable). Загружает ВСЕ заказы в память синхронно.
 98        /// </summary>
 99        [Obsolete("Загружает всю таблицу в память синхронно. Используйте GetQueryable() или GetOrdersWithPagination().")
 100        public IQueryable<Order> Get()
 101        {
 102            // Используем AsNoTracking для операций чтения
 103            // ВНИМАНИЕ: Синхронный ToList() - это антипаттерн для больших объемов данных
 0104            var orderList = BaseQuery(includeArchived: false, asNoTracking: true).ToList();
 105            // Удаляем дубликаты по ID на случай, если они все еще появились
 0106            orderList = orderList.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 0107            LoadAdditionalData(orderList);
 0108            return orderList.AsQueryable();
 109        }
 110
 111
 112        public List<Order> GetByProductKeyPrefixes(HashSet<string> productKeys)
 113        {
 0114            if (productKeys == null || productKeys.Count == 0)
 0115                return new List<Order>();
 116
 117            // Используем AsNoTracking для операций чтения
 0118            IQueryable<Order> baseQuery = BaseQuery(includeArchived: false, asNoTracking: true);
 119
 120            // EF Core рекурсивно транслирует дерево Expression.OrElse — при большом числе ключей
 121            // это вызывает StackOverflow. Решение: разбиваем на батчи по 30 ключей и объединяем в памяти.
 122            const int batchSize = 30;
 0123            var keyList = productKeys.ToList();
 0124            var allOrders = new List<Order>();
 125
 0126            for (int i = 0; i < keyList.Count; i += batchSize)
 127            {
 0128                var batch = keyList.Skip(i).Take(batchSize).ToHashSet();
 129
 0130                var parameter = Expression.Parameter(typeof(Order), "o");
 0131                Expression? body = null;
 132
 0133                foreach (var key in batch)
 134                {
 0135                    var productKeyProp = Expression.Property(parameter, nameof(Order.ProductKey));
 136
 0137                    var equals = Expression.Equal(
 0138                        productKeyProp,
 0139                        Expression.Constant(key)
 0140                    );
 141
 0142                    var startsWith = Expression.Call(
 0143                        productKeyProp,
 0144                        nameof(string.StartsWith),
 0145                        Type.EmptyTypes,
 0146                        Expression.Constant(key + "=")
 0147                    );
 148
 0149                    var condition = Expression.OrElse(equals, startsWith);
 150
 0151                    body = body == null
 0152                        ? condition
 0153                        : Expression.OrElse(body, condition);
 154                }
 155
 0156                var predicate = Expression.Lambda<Func<Order, bool>>(body!, parameter);
 0157                allOrders.AddRange(baseQuery.Where(predicate).ToList());
 158            }
 159
 160            // Удаляем дубликаты по ID на случай, если они все еще появились
 0161            var orders = allOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 0162            LoadAdditionalData(orders);
 0163            return orders;
 164        }
 165
 166        /// <summary>
 167        /// Возвращает заказы (асинхронно) с опцией включения архивных.
 168        /// Оптимизирован для производительности - загружает OrderExtensions отдельно для избежания дубликатов.
 169        /// </summary>
 170        public async Task<IQueryable<Order>> GetAsync(bool includeArchived)
 171        {
 172            // Загружаем заказы БЕЗ OrderExtensions (избегаем дубликатов от коллекции)
 0173            var query = _context.Orders.AsNoTracking();
 174
 0175            if (!includeArchived)
 176            {
 0177                query = query.Where(o => o.OrderArchive == null);
 178            }
 179
 0180            query = ApplyStandardIncludes(query);
 181
 0182            if (includeArchived)
 0183                query = query.Include(o => o.OrderArchive);
 184
 0185            var orderList = await query.ToListAsync();
 186
 187            // Инициализируем пустые коллекции OrderExtensions для всех заказов
 0188            foreach (var order in orderList)
 189            {
 0190                order.OrderExtensions = new List<OrderExtension>();
 191            }
 192
 193            // OrderItems НЕ загружаются здесь, чтобы оптимизировать кэш
 194            // Они будут загружены позже только для заказов, которые попадут на страницу
 0195            return orderList.AsQueryable();
 0196        }
 197
 198        /// <summary>
 199        /// Возвращает IQueryable заказов БЕЗ загрузки данных в память и БЕЗ сортировки.
 200        /// Используется для применения фильтров на уровне БД.
 201        /// Сортировка назначается позже — FilterByFilterDataQueryable / ApplyFinalSortQueryable.
 202        /// ВАЖНО: Использует AsSplitQuery() для избежания дублирования заказов при Include коллекций.
 203        /// </summary>
 204        public IQueryable<Order> GetQueryable(bool includeArchived)
 205        {
 0206            var query = _context.Orders.AsNoTracking();
 207
 0208            if (!includeArchived)
 209            {
 0210                query = query.Where(o => o.OrderArchive == null);
 211            }
 212
 0213            query = ApplyIncludes(query);
 214
 0215            if (includeArchived)
 0216                query = query.Include(o => o.OrderArchive);
 217
 0218            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
 8229            var order = await BaseQuery(includeArchived: false, asNoTracking: false)
 8230                .FirstOrDefaultAsync(o => o.Id == id);
 8231            if (order == null) return null;
 8232            await PopulateCartItemStatusAsync(order);
 8233            return order;
 8234        }
 235
 236        /// <summary>
 237        /// Получить заказ по id без трекинга (асинхронно).
 238        /// </summary>
 239        public async Task<Order?> GetAsyncNoTracking(int id)
 240        {
 4241            var order = await BaseQuery(includeArchived: false, asNoTracking: true)
 4242                .FirstOrDefaultAsync(o => o.Id == id);
 5243            if (order == null) return null;
 3244            await PopulateCartItemStatusAsync(order);
 3245            return order;
 4246        }
 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        {
 1258            if (ids == null || ids.Length == 0)
 0259                return new List<Order>();
 260
 1261            var distinctIds = ids.Distinct().ToArray();
 262
 1263            var ordersRaw = await BaseQuery(includeArchived: false, asNoTracking: true)
 1264                .Where(o => distinctIds.Contains(o.Id))
 1265                .ToListAsync();
 266
 267            // Один заказ — одна запись: вложенные модели с теми же заказами дают дубликаты при Include
 3268            var orders = ordersRaw.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 269
 270            // Один пакетный запрос вместо N вызовов PopulateCartItemStatusAsync
 1271            await LoadOrderItemsForOrders(orders);
 272
 1273            return orders;
 1274        }
 275
 276        public async Task<List<int>> GetOrderIdsByNumbersAndArticles(List<(string OrderNumber, string Article)> orders)
 277        {
 0278            if (orders == null || orders.Count == 0)
 0279                return new List<int>();
 280
 0281            var normalizedPairs = orders
 0282                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 0283                .Select(o => (
 0284                    OrderNumber: NormalizeShipmentNumberForMatching(o.OrderNumber),
 0285                    Article: o.Article.Trim()))
 0286                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 0287                .Distinct()
 0288                .ToHashSet();
 289
 0290            if (normalizedPairs.Count == 0)
 0291                return new List<int>();
 292
 0293            var articles = normalizedPairs.Select(o => o.Article).Distinct().ToList();
 294
 295            // Сначала фильтруем по ProductKey в БД, остальное на клиенте
 0296            var query = await _context.Orders
 0297                .AsNoTracking()
 0298                .Where(o => o.OrderArchive == null)
 0299                .Where(o => o.ProductKey != null && articles.Contains(o.ProductKey))
 0300                .Select(o => new { o.Id, o.ProductKey, o.ShipmentNumber })
 0301                .ToListAsync();
 302
 0303            var matching = query
 0304                .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber) &&
 0305                            normalizedPairs.Contains((NormalizeShipmentNumberForMatching(o.ShipmentNumber), o.ProductKey
 0306                .Select(o => o.Id)
 0307                .Distinct()
 0308                .ToList();
 309
 0310            return matching;
 0311        }
 312
 313        public async Task<HashSet<(string OrderNumber, string Article)>> GetMatchedNumbersAndArticles(List<(string Order
 314        {
 0315            if (orders == null || orders.Count == 0)
 0316                return new HashSet<(string OrderNumber, string Article)>();
 317
 0318            var normalizedPairs = orders
 0319                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 0320                .Select(o => (
 0321                    OrderNumber: NormalizeShipmentNumberForMatching(o.OrderNumber),
 0322                    Article: o.Article.Trim()))
 0323                .Where(o => !string.IsNullOrWhiteSpace(o.OrderNumber) && !string.IsNullOrWhiteSpace(o.Article))
 0324                .Distinct()
 0325                .ToHashSet();
 326
 0327            if (normalizedPairs.Count == 0)
 0328                return new HashSet<(string OrderNumber, string Article)>();
 329
 0330            var articles = normalizedPairs.Select(o => o.Article).Distinct().ToList();
 331
 0332            var query = await _context.Orders
 0333                .AsNoTracking()
 0334                .Where(o => o.OrderArchive == null)
 0335                .Where(o => o.ProductKey != null && articles.Contains(o.ProductKey))
 0336                .Select(o => new { o.ProductKey, o.ShipmentNumber })
 0337                .ToListAsync();
 338
 0339            return query
 0340                .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber) && !string.IsNullOrWhiteSpace(o.ProductKey))
 0341                .Select(o => (OrderNumber: NormalizeShipmentNumberForMatching(o.ShipmentNumber), Article: o.ProductKey!)
 0342                .Where(normalizedPairs.Contains)
 0343                .ToHashSet();
 0344        }
 345
 346        private static string NormalizeShipmentNumberForMatching(string? shipmentNumber)
 347        {
 0348            return ShipmentNumberMatching.Normalize(shipmentNumber);
 349        }
 350
 351        public async Task<Order?> GetAsyncForCart(int id)
 352        {
 353            // Используем AsNoTracking для операций чтения
 0354            return await _context.Orders
 0355                .AsNoTracking()
 0356                .Where(o => o.OrderArchive == null)
 0357                .Include(p => p.ProductInfo)
 0358                .Include(a => a.AppStatus!)
 0359                .ThenInclude(s => s.StatusColor)
 0360                .Include(cs => cs.ClientStatus)
 0361                .Include(s => s.Supplier)
 0362                .Include(o => o.OzonClient)
 0363                .Include(m => m.Manufacturer)
 0364                .Include(d => d.Delivery!)
 0365                .ThenInclude(dp => dp.Provider)
 0366                .Include(e => e.EtProducer)
 0367                .Include(o => o.OrderPrice!)
 0368                .ThenInclude(o => o.PriceIndexes)
 0369                .Include(o => o.OrderExtensions)
 0370                .FirstOrDefaultAsync(o => o.Id == id);
 0371        }
 372
 373        /// <summary>
 374        /// Получить заказ по объекту (по Key).
 375        /// Использует трекинг для возможности обновления.
 376        /// </summary>
 377        public async Task<Order?> GetAsync(Order value)
 378        {
 379            // Для операций чтения используем AsNoTracking, но если нужен трекинг для обновления - передаем false
 0380            var order = await BaseQuery(includeArchived: false, asNoTracking: false)
 0381                .FirstOrDefaultAsync(o => o.Key == value.Key);
 0382            if (order == null) return null;
 0383            await PopulateCartItemStatusAsync(order);
 0384            return order;
 0385        }
 386
 387        /// <summary>
 388        /// Возвращает заказы производителя по коду (IQueryable для дальнейшего фильтра/пейджинга).
 389        /// </summary>
 390        public IQueryable<Order> GetOrdersByManufacturerCode(string code)
 391        {
 0392            return _context.Orders.Include(m => m.Manufacturer)
 0393                .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).
 1405            if (_context.Entry(value).State == EntityState.Detached)
 406            {
 0407                ClearOrderNavigationsForUpdate(value);
 0408                _context.Orders.Update(value);
 409            }
 410
 1411            var result = await _context.SaveChangesAsync();
 1412            _changeNotifier?.NotifyOrderChanged(value.Id);
 1413            return result;
 1414        }
 415
 416        public async Task<int> UpdateRange(List<Order> orders)
 417        {
 4418            if (orders == null || !orders.Any())
 0419                return 0;
 420
 16421            foreach (var order in orders)
 422            {
 423                // См. комментарий в Update(Order) — не трогаем навигации уже трекаемых заказов.
 4424                if (_context.Entry(order).State == EntityState.Detached)
 425                {
 1426                    ClearOrderNavigationsForUpdate(order);
 1427                    _context.Orders.Update(order);
 428                }
 429            }
 430
 4431            var result = await _context.SaveChangesAsync();
 4432            _changeNotifier?.NotifyOrdersChanged(orders.Select(o => o.Id));
 4433            return result;
 4434        }
 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        {
 1446            var appStatusId = order.AppStatusId;
 1447            var clientStatusId = order.ClientStatusId;
 1448            var supplierId = order.SupplierId;
 1449            var shipmentWarehouseId = order.ShipmentWarehouseId;
 1450            var etProducerId = order.EtProducerId;
 1451            var manufacturerId = order.ManufacturerId;
 1452            var ozonClientId = order.OzonClientId;
 1453            var deliveryId = order.DeliveryId;
 1454            var currencyId = order.СurrencyId;
 1455            var productInfoId = order.ProductInfoId;
 1456            var excelFileDataId = order.ExcelFileDataId;
 457
 1458            order.AppStatus = null;
 1459            order.ClientStatus = null;
 1460            order.Supplier = null;
 1461            order.ShipmentWarehouse = null;
 1462            order.EtProducer = null;
 1463            order.Manufacturer = null;
 1464            order.OzonClient = null;
 1465            order.Delivery = null;
 1466            order.Сurrency = null;
 1467            order.ProductInfo = null;
 1468            order.ExcelFileData = null;
 1469            order.OrderArchive = null;
 1470            order.OrderPrice = null;
 1471            order.ExtraExpenses = [];
 1472            order.OrderExtensions = [];
 473
 1474            order.AppStatusId = appStatusId;
 1475            order.ClientStatusId = clientStatusId;
 1476            order.SupplierId = supplierId;
 1477            order.ShipmentWarehouseId = shipmentWarehouseId;
 1478            order.EtProducerId = etProducerId;
 1479            order.ManufacturerId = manufacturerId;
 1480            order.OzonClientId = ozonClientId;
 1481            order.DeliveryId = deliveryId;
 1482            order.СurrencyId = currencyId;
 1483            order.ProductInfoId = productInfoId;
 1484            order.ExcelFileDataId = excelFileDataId;
 1485        }
 486
 487        /// <summary>Установить флаг IsReturned для заказа.</summary>
 488        public async Task<int> SetIsReturnedAsync(int orderId, bool isReturned)
 489        {
 0490            return await _context.Orders
 0491                .Where(o => o.Id == orderId)
 0492                .ExecuteUpdateAsync(s => s.SetProperty(o => o.IsReturned, isReturned));
 0493        }
 494
 495        /// <summary>Батч-установка флага IsReturned для списка заказов. Один ExecuteUpdate вместо N вызовов.</summary>
 496        public async Task<int> SetIsReturnedForOrderIdsAsync(IEnumerable<int> orderIds, bool isReturned)
 497        {
 0498            var idList = orderIds?.Distinct().ToList() ?? new List<int>();
 0499            if (idList.Count == 0)
 0500                return 0;
 501
 0502            return await _context.Orders
 0503                .Where(o => idList.Contains(o.Id))
 0504                .ExecuteUpdateAsync(s => s.SetProperty(o => o.IsReturned, isReturned));
 0505        }
 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        {
 0533            var previousTimeout = _context.Database.GetCommandTimeout();
 0534            _context.Database.SetCommandTimeout(180);
 535            try
 536            {
 0537                var toClear = await _context.Database.SqlQueryRaw<int>(
 0538                    @"SELECT o.Id AS Value
 0539                      FROM Orders o
 0540                      LEFT JOIN OrderReturns r ON r.OrderId = o.Id
 0541                      WHERE o.IsReturned = 1 AND r.Id IS NULL").ToListAsync();
 542
 0543                var toSet = await _context.Database.SqlQueryRaw<int>(
 0544                    @"SELECT o.Id AS Value
 0545                      FROM Orders o
 0546                      JOIN (SELECT DISTINCT OrderId FROM OrderReturns WHERE OrderId IS NOT NULL) r
 0547                        ON r.OrderId = o.Id
 0548                      WHERE o.IsReturned = 0").ToListAsync();
 549
 0550                var cleared = await SetIsReturnedInChunksAsync(toClear, isReturned: false);
 0551                var set = await SetIsReturnedInChunksAsync(toSet, isReturned: true);
 552
 0553                return (cleared, set);
 554            }
 555            finally
 556            {
 0557                _context.Database.SetCommandTimeout(previousTimeout);
 558            }
 0559        }
 560
 561        private async Task<int> SetIsReturnedInChunksAsync(IReadOnlyCollection<int> orderIds, bool isReturned)
 562        {
 563            const int chunkSize = 500;
 0564            var affected = 0;
 0565            foreach (var chunk in orderIds.Chunk(chunkSize))
 0566                affected += await SetIsReturnedForOrderIdsAsync(chunk, isReturned);
 0567            return affected;
 0568        }
 569
 570        /// <summary>Загрузить заказы для обновления статуса из Ozon — минимальный набор полей (Id, ShipmentNumber, Ozon
 571        public async Task<List<Order>> GetOrdersForStatusUpdateAsync(IEnumerable<int> orderIds)
 572        {
 0573            var idList = orderIds?.Distinct().ToList() ?? new List<int>();
 0574            if (idList.Count == 0)
 0575                return new List<Order>();
 576
 0577            return await _context.Orders
 0578                .Where(o => o.OrderArchive == null && idList.Contains(o.Id))
 0579                .Include(o => o.OzonClient)
 0580                .Include(o => o.ClientStatus)
 0581                .ToListAsync();
 0582        }
 583
 584        public async Task<List<Order>> GetOrdersWithPagination(int skip, int take)
 585        {
 1586            if (skip < 0) skip = 0;
 1587            if (take <= 0) take = 15;
 588
 589            // Оптимизированный подход: сначала получаем ID заказов, затем загружаем данные
 590            // Это быстрее чем один большой запрос с множеством JOIN'ов
 1591            var orderIds = await _context.Orders
 1592                .AsNoTracking()
 1593                .Where(o => o.OrderArchive == null)
 1594                .OrderByDescending(o => o.ProcessingDate)
 1595                .Select(o => o.Id)
 1596                .Skip(skip)
 1597                .Take(take)
 1598                .ToListAsync();
 599
 1600            if (!orderIds.Any())
 1601                return new List<Order>();
 602
 603            // Загружаем полные данные только для нужных заказов БЕЗ OrderExtensions
 0604            var orderList = await _context.Orders
 0605                .AsNoTracking()
 0606                .Where(o => orderIds.Contains(o.Id))
 0607                .Include(c => c.Сurrency)
 0608                .Include(w => w.ShipmentWarehouse)
 0609                .Include(p => p.ProductInfo!)
 0610                .ThenInclude(r => r.ReplacementProduct)
 0611                .Include(a => a.AppStatus!)
 0612                .ThenInclude(s => s.StatusColor)
 0613                .Include(cs => cs.ClientStatus)
 0614                .Include(s => s.Supplier)
 0615                .Include(o => o.OzonClient)
 0616                .Include(m => m.Manufacturer)
 0617                .Include(f => f.ExcelFileData)
 0618                .Include(d => d.Delivery!)
 0619                .ThenInclude(dp => dp.Provider)
 0620                .Include(e => e.EtProducer)
 0621                .Include(o => o.OrderPrice!)
 0622                .ThenInclude(o => o.PriceIndexes)
 0623                .AsSplitQuery()
 0624                .ToListAsync();
 625
 626            // Сортируем по исходному порядку orderIds
 627            // Удаляем дубликаты перед созданием словаря (на случай если они появились)
 0628            orderList = orderList.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 0629            var orderDict = orderList.ToDictionary(o => o.Id);
 0630            orderList = orderIds.Select(id => orderDict.GetValueOrDefault(id)).OfType<Order>().ToList();
 631
 632            // OrderExtensions, ExtraExpenses и OrderItems НЕ загружаются здесь, чтобы оптимизировать загрузку страницы
 633            // Они будут загружены позже только для заказов, которые попадут на страницу (в контроллере)
 0634            foreach (var order in orderList)
 635            {
 0636                order.OrderExtensions = new List<OrderExtension>();
 0637                order.ExtraExpenses ??= new List<OrderExtraExpense>();
 638            }
 639
 0640            return orderList;
 1641        }
 642
 643
 644        /// <summary>
 645        /// Возвращает общее количество заказов (асинхронно).
 646        /// </summary>
 647        public async Task<int> GetTotalOrderCount()
 648        {
 0649            return await GetTotalCount();
 0650        }
 651
 652        public async Task<int> GetReturnableCount()
 653        {
 0654            return await _context.Orders
 0655                .Where(o => o.IsReturnable == true)
 0656                .CountAsync();
 0657        }
 658
 659        /// <summary>
 660        /// Загружает полные данные заказов только для указанных ID (оптимизация для фильтрации).
 661        /// Использует батчинг для больших списков ID для улучшения производительности и избежания таймаутов.
 662        /// </summary>
 663        public async Task<List<Order>> GetOrdersByIds(List<int> orderIds, bool includeArchived)
 664        {
 0665            if (orderIds == null || !orderIds.Any())
 0666                return new List<Order>();
 667
 668            // ОПТИМИЗАЦИЯ: Используем батчинг для больших списков ID
 669            // MySQL имеет ограничение на размер WHERE IN запросов, поэтому разбиваем на батчи
 670            // Уменьшенный размер батча для лучшей производительности и избежания таймаутов
 671            const int
 672                batchSize = 200; // Размер батча для WHERE IN запросов (оптимальный баланс между производительностью и к
 0673            var allOrders = new List<Order>();
 674
 675            // Разбиваем список ID на батчи для избежания больших WHERE IN запросов
 676            // Даже для небольших списков используем батчинг для единообразия
 0677            for (int i = 0; i < orderIds.Count; i += batchSize)
 678            {
 0679                var batchIds = orderIds.Skip(i).Take(batchSize).ToList();
 680
 0681                var query = BuildOrdersQuery(batchIds, includeArchived);
 0682                var batchOrders = await query.ToListAsync();
 683
 684                // Удаляем дубликаты в батче
 0685                batchOrders = batchOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 686
 0687                allOrders.AddRange(batchOrders);
 688            }
 689
 690            // Удаляем дубликаты из всего списка (на случай если они появились между батчами)
 0691            allOrders = allOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 692
 693            // Инициализируем пустые коллекции OrderExtensions и ExtraExpenses (загружаются отдельно при необходимости)
 0694            foreach (var order in allOrders)
 695            {
 0696                order.OrderExtensions = new List<OrderExtension>();
 0697                order.ExtraExpenses ??= new List<OrderExtraExpense>();
 698            }
 699
 0700            return allOrders;
 0701        }
 702
 703        /// <summary>
 704        /// Загружает полные данные заказов по списку ID с включённым трэкингом (для последующего обновления и SaveChang
 705        /// Использует батчинг для больших списков ID.
 706        /// </summary>
 707        public async Task<List<Order>> GetOrdersByIdsTracked(List<int> orderIds, bool includeArchived)
 708        {
 0709            if (orderIds == null || !orderIds.Any())
 0710                return new List<Order>();
 711
 712            const int batchSize = 200;
 0713            var allOrders = new List<Order>();
 714
 0715            for (int i = 0; i < orderIds.Count; i += batchSize)
 716            {
 0717                var batchIds = orderIds.Skip(i).Take(batchSize).ToList();
 0718                var query = BuildOrdersQueryTracked(batchIds, includeArchived);
 0719                var batchOrders = await query.ToListAsync();
 0720                batchOrders = batchOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 0721                allOrders.AddRange(batchOrders);
 722            }
 723
 0724            allOrders = allOrders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 725
 0726            foreach (var order in allOrders)
 727            {
 0728                order.OrderExtensions = new List<OrderExtension>();
 0729                order.ExtraExpenses ??= new List<OrderExtraExpense>();
 730            }
 731
 0732            return allOrders;
 0733        }
 734
 735        /// <summary>
 736        /// Строит запрос для загрузки заказов по списку ID (без трэкинга).
 737        /// </summary>
 738        private IQueryable<Order> BuildOrdersQuery(List<int> orderIds, bool includeArchived)
 739        {
 0740            var query = ApplyStandardIncludes(
 0741                _context.Orders.AsNoTracking().Where(o => orderIds.Contains(o.Id)));
 742
 0743            if (includeArchived)
 0744                query = query.Include(o => o.OrderArchive);
 745
 0746            return query;
 747        }
 748
 749        /// <summary>
 750        /// Строит запрос для загрузки заказов по списку ID с трэкингом (для обновления и SaveChanges).
 751        /// </summary>
 752        private IQueryable<Order> BuildOrdersQueryTracked(List<int> orderIds, bool includeArchived)
 753        {
 0754            var query = ApplyStandardIncludes(
 0755                _context.Orders.Where(o => orderIds.Contains(o.Id)));
 756
 0757            if (includeArchived)
 0758                query = query.Include(o => o.OrderArchive);
 759
 0760            return query;
 761        }
 762
 763        /// <summary>
 764        /// Загружает OrderExtensions только для указанных заказов (оптимизация для страницы).
 765        /// </summary>
 766        public async Task LoadOrderExtensionsForOrders(List<Order> orders)
 767        {
 0768            if (orders == null || !orders.Any())
 0769                return;
 770
 0771            var orderIds = orders.Select(o => o.Id).Distinct().ToList();
 0772            if (!orderIds.Any())
 0773                return;
 774
 775            // Загружаем расширения одним запросом для всех заказов на странице
 0776            var orderExtensions = await _context.OrderExtensions
 0777                .AsNoTracking()
 0778                .Where(e => orderIds.Contains(e.OrderId))
 0779                .ToListAsync();
 780
 0781            var extensionsByOrderId = orderExtensions
 0782                .GroupBy(e => e.OrderId)
 0783                .ToDictionary(g => g.Key, g => g.ToList());
 784
 785            // Присваиваем расширения заказам
 0786            foreach (var order in orders)
 787            {
 0788                if (extensionsByOrderId.TryGetValue(order.Id, out var extensions))
 789                {
 0790                    order.OrderExtensions = extensions;
 791                }
 792                else
 793                {
 0794                    order.OrderExtensions = new List<OrderExtension>();
 795                }
 796            }
 0797        }
 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        {
 0802            await Task.CompletedTask;
 0803            IQueryable<Order> query = _context.Orders;
 804
 0805            if (predicate != null)
 806            {
 0807                query = query.Where(predicate);
 808            }
 809
 0810            return query
 0811                .Select(selector)
 0812                .Where(value => value != null)
 0813                .GroupBy(value => value)
 0814                .Select(group => new KeyValuePair<string, int>(group.Key.ToString()!, group.Count()))
 0815                .AsQueryable();
 0816        }
 817
 818        public async Task<List<string>> GetShipmentNumbers()
 819        {
 0820            return await _context.Orders
 0821                .Where(o => o.ShipmentNumber != null)
 0822                .Select(o => o.ShipmentNumber!)
 0823                .ToListAsync();
 0824        }
 825
 826        /// <summary>
 827        /// Возвращает уникальные базовые номера отправлений (до первого '-') с количеством.
 828        /// Оптимизация: загружает только ShipmentNumber (без Include), группировка в памяти.
 829        /// </summary>
 830        public async Task<Dictionary<string, int>> GetUniqueShipmentNumbersGrouped()
 831        {
 0832            var numbers = await _context.Orders
 0833                .AsNoTracking()
 0834                .Where(o => o.ShipmentNumber != null)
 0835                .Select(o => o.ShipmentNumber!)
 0836                .ToListAsync();
 837
 0838            return numbers
 0839                .Select(sn => sn.Split('-')[0])
 0840                .GroupBy(sn => sn)
 0841                .ToDictionary(g => g.Key, g => g.Count());
 0842        }
 843
 844        private void LoadAdditionalData(List<Order> orders)
 845        {
 846            // Загрузка CartItemStatus для всех заказов одним запросом
 0847            var orderIdsWithCartItem = orders
 0848                .Where(order => order.CartItemId.HasValue)
 0849                .Select(order => order.Id)
 0850                .Distinct()
 0851                .ToList();
 852
 0853            if (orderIdsWithCartItem.Any())
 854            {
 0855                var orderItemsWithStatus = _context.OrderItems
 0856                    .Include(oi => oi.ItemStatus!)
 0857                    .ThenInclude(ist => ist.StatusColor)
 0858                    .Where(oi => orderIdsWithCartItem.Contains(oi.StudioOrderId ?? 0))
 0859                    .AsNoTracking()
 0860                    .ToList();
 861
 0862                var orderItemsByOrderId = orderItemsWithStatus
 0863                    .GroupBy(oi => oi.StudioOrderId)
 0864                    .ToDictionary(g => g.Key ?? 0, g => g.FirstOrDefault());
 865
 0866                foreach (var order in orders)
 867                {
 0868                    if (order.CartItemId.HasValue)
 869                    {
 0870                        if (orderItemsByOrderId.TryGetValue(order.Id, out var orderItem))
 871                        {
 0872                            order.CartItemStatus = orderItem?.ItemStatus;
 0873                            order.CartItemStatusColor = orderItem?.ItemStatus?.StatusColor;
 874                        }
 875                        else
 876                        {
 0877                            order.CartItemStatus = null;
 0878                            order.CartItemStatusColor = null;
 879                        }
 880                    }
 881                }
 882            }
 883
 884            // Загрузка StatusColor для ClientStatus отдельным запросом (оптимизация производительности)
 0885            var clientStatusIds = orders
 0886                .Where(order => order.ClientStatusId.HasValue)
 0887                .Select(order => order.ClientStatusId!.Value)
 0888                .Distinct()
 0889                .ToList();
 890
 0891            if (clientStatusIds.Any())
 892            {
 0893                var clientStatusesWithColors = _context.ClientStatuses
 0894                    .Include(cs => cs.StatusColor)
 0895                    .Where(cs => clientStatusIds.Contains(cs.Id))
 0896                    .AsNoTracking()
 0897                    .ToList();
 898
 0899                var clientStatusesById = clientStatusesWithColors
 0900                    .ToDictionary(cs => cs.Id, cs => cs);
 901
 0902                foreach (var order in orders)
 903                {
 0904                    if (order.ClientStatusId.HasValue &&
 0905                        clientStatusesById.TryGetValue(order.ClientStatusId.Value, out var clientStatusWithColor))
 906                    {
 0907                        order.ClientStatus = clientStatusWithColor;
 908                    }
 909                }
 910            }
 0911        }
 912
 913        /// <summary>
 914        /// Загружает OrderItems (CartItemStatus) и StatusColor для ClientStatus только для указанных заказов (оптимизац
 915        /// </summary>
 916        public async Task LoadOrderItemsForOrders(List<Order> orders)
 917        {
 1918            if (orders == null || !orders.Any())
 0919                return;
 920
 921            // Загрузка CartItemStatus только для заказов на странице
 1922            var orderIdsWithCartItem = orders
 1923                .Where(order => order.CartItemId.HasValue)
 0924                .Select(order => order.Id)
 1925                .Distinct()
 1926                .ToList();
 927
 1928            if (orderIdsWithCartItem.Any())
 929            {
 930                // Загружаем OrderItems одним запросом для всех заказов на странице
 0931                var orderItemsWithStatus = await _context.OrderItems
 0932                    .Include(oi => oi.ItemStatus!)
 0933                    .ThenInclude(ist => ist.StatusColor)
 0934                    .Where(oi => orderIdsWithCartItem.Contains(oi.StudioOrderId ?? 0))
 0935                    .AsNoTracking()
 0936                    .ToListAsync();
 937
 0938                var orderItemsByOrderId = orderItemsWithStatus
 0939                    .GroupBy(oi => oi.StudioOrderId)
 0940                    .ToDictionary(g => g.Key ?? 0, g => g.FirstOrDefault());
 941
 0942                foreach (var order in orders)
 943                {
 0944                    if (order.CartItemId.HasValue)
 945                    {
 0946                        if (orderItemsByOrderId.TryGetValue(order.Id, out var orderItem))
 947                        {
 0948                            order.CartItemStatus = orderItem?.ItemStatus;
 0949                            order.CartItemStatusColor = orderItem?.ItemStatus?.StatusColor;
 950                        }
 951                        else
 952                        {
 0953                            order.CartItemStatus = null;
 0954                            order.CartItemStatusColor = null;
 955                        }
 956                    }
 957                }
 958            }
 959
 960            // Загрузка StatusColor для ClientStatus отдельным запросом (оптимизация производительности)
 961            // ВАЖНО: Загружаем цвета для всех заказов на странице, даже если нет CartItemId
 1962            var clientStatusIds = orders
 1963                .Where(order => order.ClientStatusId.HasValue)
 0964                .Select(order => order.ClientStatusId!.Value)
 1965                .Distinct()
 1966                .ToList();
 967
 1968            if (clientStatusIds.Any())
 969            {
 0970                var clientStatusesWithColors = await _context.ClientStatuses
 0971                    .Include(cs => cs.StatusColor)
 0972                    .Where(cs => clientStatusIds.Contains(cs.Id))
 0973                    .AsNoTracking()
 0974                    .ToListAsync();
 975
 0976                var clientStatusesById = clientStatusesWithColors
 0977                    .ToDictionary(cs => cs.Id, cs => cs);
 978
 0979                foreach (var order in orders)
 980                {
 0981                    if (order.ClientStatusId.HasValue &&
 0982                        clientStatusesById.TryGetValue(order.ClientStatusId.Value, out var clientStatusWithColor))
 983                    {
 984                        // Заменяем ClientStatus полностью, чтобы гарантировать наличие StatusColor
 985                        // Это работает даже если ClientStatus был загружен без StatusColor в основном запросе
 0986                        order.ClientStatus = clientStatusWithColor;
 987                    }
 988                }
 989            }
 1990        }
 991
 992        /// <summary>
 993        /// Загружает ExtraExpenses (дополнительные расходы) с ExpenseType только для указанных заказов (оптимизация для
 994        /// </summary>
 995        public async Task LoadExtraExpensesForOrders(List<Order> orders)
 996        {
 0997            if (orders == null || !orders.Any())
 0998                return;
 999
 01000            var orderIds = orders.Select(o => o.Id).Distinct().ToList();
 01001            if (!orderIds.Any())
 01002                return;
 1003
 1004            // Загружаем дополнительные расходы одним запросом для всех заказов на странице
 01005            var extraExpenses = await _context.OrderExtraExpenses
 01006                .AsNoTracking()
 01007                .Include(e => e.ExpenseType)
 01008                .Where(e => orderIds.Contains(e.OrderId))
 01009                .ToListAsync();
 1010
 01011            var expensesByOrderId = extraExpenses
 01012                .GroupBy(e => e.OrderId)
 01013                .ToDictionary(g => g.Key, g => g.ToList());
 1014
 1015            // Присваиваем расходы заказам
 01016            foreach (var order in orders)
 1017            {
 01018                if (expensesByOrderId.TryGetValue(order.Id, out var expenses))
 1019                {
 01020                    order.ExtraExpenses = expenses;
 1021                }
 1022                else
 1023                {
 01024                    order.ExtraExpenses = new List<OrderExtraExpense>();
 1025                }
 1026            }
 01027        }
 1028
 1029        public async Task<int> Delete(Order value)
 1030        {
 01031            _context.Orders.Remove(value);
 01032            return await _context.SaveChangesAsync();
 01033        }
 1034
 1035        public async Task<int> GetTotalCount()
 1036        {
 01037            return await _context.Orders.CountAsync();
 01038        }
 1039
 1040        public async Task<string?> GetZasylAsync(int orderId)
 1041        {
 31042            var item = await _context.OrderExtensions
 31043                .AsNoTracking()
 31044                .FirstOrDefaultAsync(x => x.OrderId == orderId && x.Key == ZASYL_KEY);
 31045            return item?.Value;
 31046        }
 1047
 1048        public async Task<bool> HasZasylValueAsync(string value)
 1049        {
 01050            if (string.IsNullOrEmpty(value))
 01051                return false;
 1052
 01053            return await _context.OrderExtensions
 01054                .AsNoTracking()
 01055                .AnyAsync(x => x.Key == ZASYL_KEY && x.Value == value);
 01056        }
 1057
 1058
 1059        public async Task SetZasylAsync(int orderId, string? value)
 1060        {
 11061            if (value == null)
 1062            {
 01063                await _context.Database.ExecuteSqlRawAsync(
 01064                    BuildDeleteZasylSql(), orderId, ZASYL_KEY);
 1065            }
 1066            else
 1067            {
 11068                await _context.Database.ExecuteSqlRawAsync(
 11069                    BuildUpsertZasylSql(), orderId, ZASYL_KEY, value);
 1070            }
 11071        }
 1072
 1073        public async Task SetZasylAsync(Dictionary<int, string?> orderSupplierMap)
 1074        {
 11075            if (orderSupplierMap == null || orderSupplierMap.Count == 0)
 01076                return;
 1077
 11078            var sql = BuildUpsertZasylSql();
 61079            foreach (var kvp in orderSupplierMap)
 1080            {
 21081                if (kvp.Value == null) continue;
 21082                await _context.Database.ExecuteSqlRawAsync(sql, kvp.Key, ZASYL_KEY, kvp.Value);
 1083            }
 11084        }
 1085
 1086        private string BuildUpsertZasylSql()
 1087        {
 21088            var provider = _context.Database.ProviderName ?? string.Empty;
 21089            if (provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
 1090            {
 21091                return "INSERT INTO order_extensions (OrderId, Key, Value, CreatedAt) " +
 21092                       "VALUES ({0}, {1}, {2}, CURRENT_TIMESTAMP) " +
 21093                       "ON CONFLICT(OrderId, Key) DO UPDATE SET Value = excluded.Value";
 1094            }
 01095            return "INSERT INTO order_extensions (OrderId, `Key`, Value, CreatedAt) " +
 01096                   "VALUES ({0}, {1}, {2}, UTC_TIMESTAMP()) " +
 01097                   "ON DUPLICATE KEY UPDATE Value = {2}";
 1098        }
 1099
 1100        private string BuildDeleteZasylSql()
 1101        {
 01102            var provider = _context.Database.ProviderName ?? string.Empty;
 01103            if (provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
 01104                return "DELETE FROM order_extensions WHERE OrderId = {0} AND Key = {1}";
 01105            return "DELETE FROM order_extensions WHERE OrderId = {0} AND `Key` = {1}";
 1106        }
 1107
 1108        public async Task<List<string>> GetAllZasylValuesAsync()
 1109        {
 01110            return await _context.OrderExtensions
 01111                .AsNoTracking()
 01112                .Where(e => e.Key == ZASYL_KEY && e.Value != null && e.Value != "")
 01113                .Select(e => e.Value!)
 01114                .Distinct()
 01115                .OrderBy(v => v)
 01116                .ToListAsync();
 01117        }
 1118
 1119        public async Task SetTransactionDocumentAsync(int orderId, string? value)
 1120        {
 01121            var item = await _context.OrderExtensions
 01122                .FirstOrDefaultAsync(x => x.OrderId == orderId && x.Key == TRANSACTION_DOCUMENT_KEY);
 1123
 01124            if (item == null)
 1125            {
 01126                item = new OrderExtension
 01127                {
 01128                    OrderId = orderId,
 01129                    Key = TRANSACTION_DOCUMENT_KEY,
 01130                    Value = value
 01131                };
 01132                _context.OrderExtensions.Add(item);
 1133            }
 1134            else
 1135            {
 01136                item.Value = value;
 01137                _context.OrderExtensions.Update(item);
 1138            }
 1139
 01140            await _context.SaveChangesAsync();
 01141        }
 1142
 1143        public async Task<string?> GetTransactionDocument(int orderId)
 1144        {
 01145            var item = await _context.OrderExtensions
 01146                .AsNoTracking()
 01147                .FirstOrDefaultAsync(x => x.OrderId == orderId && x.Key == TRANSACTION_DOCUMENT_KEY);
 01148            return item?.Value;
 01149        }
 1150
 1151        /// <summary>
 1152        /// Получить заказ по номеру отправления (ShipmentNumber) с учетом клиента
 1153        /// Поддерживает поиск как по полному номеру (69243155-0026-4), так и по частичному (69243155-0026)
 1154        /// </summary>
 1155        public async Task<Order?> GetOrderByShipmentNumberAsync(string shipmentNumber, int clientId)
 1156        {
 01157            if (string.IsNullOrEmpty(shipmentNumber) || clientId <= 0)
 01158                return null;
 1159
 01160            return await _context.Orders
 01161                .AsNoTracking()
 01162                .Where(o => o.OrderArchive == null && o.OzonClientId == clientId) // Только активные заказы конкретного 
 01163                .Include(o => o.OzonClient)
 01164                .Include(o => o.AppStatus)
 01165                .Include(o => o.ClientStatus)
 01166                .Include(o => o.ProductInfo)
 01167                .FirstOrDefaultAsync(o => o.ShipmentNumber == shipmentNumber ||
 01168                                          (o.ShipmentNumber != null && o.ShipmentNumber.StartsWith(shipmentNumber + "-")
 01169        }
 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        {
 131180            var numberList = numbers.Where(n => !string.IsNullOrEmpty(n)).Distinct().ToList();
 71181            if (numberList.Count == 0)
 11182                return new Dictionary<string, int>();
 1183
 61184            var result = new Dictionary<string, int>();
 1185
 241186            for (int i = 0; i < numberList.Count; i += ShipmentNumberBatchSize)
 1187            {
 61188                var batch = numberList.Skip(i).Take(ShipmentNumberBatchSize).ToList();
 61189                var orders = await _context.Orders
 61190                    .AsNoTracking()
 61191                    .Where(o => o.OrderArchive == null && batch.Contains(o.ShipmentNumber!))
 61192                    .Select(o => new { o.Id, o.ShipmentNumber })
 61193                    .ToListAsync();
 1194
 161195                foreach (var order in orders)
 1196                {
 21197                    if (order.ShipmentNumber != null)
 21198                        result[order.ShipmentNumber] = order.Id;
 1199                }
 1200            }
 1201
 121202            var notFound = numberList.Where(n => !result.ContainsKey(n)).ToList();
 61203            if (notFound.Count > 0)
 1204            {
 41205                var prefixOrders = await GetOrdersByShipmentNumberPrefixesBatchedAsync(notFound);
 161206                foreach (var number in notFound)
 1207                {
 41208                    var prefix = number + "-";
 41209                    var order = prefixOrders.FirstOrDefault(o =>
 41210                        o.ShipmentNumber != null && o.ShipmentNumber.StartsWith(prefix));
 41211                    if (order.ShipmentNumber != null)
 01212                        result[number] = order.Id;
 1213                }
 1214            }
 1215
 61216            return result;
 71217        }
 1218
 1219        /// <summary>
 1220        /// Батч-поиск OrderId по номерам отправления в рамках конкретного Ozon-клиента.
 1221        /// </summary>
 1222        public async Task<Dictionary<string, int>> GetOrderIdsByShipmentNumbersAsync(IEnumerable<string> numbers, int oz
 1223        {
 01224            var numberList = numbers.Where(n => !string.IsNullOrEmpty(n)).Distinct().ToList();
 01225            if (numberList.Count == 0 || ozonClientId <= 0)
 01226                return new Dictionary<string, int>();
 1227
 01228            var result = new Dictionary<string, int>();
 1229
 01230            for (int i = 0; i < numberList.Count; i += ShipmentNumberBatchSize)
 1231            {
 01232                var batch = numberList.Skip(i).Take(ShipmentNumberBatchSize).ToList();
 01233                var orders = await _context.Orders
 01234                    .AsNoTracking()
 01235                    .Where(o => o.OrderArchive == null && o.OzonClientId == ozonClientId &&
 01236                        batch.Contains(o.ShipmentNumber!))
 01237                    .Select(o => new { o.Id, o.ShipmentNumber })
 01238                    .ToListAsync();
 1239
 01240                foreach (var order in orders)
 1241                {
 01242                    if (order.ShipmentNumber != null)
 01243                        result[order.ShipmentNumber] = order.Id;
 1244                }
 01245            }
 1246
 01247            var notFound = numberList.Where(n => !result.ContainsKey(n)).ToList();
 01248            if (notFound.Count > 0)
 1249            {
 01250                var prefixOrders = await GetOrdersByShipmentNumberPrefixesBatchedAsync(notFound, ozonClientId);
 01251                foreach (var number in notFound)
 1252                {
 01253                    var prefix = number + "-";
 01254                    var order = prefixOrders.FirstOrDefault(o =>
 01255                        o.ShipmentNumber != null && o.ShipmentNumber.StartsWith(prefix));
 01256                    if (order.ShipmentNumber != null)
 01257                        result[number] = order.Id;
 1258                }
 1259            }
 1260
 01261            return result;
 01262        }
 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        {
 71270            var ids = orderIds.Distinct().ToList();
 71271            if (ids.Count == 0)
 51272                return new Dictionary<int, OrderHandoverMatchInfo>();
 1273
 21274            var result = new Dictionary<int, OrderHandoverMatchInfo>();
 81275            for (int i = 0; i < ids.Count; i += ShipmentNumberBatchSize)
 1276            {
 21277                var batch = ids.Skip(i).Take(ShipmentNumberBatchSize).ToList();
 21278                var orders = await _context.Orders
 21279                    .AsNoTracking()
 21280                    .Where(o => batch.Contains(o.Id))
 21281                    .Select(o => new OrderHandoverMatchInfo
 21282                    {
 21283                        Id = o.Id,
 21284                        ShipmentNumber = o.ShipmentNumber,
 21285                        Article = o.Article,
 21286                        ClientName = o.OzonClient != null ? o.OzonClient.Name : null,
 21287                        ClientStatusName = o.ClientStatus != null ? o.ClientStatus.Name : null,
 21288                        ClientStatusColor = o.ClientStatus != null && o.ClientStatus.StatusColor != null ? o.ClientStatu
 21289                        AppStatusName = o.AppStatus != null ? o.AppStatus.Name : null,
 21290                        AppStatusColor = o.AppStatus != null && o.AppStatus.StatusColor != null ? o.AppStatus.StatusColo
 21291                    })
 21292                    .ToListAsync();
 1293
 81294                foreach (var order in orders)
 21295                    result[order.Id] = order;
 1296            }
 1297
 21298            return result;
 71299        }
 1300
 1301        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesBatchedAsync(List<st
 1302        {
 41303            if (numbers.Count == 0)
 01304                return new List<(int Id, string? ShipmentNumber)>();
 1305
 41306            var allResults = new List<(int Id, string? ShipmentNumber)>();
 161307            for (int i = 0; i < numbers.Count; i += PrefixBatchSize)
 1308            {
 81309                var batch = numbers.Skip(i).Take(PrefixBatchSize).Select(n => n + "-").ToList();
 41310                var batchResults = await GetOrdersByShipmentNumberPrefixesAsync(batch);
 41311                allResults.AddRange(batchResults);
 1312            }
 41313            return allResults;
 41314        }
 1315
 1316        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesBatchedAsync(List<st
 1317        {
 01318            if (numbers.Count == 0 || ozonClientId <= 0)
 01319                return new List<(int Id, string? ShipmentNumber)>();
 1320
 01321            var allResults = new List<(int Id, string? ShipmentNumber)>();
 01322            for (int i = 0; i < numbers.Count; i += PrefixBatchSize)
 1323            {
 01324                var batch = numbers.Skip(i).Take(PrefixBatchSize).Select(n => n + "-").ToList();
 01325                var batchResults = await GetOrdersByShipmentNumberPrefixesAsync(batch, ozonClientId);
 01326                allResults.AddRange(batchResults);
 1327            }
 01328            return allResults;
 01329        }
 1330
 1331        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesAsync(List<string> p
 1332        {
 01333            if (prefixes.Count == 0 || ozonClientId <= 0)
 01334                return new List<(int Id, string? ShipmentNumber)>();
 1335
 01336            var parameter = Expression.Parameter(typeof(Order), "o");
 01337            Expression? body = null;
 1338
 01339            foreach (var prefix in prefixes)
 1340            {
 01341                var shipmentNumberProp = Expression.Property(parameter, nameof(Order.ShipmentNumber));
 01342                var startsWithCall = Expression.Call(
 01343                    shipmentNumberProp,
 01344                    nameof(string.StartsWith),
 01345                    Type.EmptyTypes,
 01346                    Expression.Constant(prefix));
 1347
 01348                var notNullCheck = Expression.NotEqual(
 01349                    shipmentNumberProp,
 01350                    Expression.Constant(null, typeof(string)));
 1351
 01352                var condition = Expression.AndAlso(notNullCheck, startsWithCall);
 01353                body = body == null ? condition : Expression.OrElse(body, condition);
 1354            }
 1355
 01356            var predicate = Expression.Lambda<Func<Order, bool>>(body!, parameter);
 1357
 01358            var items = await _context.Orders
 01359                .AsNoTracking()
 01360                .Where(o => o.OrderArchive == null && o.OzonClientId == ozonClientId)
 01361                .Where(predicate)
 01362                .Select(o => new { o.Id, o.ShipmentNumber })
 01363                .ToListAsync();
 1364
 01365            return items.Select(x => (x.Id, x.ShipmentNumber)).ToList();
 01366        }
 1367
 1368        private async Task<List<(int Id, string? ShipmentNumber)>> GetOrdersByShipmentNumberPrefixesAsync(List<string> p
 1369        {
 41370            if (prefixes.Count == 0)
 01371                return new List<(int Id, string? ShipmentNumber)>();
 1372
 41373            var parameter = Expression.Parameter(typeof(Order), "o");
 41374            Expression? body = null;
 1375
 161376            foreach (var prefix in prefixes)
 1377            {
 41378                var shipmentNumberProp = Expression.Property(parameter, nameof(Order.ShipmentNumber));
 41379                var startsWithCall = Expression.Call(
 41380                    shipmentNumberProp,
 41381                    nameof(string.StartsWith),
 41382                    Type.EmptyTypes,
 41383                    Expression.Constant(prefix));
 1384
 41385                var notNullCheck = Expression.NotEqual(
 41386                    shipmentNumberProp,
 41387                    Expression.Constant(null, typeof(string)));
 1388
 41389                var condition = Expression.AndAlso(notNullCheck, startsWithCall);
 41390                body = body == null ? condition : Expression.OrElse(body, condition);
 1391            }
 1392
 41393            var predicate = Expression.Lambda<Func<Order, bool>>(body!, parameter);
 1394
 41395            var items = await _context.Orders
 41396                .AsNoTracking()
 41397                .Where(o => o.OrderArchive == null)
 41398                .Where(predicate)
 41399                .Select(o => new { o.Id, o.ShipmentNumber })
 41400                .ToListAsync();
 1401
 41402            return items.Select(x => (x.Id, x.ShipmentNumber)).ToList();
 41403        }
 1404
 1405        /// <summary>
 1406        /// Получить все заказы по Ozon SKU (FBS) с учетом клиента
 1407        /// </summary>
 1408        public async Task<List<Order>> GetOrdersBySkuAsync(string sku, int clientId)
 1409        {
 01410            if (string.IsNullOrEmpty(sku) || clientId <= 0)
 01411                return new List<Order>();
 1412
 01413            return await _context.Orders
 01414                .AsNoTracking()
 01415                .Where(o => o.OrderArchive == null && o.OzonClientId == clientId) // Только активные заказы конкретного 
 01416                .Include(o => o.OzonClient)
 01417                .Include(o => o.AppStatus)
 01418                .Include(o => o.ClientStatus)
 01419                .Include(o => o.ProductInfo)
 01420                .Where(o => o.ProductInfo != null && o.ProductInfo.FbsOzonSkuId == sku)
 01421                .ToListAsync();
 01422        }
 1423    }
 1424
 1425    /// <summary>Лёгкая проекция заказа для отображения в результатах сопоставления (импорт выдачи Ozon и т.п.).</summar
 1426    public sealed class OrderHandoverMatchInfo
 1427    {
 1428        public int Id { get; set; }
 1429        public string? ShipmentNumber { get; set; }
 1430        public string? Article { get; set; }
 1431        public string? ClientName { get; set; }
 1432        public string? ClientStatusName { get; set; }
 1433        public string? ClientStatusColor { get; set; }
 1434        public string? AppStatusName { get; set; }
 1435        public string? AppStatusColor { get; set; }
 1436    }
 1437}

Methods/Properties

.ctor(OzonRepositories.Context.ApplicationDbContext,OzonRepositories.Data.IOrderChangeNotifier)
ApplyIncludes(System.Linq.IQueryable`1<OzonDomains.Models.Order>)
ApplyStandardIncludes(System.Linq.IQueryable`1<OzonDomains.Models.Order>)
BaseQuery(System.Boolean,System.Boolean)
PopulateCartItemStatusAsync()
Get()
GetByProductKeyPrefixes(System.Collections.Generic.HashSet`1<System.String>)
GetAsync()
GetQueryable(System.Boolean)
GetAsync()
GetAsyncNoTracking()
GetOrdersNoTracking()
GetOrderIdsByNumbersAndArticles()
GetMatchedNumbersAndArticles()
NormalizeShipmentNumberForMatching(System.String)
GetAsyncForCart()
GetAsync()
GetOrdersByManufacturerCode(System.String)
Update()
UpdateRange()
ClearOrderNavigationsForUpdate(OzonDomains.Models.Order)
SetIsReturnedAsync()
SetIsReturnedForOrderIdsAsync()
ReconcileIsReturnedFlagAsync()
SetIsReturnedInChunksAsync()
GetOrdersForStatusUpdateAsync()
GetOrdersWithPagination()
GetTotalOrderCount()
GetReturnableCount()
GetOrdersByIds()
GetOrdersByIdsTracked()
BuildOrdersQuery(System.Collections.Generic.List`1<System.Int32>,System.Boolean)
BuildOrdersQueryTracked(System.Collections.Generic.List`1<System.Int32>,System.Boolean)
LoadOrderExtensionsForOrders()
GetUniqueValues()
GetShipmentNumbers()
GetUniqueShipmentNumbersGrouped()
LoadAdditionalData(System.Collections.Generic.List`1<OzonDomains.Models.Order>)
LoadOrderItemsForOrders()
LoadExtraExpensesForOrders()
Delete()
GetTotalCount()
GetZasylAsync()
HasZasylValueAsync()
SetZasylAsync()
SetZasylAsync()
BuildUpsertZasylSql()
BuildDeleteZasylSql()
GetAllZasylValuesAsync()
SetTransactionDocumentAsync()
GetTransactionDocument()
GetOrderByShipmentNumberAsync()
GetOrderIdsByShipmentNumbersAsync()
GetOrderIdsByShipmentNumbersAsync()
GetHandoverMatchInfoByIdsAsync()
GetOrdersByShipmentNumberPrefixesBatchedAsync()
GetOrdersByShipmentNumberPrefixesBatchedAsync()
GetOrdersByShipmentNumberPrefixesAsync()
GetOrdersByShipmentNumberPrefixesAsync()
GetOrdersBySkuAsync()