| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using OzonDomains.Models; |
| | | 3 | | using OzonDomains.Models.SupplierStatuses; |
| | | 4 | | using OzonRepositories.Context; |
| | | 5 | | using Services.ApiServices.NikeiApi; |
| | | 6 | | using Services.SupplierStatusServices; |
| | | 7 | | |
| | | 8 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 9 | | |
| | 205 | 10 | | public sealed record NikeiCartQueueItem(int OrderId, string ToCart, string? ProductKey, string Article, string Brand, |
| | 252 | 11 | | string? ProductName, string? WarehouseName, int Quantity, int Multiplicity, int? Stock, decimal? SourcePrice, |
| | 100 | 12 | | int? DeliveryDaysMin, int? DeliveryDaysMax, string? ShipmentNumber, string? ClientName, |
| | 68 | 13 | | int? ShippingType = null, int? ShippingAddress = null); |
| | | 14 | | |
| | | 15 | | public sealed record NikeiCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages) |
| | | 16 | | { |
| | | 17 | | public bool IsSuccess => Failed == 0; |
| | | 18 | | public string ToHtmlBlock() => $"<div class='alert alert-{(Failed == 0 ? "success" : "warning")} mb-0'><strong>Корзи |
| | | 19 | | (Messages.Count == 0 ? "</div>" : "<br/>" + string.Join("<br/>", Messages.Select(Syste |
| | | 20 | | } |
| | | 21 | | |
| | | 22 | | /// <summary>Гибридная корзина Nikei: позиция сначала добавляется в реальную корзину API, затем сохраняется её связь с з |
| | | 23 | | public sealed class NikeiCartService |
| | | 24 | | { |
| | | 25 | | public const string ProviderKey = ApiSupplierSettings.NikeiProviderKey; |
| | | 26 | | private readonly ApplicationDbContext _db; |
| | | 27 | | private readonly NikeiDataManager _nikei; |
| | | 28 | | private readonly IOrderSupplierStateService _states; |
| | | 29 | | |
| | | 30 | | public NikeiCartService(ApplicationDbContext db, NikeiDataManager nikei, IOrderSupplierStateService states) => |
| | | 31 | | (_db, _nikei, _states) = (db, nikei, states); |
| | | 32 | | |
| | | 33 | | public Task<List<NikeiCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.NikeiCartItems.AsNoTracking() |
| | | 34 | | .Include(x => x.StudioOrder).Where(x => NikeiCartItemStatus.Active.Contains(x.Status)).OrderByDescending(x => x. |
| | | 35 | | |
| | | 36 | | public Task<int> CountActiveAsync(CancellationToken ct = default) => |
| | | 37 | | _db.NikeiCartItems.CountAsync(x => NikeiCartItemStatus.Active.Contains(x.Status), ct); |
| | | 38 | | |
| | | 39 | | public Task<Newtonsoft.Json.Linq.JToken> GetApiCartAsync(CancellationToken ct = default) => _nikei.GetCartAsync(ct); |
| | | 40 | | public Task<Newtonsoft.Json.Linq.JToken> GetApiOrdersAsync(CancellationToken ct = default) => _nikei.GetOrdersAsync( |
| | | 41 | | public Task<Newtonsoft.Json.Linq.JToken> GetOfficesAsync(CancellationToken ct = default) => _nikei.GetOfficesAsync(c |
| | | 42 | | public Task<Newtonsoft.Json.Linq.JToken> GetClientAddressesAsync(CancellationToken ct = default) => _nikei.GetClient |
| | | 43 | | |
| | | 44 | | public async Task<NikeiCartOperationResult> QueueAsync(IReadOnlyCollection<NikeiCartQueueItem> queue, string user, |
| | | 45 | | CancellationToken ct = default) |
| | | 46 | | { |
| | | 47 | | var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>(); |
| | | 48 | | foreach (var item in queue) |
| | | 49 | | { |
| | | 50 | | var label = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber; |
| | | 51 | | if (string.IsNullOrWhiteSpace(item.ToCart)) { failed++; messages.Add($"{label}: у предложения Nikei отсутств |
| | | 52 | | if (item.Quantity < item.Multiplicity || item.Quantity % Math.Max(1, item.Multiplicity) != 0) { failed++; me |
| | | 53 | | if (item.Stock.HasValue && item.Quantity > item.Stock.Value) { failed++; messages.Add($"{label}: запрошено { |
| | | 54 | | if (await _db.NikeiCartItems.AnyAsync(x => x.ActiveStudioOrderId == item.OrderId, ct)) { skipped++; messages |
| | | 55 | | |
| | | 56 | | var comment = $"Studio2 {label} (ID {item.OrderId})"; |
| | | 57 | | NikeiCartAddResult api; |
| | | 58 | | try { api = await _nikei.AddToCartAsync(item.ToCart, item.Quantity, comment, ct); } |
| | | 59 | | catch (NikeiApiException ex) { failed++; messages.Add($"{label}: {ex.Message}"); continue; } |
| | | 60 | | if (!api.IsSuccess || string.IsNullOrWhiteSpace(api.ToOrder)) { failed++; messages.Add($"{label}: {api.Messa |
| | | 61 | | |
| | | 62 | | var row = new NikeiCartItem |
| | | 63 | | { |
| | | 64 | | StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, ToCart = item.ToCart, ToOrder = api.To |
| | | 65 | | ProductKey = item.ProductKey, Article = item.Article, Brand = item.Brand, ProductName = item.ProductName |
| | | 66 | | WarehouseName = item.WarehouseName, Quantity = item.Quantity, Multiplicity = Math.Max(1, item.Multiplici |
| | | 67 | | Stock = item.Stock, SourcePrice = item.SourcePrice, DeliveryDaysMin = item.DeliveryDaysMin, DeliveryDays |
| | | 68 | | Comment = comment, CreatedBy = user |
| | | 69 | | }; |
| | | 70 | | _db.NikeiCartItems.Add(row); |
| | | 71 | | try { await _db.SaveChangesAsync(ct); } |
| | | 72 | | catch (DbUpdateException) { _db.Entry(row).State = EntityState.Detached; skipped++; messages.Add($"{label}: |
| | | 73 | | added++; |
| | | 74 | | // to_order у Nikei — SHA-256 (64 символа), а legacy-поле ExternalBasketItemId в общей |
| | | 75 | | // таблице статусов ограничено 50 символами. Полный токен уже хранится в NikeiCartItems. |
| | | 76 | | await TrackAsync(() => _states.MarkInCartAsync(item.OrderId, ProviderKey, SupplierCartStatusCode.Pending, ro |
| | | 77 | | user, item.DeliveryDaysMin, item.DeliveryDaysMax, item.WarehouseName, ct)); |
| | | 78 | | } |
| | | 79 | | return new NikeiCartOperationResult(added, skipped, failed, messages); |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | /// <summary> |
| | | 83 | | /// Прямое оформление: позиции кладутся в корзину Nikei и сразу уходят одним заказом. Nikei создаёт один заказ |
| | | 84 | | /// на выбранные строки с единым способом и адресом доставки, поэтому разные адреса в одном проведении запрещены. |
| | | 85 | | /// Заказ создаётся только целиком: при ошибке добавления заказ не оформляется, а добавленные позиции остаются в кор |
| | | 86 | | /// </summary> |
| | | 87 | | public async Task<NikeiCartOperationResult> CheckoutDirectAsync(IReadOnlyCollection<NikeiCartQueueItem> queue, strin |
| | | 88 | | CancellationToken ct = default) |
| | | 89 | | { |
| | | 90 | | if (queue.Count == 0) return new NikeiCartOperationResult(0, 0, 1, ["Не выбраны позиции Nikei для оформления."]) |
| | | 91 | | if (queue.GroupBy(x => x.OrderId).Any(x => x.Count() > 1)) |
| | | 92 | | return new NikeiCartOperationResult(0, 0, 1, ["Для прямого оформления Nikei требуется ровно одно предложение |
| | | 93 | | |
| | | 94 | | var deliveries = queue.Select(x => (Type: x.ShippingType ?? 4, Address: x.ShippingAddress ?? 0)).Distinct().ToLi |
| | | 95 | | if (deliveries.Count > 1) |
| | | 96 | | return new NikeiCartOperationResult(0, 0, 1, ["Позиции Nikei относятся к разным способам или адресам доставк |
| | | 97 | | var (shippingType, shippingAddress) = deliveries[0]; |
| | | 98 | | if (shippingType is not (0 or 4) || shippingAddress <= 0) |
| | | 99 | | return new NikeiCartOperationResult(0, 0, 1, ["Выберите корректный адрес доставки Nikei."]); |
| | | 100 | | |
| | | 101 | | var queued = await QueueAsync(queue, user, ct); |
| | | 102 | | if (queued.Failed > 0) |
| | | 103 | | return new NikeiCartOperationResult(queued.Added, queued.Skipped, queued.Failed, |
| | | 104 | | [.. queued.Messages, "Заказ Nikei не создан: добавленные позиции остались в корзине Nikei."]); |
| | | 105 | | |
| | | 106 | | var orderIds = queue.Select(x => (int?)x.OrderId).ToList(); |
| | | 107 | | var ids = await _db.NikeiCartItems |
| | | 108 | | .Where(x => orderIds.Contains(x.ActiveStudioOrderId) && NikeiCartItemStatus.Active.Contains(x.Status)) |
| | | 109 | | .Select(x => x.Id).ToListAsync(ct); |
| | | 110 | | if (ids.Count != queue.Count) |
| | | 111 | | return new NikeiCartOperationResult(queued.Added, queued.Skipped, 1, |
| | | 112 | | ["Не все позиции Nikei найдены в корзине. Заказ Nikei не создан."]); |
| | | 113 | | |
| | | 114 | | try { return await CheckoutAsync(ids, shippingType, shippingAddress, "Studio2", user, ct); } |
| | | 115 | | catch (NikeiApiException ex) |
| | | 116 | | { |
| | | 117 | | return new NikeiCartOperationResult(0, 0, ids.Count, [$"Nikei: {ex.Message} Позиции остались в корзине Nikei |
| | | 118 | | } |
| | | 119 | | } |
| | | 120 | | |
| | | 121 | | public async Task<NikeiCartOperationResult> CheckoutAsync(IReadOnlyCollection<long> ids, int shippingType, int shipp |
| | | 122 | | string orderComment, string user, CancellationToken ct = default) |
| | | 123 | | { |
| | | 124 | | var rows = await SelectedAsync(ids, ct); |
| | | 125 | | if (rows.Count == 0) return new NikeiCartOperationResult(0, 0, 1, ["Выберите позиции Nikei для оформления."]); |
| | | 126 | | if (shippingType is not (0 or 4) || shippingAddress <= 0) return new NikeiCartOperationResult(0, 0, 1, ["Выберит |
| | | 127 | | var response = await _nikei.CreateOrderAsync(rows.Select(x => x.ToOrder!).ToList(), shippingType, shippingAddres |
| | | 128 | | NikeiHttpClient.ThrowIfError(response, "/account/order/create"); |
| | | 129 | | if (!string.Equals(response.Value<string>("status"), "ok", StringComparison.OrdinalIgnoreCase)) |
| | | 130 | | return new NikeiCartOperationResult(0, 0, rows.Count, [response.Value<string>("message") ?? "Nikei не создал |
| | | 131 | | var invoice = response.Value<string>("message") ?? "Nikei"; |
| | | 132 | | foreach (var row in rows) |
| | | 133 | | { |
| | | 134 | | row.Status = NikeiCartItemStatus.Ordered; row.ActiveStudioOrderId = null; row.ExternalOrderNumber = invoice; |
| | | 135 | | } |
| | | 136 | | await _db.SaveChangesAsync(ct); |
| | | 137 | | foreach (var row in rows) await TrackAsync(() => _states.MarkOrderedAsync(row.StudioOrderId, ProviderKey, invoic |
| | | 138 | | deliveryDaysMin: row.DeliveryDaysMin, deliveryDaysMax: row.DeliveryDaysMax, direction: row.WarehouseName, ct |
| | | 139 | | return new NikeiCartOperationResult(rows.Count, 0, 0, [$"Nikei создал заказ {invoice}."]); |
| | | 140 | | } |
| | | 141 | | |
| | | 142 | | public async Task<NikeiCartOperationResult> RemoveAsync(IReadOnlyCollection<long> ids, bool all, string user, Cancel |
| | | 143 | | { |
| | | 144 | | var rows = all ? await _db.NikeiCartItems.Where(x => NikeiCartItemStatus.Active.Contains(x.Status)).ToListAsync( |
| | | 145 | | if (rows.Count == 0) return new NikeiCartOperationResult(0, 0, 0, []); |
| | | 146 | | try { await _nikei.RemoveFromCartAsync(rows.Where(x => !string.IsNullOrWhiteSpace(x.ToOrder)).Select(x => x.ToOr |
| | | 147 | | catch (NikeiApiException ex) { return new NikeiCartOperationResult(0, 0, rows.Count, [ex.Message]); } |
| | | 148 | | foreach (var row in rows) { row.Status = NikeiCartItemStatus.Removed; row.ActiveStudioOrderId = null; row.Update |
| | | 149 | | await _db.SaveChangesAsync(ct); |
| | | 150 | | foreach (var row in rows) await TrackAsync(() => _states.RemoveFromCartAsync(row.StudioOrderId, ProviderKey, use |
| | | 151 | | return new NikeiCartOperationResult(rows.Count, 0, 0, []); |
| | | 152 | | } |
| | | 153 | | |
| | | 154 | | private Task<List<NikeiCartItem>> SelectedAsync(IReadOnlyCollection<long> ids, CancellationToken ct) => _db.NikeiCar |
| | | 155 | | .Where(x => ids.Contains(x.Id) && NikeiCartItemStatus.Active.Contains(x.Status)).ToListAsync(ct); |
| | | 156 | | private static async Task TrackAsync(Func<Task> action) { try { await action(); } catch { /* Статус не отменяет успе |
| | | 157 | | } |