| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using System.Data.Common; |
| | | 4 | | using OzonDomains.Models; |
| | | 5 | | using OzonDomains.Models.AccessControl; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | using OzonRepositories.Data; |
| | | 8 | | using OzonRepositories.Utils; |
| | | 9 | | using Services.DataServices.DTO; |
| | | 10 | | using Services.PriceСlculationServices; |
| | | 11 | | using System.Linq.Expressions; |
| | | 12 | | using System.Reflection; |
| | | 13 | | using System.Text; |
| | | 14 | | using System.Text.RegularExpressions; |
| | | 15 | | using OServices.FiltersServices.FilterModels; |
| | | 16 | | using OzonDomains; |
| | | 17 | | using Services.ApiServices.TelegramBotApi; |
| | | 18 | | using Services.FiltersServices.DataFilterManagers; |
| | | 19 | | using SlqStudio.Application.Services.EmailService; |
| | | 20 | | using Services.OzonWebhookServices; |
| | | 21 | | |
| | | 22 | | namespace Services.DataServices |
| | | 23 | | { |
| | 21 | 24 | | public class OrdersDataServices( |
| | 21 | 25 | | OrderRepository repository, |
| | 21 | 26 | | ApplicationDbContext context, |
| | 21 | 27 | | OrderPriceManager orderPriceManager, |
| | 21 | 28 | | AppStatusDataServices appStatusDataServices, |
| | 21 | 29 | | SupplierDataServices supplierDataServices, |
| | 21 | 30 | | ProductsDataServices productsDataServices, |
| | 21 | 31 | | WarehouseDataServices warehouseDataServices, |
| | 21 | 32 | | ExcludedArticleDataServices excludedArticleDataServices, |
| | 21 | 33 | | NewOrdersTelegramNotifier newOrdersTelegramNotifier, |
| | 21 | 34 | | OrderExtraExpenseDataServices orderExtraExpenseDataServices, |
| | 21 | 35 | | IEmailService emailService, |
| | 21 | 36 | | ShipmentRepository shipmentRepository, |
| | 21 | 37 | | IOrderChangeNotifier changeNotifier, |
| | 21 | 38 | | IOzonPostingCancellationService cancellationService, |
| | 21 | 39 | | ILogger<OrdersDataServices> logger) |
| | | 40 | | : IDataServices |
| | | 41 | | { |
| | 21 | 42 | | private readonly ILogger<OrdersDataServices> _logger = logger; |
| | 21 | 43 | | private readonly OrderExtraExpenseDataServices _orderExtraExpenseDataServices = orderExtraExpenseDataServices; |
| | 21 | 44 | | private readonly IEmailService _emailService = emailService; |
| | 21 | 45 | | private readonly ShipmentRepository _shipmentRepository = shipmentRepository; |
| | 21 | 46 | | private readonly IOrderChangeNotifier _changeNotifier = changeNotifier; |
| | | 47 | | |
| | 21 | 48 | | private readonly string[] _ignoredProperties = |
| | 21 | 49 | | [ |
| | 21 | 50 | | "AppStatus", "СurrencyId", "ProductInfoId", "UpdatedColumns", "Id", "AppStatusId", "ShipmentWarehouseId", |
| | 21 | 51 | | "IsVerified", "Comment", "Key", "IsAccepted", "MaxCommissionInfo", "MinCommissionInfo", "MinDiscount", |
| | 21 | 52 | | "MaxDiscount", "PurchasePrice", "Supplier", "SupplierId", "MinProfit", "MaxProfit", "IsReturnable", |
| | 21 | 53 | | "OrderNumberToSupplier", "FromFile", "OriginalPurchasePrice", "CostPrice", "PurchasePrice" |
| | 21 | 54 | | ]; |
| | | 55 | | |
| | 21 | 56 | | private readonly string[] _ignoredWebProperties = |
| | 21 | 57 | | [ |
| | 21 | 58 | | "СurrencyId", "ProductInfoId", "UpdatedColumns", "Id", |
| | 21 | 59 | | "ShipmentWarehouse", "Сurrency", "ProductInfo", "MaxCommissionInfo", "MinCommissionInfo", |
| | 21 | 60 | | "IsVerified", "Key", "IsAccepted", "OzonClient", "FromFile", "OrderExtensions" |
| | 21 | 61 | | ]; |
| | | 62 | | |
| | 21 | 63 | | private readonly string[] _ignoredWebNullProperties = |
| | 21 | 64 | | [ |
| | 21 | 65 | | "OrderNumberToSupplier", "Supplier", "SupplierId" |
| | 21 | 66 | | ]; |
| | | 67 | | |
| | | 68 | | /// <summary> |
| | | 69 | | /// Поля, которые считает сам сервер в <see cref="CastToModel"/>. В форме транзакции их нет, |
| | | 70 | | /// поэтому фильтр «пришло ли поле с формы» отбрасывал уже посчитанные значения: они не |
| | | 71 | | /// попадали в UpdatedColumns, а значит не сохранялись и не отражались в истории заказа. |
| | | 72 | | /// UpdatedBy сюда не входит: на POST он приходит с дефолтом модели, а не с именем пользователя. |
| | | 73 | | /// </summary> |
| | 1 | 74 | | private static readonly HashSet<string> _serverCalculatedProperties = |
| | 1 | 75 | | new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 76 | | { |
| | 1 | 77 | | nameof(Order.CostPrice), |
| | 1 | 78 | | nameof(Order.MinProfit), |
| | 1 | 79 | | nameof(Order.MaxProfit), |
| | 1 | 80 | | nameof(Order.MinDiscount), |
| | 1 | 81 | | nameof(Order.MaxDiscount) |
| | 1 | 82 | | }; |
| | | 83 | | |
| | 21 | 84 | | private readonly string[] _priceProperties = |
| | 21 | 85 | | [ |
| | 21 | 86 | | "ShipmentAmount", |
| | 21 | 87 | | "Price", |
| | 21 | 88 | | "PurchasePrice", |
| | 21 | 89 | | "OriginalPurchasePrice", |
| | 21 | 90 | | "MinOzonCommission", |
| | 21 | 91 | | "MaxOzonCommission", |
| | 21 | 92 | | "MinProfit", |
| | 21 | 93 | | "MaxProfit", |
| | 21 | 94 | | "MinDiscount", |
| | 21 | 95 | | "MaxDiscount", |
| | 21 | 96 | | "CostPrice" |
| | 21 | 97 | | ]; |
| | | 98 | | |
| | 1 | 99 | | private static readonly System.Reflection.PropertyInfo[] _cachedOrderProperties = |
| | 1 | 100 | | typeof(Order).GetProperties( |
| | 1 | 101 | | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); |
| | | 102 | | |
| | | 103 | | public async Task<IQueryable<Order>> GetOrders(bool includeArchived = false) |
| | | 104 | | { |
| | 0 | 105 | | var result = await repository.GetAsync(includeArchived); |
| | | 106 | | |
| | 0 | 107 | | return result; |
| | 0 | 108 | | } |
| | | 109 | | |
| | | 110 | | /// <summary> |
| | | 111 | | /// Возвращает IQueryable заказов БЕЗ загрузки данных в память. |
| | | 112 | | /// Используется для применения фильтров на уровне БД. |
| | | 113 | | /// </summary> |
| | | 114 | | public IQueryable<Order> GetOrdersQueryable(bool includeArchived = false) |
| | | 115 | | { |
| | 0 | 116 | | return repository.GetQueryable(includeArchived); |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | /// <summary> |
| | | 120 | | /// Заказы для аналитики: без Include и трекинга, включая архивные. Для узких проекций (Select) — |
| | | 121 | | /// аналитические отчёты не должны грузить граф сущностей заказа. |
| | | 122 | | /// </summary> |
| | 0 | 123 | | public IQueryable<Order> GetAnalyticsQueryable() => context.Orders.AsNoTracking(); |
| | | 124 | | |
| | | 125 | | public async Task<Order?> GetOrder(int id) |
| | | 126 | | { |
| | 4 | 127 | | var result = await repository.GetAsync(id); |
| | | 128 | | |
| | 4 | 129 | | return result; |
| | 4 | 130 | | } |
| | | 131 | | |
| | | 132 | | public async Task<Order?> GetAsyncNoTracking(int id) |
| | | 133 | | { |
| | 3 | 134 | | var result = await repository.GetAsyncNoTracking(id); |
| | 3 | 135 | | return result; |
| | 3 | 136 | | } |
| | | 137 | | |
| | | 138 | | /// <summary> |
| | | 139 | | /// Загружает заказы без трекинга EF Core. |
| | | 140 | | /// Используется в GET методах транзакций - изменения статусов для отображения НЕ сохранятся в БД. |
| | | 141 | | /// </summary> |
| | | 142 | | public async Task<List<Order>> GetOrdersNoTracking(int[] ids) |
| | | 143 | | { |
| | 0 | 144 | | return await repository.GetOrdersNoTracking(ids); |
| | 0 | 145 | | } |
| | | 146 | | |
| | | 147 | | public async Task<Order?> GetOrder(Order order) |
| | | 148 | | { |
| | 0 | 149 | | var result = await repository.GetAsync(order); |
| | | 150 | | |
| | 0 | 151 | | return result; |
| | 0 | 152 | | } |
| | | 153 | | |
| | | 154 | | public async Task<int> UpdateOrder(Order order) |
| | | 155 | | { |
| | 0 | 156 | | return await repository.Update(order); |
| | 0 | 157 | | } |
| | | 158 | | |
| | | 159 | | public async Task<int> UpdateOrders(List<Order> orders) |
| | | 160 | | { |
| | 0 | 161 | | return await repository.UpdateRange(orders); |
| | 0 | 162 | | } |
| | | 163 | | |
| | | 164 | | public async Task<List<Order>> GetOrders(int skip, int take) |
| | | 165 | | { |
| | 0 | 166 | | return await repository.GetOrdersWithPagination(skip, take); |
| | 0 | 167 | | } |
| | | 168 | | |
| | | 169 | | public async Task<int> GetReturnableCount() |
| | | 170 | | { |
| | 0 | 171 | | return await repository.GetReturnableCount(); |
| | 0 | 172 | | } |
| | | 173 | | |
| | | 174 | | public async Task<IQueryable<Order>> GetOrdersByManufacturerCode(string code) |
| | | 175 | | { |
| | 0 | 176 | | await Task.CompletedTask; |
| | 0 | 177 | | return repository.GetOrdersByManufacturerCode(code); |
| | 0 | 178 | | } |
| | | 179 | | |
| | | 180 | | public async Task<int> DeleteOrder(int orderId) |
| | | 181 | | { |
| | 0 | 182 | | Order? orderToDelete = await repository.GetAsync(orderId); |
| | | 183 | | |
| | 0 | 184 | | if (orderToDelete != null && orderToDelete.AppStatus?.Name == "Не указан") |
| | | 185 | | { |
| | 0 | 186 | | return await repository.Delete(orderToDelete); |
| | | 187 | | } |
| | | 188 | | |
| | 0 | 189 | | return 0; |
| | 0 | 190 | | } |
| | | 191 | | |
| | | 192 | | /// <summary> |
| | | 193 | | /// Устаревший глобальный GROUP BY по всей таблице. Используйте <see cref="GetArticleCountsForOrdersAsync"/>. |
| | | 194 | | /// </summary> |
| | | 195 | | public Task<Dictionary<string, int>> GetUniqueArticles() => |
| | 0 | 196 | | Task.FromResult(new Dictionary<string, int>()); |
| | | 197 | | |
| | | 198 | | /// <summary> |
| | | 199 | | /// Устаревший глобальный GROUP BY по всей таблице. Используйте <see cref="GetDeliveryCityCountsForOrdersAsync"/ |
| | | 200 | | /// </summary> |
| | | 201 | | public Task<Dictionary<string, int>> GetUniqueDeliveryCities() => |
| | 0 | 202 | | Task.FromResult(new Dictionary<string, int>()); |
| | | 203 | | |
| | | 204 | | /// <summary> |
| | | 205 | | /// Устаревший полный скан ShipmentNumber. Используйте <see cref="GetShipmentBaseCountsForOrdersAsync"/>. |
| | | 206 | | /// </summary> |
| | | 207 | | public Task<Dictionary<string, int>> GetUniqueShipmentNumbers() => |
| | 0 | 208 | | 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 | | { |
| | 1 | 216 | | var keys = orders |
| | 2 | 217 | | .Where(o => !string.IsNullOrEmpty(o.ProductKey)) |
| | 2 | 218 | | .Select(o => o.ProductKey!) |
| | 1 | 219 | | .Distinct() |
| | 1 | 220 | | .ToList(); |
| | 1 | 221 | | 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 | | { |
| | 0 | 230 | | var cities = orders |
| | 0 | 231 | | .Where(o => !string.IsNullOrEmpty(o.DeliveryCity)) |
| | 0 | 232 | | .Select(o => o.DeliveryCity!) |
| | 0 | 233 | | .Distinct() |
| | 0 | 234 | | .ToList(); |
| | 0 | 235 | | 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 | | { |
| | 0 | 244 | | var prefixes = orders |
| | 0 | 245 | | .Where(o => !string.IsNullOrWhiteSpace(o.ShipmentNumber)) |
| | 0 | 246 | | .Select(o => o.ShipmentNumber!.Split('-', 2)[0]) |
| | 0 | 247 | | .Distinct(StringComparer.Ordinal) |
| | 0 | 248 | | .ToList(); |
| | 0 | 249 | | return GetShipmentBaseCountsForPrefixesAsync(prefixes, includeArchived); |
| | | 250 | | } |
| | | 251 | | |
| | | 252 | | public async Task<int> MultiplayDeleteOrders(int[] idArray) |
| | | 253 | | { |
| | 0 | 254 | | int result = 0; |
| | | 255 | | |
| | 0 | 256 | | foreach (int orderId in idArray) |
| | | 257 | | { |
| | 0 | 258 | | Order? orderToDelete = await repository.GetAsync(orderId); |
| | | 259 | | |
| | 0 | 260 | | if (orderToDelete != null && orderToDelete.AppStatus?.Name == "Не указан") |
| | | 261 | | { |
| | 0 | 262 | | result += await repository.Delete(orderToDelete); |
| | | 263 | | } |
| | | 264 | | } |
| | | 265 | | |
| | 0 | 266 | | return result; |
| | 0 | 267 | | } |
| | | 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 | | // Сериализуем вставку: второй импорт начнёт после коммита первого и найдёт строки как существующие. |
| | 0 | 291 | | await AddOrdersLock.WaitAsync(); |
| | | 292 | | try |
| | | 293 | | { |
| | 0 | 294 | | return await AddOrdersCore(orders, addedShipmentNumbers); |
| | | 295 | | } |
| | | 296 | | finally |
| | | 297 | | { |
| | 0 | 298 | | AddOrdersLock.Release(); |
| | | 299 | | } |
| | 0 | 300 | | } |
| | | 301 | | |
| | 1 | 302 | | private static readonly SemaphoreSlim AddOrdersLock = new(1, 1); |
| | | 303 | | |
| | | 304 | | private async Task<int[]> AddOrdersCore(List<Order> orders, List<string>? addedShipmentNumbers) |
| | | 305 | | { |
| | 0 | 306 | | orders = await excludedArticleDataServices.ExcludeOrdersAsync(orders); |
| | 0 | 307 | | orders = DeduplicateIncomingOrders(orders); |
| | 0 | 308 | | if (orders.Count == 0) |
| | 0 | 309 | | return new[] { 0, 0 }; |
| | | 310 | | |
| | 0 | 311 | | var newOrdersForNotification = new List<TelegramNewOrderItem>(); |
| | 0 | 312 | | var result = await TransactionHelper.ExecuteInTransactionAsync(context, async (transaction) => |
| | 0 | 313 | | { |
| | 0 | 314 | | // Лямбда может выполниться повторно (retry стратегии) — список не должен копить дубли. |
| | 0 | 315 | | newOrdersForNotification.Clear(); |
| | 0 | 316 | | var chunkSize = 500; |
| | 0 | 317 | | var updateCount = 0; |
| | 0 | 318 | | var addCount = 0; |
| | 0 | 319 | | var ordersToInsert = new List<Order>(); |
| | 0 | 320 | | var queuedIdentities = new HashSet<(string Shipment, string Position)>(); |
| | 0 | 321 | | var status = context.AppStatuses.FirstOrDefault(s => s.Name == "Не указан"); |
| | 0 | 322 | | |
| | 0 | 323 | | for (int i = 0; i < orders.Count; i += chunkSize) |
| | 0 | 324 | | { |
| | 0 | 325 | | var currentChunk = orders.Skip(i).Take(chunkSize).ToList(); |
| | 0 | 326 | | |
| | 0 | 327 | | foreach (var order in currentChunk) |
| | 0 | 328 | | { |
| | 0 | 329 | | Order? existingOrder = null; |
| | 0 | 330 | | |
| | 0 | 331 | | if (order.OzonClient?.ClientType == ClientType.YANDEX) |
| | 0 | 332 | | { |
| | 0 | 333 | | existingOrder = context.Orders.FirstOrDefault(o => o.Key == order.Key); |
| | 0 | 334 | | bool endsWithSlashNumber = existingOrder != null && |
| | 0 | 335 | | !string.IsNullOrEmpty(existingOrder.ShipmentNumber) && |
| | 0 | 336 | | Regex.IsMatch(existingOrder.ShipmentNumber, @"/\d+$"); |
| | 0 | 337 | | if (endsWithSlashNumber) |
| | 0 | 338 | | continue; |
| | 0 | 339 | | } |
| | 0 | 340 | | else |
| | 0 | 341 | | { |
| | 0 | 342 | | var trimmedShipmentNumber = TrimShipmentNumber(order.ShipmentNumber); |
| | 0 | 343 | | |
| | 0 | 344 | | var existingOrders = context.Orders |
| | 0 | 345 | | .Where(o => o.Article == order.Article) |
| | 0 | 346 | | .AsEnumerable() |
| | 0 | 347 | | .Where(o => TrimShipmentNumber(o.ShipmentNumber) == trimmedShipmentNumber) |
| | 0 | 348 | | .ToList(); |
| | 0 | 349 | | |
| | 0 | 350 | | var endsWithSlashNumber = existingOrders |
| | 0 | 351 | | .FirstOrDefault(o => |
| | | 352 | | !string.IsNullOrEmpty(o.ShipmentNumber) && |
| | | 353 | | Regex.IsMatch(o.ShipmentNumber, @"/\d+$")); |
| | 0 | 354 | | |
| | 0 | 355 | | if (endsWithSlashNumber != null) |
| | 0 | 356 | | continue; |
| | 0 | 357 | | |
| | 0 | 358 | | existingOrder = SelectExistingOrderForImport(existingOrders, order); |
| | 0 | 359 | | } |
| | 0 | 360 | | |
| | 0 | 361 | | try |
| | 0 | 362 | | { |
| | 0 | 363 | | if (existingOrder != null) |
| | 0 | 364 | | { |
| | 0 | 365 | | await UpdateOrder(existingOrder, order); |
| | 0 | 366 | | updateCount++; |
| | 0 | 367 | | } |
| | 0 | 368 | | else |
| | 0 | 369 | | { |
| | 0 | 370 | | // Позиция могла уже встать в очередь на вставку в этом проходе — вторую копию не создаё |
| | 0 | 371 | | if (!queuedIdentities.Add(GetImportIdentity(order))) |
| | 0 | 372 | | continue; |
| | 0 | 373 | | |
| | 0 | 374 | | var newOrder = await CastToModel(order); |
| | 0 | 375 | | if (newOrder.OzonPromotionSnapshot != null) |
| | 0 | 376 | | { |
| | 0 | 377 | | newOrder.OzonPromotionSnapshot.OzonClientId = newOrder.OzonClient?.Id; |
| | 0 | 378 | | } |
| | 0 | 379 | | ordersToInsert.Add(newOrder); |
| | 0 | 380 | | if (!string.IsNullOrWhiteSpace(newOrder.ShipmentNumber)) |
| | 0 | 381 | | newOrdersForNotification.Add(new TelegramNewOrderItem(newOrder.ShipmentNumber, |
| | 0 | 382 | | newOrder.OzonClient?.Name, newOrder.ShipmentWarehouse?.Name, |
| | 0 | 383 | | newOrder.ProductKey ?? newOrder.Article, newOrder.ProductName, |
| | 0 | 384 | | newOrder.ProductInfo?.ImageUrl)); |
| | 0 | 385 | | if (!string.IsNullOrEmpty(newOrder.ShipmentNumber)) |
| | 0 | 386 | | addedShipmentNumbers?.Add(newOrder.ShipmentNumber); |
| | 0 | 387 | | addCount++; |
| | 0 | 388 | | } |
| | 0 | 389 | | } |
| | 0 | 390 | | catch (Exception e) |
| | 0 | 391 | | { |
| | 0 | 392 | | Console.WriteLine("---AddOrders---"); |
| | 0 | 393 | | Console.WriteLine(e); |
| | 0 | 394 | | continue; |
| | 0 | 395 | | } |
| | 0 | 396 | | } |
| | 0 | 397 | | |
| | 0 | 398 | | await context.Orders.AddRangeAsync(ordersToInsert); |
| | 0 | 399 | | await context.SaveChangesAsync(); |
| | | 400 | | _changeNotifier.NotifyOrdersChanged(ordersToInsert.Select(o => o.Id)); |
| | 0 | 401 | | ordersToInsert.Clear(); |
| | 0 | 402 | | } |
| | 0 | 403 | | |
| | 0 | 404 | | await RemoveDuplicateOrdersByKey(); |
| | 0 | 405 | | await cancellationService.LinkImportedOrdersAsync(orders); |
| | 0 | 406 | | return new[] { addCount, updateCount }; |
| | 0 | 407 | | }); |
| | | 408 | | |
| | | 409 | | // Уведомляем после фиксации транзакции: откат или повтор не должны порождать сообщения о несуществующих зак |
| | 0 | 410 | | await newOrdersTelegramNotifier.NotifyAsync(newOrdersForNotification); |
| | 0 | 411 | | return result; |
| | 0 | 412 | | } |
| | | 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, хотя почти все ключи уникальны. |
| | 0 | 430 | | var duplicateKeys = await context.Orders |
| | 0 | 431 | | .AsNoTracking() |
| | 0 | 432 | | .Where(o => o.Key != null && o.Key != string.Empty) |
| | 0 | 433 | | .GroupBy(o => new { o.Key, o.ShipmentNumber }) |
| | 0 | 434 | | .Where(group => group.Count() > 1) |
| | 0 | 435 | | .Select(group => group.Key.Key!) |
| | 0 | 436 | | .Distinct() |
| | 0 | 437 | | .ToListAsync(); |
| | | 438 | | |
| | | 439 | | // Пробельные ключи раньше игнорировались. Отсеиваем их после компактной выборки |
| | | 440 | | // ключей, чтобы не оборачивать индексированную колонку Key в TRIM() в SQL. |
| | 0 | 441 | | duplicateKeys = duplicateKeys |
| | | 442 | | .Where(key => !string.IsNullOrWhiteSpace(key)) |
| | 0 | 443 | | .ToList(); |
| | | 444 | | |
| | 0 | 445 | | if (duplicateKeys.Count == 0) |
| | | 446 | | { |
| | 0 | 447 | | return; |
| | | 448 | | } |
| | | 449 | | |
| | | 450 | | // Для выбора сохраняемого заказа и email нужны только эти поля, а не весь Order. |
| | 0 | 451 | | var duplicateOrders = await context.Orders |
| | 0 | 452 | | .AsNoTracking() |
| | 0 | 453 | | .Where(o => o.Key != null && duplicateKeys.Contains(o.Key)) |
| | 0 | 454 | | .Select(o => new DuplicateOrderCandidate |
| | 0 | 455 | | { |
| | 0 | 456 | | Id = o.Id, |
| | 0 | 457 | | Key = o.Key!, |
| | 0 | 458 | | ShipmentNumber = o.ShipmentNumber, |
| | 0 | 459 | | Article = o.Article, |
| | 0 | 460 | | ProductName = o.ProductName, |
| | 0 | 461 | | OzonClientName = o.OzonClient == null ? null : o.OzonClient.Name, |
| | 0 | 462 | | UpdatedColumns = o.UpdatedColumns, |
| | 0 | 463 | | IsVerified = o.IsVerified, |
| | 0 | 464 | | IsAccepted = o.IsAccepted, |
| | 0 | 465 | | SupplierId = o.SupplierId, |
| | 0 | 466 | | OrderNumberToSupplier = o.OrderNumberToSupplier, |
| | 0 | 467 | | Comment = o.Comment, |
| | 0 | 468 | | LastStatusChangeDate = o.LastStatusChangeDate, |
| | 0 | 469 | | CostPrice = o.CostPrice, |
| | 0 | 470 | | PurchasePrice = o.PurchasePrice, |
| | 0 | 471 | | AppStatusId = o.AppStatusId |
| | 0 | 472 | | }) |
| | 0 | 473 | | .ToListAsync(); |
| | | 474 | | |
| | | 475 | | // Группируем по паре «Key + номер отправления»: заказы с одним Key, но разными |
| | | 476 | | // номерами отправлений — разные отправления, а не дубли. |
| | 0 | 477 | | var duplicateGroups = duplicateOrders |
| | | 478 | | .GroupBy(o => (o.Key, ShipmentNumber: o.ShipmentNumber ?? string.Empty)) |
| | | 479 | | .Where(g => g.Count() > 1) |
| | 0 | 480 | | .ToList(); |
| | | 481 | | |
| | 0 | 482 | | if (duplicateGroups.Count == 0) |
| | | 483 | | { |
| | 0 | 484 | | return; |
| | | 485 | | } |
| | | 486 | | |
| | 0 | 487 | | var candidates = new List<DuplicateOrderCandidate>(); |
| | | 488 | | |
| | 0 | 489 | | foreach (var group in duplicateGroups) |
| | | 490 | | { |
| | 0 | 491 | | var ordersToKeep = group |
| | | 492 | | .OrderByDescending(o => GetOrderActivityScore(o)) |
| | | 493 | | .ThenByDescending(o => o.Id) |
| | 0 | 494 | | .First(); |
| | | 495 | | |
| | 0 | 496 | | candidates.AddRange(group.Where(o => o.Id != ordersToKeep.Id)); |
| | | 497 | | } |
| | | 498 | | |
| | 0 | 499 | | if (candidates.Count == 0) |
| | | 500 | | { |
| | 0 | 501 | | return; |
| | | 502 | | } |
| | | 503 | | |
| | | 504 | | // Удаляем по одному заказу, а не одним ExecuteDeleteAsync по всем Id: на Orders |
| | | 505 | | // висят внешние ключи с RESTRICT (OrderSupplierStates, корзины и позиции заказов |
| | | 506 | | // поставщиков), и один заблокированный заказ отменял удаление всей пачки — дубли |
| | | 507 | | // не удалялись никогда, а джоба импорта повторяла ту же ошибку каждый прогон. |
| | | 508 | | // Заказ с зависимыми записями пропускаем: за ним стоит работа пользователя, |
| | | 509 | | // молча ронять её нельзя. |
| | 0 | 510 | | var deletedOrders = new List<DuplicateOrderCandidate>(); |
| | 0 | 511 | | var blockedOrders = new List<(DuplicateOrderCandidate Order, string Reason)>(); |
| | | 512 | | |
| | 0 | 513 | | foreach (var candidate in candidates) |
| | | 514 | | { |
| | | 515 | | try |
| | | 516 | | { |
| | 0 | 517 | | var affected = await context.Orders |
| | 0 | 518 | | .Where(o => o.Id == candidate.Id) |
| | 0 | 519 | | .ExecuteDeleteAsync(); |
| | | 520 | | |
| | 0 | 521 | | if (affected > 0) |
| | | 522 | | { |
| | 0 | 523 | | deletedOrders.Add(candidate); |
| | | 524 | | } |
| | 0 | 525 | | } |
| | 0 | 526 | | catch (DbException ex) |
| | | 527 | | { |
| | | 528 | | // 23000 — нарушение ограничения целостности (для MySQL это 1451: |
| | | 529 | | // на заказ ссылается строка в зависимой таблице с ON DELETE RESTRICT). |
| | 0 | 530 | | blockedOrders.Add((candidate, ex.Message)); |
| | 0 | 531 | | } |
| | 0 | 532 | | } |
| | | 533 | | |
| | 0 | 534 | | if (blockedOrders.Count > 0) |
| | | 535 | | { |
| | 0 | 536 | | _logger.LogWarning( |
| | 0 | 537 | | "Дубли заказов: не удалось удалить {Count} заказ(ов) из-за зависимых записей. {Details}", |
| | 0 | 538 | | blockedOrders.Count, |
| | 0 | 539 | | string.Join("; ", blockedOrders.Select(b => |
| | | 540 | | $"Id={b.Order.Id}, Key={b.Order.Key}, ShipmentNumber={b.Order.ShipmentNumber}: {b.Reason}")) |
| | | 541 | | } |
| | | 542 | | |
| | 0 | 543 | | var deletedCount = deletedOrders.Count; |
| | | 544 | | |
| | 0 | 545 | | if (deletedCount > 0) |
| | | 546 | | { |
| | 0 | 547 | | var emailBody = new StringBuilder(); |
| | 0 | 548 | | emailBody.AppendLine("<h2>Обнаружены и удалены дубли заказов (Key + номер отправления)</h2>"); |
| | 0 | 549 | | emailBody.AppendLine($"<p><strong>Всего удалено дублей: {deletedCount}</strong></p>"); |
| | 0 | 550 | | emailBody.AppendLine("<hr>"); |
| | 0 | 551 | | emailBody.AppendLine("<h3>Детали удаленных заказов:</h3>"); |
| | 0 | 552 | | emailBody.AppendLine( |
| | 0 | 553 | | "<table border='1' cellpadding='5' cellspacing='0' style='border-collapse: collapse;'>"); |
| | 0 | 554 | | emailBody.AppendLine( |
| | 0 | 555 | | "<tr><th>ID</th><th>Key</th><th>ShipmentNumber</th><th>Article</th><th>ProductName</th><th>OzonC |
| | | 556 | | |
| | 0 | 557 | | foreach (var deletedOrder in deletedOrders) |
| | | 558 | | { |
| | 0 | 559 | | emailBody.AppendLine("<tr>"); |
| | 0 | 560 | | emailBody.AppendLine($"<td>{deletedOrder.Id}</td>"); |
| | 0 | 561 | | emailBody.AppendLine($"<td>{deletedOrder.Key ?? "N/A"}</td>"); |
| | 0 | 562 | | emailBody.AppendLine($"<td>{deletedOrder.ShipmentNumber ?? "N/A"}</td>"); |
| | 0 | 563 | | emailBody.AppendLine($"<td>{deletedOrder.Article ?? "N/A"}</td>"); |
| | 0 | 564 | | emailBody.AppendLine($"<td>{deletedOrder.ProductName ?? "N/A"}</td>"); |
| | 0 | 565 | | emailBody.AppendLine($"<td>{deletedOrder.OzonClientName ?? "N/A"}</td>"); |
| | 0 | 566 | | emailBody.AppendLine("</tr>"); |
| | | 567 | | } |
| | | 568 | | |
| | 0 | 569 | | emailBody.AppendLine("</table>"); |
| | 0 | 570 | | emailBody.AppendLine("<hr>"); |
| | 0 | 571 | | emailBody.AppendLine($"<p><small>Время проверки: {DateTime.Now:yyyy-MM-dd HH:mm:ss}</small></p>"); |
| | | 572 | | |
| | 0 | 573 | | await _emailService.SendEmailAsync( |
| | 0 | 574 | | "Studio 2: Удалены дубли заказов", |
| | 0 | 575 | | emailBody.ToString() |
| | 0 | 576 | | ); |
| | | 577 | | } |
| | 0 | 578 | | } |
| | 0 | 579 | | catch (Exception ex) |
| | | 580 | | { |
| | 0 | 581 | | _logger.LogError(ex, "Ошибка при удалении дублей заказов"); |
| | 0 | 582 | | } |
| | 0 | 583 | | } |
| | | 584 | | |
| | | 585 | | /// <summary> |
| | | 586 | | /// Вычисляет "оценку активности" заказа - чем выше, тем больше пользователь работал с заказом. |
| | | 587 | | /// </summary> |
| | | 588 | | private static int GetOrderActivityScore(DuplicateOrderCandidate order) |
| | | 589 | | { |
| | 0 | 590 | | int score = 0; |
| | | 591 | | |
| | | 592 | | // Количество измененных полей (основной показатель) |
| | 0 | 593 | | if (order.UpdatedColumns != null && order.UpdatedColumns.Any()) |
| | | 594 | | { |
| | 0 | 595 | | score += order.UpdatedColumns.Count * 100; // Каждое измененное поле = 100 очков |
| | | 596 | | } |
| | | 597 | | |
| | | 598 | | // Заказ был проверен пользователем |
| | 0 | 599 | | if (order.IsVerified) |
| | | 600 | | { |
| | 0 | 601 | | score += 50; |
| | | 602 | | } |
| | | 603 | | |
| | | 604 | | // Заказ был принят |
| | 0 | 605 | | if (order.IsAccepted == true) |
| | | 606 | | { |
| | 0 | 607 | | score += 30; |
| | | 608 | | } |
| | | 609 | | |
| | | 610 | | // Назначен поставщик (признак работы пользователя) |
| | 0 | 611 | | if (order.SupplierId.HasValue) |
| | | 612 | | { |
| | 0 | 613 | | score += 25; |
| | | 614 | | } |
| | | 615 | | |
| | | 616 | | // Есть номер заказа поставщику |
| | 0 | 617 | | if (!string.IsNullOrWhiteSpace(order.OrderNumberToSupplier)) |
| | | 618 | | { |
| | 0 | 619 | | score += 20; |
| | | 620 | | } |
| | | 621 | | |
| | | 622 | | // Есть комментарий |
| | 0 | 623 | | if (!string.IsNullOrWhiteSpace(order.Comment)) |
| | | 624 | | { |
| | 0 | 625 | | score += 15; |
| | | 626 | | } |
| | | 627 | | |
| | | 628 | | // Изменялся статус приложения (признак активности) |
| | 0 | 629 | | if (order.LastStatusChangeDate.HasValue) |
| | | 630 | | { |
| | 0 | 631 | | score += 10; |
| | | 632 | | } |
| | | 633 | | |
| | | 634 | | // Назначена себестоимость (признак обработки) |
| | 0 | 635 | | if (order.CostPrice.HasValue && order.CostPrice.Value > 0) |
| | | 636 | | { |
| | 0 | 637 | | score += 10; |
| | | 638 | | } |
| | | 639 | | |
| | | 640 | | // Назначена закупочная цена (признак обработки) |
| | 0 | 641 | | if (order.PurchasePrice.HasValue && order.PurchasePrice.Value > 0) |
| | | 642 | | { |
| | 0 | 643 | | score += 10; |
| | | 644 | | } |
| | | 645 | | |
| | | 646 | | // Назначен статус приложения (признак работы) |
| | 0 | 647 | | if (order.AppStatusId.HasValue) |
| | | 648 | | { |
| | 0 | 649 | | score += 5; |
| | | 650 | | } |
| | | 651 | | |
| | 0 | 652 | | return score; |
| | | 653 | | } |
| | | 654 | | |
| | | 655 | | private sealed class DuplicateOrderCandidate |
| | | 656 | | { |
| | 0 | 657 | | public int Id { get; init; } |
| | 0 | 658 | | public string Key { get; init; } = string.Empty; |
| | 0 | 659 | | public string? ShipmentNumber { get; init; } |
| | 0 | 660 | | public string? Article { get; init; } |
| | 0 | 661 | | public string? ProductName { get; init; } |
| | 0 | 662 | | public string? OzonClientName { get; init; } |
| | 0 | 663 | | public List<string>? UpdatedColumns { get; init; } |
| | 0 | 664 | | public bool IsVerified { get; init; } |
| | 0 | 665 | | public bool? IsAccepted { get; init; } |
| | 0 | 666 | | public int? SupplierId { get; init; } |
| | 0 | 667 | | public string? OrderNumberToSupplier { get; init; } |
| | 0 | 668 | | public string? Comment { get; init; } |
| | 0 | 669 | | public DateTime? LastStatusChangeDate { get; init; } |
| | 0 | 670 | | public decimal? CostPrice { get; init; } |
| | 0 | 671 | | public decimal? PurchasePrice { get; init; } |
| | 0 | 672 | | public int? AppStatusId { get; init; } |
| | | 673 | | } |
| | | 674 | | |
| | | 675 | | |
| | | 676 | | public async Task<int> MultiplayEditOrder(IEnumerable<Order> orders, string userName) |
| | | 677 | | { |
| | 0 | 678 | | foreach (Order order in orders) |
| | | 679 | | { |
| | | 680 | | try |
| | | 681 | | { |
| | 0 | 682 | | Order complitOrder = await CastToModel(order); |
| | 0 | 683 | | Order? existingOrder = await GetOrder(complitOrder.Id); |
| | 0 | 684 | | if (existingOrder == null) |
| | 0 | 685 | | throw new InvalidOperationException($"Заказ {complitOrder.Id} не найден"); |
| | 0 | 686 | | await UpdateOrderWeb(existingOrder, complitOrder); |
| | 0 | 687 | | if (existingOrder.AppStatus?.Name != complitOrder.AppStatus?.Name) |
| | | 688 | | { |
| | 0 | 689 | | existingOrder.LastStatusChangeDate = DateTime.Now; |
| | | 690 | | } |
| | | 691 | | |
| | 0 | 692 | | existingOrder.IsVerified = true; |
| | 0 | 693 | | existingOrder.UpdatedBy = userName; |
| | 0 | 694 | | } |
| | 0 | 695 | | catch (Exception ex) |
| | | 696 | | { |
| | 0 | 697 | | Console.WriteLine($"Не удалось изменить заказ {order.ShipmentNumber}"); |
| | 0 | 698 | | Console.WriteLine(ex.Message); |
| | 0 | 699 | | Console.WriteLine(ex.StackTrace); |
| | 0 | 700 | | Console.WriteLine(ex.Data); |
| | | 701 | | |
| | 0 | 702 | | throw new Exception(message: $"Не удалось изменить заказ: {ex.Message}"); |
| | | 703 | | } |
| | 0 | 704 | | } |
| | | 705 | | |
| | 0 | 706 | | return await SaveChanges(); |
| | 0 | 707 | | } |
| | | 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 | | // Модель из представления (со всеми изменёнными полями) |
| | 4 | 718 | | var complitOrder = await CastToModel(order); |
| | | 719 | | |
| | | 720 | | // Текущий заказ из БД |
| | 4 | 721 | | var existingOrder = await GetOrder(complitOrder.Id) |
| | 4 | 722 | | ?? throw new InvalidOperationException($"Заказ {complitOrder.Id} не найден"); |
| | | 723 | | |
| | | 724 | | // Количество в БД и новое количество из формы |
| | 4 | 725 | | var originalQuantity = existingOrder.Quantity ?? 0; |
| | 4 | 726 | | var newQuantity = complitOrder.Quantity ?? originalQuantity; |
| | | 727 | | |
| | 4 | 728 | | if (newQuantity < 0) |
| | | 729 | | { |
| | 0 | 730 | | throw new ArgumentException("Количество не может быть меньше нуля"); |
| | | 731 | | } |
| | | 732 | | |
| | 4 | 733 | | if (newQuantity > originalQuantity) |
| | | 734 | | { |
| | 0 | 735 | | throw new ArgumentException("Новое количество не может быть больше исходного"); |
| | | 736 | | } |
| | | 737 | | |
| | | 738 | | // Если количество уменьшили до 0 — трактуем это как "вся позиция в отмену". |
| | | 739 | | // ВАЖНО: не делаем SplitOrder, иначе появятся /1 и /2 (в т.ч. /2 с Quantity=0). |
| | | 740 | | // Статус "К отмене" будет выставлен на уровне транзакционного контроллера |
| | | 741 | | // (AfterTransactOrdersAsync -> ApplyQuantitySplitCancellationLogic) по признаку Quantity==0. |
| | 4 | 742 | | if (newQuantity == 0 && originalQuantity > 0) |
| | | 743 | | { |
| | 0 | 744 | | await UpdateOrderWeb(existingOrder, complitOrder, submittedFields); |
| | | 745 | | |
| | 0 | 746 | | if (existingOrder.AppStatus?.Name != complitOrder.AppStatus?.Name) |
| | | 747 | | { |
| | 0 | 748 | | existingOrder.LastStatusChangeDate = DateTime.Now; |
| | | 749 | | } |
| | | 750 | | |
| | 0 | 751 | | existingOrder.IsVerified = true; |
| | 0 | 752 | | SetUpdatedBy(existingOrder, updatedBy); |
| | | 753 | | |
| | 0 | 754 | | if (!complitOrder.ShipmentWarehouseId.HasValue && |
| | 0 | 755 | | order.ShipmentWarehouse != null && |
| | 0 | 756 | | order.ShipmentWarehouse.Id == 0 && |
| | 0 | 757 | | order.ShipmentWarehouse.Name != null) |
| | | 758 | | { |
| | 0 | 759 | | existingOrder.ShipmentWarehouse = |
| | 0 | 760 | | await warehouseDataServices.GetWarehouseAsync(new Warehouse |
| | 0 | 761 | | { Name = order.ShipmentWarehouse.Name }); |
| | | 762 | | } |
| | | 763 | | |
| | 0 | 764 | | await SaveChanges(); |
| | 0 | 765 | | return existingOrder; |
| | | 766 | | } |
| | | 767 | | |
| | | 768 | | // Если количество не менялось — работаем по старой схеме |
| | 4 | 769 | | if (newQuantity == originalQuantity || originalQuantity <= 1) |
| | | 770 | | { |
| | 4 | 771 | | await UpdateOrderWeb(existingOrder, complitOrder, submittedFields); |
| | | 772 | | |
| | 4 | 773 | | if (existingOrder.AppStatus?.Name != complitOrder.AppStatus?.Name) |
| | | 774 | | { |
| | 3 | 775 | | existingOrder.LastStatusChangeDate = DateTime.Now; |
| | | 776 | | } |
| | | 777 | | |
| | 4 | 778 | | existingOrder.IsVerified = true; |
| | 4 | 779 | | SetUpdatedBy(existingOrder, updatedBy); |
| | | 780 | | |
| | 4 | 781 | | if (!complitOrder.ShipmentWarehouseId.HasValue && |
| | 4 | 782 | | order.ShipmentWarehouse != null && |
| | 4 | 783 | | order.ShipmentWarehouse.Id == 0 && |
| | 4 | 784 | | order.ShipmentWarehouse.Name != null) |
| | | 785 | | { |
| | 0 | 786 | | existingOrder.ShipmentWarehouse = |
| | 0 | 787 | | await warehouseDataServices.GetWarehouseAsync(new Warehouse |
| | 0 | 788 | | { Name = order.ShipmentWarehouse.Name }); |
| | | 789 | | } |
| | | 790 | | |
| | 4 | 791 | | await SaveChanges(); |
| | 4 | 792 | | return existingOrder; |
| | | 793 | | } |
| | | 794 | | |
| | | 795 | | // Если количество уменьшилось — разделяем заказ: |
| | | 796 | | // в оригинальном остаётся остаток, для транзакции создаём новый заказ |
| | 0 | 797 | | var remainingQuantity = originalQuantity - newQuantity; |
| | | 798 | | |
| | | 799 | | // Разделяем исходный заказ: в existingOrder останется остаток, вернём новый заказ |
| | 0 | 800 | | var splittedOrder = await SplitOrder(existingOrder.Id, remainingQuantity, newQuantity); |
| | 0 | 801 | | if (splittedOrder == null) |
| | 0 | 802 | | throw new InvalidOperationException($"Не удалось разделить заказ {existingOrder.Id}"); |
| | | 803 | | |
| | | 804 | | // Настраиваем модель из представления под новый заказ |
| | 0 | 805 | | complitOrder.Id = splittedOrder.Id; |
| | 0 | 806 | | complitOrder.Quantity = splittedOrder.Quantity; |
| | | 807 | | |
| | | 808 | | // ВАЖНО: оставляем номер отправления и ShipmentId из нового заказа, |
| | | 809 | | // чтобы не перезатереть суффиксы (/1, /2) и привязку к отправлению данными из формы |
| | 0 | 810 | | complitOrder.ShipmentNumber = splittedOrder.ShipmentNumber; |
| | 0 | 811 | | complitOrder.ShipmentId = splittedOrder.ShipmentId; |
| | | 812 | | |
| | | 813 | | // Применяем изменения только к новому заказу (оригинал остаётся "нетронутым", кроме количества/номера) |
| | 0 | 814 | | await UpdateOrderWeb(splittedOrder, complitOrder, submittedFields); |
| | | 815 | | |
| | 0 | 816 | | if (splittedOrder.AppStatus?.Name != complitOrder.AppStatus?.Name) |
| | | 817 | | { |
| | 0 | 818 | | splittedOrder.LastStatusChangeDate = DateTime.Now; |
| | | 819 | | } |
| | | 820 | | |
| | 0 | 821 | | splittedOrder.IsVerified = true; |
| | 0 | 822 | | SetUpdatedBy(splittedOrder, updatedBy); |
| | | 823 | | |
| | 0 | 824 | | if (!complitOrder.ShipmentWarehouseId.HasValue && |
| | 0 | 825 | | order.ShipmentWarehouse != null && |
| | 0 | 826 | | order.ShipmentWarehouse.Id == 0 && |
| | 0 | 827 | | order.ShipmentWarehouse.Name != null) |
| | | 828 | | { |
| | 0 | 829 | | splittedOrder.ShipmentWarehouse = |
| | 0 | 830 | | await warehouseDataServices.GetWarehouseAsync(new Warehouse |
| | 0 | 831 | | { Name = order.ShipmentWarehouse.Name }); |
| | | 832 | | } |
| | | 833 | | |
| | 0 | 834 | | await SaveChanges(); |
| | 0 | 835 | | return splittedOrder; |
| | 4 | 836 | | } |
| | | 837 | | |
| | | 838 | | /// <summary>Триггер orders_history берёт автора правки из Orders.UpdatedBy (по умолчанию «Ozon»), поэтому имя п |
| | | 839 | | private static void SetUpdatedBy(Order order, string? updatedBy) |
| | | 840 | | { |
| | 4 | 841 | | if (!string.IsNullOrWhiteSpace(updatedBy)) |
| | | 842 | | { |
| | 0 | 843 | | order.UpdatedBy = updatedBy; |
| | | 844 | | } |
| | 4 | 845 | | } |
| | | 846 | | |
| | | 847 | | private async Task UpdateOrderWeb(Order existingOrder, Order jsonOrder, IReadOnlySet<string>? submittedFields = |
| | | 848 | | { |
| | 4 | 849 | | var isVerified = existingOrder.IsVerified; |
| | 4 | 850 | | var isIsAccepted = existingOrder.IsAccepted; |
| | | 851 | | |
| | 4 | 852 | | var orderChangeList = GetOrderChangeList(existingOrder, jsonOrder, _ignoredWebProperties); |
| | | 853 | | |
| | | 854 | | // Отсутствующее в форме поле выглядит как изменение «значение -> null»: без фильтра |
| | | 855 | | // такие мнимые правки попадали бы в UpdatedColumns и в историю заказа. |
| | | 856 | | // Серверные расчётные поля из фильтра исключены: их в форме нет никогда, |
| | | 857 | | // и без исключения они выпадали из UpdatedColumns, а значит и из сохранения. |
| | 4 | 858 | | if (submittedFields != null) |
| | | 859 | | { |
| | 3 | 860 | | orderChangeList = orderChangeList |
| | 24 | 861 | | .Where(name => submittedFields.Contains(name) || _serverCalculatedProperties.Contains(name)) |
| | 3 | 862 | | .ToList(); |
| | | 863 | | } |
| | | 864 | | |
| | 4 | 865 | | if (orderChangeList.Any()) |
| | | 866 | | { |
| | 2 | 867 | | existingOrder.UpdatedColumns = orderChangeList; |
| | | 868 | | } |
| | | 869 | | |
| | | 870 | | // Если UpdatedColumns пуст, но AppStatusId был передан, создаем список для обновления |
| | 4 | 871 | | if (existingOrder.UpdatedColumns == null || !existingOrder.UpdatedColumns.Any()) |
| | | 872 | | { |
| | 2 | 873 | | existingOrder.UpdatedColumns = new List<string>(); |
| | | 874 | | } |
| | | 875 | | |
| | 4 | 876 | | var appStatusSubmitted = submittedFields == null || submittedFields.Contains(nameof(Order.AppStatusId)); |
| | | 877 | | |
| | | 878 | | // Проверяем, был ли передан AppStatusId из формы, даже если он не попал в UpdatedColumns |
| | 4 | 879 | | if (appStatusSubmitted && jsonOrder.AppStatusId.HasValue && existingOrder.AppStatusId != jsonOrder.AppStatus |
| | | 880 | | { |
| | 0 | 881 | | if (!existingOrder.UpdatedColumns.Contains("AppStatusId")) |
| | | 882 | | { |
| | 0 | 883 | | existingOrder.UpdatedColumns.Add("AppStatusId"); |
| | | 884 | | } |
| | | 885 | | } |
| | | 886 | | |
| | 4 | 887 | | if (existingOrder.UpdatedColumns != null) |
| | | 888 | | { |
| | 4 | 889 | | var properties = _cachedOrderProperties; |
| | | 890 | | |
| | 960 | 891 | | foreach (var prop in properties) |
| | | 892 | | { |
| | | 893 | | try |
| | | 894 | | { |
| | 476 | 895 | | var jsonProp = jsonOrder.GetType().GetProperty(prop.Name); |
| | 476 | 896 | | var existingProp = existingOrder.GetType().GetProperty(prop.Name); |
| | | 897 | | |
| | 476 | 898 | | if (jsonProp == null || existingProp == null || !existingProp.CanWrite) |
| | 0 | 899 | | continue; |
| | | 900 | | |
| | | 901 | | // Поля, которого не было в форме, пользователь изменить не мог: |
| | | 902 | | // его null — следствие скрытой колонки, а не очистки значения. |
| | | 903 | | // Исключение — то, что посчитал сам сервер: оно и не должно приходить с формы. |
| | 476 | 904 | | if (submittedFields != null |
| | 476 | 905 | | && !submittedFields.Contains(prop.Name) |
| | 476 | 906 | | && !_serverCalculatedProperties.Contains(prop.Name)) |
| | 335 | 907 | | continue; |
| | | 908 | | |
| | 141 | 909 | | var value = jsonProp.GetValue(jsonOrder); |
| | | 910 | | |
| | 141 | 911 | | if (prop.Name == "OrderExtensions") |
| | 1 | 912 | | continue; |
| | | 913 | | |
| | | 914 | | // Специальная обработка для AppStatusId - всегда обновляем, если он был передан из формы |
| | 140 | 915 | | if (prop.Name == "AppStatusId") |
| | | 916 | | { |
| | 1 | 917 | | var jsonAppStatusId = jsonProp.GetValue(jsonOrder) as int?; |
| | 1 | 918 | | var existingAppStatusId = existingProp.GetValue(existingOrder) as int?; |
| | | 919 | | |
| | | 920 | | // Обновляем AppStatusId, если он был передан и отличается от текущего |
| | 1 | 921 | | if (jsonAppStatusId.HasValue && jsonAppStatusId != existingAppStatusId) |
| | | 922 | | { |
| | 0 | 923 | | existingProp.SetValue(existingOrder, jsonAppStatusId); |
| | | 924 | | // Убеждаемся, что AppStatusId добавлен в UpdatedColumns |
| | 0 | 925 | | if (existingOrder.UpdatedColumns != null && |
| | 0 | 926 | | !existingOrder.UpdatedColumns.Contains("AppStatusId")) |
| | | 927 | | { |
| | 0 | 928 | | existingOrder.UpdatedColumns.Add("AppStatusId"); |
| | | 929 | | } |
| | | 930 | | } |
| | | 931 | | // Также обновляем, если AppStatusId был передан как null (для сброса статуса) |
| | 1 | 932 | | else if (!jsonAppStatusId.HasValue && existingAppStatusId.HasValue) |
| | | 933 | | { |
| | 1 | 934 | | existingProp.SetValue(existingOrder, null); |
| | 1 | 935 | | if (existingOrder.UpdatedColumns != null && |
| | 1 | 936 | | !existingOrder.UpdatedColumns.Contains("AppStatusId")) |
| | | 937 | | { |
| | 0 | 938 | | existingOrder.UpdatedColumns.Add("AppStatusId"); |
| | | 939 | | } |
| | | 940 | | } |
| | | 941 | | |
| | 1 | 942 | | continue; |
| | | 943 | | } |
| | | 944 | | |
| | 139 | 945 | | bool shouldUpdate = |
| | 139 | 946 | | value != null && prop.Name != "Id" && existingOrder.UpdatedColumns?.Contains(prop.Name) == t |
| | 139 | 947 | | value == null && _ignoredWebNullProperties.Contains(prop.Name); |
| | | 948 | | |
| | 139 | 949 | | if (shouldUpdate) |
| | | 950 | | { |
| | 5 | 951 | | existingProp.SetValue(existingOrder, value); |
| | | 952 | | } |
| | 139 | 953 | | } |
| | 0 | 954 | | catch (Exception ex) |
| | | 955 | | { |
| | 0 | 956 | | Console.WriteLine($"Error setting property {prop.Name}: {ex.Message}"); |
| | 0 | 957 | | } |
| | | 958 | | } |
| | | 959 | | } |
| | | 960 | | |
| | | 961 | | // Явно обновляем AppStatus из AppStatusId, если AppStatusId был изменен |
| | | 962 | | // Это важно для транзакций, где статус может быть изменен в форме |
| | 4 | 963 | | if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("AppStatusId")) |
| | | 964 | | { |
| | 1 | 965 | | if (existingOrder.AppStatusId.HasValue) |
| | | 966 | | { |
| | 0 | 967 | | existingOrder.AppStatus = |
| | 0 | 968 | | await appStatusDataServices.GetAppStatusAsync(existingOrder.AppStatusId.Value); |
| | | 969 | | } |
| | | 970 | | |
| | 1 | 971 | | existingOrder.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow, |
| | 1 | 972 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 973 | | } |
| | | 974 | | // Также обновляем AppStatus, если он был изменен напрямую (для обратной совместимости) |
| | 3 | 975 | | else if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("AppStatus")) |
| | | 976 | | { |
| | 0 | 977 | | if (existingOrder.AppStatusId.HasValue) |
| | | 978 | | { |
| | 0 | 979 | | existingOrder.AppStatus = |
| | 0 | 980 | | await appStatusDataServices.GetAppStatusAsync(existingOrder.AppStatusId.Value); |
| | | 981 | | } |
| | | 982 | | } |
| | | 983 | | // Если AppStatusId был передан, но не попал в UpdatedColumns (например, при первом сохранении) |
| | 3 | 984 | | else if (jsonOrder.AppStatusId.HasValue && existingOrder.AppStatusId != jsonOrder.AppStatusId) |
| | | 985 | | { |
| | 0 | 986 | | existingOrder.AppStatusId = jsonOrder.AppStatusId; |
| | 0 | 987 | | existingOrder.AppStatus = await appStatusDataServices.GetAppStatusAsync(jsonOrder.AppStatusId.Value); |
| | 0 | 988 | | existingOrder.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow, |
| | 0 | 989 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 990 | | } |
| | | 991 | | |
| | 4 | 992 | | if (existingOrder.UpdatedColumns != null && !existingOrder.UpdatedColumns.Contains("IsAccepted")) |
| | | 993 | | { |
| | 4 | 994 | | existingOrder.IsAccepted = false; |
| | | 995 | | } |
| | | 996 | | else |
| | | 997 | | { |
| | 0 | 998 | | existingOrder.IsAccepted = isIsAccepted; |
| | | 999 | | } |
| | | 1000 | | |
| | 4 | 1001 | | existingOrder.IsVerified = isVerified; |
| | | 1002 | | |
| | | 1003 | | // Сохраняем изменения |
| | 4 | 1004 | | await context.SaveChangesAsync(); |
| | | 1005 | | |
| | | 1006 | | // Проверяем и удаляем дубли по Key после обновления (только если Key был изменен) |
| | 4 | 1007 | | if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("Key")) |
| | | 1008 | | { |
| | 0 | 1009 | | await RemoveDuplicateOrdersByKey(); |
| | | 1010 | | } |
| | 4 | 1011 | | } |
| | | 1012 | | |
| | | 1013 | | private async Task UpdateOrder(Order existingOrder, Order jsonOrder) |
| | | 1014 | | { |
| | 0 | 1015 | | var isVerified = existingOrder.IsVerified; |
| | 0 | 1016 | | var isIsAccepted = existingOrder.IsAccepted; |
| | | 1017 | | |
| | 0 | 1018 | | if (isVerified) |
| | | 1019 | | { |
| | | 1020 | | } |
| | | 1021 | | |
| | 0 | 1022 | | var orderChangeList = GetOrderChangeList(existingOrder, jsonOrder, _ignoredProperties); |
| | | 1023 | | |
| | 0 | 1024 | | if (orderChangeList.Any()) |
| | | 1025 | | { |
| | 0 | 1026 | | existingOrder.UpdatedColumns = orderChangeList; |
| | | 1027 | | } |
| | | 1028 | | else |
| | | 1029 | | { |
| | 0 | 1030 | | existingOrder.UpdatedColumns = null; |
| | | 1031 | | } |
| | | 1032 | | |
| | 0 | 1033 | | if (existingOrder.UpdatedColumns != null) |
| | | 1034 | | { |
| | 0 | 1035 | | var properties = _cachedOrderProperties; |
| | 0 | 1036 | | var jsonOrderType = jsonOrder.GetType(); |
| | | 1037 | | |
| | 0 | 1038 | | foreach (var prop in properties) |
| | | 1039 | | { |
| | | 1040 | | try |
| | | 1041 | | { |
| | 0 | 1042 | | var jsonOrderProp = jsonOrderType.GetProperty(prop.Name); |
| | | 1043 | | |
| | 0 | 1044 | | if (jsonOrderProp != null) |
| | | 1045 | | { |
| | 0 | 1046 | | var o = jsonOrderProp.GetValue(jsonOrder); |
| | | 1047 | | |
| | 0 | 1048 | | if (o != null && prop.Name != "Id" && existingOrder.UpdatedColumns.Contains(prop.Name)) |
| | | 1049 | | { |
| | | 1050 | | // 🔒 проверка: не даём обновлять ценовые поля, если они уже заданы |
| | 0 | 1051 | | if (_priceProperties.Contains(prop.Name)) |
| | | 1052 | | { |
| | 0 | 1053 | | var currentValue = prop.GetValue(existingOrder); |
| | 0 | 1054 | | if (currentValue != null) |
| | | 1055 | | { |
| | 0 | 1056 | | continue; // пропускаем обновление этого свойства |
| | | 1057 | | } |
| | | 1058 | | } |
| | | 1059 | | |
| | 0 | 1060 | | var existingOrderProp = existingOrder.GetType().GetProperty(prop.Name); |
| | 0 | 1061 | | if (existingOrderProp != null && existingOrderProp.CanWrite) |
| | | 1062 | | { |
| | 0 | 1063 | | existingOrderProp.SetValue(existingOrder, o); |
| | | 1064 | | } |
| | | 1065 | | } |
| | | 1066 | | } |
| | 0 | 1067 | | } |
| | 0 | 1068 | | catch (Exception ex) |
| | | 1069 | | { |
| | 0 | 1070 | | Console.WriteLine($"Error setting property {prop.Name}: {ex.Message}"); |
| | 0 | 1071 | | } |
| | | 1072 | | } |
| | | 1073 | | } |
| | | 1074 | | |
| | 0 | 1075 | | if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("AppStatusId")) |
| | | 1076 | | { |
| | 0 | 1077 | | existingOrder.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow, |
| | 0 | 1078 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 1079 | | } |
| | | 1080 | | |
| | 0 | 1081 | | if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns[0] != "IsAccepted") |
| | | 1082 | | { |
| | 0 | 1083 | | existingOrder.IsAccepted = false; |
| | | 1084 | | } |
| | | 1085 | | else |
| | | 1086 | | { |
| | 0 | 1087 | | existingOrder.IsAccepted = isIsAccepted; |
| | | 1088 | | } |
| | | 1089 | | |
| | 0 | 1090 | | existingOrder.IsVerified = isVerified; |
| | | 1091 | | |
| | | 1092 | | // Привязка к отправлению при изменении ShipmentNumber |
| | 0 | 1093 | | if (existingOrder.UpdatedColumns != null && existingOrder.UpdatedColumns.Contains("ShipmentNumber")) |
| | | 1094 | | { |
| | 0 | 1095 | | await BindShipmentAsync(existingOrder); |
| | | 1096 | | } |
| | | 1097 | | |
| | | 1098 | | //await CastToModel(existingOrder); |
| | 0 | 1099 | | } |
| | | 1100 | | |
| | | 1101 | | private List<string> GetOrderChangeList(Order existingOrder, Order jsonOrder, string[] ignoredProperties) |
| | | 1102 | | { |
| | 4 | 1103 | | List<string> changeProp = new List<string>(); |
| | | 1104 | | |
| | 4 | 1105 | | changeProp = CompareObjects(existingOrder, jsonOrder, changeProp, ignoredProperties); |
| | | 1106 | | |
| | 4 | 1107 | | return changeProp; |
| | | 1108 | | } |
| | | 1109 | | |
| | | 1110 | | private List<string> CompareObjects(object obj1, object obj2, List<string> changeProp, |
| | | 1111 | | string[] ignoredProperties) |
| | | 1112 | | { |
| | 4 | 1113 | | Type type = obj1.GetType(); |
| | | 1114 | | |
| | 960 | 1115 | | foreach (PropertyInfo property in type.GetProperties()) |
| | | 1116 | | { |
| | 476 | 1117 | | if (property.CanRead) |
| | | 1118 | | { |
| | 476 | 1119 | | if (ignoredProperties.Contains(property.Name)) |
| | | 1120 | | { |
| | | 1121 | | continue; |
| | | 1122 | | } |
| | | 1123 | | |
| | 416 | 1124 | | object? val1 = string.Equals(property.GetValue(obj1) as string, string.Empty) ? null : property.GetV |
| | 416 | 1125 | | object? val2 = string.Equals(property.GetValue(obj2) as string, string.Empty) ? null : property.GetV |
| | | 1126 | | |
| | | 1127 | | // Специальная обработка для AppStatus - сравниваем по ID, а не как объект |
| | 416 | 1128 | | if (property.Name == "AppStatus") |
| | | 1129 | | { |
| | 4 | 1130 | | int? id1 = null; |
| | 4 | 1131 | | int? id2 = null; |
| | | 1132 | | |
| | 4 | 1133 | | if (val1 != null) |
| | | 1134 | | { |
| | 4 | 1135 | | var idProp1 = val1.GetType().GetProperty("Id"); |
| | 4 | 1136 | | if (idProp1 != null) |
| | | 1137 | | { |
| | 4 | 1138 | | id1 = (int?)idProp1.GetValue(val1); |
| | | 1139 | | } |
| | | 1140 | | } |
| | | 1141 | | |
| | 4 | 1142 | | if (val2 != null) |
| | | 1143 | | { |
| | 0 | 1144 | | var idProp2 = val2.GetType().GetProperty("Id"); |
| | 0 | 1145 | | if (idProp2 != null) |
| | | 1146 | | { |
| | 0 | 1147 | | id2 = (int?)idProp2.GetValue(val2); |
| | | 1148 | | } |
| | | 1149 | | } |
| | | 1150 | | |
| | 4 | 1151 | | if (id1 != id2) |
| | | 1152 | | { |
| | 4 | 1153 | | changeProp.Add( |
| | 4 | 1154 | | "AppStatusId"); // Добавляем AppStatusId вместо AppStatus для правильного обновления |
| | | 1155 | | } |
| | | 1156 | | |
| | 4 | 1157 | | continue; |
| | | 1158 | | } |
| | | 1159 | | |
| | 412 | 1160 | | if (val1 != null && val2 != null) |
| | | 1161 | | { |
| | 32 | 1162 | | if (property.PropertyType.Assembly == type.Assembly && !property.PropertyType.IsPrimitive && |
| | 32 | 1163 | | property.PropertyType != typeof(string)) |
| | | 1164 | | { |
| | 0 | 1165 | | CompareObjects(val1, val2, changeProp, ignoredProperties); |
| | | 1166 | | } |
| | 32 | 1167 | | else if (!val1.Equals(val2)) |
| | | 1168 | | { |
| | 4 | 1169 | | changeProp.Add(property.Name); |
| | | 1170 | | } |
| | | 1171 | | } |
| | 380 | 1172 | | else if (val1 != null || val2 != null) |
| | | 1173 | | { |
| | 24 | 1174 | | changeProp.Add(property.Name); |
| | | 1175 | | } |
| | | 1176 | | } |
| | | 1177 | | } |
| | | 1178 | | |
| | 4 | 1179 | | return changeProp; |
| | | 1180 | | } |
| | | 1181 | | |
| | | 1182 | | |
| | | 1183 | | public async Task ConfirmAccepted(int id) |
| | | 1184 | | { |
| | 0 | 1185 | | var order = await repository.GetAsync(id); |
| | 0 | 1186 | | if (order == null) |
| | 0 | 1187 | | return; |
| | | 1188 | | |
| | 0 | 1189 | | order.IsAccepted = true; |
| | 0 | 1190 | | order.IsVerified = true; |
| | 0 | 1191 | | order.UpdatedColumns = null; |
| | | 1192 | | |
| | 0 | 1193 | | await SaveChanges(); |
| | 0 | 1194 | | } |
| | | 1195 | | |
| | | 1196 | | /// <summary> |
| | | 1197 | | /// Сохраняет дополнительные расходы для списка заказов. |
| | | 1198 | | /// Вызывается из контроллера транзакций вместо прямой работы с OrderExtraExpenseDataServices. |
| | | 1199 | | /// </summary> |
| | | 1200 | | public async Task SaveExtraExpensesForOrders(List<Order> orders) |
| | | 1201 | | { |
| | 0 | 1202 | | if (orders == null || orders.Count == 0) |
| | 0 | 1203 | | return; |
| | | 1204 | | |
| | | 1205 | | // Получаем список всех существующих ExpenseTypeId из базы данных |
| | 0 | 1206 | | var existingExpenseTypeIds = await context.ExpenseTypes |
| | 0 | 1207 | | .Where(et => et.IsActive) |
| | 0 | 1208 | | .Select(et => et.Id) |
| | 0 | 1209 | | .ToListAsync(); |
| | | 1210 | | |
| | | 1211 | | var orderIds = orders.Select(o => o.Id).ToList(); |
| | | 1212 | | |
| | | 1213 | | // Батч-удаление: один запрос SELECT + один DELETE для всех заказов (вместо N запросов) |
| | 0 | 1214 | | var toDelete = await context.OrderExtraExpenses |
| | 0 | 1215 | | .Where(e => orderIds.Contains(e.OrderId)) |
| | 0 | 1216 | | .ToListAsync(); |
| | | 1217 | | |
| | 0 | 1218 | | if (toDelete.Any()) |
| | | 1219 | | { |
| | 0 | 1220 | | context.OrderExtraExpenses.RemoveRange(toDelete); |
| | 0 | 1221 | | await context.SaveChangesAsync(); |
| | | 1222 | | } |
| | | 1223 | | |
| | | 1224 | | // Собираем все валидные расходы для батч-вставки |
| | 0 | 1225 | | var allNewExpenses = new List<OrderExtraExpense>(); |
| | 0 | 1226 | | foreach (var order in orders) |
| | | 1227 | | { |
| | 0 | 1228 | | var validExpenses = order.ExtraExpenses? |
| | 0 | 1229 | | .Where(e => e != null |
| | 0 | 1230 | | && e.ExpenseTypeId > 0 |
| | 0 | 1231 | | && e.Amount > 0 |
| | 0 | 1232 | | && existingExpenseTypeIds.Contains(e.ExpenseTypeId)) |
| | 0 | 1233 | | .Select(e => new OrderExtraExpense |
| | 0 | 1234 | | { |
| | 0 | 1235 | | OrderId = order.Id, |
| | 0 | 1236 | | ExpenseTypeId = e.ExpenseTypeId, |
| | 0 | 1237 | | Amount = e.Amount, |
| | 0 | 1238 | | Comment = e.Comment, |
| | 0 | 1239 | | CreatedAt = DateTime.UtcNow |
| | 0 | 1240 | | }) |
| | 0 | 1241 | | .ToList() ?? new List<OrderExtraExpense>(); |
| | | 1242 | | |
| | 0 | 1243 | | allNewExpenses.AddRange(validExpenses); |
| | | 1244 | | } |
| | | 1245 | | |
| | | 1246 | | // Батч-вставка: один INSERT для всех расходов (вместо N запросов) |
| | 0 | 1247 | | if (allNewExpenses.Any()) |
| | | 1248 | | { |
| | 0 | 1249 | | await context.OrderExtraExpenses.AddRangeAsync(allNewExpenses); |
| | 0 | 1250 | | await context.SaveChangesAsync(); |
| | | 1251 | | } |
| | 0 | 1252 | | } |
| | | 1253 | | |
| | | 1254 | | public async Task<Order> CastToModel(Order order) |
| | | 1255 | | { |
| | | 1256 | | // Обновляем AppStatus из AppStatusId, если AppStatusId указан |
| | | 1257 | | // Это важно для транзакций, где статус может быть изменен в форме |
| | 4 | 1258 | | if (order.AppStatusId.HasValue) |
| | | 1259 | | { |
| | | 1260 | | // Проверяем, не установлен ли уже AppStatus с правильным ID |
| | 0 | 1261 | | if (order.AppStatus == null || order.AppStatus.Id != order.AppStatusId.Value) |
| | | 1262 | | { |
| | | 1263 | | // Проверяем, не отслеживается ли уже AppStatus в контексте |
| | 0 | 1264 | | var trackedStatus = context.AppStatuses.Local.FirstOrDefault(s => s.Id == order.AppStatusId.Value); |
| | 0 | 1265 | | if (trackedStatus != null) |
| | | 1266 | | { |
| | 0 | 1267 | | order.AppStatus = trackedStatus; |
| | | 1268 | | } |
| | | 1269 | | else |
| | | 1270 | | { |
| | | 1271 | | // Если AppStatus уже установлен, но ID не совпадает, проверяем его состояние |
| | 0 | 1272 | | if (order.AppStatus != null) |
| | | 1273 | | { |
| | 0 | 1274 | | var entry = context.Entry(order.AppStatus); |
| | | 1275 | | // Если AppStatus отслеживается, отсоединяем его |
| | 0 | 1276 | | if (entry.State != EntityState.Detached) |
| | | 1277 | | { |
| | 0 | 1278 | | entry.State = EntityState.Detached; |
| | | 1279 | | } |
| | | 1280 | | } |
| | | 1281 | | |
| | | 1282 | | // Загружаем AppStatus с AsNoTracking, чтобы избежать конфликтов отслеживания |
| | 0 | 1283 | | var appStatus = await context.AppStatuses |
| | 0 | 1284 | | .AsNoTracking() |
| | 0 | 1285 | | .FirstOrDefaultAsync(s => s.Id == order.AppStatusId.Value); |
| | | 1286 | | |
| | 0 | 1287 | | if (appStatus != null) |
| | | 1288 | | { |
| | | 1289 | | // Проверяем, не отслеживается ли уже эта сущность в Local |
| | 0 | 1290 | | var existingTracked = context.AppStatuses.Local.FirstOrDefault(s => s.Id == appStatus.Id); |
| | 0 | 1291 | | if (existingTracked != null) |
| | | 1292 | | { |
| | 0 | 1293 | | order.AppStatus = existingTracked; |
| | | 1294 | | } |
| | | 1295 | | else |
| | | 1296 | | { |
| | | 1297 | | // Присоединяем существующую сущность к контексту |
| | 0 | 1298 | | context.AppStatuses.Attach(appStatus); |
| | 0 | 1299 | | order.AppStatus = appStatus; |
| | | 1300 | | } |
| | | 1301 | | } |
| | 0 | 1302 | | } |
| | | 1303 | | } |
| | | 1304 | | // Если AppStatus уже установлен и ID совпадает, ничего не делаем |
| | | 1305 | | } |
| | | 1306 | | |
| | 4 | 1307 | | if (order.SupplierId.HasValue) |
| | | 1308 | | { |
| | 0 | 1309 | | order.Supplier = await supplierDataServices.GetSupplierAsync(order.SupplierId.Value); |
| | | 1310 | | } |
| | | 1311 | | |
| | 4 | 1312 | | decimal? weight = order.ProductInfo?.Weight; |
| | | 1313 | | |
| | 4 | 1314 | | if (order.ProductInfoId.HasValue) |
| | | 1315 | | { |
| | 0 | 1316 | | order.ProductInfo = await productsDataServices.GetProductAsync(order.ProductInfoId.Value); |
| | | 1317 | | } |
| | | 1318 | | |
| | 4 | 1319 | | if (order.ProductInfo != null) |
| | | 1320 | | { |
| | 0 | 1321 | | order.ProductInfo.Weight = weight; |
| | 0 | 1322 | | await productsDataServices.Update(order.ProductInfo); |
| | | 1323 | | } |
| | | 1324 | | |
| | | 1325 | | try |
| | | 1326 | | { |
| | 4 | 1327 | | order = Coalesce(order, await orderPriceManager.SetPurchasePriceToRUB(order)); |
| | 0 | 1328 | | order = Coalesce(order, await orderPriceManager.CalculateCostPrice(order)); |
| | | 1329 | | |
| | 0 | 1330 | | if (order.ExtraExpenses != null && order.ExtraExpenses.Any() && order.CostPrice.HasValue) |
| | | 1331 | | { |
| | 0 | 1332 | | var totalExtraAmount = order.ExtraExpenses |
| | | 1333 | | .Where(e => e.ExpenseTypeId > 0 && e.Amount > 0) |
| | | 1334 | | .Sum(e => e.Amount); |
| | | 1335 | | |
| | 0 | 1336 | | if (totalExtraAmount > 0) |
| | | 1337 | | { |
| | 0 | 1338 | | var quantity = order.Quantity > 0 ? order.Quantity : 1; |
| | 0 | 1339 | | order.CostPrice += totalExtraAmount / quantity; |
| | | 1340 | | } |
| | | 1341 | | } |
| | | 1342 | | |
| | 0 | 1343 | | order = Coalesce(order, await orderPriceManager.CalculateProfit(order)); |
| | 0 | 1344 | | order = Coalesce(order, await orderPriceManager.CalculateDiscount(order)); |
| | 0 | 1345 | | } |
| | 4 | 1346 | | catch (Exception ex) |
| | | 1347 | | { |
| | | 1348 | | // Ошибка пересчёта цены не должна блокировать сохранение статуса и других полей |
| | 4 | 1349 | | Console.WriteLine($"Ошибка пересчёта цены для заказа {order.Id}: {ex.Message}"); |
| | 4 | 1350 | | } |
| | | 1351 | | |
| | 4 | 1352 | | await SetIsReturnable(order); |
| | | 1353 | | |
| | | 1354 | | // Привязка к отправлению |
| | 4 | 1355 | | await BindShipmentAsync(order); |
| | | 1356 | | |
| | 4 | 1357 | | return order; |
| | 4 | 1358 | | } |
| | | 1359 | | |
| | | 1360 | | /// <summary> |
| | | 1361 | | /// Привязывает заказ к отправлению по номеру отправления. |
| | | 1362 | | /// Если отправление не существует, создает новое. |
| | | 1363 | | /// </summary> |
| | | 1364 | | private async Task BindShipmentAsync(Order order) |
| | | 1365 | | { |
| | 4 | 1366 | | if (string.IsNullOrWhiteSpace(order.ShipmentNumber)) |
| | 4 | 1367 | | return; |
| | | 1368 | | |
| | 0 | 1369 | | order.ShipmentId = await _shipmentRepository.GetOrCreateAsync(order.ShipmentNumber); |
| | 4 | 1370 | | } |
| | | 1371 | | |
| | | 1372 | | private async Task<Order> CastToModelForOzon(Order order) |
| | | 1373 | | { |
| | | 1374 | | // Всегда обновляем AppStatus из AppStatusId, если AppStatusId указан |
| | 0 | 1375 | | if (order.AppStatusId.HasValue) |
| | | 1376 | | { |
| | 0 | 1377 | | order.AppStatus = await appStatusDataServices.GetAppStatusAsync(order.AppStatusId.Value); |
| | | 1378 | | } |
| | | 1379 | | |
| | 0 | 1380 | | if (order.SupplierId.HasValue) |
| | | 1381 | | { |
| | 0 | 1382 | | order.Supplier = await supplierDataServices.GetSupplierAsync(order.SupplierId.Value); |
| | | 1383 | | } |
| | | 1384 | | |
| | 0 | 1385 | | decimal? weight = order.ProductInfo?.Weight; |
| | | 1386 | | |
| | 0 | 1387 | | if (order.ProductInfoId.HasValue) |
| | | 1388 | | { |
| | 0 | 1389 | | order.ProductInfo = await productsDataServices.GetProductAsync(order.ProductInfoId.Value); |
| | | 1390 | | } |
| | | 1391 | | |
| | 0 | 1392 | | if (order.ProductInfo != null) |
| | | 1393 | | { |
| | 0 | 1394 | | order.ProductInfo.Weight = weight; |
| | 0 | 1395 | | 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 | | |
| | 0 | 1405 | | await SetIsReturnable(order); |
| | | 1406 | | |
| | | 1407 | | // Привязка к отправлению |
| | 0 | 1408 | | await BindShipmentAsync(order); |
| | | 1409 | | |
| | 0 | 1410 | | return order; |
| | 0 | 1411 | | } |
| | | 1412 | | |
| | 0 | 1413 | | private static Order Coalesce(Order order, Order? updated) => updated ?? order; |
| | | 1414 | | |
| | | 1415 | | private async Task<Order?> SetIsReturnable(Order? order) |
| | | 1416 | | { |
| | 4 | 1417 | | await Task.CompletedTask; |
| | 4 | 1418 | | if (order?.AppStatus != null) |
| | | 1419 | | { |
| | 0 | 1420 | | if (order.AppStatus.Name == "Заказан поставщику" && order.ClientStatus?.Name == "Отменён") |
| | | 1421 | | { |
| | 0 | 1422 | | order.IsReturnable = true; |
| | | 1423 | | } |
| | | 1424 | | else |
| | | 1425 | | { |
| | 0 | 1426 | | order.IsReturnable = false; |
| | | 1427 | | } |
| | | 1428 | | } |
| | | 1429 | | |
| | 4 | 1430 | | return order; |
| | 4 | 1431 | | } |
| | | 1432 | | |
| | | 1433 | | public async Task<List<Order>> CalculateCostPriceForNotFullOrders(List<Order> orders) |
| | | 1434 | | { |
| | 0 | 1435 | | List<Order> result = new List<Order>(); |
| | | 1436 | | |
| | 0 | 1437 | | foreach (var order in orders) |
| | | 1438 | | { |
| | | 1439 | | try |
| | | 1440 | | { |
| | 0 | 1441 | | var reformedOrder = await orderPriceManager.SetPurchasePriceToRUB(order); |
| | 0 | 1442 | | var calculatedOrder = await orderPriceManager.CalculateCostPrice(reformedOrder); |
| | 0 | 1443 | | result.Add(calculatedOrder ?? order); |
| | 0 | 1444 | | } |
| | 0 | 1445 | | catch |
| | | 1446 | | { |
| | 0 | 1447 | | result.Add(order); // добавляем исходный order при ошибке |
| | 0 | 1448 | | } |
| | 0 | 1449 | | } |
| | | 1450 | | |
| | 0 | 1451 | | return result; |
| | 0 | 1452 | | } |
| | | 1453 | | |
| | | 1454 | | /// <summary> |
| | | 1455 | | /// Нормализует артикул: оставляет только буквы и цифры, приводит к нижнему регистру |
| | | 1456 | | /// </summary> |
| | | 1457 | | private string NormalizeArticle(string article) |
| | | 1458 | | { |
| | 0 | 1459 | | if (string.IsNullOrEmpty(article)) |
| | 0 | 1460 | | return string.Empty; |
| | | 1461 | | |
| | | 1462 | | // Оставляем только буквы и цифры, приводим к нижнему регистру |
| | 0 | 1463 | | return new string(article |
| | 0 | 1464 | | .Where(c => char.IsLetterOrDigit(c)) |
| | 0 | 1465 | | .Select(c => char.ToLowerInvariant(c)) |
| | 0 | 1466 | | .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 | | { |
| | 0 | 1475 | | var excelQty = excelOrder.Quantity ?? 0; |
| | 0 | 1476 | | var repoEffectiveQty = repoOrder.TrueQuantity ?? repoOrder.Quantity ?? 0; |
| | 0 | 1477 | | 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 заказов для поиска в репозитории |
| | 0 | 1486 | | var normalizedProductKeys = excelOrders |
| | 0 | 1487 | | .Where(o => !string.IsNullOrEmpty(o.ProductKey)) |
| | 0 | 1488 | | .Select(o => NormalizeArticle(o.ProductKey!.Split('=')[0])) |
| | 0 | 1489 | | .Distinct() |
| | 0 | 1490 | | .Where(k => !string.IsNullOrEmpty(k)) |
| | 0 | 1491 | | .ToList(); |
| | | 1492 | | |
| | | 1493 | | // Получаем все заказы из репозитория, которые могут совпадать |
| | | 1494 | | // Используем оригинальные ключи для запроса, но затем будем сравнивать нормализованные |
| | 0 | 1495 | | var originalProductKeys = excelOrders |
| | 0 | 1496 | | .Where(o => !string.IsNullOrEmpty(o.ProductKey)) |
| | 0 | 1497 | | .Select(o => o.ProductKey!.Split('=')[0]) |
| | 0 | 1498 | | .Distinct() |
| | 0 | 1499 | | .ToList(); |
| | | 1500 | | |
| | 0 | 1501 | | var repoOrders = repository.GetByProductKeyPrefixes(originalProductKeys.ToHashSet()); |
| | | 1502 | | |
| | | 1503 | | // Группируем заказы из репозитория по нормализованному артикулу |
| | 0 | 1504 | | var repoOrdersByArticle = repoOrders |
| | 0 | 1505 | | .Where(o => !string.IsNullOrEmpty(o.ProductKey)) |
| | 0 | 1506 | | .GroupBy(o => NormalizeArticle(o.ProductKey!.Split('=')[0])) |
| | 0 | 1507 | | .Where(g => !string.IsNullOrEmpty(g.Key)) |
| | 0 | 1508 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 1509 | | |
| | | 1510 | | // Группируем Excel заказы по нормализованному артикулу |
| | 0 | 1511 | | var excelOrdersByArticle = excelOrders |
| | 0 | 1512 | | .GroupBy(o => NormalizeArticle(o.ProductKey?.Split('=')[0] ?? "")) |
| | 0 | 1513 | | .Where(g => !string.IsNullOrEmpty(g.Key)) |
| | 0 | 1514 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 1515 | | |
| | 0 | 1516 | | var usedRepoOrderIds = new HashSet<int>(); |
| | | 1517 | | |
| | 0 | 1518 | | var uniqueOrders = new List<Order>(); |
| | 0 | 1519 | | var multipleMatches = new Dictionary<Order, List<Order>>(); |
| | 0 | 1520 | | var oneMatch = new Dictionary<Order, Order>(); |
| | 0 | 1521 | | var distributionReasons = new Dictionary<Order, string>(); |
| | | 1522 | | |
| | | 1523 | | // Находим заказы без нормализованного артикула и добавляем их в uniqueOrders |
| | | 1524 | | // Это заказы, которые не попали в excelOrdersByArticle из-за пустого или null ProductKey |
| | 0 | 1525 | | var ordersInGroups = excelOrdersByArticle.Values.SelectMany(g => g).ToHashSet(); |
| | 0 | 1526 | | var ordersWithoutArticle = excelOrders |
| | 0 | 1527 | | .Where(o => !ordersInGroups.Contains(o)) |
| | 0 | 1528 | | .ToList(); |
| | | 1529 | | |
| | 0 | 1530 | | if (ordersWithoutArticle.Any()) |
| | | 1531 | | { |
| | 0 | 1532 | | uniqueOrders.AddRange(ordersWithoutArticle); |
| | 0 | 1533 | | foreach (var excelOrder in ordersWithoutArticle) |
| | | 1534 | | { |
| | 0 | 1535 | | distributionReasons[excelOrder] = "Не удалось определить артикул заказа"; |
| | | 1536 | | } |
| | | 1537 | | } |
| | | 1538 | | |
| | 0 | 1539 | | foreach (var (normalizedArticle, excelGroup) in excelOrdersByArticle) |
| | | 1540 | | { |
| | 0 | 1541 | | if (!repoOrdersByArticle.TryGetValue(normalizedArticle, out var repoGroup)) |
| | | 1542 | | { |
| | 0 | 1543 | | uniqueOrders.AddRange(excelGroup); |
| | 0 | 1544 | | foreach (var excelOrder in excelGroup) |
| | | 1545 | | { |
| | 0 | 1546 | | distributionReasons[excelOrder] = "В системе не найдено заказов с таким артикулом"; |
| | | 1547 | | } |
| | | 1548 | | |
| | | 1549 | | continue; |
| | | 1550 | | } |
| | | 1551 | | |
| | | 1552 | | |
| | 0 | 1553 | | var activeRepoOrders = repoGroup |
| | 0 | 1554 | | .Where(o => |
| | 0 | 1555 | | (o.ClientStatus?.Name != "Отменен" && o.ClientStatus?.Name != "Отменён") && |
| | 0 | 1556 | | !usedRepoOrderIds.Contains(o.Id)) |
| | 0 | 1557 | | .ToList(); |
| | | 1558 | | |
| | 0 | 1559 | | activeRepoOrders = |
| | 0 | 1560 | | dataFilterManager.FilterByFilterDataFromList(activeRepoOrders, orderFilterModel, accessProfile); |
| | | 1561 | | |
| | 0 | 1562 | | if (activeRepoOrders.Count == 0) |
| | | 1563 | | { |
| | 0 | 1564 | | uniqueOrders.AddRange(excelGroup); |
| | 0 | 1565 | | foreach (var excelOrder in excelGroup) |
| | | 1566 | | { |
| | 0 | 1567 | | var reason = "В системе найдены заказы с таким артикулом, но все они: "; |
| | 0 | 1568 | | var reasons = new List<string>(); |
| | 0 | 1569 | | if (repoGroup.Any(o => o.ClientStatus?.Name == "Отменен" || o.ClientStatus?.Name == "Отменён")) |
| | | 1570 | | { |
| | 0 | 1571 | | reasons.Add("отменены"); |
| | | 1572 | | } |
| | | 1573 | | |
| | 0 | 1574 | | if (orderFilterModel != null) |
| | | 1575 | | { |
| | 0 | 1576 | | reasons.Add("не соответствуют примененным фильтрам"); |
| | | 1577 | | } |
| | | 1578 | | |
| | 0 | 1579 | | if (reasons.Count == 0) |
| | | 1580 | | { |
| | 0 | 1581 | | reason = "В системе найдены заказы с таким артикулом, но они не прошли фильтрацию"; |
| | | 1582 | | } |
| | | 1583 | | else |
| | | 1584 | | { |
| | 0 | 1585 | | reason += string.Join(" или ", reasons); |
| | | 1586 | | } |
| | | 1587 | | |
| | 0 | 1588 | | distributionReasons[excelOrder] = reason; |
| | | 1589 | | } |
| | | 1590 | | |
| | | 1591 | | continue; |
| | | 1592 | | } |
| | | 1593 | | |
| | 0 | 1594 | | if (activeRepoOrders.Count == 1) |
| | | 1595 | | { |
| | 0 | 1596 | | var repoOrder = activeRepoOrders[0]; |
| | | 1597 | | |
| | | 1598 | | // Проверяем, есть ли разделенные заказы |
| | | 1599 | | // Разделенные заказы имеют тот же ProductKey и могут иметь ShipmentNumber с суффиксами /1, /2 и т.д |
| | | 1600 | | // Ищем все заказы с таким же ProductKey (нормализованным артикулом), которые могут быть разделенным |
| | 0 | 1601 | | var allRelatedOrders = new List<Order> { repoOrder }; |
| | | 1602 | | |
| | | 1603 | | // Ищем все заказы с таким же нормализованным артикулом из всей выборки repoOrders |
| | | 1604 | | // Это включает разделенные заказы, которые имеют тот же ProductKey |
| | 0 | 1605 | | var relatedOrders = repoOrders |
| | 0 | 1606 | | .Where(o => o.ProductKey != null && |
| | 0 | 1607 | | NormalizeArticle(o.ProductKey.Split('=')[0]) == normalizedArticle && |
| | 0 | 1608 | | (o.ClientStatus?.Name != "Отменен" && o.ClientStatus?.Name != "Отменён") && |
| | 0 | 1609 | | o.Id != repoOrder.Id && |
| | 0 | 1610 | | !usedRepoOrderIds.Contains(o.Id)) |
| | 0 | 1611 | | .ToList(); |
| | | 1612 | | |
| | | 1613 | | // Проверяем, являются ли найденные заказы разделенными (имеют общий базовый ShipmentNumber) |
| | | 1614 | | // или просто заказами с таким же артикулом |
| | 0 | 1615 | | var baseShipmentNumber = repoOrder.ShipmentNumber?.Split('/')[0]?.Trim(); |
| | 0 | 1616 | | if (!string.IsNullOrEmpty(baseShipmentNumber)) |
| | | 1617 | | { |
| | | 1618 | | // Фильтруем только те заказы, которые имеют тот же базовый ShipmentNumber (разделенные) |
| | 0 | 1619 | | var splitRelatedOrders = relatedOrders |
| | 0 | 1620 | | .Where(o => o.ShipmentNumber != null && |
| | 0 | 1621 | | o.ShipmentNumber.Split('/')[0]?.Trim() == baseShipmentNumber) |
| | 0 | 1622 | | .ToList(); |
| | | 1623 | | |
| | 0 | 1624 | | if (splitRelatedOrders.Any()) |
| | | 1625 | | { |
| | 0 | 1626 | | allRelatedOrders.AddRange(splitRelatedOrders); |
| | | 1627 | | } |
| | | 1628 | | } |
| | | 1629 | | |
| | | 1630 | | // Применяем фильтры ко всем связанным заказам |
| | 0 | 1631 | | allRelatedOrders = dataFilterManager |
| | 0 | 1632 | | .FilterByFilterDataFromList(allRelatedOrders, orderFilterModel, accessProfile).ToList(); |
| | | 1633 | | |
| | 0 | 1634 | | if (allRelatedOrders.Count > 1) |
| | | 1635 | | { |
| | | 1636 | | // Сортируем заказы по ShipmentNumber для правильного отображения (разделенные заказы рядом) |
| | 0 | 1637 | | allRelatedOrders = allRelatedOrders.OrderBy(o => o.ShipmentNumber).ToList(); |
| | | 1638 | | |
| | | 1639 | | // Если есть несколько связанных заказов (разделенные), показываем их все |
| | 0 | 1640 | | foreach (var excelOrder in excelGroup) |
| | | 1641 | | { |
| | 0 | 1642 | | multipleMatches[excelOrder] = allRelatedOrders; |
| | 0 | 1643 | | distributionReasons[excelOrder] = |
| | 0 | 1644 | | $"Найдено {allRelatedOrders.Count} совпадений в системе (включая разделенные заказы). Тр |
| | | 1645 | | } |
| | | 1646 | | |
| | | 1647 | | // Помечаем все связанные заказы как использованные |
| | 0 | 1648 | | foreach (var ro in allRelatedOrders) |
| | | 1649 | | { |
| | 0 | 1650 | | usedRepoOrderIds.Add(ro.Id); |
| | | 1651 | | } |
| | | 1652 | | } |
| | | 1653 | | else |
| | | 1654 | | { |
| | | 1655 | | // Если только один заказ, используем стандартную логику |
| | 0 | 1656 | | usedRepoOrderIds.Add(repoOrder.Id); |
| | 0 | 1657 | | foreach (var excelOrder in excelGroup) |
| | | 1658 | | { |
| | 0 | 1659 | | oneMatch[excelOrder] = repoOrder; |
| | 0 | 1660 | | distributionReasons[excelOrder] = $"Найдено одно совпадение в системе (ID: {repoOrder.Id})"; |
| | | 1661 | | } |
| | | 1662 | | } |
| | | 1663 | | } |
| | | 1664 | | else |
| | | 1665 | | { |
| | | 1666 | | // Список заказов, которые уже были назначены Excel заказам в этой группе |
| | 0 | 1667 | | var assignedInThisGroup = new HashSet<int>(); |
| | | 1668 | | |
| | 0 | 1669 | | foreach (var excelOrder in excelGroup) |
| | | 1670 | | { |
| | | 1671 | | // Получаем доступные заказы, исключая уже использованные и назначенные в этой группе |
| | 0 | 1672 | | var availableRepoOrders = activeRepoOrders |
| | 0 | 1673 | | .Where(o => !usedRepoOrderIds.Contains(o.Id) && !assignedInThisGroup.Contains(o.Id)) |
| | 0 | 1674 | | .GroupBy(o => o.Id) // Группируем по ID для удаления дубликатов |
| | 0 | 1675 | | .Select(g => g.First()) // Берем первый элемент из каждой группы |
| | 0 | 1676 | | .ToList(); |
| | | 1677 | | |
| | | 1678 | | // Приоритет заказам, совместимым по количеству с учётом кратности упаковки [N] в ProductKey |
| | 0 | 1679 | | var quantityCompatibleRepoOrders = availableRepoOrders |
| | 0 | 1680 | | .Where(r => IsQuantityCompatible(excelOrder, r)) |
| | 0 | 1681 | | .ToList(); |
| | 0 | 1682 | | var effectiveCandidates = quantityCompatibleRepoOrders.Count > 0 |
| | 0 | 1683 | | ? quantityCompatibleRepoOrders |
| | 0 | 1684 | | : availableRepoOrders; |
| | | 1685 | | |
| | 0 | 1686 | | if (availableRepoOrders.Count == 0) |
| | | 1687 | | { |
| | 0 | 1688 | | uniqueOrders.Add(excelOrder); |
| | 0 | 1689 | | distributionReasons[excelOrder] = |
| | 0 | 1690 | | "Все подходящие заказы уже были использованы для других Excel заказов с таким же артикул |
| | | 1691 | | } |
| | 0 | 1692 | | else if (effectiveCandidates.Count == 1) |
| | | 1693 | | { |
| | 0 | 1694 | | var repoOrder = effectiveCandidates[0]; |
| | | 1695 | | |
| | | 1696 | | // Проверяем, есть ли разделенные заказы для этого заказа |
| | 0 | 1697 | | var baseShipmentNumber = repoOrder.ShipmentNumber?.Split('/')[0]?.Trim(); |
| | 0 | 1698 | | var allRelatedOrders = new List<Order> { repoOrder }; |
| | | 1699 | | |
| | 0 | 1700 | | if (!string.IsNullOrEmpty(baseShipmentNumber) && |
| | 0 | 1701 | | repoOrder.ShipmentNumber?.Contains("/") == true) |
| | | 1702 | | { |
| | | 1703 | | // Ищем все заказы с таким же базовым номером (включая разделенные) |
| | 0 | 1704 | | var relatedOrders = repoOrders |
| | 0 | 1705 | | .Where(o => o.ProductKey != null && |
| | 0 | 1706 | | NormalizeArticle(o.ProductKey.Split('=')[0]) == normalizedArticle && |
| | 0 | 1707 | | o.ShipmentNumber != null && |
| | 0 | 1708 | | o.ShipmentNumber.Split('/')[0]?.Trim() == baseShipmentNumber && |
| | 0 | 1709 | | o.Status != "Отменен" && |
| | 0 | 1710 | | o.Status != "Отменён" && |
| | 0 | 1711 | | o.Id != repoOrder.Id && |
| | 0 | 1712 | | !usedRepoOrderIds.Contains(o.Id) && |
| | 0 | 1713 | | !assignedInThisGroup.Contains(o.Id)) |
| | 0 | 1714 | | .ToList(); |
| | | 1715 | | |
| | 0 | 1716 | | if (relatedOrders.Any()) |
| | | 1717 | | { |
| | 0 | 1718 | | allRelatedOrders.AddRange(relatedOrders); |
| | | 1719 | | } |
| | | 1720 | | } |
| | | 1721 | | |
| | | 1722 | | // Применяем фильтры ко всем связанным заказам |
| | 0 | 1723 | | allRelatedOrders = dataFilterManager |
| | 0 | 1724 | | .FilterByFilterDataFromList(allRelatedOrders, orderFilterModel, accessProfile).ToList(); |
| | | 1725 | | |
| | 0 | 1726 | | if (allRelatedOrders.Count > 1) |
| | | 1727 | | { |
| | | 1728 | | // Если есть несколько связанных заказов (разделенные), показываем их все |
| | | 1729 | | // Сортируем заказы по ShipmentNumber для правильного отображения (разделенные заказы ря |
| | 0 | 1730 | | allRelatedOrders = allRelatedOrders.OrderBy(o => o.ShipmentNumber).ToList(); |
| | | 1731 | | |
| | 0 | 1732 | | multipleMatches[excelOrder] = allRelatedOrders; |
| | 0 | 1733 | | distributionReasons[excelOrder] = |
| | 0 | 1734 | | $"Найдено {allRelatedOrders.Count} совпадений в системе (включая разделенные заказы) |
| | | 1735 | | |
| | | 1736 | | // Помечаем все связанные заказы как назначенные в этой группе |
| | 0 | 1737 | | foreach (var ro in allRelatedOrders) |
| | | 1738 | | { |
| | 0 | 1739 | | assignedInThisGroup.Add(ro.Id); |
| | | 1740 | | } |
| | | 1741 | | } |
| | | 1742 | | else |
| | | 1743 | | { |
| | | 1744 | | // Если только один заказ, используем стандартную логику |
| | 0 | 1745 | | oneMatch[excelOrder] = repoOrder; |
| | 0 | 1746 | | usedRepoOrderIds.Add(repoOrder.Id); |
| | 0 | 1747 | | assignedInThisGroup.Add(repoOrder.Id); |
| | 0 | 1748 | | distributionReasons[excelOrder] = |
| | 0 | 1749 | | $"Найдено одно совпадение в системе (ID: {repoOrder.Id})"; |
| | | 1750 | | } |
| | | 1751 | | } |
| | | 1752 | | else |
| | | 1753 | | { |
| | | 1754 | | // Убираем дубликаты перед добавлением в multipleMatches |
| | 0 | 1755 | | var distinctRepoOrders = effectiveCandidates |
| | 0 | 1756 | | .GroupBy(o => o.Id) |
| | 0 | 1757 | | .Select(g => g.First()) |
| | 0 | 1758 | | .OrderBy(o => |
| | 0 | 1759 | | o.ShipmentNumber) // Сортируем по ShipmentNumber для правильного отображения |
| | 0 | 1760 | | .ToList(); |
| | | 1761 | | |
| | 0 | 1762 | | multipleMatches[excelOrder] = distinctRepoOrders; |
| | 0 | 1763 | | distributionReasons[excelOrder] = |
| | 0 | 1764 | | $"Найдено {distinctRepoOrders.Count} совпадений в системе. Требуется выбор пользователя" |
| | | 1765 | | |
| | | 1766 | | // Помечаем заказы как назначенные в этой группе, чтобы они не попали в другие Excel заказы |
| | | 1767 | | // Но НЕ помечаем как использованные глобально, так как пользователь еще не выбрал |
| | 0 | 1768 | | foreach (var ro in distinctRepoOrders) |
| | | 1769 | | { |
| | 0 | 1770 | | assignedInThisGroup.Add(ro.Id); |
| | | 1771 | | } |
| | | 1772 | | } |
| | | 1773 | | } |
| | | 1774 | | } |
| | | 1775 | | } |
| | | 1776 | | |
| | | 1777 | | // Сохраняем все уникальные заказы до фильтрации |
| | 0 | 1778 | | var allUniqueOrdersBeforeFilter = uniqueOrders.ToList(); |
| | | 1779 | | |
| | | 1780 | | // Применяем фильтрацию для определения, какие заказы не прошли фильтрацию |
| | 0 | 1781 | | var filteredUniqueOrders = |
| | 0 | 1782 | | dataFilterManager.FilterByFilterDataFromList(uniqueOrders, orderFilterModel, accessProfile); |
| | | 1783 | | |
| | | 1784 | | // Определяем заказы, которые не прошли фильтрацию |
| | 0 | 1785 | | var filteredUniqueOrdersSet = new HashSet<Order>(filteredUniqueOrders); |
| | 0 | 1786 | | var unfilteredOrders = allUniqueOrdersBeforeFilter |
| | 0 | 1787 | | .Where(o => !filteredUniqueOrdersSet.Contains(o)) |
| | 0 | 1788 | | .ToList(); |
| | | 1789 | | |
| | | 1790 | | // Обновляем причины для заказов, которые не прошли фильтрацию |
| | 0 | 1791 | | foreach (var unfilteredOrder in unfilteredOrders) |
| | | 1792 | | { |
| | 0 | 1793 | | var existingReason = distributionReasons.ContainsKey(unfilteredOrder) |
| | 0 | 1794 | | ? distributionReasons[unfilteredOrder] |
| | 0 | 1795 | | : ""; |
| | | 1796 | | |
| | 0 | 1797 | | var filterReason = "Заказ не соответствует примененным фильтрам"; |
| | | 1798 | | |
| | 0 | 1799 | | if (!string.IsNullOrEmpty(existingReason)) |
| | | 1800 | | { |
| | | 1801 | | // Добавляем информацию о фильтрации к существующей причине, если её там еще нет |
| | 0 | 1802 | | if (!existingReason.Contains("не соответствует примененным фильтрам") && |
| | 0 | 1803 | | !existingReason.Contains("не прошли фильтрацию")) |
| | | 1804 | | { |
| | 0 | 1805 | | distributionReasons[unfilteredOrder] = $"{existingReason}. {filterReason}"; |
| | | 1806 | | } |
| | | 1807 | | } |
| | | 1808 | | else |
| | | 1809 | | { |
| | 0 | 1810 | | distributionReasons[unfilteredOrder] = filterReason; |
| | | 1811 | | } |
| | | 1812 | | } |
| | | 1813 | | |
| | | 1814 | | // Возвращаем ВСЕ уникальные заказы (и прошедшие, и не прошедшие фильтрацию) |
| | | 1815 | | // Это позволяет пользователю видеть все заказы из файла, даже если они не соответствуют фильтрам |
| | 0 | 1816 | | uniqueOrders = allUniqueOrdersBeforeFilter |
| | 0 | 1817 | | .OrderBy(o => NormalizeArticle(o.ProductKey?.Split('=')[0] ?? "")) |
| | 0 | 1818 | | .ToList(); |
| | | 1819 | | |
| | | 1820 | | // Проверка: убеждаемся, что все заказы из Excel обработаны |
| | | 1821 | | // Используем сравнение по ссылке, так как это те же объекты из excelOrders |
| | 0 | 1822 | | var processedOrdersSet = new HashSet<Order>(); |
| | 0 | 1823 | | foreach (var order in uniqueOrders) |
| | | 1824 | | { |
| | 0 | 1825 | | processedOrdersSet.Add(order); |
| | | 1826 | | } |
| | | 1827 | | |
| | 0 | 1828 | | foreach (var order in oneMatch.Keys) |
| | | 1829 | | { |
| | 0 | 1830 | | processedOrdersSet.Add(order); |
| | | 1831 | | } |
| | | 1832 | | |
| | 0 | 1833 | | foreach (var order in multipleMatches.Keys) |
| | | 1834 | | { |
| | 0 | 1835 | | processedOrdersSet.Add(order); |
| | | 1836 | | } |
| | | 1837 | | |
| | 0 | 1838 | | var unprocessedOrders = excelOrders.Where(o => !processedOrdersSet.Contains(o)).ToList(); |
| | 0 | 1839 | | if (unprocessedOrders.Any()) |
| | | 1840 | | { |
| | | 1841 | | // Добавляем необработанные заказы в uniqueOrders с соответствующей причиной |
| | 0 | 1842 | | uniqueOrders.AddRange(unprocessedOrders); |
| | 0 | 1843 | | foreach (var unprocessedOrder in unprocessedOrders) |
| | | 1844 | | { |
| | 0 | 1845 | | if (!distributionReasons.ContainsKey(unprocessedOrder)) |
| | | 1846 | | { |
| | 0 | 1847 | | distributionReasons[unprocessedOrder] = "Заказ не был обработан (возможно, ошибка в данных)"; |
| | | 1848 | | } |
| | | 1849 | | } |
| | | 1850 | | |
| | | 1851 | | // Пересортируем после добавления необработанных заказов |
| | 0 | 1852 | | uniqueOrders = uniqueOrders |
| | 0 | 1853 | | .OrderBy(o => NormalizeArticle(o.ProductKey?.Split('=')[0] ?? "")) |
| | 0 | 1854 | | .ToList(); |
| | | 1855 | | } |
| | | 1856 | | |
| | 0 | 1857 | | return new NotFullOrdersModel |
| | 0 | 1858 | | { |
| | 0 | 1859 | | UniqueOrders = uniqueOrders, |
| | 0 | 1860 | | OrdersWithOneMatches = oneMatch |
| | 0 | 1861 | | .OrderBy(x => NormalizeArticle(x.Key.ProductKey?.Split('=')[0] ?? "")) |
| | 0 | 1862 | | .ToDictionary(x => x.Key, x => x.Value), |
| | 0 | 1863 | | |
| | 0 | 1864 | | OrdersWithMultipleMatches = multipleMatches |
| | 0 | 1865 | | .OrderBy(x => NormalizeArticle(x.Key.ProductKey?.Split('=')[0] ?? "")) |
| | 0 | 1866 | | .ToDictionary(x => x.Key, x => x.Value), |
| | 0 | 1867 | | |
| | 0 | 1868 | | DistributionReasons = distributionReasons |
| | 0 | 1869 | | }; |
| | | 1870 | | } |
| | | 1871 | | |
| | | 1872 | | public async Task<List<Order>> ProcessingNotFullOrder(Order notFullOrder, List<int>? orderIds, string updatedBy) |
| | | 1873 | | { |
| | 0 | 1874 | | List<Order> result = new List<Order>(); |
| | 0 | 1875 | | bool isNewOrder = orderIds == null || !orderIds.Any(); |
| | 0 | 1876 | | notFullOrder = await CastToFullOrder(notFullOrder, isNewOrder); |
| | | 1877 | | |
| | 0 | 1878 | | if (isNewOrder) |
| | | 1879 | | { |
| | 0 | 1880 | | result.Add(await PrepareNewOrder(notFullOrder, updatedBy)); |
| | | 1881 | | } |
| | | 1882 | | else |
| | | 1883 | | { |
| | 0 | 1884 | | result = await ProcessExistingOrders(notFullOrder, orderIds!, updatedBy); |
| | | 1885 | | } |
| | | 1886 | | |
| | 0 | 1887 | | return result; |
| | 0 | 1888 | | } |
| | | 1889 | | |
| | | 1890 | | public async Task<List<Order>> ProcessingNotFullOrderForExcel(Order notFullOrder, List<int>? orderIds) |
| | | 1891 | | { |
| | 0 | 1892 | | bool isNewOrder = orderIds == null || !orderIds.Any(); |
| | 0 | 1893 | | notFullOrder = await CastToFullOrder(notFullOrder, isNewOrder); |
| | | 1894 | | |
| | 0 | 1895 | | return isNewOrder |
| | 0 | 1896 | | ? new List<Order> { notFullOrder } |
| | 0 | 1897 | | : await ProcessOrdersForExcel(notFullOrder, orderIds!); |
| | 0 | 1898 | | } |
| | | 1899 | | |
| | | 1900 | | private async Task<Order> PrepareNewOrder(Order order, string updatedBy) |
| | | 1901 | | { |
| | 0 | 1902 | | order = await CastToModel(order); |
| | 0 | 1903 | | order.UpdatedBy = updatedBy; |
| | 0 | 1904 | | order.LastStatusChangeDate = GetRussianCurrentTime(); |
| | 0 | 1905 | | return order; |
| | 0 | 1906 | | } |
| | | 1907 | | |
| | | 1908 | | private async Task<List<Order>> ProcessExistingOrders(Order templateOrder, List<int> orderIds, string updatedBy) |
| | | 1909 | | { |
| | 0 | 1910 | | var distinctOrderIds = orderIds.Distinct().ToList(); |
| | 0 | 1911 | | List<Order> result = new List<Order>(); |
| | | 1912 | | |
| | 0 | 1913 | | foreach (var id in distinctOrderIds) |
| | | 1914 | | { |
| | 0 | 1915 | | var order = await repository.GetAsync(id); |
| | 0 | 1916 | | if (order == null) continue; |
| | | 1917 | | |
| | 0 | 1918 | | UpdateOrderProperties(order, templateOrder, updatedBy); |
| | 0 | 1919 | | order = Coalesce(order, await orderPriceManager.CalculateProfit(order)); |
| | 0 | 1920 | | order = Coalesce(order, await orderPriceManager.CalculateDiscount(order)); |
| | 0 | 1921 | | result.Add(order); |
| | | 1922 | | } |
| | | 1923 | | |
| | 0 | 1924 | | return result; |
| | 0 | 1925 | | } |
| | | 1926 | | |
| | | 1927 | | private async Task<List<Order>> ProcessOrdersForExcel(Order templateOrder, List<int> orderIds) |
| | | 1928 | | { |
| | 0 | 1929 | | var distinctOrderIds = orderIds.Distinct().ToList(); |
| | 0 | 1930 | | List<Order> ordersToExcel = new List<Order>(); |
| | | 1931 | | |
| | 0 | 1932 | | foreach (var id in distinctOrderIds) |
| | | 1933 | | { |
| | 0 | 1934 | | var order = await repository.GetAsync(id); |
| | 0 | 1935 | | if (order == null) continue; |
| | | 1936 | | |
| | 0 | 1937 | | UpdateOrderProperties(order, templateOrder); |
| | 0 | 1938 | | order = Coalesce(order, await orderPriceManager.CalculateProfit(order)); |
| | 0 | 1939 | | order = Coalesce(order, await orderPriceManager.CalculateDiscount(order)); |
| | 0 | 1940 | | ordersToExcel.Add(order); |
| | | 1941 | | } |
| | | 1942 | | |
| | 0 | 1943 | | return ordersToExcel; |
| | 0 | 1944 | | } |
| | | 1945 | | |
| | | 1946 | | private void UpdateOrderProperties(Order targetOrder, Order sourceOrder, string? updatedBy = null) |
| | | 1947 | | { |
| | 0 | 1948 | | if (targetOrder.AppStatus != sourceOrder.AppStatus) |
| | | 1949 | | { |
| | 0 | 1950 | | targetOrder.LastStatusChangeDate = GetRussianCurrentTime(); |
| | | 1951 | | } |
| | | 1952 | | |
| | 0 | 1953 | | targetOrder.AppStatus = sourceOrder.AppStatus; |
| | 0 | 1954 | | targetOrder.PurchasePrice = sourceOrder.PurchasePrice; |
| | 0 | 1955 | | targetOrder.CostPrice = sourceOrder.CostPrice; |
| | 0 | 1956 | | targetOrder.OrderNumberToSupplier = sourceOrder.OrderNumberToSupplier; |
| | 0 | 1957 | | if (!string.IsNullOrEmpty(updatedBy)) |
| | | 1958 | | { |
| | 0 | 1959 | | targetOrder.UpdatedBy = updatedBy; |
| | | 1960 | | } |
| | 0 | 1961 | | } |
| | | 1962 | | |
| | | 1963 | | private DateTime GetRussianCurrentTime() |
| | | 1964 | | { |
| | 0 | 1965 | | return TimeZoneInfo.ConvertTime(DateTime.UtcNow, |
| | 0 | 1966 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 1967 | | } |
| | | 1968 | | |
| | | 1969 | | private async Task<Order> CastToFullOrder(Order notFullOrder, bool isNewOrder) |
| | | 1970 | | { |
| | 0 | 1971 | | notFullOrder.AppStatus = await GetAppStatusAsync(notFullOrder.AppStatus?.Id); |
| | 0 | 1972 | | notFullOrder.Manufacturer = GetManufacturerById(notFullOrder.Manufacturer?.Id); |
| | 0 | 1973 | | notFullOrder.ShipmentWarehouse = await GetWarehouseAsync(notFullOrder.ShipmentWarehouse?.Id); |
| | 0 | 1974 | | notFullOrder.Supplier = await GetSupplierAsync(notFullOrder.SupplierId ?? 0); |
| | 0 | 1975 | | notFullOrder.ProductInfo = await GetProductAsync(notFullOrder.ProductInfo?.Id); |
| | 0 | 1976 | | notFullOrder.OzonClient = GetOzonClientById(notFullOrder.OzonClient?.Id); |
| | | 1977 | | |
| | 0 | 1978 | | if (isNewOrder) |
| | | 1979 | | { |
| | 0 | 1980 | | notFullOrder = (await orderPriceManager.CalculateProfit(notFullOrder)) ?? notFullOrder; |
| | 0 | 1981 | | notFullOrder = (await orderPriceManager.CalculateDiscount(notFullOrder)) ?? notFullOrder; |
| | | 1982 | | } |
| | | 1983 | | |
| | 0 | 1984 | | return notFullOrder; |
| | 0 | 1985 | | } |
| | | 1986 | | |
| | | 1987 | | private async Task<AppStatus?> GetAppStatusAsync(int? id) |
| | | 1988 | | { |
| | 0 | 1989 | | return id != null ? await appStatusDataServices.GetAppStatusAsync(id.Value) : null; |
| | 0 | 1990 | | } |
| | | 1991 | | |
| | | 1992 | | private Manufacturer? GetManufacturerById(int? id) |
| | | 1993 | | { |
| | 0 | 1994 | | return id != null ? context.Manufacturers.FirstOrDefault(m => m.Id == id.Value) : null; |
| | | 1995 | | } |
| | | 1996 | | |
| | | 1997 | | private async Task<Warehouse?> GetWarehouseAsync(int? id) |
| | | 1998 | | { |
| | 0 | 1999 | | return id != null ? await warehouseDataServices.GetWarehouseAsync(id.Value) : null; |
| | 0 | 2000 | | } |
| | | 2001 | | |
| | | 2002 | | private async Task<Supplier?> GetSupplierAsync(int id) |
| | | 2003 | | { |
| | 0 | 2004 | | return id != 0 ? await supplierDataServices.GetSupplierAsync(id) : null; |
| | 0 | 2005 | | } |
| | | 2006 | | |
| | | 2007 | | private async Task<Product?> GetProductAsync(int? id) |
| | | 2008 | | { |
| | 0 | 2009 | | return id != null ? await productsDataServices.GetProductAsync(id.Value) : null; |
| | 0 | 2010 | | } |
| | | 2011 | | |
| | | 2012 | | private OzonClient? GetOzonClientById(int? id) |
| | | 2013 | | { |
| | 0 | 2014 | | 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 | | { |
| | 0 | 2019 | | await Task.CompletedTask; |
| | 0 | 2020 | | int numberInExcel = 2; |
| | 0 | 2021 | | foreach (var order in orders) |
| | | 2022 | | { |
| | 0 | 2023 | | order.NumberInExcel = numberInExcel++; |
| | | 2024 | | } |
| | | 2025 | | |
| | 0 | 2026 | | return orders; |
| | 0 | 2027 | | } |
| | | 2028 | | |
| | | 2029 | | |
| | | 2030 | | public async Task<List<Order>> SetUniqueShipmentNumberAndKey(List<Order> orders) |
| | | 2031 | | { |
| | 0 | 2032 | | await Task.CompletedTask; |
| | 0 | 2033 | | var shipmentNumberCounts = new Dictionary<string, int>(); |
| | | 2034 | | |
| | 0 | 2035 | | foreach (var order in orders) |
| | | 2036 | | { |
| | 0 | 2037 | | if (!string.IsNullOrEmpty(order.ShipmentNumber)) |
| | | 2038 | | { |
| | 0 | 2039 | | if (shipmentNumberCounts.ContainsKey(order.ShipmentNumber)) |
| | | 2040 | | { |
| | 0 | 2041 | | shipmentNumberCounts[order.ShipmentNumber]++; |
| | 0 | 2042 | | int number = shipmentNumberCounts[order.ShipmentNumber]; |
| | 0 | 2043 | | order.ShipmentNumber = $"{order.ShipmentNumber}/{number}"; |
| | 0 | 2044 | | order.Key = $"{order.Key}/{number}"; |
| | | 2045 | | } |
| | | 2046 | | else |
| | | 2047 | | { |
| | 0 | 2048 | | shipmentNumberCounts[order.ShipmentNumber] = 1; |
| | | 2049 | | } |
| | | 2050 | | } |
| | | 2051 | | } |
| | | 2052 | | |
| | 0 | 2053 | | return orders; |
| | 0 | 2054 | | } |
| | | 2055 | | |
| | | 2056 | | public async Task<int> CancellationOrders(int[] idArray) |
| | | 2057 | | { |
| | 0 | 2058 | | var appStatus = await context.AppStatuses.FirstOrDefaultAsync(a => a.Name == "Отменен"); |
| | | 2059 | | |
| | 0 | 2060 | | if (appStatus == null) |
| | | 2061 | | { |
| | 0 | 2062 | | AppStatus newStatus = new AppStatus() |
| | 0 | 2063 | | { |
| | 0 | 2064 | | Name = "Отменен" |
| | 0 | 2065 | | }; |
| | 0 | 2066 | | context.AppStatuses.Add(newStatus); |
| | 0 | 2067 | | await context.SaveChangesAsync(); |
| | | 2068 | | } |
| | | 2069 | | |
| | 0 | 2070 | | foreach (int orderId in idArray) |
| | | 2071 | | { |
| | 0 | 2072 | | Order? orderToCancellation = await repository.GetAsync(orderId); |
| | | 2073 | | |
| | 0 | 2074 | | if (orderToCancellation != null && |
| | 0 | 2075 | | IsStatusAllowedForCancellation(orderToCancellation.AppStatus?.Name) && |
| | 0 | 2076 | | (orderToCancellation.ClientStatus?.Name == "Отменён" || orderToCancellation.ClientStatus == null)) |
| | | 2077 | | { |
| | 0 | 2078 | | orderToCancellation.AppStatus = appStatus; |
| | 0 | 2079 | | orderToCancellation.LastStatusChangeDate = TimeZoneInfo.ConvertTime(DateTime.UtcNow, |
| | 0 | 2080 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 2081 | | } |
| | | 2082 | | } |
| | | 2083 | | |
| | 0 | 2084 | | return await SaveChanges(); |
| | 0 | 2085 | | } |
| | | 2086 | | |
| | | 2087 | | private static bool IsStatusAllowedForCancellation(string? statusName) |
| | | 2088 | | { |
| | 0 | 2089 | | return statusName == "Не указан" || statusName == "Приостановлен"; |
| | | 2090 | | } |
| | | 2091 | | |
| | | 2092 | | public List<Order> MergeOrders(List<Order> orders) |
| | | 2093 | | { |
| | 0 | 2094 | | return orders |
| | 0 | 2095 | | .GroupBy(o => new { o.Article, o.EtProducer }) |
| | 0 | 2096 | | .Select(g => |
| | 0 | 2097 | | { |
| | 0 | 2098 | | int? totalQuantity = g.Sum(o => o.Quantity); |
| | 0 | 2099 | | decimal? totalPurchasePrice = g.Sum(o => o.PurchasePrice * o.Quantity); |
| | 0 | 2100 | | var firstOrder = g.First(); |
| | 0 | 2101 | | |
| | 0 | 2102 | | if (totalQuantity is not > 0) |
| | 0 | 2103 | | { |
| | 0 | 2104 | | return (Order?)null; |
| | 0 | 2105 | | } |
| | 0 | 2106 | | |
| | 0 | 2107 | | var mergedOrder = new Order |
| | 0 | 2108 | | { |
| | 0 | 2109 | | Article = g.Key.Article, |
| | 0 | 2110 | | EtProducer = g.Key.EtProducer, |
| | 0 | 2111 | | PurchasePrice = Math.Round((decimal)(totalPurchasePrice!.Value / totalQuantity!.Value), 2), |
| | 0 | 2112 | | Quantity = totalQuantity, |
| | 0 | 2113 | | }; |
| | 0 | 2114 | | |
| | 0 | 2115 | | foreach (PropertyInfo property in _cachedOrderProperties) |
| | 0 | 2116 | | { |
| | 0 | 2117 | | if (property.Name == nameof(Order.Article) || |
| | 0 | 2118 | | property.Name == nameof(Order.EtProducer) || |
| | 0 | 2119 | | property.Name == nameof(Order.PurchasePrice) || |
| | 0 | 2120 | | property.Name == nameof(Order.Quantity)) |
| | 0 | 2121 | | { |
| | 0 | 2122 | | continue; |
| | 0 | 2123 | | } |
| | 0 | 2124 | | |
| | 0 | 2125 | | property.SetValue(mergedOrder, property.GetValue(firstOrder)); |
| | 0 | 2126 | | } |
| | 0 | 2127 | | |
| | 0 | 2128 | | return mergedOrder; |
| | 0 | 2129 | | }) |
| | 0 | 2130 | | .Where(order => order != null) |
| | 0 | 2131 | | .Select(order => order!) |
| | 0 | 2132 | | .OrderBy(o => o.Article) |
| | 0 | 2133 | | .ThenBy(o => o.EtProducer?.Name) |
| | 0 | 2134 | | .ToList(); |
| | | 2135 | | } |
| | | 2136 | | |
| | | 2137 | | public async Task<int> SaveChanges() |
| | | 2138 | | { |
| | 4 | 2139 | | return await repository.SaveChanges(); |
| | 4 | 2140 | | } |
| | | 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 | | // Получаем заказ с отслеживанием - только ОДИН запрос к БД |
| | 0 | 2147 | | var order = await repository.GetAsync(orderId); |
| | 0 | 2148 | | if (order == null || !order.Quantity.HasValue || order.Quantity <= 1) |
| | | 2149 | | { |
| | 0 | 2150 | | return order; |
| | | 2151 | | } |
| | | 2152 | | |
| | 0 | 2153 | | var expectedTotal = totalQuantityOverride ?? order.Quantity.Value; |
| | 0 | 2154 | | if (part1 + part2 != expectedTotal) |
| | | 2155 | | { |
| | 0 | 2156 | | throw new ArgumentException("Сумма частей должна равняться исходному количеству"); |
| | | 2157 | | } |
| | | 2158 | | |
| | | 2159 | | // Сохраняем оригинальный номер и расширения для нового заказа |
| | 0 | 2160 | | var originalShipmentNumber = order.ShipmentNumber; |
| | 0 | 2161 | | var extensionsToCopy = order.OrderExtensions?.ToList() ?? new List<OrderExtension>(); |
| | | 2162 | | |
| | | 2163 | | // Номера отправлений: из Ozon API или локальные с суффиксами /1 и /2 |
| | 0 | 2164 | | var posting1Number = ozonPostingNumbers?.posting1 ?? originalShipmentNumber + "/1"; |
| | 0 | 2165 | | var posting2Number = ozonPostingNumbers?.posting2 ?? originalShipmentNumber + "/2"; |
| | | 2166 | | |
| | | 2167 | | // Создаем или получаем ShipmentId для обоих отправлений (при Ozon split — всегда, иначе — если был Shipment |
| | 0 | 2168 | | int? newShipmentId = null; |
| | 0 | 2169 | | int? originalShipmentId = null; |
| | 0 | 2170 | | if (order.ShipmentId.HasValue || ozonPostingNumbers != null) |
| | | 2171 | | { |
| | 0 | 2172 | | originalShipmentId = await _shipmentRepository.GetOrCreateAsync(posting1Number); |
| | 0 | 2173 | | newShipmentId = await _shipmentRepository.GetOrCreateAsync(posting2Number); |
| | | 2174 | | } |
| | | 2175 | | |
| | | 2176 | | // Создаем новый заказ через клонирование (быстро) |
| | 0 | 2177 | | var newOrder = new Order |
| | 0 | 2178 | | { |
| | 0 | 2179 | | // Копируем все основные поля |
| | 0 | 2180 | | Key = order.Key + "/2", |
| | 0 | 2181 | | ShipmentNumber = posting2Number, |
| | 0 | 2182 | | ProductName = order.ProductName, |
| | 0 | 2183 | | Article = order.Article, |
| | 0 | 2184 | | ProductKey = order.ProductKey, |
| | 0 | 2185 | | Price = order.Price, |
| | 0 | 2186 | | Quantity = part2, |
| | 0 | 2187 | | ShipmentAmount = order.ShipmentAmount, |
| | 0 | 2188 | | ProcessingDate = order.ProcessingDate, |
| | 0 | 2189 | | ShippingDate = order.ShippingDate, |
| | 0 | 2190 | | DeliveryCity = order.DeliveryCity, |
| | 0 | 2191 | | |
| | 0 | 2192 | | // ID ссылки (без навигационных свойств) |
| | 0 | 2193 | | OzonClientId = order.OzonClientId, |
| | 0 | 2194 | | SupplierId = order.SupplierId, |
| | 0 | 2195 | | AppStatusId = order.AppStatusId, |
| | 0 | 2196 | | ClientStatusId = order.ClientStatusId, |
| | 0 | 2197 | | ShipmentWarehouseId = order.ShipmentWarehouseId, |
| | 0 | 2198 | | DeliveryId = order.DeliveryId, |
| | 0 | 2199 | | EtProducerId = order.EtProducerId, |
| | 0 | 2200 | | ProductInfoId = order.ProductInfoId, |
| | 0 | 2201 | | |
| | 0 | 2202 | | // Цены и прочие поля |
| | 0 | 2203 | | PurchasePrice = order.PurchasePrice, |
| | 0 | 2204 | | OriginalPurchasePrice = order.OriginalPurchasePrice, |
| | 0 | 2205 | | CostPrice = order.CostPrice, |
| | 0 | 2206 | | OrderNumberToSupplier = order.OrderNumberToSupplier, |
| | 0 | 2207 | | ShipmentId = newShipmentId, |
| | 0 | 2208 | | FromFile = order.FromFile, |
| | 0 | 2209 | | IsVerified = order.IsVerified, |
| | 0 | 2210 | | IsReturnable = order.IsReturnable, |
| | 0 | 2211 | | IsAccepted = order.IsAccepted, |
| | 0 | 2212 | | LastStatusChangeDate = order.LastStatusChangeDate, |
| | 0 | 2213 | | |
| | 0 | 2214 | | // Копируем поля комиссии, прибыли и наценки |
| | 0 | 2215 | | MinOzonCommission = order.MinOzonCommission, |
| | 0 | 2216 | | MaxOzonCommission = order.MaxOzonCommission, |
| | 0 | 2217 | | MaxCommissionInfo = order.MaxCommissionInfo, |
| | 0 | 2218 | | MinCommissionInfo = order.MinCommissionInfo, |
| | 0 | 2219 | | MinProfit = order.MinProfit, |
| | 0 | 2220 | | MaxProfit = order.MaxProfit, |
| | 0 | 2221 | | MinDiscount = order.MinDiscount, |
| | 0 | 2222 | | MaxDiscount = order.MaxDiscount |
| | 0 | 2223 | | }; |
| | | 2224 | | |
| | | 2225 | | // Обновляем оригинальный заказ |
| | 0 | 2226 | | order.Quantity = part1; |
| | 0 | 2227 | | order.ShipmentNumber = posting1Number; |
| | 0 | 2228 | | if (originalShipmentId.HasValue) |
| | | 2229 | | { |
| | 0 | 2230 | | order.ShipmentId = originalShipmentId; |
| | | 2231 | | } |
| | | 2232 | | |
| | | 2233 | | // Добавляем новый заказ в контекст |
| | 0 | 2234 | | context.Orders.Add(newOrder); |
| | | 2235 | | |
| | | 2236 | | // Сохраняем изменения одним вызовом |
| | 0 | 2237 | | await context.SaveChangesAsync(); |
| | 0 | 2238 | | _changeNotifier.NotifyOrdersChanged(new[] { order.Id, newOrder.Id }); |
| | | 2239 | | |
| | | 2240 | | // Копируем OrderExtensions для нового заказа, если есть |
| | 0 | 2241 | | if (extensionsToCopy.Any()) |
| | | 2242 | | { |
| | 0 | 2243 | | var newExtensions = extensionsToCopy.Select(ext => new OrderExtension |
| | 0 | 2244 | | { |
| | 0 | 2245 | | OrderId = newOrder.Id, |
| | 0 | 2246 | | Key = ext.Key, |
| | 0 | 2247 | | Value = ext.Value |
| | 0 | 2248 | | }).ToList(); |
| | | 2249 | | |
| | 0 | 2250 | | context.OrderExtensions.AddRange(newExtensions); |
| | 0 | 2251 | | await context.SaveChangesAsync(); |
| | | 2252 | | } |
| | | 2253 | | |
| | | 2254 | | // Возвращаем новый заказ |
| | 0 | 2255 | | return newOrder; |
| | 0 | 2256 | | } |
| | | 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 | | { |
| | 12 | 2265 | | var position = !string.IsNullOrWhiteSpace(order.Key) |
| | 12 | 2266 | | ? order.Key!.Trim() |
| | 12 | 2267 | | : (order.ProductKey ?? order.Article ?? string.Empty).Trim(); |
| | 12 | 2268 | | return (TrimShipmentNumber(order.ShipmentNumber).Trim(), position); |
| | | 2269 | | } |
| | | 2270 | | |
| | | 2271 | | /// <summary> |
| | | 2272 | | /// Схлопывает повторы внутри одного входящего пакета (одна и та же позиция дважды в отчёте/файле/письме). |
| | | 2273 | | /// Побеждает последняя строка — она свежее. Порядок остальных сохраняется. |
| | | 2274 | | /// </summary> |
| | | 2275 | | public static List<Order> DeduplicateIncomingOrders(IReadOnlyCollection<Order> orders) |
| | | 2276 | | { |
| | 3 | 2277 | | var lastIndex = new Dictionary<(string Shipment, string Position), int>(); |
| | 3 | 2278 | | var list = orders.ToList(); |
| | 18 | 2279 | | for (var i = 0; i < list.Count; i++) |
| | 6 | 2280 | | lastIndex[GetImportIdentity(list[i])] = i; |
| | | 2281 | | |
| | 9 | 2282 | | 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 | | { |
| | 13 | 2295 | | var sameShipment = candidates.Where(c => c.ShipmentNumber == incoming.ShipmentNumber).ToList(); |
| | 5 | 2296 | | if (sameShipment.Count <= 1) |
| | 2 | 2297 | | return sameShipment.FirstOrDefault(); |
| | | 2298 | | |
| | 8 | 2299 | | return sameShipment.FirstOrDefault(c => !string.IsNullOrEmpty(incoming.Key) && c.Key == incoming.Key) |
| | 3 | 2300 | | ?? sameShipment.FirstOrDefault(c => |
| | 2 | 2301 | | !string.IsNullOrEmpty(incoming.ProductKey) && c.ProductKey == incoming.ProductKey) |
| | 3 | 2302 | | ?? sameShipment.First(); |
| | | 2303 | | } |
| | | 2304 | | |
| | | 2305 | | public static string TrimShipmentNumber(string? shipmentNumber) |
| | | 2306 | | { |
| | 12 | 2307 | | if (string.IsNullOrEmpty(shipmentNumber)) |
| | 0 | 2308 | | return shipmentNumber ?? string.Empty; |
| | | 2309 | | |
| | 12 | 2310 | | var trimmed = shipmentNumber.Trim(); |
| | | 2311 | | |
| | | 2312 | | // Удаление всех окончаний вида /число (например, /1, /2 и т.п.) |
| | 12 | 2313 | | trimmed = Regex.Replace(trimmed, @"(/[\d]+)+$", string.Empty); |
| | | 2314 | | |
| | | 2315 | | // Удаление окончания вида -X, где X — одна цифра |
| | 12 | 2316 | | if (trimmed.Length >= 2 && trimmed[^2] == '-' && char.IsDigit(trimmed[^1])) |
| | | 2317 | | { |
| | 12 | 2318 | | trimmed = trimmed[..^2]; |
| | | 2319 | | } |
| | | 2320 | | |
| | 12 | 2321 | | return trimmed; |
| | | 2322 | | } |
| | | 2323 | | |
| | | 2324 | | public async Task<List<int>> GetOrderIdsByNumbersAndArticles(List<(string OrderNumber, string Article)> orders) |
| | | 2325 | | { |
| | 0 | 2326 | | return await repository.GetOrderIdsByNumbersAndArticles(orders); |
| | 0 | 2327 | | } |
| | | 2328 | | |
| | | 2329 | | public async Task<HashSet<(string OrderNumber, string Article)>> GetMatchedNumbersAndArticles(List<(string Order |
| | | 2330 | | { |
| | 0 | 2331 | | return await repository.GetMatchedNumbersAndArticles(orders); |
| | 0 | 2332 | | } |
| | | 2333 | | |
| | | 2334 | | /// <summary> |
| | | 2335 | | /// Уникальные Id заказов по номерам отправления и клиенту Ozon (порядок — по первому вхождению в списке). |
| | | 2336 | | /// </summary> |
| | | 2337 | | public async Task<List<int>> GetOrderIdsByShipmentNumbersForClientAsync( |
| | | 2338 | | IReadOnlyList<string> shipmentNumbers, |
| | | 2339 | | int ozonClientId) |
| | | 2340 | | { |
| | 0 | 2341 | | var (ids, _) = await GetOrderIdsAndMatchedShipmentsByShipmentListAsync(shipmentNumbers, ozonClientId); |
| | 0 | 2342 | | return ids; |
| | 0 | 2343 | | } |
| | | 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 | | { |
| | 0 | 2352 | | if (shipmentNumbers == null || shipmentNumbers.Count == 0 || ozonClientId <= 0) |
| | 0 | 2353 | | return (new List<int>(), new HashSet<string>(StringComparer.OrdinalIgnoreCase)); |
| | | 2354 | | |
| | 0 | 2355 | | var dict = await repository.GetOrderIdsByShipmentNumbersAsync(shipmentNumbers, ozonClientId); |
| | 0 | 2356 | | var orderedIds = new List<int>(); |
| | 0 | 2357 | | var seen = new HashSet<int>(); |
| | 0 | 2358 | | var matchedShipments = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 2359 | | foreach (var s in shipmentNumbers) |
| | | 2360 | | { |
| | 0 | 2361 | | if (string.IsNullOrWhiteSpace(s)) |
| | | 2362 | | continue; |
| | | 2363 | | |
| | 0 | 2364 | | var trimmed = s.Trim(); |
| | 0 | 2365 | | if (!dict.TryGetValue(trimmed, out var id)) |
| | | 2366 | | continue; |
| | | 2367 | | |
| | 0 | 2368 | | matchedShipments.Add(trimmed); |
| | 0 | 2369 | | if (seen.Add(id)) |
| | 0 | 2370 | | orderedIds.Add(id); |
| | | 2371 | | } |
| | | 2372 | | |
| | 0 | 2373 | | return (orderedIds, matchedShipments); |
| | 0 | 2374 | | } |
| | | 2375 | | |
| | | 2376 | | /// <summary> |
| | | 2377 | | /// Поиск по отправлениям без ограничения по Ozon-клиенту. |
| | | 2378 | | /// </summary> |
| | | 2379 | | public async Task<(List<int> OrderIds, HashSet<string> MatchedShipments)> GetOrderIdsAndMatchedShipmentsByShipme |
| | | 2380 | | IReadOnlyList<string> shipmentNumbers) |
| | | 2381 | | { |
| | 0 | 2382 | | if (shipmentNumbers == null || shipmentNumbers.Count == 0) |
| | 0 | 2383 | | return (new List<int>(), new HashSet<string>(StringComparer.OrdinalIgnoreCase)); |
| | | 2384 | | |
| | 0 | 2385 | | var dict = await repository.GetOrderIdsByShipmentNumbersAsync(shipmentNumbers); |
| | 0 | 2386 | | var orderedIds = new List<int>(); |
| | 0 | 2387 | | var seen = new HashSet<int>(); |
| | 0 | 2388 | | var matchedShipments = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 2389 | | foreach (var s in shipmentNumbers) |
| | | 2390 | | { |
| | 0 | 2391 | | if (string.IsNullOrWhiteSpace(s)) |
| | | 2392 | | continue; |
| | | 2393 | | |
| | 0 | 2394 | | var trimmed = s.Trim(); |
| | 0 | 2395 | | if (!dict.TryGetValue(trimmed, out var id)) |
| | | 2396 | | continue; |
| | | 2397 | | |
| | 0 | 2398 | | matchedShipments.Add(trimmed); |
| | 0 | 2399 | | if (seen.Add(id)) |
| | 0 | 2400 | | orderedIds.Add(id); |
| | | 2401 | | } |
| | | 2402 | | |
| | 0 | 2403 | | return (orderedIds, matchedShipments); |
| | 0 | 2404 | | } |
| | | 2405 | | |
| | | 2406 | | public string NormalizeOrderNumberForMatching(string? shipmentNumber) |
| | | 2407 | | { |
| | 0 | 2408 | | return NormalizeShipmentNumberForMatching(shipmentNumber); |
| | | 2409 | | } |
| | | 2410 | | |
| | | 2411 | | private static string NormalizeShipmentNumberForMatching(string? shipmentNumber) |
| | | 2412 | | { |
| | 0 | 2413 | | return ShipmentNumberMatching.Normalize(shipmentNumber); |
| | | 2414 | | } |
| | | 2415 | | |
| | | 2416 | | public async Task<string?> GetZasylAsync(int orderId) |
| | | 2417 | | { |
| | 0 | 2418 | | return await repository.GetZasylAsync(orderId); |
| | 0 | 2419 | | } |
| | | 2420 | | |
| | | 2421 | | public async Task<int> GetTotalCount() |
| | | 2422 | | { |
| | 0 | 2423 | | return await repository.GetTotalCount(); |
| | 0 | 2424 | | } |
| | | 2425 | | |
| | | 2426 | | public async Task LoadOrderExtensionsForOrders(List<Order> orders) |
| | | 2427 | | { |
| | 0 | 2428 | | await repository.LoadOrderExtensionsForOrders(orders); |
| | 0 | 2429 | | } |
| | | 2430 | | |
| | | 2431 | | public async Task LoadOrderItemsForOrders(List<Order> orders) |
| | | 2432 | | { |
| | 0 | 2433 | | await repository.LoadOrderItemsForOrders(orders); |
| | 0 | 2434 | | } |
| | | 2435 | | |
| | | 2436 | | public async Task LoadExtraExpensesForOrders(List<Order> orders) |
| | | 2437 | | { |
| | 0 | 2438 | | await repository.LoadExtraExpensesForOrders(orders); |
| | 0 | 2439 | | } |
| | | 2440 | | |
| | | 2441 | | public async Task<List<Order>> GetOrdersByIds(List<int> orderIds, bool includeArchived) |
| | | 2442 | | { |
| | 0 | 2443 | | return await repository.GetOrdersByIds(orderIds, includeArchived); |
| | 0 | 2444 | | } |
| | | 2445 | | |
| | | 2446 | | /// <summary> |
| | | 2447 | | /// Загружает заказы по ID с трэкингом (для обновления полей и сохранения через SaveChangesAsync). |
| | | 2448 | | /// </summary> |
| | | 2449 | | public async Task<List<Order>> GetOrdersByIdsTracked(List<int> orderIds, bool includeArchived) |
| | | 2450 | | { |
| | 0 | 2451 | | return await repository.GetOrdersByIdsTracked(orderIds, includeArchived); |
| | 0 | 2452 | | } |
| | | 2453 | | |
| | | 2454 | | /// <summary> |
| | | 2455 | | /// Сохраняет изменения в контексте (для уже отслеживаемых сущностей). |
| | | 2456 | | /// </summary> |
| | | 2457 | | public async Task SaveChangesAsync() |
| | | 2458 | | { |
| | 0 | 2459 | | await context.SaveChangesAsync(); |
| | 0 | 2460 | | } |
| | | 2461 | | |
| | | 2462 | | public async Task SetZasylAsync(Dictionary<int, string?> orderSupplierMap) |
| | | 2463 | | { |
| | 0 | 2464 | | await repository.SetZasylAsync(orderSupplierMap); |
| | 0 | 2465 | | } |
| | | 2466 | | |
| | | 2467 | | public async Task<bool> HasZasylValueAsync(string value) |
| | | 2468 | | { |
| | 0 | 2469 | | return await repository.HasZasylValueAsync(value); |
| | 0 | 2470 | | } |
| | | 2471 | | |
| | | 2472 | | public async Task<List<string>> GetAllZasylValuesAsync() |
| | | 2473 | | { |
| | 0 | 2474 | | return await repository.GetAllZasylValuesAsync(); |
| | 0 | 2475 | | } |
| | | 2476 | | |
| | | 2477 | | public async Task SetTransactionDocumentAsync(int orderId, string? value) |
| | | 2478 | | { |
| | 0 | 2479 | | await repository.SetTransactionDocumentAsync(orderId, value); |
| | 0 | 2480 | | } |
| | | 2481 | | |
| | | 2482 | | public async Task<string?> GetTransactionDocument(int orderId) |
| | | 2483 | | { |
| | 0 | 2484 | | return await repository.GetTransactionDocument(orderId); |
| | 0 | 2485 | | } |
| | | 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 | | { |
| | 0 | 2498 | | if (idsToUpdate.Count == 0) return 0; |
| | | 2499 | | |
| | 0 | 2500 | | var idSet = new HashSet<int>(idsToUpdate); |
| | 0 | 2501 | | var updateMap = ordersToUpdate |
| | 0 | 2502 | | .Where(o => idSet.Contains(o.Id) && o.ClientStatus?.Id > 0) |
| | | 2503 | | .ToDictionary(o => o.Id, o => o.ClientStatus!.Id); |
| | | 2504 | | |
| | 0 | 2505 | | if (updateMap.Count == 0) return 0; |
| | | 2506 | | |
| | 0 | 2507 | | var changedAt = DateTime.Now; |
| | 0 | 2508 | | var updated = 0; |
| | | 2509 | | |
| | | 2510 | | // Одним UPDATE на каждую пару (новый статус, ожидаемый прежний). |
| | 0 | 2511 | | var groups = updateMap.GroupBy(kv => ( |
| | 0 | 2512 | | NewStatusId: kv.Value, |
| | 0 | 2513 | | Expected: expectedStatusIds != null && expectedStatusIds.TryGetValue(kv.Key, out var e) ? e : (int?)null |
| | 0 | 2514 | | HasExpected: expectedStatusIds != null && expectedStatusIds.ContainsKey(kv.Key))); |
| | | 2515 | | |
| | 0 | 2516 | | foreach (var group in groups) |
| | | 2517 | | { |
| | 0 | 2518 | | var statusId = group.Key.NewStatusId; |
| | | 2519 | | var orderIds = group.Select(kv => kv.Key).ToList(); |
| | 0 | 2520 | | var query = context.Orders.Where(o => orderIds.Contains(o.Id)); |
| | | 2521 | | |
| | 0 | 2522 | | if (group.Key.HasExpected) |
| | | 2523 | | { |
| | 0 | 2524 | | var expected = group.Key.Expected; |
| | 0 | 2525 | | query = expected == null |
| | 0 | 2526 | | ? query.Where(o => o.ClientStatusId == null) |
| | 0 | 2527 | | : query.Where(o => o.ClientStatusId == expected); |
| | | 2528 | | } |
| | | 2529 | | |
| | 0 | 2530 | | updated += await query.ExecuteUpdateAsync(s => s |
| | 0 | 2531 | | .SetProperty(o => o.ClientStatusId, statusId) |
| | 0 | 2532 | | .SetProperty(o => o.LastStatusChangeDate, changedAt)); |
| | | 2533 | | } |
| | | 2534 | | |
| | 0 | 2535 | | return updated; |
| | 0 | 2536 | | } |
| | | 2537 | | |
| | | 2538 | | public async Task<Dictionary<string, int>> GetArticleCountsForKeysAsync( |
| | | 2539 | | IList<string> keys, bool includeArchived) |
| | | 2540 | | { |
| | 6 | 2541 | | if (keys.Count == 0) return new Dictionary<string, int>(); |
| | 4 | 2542 | | return await context.Orders |
| | 4 | 2543 | | .AsNoTracking() |
| | 4 | 2544 | | .Where(o => o.ProductKey != null && keys.Contains(o.ProductKey) && (includeArchived || o.OrderArchive == |
| | 4 | 2545 | | .GroupBy(o => o.ProductKey) |
| | 4 | 2546 | | .Select(g => new { Key = g.Key, Count = g.Count() }) |
| | | 2547 | | .ToDictionaryAsync(x => x.Key!, x => x.Count); |
| | 5 | 2548 | | } |
| | | 2549 | | |
| | | 2550 | | public async Task<Dictionary<string, int>> GetDeliveryCityCountsForCitiesAsync( |
| | | 2551 | | IList<string> cities, bool includeArchived) |
| | | 2552 | | { |
| | 4 | 2553 | | if (cities.Count == 0) return new Dictionary<string, int>(); |
| | 2 | 2554 | | return await context.Orders |
| | 2 | 2555 | | .AsNoTracking() |
| | 2 | 2556 | | .Where(o => o.DeliveryCity != null && cities.Contains(o.DeliveryCity) && (includeArchived || o.OrderArch |
| | 2 | 2557 | | .GroupBy(o => o.DeliveryCity) |
| | 2 | 2558 | | .Select(g => new { Key = g.Key, Count = g.Count() }) |
| | | 2559 | | .ToDictionaryAsync(x => x.Key!, x => x.Count); |
| | 3 | 2560 | | } |
| | | 2561 | | |
| | | 2562 | | public async Task<Dictionary<string, int>> GetShipmentBaseCountsForPrefixesAsync( |
| | | 2563 | | IList<string> prefixes, bool includeArchived) |
| | | 2564 | | { |
| | 1 | 2565 | | if (prefixes.Count == 0) |
| | | 2566 | | { |
| | 0 | 2567 | | return new Dictionary<string, int>(); |
| | | 2568 | | } |
| | | 2569 | | |
| | 1 | 2570 | | var distinct = prefixes |
| | | 2571 | | .Where(p => !string.IsNullOrWhiteSpace(p)) |
| | | 2572 | | .Select(p => p.Trim()) |
| | 1 | 2573 | | .Distinct(StringComparer.Ordinal) |
| | 1 | 2574 | | .ToList(); |
| | 1 | 2575 | | if (distinct.Count == 0) |
| | | 2576 | | { |
| | 0 | 2577 | | return new Dictionary<string, int>(); |
| | | 2578 | | } |
| | | 2579 | | |
| | 1 | 2580 | | var numbers = await ApplyShipmentBasePrefixFilter( |
| | 1 | 2581 | | context.Orders.AsNoTracking() |
| | 1 | 2582 | | .Where(o => o.ShipmentNumber != null && (includeArchived || o.OrderArchive == null)), |
| | 1 | 2583 | | distinct) |
| | 1 | 2584 | | .Select(o => o.ShipmentNumber!) |
| | 1 | 2585 | | .ToListAsync(); |
| | | 2586 | | |
| | 1 | 2587 | | 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); |
| | 1 | 2591 | | } |
| | | 2592 | | |
| | | 2593 | | private static IQueryable<Order> ApplyShipmentBasePrefixFilter( |
| | | 2594 | | IQueryable<Order> query, |
| | | 2595 | | IList<string> prefixes) |
| | | 2596 | | { |
| | 1 | 2597 | | var param = Expression.Parameter(typeof(Order), "o"); |
| | 1 | 2598 | | var shipmentProp = Expression.Property(param, nameof(Order.ShipmentNumber)); |
| | 1 | 2599 | | var startsWithMethod = typeof(string).GetMethod(nameof(string.StartsWith), [typeof(string)])!; |
| | | 2600 | | |
| | 1 | 2601 | | Expression? combined = null; |
| | 4 | 2602 | | foreach (var prefix in prefixes) |
| | | 2603 | | { |
| | 1 | 2604 | | var constPrefix = Expression.Constant(prefix); |
| | 1 | 2605 | | var constDashPrefix = Expression.Constant(prefix + "-"); |
| | 1 | 2606 | | var notNull = Expression.NotEqual(shipmentProp, Expression.Constant(null, typeof(string))); |
| | 1 | 2607 | | var equals = Expression.Equal(shipmentProp, constPrefix); |
| | 1 | 2608 | | var startsWith = Expression.Call(shipmentProp, startsWithMethod, constDashPrefix); |
| | 1 | 2609 | | var match = Expression.OrElse(equals, startsWith); |
| | 1 | 2610 | | var clause = Expression.AndAlso(notNull, match); |
| | 1 | 2611 | | combined = combined == null ? clause : Expression.OrElse(combined, clause); |
| | | 2612 | | } |
| | | 2613 | | |
| | 1 | 2614 | | if (combined == null) |
| | | 2615 | | { |
| | 0 | 2616 | | return query.Where(_ => false); |
| | | 2617 | | } |
| | | 2618 | | |
| | 1 | 2619 | | var lambda = Expression.Lambda<Func<Order, bool>>(combined, param); |
| | 1 | 2620 | | return query.Where(lambda); |
| | | 2621 | | } |
| | | 2622 | | } |
| | | 2623 | | } |