< Summary

Information
Class: Services.DataServices.OrdersDataServices
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/DataServices/OrdersDataServices.cs
Line coverage
20%
Covered lines: 269
Uncovered lines: 1048
Coverable lines: 1317
Total lines: 2623
Line coverage: 20.4%
Branch coverage
19%
Covered branches: 159
Total branches: 814
Branch coverage: 19.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
GetOrders()100%210%
GetOrdersQueryable(...)100%210%
GetAnalyticsQueryable()100%210%
GetOrder()100%11100%
GetAsyncNoTracking()100%11100%
GetOrdersNoTracking()100%210%
GetOrder()100%210%
UpdateOrder()100%210%
UpdateOrders()100%210%
GetOrders()100%210%
GetReturnableCount()100%210%
GetOrdersByManufacturerCode()100%210%
DeleteOrder()0%4260%
GetUniqueArticles()100%210%
GetUniqueDeliveryCities()100%210%
GetUniqueShipmentNumbers()100%210%
GetArticleCountsForOrdersAsync(...)100%11100%
GetDeliveryCityCountsForOrdersAsync(...)100%210%
GetShipmentBaseCountsForOrdersAsync(...)100%210%
MultiplayDeleteOrders()0%7280%
AddOrders()100%210%
AddOrdersCore()0%620%
<AddOrdersCore()0%1482380%
RemoveDuplicateOrdersByKey()0%812280%
GetOrderActivityScore(...)0%702260%
get_Id()100%210%
get_Key()100%210%
get_ShipmentNumber()100%210%
get_Article()100%210%
get_ProductName()100%210%
get_OzonClientName()100%210%
get_UpdatedColumns()100%210%
get_IsVerified()100%210%
get_IsAccepted()100%210%
get_SupplierId()100%210%
get_OrderNumberToSupplier()100%210%
get_Comment()100%210%
get_LastStatusChangeDate()100%210%
get_CostPrice()100%210%
get_PurchasePrice()100%210%
get_AppStatusId()100%210%
MultiplayEditOrder()0%110100%
TransactOrder()29.31%10315833.87%
SetUpdatedBy(...)50%2266.66%
UpdateOrderWeb()77.77%3039070.27%
UpdateOrder()0%1190340%
GetOrderChangeList(...)100%11100%
CompareObjects(...)86.84%413886.66%
ConfirmAccepted()0%620%
SaveExtraExpensesForOrders()0%272160%
CastToModel()15%5674030.95%
BindShipmentAsync()50%2275%
CastToModelForOzon()0%110100%
Coalesce(...)0%620%
SetIsReturnable()20%181057.14%
CalculateCostPriceForNotFullOrders()0%2040%
NormalizeArticle(...)0%620%
IsQuantityCompatible(...)0%2040%
GetNotFullOrdersModel(...)0%264061620%
ProcessingNotFullOrder()0%2040%
ProcessingNotFullOrderForExcel()0%2040%
PrepareNewOrder()100%210%
ProcessExistingOrders()0%2040%
ProcessOrdersForExcel()0%2040%
UpdateOrderProperties(...)0%2040%
GetRussianCurrentTime()100%210%
CastToFullOrder()0%272160%
GetAppStatusAsync()0%620%
GetManufacturerById(...)0%620%
GetWarehouseAsync()0%620%
GetSupplierAsync()0%620%
GetProductAsync()0%620%
GetOzonClientById(...)0%620%
SetNumberInExcel()0%620%
SetUniqueShipmentNumberAndKey()0%4260%
CancellationOrders()0%272160%
IsStatusAllowedForCancellation(...)0%620%
MergeOrders(...)0%420200%
SaveChanges()100%11100%
SplitOrder()0%930300%
GetImportIdentity(...)16.66%66100%
DeduplicateIncomingOrders(...)100%22100%
SelectExistingOrderForImport(...)70%1010100%
TrimShipmentNumber(...)70%101085.71%
GetOrderIdsByNumbersAndArticles()100%210%
GetMatchedNumbersAndArticles()100%210%
GetOrderIdsByShipmentNumbersForClientAsync()100%210%
GetOrderIdsAndMatchedShipmentsByShipmentListAsync()0%210140%
GetOrderIdsAndMatchedShipmentsByShipmentListAsync()0%156120%
NormalizeOrderNumberForMatching(...)100%210%
NormalizeShipmentNumberForMatching(...)100%210%
GetZasylAsync()100%210%
GetTotalCount()100%210%
LoadOrderExtensionsForOrders()100%210%
LoadOrderItemsForOrders()100%210%
LoadExtraExpensesForOrders()100%210%
GetOrdersByIds()100%210%
GetOrdersByIdsTracked()100%210%
SaveChangesAsync()100%210%
SetZasylAsync()100%210%
HasZasylValueAsync()100%210%
GetAllZasylValuesAsync()100%210%
SetTransactionDocumentAsync()100%210%
GetTransactionDocument()100%210%
SaveClientStatusUpdatesAsync()0%110100%
GetArticleCountsForKeysAsync()100%22100%
GetDeliveryCityCountsForCitiesAsync()100%22100%
GetShipmentBaseCountsForPrefixesAsync()50%4486.66%
ApplyShipmentBasePrefixFilter(...)66.66%6694.11%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/DataServices/OrdersDataServices.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Logging;
 3using System.Data.Common;
 4using OzonDomains.Models;
 5using OzonDomains.Models.AccessControl;
 6using OzonRepositories.Context;
 7using OzonRepositories.Data;
 8using OzonRepositories.Utils;
 9using Services.DataServices.DTO;
 10using Services.PriceСlculationServices;
 11using System.Linq.Expressions;
 12using System.Reflection;
 13using System.Text;
 14using System.Text.RegularExpressions;
 15using OServices.FiltersServices.FilterModels;
 16using OzonDomains;
 17using Services.ApiServices.TelegramBotApi;
 18using Services.FiltersServices.DataFilterManagers;
 19using SlqStudio.Application.Services.EmailService;
 20using Services.OzonWebhookServices;
 21
 22namespace Services.DataServices
 23{
 2124    public class OrdersDataServices(
 2125        OrderRepository repository,
 2126        ApplicationDbContext context,
 2127        OrderPriceManager orderPriceManager,
 2128        AppStatusDataServices appStatusDataServices,
 2129        SupplierDataServices supplierDataServices,
 2130        ProductsDataServices productsDataServices,
 2131        WarehouseDataServices warehouseDataServices,
 2132        ExcludedArticleDataServices excludedArticleDataServices,
 2133        NewOrdersTelegramNotifier newOrdersTelegramNotifier,
 2134        OrderExtraExpenseDataServices orderExtraExpenseDataServices,
 2135        IEmailService emailService,
 2136        ShipmentRepository shipmentRepository,
 2137        IOrderChangeNotifier changeNotifier,
 2138        IOzonPostingCancellationService cancellationService,
 2139        ILogger<OrdersDataServices> logger)
 40        : IDataServices
 41    {
 2142        private readonly ILogger<OrdersDataServices> _logger = logger;
 2143        private readonly OrderExtraExpenseDataServices _orderExtraExpenseDataServices = orderExtraExpenseDataServices;
 2144        private readonly IEmailService _emailService = emailService;
 2145        private readonly ShipmentRepository _shipmentRepository = shipmentRepository;
 2146        private readonly IOrderChangeNotifier _changeNotifier = changeNotifier;
 47
 2148        private readonly string[] _ignoredProperties =
 2149        [
 2150            "AppStatus", "СurrencyId", "ProductInfoId", "UpdatedColumns", "Id", "AppStatusId", "ShipmentWarehouseId",
 2151            "IsVerified", "Comment", "Key", "IsAccepted", "MaxCommissionInfo", "MinCommissionInfo", "MinDiscount",
 2152            "MaxDiscount", "PurchasePrice", "Supplier", "SupplierId", "MinProfit", "MaxProfit", "IsReturnable",
 2153            "OrderNumberToSupplier", "FromFile", "OriginalPurchasePrice", "CostPrice", "PurchasePrice"
 2154        ];
 55
 2156        private readonly string[] _ignoredWebProperties =
 2157        [
 2158            "СurrencyId", "ProductInfoId", "UpdatedColumns", "Id",
 2159            "ShipmentWarehouse", "Сurrency", "ProductInfo", "MaxCommissionInfo", "MinCommissionInfo",
 2160            "IsVerified", "Key", "IsAccepted", "OzonClient", "FromFile", "OrderExtensions"
 2161        ];
 62
 2163        private readonly string[] _ignoredWebNullProperties =
 2164        [
 2165            "OrderNumberToSupplier", "Supplier", "SupplierId"
 2166        ];
 67
 68        /// <summary>
 69        /// Поля, которые считает сам сервер в <see cref="CastToModel"/>. В форме транзакции их нет,
 70        /// поэтому фильтр «пришло ли поле с формы» отбрасывал уже посчитанные значения: они не
 71        /// попадали в UpdatedColumns, а значит не сохранялись и не отражались в истории заказа.
 72        /// UpdatedBy сюда не входит: на POST он приходит с дефолтом модели, а не с именем пользователя.
 73        /// </summary>
 174        private static readonly HashSet<string> _serverCalculatedProperties =
 175            new(StringComparer.OrdinalIgnoreCase)
 176            {
 177                nameof(Order.CostPrice),
 178                nameof(Order.MinProfit),
 179                nameof(Order.MaxProfit),
 180                nameof(Order.MinDiscount),
 181                nameof(Order.MaxDiscount)
 182            };
 83
 2184        private readonly string[] _priceProperties =
 2185        [
 2186            "ShipmentAmount",
 2187            "Price",
 2188            "PurchasePrice",
 2189            "OriginalPurchasePrice",
 2190            "MinOzonCommission",
 2191            "MaxOzonCommission",
 2192            "MinProfit",
 2193            "MaxProfit",
 2194            "MinDiscount",
 2195            "MaxDiscount",
 2196            "CostPrice"
 2197        ];
 98
 199        private static readonly System.Reflection.PropertyInfo[] _cachedOrderProperties =
 1100            typeof(Order).GetProperties(
 1101                System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
 102
 103        public async Task<IQueryable<Order>> GetOrders(bool includeArchived = false)
 104        {
 0105            var result = await repository.GetAsync(includeArchived);
 106
 0107            return result;
 0108        }
 109
 110        /// <summary>
 111        /// Возвращает IQueryable заказов БЕЗ загрузки данных в память.
 112        /// Используется для применения фильтров на уровне БД.
 113        /// </summary>
 114        public IQueryable<Order> GetOrdersQueryable(bool includeArchived = false)
 115        {
 0116            return repository.GetQueryable(includeArchived);
 117        }
 118
 119        /// <summary>
 120        /// Заказы для аналитики: без Include и трекинга, включая архивные. Для узких проекций (Select) —
 121        /// аналитические отчёты не должны грузить граф сущностей заказа.
 122        /// </summary>
 0123        public IQueryable<Order> GetAnalyticsQueryable() => context.Orders.AsNoTracking();
 124
 125        public async Task<Order?> GetOrder(int id)
 126        {
 4127            var result = await repository.GetAsync(id);
 128
 4129            return result;
 4130        }
 131
 132        public async Task<Order?> GetAsyncNoTracking(int id)
 133        {
 3134            var result = await repository.GetAsyncNoTracking(id);
 3135            return result;
 3136        }
 137
 138        /// <summary>
 139        /// Загружает заказы без трекинга EF Core.
 140        /// Используется в GET методах транзакций - изменения статусов для отображения НЕ сохранятся в БД.
 141        /// </summary>
 142        public async Task<List<Order>> GetOrdersNoTracking(int[] ids)
 143        {
 0144            return await repository.GetOrdersNoTracking(ids);
 0145        }
 146
 147        public async Task<Order?> GetOrder(Order order)
 148        {
 0149            var result = await repository.GetAsync(order);
 150
 0151            return result;
 0152        }
 153
 154        public async Task<int> UpdateOrder(Order order)
 155        {
 0156            return await repository.Update(order);
 0157        }
 158
 159        public async Task<int> UpdateOrders(List<Order> orders)
 160        {
 0161            return await repository.UpdateRange(orders);
 0162        }
 163
 164        public async Task<List<Order>> GetOrders(int skip, int take)
 165        {
 0166            return await repository.GetOrdersWithPagination(skip, take);
 0167        }
 168
 169        public async Task<int> GetReturnableCount()
 170        {
 0171            return await repository.GetReturnableCount();
 0172        }
 173
 174        public async Task<IQueryable<Order>> GetOrdersByManufacturerCode(string code)
 175        {
 0176            await Task.CompletedTask;
 0177            return repository.GetOrdersByManufacturerCode(code);
 0178        }
 179
 180        public async Task<int> DeleteOrder(int orderId)
 181        {
 0182            Order? orderToDelete = await repository.GetAsync(orderId);
 183
 0184            if (orderToDelete != null && orderToDelete.AppStatus?.Name == "Не указан")
 185            {
 0186                return await repository.Delete(orderToDelete);
 187            }
 188
 0189            return 0;
 0190        }
 191
 192        /// <summary>
 193        /// Устаревший глобальный GROUP BY по всей таблице. Используйте <see cref="GetArticleCountsForOrdersAsync"/>.
 194        /// </summary>
 195        public Task<Dictionary<string, int>> GetUniqueArticles() =>
 0196            Task.FromResult(new Dictionary<string, int>());
 197
 198        /// <summary>
 199        /// Устаревший глобальный GROUP BY по всей таблице. Используйте <see cref="GetDeliveryCityCountsForOrdersAsync"/
 200        /// </summary>
 201        public Task<Dictionary<string, int>> GetUniqueDeliveryCities() =>
 0202            Task.FromResult(new Dictionary<string, int>());
 203
 204        /// <summary>
 205        /// Устаревший полный скан ShipmentNumber. Используйте <see cref="GetShipmentBaseCountsForOrdersAsync"/>.
 206        /// </summary>
 207        public Task<Dictionary<string, int>> GetUniqueShipmentNumbers() =>
 0208            Task.FromResult(new Dictionary<string, int>());
 209
 210        /// <summary>
 211        /// Глобальные счётчики по ProductKey только для ключей, видимых в <paramref name="orders"/>.
 212        /// </summary>
 213        public Task<Dictionary<string, int>> GetArticleCountsForOrdersAsync(
 214            IEnumerable<Order> orders, bool includeArchived = false)
 215        {
 1216            var keys = orders
 2217                .Where(o => !string.IsNullOrEmpty(o.ProductKey))
 2218                .Select(o => o.ProductKey!)
 1219                .Distinct()
 1220                .ToList();
 1221            return GetArticleCountsForKeysAsync(keys, includeArchived);
 222        }
 223
 224        /// <summary>
 225        /// Глобальные счётчики по DeliveryCity только для городов, видимых в <paramref name="orders"/>.
 226        /// </summary>
 227        public Task<Dictionary<string, int>> GetDeliveryCityCountsForOrdersAsync(
 228            IEnumerable<Order> orders, bool includeArchived = false)
 229        {
 0230            var cities = orders
 0231                .Where(o => !string.IsNullOrEmpty(o.DeliveryCity))
 0232                .Select(o => o.DeliveryCity!)
 0233                .Distinct()
 0234                .ToList();
 0235            return GetDeliveryCityCountsForCitiesAsync(cities, includeArchived);
 236        }
 237
 238        /// <summary>
 239        /// Глобальные счётчики по базовому номеру отправления (до первого «-») для номеров на странице.
 240        /// </summary>
 241        public Task<Dictionary<string, int>> GetShipmentBaseCountsForOrdersAsync(
 242            IEnumerable<Order> orders, bool includeArchived = false)
 243        {
 0244            var prefixes = orders
 0245                .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber))
 0246                .Select(o => o.ShipmentNumber!.Split('-', 2)[0])
 0247                .Distinct(StringComparer.Ordinal)
 0248                .ToList();
 0249            return GetShipmentBaseCountsForPrefixesAsync(prefixes, includeArchived);
 250        }
 251
 252        public async Task<int> MultiplayDeleteOrders(int[] idArray)
 253        {
 0254            int result = 0;
 255
 0256            foreach (int orderId in idArray)
 257            {
 0258                Order? orderToDelete = await repository.GetAsync(orderId);
 259
 0260                if (orderToDelete != null && orderToDelete.AppStatus?.Name == "Не указан")
 261                {
 0262                    result += await repository.Delete(orderToDelete);
 263                }
 264            }
 265
 0266            return result;
 0267        }
 268
 269        /// <summary>
 270        /// ╔═══════════════════════════════════════════════════════════╗
 271        /// ║                                                           ║
 272        /// ║    ███╗   ██╗███████╗      ████████╗██████╗  ██████╗     ║
 273        /// ║    ████╗  ██║██╔════╝      ╚══██╔══╝██╔══██╗██╔═══██╗    ║
 274        /// ║    ██╔██╗ ██║█████╗  █████╗   ██║   ██████╔╝██║   ██║    ║
 275        /// ║    ██║╚██╗██║██╔══╝  ╚════╝   ██║   ██╔══██╗██║   ██║    ║
 276        /// ║    ██║ ╚████║███████╗         ██║   ██║  ██║╚██████╔╝    ║
 277        /// ║    ╚═╝  ╚═══╝╚══════╝         ╚═╝   ╚═╝  ╚═╝ ╚═════╝     ║
 278        /// ║                                                           ║
 279        /// ║    ═══════════════════════════════════════════════════    ║
 280        /// ║    Единственный метод для добавления новых заказов       ║
 281        /// ║    в систему. Гарантирует отсутствие дублей и            ║
 282        /// ║    централизованное создание заказов.                    ║
 283        /// ║    ═══════════════════════════════════════════════════    ║
 284        /// ║                                                           ║
 285        /// ╚═══════════════════════════════════════════════════════════╝
 286        /// </summary>
 287        public async Task<int[]> AddOrders(List<Order> orders, List<string>? addedShipmentNumbers = null)
 288        {
 289            // Параллельные импорты (Hangfire + ручная загрузка) оба не находят заказ в БД и вставляют по копии.
 290            // Сериализуем вставку: второй импорт начнёт после коммита первого и найдёт строки как существующие.
 0291            await AddOrdersLock.WaitAsync();
 292            try
 293            {
 0294                return await AddOrdersCore(orders, addedShipmentNumbers);
 295            }
 296            finally
 297            {
 0298                AddOrdersLock.Release();
 299            }
 0300        }
 301
 1302        private static readonly SemaphoreSlim AddOrdersLock = new(1, 1);
 303
 304        private async Task<int[]> AddOrdersCore(List<Order> orders, List<string>? addedShipmentNumbers)
 305        {
 0306            orders = await excludedArticleDataServices.ExcludeOrdersAsync(orders);
 0307            orders = DeduplicateIncomingOrders(orders);
 0308            if (orders.Count == 0)
 0309                return new[] { 0, 0 };
 310
 0311            var newOrdersForNotification = new List<TelegramNewOrderItem>();
 0312            var result = await TransactionHelper.ExecuteInTransactionAsync(context, async (transaction) =>
 0313            {
 0314                // Лямбда может выполниться повторно (retry стратегии) — список не должен копить дубли.
 0315                newOrdersForNotification.Clear();
 0316                var chunkSize = 500;
 0317                var updateCount = 0;
 0318                var addCount = 0;
 0319                var ordersToInsert = new List<Order>();
 0320                var queuedIdentities = new HashSet<(string Shipment, string Position)>();
 0321                var status = context.AppStatuses.FirstOrDefault(s => s.Name == "Не указан");
 0322
 0323                for (int i = 0; i < orders.Count; i += chunkSize)
 0324                {
 0325                    var currentChunk = orders.Skip(i).Take(chunkSize).ToList();
 0326
 0327                    foreach (var order in currentChunk)
 0328                    {
 0329                        Order? existingOrder = null;
 0330
 0331                        if (order.OzonClient?.ClientType == ClientType.YANDEX)
 0332                        {
 0333                            existingOrder = context.Orders.FirstOrDefault(o => o.Key == order.Key);
 0334                            bool endsWithSlashNumber = existingOrder != null &&
 0335                                                       !string.IsNullOrEmpty(existingOrder.ShipmentNumber) &&
 0336                                                       Regex.IsMatch(existingOrder.ShipmentNumber, @"/\d+$");
 0337                            if (endsWithSlashNumber)
 0338                                continue;
 0339                        }
 0340                        else
 0341                        {
 0342                            var trimmedShipmentNumber = TrimShipmentNumber(order.ShipmentNumber);
 0343
 0344                            var existingOrders = context.Orders
 0345                                .Where(o => o.Article == order.Article)
 0346                                .AsEnumerable()
 0347                                .Where(o => TrimShipmentNumber(o.ShipmentNumber) == trimmedShipmentNumber)
 0348                                .ToList();
 0349
 0350                            var endsWithSlashNumber = existingOrders
 0351                                .FirstOrDefault(o =>
 352                                    !string.IsNullOrEmpty(o.ShipmentNumber) &&
 353                                    Regex.IsMatch(o.ShipmentNumber, @"/\d+$"));
 0354
 0355                            if (endsWithSlashNumber != null)
 0356                                continue;
 0357
 0358                            existingOrder = SelectExistingOrderForImport(existingOrders, order);
 0359                        }
 0360
 0361                        try
 0362                        {
 0363                            if (existingOrder != null)
 0364                            {
 0365                                await UpdateOrder(existingOrder, order);
 0366                                updateCount++;
 0367                            }
 0368                            else
 0369                            {
 0370                                // Позиция могла уже встать в очередь на вставку в этом проходе — вторую копию не создаё
 0371                                if (!queuedIdentities.Add(GetImportIdentity(order)))
 0372                                    continue;
 0373
 0374                                var newOrder = await CastToModel(order);
 0375                                if (newOrder.OzonPromotionSnapshot != null)
 0376                                {
 0377                                    newOrder.OzonPromotionSnapshot.OzonClientId = newOrder.OzonClient?.Id;
 0378                                }
 0379                                ordersToInsert.Add(newOrder);
 0380                                if (!string.IsNullOrWhiteSpace(newOrder.ShipmentNumber))
 0381                                    newOrdersForNotification.Add(new TelegramNewOrderItem(newOrder.ShipmentNumber,
 0382                                        newOrder.OzonClient?.Name, newOrder.ShipmentWarehouse?.Name,
 0383                                        newOrder.ProductKey ?? newOrder.Article, newOrder.ProductName,
 0384                                        newOrder.ProductInfo?.ImageUrl));
 0385                                if (!string.IsNullOrEmpty(newOrder.ShipmentNumber))
 0386                                    addedShipmentNumbers?.Add(newOrder.ShipmentNumber);
 0387                                addCount++;
 0388                            }
 0389                        }
 0390                        catch (Exception e)
 0391                        {
 0392                            Console.WriteLine("---AddOrders---");
 0393                            Console.WriteLine(e);
 0394                            continue;
 0395                        }
 0396                    }
 0397
 0398                    await context.Orders.AddRangeAsync(ordersToInsert);
 0399                    await context.SaveChangesAsync();
 400                    _changeNotifier.NotifyOrdersChanged(ordersToInsert.Select(o => o.Id));
 0401                    ordersToInsert.Clear();
 0402                }
 0403
 0404                await RemoveDuplicateOrdersByKey();
 0405                await cancellationService.LinkImportedOrdersAsync(orders);
 0406                return new[] { addCount, updateCount };
 0407            });
 408
 409            // Уведомляем после фиксации транзакции: откат или повтор не должны порождать сообщения о несуществующих зак
 0410            await newOrdersTelegramNotifier.NotifyAsync(newOrdersForNotification);
 0411            return result;
 0412        }
 413
 414        /// <summary>
 415        /// Удаляет дубли заказов и отправляет email уведомление, если дубли найдены.
 416        /// Оставляет заказ с наибольшим количеством измененных полей (с которым больше работал пользователь).
 417        ///
 418        /// Дубль — совпадение <c>Key</c> И <c>ShipmentNumber</c>. Одного <c>Key</c> недостаточно:
 419        /// парсеры строят его как «номер отправления на момент импорта + артикул», и после смены
 420        /// номера отправления <c>Key</c> остаётся прежним. Из-за этого разные отправления одного
 421        /// заказа с одним артикулом (45583074-0100-3 и 45583074-0100-5) выглядели дублями, и джоба
 422        /// пыталась удалить легитимный заказ — от потери данных спасали только FK с RESTRICT.
 423        /// </summary>
 424        private async Task RemoveDuplicateOrdersByKey()
 425        {
 426            try
 427            {
 428                // Сначала находим только ключи с дублями на стороне БД. Раньше сюда загружались
 429                // все заказы с Key вместе с OzonClient, хотя почти все ключи уникальны.
 0430                var duplicateKeys = await context.Orders
 0431                    .AsNoTracking()
 0432                    .Where(o => o.Key != null && o.Key != string.Empty)
 0433                    .GroupBy(o => new { o.Key, o.ShipmentNumber })
 0434                    .Where(group => group.Count() > 1)
 0435                    .Select(group => group.Key.Key!)
 0436                    .Distinct()
 0437                    .ToListAsync();
 438
 439                // Пробельные ключи раньше игнорировались. Отсеиваем их после компактной выборки
 440                // ключей, чтобы не оборачивать индексированную колонку Key в TRIM() в SQL.
 0441                duplicateKeys = duplicateKeys
 442                    .Where(key => !string.IsNullOrWhiteSpace(key))
 0443                    .ToList();
 444
 0445                if (duplicateKeys.Count == 0)
 446                {
 0447                    return;
 448                }
 449
 450                // Для выбора сохраняемого заказа и email нужны только эти поля, а не весь Order.
 0451                var duplicateOrders = await context.Orders
 0452                    .AsNoTracking()
 0453                    .Where(o => o.Key != null && duplicateKeys.Contains(o.Key))
 0454                    .Select(o => new DuplicateOrderCandidate
 0455                    {
 0456                        Id = o.Id,
 0457                        Key = o.Key!,
 0458                        ShipmentNumber = o.ShipmentNumber,
 0459                        Article = o.Article,
 0460                        ProductName = o.ProductName,
 0461                        OzonClientName = o.OzonClient == null ? null : o.OzonClient.Name,
 0462                        UpdatedColumns = o.UpdatedColumns,
 0463                        IsVerified = o.IsVerified,
 0464                        IsAccepted = o.IsAccepted,
 0465                        SupplierId = o.SupplierId,
 0466                        OrderNumberToSupplier = o.OrderNumberToSupplier,
 0467                        Comment = o.Comment,
 0468                        LastStatusChangeDate = o.LastStatusChangeDate,
 0469                        CostPrice = o.CostPrice,
 0470                        PurchasePrice = o.PurchasePrice,
 0471                        AppStatusId = o.AppStatusId
 0472                    })
 0473                    .ToListAsync();
 474
 475                // Группируем по паре «Key + номер отправления»: заказы с одним Key, но разными
 476                // номерами отправлений — разные отправления, а не дубли.
 0477                var duplicateGroups = duplicateOrders
 478                    .GroupBy(o => (o.Key, ShipmentNumber: o.ShipmentNumber ?? string.Empty))
 479                    .Where(g => g.Count() > 1)
 0480                    .ToList();
 481
 0482                if (duplicateGroups.Count == 0)
 483                {
 0484                    return;
 485                }
 486
 0487                var candidates = new List<DuplicateOrderCandidate>();
 488
 0489                foreach (var group in duplicateGroups)
 490                {
 0491                    var ordersToKeep = group
 492                        .OrderByDescending(o => GetOrderActivityScore(o))
 493                        .ThenByDescending(o => o.Id)
 0494                        .First();
 495
 0496                    candidates.AddRange(group.Where(o => o.Id != ordersToKeep.Id));
 497                }
 498
 0499                if (candidates.Count == 0)
 500                {
 0501                    return;
 502                }
 503
 504                // Удаляем по одному заказу, а не одним ExecuteDeleteAsync по всем Id: на Orders
 505                // висят внешние ключи с RESTRICT (OrderSupplierStates, корзины и позиции заказов
 506                // поставщиков), и один заблокированный заказ отменял удаление всей пачки — дубли
 507                // не удалялись никогда, а джоба импорта повторяла ту же ошибку каждый прогон.
 508                // Заказ с зависимыми записями пропускаем: за ним стоит работа пользователя,
 509                // молча ронять её нельзя.
 0510                var deletedOrders = new List<DuplicateOrderCandidate>();
 0511                var blockedOrders = new List<(DuplicateOrderCandidate Order, string Reason)>();
 512
 0513                foreach (var candidate in candidates)
 514                {
 515                    try
 516                    {
 0517                        var affected = await context.Orders
 0518                            .Where(o => o.Id == candidate.Id)
 0519                            .ExecuteDeleteAsync();
 520
 0521                        if (affected > 0)
 522                        {
 0523                            deletedOrders.Add(candidate);
 524                        }
 0525                    }
 0526                    catch (DbException ex)
 527                    {
 528                        // 23000 — нарушение ограничения целостности (для MySQL это 1451:
 529                        // на заказ ссылается строка в зависимой таблице с ON DELETE RESTRICT).
 0530                        blockedOrders.Add((candidate, ex.Message));
 0531                    }
 0532                }
 533
 0534                if (blockedOrders.Count > 0)
 535                {
 0536                    _logger.LogWarning(
 0537                        "Дубли заказов: не удалось удалить {Count} заказ(ов) из-за зависимых записей. {Details}",
 0538                        blockedOrders.Count,
 0539                        string.Join("; ", blockedOrders.Select(b =>
 540                            $"Id={b.Order.Id}, Key={b.Order.Key}, ShipmentNumber={b.Order.ShipmentNumber}: {b.Reason}"))
 541                }
 542
 0543                var deletedCount = deletedOrders.Count;
 544
 0545                if (deletedCount > 0)
 546                {
 0547                    var emailBody = new StringBuilder();
 0548                    emailBody.AppendLine("<h2>Обнаружены и удалены дубли заказов (Key + номер отправления)</h2>");
 0549                    emailBody.AppendLine($"<p><strong>Всего удалено дублей: {deletedCount}</strong></p>");
 0550                    emailBody.AppendLine("<hr>");
 0551                    emailBody.AppendLine("<h3>Детали удаленных заказов:</h3>");
 0552                    emailBody.AppendLine(
 0553                        "<table border='1' cellpadding='5' cellspacing='0' style='border-collapse: collapse;'>");
 0554                    emailBody.AppendLine(
 0555                        "<tr><th>ID</th><th>Key</th><th>ShipmentNumber</th><th>Article</th><th>ProductName</th><th>OzonC
 556
 0557                    foreach (var deletedOrder in deletedOrders)
 558                    {
 0559                        emailBody.AppendLine("<tr>");
 0560                        emailBody.AppendLine($"<td>{deletedOrder.Id}</td>");
 0561                        emailBody.AppendLine($"<td>{deletedOrder.Key ?? "N/A"}</td>");
 0562                        emailBody.AppendLine($"<td>{deletedOrder.ShipmentNumber ?? "N/A"}</td>");
 0563                        emailBody.AppendLine($"<td>{deletedOrder.Article ?? "N/A"}</td>");
 0564                        emailBody.AppendLine($"<td>{deletedOrder.ProductName ?? "N/A"}</td>");
 0565                        emailBody.AppendLine($"<td>{deletedOrder.OzonClientName ?? "N/A"}</td>");
 0566                        emailBody.AppendLine("</tr>");
 567                    }
 568
 0569                    emailBody.AppendLine("</table>");
 0570                    emailBody.AppendLine("<hr>");
 0571                    emailBody.AppendLine($"<p><small>Время проверки: {DateTime.Now:yyyy-MM-dd HH:mm:ss}</small></p>");
 572
 0573                    await _emailService.SendEmailAsync(
 0574                        "Studio 2: Удалены дубли заказов",
 0575                        emailBody.ToString()
 0576                    );
 577                }
 0578            }
 0579            catch (Exception ex)
 580            {
 0581                _logger.LogError(ex, "Ошибка при удалении дублей заказов");
 0582            }
 0583        }
 584
 585        /// <summary>
 586        /// Вычисляет "оценку активности" заказа - чем выше, тем больше пользователь работал с заказом.
 587        /// </summary>
 588        private static int GetOrderActivityScore(DuplicateOrderCandidate order)
 589        {
 0590            int score = 0;
 591
 592            // Количество измененных полей (основной показатель)
 0593            if (order.UpdatedColumns != null && order.UpdatedColumns.Any())
 594            {
 0595                score += order.UpdatedColumns.Count * 100; // Каждое измененное поле = 100 очков
 596            }
 597
 598            // Заказ был проверен пользователем
 0599            if (order.IsVerified)
 600            {
 0601                score += 50;
 602            }
 603
 604            // Заказ был принят
 0605            if (order.IsAccepted == true)
 606            {
 0607                score += 30;
 608            }
 609
 610            // Назначен поставщик (признак работы пользователя)
 0611            if (order.SupplierId.HasValue)
 612            {
 0613                score += 25;
 614            }
 615
 616            // Есть номер заказа поставщику
 0617            if (!string.IsNullOrWhiteSpace(order.OrderNumberToSupplier))
 618            {
 0619                score += 20;
 620            }
 621
 622            // Есть комментарий
 0623            if (!string.IsNullOrWhiteSpace(order.Comment))
 624            {
 0625                score += 15;
 626            }
 627
 628            // Изменялся статус приложения (признак активности)
 0629            if (order.LastStatusChangeDate.HasValue)
 630            {
 0631                score += 10;
 632            }
 633
 634            // Назначена себестоимость (признак обработки)
 0635            if (order.CostPrice.HasValue && order.CostPrice.Value > 0)
 636            {
 0637                score += 10;
 638            }
 639
 640            // Назначена закупочная цена (признак обработки)
 0641            if (order.PurchasePrice.HasValue && order.PurchasePrice.Value > 0)
 642            {
 0643                score += 10;
 644            }
 645
 646            // Назначен статус приложения (признак работы)
 0647            if (order.AppStatusId.HasValue)
 648            {
 0649                score += 5;
 650            }
 651
 0652            return score;
 653        }
 654
 655        private sealed class DuplicateOrderCandidate
 656        {
 0657            public int Id { get; init; }
 0658            public string Key { get; init; } = string.Empty;
 0659            public string? ShipmentNumber { get; init; }
 0660            public string? Article { get; init; }
 0661            public string? ProductName { get; init; }
 0662            public string? OzonClientName { get; init; }
 0663            public List<string>? UpdatedColumns { get; init; }
 0664            public bool IsVerified { get; init; }
 0665            public bool? IsAccepted { get; init; }
 0666            public int? SupplierId { get; init; }
 0667            public string? OrderNumberToSupplier { get; init; }
 0668            public string? Comment { get; init; }
 0669            public DateTime? LastStatusChangeDate { get; init; }
 0670            public decimal? CostPrice { get; init; }
 0671            public decimal? PurchasePrice { get; init; }
 0672            public int? AppStatusId { get; init; }
 673        }
 674
 675
 676        public async Task<int> MultiplayEditOrder(IEnumerable<Order> orders, string userName)
 677        {
 0678            foreach (Order order in orders)
 679            {
 680                try
 681                {
 0682                    Order complitOrder = await CastToModel(order);
 0683                    Order? existingOrder = await GetOrder(complitOrder.Id);
 0684                    if (existingOrder == null)
 0685                        throw new InvalidOperationException($"Заказ {complitOrder.Id} не найден");
 0686                    await UpdateOrderWeb(existingOrder, complitOrder);
 0687                    if (existingOrder.AppStatus?.Name != complitOrder.AppStatus?.Name)
 688                    {
 0689                        existingOrder.LastStatusChangeDate = DateTime.Now;
 690                    }
 691
 0692                    existingOrder.IsVerified = true;
 0693                    existingOrder.UpdatedBy = userName;
 0694                }
 0695                catch (Exception ex)
 696                {
 0697                    Console.WriteLine($"Не удалось изменить заказ {order.ShipmentNumber}");
 0698                    Console.WriteLine(ex.Message);
 0699                    Console.WriteLine(ex.StackTrace);
 0700                    Console.WriteLine(ex.Data);
 701
 0702                    throw new Exception(message: $"Не удалось изменить заказ: {ex.Message}");
 703                }
 0704            }
 705
 0706            return await SaveChanges();
 0707        }
 708
 709        /// <param name="submittedFields">
 710        /// Имена свойств заказа, которые реально пришли с формы. Поле, скрытое правами доступа,
 711        /// не рендерится и приходит как null — без этого списка null трактовался как «очистить»
 712        /// и стирал статус, поставщика и номер заказа поставщику. null означает «список неизвестен»
 713        /// и сохраняет прежнее поведение.
 714        /// </param>
 715        public async Task<Order> TransactOrder(Order order, IReadOnlySet<string>? submittedFields = null, string? update
 716        {
 717            // Модель из представления (со всеми изменёнными полями)
 4718            var complitOrder = await CastToModel(order);
 719
 720            // Текущий заказ из БД
 4721            var existingOrder = await GetOrder(complitOrder.Id)
 4722                ?? throw new InvalidOperationException($"Заказ {complitOrder.Id} не найден");
 723
 724            // Количество в БД и новое количество из формы
 4725            var originalQuantity = existingOrder.Quantity ?? 0;
 4726            var newQuantity = complitOrder.Quantity ?? originalQuantity;
 727
 4728            if (newQuantity < 0)
 729            {
 0730                throw new ArgumentException("Количество не может быть меньше нуля");
 731            }
 732
 4733            if (newQuantity > originalQuantity)
 734            {
 0735                throw new ArgumentException("Новое количество не может быть больше исходного");
 736            }
 737
 738            // Если количество уменьшили до 0 — трактуем это как "вся позиция в отмену".
 739            // ВАЖНО: не делаем SplitOrder, иначе появятся /1 и /2 (в т.ч. /2 с Quantity=0).
 740            // Статус "К отмене" будет выставлен на уровне транзакционного контроллера
 741            // (AfterTransactOrdersAsync -> ApplyQuantitySplitCancellationLogic) по признаку Quantity==0.
 4742            if (newQuantity == 0 && originalQuantity > 0)
 743            {
 0744                await UpdateOrderWeb(existingOrder, complitOrder, submittedFields);
 745
 0746                if (existingOrder.AppStatus?.Name != complitOrder.AppStatus?.Name)
 747                {
 0748                    existingOrder.LastStatusChangeDate = DateTime.Now;
 749                }
 750
 0751                existingOrder.IsVerified = true;
 0752                SetUpdatedBy(existingOrder, updatedBy);
 753
 0754                if (!complitOrder.ShipmentWarehouseId.HasValue &&
 0755                    order.ShipmentWarehouse != null &&
 0756                    order.ShipmentWarehouse.Id == 0 &&
 0757                    order.ShipmentWarehouse.Name != null)
 758                {
 0759                    existingOrder.ShipmentWarehouse =
 0760                        await warehouseDataServices.GetWarehouseAsync(new Warehouse
 0761                            { Name = order.ShipmentWarehouse.Name });
 762                }
 763
 0764                await SaveChanges();
 0765                return existingOrder;
 766            }
 767
 768            // Если количество не менялось — работаем по старой схеме
 4769            if (newQuantity == originalQuantity || originalQuantity <= 1)
 770            {
 4771                await UpdateOrderWeb(existingOrder, complitOrder, submittedFields);
 772
 4773                if (existingOrder.AppStatus?.Name != complitOrder.AppStatus?.Name)
 774                {
 3775                    existingOrder.LastStatusChangeDate = DateTime.Now;
 776                }
 777
 4778                existingOrder.IsVerified = true;
 4779                SetUpdatedBy(existingOrder, updatedBy);
 780
 4781                if (!complitOrder.ShipmentWarehouseId.HasValue &&
 4782                    order.ShipmentWarehouse != null &&
 4783                    order.ShipmentWarehouse.Id == 0 &&
 4784                    order.ShipmentWarehouse.Name != null)
 785                {
 0786                    existingOrder.ShipmentWarehouse =
 0787                        await warehouseDataServices.GetWarehouseAsync(new Warehouse
 0788                            { Name = order.ShipmentWarehouse.Name });
 789                }
 790
 4791                await SaveChanges();
 4792                return existingOrder;
 793            }
 794
 795            // Если количество уменьшилось — разделяем заказ:
 796            // в оригинальном остаётся остаток, для транзакции создаём новый заказ
 0797            var remainingQuantity = originalQuantity - newQuantity;
 798
 799            // Разделяем исходный заказ: в existingOrder останется остаток, вернём новый заказ
 0800            var splittedOrder = await SplitOrder(existingOrder.Id, remainingQuantity, newQuantity);
 0801            if (splittedOrder == null)
 0802                throw new InvalidOperationException($"Не удалось разделить заказ {existingOrder.Id}");
 803
 804            // Настраиваем модель из представления под новый заказ
 0805            complitOrder.Id = splittedOrder.Id;
 0806            complitOrder.Quantity = splittedOrder.Quantity;
 807
 808            // ВАЖНО: оставляем номер отправления и ShipmentId из нового заказа,
 809            // чтобы не перезатереть суффиксы (/1, /2) и привязку к отправлению данными из формы
 0810            complitOrder.ShipmentNumber = splittedOrder.ShipmentNumber;
 0811            complitOrder.ShipmentId = splittedOrder.ShipmentId;
 812
 813            // Применяем изменения только к новому заказу (оригинал остаётся "нетронутым", кроме количества/номера)
 0814            await UpdateOrderWeb(splittedOrder, complitOrder, submittedFields);
 815
 0816            if (splittedOrder.AppStatus?.Name != complitOrder.AppStatus?.Name)
 817            {
 0818                splittedOrder.LastStatusChangeDate = DateTime.Now;
 819            }
 820
 0821            splittedOrder.IsVerified = true;
 0822            SetUpdatedBy(splittedOrder, updatedBy);
 823
 0824            if (!complitOrder.ShipmentWarehouseId.HasValue &&
 0825                order.ShipmentWarehouse != null &&
 0826                order.ShipmentWarehouse.Id == 0 &&
 0827                order.ShipmentWarehouse.Name != null)
 828            {
 0829                splittedOrder.ShipmentWarehouse =
 0830                    await warehouseDataServices.GetWarehouseAsync(new Warehouse
 0831                        { Name = order.ShipmentWarehouse.Name });
 832            }
 833
 0834            await SaveChanges();
 0835            return splittedOrder;
 4836        }
 837
 838        /// <summary>Триггер orders_history берёт автора правки из Orders.UpdatedBy (по умолчанию «Ozon»), поэтому имя п
 839        private static void SetUpdatedBy(Order order, string? updatedBy)
 840        {
 4841            if (!string.IsNullOrWhiteSpace(updatedBy))
 842            {
 0843                order.UpdatedBy = updatedBy;
 844            }
 4845        }
 846
 847        private async Task UpdateOrderWeb(Order existingOrder, Order jsonOrder, IReadOnlySet<string>? submittedFields = 
 848        {
 4849            var isVerified = existingOrder.IsVerified;
 4850            var isIsAccepted = existingOrder.IsAccepted;
 851
 4852            var orderChangeList = GetOrderChangeList(existingOrder, jsonOrder, _ignoredWebProperties);
 853
 854            // Отсутствующее в форме поле выглядит как изменение «значение -> null»: без фильтра
 855            // такие мнимые правки попадали бы в UpdatedColumns и в историю заказа.
 856            // Серверные расчётные поля из фильтра исключены: их в форме нет никогда,
 857            // и без исключения они выпадали из UpdatedColumns, а значит и из сохранения.
 4858            if (submittedFields != null)
 859            {
 3860                orderChangeList = orderChangeList
 24861                    .Where(name => submittedFields.Contains(name) || _serverCalculatedProperties.Contains(name))
 3862                    .ToList();
 863            }
 864
 4865            if (orderChangeList.Any())
 866            {
 2867                existingOrder.UpdatedColumns = orderChangeList;
 868            }
 869
 870            // Если UpdatedColumns пуст, но AppStatusId был передан, создаем список для обновления
 4871            if (existingOrder.UpdatedColumns == null || !existingOrder.UpdatedColumns.Any())
 872            {
 2873                existingOrder.UpdatedColumns = new List<string>();
 874            }
 875
 4876            var appStatusSubmitted = submittedFields == null || submittedFields.Contains(nameof(Order.AppStatusId));
 877
 878            // Проверяем, был ли передан AppStatusId из формы, даже если он не попал в UpdatedColumns
 4879            if (appStatusSubmitted && jsonOrder.AppStatusId.HasValue && existingOrder.AppStatusId != jsonOrder.AppStatus
 880            {
 0881                if (!existingOrder.UpdatedColumns.Contains("AppStatusId"))
 882                {
 0883                    existingOrder.UpdatedColumns.Add("AppStatusId");
 884                }
 885            }
 886
 4887            if (existingOrder.UpdatedColumns != null)
 888            {
 4889                var properties = _cachedOrderProperties;
 890
 960891                foreach (var prop in properties)
 892                {
 893                    try
 894                    {
 476895                        var jsonProp = jsonOrder.GetType().GetProperty(prop.Name);
 476896                        var existingProp = existingOrder.GetType().GetProperty(prop.Name);
 897
 476898                        if (jsonProp == null || existingProp == null || !existingProp.CanWrite)
 0899                            continue;
 900
 901                        // Поля, которого не было в форме, пользователь изменить не мог:
 902                        // его null — следствие скрытой колонки, а не очистки значения.
 903                        // Исключение — то, что посчитал сам сервер: оно и не должно приходить с формы.
 476904                        if (submittedFields != null
 476905                            && !submittedFields.Contains(prop.Name)
 476906                            && !_serverCalculatedProperties.Contains(prop.Name))
 335907                            continue;
 908
 141909                        var value = jsonProp.GetValue(jsonOrder);
 910
 141911                        if (prop.Name == "OrderExtensions")
 1912                            continue;
 913
 914                        // Специальная обработка для AppStatusId - всегда обновляем, если он был передан из формы
 140915                        if (prop.Name == "AppStatusId")
 916                        {
 1917                            var jsonAppStatusId = jsonProp.GetValue(jsonOrder) as int?;
 1918                            var existingAppStatusId = existingProp.GetValue(existingOrder) as int?;
 919
 920                            // Обновляем AppStatusId, если он был передан и отличается от текущего
 1921                            if (jsonAppStatusId.HasValue && jsonAppStatusId != existingAppStatusId)
 922                            {
 0923                                existingProp.SetValue(existingOrder, jsonAppStatusId);
 924                                // Убеждаемся, что AppStatusId добавлен в UpdatedColumns
 0925                                if (existingOrder.UpdatedColumns != null &&
 0926                                    !existingOrder.UpdatedColumns.Contains("AppStatusId"))
 927                                {
 0928                                    existingOrder.UpdatedColumns.Add("AppStatusId");
 929                                }
 930                            }
 931                            // Также обновляем, если AppStatusId был передан как null (для сброса статуса)
 1932                            else if (!jsonAppStatusId.HasValue && existingAppStatusId.HasValue)
 933                            {
 1934                                existingProp.SetValue(existingOrder, null);
 1935                                if (existingOrder.UpdatedColumns != null &&
 1936                                    !existingOrder.UpdatedColumns.Contains("AppStatusId"))
 937                                {
 0938                                    existingOrder.UpdatedColumns.Add("AppStatusId");
 939                                }
 940                            }
 941
 1942                            continue;
 943                        }
 944
 139945                        bool shouldUpdate =
 139946                            value != null && prop.Name != "Id" && existingOrder.UpdatedColumns?.Contains(prop.Name) == t
 139947                            value == null && _ignoredWebNullProperties.Contains(prop.Name);
 948
 139949                        if (shouldUpdate)
 950                        {
 5951                            existingProp.SetValue(existingOrder, value);
 952                        }
 139953                    }
 0954                    catch (Exception ex)
 955                    {
 0956                        Console.WriteLine($"Error setting property {prop.Name}: {ex.Message}");
 0957                    }
 958                }
 959            }
 960
 961            // Явно обновляем AppStatus из AppStatusId, если AppStatusId был изменен
 962            // Это важно для транзакций, где статус может быть изменен в форме
 4963            if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("AppStatusId"))
 964            {
 1965                if (existingOrder.AppStatusId.HasValue)
 966                {
 0967                    existingOrder.AppStatus =
 0968                        await appStatusDataServices.GetAppStatusAsync(existingOrder.AppStatusId.Value);
 969                }
 970
 1971                existingOrder.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow,
 1972                    TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 973            }
 974            // Также обновляем AppStatus, если он был изменен напрямую (для обратной совместимости)
 3975            else if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("AppStatus"))
 976            {
 0977                if (existingOrder.AppStatusId.HasValue)
 978                {
 0979                    existingOrder.AppStatus =
 0980                        await appStatusDataServices.GetAppStatusAsync(existingOrder.AppStatusId.Value);
 981                }
 982            }
 983            // Если AppStatusId был передан, но не попал в UpdatedColumns (например, при первом сохранении)
 3984            else if (jsonOrder.AppStatusId.HasValue && existingOrder.AppStatusId != jsonOrder.AppStatusId)
 985            {
 0986                existingOrder.AppStatusId = jsonOrder.AppStatusId;
 0987                existingOrder.AppStatus = await appStatusDataServices.GetAppStatusAsync(jsonOrder.AppStatusId.Value);
 0988                existingOrder.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow,
 0989                    TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 990            }
 991
 4992            if (existingOrder.UpdatedColumns != null && !existingOrder.UpdatedColumns.Contains("IsAccepted"))
 993            {
 4994                existingOrder.IsAccepted = false;
 995            }
 996            else
 997            {
 0998                existingOrder.IsAccepted = isIsAccepted;
 999            }
 1000
 41001            existingOrder.IsVerified = isVerified;
 1002
 1003            // Сохраняем изменения
 41004            await context.SaveChangesAsync();
 1005
 1006            // Проверяем и удаляем дубли по Key после обновления (только если Key был изменен)
 41007            if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("Key"))
 1008            {
 01009                await RemoveDuplicateOrdersByKey();
 1010            }
 41011        }
 1012
 1013        private async Task UpdateOrder(Order existingOrder, Order jsonOrder)
 1014        {
 01015            var isVerified = existingOrder.IsVerified;
 01016            var isIsAccepted = existingOrder.IsAccepted;
 1017
 01018            if (isVerified)
 1019            {
 1020            }
 1021
 01022            var orderChangeList = GetOrderChangeList(existingOrder, jsonOrder, _ignoredProperties);
 1023
 01024            if (orderChangeList.Any())
 1025            {
 01026                existingOrder.UpdatedColumns = orderChangeList;
 1027            }
 1028            else
 1029            {
 01030                existingOrder.UpdatedColumns = null;
 1031            }
 1032
 01033            if (existingOrder.UpdatedColumns != null)
 1034            {
 01035                var properties = _cachedOrderProperties;
 01036                var jsonOrderType = jsonOrder.GetType();
 1037
 01038                foreach (var prop in properties)
 1039                {
 1040                    try
 1041                    {
 01042                        var jsonOrderProp = jsonOrderType.GetProperty(prop.Name);
 1043
 01044                        if (jsonOrderProp != null)
 1045                        {
 01046                            var o = jsonOrderProp.GetValue(jsonOrder);
 1047
 01048                            if (o != null && prop.Name != "Id" && existingOrder.UpdatedColumns.Contains(prop.Name))
 1049                            {
 1050                                // 🔒 проверка: не даём обновлять ценовые поля, если они уже заданы
 01051                                if (_priceProperties.Contains(prop.Name))
 1052                                {
 01053                                    var currentValue = prop.GetValue(existingOrder);
 01054                                    if (currentValue != null)
 1055                                    {
 01056                                        continue; // пропускаем обновление этого свойства
 1057                                    }
 1058                                }
 1059
 01060                                var existingOrderProp = existingOrder.GetType().GetProperty(prop.Name);
 01061                                if (existingOrderProp != null && existingOrderProp.CanWrite)
 1062                                {
 01063                                    existingOrderProp.SetValue(existingOrder, o);
 1064                                }
 1065                            }
 1066                        }
 01067                    }
 01068                    catch (Exception ex)
 1069                    {
 01070                        Console.WriteLine($"Error setting property {prop.Name}: {ex.Message}");
 01071                    }
 1072                }
 1073            }
 1074
 01075            if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("AppStatusId"))
 1076            {
 01077                existingOrder.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow,
 01078                    TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 1079            }
 1080
 01081            if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns[0] != "IsAccepted")
 1082            {
 01083                existingOrder.IsAccepted = false;
 1084            }
 1085            else
 1086            {
 01087                existingOrder.IsAccepted = isIsAccepted;
 1088            }
 1089
 01090            existingOrder.IsVerified = isVerified;
 1091
 1092            // Привязка к отправлению при изменении ShipmentNumber
 01093            if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("ShipmentNumber"))
 1094            {
 01095                await BindShipmentAsync(existingOrder);
 1096            }
 1097
 1098            //await CastToModel(existingOrder);
 01099        }
 1100
 1101        private List<string> GetOrderChangeList(Order existingOrder, Order jsonOrder, string[] ignoredProperties)
 1102        {
 41103            List<string> changeProp = new List<string>();
 1104
 41105            changeProp = CompareObjects(existingOrder, jsonOrder, changeProp, ignoredProperties);
 1106
 41107            return changeProp;
 1108        }
 1109
 1110        private List<string> CompareObjects(object obj1, object obj2, List<string> changeProp,
 1111            string[] ignoredProperties)
 1112        {
 41113            Type type = obj1.GetType();
 1114
 9601115            foreach (PropertyInfo property in type.GetProperties())
 1116            {
 4761117                if (property.CanRead)
 1118                {
 4761119                    if (ignoredProperties.Contains(property.Name))
 1120                    {
 1121                        continue;
 1122                    }
 1123
 4161124                    object? val1 = string.Equals(property.GetValue(obj1) as string, string.Empty) ? null : property.GetV
 4161125                    object? val2 = string.Equals(property.GetValue(obj2) as string, string.Empty) ? null : property.GetV
 1126
 1127                    // Специальная обработка для AppStatus - сравниваем по ID, а не как объект
 4161128                    if (property.Name == "AppStatus")
 1129                    {
 41130                        int? id1 = null;
 41131                        int? id2 = null;
 1132
 41133                        if (val1 != null)
 1134                        {
 41135                            var idProp1 = val1.GetType().GetProperty("Id");
 41136                            if (idProp1 != null)
 1137                            {
 41138                                id1 = (int?)idProp1.GetValue(val1);
 1139                            }
 1140                        }
 1141
 41142                        if (val2 != null)
 1143                        {
 01144                            var idProp2 = val2.GetType().GetProperty("Id");
 01145                            if (idProp2 != null)
 1146                            {
 01147                                id2 = (int?)idProp2.GetValue(val2);
 1148                            }
 1149                        }
 1150
 41151                        if (id1 != id2)
 1152                        {
 41153                            changeProp.Add(
 41154                                "AppStatusId"); // Добавляем AppStatusId вместо AppStatus для правильного обновления
 1155                        }
 1156
 41157                        continue;
 1158                    }
 1159
 4121160                    if (val1 != null && val2 != null)
 1161                    {
 321162                        if (property.PropertyType.Assembly == type.Assembly && !property.PropertyType.IsPrimitive &&
 321163                            property.PropertyType != typeof(string))
 1164                        {
 01165                            CompareObjects(val1, val2, changeProp, ignoredProperties);
 1166                        }
 321167                        else if (!val1.Equals(val2))
 1168                        {
 41169                            changeProp.Add(property.Name);
 1170                        }
 1171                    }
 3801172                    else if (val1 != null || val2 != null)
 1173                    {
 241174                        changeProp.Add(property.Name);
 1175                    }
 1176                }
 1177            }
 1178
 41179            return changeProp;
 1180        }
 1181
 1182
 1183        public async Task ConfirmAccepted(int id)
 1184        {
 01185            var order = await repository.GetAsync(id);
 01186            if (order == null)
 01187                return;
 1188
 01189            order.IsAccepted = true;
 01190            order.IsVerified = true;
 01191            order.UpdatedColumns = null;
 1192
 01193            await SaveChanges();
 01194        }
 1195
 1196        /// <summary>
 1197        /// Сохраняет дополнительные расходы для списка заказов.
 1198        /// Вызывается из контроллера транзакций вместо прямой работы с OrderExtraExpenseDataServices.
 1199        /// </summary>
 1200        public async Task SaveExtraExpensesForOrders(List<Order> orders)
 1201        {
 01202            if (orders == null || orders.Count == 0)
 01203                return;
 1204
 1205            // Получаем список всех существующих ExpenseTypeId из базы данных
 01206            var existingExpenseTypeIds = await context.ExpenseTypes
 01207                .Where(et => et.IsActive)
 01208                .Select(et => et.Id)
 01209                .ToListAsync();
 1210
 1211            var orderIds = orders.Select(o => o.Id).ToList();
 1212
 1213            // Батч-удаление: один запрос SELECT + один DELETE для всех заказов (вместо N запросов)
 01214            var toDelete = await context.OrderExtraExpenses
 01215                .Where(e => orderIds.Contains(e.OrderId))
 01216                .ToListAsync();
 1217
 01218            if (toDelete.Any())
 1219            {
 01220                context.OrderExtraExpenses.RemoveRange(toDelete);
 01221                await context.SaveChangesAsync();
 1222            }
 1223
 1224            // Собираем все валидные расходы для батч-вставки
 01225            var allNewExpenses = new List<OrderExtraExpense>();
 01226            foreach (var order in orders)
 1227            {
 01228                var validExpenses = order.ExtraExpenses?
 01229                    .Where(e => e != null
 01230                                && e.ExpenseTypeId > 0
 01231                                && e.Amount > 0
 01232                                && existingExpenseTypeIds.Contains(e.ExpenseTypeId))
 01233                    .Select(e => new OrderExtraExpense
 01234                    {
 01235                        OrderId = order.Id,
 01236                        ExpenseTypeId = e.ExpenseTypeId,
 01237                        Amount = e.Amount,
 01238                        Comment = e.Comment,
 01239                        CreatedAt = DateTime.UtcNow
 01240                    })
 01241                    .ToList() ?? new List<OrderExtraExpense>();
 1242
 01243                allNewExpenses.AddRange(validExpenses);
 1244            }
 1245
 1246            // Батч-вставка: один INSERT для всех расходов (вместо N запросов)
 01247            if (allNewExpenses.Any())
 1248            {
 01249                await context.OrderExtraExpenses.AddRangeAsync(allNewExpenses);
 01250                await context.SaveChangesAsync();
 1251            }
 01252        }
 1253
 1254        public async Task<Order> CastToModel(Order order)
 1255        {
 1256            // Обновляем AppStatus из AppStatusId, если AppStatusId указан
 1257            // Это важно для транзакций, где статус может быть изменен в форме
 41258            if (order.AppStatusId.HasValue)
 1259            {
 1260                // Проверяем, не установлен ли уже AppStatus с правильным ID
 01261                if (order.AppStatus == null || order.AppStatus.Id != order.AppStatusId.Value)
 1262                {
 1263                    // Проверяем, не отслеживается ли уже AppStatus в контексте
 01264                    var trackedStatus = context.AppStatuses.Local.FirstOrDefault(s => s.Id == order.AppStatusId.Value);
 01265                    if (trackedStatus != null)
 1266                    {
 01267                        order.AppStatus = trackedStatus;
 1268                    }
 1269                    else
 1270                    {
 1271                        // Если AppStatus уже установлен, но ID не совпадает, проверяем его состояние
 01272                        if (order.AppStatus != null)
 1273                        {
 01274                            var entry = context.Entry(order.AppStatus);
 1275                            // Если AppStatus отслеживается, отсоединяем его
 01276                            if (entry.State != EntityState.Detached)
 1277                            {
 01278                                entry.State = EntityState.Detached;
 1279                            }
 1280                        }
 1281
 1282                        // Загружаем AppStatus с AsNoTracking, чтобы избежать конфликтов отслеживания
 01283                        var appStatus = await context.AppStatuses
 01284                            .AsNoTracking()
 01285                            .FirstOrDefaultAsync(s => s.Id == order.AppStatusId.Value);
 1286
 01287                        if (appStatus != null)
 1288                        {
 1289                            // Проверяем, не отслеживается ли уже эта сущность в Local
 01290                            var existingTracked = context.AppStatuses.Local.FirstOrDefault(s => s.Id == appStatus.Id);
 01291                            if (existingTracked != null)
 1292                            {
 01293                                order.AppStatus = existingTracked;
 1294                            }
 1295                            else
 1296                            {
 1297                                // Присоединяем существующую сущность к контексту
 01298                                context.AppStatuses.Attach(appStatus);
 01299                                order.AppStatus = appStatus;
 1300                            }
 1301                        }
 01302                    }
 1303                }
 1304                // Если AppStatus уже установлен и ID совпадает, ничего не делаем
 1305            }
 1306
 41307            if (order.SupplierId.HasValue)
 1308            {
 01309                order.Supplier = await supplierDataServices.GetSupplierAsync(order.SupplierId.Value);
 1310            }
 1311
 41312            decimal? weight = order.ProductInfo?.Weight;
 1313
 41314            if (order.ProductInfoId.HasValue)
 1315            {
 01316                order.ProductInfo = await productsDataServices.GetProductAsync(order.ProductInfoId.Value);
 1317            }
 1318
 41319            if (order.ProductInfo != null)
 1320            {
 01321                order.ProductInfo.Weight = weight;
 01322                await productsDataServices.Update(order.ProductInfo);
 1323            }
 1324
 1325            try
 1326            {
 41327                order = Coalesce(order, await orderPriceManager.SetPurchasePriceToRUB(order));
 01328                order = Coalesce(order, await orderPriceManager.CalculateCostPrice(order));
 1329
 01330                if (order.ExtraExpenses != null && order.ExtraExpenses.Any() && order.CostPrice.HasValue)
 1331                {
 01332                    var totalExtraAmount = order.ExtraExpenses
 1333                        .Where(e => e.ExpenseTypeId > 0 && e.Amount > 0)
 1334                        .Sum(e => e.Amount);
 1335
 01336                    if (totalExtraAmount > 0)
 1337                    {
 01338                        var quantity = order.Quantity > 0 ? order.Quantity : 1;
 01339                        order.CostPrice += totalExtraAmount / quantity;
 1340                    }
 1341                }
 1342
 01343                order = Coalesce(order, await orderPriceManager.CalculateProfit(order));
 01344                order = Coalesce(order, await orderPriceManager.CalculateDiscount(order));
 01345            }
 41346            catch (Exception ex)
 1347            {
 1348                // Ошибка пересчёта цены не должна блокировать сохранение статуса и других полей
 41349                Console.WriteLine($"Ошибка пересчёта цены для заказа {order.Id}: {ex.Message}");
 41350            }
 1351
 41352            await SetIsReturnable(order);
 1353
 1354            // Привязка к отправлению
 41355            await BindShipmentAsync(order);
 1356
 41357            return order;
 41358        }
 1359
 1360        /// <summary>
 1361        /// Привязывает заказ к отправлению по номеру отправления.
 1362        /// Если отправление не существует, создает новое.
 1363        /// </summary>
 1364        private async Task BindShipmentAsync(Order order)
 1365        {
 41366            if (string.IsNullOrWhiteSpace(order.ShipmentNumber))
 41367                return;
 1368
 01369            order.ShipmentId = await _shipmentRepository.GetOrCreateAsync(order.ShipmentNumber);
 41370        }
 1371
 1372        private async Task<Order> CastToModelForOzon(Order order)
 1373        {
 1374            // Всегда обновляем AppStatus из AppStatusId, если AppStatusId указан
 01375            if (order.AppStatusId.HasValue)
 1376            {
 01377                order.AppStatus = await appStatusDataServices.GetAppStatusAsync(order.AppStatusId.Value);
 1378            }
 1379
 01380            if (order.SupplierId.HasValue)
 1381            {
 01382                order.Supplier = await supplierDataServices.GetSupplierAsync(order.SupplierId.Value);
 1383            }
 1384
 01385            decimal? weight = order.ProductInfo?.Weight;
 1386
 01387            if (order.ProductInfoId.HasValue)
 1388            {
 01389                order.ProductInfo = await productsDataServices.GetProductAsync(order.ProductInfoId.Value);
 1390            }
 1391
 01392            if (order.ProductInfo != null)
 1393            {
 01394                order.ProductInfo.Weight = weight;
 01395                await productsDataServices.Update(order.ProductInfo);
 1396            }
 1397
 1398            //order = await orderPriceManager.SetPurchasePriceToRUB(order);
 1399
 1400            //order = await orderPriceManager.CalculateCostPrice(order);
 1401
 1402            //order = await orderPriceManager.CalculateProfit(order);
 1403            //order = await orderPriceManager.CalculateDiscount(order);
 1404
 01405            await SetIsReturnable(order);
 1406
 1407            // Привязка к отправлению
 01408            await BindShipmentAsync(order);
 1409
 01410            return order;
 01411        }
 1412
 01413        private static Order Coalesce(Order order, Order? updated) => updated ?? order;
 1414
 1415        private async Task<Order?> SetIsReturnable(Order? order)
 1416        {
 41417            await Task.CompletedTask;
 41418            if (order?.AppStatus != null)
 1419            {
 01420                if (order.AppStatus.Name == "Заказан поставщику" && order.ClientStatus?.Name == "Отменён")
 1421                {
 01422                    order.IsReturnable = true;
 1423                }
 1424                else
 1425                {
 01426                    order.IsReturnable = false;
 1427                }
 1428            }
 1429
 41430            return order;
 41431        }
 1432
 1433        public async Task<List<Order>> CalculateCostPriceForNotFullOrders(List<Order> orders)
 1434        {
 01435            List<Order> result = new List<Order>();
 1436
 01437            foreach (var order in orders)
 1438            {
 1439                try
 1440                {
 01441                    var reformedOrder = await orderPriceManager.SetPurchasePriceToRUB(order);
 01442                    var calculatedOrder = await orderPriceManager.CalculateCostPrice(reformedOrder);
 01443                    result.Add(calculatedOrder ?? order);
 01444                }
 01445                catch
 1446                {
 01447                    result.Add(order); // добавляем исходный order при ошибке
 01448                }
 01449            }
 1450
 01451            return result;
 01452        }
 1453
 1454        /// <summary>
 1455        /// Нормализует артикул: оставляет только буквы и цифры, приводит к нижнему регистру
 1456        /// </summary>
 1457        private string NormalizeArticle(string article)
 1458        {
 01459            if (string.IsNullOrEmpty(article))
 01460                return string.Empty;
 1461
 1462            // Оставляем только буквы и цифры, приводим к нижнему регистру
 01463            return new string(article
 01464                .Where(c => char.IsLetterOrDigit(c))
 01465                .Select(c => char.ToLowerInvariant(c))
 01466                .ToArray());
 1467        }
 1468
 1469        /// <summary>
 1470        /// Проверяет совместимость по количеству с учётом кратности упаковки [N] в ProductKey.
 1471        /// Excel-заказ совместим с заказом в системе, если excel_qty == system_qty * pack_multiplier.
 1472        /// </summary>
 1473        private static bool IsQuantityCompatible(Order excelOrder, Order repoOrder)
 1474        {
 01475            var excelQty = excelOrder.Quantity ?? 0;
 01476            var repoEffectiveQty = repoOrder.TrueQuantity ?? repoOrder.Quantity ?? 0;
 01477            return excelQty > 0 && excelQty == repoEffectiveQty;
 1478        }
 1479
 1480        public NotFullOrdersModel GetNotFullOrdersModel(OrderDataFilterManager dataFilterManager,
 1481            List<Order> excelOrders,
 1482            OrderFilterModel? orderFilterModel = null,
 1483            AccessProfile? accessProfile = null)
 1484        {
 1485            // Нормализуем артикулы из Excel заказов для поиска в репозитории
 01486            var normalizedProductKeys = excelOrders
 01487                .Where(o => !string.IsNullOrEmpty(o.ProductKey))
 01488                .Select(o => NormalizeArticle(o.ProductKey!.Split('=')[0]))
 01489                .Distinct()
 01490                .Where(k => !string.IsNullOrEmpty(k))
 01491                .ToList();
 1492
 1493            // Получаем все заказы из репозитория, которые могут совпадать
 1494            // Используем оригинальные ключи для запроса, но затем будем сравнивать нормализованные
 01495            var originalProductKeys = excelOrders
 01496                .Where(o => !string.IsNullOrEmpty(o.ProductKey))
 01497                .Select(o => o.ProductKey!.Split('=')[0])
 01498                .Distinct()
 01499                .ToList();
 1500
 01501            var repoOrders = repository.GetByProductKeyPrefixes(originalProductKeys.ToHashSet());
 1502
 1503            // Группируем заказы из репозитория по нормализованному артикулу
 01504            var repoOrdersByArticle = repoOrders
 01505                .Where(o => !string.IsNullOrEmpty(o.ProductKey))
 01506                .GroupBy(o => NormalizeArticle(o.ProductKey!.Split('=')[0]))
 01507                .Where(g => !string.IsNullOrEmpty(g.Key))
 01508                .ToDictionary(g => g.Key, g => g.ToList());
 1509
 1510            // Группируем Excel заказы по нормализованному артикулу
 01511            var excelOrdersByArticle = excelOrders
 01512                .GroupBy(o => NormalizeArticle(o.ProductKey?.Split('=')[0] ?? ""))
 01513                .Where(g => !string.IsNullOrEmpty(g.Key))
 01514                .ToDictionary(g => g.Key, g => g.ToList());
 1515
 01516            var usedRepoOrderIds = new HashSet<int>();
 1517
 01518            var uniqueOrders = new List<Order>();
 01519            var multipleMatches = new Dictionary<Order, List<Order>>();
 01520            var oneMatch = new Dictionary<Order, Order>();
 01521            var distributionReasons = new Dictionary<Order, string>();
 1522
 1523            // Находим заказы без нормализованного артикула и добавляем их в uniqueOrders
 1524            // Это заказы, которые не попали в excelOrdersByArticle из-за пустого или null ProductKey
 01525            var ordersInGroups = excelOrdersByArticle.Values.SelectMany(g => g).ToHashSet();
 01526            var ordersWithoutArticle = excelOrders
 01527                .Where(o => !ordersInGroups.Contains(o))
 01528                .ToList();
 1529
 01530            if (ordersWithoutArticle.Any())
 1531            {
 01532                uniqueOrders.AddRange(ordersWithoutArticle);
 01533                foreach (var excelOrder in ordersWithoutArticle)
 1534                {
 01535                    distributionReasons[excelOrder] = "Не удалось определить артикул заказа";
 1536                }
 1537            }
 1538
 01539            foreach (var (normalizedArticle, excelGroup) in excelOrdersByArticle)
 1540            {
 01541                if (!repoOrdersByArticle.TryGetValue(normalizedArticle, out var repoGroup))
 1542                {
 01543                    uniqueOrders.AddRange(excelGroup);
 01544                    foreach (var excelOrder in excelGroup)
 1545                    {
 01546                        distributionReasons[excelOrder] = "В системе не найдено заказов с таким артикулом";
 1547                    }
 1548
 1549                    continue;
 1550                }
 1551
 1552
 01553                var activeRepoOrders = repoGroup
 01554                    .Where(o =>
 01555                        (o.ClientStatus?.Name != "Отменен" && o.ClientStatus?.Name != "Отменён") &&
 01556                        !usedRepoOrderIds.Contains(o.Id))
 01557                    .ToList();
 1558
 01559                activeRepoOrders =
 01560                    dataFilterManager.FilterByFilterDataFromList(activeRepoOrders, orderFilterModel, accessProfile);
 1561
 01562                if (activeRepoOrders.Count == 0)
 1563                {
 01564                    uniqueOrders.AddRange(excelGroup);
 01565                    foreach (var excelOrder in excelGroup)
 1566                    {
 01567                        var reason = "В системе найдены заказы с таким артикулом, но все они: ";
 01568                        var reasons = new List<string>();
 01569                        if (repoGroup.Any(o => o.ClientStatus?.Name == "Отменен" || o.ClientStatus?.Name == "Отменён"))
 1570                        {
 01571                            reasons.Add("отменены");
 1572                        }
 1573
 01574                        if (orderFilterModel != null)
 1575                        {
 01576                            reasons.Add("не соответствуют примененным фильтрам");
 1577                        }
 1578
 01579                        if (reasons.Count == 0)
 1580                        {
 01581                            reason = "В системе найдены заказы с таким артикулом, но они не прошли фильтрацию";
 1582                        }
 1583                        else
 1584                        {
 01585                            reason += string.Join(" или ", reasons);
 1586                        }
 1587
 01588                        distributionReasons[excelOrder] = reason;
 1589                    }
 1590
 1591                    continue;
 1592                }
 1593
 01594                if (activeRepoOrders.Count == 1)
 1595                {
 01596                    var repoOrder = activeRepoOrders[0];
 1597
 1598                    // Проверяем, есть ли разделенные заказы
 1599                    // Разделенные заказы имеют тот же ProductKey и могут иметь ShipmentNumber с суффиксами /1, /2 и т.д
 1600                    // Ищем все заказы с таким же ProductKey (нормализованным артикулом), которые могут быть разделенным
 01601                    var allRelatedOrders = new List<Order> { repoOrder };
 1602
 1603                    // Ищем все заказы с таким же нормализованным артикулом из всей выборки repoOrders
 1604                    // Это включает разделенные заказы, которые имеют тот же ProductKey
 01605                    var relatedOrders = repoOrders
 01606                        .Where(o => o.ProductKey != null &&
 01607                                    NormalizeArticle(o.ProductKey.Split('=')[0]) == normalizedArticle &&
 01608                                    (o.ClientStatus?.Name != "Отменен" && o.ClientStatus?.Name != "Отменён") &&
 01609                                    o.Id != repoOrder.Id &&
 01610                                    !usedRepoOrderIds.Contains(o.Id))
 01611                        .ToList();
 1612
 1613                    // Проверяем, являются ли найденные заказы разделенными (имеют общий базовый ShipmentNumber)
 1614                    // или просто заказами с таким же артикулом
 01615                    var baseShipmentNumber = repoOrder.ShipmentNumber?.Split('/')[0]?.Trim();
 01616                    if (!string.IsNullOrEmpty(baseShipmentNumber))
 1617                    {
 1618                        // Фильтруем только те заказы, которые имеют тот же базовый ShipmentNumber (разделенные)
 01619                        var splitRelatedOrders = relatedOrders
 01620                            .Where(o => o.ShipmentNumber != null &&
 01621                                        o.ShipmentNumber.Split('/')[0]?.Trim() == baseShipmentNumber)
 01622                            .ToList();
 1623
 01624                        if (splitRelatedOrders.Any())
 1625                        {
 01626                            allRelatedOrders.AddRange(splitRelatedOrders);
 1627                        }
 1628                    }
 1629
 1630                    // Применяем фильтры ко всем связанным заказам
 01631                    allRelatedOrders = dataFilterManager
 01632                        .FilterByFilterDataFromList(allRelatedOrders, orderFilterModel, accessProfile).ToList();
 1633
 01634                    if (allRelatedOrders.Count > 1)
 1635                    {
 1636                        // Сортируем заказы по ShipmentNumber для правильного отображения (разделенные заказы рядом)
 01637                        allRelatedOrders = allRelatedOrders.OrderBy(o => o.ShipmentNumber).ToList();
 1638
 1639                        // Если есть несколько связанных заказов (разделенные), показываем их все
 01640                        foreach (var excelOrder in excelGroup)
 1641                        {
 01642                            multipleMatches[excelOrder] = allRelatedOrders;
 01643                            distributionReasons[excelOrder] =
 01644                                $"Найдено {allRelatedOrders.Count} совпадений в системе (включая разделенные заказы). Тр
 1645                        }
 1646
 1647                        // Помечаем все связанные заказы как использованные
 01648                        foreach (var ro in allRelatedOrders)
 1649                        {
 01650                            usedRepoOrderIds.Add(ro.Id);
 1651                        }
 1652                    }
 1653                    else
 1654                    {
 1655                        // Если только один заказ, используем стандартную логику
 01656                        usedRepoOrderIds.Add(repoOrder.Id);
 01657                        foreach (var excelOrder in excelGroup)
 1658                        {
 01659                            oneMatch[excelOrder] = repoOrder;
 01660                            distributionReasons[excelOrder] = $"Найдено одно совпадение в системе (ID: {repoOrder.Id})";
 1661                        }
 1662                    }
 1663                }
 1664                else
 1665                {
 1666                    // Список заказов, которые уже были назначены Excel заказам в этой группе
 01667                    var assignedInThisGroup = new HashSet<int>();
 1668
 01669                    foreach (var excelOrder in excelGroup)
 1670                    {
 1671                        // Получаем доступные заказы, исключая уже использованные и назначенные в этой группе
 01672                        var availableRepoOrders = activeRepoOrders
 01673                            .Where(o => !usedRepoOrderIds.Contains(o.Id) && !assignedInThisGroup.Contains(o.Id))
 01674                            .GroupBy(o => o.Id) // Группируем по ID для удаления дубликатов
 01675                            .Select(g => g.First()) // Берем первый элемент из каждой группы
 01676                            .ToList();
 1677
 1678                        // Приоритет заказам, совместимым по количеству с учётом кратности упаковки [N] в ProductKey
 01679                        var quantityCompatibleRepoOrders = availableRepoOrders
 01680                            .Where(r => IsQuantityCompatible(excelOrder, r))
 01681                            .ToList();
 01682                        var effectiveCandidates = quantityCompatibleRepoOrders.Count > 0
 01683                            ? quantityCompatibleRepoOrders
 01684                            : availableRepoOrders;
 1685
 01686                        if (availableRepoOrders.Count == 0)
 1687                        {
 01688                            uniqueOrders.Add(excelOrder);
 01689                            distributionReasons[excelOrder] =
 01690                                "Все подходящие заказы уже были использованы для других Excel заказов с таким же артикул
 1691                        }
 01692                        else if (effectiveCandidates.Count == 1)
 1693                        {
 01694                            var repoOrder = effectiveCandidates[0];
 1695
 1696                            // Проверяем, есть ли разделенные заказы для этого заказа
 01697                            var baseShipmentNumber = repoOrder.ShipmentNumber?.Split('/')[0]?.Trim();
 01698                            var allRelatedOrders = new List<Order> { repoOrder };
 1699
 01700                            if (!string.IsNullOrEmpty(baseShipmentNumber) &&
 01701                                repoOrder.ShipmentNumber?.Contains("/") == true)
 1702                            {
 1703                                // Ищем все заказы с таким же базовым номером (включая разделенные)
 01704                                var relatedOrders = repoOrders
 01705                                    .Where(o => o.ProductKey != null &&
 01706                                                NormalizeArticle(o.ProductKey.Split('=')[0]) == normalizedArticle &&
 01707                                                o.ShipmentNumber != null &&
 01708                                                o.ShipmentNumber.Split('/')[0]?.Trim() == baseShipmentNumber &&
 01709                                                o.Status != "Отменен" &&
 01710                                                o.Status != "Отменён" &&
 01711                                                o.Id != repoOrder.Id &&
 01712                                                !usedRepoOrderIds.Contains(o.Id) &&
 01713                                                !assignedInThisGroup.Contains(o.Id))
 01714                                    .ToList();
 1715
 01716                                if (relatedOrders.Any())
 1717                                {
 01718                                    allRelatedOrders.AddRange(relatedOrders);
 1719                                }
 1720                            }
 1721
 1722                            // Применяем фильтры ко всем связанным заказам
 01723                            allRelatedOrders = dataFilterManager
 01724                                .FilterByFilterDataFromList(allRelatedOrders, orderFilterModel, accessProfile).ToList();
 1725
 01726                            if (allRelatedOrders.Count > 1)
 1727                            {
 1728                                // Если есть несколько связанных заказов (разделенные), показываем их все
 1729                                // Сортируем заказы по ShipmentNumber для правильного отображения (разделенные заказы ря
 01730                                allRelatedOrders = allRelatedOrders.OrderBy(o => o.ShipmentNumber).ToList();
 1731
 01732                                multipleMatches[excelOrder] = allRelatedOrders;
 01733                                distributionReasons[excelOrder] =
 01734                                    $"Найдено {allRelatedOrders.Count} совпадений в системе (включая разделенные заказы)
 1735
 1736                                // Помечаем все связанные заказы как назначенные в этой группе
 01737                                foreach (var ro in allRelatedOrders)
 1738                                {
 01739                                    assignedInThisGroup.Add(ro.Id);
 1740                                }
 1741                            }
 1742                            else
 1743                            {
 1744                                // Если только один заказ, используем стандартную логику
 01745                                oneMatch[excelOrder] = repoOrder;
 01746                                usedRepoOrderIds.Add(repoOrder.Id);
 01747                                assignedInThisGroup.Add(repoOrder.Id);
 01748                                distributionReasons[excelOrder] =
 01749                                    $"Найдено одно совпадение в системе (ID: {repoOrder.Id})";
 1750                            }
 1751                        }
 1752                        else
 1753                        {
 1754                            // Убираем дубликаты перед добавлением в multipleMatches
 01755                            var distinctRepoOrders = effectiveCandidates
 01756                                .GroupBy(o => o.Id)
 01757                                .Select(g => g.First())
 01758                                .OrderBy(o =>
 01759                                    o.ShipmentNumber) // Сортируем по ShipmentNumber для правильного отображения
 01760                                .ToList();
 1761
 01762                            multipleMatches[excelOrder] = distinctRepoOrders;
 01763                            distributionReasons[excelOrder] =
 01764                                $"Найдено {distinctRepoOrders.Count} совпадений в системе. Требуется выбор пользователя"
 1765
 1766                            // Помечаем заказы как назначенные в этой группе, чтобы они не попали в другие Excel заказы
 1767                            // Но НЕ помечаем как использованные глобально, так как пользователь еще не выбрал
 01768                            foreach (var ro in distinctRepoOrders)
 1769                            {
 01770                                assignedInThisGroup.Add(ro.Id);
 1771                            }
 1772                        }
 1773                    }
 1774                }
 1775            }
 1776
 1777            // Сохраняем все уникальные заказы до фильтрации
 01778            var allUniqueOrdersBeforeFilter = uniqueOrders.ToList();
 1779
 1780            // Применяем фильтрацию для определения, какие заказы не прошли фильтрацию
 01781            var filteredUniqueOrders =
 01782                dataFilterManager.FilterByFilterDataFromList(uniqueOrders, orderFilterModel, accessProfile);
 1783
 1784            // Определяем заказы, которые не прошли фильтрацию
 01785            var filteredUniqueOrdersSet = new HashSet<Order>(filteredUniqueOrders);
 01786            var unfilteredOrders = allUniqueOrdersBeforeFilter
 01787                .Where(o => !filteredUniqueOrdersSet.Contains(o))
 01788                .ToList();
 1789
 1790            // Обновляем причины для заказов, которые не прошли фильтрацию
 01791            foreach (var unfilteredOrder in unfilteredOrders)
 1792            {
 01793                var existingReason = distributionReasons.ContainsKey(unfilteredOrder)
 01794                    ? distributionReasons[unfilteredOrder]
 01795                    : "";
 1796
 01797                var filterReason = "Заказ не соответствует примененным фильтрам";
 1798
 01799                if (!string.IsNullOrEmpty(existingReason))
 1800                {
 1801                    // Добавляем информацию о фильтрации к существующей причине, если её там еще нет
 01802                    if (!existingReason.Contains("не соответствует примененным фильтрам") &&
 01803                        !existingReason.Contains("не прошли фильтрацию"))
 1804                    {
 01805                        distributionReasons[unfilteredOrder] = $"{existingReason}. {filterReason}";
 1806                    }
 1807                }
 1808                else
 1809                {
 01810                    distributionReasons[unfilteredOrder] = filterReason;
 1811                }
 1812            }
 1813
 1814            // Возвращаем ВСЕ уникальные заказы (и прошедшие, и не прошедшие фильтрацию)
 1815            // Это позволяет пользователю видеть все заказы из файла, даже если они не соответствуют фильтрам
 01816            uniqueOrders = allUniqueOrdersBeforeFilter
 01817                .OrderBy(o => NormalizeArticle(o.ProductKey?.Split('=')[0] ?? ""))
 01818                .ToList();
 1819
 1820            // Проверка: убеждаемся, что все заказы из Excel обработаны
 1821            // Используем сравнение по ссылке, так как это те же объекты из excelOrders
 01822            var processedOrdersSet = new HashSet<Order>();
 01823            foreach (var order in uniqueOrders)
 1824            {
 01825                processedOrdersSet.Add(order);
 1826            }
 1827
 01828            foreach (var order in oneMatch.Keys)
 1829            {
 01830                processedOrdersSet.Add(order);
 1831            }
 1832
 01833            foreach (var order in multipleMatches.Keys)
 1834            {
 01835                processedOrdersSet.Add(order);
 1836            }
 1837
 01838            var unprocessedOrders = excelOrders.Where(o => !processedOrdersSet.Contains(o)).ToList();
 01839            if (unprocessedOrders.Any())
 1840            {
 1841                // Добавляем необработанные заказы в uniqueOrders с соответствующей причиной
 01842                uniqueOrders.AddRange(unprocessedOrders);
 01843                foreach (var unprocessedOrder in unprocessedOrders)
 1844                {
 01845                    if (!distributionReasons.ContainsKey(unprocessedOrder))
 1846                    {
 01847                        distributionReasons[unprocessedOrder] = "Заказ не был обработан (возможно, ошибка в данных)";
 1848                    }
 1849                }
 1850
 1851                // Пересортируем после добавления необработанных заказов
 01852                uniqueOrders = uniqueOrders
 01853                    .OrderBy(o => NormalizeArticle(o.ProductKey?.Split('=')[0] ?? ""))
 01854                    .ToList();
 1855            }
 1856
 01857            return new NotFullOrdersModel
 01858            {
 01859                UniqueOrders = uniqueOrders,
 01860                OrdersWithOneMatches = oneMatch
 01861                    .OrderBy(x => NormalizeArticle(x.Key.ProductKey?.Split('=')[0] ?? ""))
 01862                    .ToDictionary(x => x.Key, x => x.Value),
 01863
 01864                OrdersWithMultipleMatches = multipleMatches
 01865                    .OrderBy(x => NormalizeArticle(x.Key.ProductKey?.Split('=')[0] ?? ""))
 01866                    .ToDictionary(x => x.Key, x => x.Value),
 01867
 01868                DistributionReasons = distributionReasons
 01869            };
 1870        }
 1871
 1872        public async Task<List<Order>> ProcessingNotFullOrder(Order notFullOrder, List<int>? orderIds, string updatedBy)
 1873        {
 01874            List<Order> result = new List<Order>();
 01875            bool isNewOrder = orderIds == null || !orderIds.Any();
 01876            notFullOrder = await CastToFullOrder(notFullOrder, isNewOrder);
 1877
 01878            if (isNewOrder)
 1879            {
 01880                result.Add(await PrepareNewOrder(notFullOrder, updatedBy));
 1881            }
 1882            else
 1883            {
 01884                result = await ProcessExistingOrders(notFullOrder, orderIds!, updatedBy);
 1885            }
 1886
 01887            return result;
 01888        }
 1889
 1890        public async Task<List<Order>> ProcessingNotFullOrderForExcel(Order notFullOrder, List<int>? orderIds)
 1891        {
 01892            bool isNewOrder = orderIds == null || !orderIds.Any();
 01893            notFullOrder = await CastToFullOrder(notFullOrder, isNewOrder);
 1894
 01895            return isNewOrder
 01896                ? new List<Order> { notFullOrder }
 01897                : await ProcessOrdersForExcel(notFullOrder, orderIds!);
 01898        }
 1899
 1900        private async Task<Order> PrepareNewOrder(Order order, string updatedBy)
 1901        {
 01902            order = await CastToModel(order);
 01903            order.UpdatedBy = updatedBy;
 01904            order.LastStatusChangeDate = GetRussianCurrentTime();
 01905            return order;
 01906        }
 1907
 1908        private async Task<List<Order>> ProcessExistingOrders(Order templateOrder, List<int> orderIds, string updatedBy)
 1909        {
 01910            var distinctOrderIds = orderIds.Distinct().ToList();
 01911            List<Order> result = new List<Order>();
 1912
 01913            foreach (var id in distinctOrderIds)
 1914            {
 01915                var order = await repository.GetAsync(id);
 01916                if (order == null) continue;
 1917
 01918                UpdateOrderProperties(order, templateOrder, updatedBy);
 01919                order = Coalesce(order, await orderPriceManager.CalculateProfit(order));
 01920                order = Coalesce(order, await orderPriceManager.CalculateDiscount(order));
 01921                result.Add(order);
 1922            }
 1923
 01924            return result;
 01925        }
 1926
 1927        private async Task<List<Order>> ProcessOrdersForExcel(Order templateOrder, List<int> orderIds)
 1928        {
 01929            var distinctOrderIds = orderIds.Distinct().ToList();
 01930            List<Order> ordersToExcel = new List<Order>();
 1931
 01932            foreach (var id in distinctOrderIds)
 1933            {
 01934                var order = await repository.GetAsync(id);
 01935                if (order == null) continue;
 1936
 01937                UpdateOrderProperties(order, templateOrder);
 01938                order = Coalesce(order, await orderPriceManager.CalculateProfit(order));
 01939                order = Coalesce(order, await orderPriceManager.CalculateDiscount(order));
 01940                ordersToExcel.Add(order);
 1941            }
 1942
 01943            return ordersToExcel;
 01944        }
 1945
 1946        private void UpdateOrderProperties(Order targetOrder, Order sourceOrder, string? updatedBy = null)
 1947        {
 01948            if (targetOrder.AppStatus != sourceOrder.AppStatus)
 1949            {
 01950                targetOrder.LastStatusChangeDate = GetRussianCurrentTime();
 1951            }
 1952
 01953            targetOrder.AppStatus = sourceOrder.AppStatus;
 01954            targetOrder.PurchasePrice = sourceOrder.PurchasePrice;
 01955            targetOrder.CostPrice = sourceOrder.CostPrice;
 01956            targetOrder.OrderNumberToSupplier = sourceOrder.OrderNumberToSupplier;
 01957            if (!string.IsNullOrEmpty(updatedBy))
 1958            {
 01959                targetOrder.UpdatedBy = updatedBy;
 1960            }
 01961        }
 1962
 1963        private DateTime GetRussianCurrentTime()
 1964        {
 01965            return TimeZoneInfo.ConvertTime(DateTime.UtcNow,
 01966                TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 1967        }
 1968
 1969        private async Task<Order> CastToFullOrder(Order notFullOrder, bool isNewOrder)
 1970        {
 01971            notFullOrder.AppStatus = await GetAppStatusAsync(notFullOrder.AppStatus?.Id);
 01972            notFullOrder.Manufacturer = GetManufacturerById(notFullOrder.Manufacturer?.Id);
 01973            notFullOrder.ShipmentWarehouse = await GetWarehouseAsync(notFullOrder.ShipmentWarehouse?.Id);
 01974            notFullOrder.Supplier = await GetSupplierAsync(notFullOrder.SupplierId ?? 0);
 01975            notFullOrder.ProductInfo = await GetProductAsync(notFullOrder.ProductInfo?.Id);
 01976            notFullOrder.OzonClient = GetOzonClientById(notFullOrder.OzonClient?.Id);
 1977
 01978            if (isNewOrder)
 1979            {
 01980                notFullOrder = (await orderPriceManager.CalculateProfit(notFullOrder)) ?? notFullOrder;
 01981                notFullOrder = (await orderPriceManager.CalculateDiscount(notFullOrder)) ?? notFullOrder;
 1982            }
 1983
 01984            return notFullOrder;
 01985        }
 1986
 1987        private async Task<AppStatus?> GetAppStatusAsync(int? id)
 1988        {
 01989            return id != null ? await appStatusDataServices.GetAppStatusAsync(id.Value) : null;
 01990        }
 1991
 1992        private Manufacturer? GetManufacturerById(int? id)
 1993        {
 01994            return id != null ? context.Manufacturers.FirstOrDefault(m => m.Id == id.Value) : null;
 1995        }
 1996
 1997        private async Task<Warehouse?> GetWarehouseAsync(int? id)
 1998        {
 01999            return id != null ? await warehouseDataServices.GetWarehouseAsync(id.Value) : null;
 02000        }
 2001
 2002        private async Task<Supplier?> GetSupplierAsync(int id)
 2003        {
 02004            return id != 0 ? await supplierDataServices.GetSupplierAsync(id) : null;
 02005        }
 2006
 2007        private async Task<Product?> GetProductAsync(int? id)
 2008        {
 02009            return id != null ? await productsDataServices.GetProductAsync(id.Value) : null;
 02010        }
 2011
 2012        private OzonClient? GetOzonClientById(int? id)
 2013        {
 02014            return id != null ? context.OzonClients.FirstOrDefault(o => o.Id == id.Value) : null;
 2015        }
 2016
 2017        public async Task<List<Order>> SetNumberInExcel(List<Order> orders)
 2018        {
 02019            await Task.CompletedTask;
 02020            int numberInExcel = 2;
 02021            foreach (var order in orders)
 2022            {
 02023                order.NumberInExcel = numberInExcel++;
 2024            }
 2025
 02026            return orders;
 02027        }
 2028
 2029
 2030        public async Task<List<Order>> SetUniqueShipmentNumberAndKey(List<Order> orders)
 2031        {
 02032            await Task.CompletedTask;
 02033            var shipmentNumberCounts = new Dictionary<string, int>();
 2034
 02035            foreach (var order in orders)
 2036            {
 02037                if (!string.IsNullOrEmpty(order.ShipmentNumber))
 2038                {
 02039                    if (shipmentNumberCounts.ContainsKey(order.ShipmentNumber))
 2040                    {
 02041                        shipmentNumberCounts[order.ShipmentNumber]++;
 02042                        int number = shipmentNumberCounts[order.ShipmentNumber];
 02043                        order.ShipmentNumber = $"{order.ShipmentNumber}/{number}";
 02044                        order.Key = $"{order.Key}/{number}";
 2045                    }
 2046                    else
 2047                    {
 02048                        shipmentNumberCounts[order.ShipmentNumber] = 1;
 2049                    }
 2050                }
 2051            }
 2052
 02053            return orders;
 02054        }
 2055
 2056        public async Task<int> CancellationOrders(int[] idArray)
 2057        {
 02058            var appStatus = await context.AppStatuses.FirstOrDefaultAsync(a => a.Name == "Отменен");
 2059
 02060            if (appStatus == null)
 2061            {
 02062                AppStatus newStatus = new AppStatus()
 02063                {
 02064                    Name = "Отменен"
 02065                };
 02066                context.AppStatuses.Add(newStatus);
 02067                await context.SaveChangesAsync();
 2068            }
 2069
 02070            foreach (int orderId in idArray)
 2071            {
 02072                Order? orderToCancellation = await repository.GetAsync(orderId);
 2073
 02074                if (orderToCancellation != null &&
 02075                    IsStatusAllowedForCancellation(orderToCancellation.AppStatus?.Name) &&
 02076                    (orderToCancellation.ClientStatus?.Name == "Отменён" || orderToCancellation.ClientStatus == null))
 2077                {
 02078                    orderToCancellation.AppStatus = appStatus;
 02079                    orderToCancellation.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow,
 02080                        TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 2081                }
 2082            }
 2083
 02084            return await SaveChanges();
 02085        }
 2086
 2087        private static bool IsStatusAllowedForCancellation(string? statusName)
 2088        {
 02089            return statusName == "Не указан" || statusName == "Приостановлен";
 2090        }
 2091
 2092        public List<Order> MergeOrders(List<Order> orders)
 2093        {
 02094            return orders
 02095                .GroupBy(o => new { o.Article, o.EtProducer })
 02096                .Select(g =>
 02097                {
 02098                    int? totalQuantity = g.Sum(o => o.Quantity);
 02099                    decimal? totalPurchasePrice = g.Sum(o => o.PurchasePrice * o.Quantity);
 02100                    var firstOrder = g.First();
 02101
 02102                    if (totalQuantity is not > 0)
 02103                    {
 02104                        return (Order?)null;
 02105                    }
 02106
 02107                    var mergedOrder = new Order
 02108                    {
 02109                        Article = g.Key.Article,
 02110                        EtProducer = g.Key.EtProducer,
 02111                        PurchasePrice = Math.Round((decimal)(totalPurchasePrice!.Value / totalQuantity!.Value), 2),
 02112                        Quantity = totalQuantity,
 02113                    };
 02114
 02115                    foreach (PropertyInfo property in _cachedOrderProperties)
 02116                    {
 02117                        if (property.Name == nameof(Order.Article) ||
 02118                            property.Name == nameof(Order.EtProducer) ||
 02119                            property.Name == nameof(Order.PurchasePrice) ||
 02120                            property.Name == nameof(Order.Quantity))
 02121                        {
 02122                            continue;
 02123                        }
 02124
 02125                        property.SetValue(mergedOrder, property.GetValue(firstOrder));
 02126                    }
 02127
 02128                    return mergedOrder;
 02129                })
 02130                .Where(order => order != null)
 02131                .Select(order => order!)
 02132                .OrderBy(o => o.Article)
 02133                .ThenBy(o => o.EtProducer?.Name)
 02134                .ToList();
 2135        }
 2136
 2137        public async Task<int> SaveChanges()
 2138        {
 42139            return await repository.SaveChanges();
 42140        }
 2141
 2142        /// <param name="ozonPostingNumbers">Номера отправлений из ответа Ozon API (posting1 — для оригинала, posting2 —
 2143        /// <param name="totalQuantityOverride">Если задано — проверка суммы частей идёт по этому значению (для синхрони
 2144        public async Task<Order?> SplitOrder(int orderId, int part1, int part2, (string posting1, string posting2)? ozon
 2145        {
 2146            // Получаем заказ с отслеживанием - только ОДИН запрос к БД
 02147            var order = await repository.GetAsync(orderId);
 02148            if (order == null || !order.Quantity.HasValue || order.Quantity <= 1)
 2149            {
 02150                return order;
 2151            }
 2152
 02153            var expectedTotal = totalQuantityOverride ?? order.Quantity.Value;
 02154            if (part1 + part2 != expectedTotal)
 2155            {
 02156                throw new ArgumentException("Сумма частей должна равняться исходному количеству");
 2157            }
 2158
 2159            // Сохраняем оригинальный номер и расширения для нового заказа
 02160            var originalShipmentNumber = order.ShipmentNumber;
 02161            var extensionsToCopy = order.OrderExtensions?.ToList() ?? new List<OrderExtension>();
 2162
 2163            // Номера отправлений: из Ozon API или локальные с суффиксами /1 и /2
 02164            var posting1Number = ozonPostingNumbers?.posting1 ?? originalShipmentNumber + "/1";
 02165            var posting2Number = ozonPostingNumbers?.posting2 ?? originalShipmentNumber + "/2";
 2166
 2167            // Создаем или получаем ShipmentId для обоих отправлений (при Ozon split — всегда, иначе — если был Shipment
 02168            int? newShipmentId = null;
 02169            int? originalShipmentId = null;
 02170            if (order.ShipmentId.HasValue || ozonPostingNumbers != null)
 2171            {
 02172                originalShipmentId = await _shipmentRepository.GetOrCreateAsync(posting1Number);
 02173                newShipmentId = await _shipmentRepository.GetOrCreateAsync(posting2Number);
 2174            }
 2175
 2176            // Создаем новый заказ через клонирование (быстро)
 02177            var newOrder = new Order
 02178            {
 02179                // Копируем все основные поля
 02180                Key = order.Key + "/2",
 02181                ShipmentNumber = posting2Number,
 02182                ProductName = order.ProductName,
 02183                Article = order.Article,
 02184                ProductKey = order.ProductKey,
 02185                Price = order.Price,
 02186                Quantity = part2,
 02187                ShipmentAmount = order.ShipmentAmount,
 02188                ProcessingDate = order.ProcessingDate,
 02189                ShippingDate = order.ShippingDate,
 02190                DeliveryCity = order.DeliveryCity,
 02191
 02192                // ID ссылки (без навигационных свойств)
 02193                OzonClientId = order.OzonClientId,
 02194                SupplierId = order.SupplierId,
 02195                AppStatusId = order.AppStatusId,
 02196                ClientStatusId = order.ClientStatusId,
 02197                ShipmentWarehouseId = order.ShipmentWarehouseId,
 02198                DeliveryId = order.DeliveryId,
 02199                EtProducerId = order.EtProducerId,
 02200                ProductInfoId = order.ProductInfoId,
 02201
 02202                // Цены и прочие поля
 02203                PurchasePrice = order.PurchasePrice,
 02204                OriginalPurchasePrice = order.OriginalPurchasePrice,
 02205                CostPrice = order.CostPrice,
 02206                OrderNumberToSupplier = order.OrderNumberToSupplier,
 02207                ShipmentId = newShipmentId,
 02208                FromFile = order.FromFile,
 02209                IsVerified = order.IsVerified,
 02210                IsReturnable = order.IsReturnable,
 02211                IsAccepted = order.IsAccepted,
 02212                LastStatusChangeDate = order.LastStatusChangeDate,
 02213
 02214                // Копируем поля комиссии, прибыли и наценки
 02215                MinOzonCommission = order.MinOzonCommission,
 02216                MaxOzonCommission = order.MaxOzonCommission,
 02217                MaxCommissionInfo = order.MaxCommissionInfo,
 02218                MinCommissionInfo = order.MinCommissionInfo,
 02219                MinProfit = order.MinProfit,
 02220                MaxProfit = order.MaxProfit,
 02221                MinDiscount = order.MinDiscount,
 02222                MaxDiscount = order.MaxDiscount
 02223            };
 2224
 2225            // Обновляем оригинальный заказ
 02226            order.Quantity = part1;
 02227            order.ShipmentNumber = posting1Number;
 02228            if (originalShipmentId.HasValue)
 2229            {
 02230                order.ShipmentId = originalShipmentId;
 2231            }
 2232
 2233            // Добавляем новый заказ в контекст
 02234            context.Orders.Add(newOrder);
 2235
 2236            // Сохраняем изменения одним вызовом
 02237            await context.SaveChangesAsync();
 02238            _changeNotifier.NotifyOrdersChanged(new[] { order.Id, newOrder.Id });
 2239
 2240            // Копируем OrderExtensions для нового заказа, если есть
 02241            if (extensionsToCopy.Any())
 2242            {
 02243                var newExtensions = extensionsToCopy.Select(ext => new OrderExtension
 02244                {
 02245                    OrderId = newOrder.Id,
 02246                    Key = ext.Key,
 02247                    Value = ext.Value
 02248                }).ToList();
 2249
 02250                context.OrderExtensions.AddRange(newExtensions);
 02251                await context.SaveChangesAsync();
 2252            }
 2253
 2254            // Возвращаем новый заказ
 02255            return newOrder;
 02256        }
 2257
 2258
 2259        /// <summary>
 2260        /// Идентичность позиции заказа при импорте: отправление + позиция внутри него (<c>Key</c>, иначе
 2261        /// <c>ProductKey</c>/<c>Article</c>). У одного отправления позиции с одним артикулом различаются оффером.
 2262        /// </summary>
 2263        public static (string Shipment, string Position) GetImportIdentity(Order order)
 2264        {
 122265            var position = !string.IsNullOrWhiteSpace(order.Key)
 122266                ? order.Key!.Trim()
 122267                : (order.ProductKey ?? order.Article ?? string.Empty).Trim();
 122268            return (TrimShipmentNumber(order.ShipmentNumber).Trim(), position);
 2269        }
 2270
 2271        /// <summary>
 2272        /// Схлопывает повторы внутри одного входящего пакета (одна и та же позиция дважды в отчёте/файле/письме).
 2273        /// Побеждает последняя строка — она свежее. Порядок остальных сохраняется.
 2274        /// </summary>
 2275        public static List<Order> DeduplicateIncomingOrders(IReadOnlyCollection<Order> orders)
 2276        {
 32277            var lastIndex = new Dictionary<(string Shipment, string Position), int>();
 32278            var list = orders.ToList();
 182279            for (var i = 0; i < list.Count; i++)
 62280                lastIndex[GetImportIdentity(list[i])] = i;
 2281
 92282            return list.Where((o, i) => lastIndex[GetImportIdentity(o)] == i).ToList();
 2283        }
 2284
 2285        /// <summary>
 2286        /// Выбирает существующую строку заказа, которую должна обновить импортируемая позиция.
 2287        /// Кандидаты уже отфильтрованы по артикулу и номеру отправления, но у одного отправления может быть
 2288        /// несколько позиций с одним артикулом и разными офферами (<c>PK16R11=DNS[2]</c> и <c>PK16R11=DNS</c>
 2289        /// — упаковка на 2 шт. и штучный товар по разной цене). Выбор по первому совпадению номера
 2290        /// заставлял вторую позицию перезаписывать первую, а сама вторая строка не обновлялась.
 2291        /// Поэтому сначала сопоставляем по <c>Key</c>, затем по <c>ProductKey</c> и только потом берём первую.
 2292        /// </summary>
 2293        public static Order? SelectExistingOrderForImport(IReadOnlyCollection<Order> candidates, Order incoming)
 2294        {
 132295            var sameShipment = candidates.Where(c => c.ShipmentNumber == incoming.ShipmentNumber).ToList();
 52296            if (sameShipment.Count <= 1)
 22297                return sameShipment.FirstOrDefault();
 2298
 82299            return sameShipment.FirstOrDefault(c => !string.IsNullOrEmpty(incoming.Key) && c.Key == incoming.Key)
 32300                   ?? sameShipment.FirstOrDefault(c =>
 22301                       !string.IsNullOrEmpty(incoming.ProductKey) && c.ProductKey == incoming.ProductKey)
 32302                   ?? sameShipment.First();
 2303        }
 2304
 2305        public static string TrimShipmentNumber(string? shipmentNumber)
 2306        {
 122307            if (string.IsNullOrEmpty(shipmentNumber))
 02308                return shipmentNumber ?? string.Empty;
 2309
 122310            var trimmed = shipmentNumber.Trim();
 2311
 2312            // Удаление всех окончаний вида /число (например, /1, /2 и т.п.)
 122313            trimmed = Regex.Replace(trimmed, @"(/[\d]+)+$", string.Empty);
 2314
 2315            // Удаление окончания вида -X, где X — одна цифра
 122316            if (trimmed.Length >= 2 && trimmed[^2] == '-' && char.IsDigit(trimmed[^1]))
 2317            {
 122318                trimmed = trimmed[..^2];
 2319            }
 2320
 122321            return trimmed;
 2322        }
 2323
 2324        public async Task<List<int>> GetOrderIdsByNumbersAndArticles(List<(string OrderNumber, string Article)> orders)
 2325        {
 02326            return await repository.GetOrderIdsByNumbersAndArticles(orders);
 02327        }
 2328
 2329        public async Task<HashSet<(string OrderNumber, string Article)>> GetMatchedNumbersAndArticles(List<(string Order
 2330        {
 02331            return await repository.GetMatchedNumbersAndArticles(orders);
 02332        }
 2333
 2334        /// <summary>
 2335        /// Уникальные Id заказов по номерам отправления и клиенту Ozon (порядок — по первому вхождению в списке).
 2336        /// </summary>
 2337        public async Task<List<int>> GetOrderIdsByShipmentNumbersForClientAsync(
 2338            IReadOnlyList<string> shipmentNumbers,
 2339            int ozonClientId)
 2340        {
 02341            var (ids, _) = await GetOrderIdsAndMatchedShipmentsByShipmentListAsync(shipmentNumbers, ozonClientId);
 02342            return ids;
 02343        }
 2344
 2345        /// <summary>
 2346        /// Однократный поиск по отправлениям: Id заказов (уникальные, порядок первого вхождения) и множество номеров из
 2347        /// </summary>
 2348        public async Task<(List<int> OrderIds, HashSet<string> MatchedShipments)> GetOrderIdsAndMatchedShipmentsByShipme
 2349            IReadOnlyList<string> shipmentNumbers,
 2350            int ozonClientId)
 2351        {
 02352            if (shipmentNumbers == null || shipmentNumbers.Count == 0 || ozonClientId <= 0)
 02353                return (new List<int>(), new HashSet<string>(StringComparer.OrdinalIgnoreCase));
 2354
 02355            var dict = await repository.GetOrderIdsByShipmentNumbersAsync(shipmentNumbers, ozonClientId);
 02356            var orderedIds = new List<int>();
 02357            var seen = new HashSet<int>();
 02358            var matchedShipments = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 02359            foreach (var s in shipmentNumbers)
 2360            {
 02361                if (string.IsNullOrWhiteSpace(s))
 2362                    continue;
 2363
 02364                var trimmed = s.Trim();
 02365                if (!dict.TryGetValue(trimmed, out var id))
 2366                    continue;
 2367
 02368                matchedShipments.Add(trimmed);
 02369                if (seen.Add(id))
 02370                    orderedIds.Add(id);
 2371            }
 2372
 02373            return (orderedIds, matchedShipments);
 02374        }
 2375
 2376        /// <summary>
 2377        /// Поиск по отправлениям без ограничения по Ozon-клиенту.
 2378        /// </summary>
 2379        public async Task<(List<int> OrderIds, HashSet<string> MatchedShipments)> GetOrderIdsAndMatchedShipmentsByShipme
 2380            IReadOnlyList<string> shipmentNumbers)
 2381        {
 02382            if (shipmentNumbers == null || shipmentNumbers.Count == 0)
 02383                return (new List<int>(), new HashSet<string>(StringComparer.OrdinalIgnoreCase));
 2384
 02385            var dict = await repository.GetOrderIdsByShipmentNumbersAsync(shipmentNumbers);
 02386            var orderedIds = new List<int>();
 02387            var seen = new HashSet<int>();
 02388            var matchedShipments = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 02389            foreach (var s in shipmentNumbers)
 2390            {
 02391                if (string.IsNullOrWhiteSpace(s))
 2392                    continue;
 2393
 02394                var trimmed = s.Trim();
 02395                if (!dict.TryGetValue(trimmed, out var id))
 2396                    continue;
 2397
 02398                matchedShipments.Add(trimmed);
 02399                if (seen.Add(id))
 02400                    orderedIds.Add(id);
 2401            }
 2402
 02403            return (orderedIds, matchedShipments);
 02404        }
 2405
 2406        public string NormalizeOrderNumberForMatching(string? shipmentNumber)
 2407        {
 02408            return NormalizeShipmentNumberForMatching(shipmentNumber);
 2409        }
 2410
 2411        private static string NormalizeShipmentNumberForMatching(string? shipmentNumber)
 2412        {
 02413            return ShipmentNumberMatching.Normalize(shipmentNumber);
 2414        }
 2415
 2416        public async Task<string?> GetZasylAsync(int orderId)
 2417        {
 02418            return await repository.GetZasylAsync(orderId);
 02419        }
 2420
 2421        public async Task<int> GetTotalCount()
 2422        {
 02423            return await repository.GetTotalCount();
 02424        }
 2425
 2426        public async Task LoadOrderExtensionsForOrders(List<Order> orders)
 2427        {
 02428            await repository.LoadOrderExtensionsForOrders(orders);
 02429        }
 2430
 2431        public async Task LoadOrderItemsForOrders(List<Order> orders)
 2432        {
 02433            await repository.LoadOrderItemsForOrders(orders);
 02434        }
 2435
 2436        public async Task LoadExtraExpensesForOrders(List<Order> orders)
 2437        {
 02438            await repository.LoadExtraExpensesForOrders(orders);
 02439        }
 2440
 2441        public async Task<List<Order>> GetOrdersByIds(List<int> orderIds, bool includeArchived)
 2442        {
 02443            return await repository.GetOrdersByIds(orderIds, includeArchived);
 02444        }
 2445
 2446        /// <summary>
 2447        /// Загружает заказы по ID с трэкингом (для обновления полей и сохранения через SaveChangesAsync).
 2448        /// </summary>
 2449        public async Task<List<Order>> GetOrdersByIdsTracked(List<int> orderIds, bool includeArchived)
 2450        {
 02451            return await repository.GetOrdersByIdsTracked(orderIds, includeArchived);
 02452        }
 2453
 2454        /// <summary>
 2455        /// Сохраняет изменения в контексте (для уже отслеживаемых сущностей).
 2456        /// </summary>
 2457        public async Task SaveChangesAsync()
 2458        {
 02459            await context.SaveChangesAsync();
 02460        }
 2461
 2462        public async Task SetZasylAsync(Dictionary<int, string?> orderSupplierMap)
 2463        {
 02464            await repository.SetZasylAsync(orderSupplierMap);
 02465        }
 2466
 2467        public async Task<bool> HasZasylValueAsync(string value)
 2468        {
 02469            return await repository.HasZasylValueAsync(value);
 02470        }
 2471
 2472        public async Task<List<string>> GetAllZasylValuesAsync()
 2473        {
 02474            return await repository.GetAllZasylValuesAsync();
 02475        }
 2476
 2477        public async Task SetTransactionDocumentAsync(int orderId, string? value)
 2478        {
 02479            await repository.SetTransactionDocumentAsync(orderId, value);
 02480        }
 2481
 2482        public async Task<string?> GetTransactionDocument(int orderId)
 2483        {
 02484            return await repository.GetTransactionDocument(orderId);
 02485        }
 2486
 2487        /// <summary>
 2488        /// Записывает новые ClientStatusId, прочитанные из Ozon. <paramref name="expectedStatusIds"/> — статус заказа
 2489        /// на момент чтения: UPDATE выполняется только если он не изменился (например, пришёл более свежий пуш
 2490        /// Ozon, пока шёл опрос), иначе устаревший ответ API перезаписал бы свежее значение.
 2491        /// Возвращает число реально обновлённых строк.
 2492        /// </summary>
 2493        public async Task<int> SaveClientStatusUpdatesAsync(
 2494            List<int> idsToUpdate,
 2495            List<Order> ordersToUpdate,
 2496            IReadOnlyDictionary<int, int?>? expectedStatusIds = null)
 2497        {
 02498            if (idsToUpdate.Count == 0) return 0;
 2499
 02500            var idSet = new HashSet<int>(idsToUpdate);
 02501            var updateMap = ordersToUpdate
 02502                .Where(o => idSet.Contains(o.Id) && o.ClientStatus?.Id > 0)
 2503                .ToDictionary(o => o.Id, o => o.ClientStatus!.Id);
 2504
 02505            if (updateMap.Count == 0) return 0;
 2506
 02507            var changedAt = DateTime.Now;
 02508            var updated = 0;
 2509
 2510            // Одним UPDATE на каждую пару (новый статус, ожидаемый прежний).
 02511            var groups = updateMap.GroupBy(kv => (
 02512                NewStatusId: kv.Value,
 02513                Expected: expectedStatusIds != null && expectedStatusIds.TryGetValue(kv.Key, out var e) ? e : (int?)null
 02514                HasExpected: expectedStatusIds != null && expectedStatusIds.ContainsKey(kv.Key)));
 2515
 02516            foreach (var group in groups)
 2517            {
 02518                var statusId = group.Key.NewStatusId;
 2519                var orderIds = group.Select(kv => kv.Key).ToList();
 02520                var query = context.Orders.Where(o => orderIds.Contains(o.Id));
 2521
 02522                if (group.Key.HasExpected)
 2523                {
 02524                    var expected = group.Key.Expected;
 02525                    query = expected == null
 02526                        ? query.Where(o => o.ClientStatusId == null)
 02527                        : query.Where(o => o.ClientStatusId == expected);
 2528                }
 2529
 02530                updated += await query.ExecuteUpdateAsync(s => s
 02531                    .SetProperty(o => o.ClientStatusId, statusId)
 02532                    .SetProperty(o => o.LastStatusChangeDate, changedAt));
 2533            }
 2534
 02535            return updated;
 02536        }
 2537
 2538        public async Task<Dictionary<string, int>> GetArticleCountsForKeysAsync(
 2539            IList<string> keys, bool includeArchived)
 2540        {
 62541            if (keys.Count == 0) return new Dictionary<string, int>();
 42542            return await context.Orders
 42543                .AsNoTracking()
 42544                .Where(o => o.ProductKey != null && keys.Contains(o.ProductKey) && (includeArchived || o.OrderArchive ==
 42545                .GroupBy(o => o.ProductKey)
 42546                .Select(g => new { Key = g.Key, Count = g.Count() })
 2547                .ToDictionaryAsync(x => x.Key!, x => x.Count);
 52548        }
 2549
 2550        public async Task<Dictionary<string, int>> GetDeliveryCityCountsForCitiesAsync(
 2551            IList<string> cities, bool includeArchived)
 2552        {
 42553            if (cities.Count == 0) return new Dictionary<string, int>();
 22554            return await context.Orders
 22555                .AsNoTracking()
 22556                .Where(o => o.DeliveryCity != null && cities.Contains(o.DeliveryCity) && (includeArchived || o.OrderArch
 22557                .GroupBy(o => o.DeliveryCity)
 22558                .Select(g => new { Key = g.Key, Count = g.Count() })
 2559                .ToDictionaryAsync(x => x.Key!, x => x.Count);
 32560        }
 2561
 2562        public async Task<Dictionary<string, int>> GetShipmentBaseCountsForPrefixesAsync(
 2563            IList<string> prefixes, bool includeArchived)
 2564        {
 12565            if (prefixes.Count == 0)
 2566            {
 02567                return new Dictionary<string, int>();
 2568            }
 2569
 12570            var distinct = prefixes
 2571                .Where(p => !string.IsNullOrWhiteSpace(p))
 2572                .Select(p => p.Trim())
 12573                .Distinct(StringComparer.Ordinal)
 12574                .ToList();
 12575            if (distinct.Count == 0)
 2576            {
 02577                return new Dictionary<string, int>();
 2578            }
 2579
 12580            var numbers = await ApplyShipmentBasePrefixFilter(
 12581                    context.Orders.AsNoTracking()
 12582                        .Where(o => o.ShipmentNumber != null && (includeArchived || o.OrderArchive == null)),
 12583                    distinct)
 12584                .Select(o => o.ShipmentNumber!)
 12585                .ToListAsync();
 2586
 12587            return numbers
 2588                .Select(sn => sn.Split('-', 2)[0])
 2589                .GroupBy(b => b, StringComparer.Ordinal)
 2590                .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
 12591        }
 2592
 2593        private static IQueryable<Order> ApplyShipmentBasePrefixFilter(
 2594            IQueryable<Order> query,
 2595            IList<string> prefixes)
 2596        {
 12597            var param = Expression.Parameter(typeof(Order), "o");
 12598            var shipmentProp = Expression.Property(param, nameof(Order.ShipmentNumber));
 12599            var startsWithMethod = typeof(string).GetMethod(nameof(string.StartsWith), [typeof(string)])!;
 2600
 12601            Expression? combined = null;
 42602            foreach (var prefix in prefixes)
 2603            {
 12604                var constPrefix = Expression.Constant(prefix);
 12605                var constDashPrefix = Expression.Constant(prefix + "-");
 12606                var notNull = Expression.NotEqual(shipmentProp, Expression.Constant(null, typeof(string)));
 12607                var equals = Expression.Equal(shipmentProp, constPrefix);
 12608                var startsWith = Expression.Call(shipmentProp, startsWithMethod, constDashPrefix);
 12609                var match = Expression.OrElse(equals, startsWith);
 12610                var clause = Expression.AndAlso(notNull, match);
 12611                combined = combined == null ? clause : Expression.OrElse(combined, clause);
 2612            }
 2613
 12614            if (combined == null)
 2615            {
 02616                return query.Where(_ => false);
 2617            }
 2618
 12619            var lambda = Expression.Lambda<Func<Order, bool>>(combined, param);
 12620            return query.Where(lambda);
 2621        }
 2622    }
 2623}

Methods/Properties

.ctor(OzonRepositories.Data.OrderRepository,OzonRepositories.Context.ApplicationDbContext,Services.PriceСlculationServices.OrderPriceManager,Services.DataServices.AppStatusDataServices,Services.DataServices.SupplierDataServices,Services.DataServices.ProductsDataServices,Services.DataServices.WarehouseDataServices,Services.DataServices.ExcludedArticleDataServices,Services.ApiServices.TelegramBotApi.NewOrdersTelegramNotifier,Services.DataServices.OrderExtraExpenseDataServices,SlqStudio.Application.Services.EmailService.IEmailService,OzonRepositories.Data.ShipmentRepository,OzonRepositories.Data.IOrderChangeNotifier,Services.OzonWebhookServices.IOzonPostingCancellationService,Microsoft.Extensions.Logging.ILogger`1<Services.DataServices.OrdersDataServices>)
.cctor()
GetOrders()
GetOrdersQueryable(System.Boolean)
GetAnalyticsQueryable()
GetOrder()
GetAsyncNoTracking()
GetOrdersNoTracking()
GetOrder()
UpdateOrder()
UpdateOrders()
GetOrders()
GetReturnableCount()
GetOrdersByManufacturerCode()
DeleteOrder()
GetUniqueArticles()
GetUniqueDeliveryCities()
GetUniqueShipmentNumbers()
GetArticleCountsForOrdersAsync(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.Order>,System.Boolean)
GetDeliveryCityCountsForOrdersAsync(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.Order>,System.Boolean)
GetShipmentBaseCountsForOrdersAsync(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.Order>,System.Boolean)
MultiplayDeleteOrders()
AddOrders()
AddOrdersCore()
<AddOrdersCore()
RemoveDuplicateOrdersByKey()
GetOrderActivityScore(Services.DataServices.OrdersDataServices/DuplicateOrderCandidate)
get_Id()
get_Key()
get_ShipmentNumber()
get_Article()
get_ProductName()
get_OzonClientName()
get_UpdatedColumns()
get_IsVerified()
get_IsAccepted()
get_SupplierId()
get_OrderNumberToSupplier()
get_Comment()
get_LastStatusChangeDate()
get_CostPrice()
get_PurchasePrice()
get_AppStatusId()
MultiplayEditOrder()
TransactOrder()
SetUpdatedBy(OzonDomains.Models.Order,System.String)
UpdateOrderWeb()
UpdateOrder()
GetOrderChangeList(OzonDomains.Models.Order,OzonDomains.Models.Order,System.String[])
CompareObjects(System.Object,System.Object,System.Collections.Generic.List`1<System.String>,System.String[])
ConfirmAccepted()
SaveExtraExpensesForOrders()
CastToModel()
BindShipmentAsync()
CastToModelForOzon()
Coalesce(OzonDomains.Models.Order,OzonDomains.Models.Order)
SetIsReturnable()
CalculateCostPriceForNotFullOrders()
NormalizeArticle(System.String)
IsQuantityCompatible(OzonDomains.Models.Order,OzonDomains.Models.Order)
GetNotFullOrdersModel(Services.FiltersServices.DataFilterManagers.OrderDataFilterManager,System.Collections.Generic.List`1<OzonDomains.Models.Order>,OServices.FiltersServices.FilterModels.OrderFilterModel,OzonDomains.Models.AccessControl.AccessProfile)
ProcessingNotFullOrder()
ProcessingNotFullOrderForExcel()
PrepareNewOrder()
ProcessExistingOrders()
ProcessOrdersForExcel()
UpdateOrderProperties(OzonDomains.Models.Order,OzonDomains.Models.Order,System.String)
GetRussianCurrentTime()
CastToFullOrder()
GetAppStatusAsync()
GetManufacturerById(System.Nullable`1<System.Int32>)
GetWarehouseAsync()
GetSupplierAsync()
GetProductAsync()
GetOzonClientById(System.Nullable`1<System.Int32>)
SetNumberInExcel()
SetUniqueShipmentNumberAndKey()
CancellationOrders()
IsStatusAllowedForCancellation(System.String)
MergeOrders(System.Collections.Generic.List`1<OzonDomains.Models.Order>)
SaveChanges()
SplitOrder()
GetImportIdentity(OzonDomains.Models.Order)
DeduplicateIncomingOrders(System.Collections.Generic.IReadOnlyCollection`1<OzonDomains.Models.Order>)
SelectExistingOrderForImport(System.Collections.Generic.IReadOnlyCollection`1<OzonDomains.Models.Order>,OzonDomains.Models.Order)
TrimShipmentNumber(System.String)
GetOrderIdsByNumbersAndArticles()
GetMatchedNumbersAndArticles()
GetOrderIdsByShipmentNumbersForClientAsync()
GetOrderIdsAndMatchedShipmentsByShipmentListAsync()
GetOrderIdsAndMatchedShipmentsByShipmentListAsync()
NormalizeOrderNumberForMatching(System.String)
NormalizeShipmentNumberForMatching(System.String)
GetZasylAsync()
GetTotalCount()
LoadOrderExtensionsForOrders()
LoadOrderItemsForOrders()
LoadExtraExpensesForOrders()
GetOrdersByIds()
GetOrdersByIdsTracked()
SaveChangesAsync()
SetZasylAsync()
HasZasylValueAsync()
GetAllZasylValuesAsync()
SetTransactionDocumentAsync()
GetTransactionDocument()
SaveClientStatusUpdatesAsync()
GetArticleCountsForKeysAsync()
GetDeliveryCityCountsForCitiesAsync()
GetShipmentBaseCountsForPrefixesAsync()
ApplyShipmentBasePrefixFilter(System.Linq.IQueryable`1<OzonDomains.Models.Order>,System.Collections.Generic.IList`1<System.String>)