| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | using Microsoft.Extensions.Logging.Abstractions; |
| | | 7 | | using Newtonsoft.Json; |
| | | 8 | | using Newtonsoft.Json.Linq; |
| | | 9 | | using OzonDomains.Models; |
| | | 10 | | using OzonOrdersWeb.TransactionServices; |
| | | 11 | | using OzonRepositories.Context; |
| | | 12 | | using Services.ApiServices.MlAutoApi; |
| | | 13 | | using Services.DataServices; |
| | | 14 | | using Services.ApiSupplierSettingsServices; |
| | | 15 | | using Services.SupplierStatusServices; |
| | | 16 | | |
| | | 17 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 18 | | |
| | | 19 | | public sealed record MlAutoRuCartQueueItem(int OrderId, string Pin, string Brand, string? StorageCode, string? Warehouse |
| | 151 | 20 | | public sealed record MlAutoRuCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages) |
| | | 21 | | { |
| | | 22 | | public string ToHtmlBlock() |
| | | 23 | | { |
| | 27 | 24 | | var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>"); |
| | 3 | 25 | | var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty; |
| | 3 | 26 | | var verificationNotice = Added > 0 |
| | 3 | 27 | | ? "<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'></ |
| | 3 | 28 | | : string.Empty; |
| | | 29 | | |
| | 3 | 30 | | return "<div class='alert alert-info border-primary-subtle mb-0'>" + |
| | 3 | 31 | | "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина ML-Auto RU</ |
| | 3 | 32 | | $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b |
| | 3 | 33 | | $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" + |
| | 3 | 34 | | "<div class='d-flex flex-wrap gap-2 mt-2'><a class='btn btn-outline-primary btn-sm' href='/SupplierCart?p |
| | 3 | 35 | | verificationNotice + "</div>"; |
| | | 36 | | } |
| | | 37 | | } |
| | | 38 | | |
| | | 39 | | public sealed record MlAutoRuCartRequoteOffer(string StorageCode, string WarehouseName, decimal Price, decimal? Availabl |
| | | 40 | | public sealed record MlAutoRuCartRequoteResult(string OriginalWarehouse, string DeliveryAddress, IReadOnlyList<MlAutoRuC |
| | | 41 | | public sealed record MlAutoRuDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages); |
| | | 42 | | |
| | | 43 | | public sealed class MlAutoRuCartService |
| | | 44 | | { |
| | | 45 | | private const string Logo = "https://ml-auto.ru/media/files/settings/inside-placeholder-logo-mobile.png"; |
| | | 46 | | /// <summary>Ключ поставщика в <c>orders_history</c> (ApiSupplierBasketPlacement); под тем же ColumnName пишут и дру |
| | | 47 | | private const string HistoryProviderKey = "MlAutoRu"; |
| | | 48 | | private readonly ApplicationDbContext _db; private readonly MlAutoRuDataManager _api; private readonly MlAutoRuDeliv |
| | | 49 | | private readonly IOrderSupplierStateService _supplierState; |
| | | 50 | | private readonly ILogger<MlAutoRuCartService> _logger; |
| | | 51 | | public MlAutoRuCartService(ApplicationDbContext db, MlAutoRuDataManager api, MlAutoRuDeliveryAddressMappingService m |
| | | 52 | | IOrderHistoryDataService history, IOrderSupplierStateService supplierState, ILogger<MlAutoRuCartService>? logger |
| | | 53 | | { |
| | | 54 | | _db = db; _api = api; _mappings = mappings; _history = history; _supplierState = supplierState; |
| | | 55 | | _logger = logger ?? NullLogger<MlAutoRuCartService>.Instance; |
| | | 56 | | } |
| | | 57 | | |
| | | 58 | | /// <summary> |
| | | 59 | | /// Отражает строку корзины в отслеживании статусов поставщика. Переходы внутри корзины |
| | | 60 | | /// читает LocalCartStatusReader напрямую из строки, поэтому здесь только появление, |
| | | 61 | | /// оформление и уход из корзины. Сбой журнала не ломает работу с корзиной. |
| | | 62 | | /// </summary> |
| | | 63 | | private async Task TrackAsync(Func<Task> track) |
| | | 64 | | { |
| | | 65 | | try { await track(); } |
| | | 66 | | catch (Exception exception) { _logger.LogWarning(exception, "Не удалось обновить состояние заказа у поставщика M |
| | | 67 | | } |
| | | 68 | | |
| | | 69 | | public Task<List<MlAutoRuCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.MlAutoRuCartItems.Include( |
| | | 70 | | public Task<List<MlAutoRuOrder>> GetOrdersAsync(CancellationToken ct = default) => _db.MlAutoRuOrders.AsNoTracking() |
| | | 71 | | public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.MlAutoRuCartItems.CountAsync(x => MlAutoRuC |
| | | 72 | | |
| | | 73 | | public async Task<MlAutoRuCartResult> QueueAsync(IReadOnlyCollection<MlAutoRuCartQueueItem> queue, string user, Canc |
| | | 74 | | { |
| | | 75 | | var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>(); |
| | | 76 | | foreach (var item in queue) |
| | | 77 | | { |
| | | 78 | | if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.Pin) || string.IsNullOrWhiteSpace(item.Brand) || ite |
| | | 79 | | if (item.MinimumQuantity > 0 && item.Quantity < item.MinimumQuantity) { failed++; messages.Add($"{item.Pin}: |
| | | 80 | | if (await _db.MlAutoRuCartItems.AnyAsync(x => x.StudioOrderId == item.OrderId && MlAutoRuCartItemStatus.Acti |
| | | 81 | | var destination = await _mappings.ResolveAsync(item.OrderId, item.ZasylWarehouseName, ct); |
| | | 82 | | var cart = new MlAutoRuCartItem { StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, Pin = it |
| | | 83 | | _db.MlAutoRuCartItems.Add(cart); await _db.SaveChangesAsync(ct); added++; messages.Add(item.Pin + ": добавле |
| | | 84 | | await TrackAsync(() => _supplierState.MarkInCartAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoRuProvid |
| | | 85 | | deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction, |
| | | 86 | | } |
| | | 87 | | return new MlAutoRuCartResult(added, skipped, failed, messages); |
| | | 88 | | } |
| | | 89 | | |
| | | 90 | | /// <summary>Проверяет и оформляет предложения без промежуточного сохранения в локальную корзину.</summary> |
| | | 91 | | public async Task<MlAutoRuDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<MlAutoRuCartQueueItem> queue |
| | | 92 | | { |
| | | 93 | | var messages = new List<string>(); |
| | | 94 | | if (queue.Count == 0) return new(false, ["Не выбраны позиции ML-Auto RU для оформления."]); |
| | | 95 | | if (queue.GroupBy(x => x.OrderId).Any(x => x.Key <= 0 || x.Count() > 1)) |
| | | 96 | | return new(false, ["Для прямого оформления ML-Auto RU требуется ровно одно предложение на заказ."]); |
| | | 97 | | |
| | | 98 | | // Идемпотентность: заказ Studio2, уже оформленный у ML-Auto RU, повторно не отправляем |
| | | 99 | | // (повторная отправка транзакции после сбоя иначе создаёт у поставщика дубль реального заказа). |
| | | 100 | | var alreadyPlaced = await FindPlacedOrderNumbersAsync(queue.Select(x => x.OrderId).ToArray(), ct); |
| | | 101 | | var skippedMessages = queue.Where(x => alreadyPlaced.ContainsKey(x.OrderId)) |
| | | 102 | | .Select(x => $"{x.Pin}: уже оформлено, заказ ML-Auto RU №{alreadyPlaced[x.OrderId]}; повторно не отправлялся |
| | | 103 | | var pending = queue.Where(x => !alreadyPlaced.ContainsKey(x.OrderId)).ToList(); |
| | | 104 | | if (pending.Count == 0) return new(true, skippedMessages); |
| | | 105 | | |
| | | 106 | | var prepared = new List<MlAutoRuCartItem>(); |
| | | 107 | | foreach (var source in pending) |
| | | 108 | | { |
| | | 109 | | if (string.IsNullOrWhiteSpace(source.Pin) || string.IsNullOrWhiteSpace(source.Brand) || source.Quantity <= 0 |
| | | 110 | | (source.MinimumQuantity > 0 && source.Quantity < source.MinimumQuantity)) |
| | | 111 | | { |
| | | 112 | | messages.Add($"{source.Pin}: некорректное количество или предложение ML-Auto RU."); |
| | | 113 | | continue; |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | var destination = await _mappings.ResolveAsync(source.OrderId, source.ZasylWarehouseName, ct); |
| | | 117 | | if (destination is null) |
| | | 118 | | { |
| | | 119 | | messages.Add($"{source.Pin}: для склада заказа не настроен адрес доставки ML-Auto RU."); |
| | | 120 | | continue; |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | var item = new MlAutoRuCartItem |
| | | 124 | | { |
| | | 125 | | StudioOrderId = source.OrderId, Pin = source.Pin.Trim(), Brand = source.Brand.Trim(), |
| | | 126 | | StorageCode = source.StorageCode, WarehouseName = source.WarehouseName, ProductName = source.ProductName |
| | | 127 | | DeliveryAddressId = destination.DeliveryAddressId, DeliveryAddressName = destination.DeliveryAddressId, |
| | | 128 | | Quantity = source.Quantity, SourcePrice = source.SourcePrice, CurrencyCode = source.CurrencyCode ?? "RUB |
| | | 129 | | DeliveryAtUtc = ParseDate(source.DeliveryAtUtc), CreatedBy = user |
| | | 130 | | }; |
| | | 131 | | try |
| | | 132 | | { |
| | | 133 | | await RepriceAsync(item, ct); |
| | | 134 | | var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c |
| | | 135 | | item.TestResultJson = test.Response.ToString(Formatting.None); |
| | | 136 | | var confirmed = EnsureTestAccepted(test.Response, test.Message); |
| | | 137 | | item.SourcePrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE")) ?? item.SourcePrice; |
| | | 138 | | prepared.Add(item); |
| | | 139 | | } |
| | | 140 | | catch (MlAutoApiException ex) { messages.Add($"{source.Pin}: {ex.Message}"); } |
| | | 141 | | } |
| | | 142 | | |
| | | 143 | | if (messages.Count > 0) return new(false, messages); |
| | | 144 | | // Прямое оформление минует локальную корзину: срок/направление берём обратно из queue |
| | | 145 | | // по OrderId (проверка выше гарантирует не более одного предложения на заказ). |
| | | 146 | | var sourceByOrderId = pending.ToDictionary(x => x.OrderId); |
| | | 147 | | var created = new List<string>(); |
| | | 148 | | foreach (var group in prepared.GroupBy(x => x.DeliveryAddressId!)) |
| | | 149 | | { |
| | | 150 | | Dictionary<string, string?> request; |
| | | 151 | | string number; |
| | | 152 | | JToken response; |
| | | 153 | | try |
| | | 154 | | { |
| | | 155 | | request = BuildRequest(group.Key, group.ToList(), true); |
| | | 156 | | response = await _api.CreateOrderAsync(request, ct); |
| | | 157 | | number = Find(response, "ORDER-NUMBER") ?? string.Empty; |
| | | 158 | | if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto RU не создал заказ: в ответ |
| | | 159 | | } |
| | | 160 | | catch (MlAutoApiException ex) { return new(false, [.. skippedMessages, .. created, ex.Message]); } |
| | | 161 | | |
| | | 162 | | // С этого места заказ у поставщика уже существует: любой сбой дальше не должен терять его номер, |
| | | 163 | | // иначе повторная отправка создаст дубль. Поэтому сохраняем сразу и без токена запроса. |
| | | 164 | | var order = new MlAutoRuOrder |
| | | 165 | | { |
| | | 166 | | OrderNumber = number, DeliveryAddressId = group.Key, DeliveryAddressName = group.First().DeliveryAddress |
| | | 167 | | IsCashless = true, CreatedBy = user, Status = "Created", |
| | | 168 | | RequestJson = System.Text.Json.JsonSerializer.Serialize(request), ResponseJson = response.ToString(Forma |
| | | 169 | | }; |
| | | 170 | | foreach (var item in group) |
| | | 171 | | order.Items.Add(new MlAutoRuOrderItem { StudioOrderId = item.StudioOrderId, Pin = item.Pin, Brand = item |
| | | 172 | | try |
| | | 173 | | { |
| | | 174 | | _db.MlAutoRuOrders.Add(order); |
| | | 175 | | await _db.SaveChangesAsync(CancellationToken.None); |
| | | 176 | | } |
| | | 177 | | catch (Exception exception) |
| | | 178 | | { |
| | | 179 | | _logger.LogError(exception, "Заказ ML-Auto RU №{OrderNumber} создан, но не сохранён в БД; защита от повт |
| | | 180 | | _db.ChangeTracker.Clear(); |
| | | 181 | | } |
| | | 182 | | |
| | | 183 | | foreach (var item in group) |
| | | 184 | | { |
| | | 185 | | await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ordered, number, CancellationToke |
| | | 186 | | // Прямое оформление обходит локальную корзину: показываем статус сразу. |
| | | 187 | | sourceByOrderId.TryGetValue(item.StudioOrderId, out var source); |
| | | 188 | | await TrackAsync(() => _supplierState.MarkOrderedAsync( |
| | | 189 | | item.StudioOrderId, ApiSupplierSettings.MlAutoRuProviderKey, number, user: user, |
| | | 190 | | deliveryDaysMin: source?.DeliveryDaysMin, deliveryDaysMax: source?.DeliveryDaysMax, direction: sourc |
| | | 191 | | } |
| | | 192 | | |
| | | 193 | | await RefreshOrderDetailsAsync(order, number); |
| | | 194 | | created.Add($"Создан заказ ML-Auto RU №{number}."); |
| | | 195 | | } |
| | | 196 | | return new(true, [.. skippedMessages, .. created]); |
| | | 197 | | } |
| | | 198 | | |
| | | 199 | | /// <summary>Подтягивает статус и ответ заказа поставщика. Заказ уже создан, поэтому сбой здесь только логируется.</ |
| | | 200 | | private async Task RefreshOrderDetailsAsync(MlAutoRuOrder order, string number) |
| | | 201 | | { |
| | | 202 | | try |
| | | 203 | | { |
| | | 204 | | // Id нет, если сохранение заказа не удалось: обновлять в БД нечего. |
| | | 205 | | if (order.Id <= 0) return; |
| | | 206 | | var details = await _api.GetOrderByNumberAsync(number, CancellationToken.None); |
| | | 207 | | order.Status = Find(details, "STATUS") ?? order.Status; |
| | | 208 | | order.ResponseJson = details.ToString(Formatting.None); |
| | | 209 | | await _db.SaveChangesAsync(CancellationToken.None); |
| | | 210 | | } |
| | | 211 | | catch (Exception exception) |
| | | 212 | | { |
| | | 213 | | _logger.LogWarning(exception, "Не удалось получить детали заказа ML-Auto RU №{OrderNumber}; заказ уже создан |
| | | 214 | | } |
| | | 215 | | } |
| | | 216 | | |
| | | 217 | | /// <summary> |
| | | 218 | | /// Номера заказов ML-Auto RU, в которые заказ Studio2 уже оформлен: по сохранённым позициям <c>MlAutoRuOrders</c>, |
| | | 219 | | /// а если их нет — по успешной записи <c>ApiSupplierBasketPlacement</c> (ProviderKey = MlAutoRu, статус Ordered) в |
| | | 220 | | /// Записи корзины (Pending/Ready) и других поставщиков не считаются оформлением. |
| | | 221 | | /// </summary> |
| | | 222 | | private async Task<Dictionary<int, string>> FindPlacedOrderNumbersAsync(IReadOnlyCollection<int> studioOrderIds, Can |
| | | 223 | | { |
| | | 224 | | var result = new Dictionary<int, string>(); |
| | | 225 | | var ids = studioOrderIds.Where(x => x > 0).Distinct().ToArray(); |
| | | 226 | | if (ids.Length == 0) return result; |
| | | 227 | | |
| | | 228 | | var saved = await _db.MlAutoRuOrders.AsNoTracking() |
| | | 229 | | .SelectMany(order => order.Items.Where(item => ids.Contains(item.StudioOrderId) && item.Error == null), |
| | | 230 | | (order, item) => new { item.StudioOrderId, order.OrderNumber, order.CreatedAtUtc }) |
| | | 231 | | .OrderBy(x => x.CreatedAtUtc).ToListAsync(ct); |
| | | 232 | | foreach (var row in saved) result.TryAdd(row.StudioOrderId, row.OrderNumber); |
| | | 233 | | |
| | | 234 | | foreach (var id in ids.Where(x => !result.ContainsKey(x))) |
| | | 235 | | { |
| | | 236 | | var number = (await _history.GetByOrderIdAsync(id)) |
| | | 237 | | .Where(x => string.Equals(x.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName, StringCompariso |
| | | 238 | | .Select(x => ApiSupplierBasketHistoryEntry.TryParse(x.NewValue)) |
| | | 239 | | .FirstOrDefault(x => x is { Success: true, CartStatus: ApiSupplierBasketCartStatus.Ordered } && |
| | | 240 | | string.Equals(x.ProviderKey, HistoryProviderKey, StringComparison.Ordinal) && |
| | | 241 | | !string.IsNullOrWhiteSpace(x.SupplierOrderNumber))?.SupplierOrderNumber; |
| | | 242 | | if (number is not null) result[id] = number; |
| | | 243 | | } |
| | | 244 | | return result; |
| | | 245 | | } |
| | | 246 | | |
| | | 247 | | public async Task<MlAutoRuCartResult> TestAsync(IReadOnlyCollection<long>? ids, string user, CancellationToken ct = |
| | | 248 | | { |
| | | 249 | | var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto RU пуста." |
| | | 250 | | var result = new List<string>(); var ok = 0; var failed = 0; |
| | | 251 | | foreach (var item in items) |
| | | 252 | | { |
| | | 253 | | if (string.IsNullOrWhiteSpace(item.DeliveryAddressId)) { item.Status = MlAutoRuCartItemStatus.Failed; item.L |
| | | 254 | | try |
| | | 255 | | { |
| | | 256 | | var reprice = await RepriceAsync(item, ct); |
| | | 257 | | var test = await _api.CreateOrderTestDetailedAsync(BuildRequest(item.DeliveryAddressId, [item], true), c |
| | | 258 | | item.TestResultJson = test.Response.ToString(Formatting.None); |
| | | 259 | | var confirmed = EnsureTestAccepted(test.Response, test.Message); |
| | | 260 | | var confirmedPrice = MlAutoValueParser.ParseDecimal(confirmed.Value<string>("PRICE")); |
| | | 261 | | if (confirmedPrice.HasValue) item.SourcePrice = confirmedPrice; |
| | | 262 | | var providerResult = confirmed.Value<string>("MSG"); |
| | | 263 | | item.TestedAtUtc = DateTime.UtcNow; item.Status = MlAutoRuCartItemStatus.Ready; item.LastError = null; o |
| | | 264 | | result.Add($"{item.Pin}: принято ML-Auto RU; склад {item.StorageCode ?? "—"}, цена поставщика {item.Sour |
| | | 265 | | await HistoryAsync(item, user, true, null, ApiSupplierBasketCartStatus.Ready, null, ct); |
| | | 266 | | } |
| | | 267 | | catch (MlAutoApiException ex) { item.Status = MlAutoRuCartItemStatus.Failed; item.LastError = ex.Message; fa |
| | | 268 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 269 | | } |
| | | 270 | | await _db.SaveChangesAsync(ct); return new(ok, 0, failed, result); |
| | | 271 | | } |
| | | 272 | | |
| | | 273 | | public async Task<MlAutoRuCartResult> CheckoutAsync(IReadOnlyCollection<long>? ids, bool cashless, string user, Canc |
| | | 274 | | { |
| | | 275 | | var items = await SelectedAsync(ids, ct); if (items.Count == 0) return new(0, 0, 0, ["Корзина ML-Auto RU пуста." |
| | | 276 | | if (items.Any(x => x.Status != MlAutoRuCartItemStatus.Ready || !x.TestedAtUtc.HasValue || x.TestedAtUtc.Value < |
| | | 277 | | var messages = new List<string>(); var ok = 0; var failed = 0; |
| | | 278 | | foreach (var group in items.GroupBy(x => x.DeliveryAddressId)) |
| | | 279 | | { |
| | | 280 | | if (string.IsNullOrWhiteSpace(group.Key)) { failed += group.Count(); messages.Add("Не выбран адрес доставки |
| | | 281 | | foreach (var item in group) item.Status = MlAutoRuCartItemStatus.Processing; |
| | | 282 | | await _db.SaveChangesAsync(ct); |
| | | 283 | | try |
| | | 284 | | { |
| | | 285 | | var request = BuildRequest(group.Key, group.ToList(), cashless); var response = await _api.CreateOrderAs |
| | | 286 | | if (string.IsNullOrWhiteSpace(number)) throw new MlAutoApiException("ML-Auto RU не создал заказ: в ответ |
| | | 287 | | var details = await _api.GetOrderByNumberAsync(number, ct); |
| | | 288 | | var order = new MlAutoRuOrder { OrderNumber = number, DeliveryAddressId = group.Key!, DeliveryAddressNam |
| | | 289 | | foreach (var item in group) { item.Status = MlAutoRuCartItemStatus.Ordered; item.ActiveStudioOrderId = n |
| | | 290 | | _db.MlAutoRuOrders.Add(order); await _db.SaveChangesAsync(ct); ok += group.Count(); messages.Add("Создан |
| | | 291 | | } |
| | | 292 | | catch (MlAutoApiException ex) { foreach (var item in group) { item.Status = MlAutoRuCartItemStatus.Failed; i |
| | | 293 | | } |
| | | 294 | | return new(ok, 0, failed, messages); |
| | | 295 | | } |
| | | 296 | | |
| | | 297 | | public async Task<bool> UpdateAddressAsync(long id, string addressId, string? addressName, CancellationToken ct = de |
| | | 298 | | |
| | | 299 | | /// <summary>Возвращает актуальные варианты ML-Auto RU для сохранённого адреса без привязки к устаревшему складу.</s |
| | | 300 | | public async Task<MlAutoRuCartRequoteResult> RequoteAsync(long cartItemId, CancellationToken ct = default) |
| | | 301 | | { |
| | | 302 | | var item = await _db.MlAutoRuCartItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoRuCar |
| | | 303 | | ?? throw new InvalidOperationException("Позиция корзины не найдена или уже оформляется."); |
| | | 304 | | if (string.IsNullOrWhiteSpace(item.DeliveryAddressId)) |
| | | 305 | | throw new InvalidOperationException("Сначала выберите адрес доставки ML-Auto RU для этой позиции."); |
| | | 306 | | |
| | | 307 | | var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN |
| | | 308 | | var offers = (response as JArray ?? new JArray()).OfType<JObject>() |
| | | 309 | | .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq |
| | | 310 | | .Select(x => new MlAutoRuCartRequoteOffer( |
| | | 311 | | x.Value<string>("STORAGE_CODE") ?? string.Empty, |
| | | 312 | | x.Value<string>("STORAGE_NAME") ?? x.Value<string>("STORAGE") ?? "Склад ML-Auto RU", |
| | | 313 | | MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")) ?? -1m, |
| | | 314 | | MlAutoValueParser.ParseQuantity(x.Value<string>("QUANTITY")), |
| | | 315 | | MlAutoValueParser.ParseDate(x.Value<string>("DATE")))) |
| | | 316 | | .Where(x => x.Price >= 0 && x.AvailableQuantity.GetValueOrDefault() >= item.Quantity && !string.IsNullOrWhit |
| | | 317 | | .OrderBy(x => x.Price).ThenBy(x => x.DeliveryAtUtc ?? DateTime.MaxValue).Take(8).ToArray(); |
| | | 318 | | return new MlAutoRuCartRequoteResult( |
| | | 319 | | string.IsNullOrWhiteSpace(item.WarehouseName) ? item.StorageCode ?? "—" : $"{item.WarehouseName} ({item.Stor |
| | | 320 | | item.DeliveryAddressName ?? item.DeliveryAddressId, |
| | | 321 | | offers); |
| | | 322 | | } |
| | | 323 | | |
| | | 324 | | /// <summary>Заменяет строку корзины выбранным свежим предложением и требует повторную проверку.</summary> |
| | | 325 | | public async Task<bool> ReplaceWithRequotedOfferAsync(long cartItemId, string storageCode, CancellationToken ct = de |
| | | 326 | | { |
| | | 327 | | var item = await _db.MlAutoRuCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId && MlAutoRuCartItemStatus.Act |
| | | 328 | | if (item is null || item.Status == MlAutoRuCartItemStatus.Processing) return false; |
| | | 329 | | var offer = (await RequoteAsync(cartItemId, ct)).Offers.FirstOrDefault(x => string.Equals(x.StorageCode, storage |
| | | 330 | | if (offer is null) throw new InvalidOperationException("Выбранное предложение уже не актуально. Выполните процен |
| | | 331 | | item.StorageCode = offer.StorageCode; |
| | | 332 | | item.WarehouseName = offer.WarehouseName; |
| | | 333 | | item.SourcePrice = offer.Price; |
| | | 334 | | item.DeliveryAtUtc = offer.DeliveryAtUtc; |
| | | 335 | | item.Status = MlAutoRuCartItemStatus.Pending; |
| | | 336 | | item.TestedAtUtc = null; |
| | | 337 | | item.TestResultJson = null; |
| | | 338 | | item.LastError = "Выбрано актуальное предложение ML-Auto RU. Выполните проверку перед оформлением."; |
| | | 339 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 340 | | await _db.SaveChangesAsync(ct); |
| | | 341 | | return true; |
| | | 342 | | } |
| | | 343 | | |
| | | 344 | | public async Task<MlAutoRuCartResult> ImportOrderAsync(string orderNumber, string user, CancellationToken ct = defau |
| | | 345 | | { |
| | | 346 | | if (await _db.MlAutoRuOrders.AnyAsync(x => x.OrderNumber == orderNumber, ct)) |
| | | 347 | | return new(0, 1, 0, [$"Заказ ML-Auto RU №{orderNumber} уже импортирован."]); |
| | | 348 | | var response = await _api.GetOrderByNumberAsync(orderNumber, ct); |
| | | 349 | | var sourceItems = FindToken(response, "ITEMS") as JArray ?? new JArray(); |
| | | 350 | | if (sourceItems.Count == 0) return new(0, 0, 1, [$"ML-Auto RU не вернул позиции заказа №{orderNumber}."]); |
| | | 351 | | var order = new MlAutoRuOrder { OrderNumber = orderNumber, DeliveryAddressId = string.Empty, Status = Find(respo |
| | | 352 | | var imported = 0; |
| | | 353 | | foreach (var source in sourceItems.OfType<JObject>()) |
| | | 354 | | { |
| | | 355 | | var pin = source.Value<string>("PIN") ?? string.Empty; |
| | | 356 | | var brand = source.Value<string>("BRAND") ?? string.Empty; |
| | | 357 | | var cart = await _db.MlAutoRuCartItems.FirstOrDefaultAsync(x => MlAutoRuCartItemStatus.Active.Contains(x.Sta |
| | | 358 | | if (cart is null) continue; |
| | | 359 | | cart.Status = MlAutoRuCartItemStatus.Ordered; cart.ActiveStudioOrderId = null; cart.LastError = null; |
| | | 360 | | order.DeliveryAddressId = string.IsNullOrWhiteSpace(order.DeliveryAddressId) ? cart.DeliveryAddressId ?? str |
| | | 361 | | order.DeliveryAddressName ??= cart.DeliveryAddressName; |
| | | 362 | | order.Items.Add(new MlAutoRuOrderItem { MlAutoRuCartItemId = cart.Id, StudioOrderId = cart.StudioOrderId, Pi |
| | | 363 | | await HistoryAsync(cart, user, true, null, ApiSupplierBasketCartStatus.Ordered, orderNumber, ct); |
| | | 364 | | await TrackAsync(() => _supplierState.MarkOrderedAsync(cart.StudioOrderId, ApiSupplierSettings.MlAutoRuProvi |
| | | 365 | | imported++; |
| | | 366 | | } |
| | | 367 | | if (imported == 0) return new(0, 0, 1, [$"В корзине нет активных позиций, совпадающих с заказом ML-Auto RU №{ord |
| | | 368 | | _db.MlAutoRuOrders.Add(order); await _db.SaveChangesAsync(ct); |
| | | 369 | | return new(imported, 0, 0, [$"Заказ ML-Auto RU №{orderNumber} импортирован: позиций {imported}."]); |
| | | 370 | | } |
| | | 371 | | public async Task<int> RemoveAsync(IReadOnlyCollection<long>? ids, CancellationToken ct = default) { var items = awa |
| | | 372 | | private Task<List<MlAutoRuCartItem>> SelectedAsync(IReadOnlyCollection<long>? ids, CancellationToken ct) { var q = _ |
| | | 373 | | /// <summary>ML-Auto RU принимает массив позиций как PHP form-поля <c>ITEMS[1][PIN]</c>, а не JSON в поле ITEMS.</su |
| | | 374 | | private static Dictionary<string, string?> BuildRequest(string address, IReadOnlyCollection<MlAutoRuCartItem> items, |
| | | 375 | | { |
| | | 376 | | var request = new Dictionary<string, string?> |
| | | 377 | | { |
| | | 378 | | ["ADDRESS_ID"] = address, |
| | | 379 | | ["ONE-TIME-DELIVERY"] = "1", |
| | | 380 | | ["BEZNAL"] = cashless ? "1" : "0" |
| | | 381 | | }; |
| | | 382 | | foreach (var (item, index) in items.Select((item, index) => (item, index + 1))) |
| | | 383 | | { |
| | | 384 | | var prefix = $"ITEMS[{index}]"; |
| | | 385 | | request[$"{prefix}[PIN]"] = item.Pin; |
| | | 386 | | request[$"{prefix}[BRAND]"] = item.Brand; |
| | | 387 | | request[$"{prefix}[QUANTITY]"] = item.Quantity.ToString("0.###", CultureInfo.InvariantCulture); |
| | | 388 | | request[$"{prefix}[STORAGE-CODE]"] = item.StorageCode; |
| | | 389 | | request[$"{prefix}[MAX-PRICE]"] = item.SourcePrice?.ToString("0.####", CultureInfo.InvariantCulture); |
| | | 390 | | request[$"{prefix}[MAX-DATE]"] = item.DeliveryAtUtc?.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture |
| | | 391 | | } |
| | | 392 | | return request; |
| | | 393 | | } |
| | | 394 | | private async Task<string> RepriceAsync(MlAutoRuCartItem item, CancellationToken ct) |
| | | 395 | | { |
| | | 396 | | var response = await _api.PostAsync("/Search/", new Dictionary<string, string?> { ["ARTICLE"] = item.Pin, ["BRAN |
| | | 397 | | var offers = (response as JArray ?? new JArray()).OfType<JObject>() |
| | | 398 | | .Where(x => string.Equals(x.Value<string>("PIN"), item.Pin, StringComparison.OrdinalIgnoreCase) && string.Eq |
| | | 399 | | .Select(x => new { Token = x, Price = MlAutoValueParser.ParseDecimal(x.Value<string>("PRICE")), Quantity = M |
| | | 400 | | .Where(x => x.Price.HasValue && x.Quantity.GetValueOrDefault() >= item.Quantity && x.Minimum <= item.Quantit |
| | | 401 | | .OrderByDescending(x => string.Equals(x.Token.Value<string>("STORAGE_CODE"), item.StorageCode, StringCompari |
| | | 402 | | .ThenBy(x => x.Price).ThenBy(x => x.Date ?? DateTime.MaxValue).ToArray(); |
| | | 403 | | var offer = offers.FirstOrDefault() ?? throw new MlAutoApiException("ML-Auto RU: актуальное предложение для пров |
| | | 404 | | var changed = item.StorageCode != offer.Token.Value<string>("STORAGE_CODE") || item.SourcePrice != offer.Price | |
| | | 405 | | item.StorageCode = offer.Token.Value<string>("STORAGE_CODE"); |
| | | 406 | | item.SourcePrice = offer.Price; |
| | | 407 | | item.DeliveryAtUtc = offer.Date; |
| | | 408 | | return changed ? " Данные корзины обновлены по актуальной проценке." : string.Empty; |
| | | 409 | | } |
| | | 410 | | |
| | | 411 | | private static JObject EnsureTestAccepted(JToken response, string? providerMessage) |
| | | 412 | | { |
| | | 413 | | var items = FindToken(response, "ITEMS"); |
| | | 414 | | if (items is JArray { Count: > 0 } array && array.OfType<JObject>().FirstOrDefault() is { } result) return resul |
| | | 415 | | if (items is JObject { HasValues: true } item) return item; |
| | | 416 | | throw new MlAutoApiException(string.IsNullOrWhiteSpace(providerMessage) |
| | | 417 | | ? "ML-Auto RU не подтвердил ни одной позиции. Проверьте минимальное количество, склад, цену и срок поставки. |
| | | 418 | | : $"ML-Auto RU: {providerMessage}. Поставщик не подтвердил позицию."); |
| | | 419 | | } |
| | | 420 | | private static string? Find(JToken token, string name) => (token is JContainer container ? container.DescendantsAndS |
| | | 421 | | private static JToken? FindToken(JToken token, string name) => (token is JContainer container ? container.Descendant |
| | | 422 | | private static DateTime? ParseDate(string? value) => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTime |
| | | 423 | | private async Task HistoryAsync(MlAutoRuCartItem item, string user, bool success, string? error, string status, stri |
| | | 424 | | { |
| | | 425 | | try |
| | | 426 | | { |
| | | 427 | | var entry = new ApiSupplierBasketHistoryEntry(HistoryProviderKey, "ML-Auto RU", Logo, success, error, null, |
| | | 428 | | item.ProductName ?? item.Brand, item.SourcePrice, item.CurrencyCode, item.Quantity, item.StudioOrder?.Sh |
| | | 429 | | CartStatus: status, SupplierOrderNumber: number, DeliveryAddressName: item.DeliveryAddressName, |
| | | 430 | | SupplierCartUrl: "/SupplierCart?provider=mlautoru&supplierTab=basket"); |
| | | 431 | | await _history.AddOrderHistory(new OrderHistory |
| | | 432 | | { |
| | | 433 | | OrderId = item.StudioOrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | | 434 | | ColumnDisplayName = "Корзина ML-Auto RU", NewValue = System.Text.Json.JsonSerializer.Serialize(entry, Ap |
| | | 435 | | ChangedAt = DateTime.Now, ChangedBy = user |
| | | 436 | | }); |
| | | 437 | | } |
| | | 438 | | catch (Exception exception) |
| | | 439 | | { |
| | | 440 | | _logger.LogWarning(exception, "Не удалось записать историю корзины ML-Auto RU для заказа {OrderId}", item.St |
| | | 441 | | } |
| | | 442 | | } |
| | | 443 | | } |