| | | 1 | | using System.Text.Json; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using OzonDomains.Models; |
| | | 4 | | using OzonDomains.Models.SupplierStatuses; |
| | | 5 | | using OzonRepositories.Context; |
| | | 6 | | using Services.ApiServices.BergApi; |
| | | 7 | | using Services.ApiSupplierSettingsServices; |
| | | 8 | | using Services.DataServices; |
| | | 9 | | using Services.SupplierStatusServices; |
| | | 10 | | |
| | | 11 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 12 | | |
| | | 13 | | public sealed record BergCartQueueItem( |
| | | 14 | | int OrderId, |
| | | 15 | | long ResourceId, |
| | | 16 | | long WarehouseId, |
| | | 17 | | decimal Quantity, |
| | | 18 | | decimal? Price, |
| | | 19 | | string? Article, |
| | | 20 | | string? Brand, |
| | | 21 | | string? ProductName, |
| | | 22 | | string? ShipmentNumber, |
| | | 23 | | string? ClientName, |
| | | 24 | | int Multi = 1, |
| | | 25 | | int? DeliveryDaysMin = null, |
| | | 26 | | int? DeliveryDaysMax = null, |
| | | 27 | | string? Direction = null); |
| | | 28 | | |
| | 22 | 29 | | public sealed record BergCartResult(bool IsSuccess, IReadOnlyList<string> Messages); |
| | | 30 | | |
| | | 31 | | /// <summary> |
| | | 32 | | /// Корзина Berg со страницы проведения. У Berg нет API корзины: <c>ordering/place_order.json</c> |
| | | 33 | | /// размещает заказ целиком одним вызовом, поэтому отобранные позиции копятся в локальной таблице |
| | | 34 | | /// <c>BergCartItems</c>, а прямое оформление отправляет их поставщику за один запрос. |
| | | 35 | | /// </summary> |
| | | 36 | | public sealed class BergCartService |
| | | 37 | | { |
| | | 38 | | private const string ProviderKey = ApiSupplierSettings.BergProviderKey; |
| | | 39 | | private const string SupplierDisplayName = "Берг"; |
| | | 40 | | private const string LogoUrl = "/images/berg-logo-2026.png"; |
| | | 41 | | private const string CurrencyCode = "RUB"; |
| | | 42 | | private const string SupplierCartUrl = "/SupplierCart?provider=berg&supplierTab=basket"; |
| | | 43 | | |
| | | 44 | | /// <summary>Заказы, поданные после этого часа, Berg отгружает во второй половине дня.</summary> |
| | | 45 | | private const int MiddayHour = 15; |
| | | 46 | | |
| | | 47 | | private readonly ApplicationDbContext _context; |
| | | 48 | | private readonly BergDataManager _berg; |
| | | 49 | | private readonly BergDeliveryAddressMappingService _addresses; |
| | | 50 | | private readonly IOrderHistoryDataService _orderHistoryService; |
| | | 51 | | private readonly IOrderSupplierStateService _supplierStateService; |
| | | 52 | | |
| | | 53 | | public BergCartService(ApplicationDbContext context, BergDataManager berg, |
| | | 54 | | BergDeliveryAddressMappingService addresses, IOrderHistoryDataService orderHistoryService, |
| | | 55 | | IOrderSupplierStateService supplierStateService) |
| | | 56 | | { |
| | | 57 | | _context = context; |
| | | 58 | | _berg = berg; |
| | | 59 | | _addresses = addresses; |
| | | 60 | | _orderHistoryService = orderHistoryService; |
| | | 61 | | _supplierStateService = supplierStateService; |
| | | 62 | | } |
| | | 63 | | |
| | | 64 | | /// <summary>Позиции кладутся только в локальную корзину: заказ у Berg оформляется отдельно.</summary> |
| | | 65 | | public async Task<BergCartResult> PlaceQueuedItemsAsync(IReadOnlyCollection<BergCartQueueItem> items, |
| | | 66 | | string changedBy, CancellationToken ct = default) |
| | | 67 | | { |
| | | 68 | | if (items.Count == 0) return new(true, []); |
| | | 69 | | if (Invalid(items)) return new(false, ["Для корзины Berg требуется ровно одно корректное предложение на заказ."] |
| | | 70 | | |
| | | 71 | | var messages = new List<string>(); |
| | | 72 | | var added = new List<BergCartQueueItem>(); |
| | | 73 | | foreach (var item in items) |
| | | 74 | | { |
| | | 75 | | var existing = await _context.BergCartItems |
| | | 76 | | .SingleOrDefaultAsync(x => x.ActiveStudioOrderId == item.OrderId, ct); |
| | | 77 | | if (existing is not null) |
| | | 78 | | { |
| | | 79 | | messages.Add($"{Label(item)}: уже в корзине Berg, повторно не добавляем."); |
| | | 80 | | continue; |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | _context.BergCartItems.Add(BuildCartItem(item, changedBy, BergCartItemStatus.Pending)); |
| | | 84 | | added.Add(item); |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | if (added.Count == 0) |
| | | 88 | | return new(false, messages); |
| | | 89 | | |
| | | 90 | | await _context.SaveChangesAsync(ct); |
| | | 91 | | |
| | | 92 | | // Журнал и стадия — только по реально добавленным строкам: пропущенный дубль |
| | | 93 | | // не должен появляться в истории заказа. |
| | | 94 | | foreach (var item in added) |
| | | 95 | | { |
| | | 96 | | await TryTrackCartAsync(item, changedBy); |
| | | 97 | | await TryRecordHistoryAsync(item, true, null, null, changedBy, ApiSupplierBasketCartStatus.Pending); |
| | | 98 | | messages.Add($"{Label(item)}: добавлено в корзину Berg."); |
| | | 99 | | } |
| | | 100 | | |
| | | 101 | | return new(added.Count == items.Count, messages); |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | /// <summary> |
| | | 105 | | /// Прямое оформление: позиции сохраняются в локальной корзине и сразу уходят в Berg одним |
| | | 106 | | /// заказом. Berg не умеет дозаказывать в существующий заказ, поэтому дробить вызов нельзя. |
| | | 107 | | /// </summary> |
| | | 108 | | public async Task<BergCartResult> CheckoutDirectAsync(IReadOnlyCollection<BergCartQueueItem> items, |
| | | 109 | | string changedBy, CancellationToken ct = default, bool isTest = false) |
| | | 110 | | { |
| | | 111 | | if (items.Count == 0) return new(false, ["Не выбраны позиции Berg для оформления."]); |
| | | 112 | | if (Invalid(items)) return new(false, ["Для прямого оформления Berg требуется ровно одно корректное предложение |
| | | 113 | | |
| | | 114 | | // Адрес отгрузки обязателен и определяется складом заказа. Заказы с разными адресами — |
| | | 115 | | // это разные заказы Berg, а одним вызовом place_order их не разделить. |
| | | 116 | | var addresses = new Dictionary<int, BergDeliveryAddressSelection>(); |
| | | 117 | | var missing = new List<string>(); |
| | | 118 | | foreach (var item in items) |
| | | 119 | | { |
| | | 120 | | var address = await _addresses.ResolveAsync(item.OrderId, cancellationToken: ct); |
| | | 121 | | if (address is null) missing.Add(Label(item)); |
| | | 122 | | else addresses[item.OrderId] = address; |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | if (missing.Count > 0) |
| | | 126 | | return new(false, [$"Для складов этих позиций не задан адрес отгрузки Berg: {string.Join(", ", missing)}. " |
| | | 127 | | "Свяжите склад с адресом в <a href=\"/Studio2/ApiSupplierSettings/Berg\" target=\"_blank\ |
| | | 128 | | "(раздел Справочники → Настройки API-поставщиков → Berg)."]); |
| | | 129 | | |
| | | 130 | | var distinctAddresses = addresses.Values.Select(x => x.BergShipmentAddressId).Distinct().Count(); |
| | | 131 | | if (distinctAddresses > 1) |
| | | 132 | | return new(false, ["Позиции относятся к разным адресам отгрузки Berg. Проведите их отдельными документами."] |
| | | 133 | | |
| | | 134 | | var selection = addresses.Values.First(); |
| | | 135 | | var request = BuildOrderRequest(items, selection) with { IsTest = isTest }; |
| | | 136 | | |
| | | 137 | | var cartItems = new List<BergCartItem>(); |
| | | 138 | | foreach (var item in items) |
| | | 139 | | { |
| | | 140 | | var existing = await _context.BergCartItems |
| | | 141 | | .SingleOrDefaultAsync(x => x.ActiveStudioOrderId == item.OrderId, ct); |
| | | 142 | | if (existing is null) |
| | | 143 | | { |
| | | 144 | | existing = BuildCartItem(item, changedBy, BergCartItemStatus.Processing); |
| | | 145 | | _context.BergCartItems.Add(existing); |
| | | 146 | | } |
| | | 147 | | else |
| | | 148 | | { |
| | | 149 | | existing.Status = BergCartItemStatus.Processing; |
| | | 150 | | existing.UpdatedAtUtc = DateTime.UtcNow; |
| | | 151 | | } |
| | | 152 | | |
| | | 153 | | cartItems.Add(existing); |
| | | 154 | | } |
| | | 155 | | |
| | | 156 | | await _context.SaveChangesAsync(ct); |
| | | 157 | | |
| | | 158 | | BergPlaceOrderResult result; |
| | | 159 | | try |
| | | 160 | | { |
| | | 161 | | result = await _berg.PlaceOrderAsync(request, ct); |
| | | 162 | | } |
| | | 163 | | catch (BergApiException ex) |
| | | 164 | | { |
| | | 165 | | await MarkFailedAsync(cartItems, items, ex.Message, changedBy, ct); |
| | | 166 | | return new(false, [$"Berg не оформил заказ: {ex.Message}. Позиции остались в корзине Berg."]); |
| | | 167 | | } |
| | | 168 | | |
| | | 169 | | if (!result.IsSuccess) |
| | | 170 | | { |
| | | 171 | | var error = result.Errors.Count > 0 |
| | | 172 | | ? string.Join("; ", result.Errors.Select(x => $"{x.Text} ({x.Code})")) |
| | | 173 | | : "поставщик не вернул номер заказа"; |
| | | 174 | | await MarkFailedAsync(cartItems, items, error, changedBy, ct); |
| | | 175 | | return new(false, [$"Berg не оформил заказ: {error}. Позиции остались в корзине Berg."]); |
| | | 176 | | } |
| | | 177 | | |
| | | 178 | | var order = await SaveOrderAsync(result.Order!, request, items, cartItems, changedBy, ct); |
| | | 179 | | |
| | | 180 | | var messages = new List<string>(); |
| | | 181 | | foreach (var item in items) |
| | | 182 | | { |
| | | 183 | | var line = MatchLine(result.Order!, item); |
| | | 184 | | await TryTrackOrderedAsync(item, order.ExternalOrderId, line?.Sequence, changedBy); |
| | | 185 | | await TryRecordHistoryAsync(item, true, line?.Sequence.ToString(), order.ExternalOrderId.ToString(), |
| | | 186 | | changedBy, ApiSupplierBasketCartStatus.Ordered); |
| | | 187 | | } |
| | | 188 | | |
| | | 189 | | messages.Add(request.IsTest |
| | | 190 | | ? $"Berg принял тестовый заказ №{order.ExternalOrderId} (is_test — заказ не обрабатывается)." |
| | | 191 | | : $"Создан заказ Berg №{order.ExternalOrderId}."); |
| | | 192 | | foreach (var warning in result.Warnings) |
| | | 193 | | messages.Add($"Berg: {warning.Text} ({warning.Code})."); |
| | | 194 | | |
| | | 195 | | return new(true, messages); |
| | | 196 | | } |
| | | 197 | | |
| | | 198 | | internal static BergOrderRequest BuildOrderRequest(IReadOnlyCollection<BergCartQueueItem> items, |
| | | 199 | | BergDeliveryAddressSelection address, DateTime? now = null) |
| | | 200 | | { |
| | | 201 | | var moment = now ?? DateTime.Now; |
| | | 202 | | // Дату отгрузки задаёт самая долгая позиция: раньше неё заказ целиком не уедет. |
| | | 203 | | var days = items.Max(x => x.DeliveryDaysMin ?? 0); |
| | | 204 | | return new BergOrderRequest |
| | | 205 | | { |
| | | 206 | | Items = items.Select(x => new BergOrderItemRequest( |
| | | 207 | | x.ResourceId, x.WarehouseId, x.Quantity, |
| | | 208 | | MaxPrice: x.Price, |
| | | 209 | | Comment: Comment(x))).ToList(), |
| | | 210 | | DispatchType = address.DispatchType, |
| | | 211 | | DispatchAt = moment.Date.AddDays(days), |
| | | 212 | | DispatchTime = moment.Hour < MiddayHour ? BergDispatchTime.BeforeMidday : BergDispatchTime.AfterMidday, |
| | | 213 | | ShipmentAddressId = address.BergShipmentAddressId, |
| | | 214 | | Comment = $"Studio2: {string.Join(", ", items.Select(Label))}", |
| | | 215 | | // Числовой ключ идемпотентности: Berg отклонит повторную отправку той же формы. |
| | | 216 | | Reference = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() |
| | | 217 | | }; |
| | | 218 | | } |
| | | 219 | | |
| | | 220 | | private async Task<BergOrder> SaveOrderAsync(BergOrderState state, BergOrderRequest request, |
| | | 221 | | IReadOnlyCollection<BergCartQueueItem> items, IReadOnlyList<BergCartItem> cartItems, string changedBy, |
| | | 222 | | CancellationToken ct) |
| | | 223 | | { |
| | | 224 | | var order = new BergOrder |
| | | 225 | | { |
| | | 226 | | ExternalOrderId = state.Id, |
| | | 227 | | Reference = request.Reference, |
| | | 228 | | DispatchType = request.DispatchType, |
| | | 229 | | DispatchAt = request.DispatchAt, |
| | | 230 | | DispatchTime = request.DispatchTime, |
| | | 231 | | ShipmentAddressId = request.ShipmentAddressId, |
| | | 232 | | ShipmentAddress = state.ShipmentAddress, |
| | | 233 | | PaymentType = state.PaymentType, |
| | | 234 | | IsTest = state.IsTest || request.IsTest, |
| | | 235 | | Status = request.IsTest ? BergOrderStatus.Test : BergOrderStatus.Placed, |
| | | 236 | | RequestJson = BergDataManager.BuildOrderBody(request).ToString(Newtonsoft.Json.Formatting.None), |
| | | 237 | | ResponseJson = JsonSerializer.Serialize(state, HistoryJsonOptions), |
| | | 238 | | CreatedBy = changedBy, |
| | | 239 | | CreatedAtUtc = DateTime.UtcNow |
| | | 240 | | }; |
| | | 241 | | |
| | | 242 | | foreach (var item in items) |
| | | 243 | | { |
| | | 244 | | var line = MatchLine(state, item); |
| | | 245 | | var cartItem = cartItems.FirstOrDefault(x => x.StudioOrderId == item.OrderId); |
| | | 246 | | order.Items.Add(new BergOrderItem |
| | | 247 | | { |
| | | 248 | | BergCartItemId = cartItem?.Id, |
| | | 249 | | StudioOrderId = item.OrderId, |
| | | 250 | | Sequence = line?.Sequence ?? 0, |
| | | 251 | | ResourceId = item.ResourceId, |
| | | 252 | | WarehouseId = item.WarehouseId, |
| | | 253 | | Article = Trim(item.Article, 60), |
| | | 254 | | Brand = Trim(item.Brand, 100), |
| | | 255 | | Quantity = item.Quantity, |
| | | 256 | | Price = line?.Price ?? item.Price, |
| | | 257 | | StateId = line?.StateId, |
| | | 258 | | StateName = Trim(line?.StateName, 200) |
| | | 259 | | }); |
| | | 260 | | |
| | | 261 | | if (cartItem is null) continue; |
| | | 262 | | cartItem.Status = BergCartItemStatus.Ordered; |
| | | 263 | | // Строка уходит из активной корзины, но остаётся в истории. |
| | | 264 | | cartItem.ActiveStudioOrderId = null; |
| | | 265 | | cartItem.LastError = null; |
| | | 266 | | cartItem.UpdatedAtUtc = DateTime.UtcNow; |
| | | 267 | | } |
| | | 268 | | |
| | | 269 | | _context.BergOrders.Add(order); |
| | | 270 | | await _context.SaveChangesAsync(ct); |
| | | 271 | | return order; |
| | | 272 | | } |
| | | 273 | | |
| | | 274 | | private async Task MarkFailedAsync(IReadOnlyList<BergCartItem> cartItems, |
| | | 275 | | IReadOnlyCollection<BergCartQueueItem> items, string error, string changedBy, CancellationToken ct) |
| | | 276 | | { |
| | | 277 | | foreach (var cartItem in cartItems) |
| | | 278 | | { |
| | | 279 | | cartItem.Status = BergCartItemStatus.Failed; |
| | | 280 | | cartItem.LastError = Trim(error, 1000); |
| | | 281 | | cartItem.UpdatedAtUtc = DateTime.UtcNow; |
| | | 282 | | } |
| | | 283 | | |
| | | 284 | | await _context.SaveChangesAsync(ct); |
| | | 285 | | |
| | | 286 | | foreach (var item in items) |
| | | 287 | | { |
| | | 288 | | await TryTrackCartAsync(item, changedBy, SupplierCartStatusCode.Failed); |
| | | 289 | | await TryRecordHistoryAsync(item, false, null, null, changedBy, ApiSupplierBasketCartStatus.Failed, error); |
| | | 290 | | } |
| | | 291 | | } |
| | | 292 | | |
| | | 293 | | private BergCartItem BuildCartItem(BergCartQueueItem item, string changedBy, string status) => new() |
| | | 294 | | { |
| | | 295 | | StudioOrderId = item.OrderId, |
| | | 296 | | ActiveStudioOrderId = item.OrderId, |
| | | 297 | | ResourceId = item.ResourceId, |
| | | 298 | | WarehouseId = item.WarehouseId, |
| | | 299 | | Article = Trim(item.Article, 60), |
| | | 300 | | Brand = Trim(item.Brand, 100), |
| | | 301 | | ProductName = Trim(item.ProductName, 200), |
| | | 302 | | WarehouseName = Trim(item.Direction, 160), |
| | | 303 | | Quantity = item.Quantity, |
| | | 304 | | Multi = item.Multi > 0 ? item.Multi : 1, |
| | | 305 | | SourcePrice = item.Price, |
| | | 306 | | MaxPrice = item.Price, |
| | | 307 | | CurrencyCode = CurrencyCode, |
| | | 308 | | DeliveryAtUtc = item.DeliveryDaysMin.HasValue ? DateTime.UtcNow.Date.AddDays(item.DeliveryDaysMin.Value) : null, |
| | | 309 | | Status = status, |
| | | 310 | | CreatedBy = changedBy, |
| | | 311 | | CreatedAtUtc = DateTime.UtcNow, |
| | | 312 | | UpdatedAtUtc = DateTime.UtcNow |
| | | 313 | | }; |
| | | 314 | | |
| | | 315 | | /// <summary>Berg нумерует строки заказа сам, поэтому сопоставляем по паре resource + склад.</summary> |
| | | 316 | | private static BergOrderItemState? MatchLine(BergOrderState state, BergCartQueueItem item) => |
| | | 317 | | state.Items.FirstOrDefault(x => x.ResourceId == item.ResourceId && x.WarehouseId == item.WarehouseId); |
| | | 318 | | |
| | | 319 | | private static bool Invalid(IReadOnlyCollection<BergCartQueueItem> items) => |
| | | 320 | | items.Any(x => x.OrderId <= 0 || x.ResourceId <= 0 || x.WarehouseId <= 0 || x.Quantity <= 0) || |
| | | 321 | | items.GroupBy(x => x.OrderId).Any(x => x.Count() > 1); |
| | | 322 | | |
| | | 323 | | /// <summary>Подпись позиции в сообщениях: номер отгрузки Studio2 и артикул — по ним |
| | | 324 | | /// пользователь находит заказ, к которому относится строка.</summary> |
| | | 325 | | private static string Label(BergCartQueueItem item) |
| | | 326 | | { |
| | | 327 | | var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber!; |
| | | 328 | | return string.IsNullOrWhiteSpace(item.Article) ? order : $"{order} ({item.Article})"; |
| | | 329 | | } |
| | | 330 | | |
| | | 331 | | private static string Comment(BergCartQueueItem item) => |
| | | 332 | | $"Studio2 {(string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber)} (ID {i |
| | | 333 | | |
| | | 334 | | private static string Trim(string? value, int maxLength) |
| | | 335 | | { |
| | | 336 | | var trimmed = (value ?? string.Empty).Trim(); |
| | | 337 | | return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; |
| | | 338 | | } |
| | | 339 | | |
| | | 340 | | private static readonly JsonSerializerOptions HistoryJsonOptions = new() |
| | | 341 | | { |
| | | 342 | | Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All) |
| | | 343 | | }; |
| | | 344 | | |
| | | 345 | | /// <summary>Стадия корзины — вспомогательный журнал: её сбой не должен ломать уже выполненное размещение.</summary> |
| | | 346 | | private async Task TryTrackCartAsync(BergCartQueueItem item, string changedBy, |
| | | 347 | | string statusCode = SupplierCartStatusCode.Pending) |
| | | 348 | | { |
| | | 349 | | try |
| | | 350 | | { |
| | | 351 | | await _supplierStateService.MarkInCartAsync(item.OrderId, ProviderKey, statusCode, |
| | | 352 | | cartItemId: null, externalBasketItemId: null, user: changedBy, |
| | | 353 | | deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction) |
| | | 354 | | } |
| | | 355 | | catch |
| | | 356 | | { |
| | | 357 | | // См. комментарий к методу. |
| | | 358 | | } |
| | | 359 | | } |
| | | 360 | | |
| | | 361 | | private async Task TryTrackOrderedAsync(BergCartQueueItem item, long externalOrderId, int? sequence, string changedB |
| | | 362 | | { |
| | | 363 | | try |
| | | 364 | | { |
| | | 365 | | var orderId = externalOrderId.ToString(); |
| | | 366 | | await _supplierStateService.MarkOrderedAsync(item.OrderId, ProviderKey, externalOrderNumber: orderId, |
| | | 367 | | externalOrderId: orderId, externalLineId: sequence?.ToString(), user: changedBy, |
| | | 368 | | deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction) |
| | | 369 | | } |
| | | 370 | | catch |
| | | 371 | | { |
| | | 372 | | // См. TryTrackCartAsync. |
| | | 373 | | } |
| | | 374 | | } |
| | | 375 | | |
| | | 376 | | private async Task TryRecordHistoryAsync(BergCartQueueItem item, bool success, string? apiItemId, |
| | | 377 | | string? supplierOrderNumber, string changedBy, string cartStatus, string? errorMessage = null) |
| | | 378 | | { |
| | | 379 | | try |
| | | 380 | | { |
| | | 381 | | var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, SupplierDisplayName, LogoUrl, success, |
| | | 382 | | errorMessage, apiItemId, item.Article, item.ProductName, item.Price, CurrencyCode, item.Quantity, |
| | | 383 | | string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber, |
| | | 384 | | ClientName: item.ClientName, WarehouseName: item.Direction, CartStatus: cartStatus, |
| | | 385 | | SupplierOrderNumber: supplierOrderNumber, SupplierCartUrl: SupplierCartUrl); |
| | | 386 | | |
| | | 387 | | await _orderHistoryService.AddOrderHistory(new OrderHistory |
| | | 388 | | { |
| | | 389 | | OrderId = item.OrderId, |
| | | 390 | | ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | | 391 | | ColumnDisplayName = $"Корзина {SupplierDisplayName}", |
| | | 392 | | OldValue = null, |
| | | 393 | | NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions), |
| | | 394 | | ChangedAt = DateTime.Now, |
| | | 395 | | ChangedBy = changedBy |
| | | 396 | | }); |
| | | 397 | | } |
| | | 398 | | catch |
| | | 399 | | { |
| | | 400 | | // Не ломаем размещение в корзине из-за журнала истории. |
| | | 401 | | } |
| | | 402 | | } |
| | | 403 | | |
| | | 404 | | /// <summary> |
| | | 405 | | /// Оформление отобранных строк локальной корзины со страницы «Корзина поставщика». |
| | | 406 | | /// <paramref name="isTest"/> отправляет заказ с order[is_test]=1: Berg сохранит его в истории, |
| | | 407 | | /// но обрабатывать не станет — это штатная проверка перед боевым размещением. |
| | | 408 | | /// </summary> |
| | | 409 | | public async Task<BergCartResult> CheckoutAsync(IReadOnlyCollection<long> cartItemIds, string changedBy, |
| | | 410 | | bool isTest = false, CancellationToken ct = default) |
| | | 411 | | { |
| | | 412 | | var rows = await _context.BergCartItems |
| | | 413 | | .Where(x => cartItemIds.Contains(x.Id) && BergCartItemStatus.Active.Contains(x.Status)) |
| | | 414 | | .ToListAsync(ct); |
| | | 415 | | if (rows.Count == 0) return new(false, ["Не выбраны позиции корзины Berg."]); |
| | | 416 | | |
| | | 417 | | var queue = rows.Select(x => new BergCartQueueItem(x.StudioOrderId, x.ResourceId, x.WarehouseId, x.Quantity, |
| | | 418 | | x.MaxPrice ?? x.SourcePrice, x.Article, x.Brand, x.ProductName, null, null, |
| | | 419 | | x.Multi, null, null, x.WarehouseName)).ToList(); |
| | | 420 | | |
| | | 421 | | var shipmentNumbers = await _context.Orders.AsNoTracking() |
| | | 422 | | .Where(x => queue.Select(q => q.OrderId).Contains(x.Id)) |
| | | 423 | | .ToDictionaryAsync(x => x.Id, x => x.ShipmentNumber, ct); |
| | | 424 | | queue = queue.Select(x => shipmentNumbers.TryGetValue(x.OrderId, out var number) && !string.IsNullOrWhiteSpace(n |
| | | 425 | | ? x with { ShipmentNumber = number } |
| | | 426 | | : x).ToList(); |
| | | 427 | | |
| | | 428 | | return await CheckoutDirectAsync(queue, changedBy, ct, isTest); |
| | | 429 | | } |
| | | 430 | | |
| | | 431 | | public Task<int> CountActiveAsync(CancellationToken ct = default) => |
| | | 432 | | _context.BergCartItems.CountAsync(x => BergCartItemStatus.Active.Contains(x.Status), ct); |
| | | 433 | | |
| | | 434 | | public Task<List<BergCartItem>> GetActiveAsync(CancellationToken ct = default) => |
| | | 435 | | _context.BergCartItems.AsNoTracking() |
| | | 436 | | .Where(x => BergCartItemStatus.Active.Contains(x.Status)) |
| | | 437 | | .OrderByDescending(x => x.CreatedAtUtc) |
| | | 438 | | .ToListAsync(ct); |
| | | 439 | | |
| | | 440 | | /// <summary>Снимает строки с активной корзины, сохраняя их в истории.</summary> |
| | | 441 | | public async Task<int> RemoveAsync(IReadOnlyCollection<long> cartItemIds, CancellationToken ct = default) |
| | | 442 | | { |
| | | 443 | | if (cartItemIds.Count == 0) return 0; |
| | | 444 | | |
| | | 445 | | var rows = await _context.BergCartItems.Where(x => cartItemIds.Contains(x.Id)).ToListAsync(ct); |
| | | 446 | | foreach (var row in rows) |
| | | 447 | | { |
| | | 448 | | row.Status = BergCartItemStatus.Removed; |
| | | 449 | | row.ActiveStudioOrderId = null; |
| | | 450 | | row.UpdatedAtUtc = DateTime.UtcNow; |
| | | 451 | | } |
| | | 452 | | |
| | | 453 | | await _context.SaveChangesAsync(ct); |
| | | 454 | | |
| | | 455 | | foreach (var row in rows) |
| | | 456 | | { |
| | | 457 | | try { await _supplierStateService.RemoveFromCartAsync(row.StudioOrderId, ProviderKey); } |
| | | 458 | | catch { /* журнал стадии не должен ломать удаление */ } |
| | | 459 | | } |
| | | 460 | | |
| | | 461 | | return rows.Count; |
| | | 462 | | } |
| | | 463 | | } |