| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Caching.Memory; |
| | | 6 | | using Microsoft.Extensions.Logging; |
| | | 7 | | using Newtonsoft.Json.Linq; |
| | | 8 | | using OzonDomains.Models; |
| | | 9 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart; |
| | | 10 | | using OzonRepositories.Context; |
| | | 11 | | using Services.ApiServices.ForumAutoApi; |
| | | 12 | | using Services.DataServices; |
| | | 13 | | using Services.SupplierStatusServices; |
| | | 14 | | |
| | | 15 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 16 | | |
| | | 17 | | public sealed record ForumAutoCartQueueItem( |
| | | 18 | | int OrderId, string ProviderKey, string Tid, string Article, string Brand, string? ProductName, |
| | | 19 | | string? ShipmentNumber, string? ClientName, decimal Quantity, decimal? SourcePrice, string? CurrencyCode, |
| | | 20 | | string? DeliveryAtUtc, int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, string? Direction = null); |
| | | 21 | | |
| | | 22 | | public sealed record ForumAutoCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages, st |
| | | 23 | | { |
| | | 24 | | public string ToHtmlBlock() |
| | | 25 | | { |
| | | 26 | | var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>"); |
| | | 27 | | var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty; |
| | | 28 | | var cartLink = ProviderKey switch |
| | | 29 | | { |
| | | 30 | | ApiSupplierSettings.ForumAutoProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?p |
| | | 31 | | ApiSupplierSettings.ForumAutoInterpartsProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/Supp |
| | | 32 | | ApiSupplierSettings.ForumAutoPiterProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/SupplierC |
| | | 33 | | ApiSupplierSettings.ForumAutoRostovProviderKey => "<a class='btn btn-outline-primary btn-sm' href='/Supplier |
| | | 34 | | _ => string.Empty |
| | | 35 | | }; |
| | | 36 | | var verificationNotice = Added > 0 |
| | | 37 | | ? "<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'></ |
| | | 38 | | : string.Empty; |
| | | 39 | | return "<div class='alert alert-info border-primary-subtle mb-0'>" + |
| | | 40 | | "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина Форум-Авто</ |
| | | 41 | | $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b |
| | | 42 | | $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" + |
| | | 43 | | $"<div class='d-flex flex-wrap gap-2 mt-2'>{cartLink}</div>{verificationNotice}</div>"; |
| | | 44 | | } |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | public sealed record ForumAutoDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages); |
| | | 48 | | |
| | | 49 | | /// <summary>Локальная корзина Forum-Auto. У API нет отдельной корзины и нет тестового эндпоинта — |
| | | 50 | | /// "проверка" перед заказом это свежий listGoods, а addGoodsToOrder сразу мутирует заказ поставщика.</summary> |
| | | 51 | | public sealed class ForumAutoCartService |
| | | 52 | | { |
| | | 53 | | private const string LogoUrl = "/images/forum-auto-logo.svg"; |
| | | 54 | | private readonly ApplicationDbContext _db; |
| | | 55 | | private readonly ForumAutoDataManager _forumAuto; |
| | | 56 | | private readonly ForumAutoInterpartsDataManager _forumAutoInterparts; |
| | | 57 | | private readonly ForumAutoPiterDataManager _forumAutoPiter; |
| | | 58 | | private readonly ForumAutoRostovDataManager _forumAutoRostov; |
| | | 59 | | private readonly ForumAutoIstraDataManager _forumAutoIstra; |
| | | 60 | | private readonly IOrderHistoryDataService _history; |
| | | 61 | | private readonly IMemoryCache _cache; |
| | | 62 | | private readonly IOrderSupplierStateService _supplierState; |
| | | 63 | | private readonly ILogger<ForumAutoCartService> _logger; |
| | | 64 | | |
| | 13 | 65 | | public ForumAutoCartService(ApplicationDbContext db, ForumAutoDataManager forumAuto, ForumAutoInterpartsDataManager |
| | 13 | 66 | | ForumAutoPiterDataManager forumAutoPiter, ForumAutoRostovDataManager forumAutoRostov, ForumAutoIstraDataManager |
| | 13 | 67 | | IOrderSupplierStateService supplierState, ILogger<ForumAutoCartService> logger) |
| | | 68 | | { |
| | 13 | 69 | | _db = db; |
| | 13 | 70 | | _forumAuto = forumAuto; |
| | 13 | 71 | | _forumAutoInterparts = forumAutoInterparts; |
| | 13 | 72 | | _forumAutoPiter = forumAutoPiter; |
| | 13 | 73 | | _forumAutoRostov = forumAutoRostov; |
| | 13 | 74 | | _forumAutoIstra = forumAutoIstra; |
| | 13 | 75 | | _history = history; |
| | 13 | 76 | | _cache = cache; |
| | 13 | 77 | | _supplierState = supplierState; |
| | 13 | 78 | | _logger = logger; |
| | 13 | 79 | | } |
| | | 80 | | |
| | | 81 | | private async Task TrackCartAsync(int orderId, string providerKey, string cartStatusCode, long cartItemId, string? u |
| | | 82 | | int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null) |
| | | 83 | | { |
| | | 84 | | try |
| | | 85 | | { |
| | 2 | 86 | | await _supplierState.MarkInCartAsync(orderId, providerKey, cartStatusCode, cartItemId, user: user, |
| | 2 | 87 | | deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct); |
| | 2 | 88 | | } |
| | 0 | 89 | | catch (Exception ex) |
| | | 90 | | { |
| | 0 | 91 | | _logger.LogWarning(ex, "{Provider}: не удалось записать состояние корзины для заказа {OrderId}", providerKey |
| | 0 | 92 | | } |
| | 2 | 93 | | } |
| | | 94 | | |
| | | 95 | | private async Task TrackOrderedAsync(int orderId, string providerKey, string? orderNumber, string? user, Cancellatio |
| | | 96 | | int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null) |
| | | 97 | | { |
| | | 98 | | try |
| | | 99 | | { |
| | 5 | 100 | | await _supplierState.MarkOrderedAsync(orderId, providerKey, orderNumber, user: user, |
| | 5 | 101 | | deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct); |
| | 5 | 102 | | } |
| | 0 | 103 | | catch (Exception ex) |
| | | 104 | | { |
| | 0 | 105 | | _logger.LogWarning(ex, "{Provider}: не удалось привязать заказ {OrderId} к заказу поставщика", providerKey, |
| | 0 | 106 | | } |
| | 5 | 107 | | } |
| | | 108 | | |
| | | 109 | | private async Task TrackRemovedAsync(int orderId, string providerKey, string? user, CancellationToken ct) |
| | | 110 | | { |
| | | 111 | | try |
| | | 112 | | { |
| | 1 | 113 | | await _supplierState.RemoveFromCartAsync(orderId, providerKey, user, ct); |
| | 1 | 114 | | } |
| | 0 | 115 | | catch (Exception ex) |
| | | 116 | | { |
| | 0 | 117 | | _logger.LogWarning(ex, "{Provider}: не удалось закрыть состояние корзины для заказа {OrderId}", providerKey, |
| | 0 | 118 | | } |
| | 1 | 119 | | } |
| | | 120 | | |
| | | 121 | | public Task<List<ForumAutoCartItem>> GetActiveAsync(string providerKey, CancellationToken ct = default) => |
| | 0 | 122 | | _db.ForumAutoCartItems.AsNoTracking().Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo) |
| | 0 | 123 | | .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer) |
| | 0 | 124 | | .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient) |
| | 0 | 125 | | .Where(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Contains(x.Status)) |
| | 0 | 126 | | .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct); |
| | | 127 | | |
| | | 128 | | public Task<List<ForumAutoOrder>> GetOrdersAsync(string providerKey, CancellationToken ct = default) => |
| | 0 | 129 | | _db.ForumAutoOrders.AsNoTracking().Include(x => x.Items).ThenInclude(x => x.StudioOrder) |
| | 0 | 130 | | .Where(x => x.ProviderKey == providerKey).OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct); |
| | | 131 | | |
| | | 132 | | public Task<int> CountActiveAsync(CancellationToken ct = default) => |
| | 0 | 133 | | _db.ForumAutoCartItems.CountAsync(x => ForumAutoCartItemStatus.Active.Contains(x.Status), ct); |
| | | 134 | | |
| | | 135 | | public async Task<ForumAutoCartOperationResult> QueueAsync(IReadOnlyCollection<ForumAutoCartQueueItem> queue, string |
| | | 136 | | { |
| | 3 | 137 | | var messages = new List<string>(); |
| | 9 | 138 | | var added = 0; var skipped = 0; var failed = 0; |
| | 3 | 139 | | string? providerKey = null; |
| | 12 | 140 | | foreach (var item in queue) |
| | | 141 | | { |
| | 3 | 142 | | providerKey ??= item.ProviderKey; |
| | 3 | 143 | | if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 || |
| | 3 | 144 | | string.IsNullOrWhiteSpace(item.Tid) || string.IsNullOrWhiteSpace(item.Article) || string.IsNullOrWhiteSp |
| | | 145 | | { |
| | 0 | 146 | | failed++; |
| | 0 | 147 | | messages.Add($"{Describe(item)} — неполные данные предложения Форум-Авто."); |
| | 0 | 148 | | continue; |
| | | 149 | | } |
| | 3 | 150 | | var exists = await _db.ForumAutoCartItems.AnyAsync( |
| | 3 | 151 | | x => x.ProviderKey == item.ProviderKey && x.StudioOrderId == item.OrderId && ForumAutoCartItemStatus.Act |
| | 6 | 152 | | if (exists) { skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.") |
| | | 153 | | |
| | 2 | 154 | | var cartItem = new ForumAutoCartItem |
| | 2 | 155 | | { |
| | 2 | 156 | | ProviderKey = item.ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, |
| | 2 | 157 | | Tid = item.Tid, Article = item.Article, Brand = item.Brand, ProductName = item.ProductName, |
| | 2 | 158 | | Quantity = item.Quantity, SourcePrice = item.SourcePrice, CurrencyCode = item.CurrencyCode ?? "RUB", |
| | 2 | 159 | | DeliveryAtUtc = ParseApiDate(item.DeliveryAtUtc), CreatedBy = changedBy |
| | 2 | 160 | | }; |
| | 2 | 161 | | _db.ForumAutoCartItems.Add(cartItem); |
| | | 162 | | try |
| | | 163 | | { |
| | 2 | 164 | | await _db.SaveChangesAsync(ct); |
| | 2 | 165 | | } |
| | 0 | 166 | | catch (DbUpdateException) |
| | | 167 | | { |
| | 0 | 168 | | _db.Entry(cartItem).State = EntityState.Detached; |
| | 0 | 169 | | skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}."); |
| | 0 | 170 | | continue; |
| | | 171 | | } |
| | 2 | 172 | | added++; |
| | 2 | 173 | | messages.Add($"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}; требуется проверка п |
| | 2 | 174 | | await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending, supplierOrderN |
| | 2 | 175 | | await TrackCartAsync(item.OrderId, item.ProviderKey, ForumAutoCartItemStatus.Pending, cartItem.Id, changedBy |
| | 2 | 176 | | item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction); |
| | 2 | 177 | | } |
| | 3 | 178 | | InvalidateCount(); |
| | 3 | 179 | | return new ForumAutoCartOperationResult(added, skipped, failed, messages, providerKey); |
| | 3 | 180 | | } |
| | | 181 | | |
| | | 182 | | /// <summary>Репрайсинг перед заказом: у Forum-Auto нет тестового эндпоинта, поэтому "проверка" — |
| | | 183 | | /// это свежий listGoods по исходному артикулу и сверка найденной строки с нашим tid (=gid).</summary> |
| | | 184 | | private async Task<(bool Ok, string? Message, decimal? NewPrice, int? NewRest)> RepriceAsync( |
| | | 185 | | string providerKey, string article, string brand, string tid, decimal quantity, CancellationToken ct) |
| | | 186 | | { |
| | 6 | 187 | | var client = ResolveClient(providerKey); |
| | 6 | 188 | | var response = await client.ListGoodsAsync(article, brand, cross: false, ct: ct); |
| | 6 | 189 | | var items = ForumAutoPriceMapper.Map(response, article, null, null, providerKey); |
| | 12 | 190 | | var match = items.FirstOrDefault(x => string.Equals(x.ItemId, tid, StringComparison.Ordinal)); |
| | 6 | 191 | | if (match is null) return (false, "снят с продажи или изменился gid — выполните проценку заново", null, null); |
| | 6 | 192 | | if (!int.TryParse(match.Rest, out var rest) || rest < quantity) |
| | 2 | 193 | | return (false, $"недостаточно остатка ({match.Rest ?? "0"} < {quantity:0.###})", match.SourcePrice, null); |
| | 4 | 194 | | return (true, null, match.SourcePrice, rest); |
| | 6 | 195 | | } |
| | | 196 | | |
| | | 197 | | public async Task<ForumAutoCartOperationResult> TestAsync(string providerKey, string changedBy, IReadOnlyCollection< |
| | | 198 | | { |
| | 2 | 199 | | var selectionError = ValidateSelection(cartItemIds, out var selectedIds); |
| | 2 | 200 | | if (selectionError is not null) return new(0, 0, 0, [selectionError]); |
| | 2 | 201 | | var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct); |
| | 2 | 202 | | if (selectedIds is not null && items.Count != selectedIds.Count) |
| | 0 | 203 | | return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите проверку."]); |
| | 2 | 204 | | if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]); |
| | | 205 | | |
| | 6 | 206 | | var ready = 0; var failed = 0; var messages = new List<string>(); |
| | 8 | 207 | | foreach (var item in items) |
| | | 208 | | { |
| | | 209 | | try |
| | | 210 | | { |
| | 2 | 211 | | var (ok, message, newPrice, newRest) = await RepriceAsync(providerKey, item.Article, item.Brand, item.Ti |
| | 4 | 212 | | item.TestedAtUtc = DateTime.UtcNow; item.UpdatedAtUtc = DateTime.UtcNow; |
| | 2 | 213 | | if (ok) |
| | | 214 | | { |
| | | 215 | | string priceNote; |
| | 1 | 216 | | if (newPrice.HasValue && newPrice.Value != item.SourcePrice) |
| | 1 | 217 | | priceNote = $" Цена обновлена: {item.SourcePrice?.ToString("0.##", CultureInfo.InvariantCulture) |
| | | 218 | | else |
| | 0 | 219 | | priceNote = string.Empty; |
| | 2 | 220 | | if (newPrice.HasValue) item.SourcePrice = newPrice; |
| | 1 | 221 | | item.Status = ForumAutoCartItemStatus.Ready; |
| | 1 | 222 | | item.LastError = null; |
| | 1 | 223 | | ready++; |
| | 1 | 224 | | messages.Add($"{item.Article}: проверено у поставщика, остаток {newRest}.{priceNote}"); |
| | 1 | 225 | | await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), true, null, ApiSupplierBasketCartSta |
| | | 226 | | } |
| | | 227 | | else |
| | | 228 | | { |
| | 1 | 229 | | item.Status = ForumAutoCartItemStatus.Failed; |
| | 1 | 230 | | item.LastError = $"{message} для арт. {item.Article} · {item.Brand}."; |
| | 1 | 231 | | failed++; |
| | 1 | 232 | | messages.Add(item.LastError); |
| | 1 | 233 | | await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), false, item.LastError, ApiSupplierBa |
| | | 234 | | } |
| | 2 | 235 | | } |
| | 0 | 236 | | catch (ForumAutoApiException ex) |
| | | 237 | | { |
| | 0 | 238 | | item.Status = ForumAutoCartItemStatus.Failed; item.LastError = ex.Message; |
| | 0 | 239 | | item.TestedAtUtc = DateTime.UtcNow; item.UpdatedAtUtc = DateTime.UtcNow; |
| | 0 | 240 | | failed++; messages.Add($"{item.Article}: {ex.Message}"); |
| | 0 | 241 | | await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), false, ex.Message, ApiSupplierBasketCart |
| | | 242 | | } |
| | 2 | 243 | | } |
| | 2 | 244 | | await _db.SaveChangesAsync(ct); |
| | 2 | 245 | | return new(ready, 0, failed, messages, providerKey); |
| | 2 | 246 | | } |
| | | 247 | | |
| | | 248 | | public async Task<ForumAutoCartOperationResult> CheckoutAsync(string providerKey, string changedBy, IReadOnlyCollect |
| | | 249 | | { |
| | 3 | 250 | | var selectionError = ValidateSelection(cartItemIds, out var selectedIds); |
| | 3 | 251 | | if (selectionError is not null) return new(0, 0, 0, [selectionError]); |
| | 3 | 252 | | var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct); |
| | 3 | 253 | | if (selectedIds is not null && items.Count != selectedIds.Count) |
| | 0 | 254 | | return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите оформление."]) |
| | 3 | 255 | | if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]); |
| | 7 | 256 | | if (items.Any(x => x.Status != ForumAutoCartItemStatus.Ready || x.TestedAtUtc is null || x.TestedAtUtc < DateTim |
| | 1 | 257 | | return new(0, 0, items.Count, ["Перед оформлением выполните свежую проверку Форум-Авто."]); |
| | | 258 | | |
| | 5 | 259 | | var ids = items.Select(x => x.Id).ToArray(); |
| | 2 | 260 | | var locked = await _db.ForumAutoCartItems.Where(x => ids.Contains(x.Id) && x.Status == ForumAutoCartItemStatus.R |
| | 2 | 261 | | .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ForumAutoCartItemStatus.Processing).SetProperty(x => x |
| | 2 | 262 | | if (locked != items.Count) return new(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]); |
| | | 263 | | |
| | 5 | 264 | | var lines = items.Select(item => new ForumAutoPlacementLine(item.Id, item.StudioOrderId, item.Tid, item.Article, |
| | 5 | 265 | | item.ProductName, item.Quantity, item.SourcePrice, item.CurrencyCode, null, null, null, null, null)).ToList( |
| | 2 | 266 | | var (success, failed, messages) = await PlaceOrderItemsAsync(providerKey, lines, changedBy, ct); |
| | 2 | 267 | | InvalidateCount(); |
| | 2 | 268 | | return new(success, 0, failed, messages, providerKey); |
| | 3 | 269 | | } |
| | | 270 | | |
| | | 271 | | /// <summary>Оформляет позиции без локальной корзины. Вызывается до проведения статусов Studio2.</summary> |
| | | 272 | | public async Task<ForumAutoDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ForumAutoCartQueueItem> que |
| | | 273 | | { |
| | 4 | 274 | | if (queue.Count == 0) return new(true, []); |
| | 12 | 275 | | if (queue.GroupBy(x => new { x.ProviderKey, x.OrderId }).Any(group => group.Count() > 1)) |
| | 0 | 276 | | return new(false, ["Для одного заказа выбрано несколько предложений Форум-Авто."]); |
| | | 277 | | |
| | 4 | 278 | | var messages = new List<string>(); |
| | 19 | 279 | | foreach (var group in queue.GroupBy(x => x.ProviderKey)) |
| | | 280 | | { |
| | 4 | 281 | | if (!IsProvider(group.Key)) return new(false, ["Неизвестный аккаунт Форум-Авто."]); |
| | 4 | 282 | | var lines = new List<ForumAutoPlacementLine>(); |
| | 15 | 283 | | foreach (var item in group) |
| | | 284 | | { |
| | 4 | 285 | | if (item.OrderId <= 0 || item.Quantity <= 0 || string.IsNullOrWhiteSpace(item.Tid) || |
| | 4 | 286 | | string.IsNullOrWhiteSpace(item.Article) || string.IsNullOrWhiteSpace(item.Brand)) |
| | 0 | 287 | | return new(false, [$"{Describe(item)}: неполные данные предложения Форум-Авто."]); |
| | | 288 | | |
| | 4 | 289 | | var (ok, message, newPrice, _) = await RepriceAsync(item.ProviderKey, item.Article, item.Brand, item.Tid |
| | 5 | 290 | | if (!ok) return new(false, [$"{item.Article}: {message}."]); |
| | | 291 | | |
| | 3 | 292 | | lines.Add(new ForumAutoPlacementLine(null, item.OrderId, item.Tid, item.Article, item.Brand, item.Produc |
| | 3 | 293 | | item.Quantity, newPrice ?? item.SourcePrice, item.CurrencyCode ?? "RUB", item.ShipmentNumber, item.C |
| | 3 | 294 | | item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction)); |
| | 3 | 295 | | } |
| | 3 | 296 | | var (success, failed, placeMessages) = await PlaceOrderItemsAsync(group.Key, lines, changedBy, ct); |
| | 3 | 297 | | messages.AddRange(placeMessages); |
| | 3 | 298 | | if (failed > 0) return new(false, messages); |
| | 3 | 299 | | } |
| | 3 | 300 | | InvalidateCount(); |
| | 3 | 301 | | return new(true, messages); |
| | 4 | 302 | | } |
| | | 303 | | |
| | 90 | 304 | | private sealed record ForumAutoPlacementLine(long? CartItemId, int StudioOrderId, string Tid, string Article, string |
| | 52 | 305 | | string? ProductName, decimal Quantity, decimal? SourcePrice, string? CurrencyCode, string? ShipmentNumber, strin |
| | 39 | 306 | | int? DeliveryDaysMin, int? DeliveryDaysMax, string? Direction); |
| | | 307 | | |
| | | 308 | | private static bool IsDuplicateDidException(Exception ex) |
| | | 309 | | { |
| | 0 | 310 | | var msg = ex.InnerException?.Message ?? ex.Message; |
| | 0 | 311 | | return msg.Contains("UX_ForumAutoOrders_Provider_Did", StringComparison.OrdinalIgnoreCase) |
| | 0 | 312 | | || msg.Contains("ForumAutoOrders.ProviderKey", StringComparison.OrdinalIgnoreCase) |
| | 0 | 313 | | || msg.Contains("1062", StringComparison.OrdinalIgnoreCase); |
| | | 314 | | } |
| | | 315 | | |
| | | 316 | | /// <summary>Общий алгоритм размещения: создаёт заказ Форум-Авто (наш eoid = Id новой строки ForumAutoOrders), |
| | | 317 | | /// последовательно вызывает addGoodsToOrder по каждой позиции — первый успешный ответ даёт реальный did. |
| | | 318 | | /// Ошибка одной позиции не откатывает остальные (в отличие от Armtek — здесь один вызов на позицию).</summary> |
| | | 319 | | private async Task<(int Success, int Failed, List<string> Messages)> PlaceOrderItemsAsync( |
| | | 320 | | string providerKey, IReadOnlyList<ForumAutoPlacementLine> lines, string changedBy, CancellationToken ct) |
| | | 321 | | { |
| | 5 | 322 | | var client = ResolveClient(providerKey); |
| | 5 | 323 | | var order = new ForumAutoOrder { ProviderKey = providerKey, Status = "Creating", CreatedBy = changedBy }; |
| | 5 | 324 | | _db.ForumAutoOrders.Add(order); |
| | 5 | 325 | | await _db.SaveChangesAsync(ct); |
| | 5 | 326 | | order.Eoid = order.Id; |
| | 5 | 327 | | await _db.SaveChangesAsync(ct); |
| | | 328 | | |
| | 15 | 329 | | var success = 0; var failed = 0; var messages = new List<string>(); |
| | 5 | 330 | | var seenEids = new HashSet<int>(); |
| | 22 | 331 | | foreach (var line in lines) |
| | | 332 | | { |
| | | 333 | | try |
| | | 334 | | { |
| | 6 | 335 | | var response = await client.AddGoodsToOrderAsync(line.Tid, (int)Math.Round(line.Quantity), eid: null, eo |
| | | 336 | | |
| | 5 | 337 | | var orderArray = (response as JArray)?.OfType<JObject>().ToList() |
| | 5 | 338 | | ?? (response is JObject obj ? new List<JObject> { obj } : null) |
| | 5 | 339 | | ?? throw new ForumAutoApiException("Forum-Auto вернул неожиданный формат ответа addGoodsToOrder."); |
| | | 340 | | |
| | | 341 | | // Ищем целевой заказ в ответе поставщика: |
| | | 342 | | // 1) Если did уже известен для текущего заказа — ищем по did |
| | | 343 | | // 2) Если у нас есть eoid — ищем по eoid (число или строка) |
| | | 344 | | // 3) Ищем заказ, в arTovs которого присутствует только что добавленный line.Tid (и eid не был учтен ран |
| | | 345 | | // 4) Если не удалось найти точным сопоставлением — выбираем заказ с максимальным did (наиболее свежий), |
| | 5 | 346 | | var orderObj = (order.Did.HasValue ? orderArray.FirstOrDefault(x => x.Value<int?>("did") == order.Did.Va |
| | 6 | 347 | | ?? (order.Eoid > 0 ? orderArray.FirstOrDefault(x => x.Value<long?>("eoid") == order.Eoid || string.E |
| | 6 | 348 | | ?? orderArray.FirstOrDefault(x => (x["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()).An |
| | 0 | 349 | | ?? orderArray.OrderByDescending(x => x.Value<int?>("did") ?? 0).FirstOrDefault() |
| | 5 | 350 | | ?? throw new ForumAutoApiException("Forum-Auto вернул неожиданный формат ответа addGoodsToOrder."); |
| | | 351 | | |
| | 5 | 352 | | order.ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None); |
| | 5 | 353 | | var did = orderObj.Value<int?>("did"); |
| | 5 | 354 | | if (did.HasValue) |
| | | 355 | | { |
| | 5 | 356 | | if (!order.Did.HasValue) |
| | | 357 | | { |
| | | 358 | | // Проверяем, существует ли уже заказ с таким (ProviderKey, Did) в базе данных |
| | 5 | 359 | | var existingOrder = await _db.ForumAutoOrders |
| | 5 | 360 | | .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value && x.Id != orde |
| | | 361 | | |
| | 5 | 362 | | if (existingOrder != null) |
| | | 363 | | { |
| | | 364 | | // Поставщик привязал позицию к уже существующему заказу (например, дневной сводный заказ). |
| | | 365 | | // Перепривязываем элементы к существующему заказу и удаляем черновой заказ-плейсхолдер. |
| | 2 | 366 | | foreach (var itemEntry in _db.ChangeTracker.Entries<ForumAutoOrderItem>() |
| | 1 | 367 | | .Where(e => e.Entity.ForumAutoOrderId == order.Id)) |
| | | 368 | | { |
| | 0 | 369 | | itemEntry.Entity.ForumAutoOrderId = existingOrder.Id; |
| | | 370 | | } |
| | | 371 | | |
| | 1 | 372 | | _db.ForumAutoOrders.Remove(order); |
| | 1 | 373 | | order = existingOrder; |
| | 1 | 374 | | order.ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None); |
| | | 375 | | } |
| | | 376 | | else |
| | | 377 | | { |
| | 4 | 378 | | order.Did = did.Value; |
| | | 379 | | } |
| | | 380 | | } |
| | 0 | 381 | | else if (order.Did.Value != did.Value) |
| | | 382 | | { |
| | | 383 | | // Поставщик разбил заказ на другой did (например, другой склад отгрузки) |
| | 0 | 384 | | var splitOrder = await _db.ForumAutoOrders |
| | 0 | 385 | | .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value, ct); |
| | | 386 | | |
| | 0 | 387 | | if (splitOrder == null) |
| | | 388 | | { |
| | 0 | 389 | | splitOrder = new ForumAutoOrder |
| | 0 | 390 | | { |
| | 0 | 391 | | ProviderKey = providerKey, |
| | 0 | 392 | | Did = did.Value, |
| | 0 | 393 | | Eoid = order.Eoid, |
| | 0 | 394 | | Status = "Creating", |
| | 0 | 395 | | CreatedBy = changedBy, |
| | 0 | 396 | | ResponseJson = orderObj.ToString(Newtonsoft.Json.Formatting.None) |
| | 0 | 397 | | }; |
| | 0 | 398 | | _db.ForumAutoOrders.Add(splitOrder); |
| | 0 | 399 | | await _db.SaveChangesAsync(ct); |
| | | 400 | | } |
| | 0 | 401 | | order = splitOrder; |
| | 0 | 402 | | } |
| | | 403 | | } |
| | | 404 | | |
| | 5 | 405 | | var matchedRow = (orderObj["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()) |
| | 5 | 406 | | .Where(x => string.Equals(x.Value<string>("gid"), line.Tid, StringComparison.Ordinal)) |
| | 10 | 407 | | .FirstOrDefault(x => !seenEids.Contains(x.Value<int?>("eid") ?? -1)); |
| | 10 | 408 | | if (matchedRow is not null) seenEids.Add(matchedRow.Value<int?>("eid") ?? -1); |
| | 5 | 409 | | var eid = matchedRow?.Value<int?>("eid"); |
| | 5 | 410 | | var price = matchedRow?.Value<decimal?>("price") ?? line.SourcePrice; |
| | | 411 | | |
| | 5 | 412 | | _db.ForumAutoOrderItems.Add(new ForumAutoOrderItem |
| | 5 | 413 | | { |
| | 5 | 414 | | ForumAutoOrderId = order.Id, ForumAutoCartItemId = line.CartItemId, StudioOrderId = line.StudioOrder |
| | 5 | 415 | | Eid = eid, Tid = line.Tid, Brand = line.Brand, Quantity = line.Quantity, Price = price, CurrencyCode |
| | 5 | 416 | | }); |
| | | 417 | | |
| | 5 | 418 | | if (line.CartItemId.HasValue) |
| | | 419 | | { |
| | 2 | 420 | | var cartItem = await _db.ForumAutoCartItems.FirstAsync(x => x.Id == line.CartItemId.Value, ct); |
| | 4 | 421 | | cartItem.Status = ForumAutoCartItemStatus.Ordered; cartItem.ActiveStudioOrderId = null; |
| | 4 | 422 | | cartItem.LastError = null; cartItem.UpdatedAtUtc = DateTime.UtcNow; |
| | | 423 | | } |
| | | 424 | | |
| | | 425 | | try |
| | | 426 | | { |
| | 5 | 427 | | await _db.SaveChangesAsync(ct); |
| | 5 | 428 | | } |
| | 0 | 429 | | catch (DbUpdateException dbEx) when (IsDuplicateDidException(dbEx) && did.HasValue) |
| | | 430 | | { |
| | 0 | 431 | | _logger.LogWarning(dbEx, "Конкурентное создание ForumAutoOrder для Did {Did}, перепривязываем к суще |
| | 0 | 432 | | var winningOrder = await _db.ForumAutoOrders.AsTracking() |
| | 0 | 433 | | .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Did == did.Value && x.Id != order.Id |
| | | 434 | | |
| | 0 | 435 | | if (winningOrder != null) |
| | | 436 | | { |
| | 0 | 437 | | foreach (var itemEntry in _db.ChangeTracker.Entries<ForumAutoOrderItem>() |
| | 0 | 438 | | .Where(e => e.Entity.ForumAutoOrderId == order.Id)) |
| | | 439 | | { |
| | 0 | 440 | | itemEntry.Entity.ForumAutoOrderId = winningOrder.Id; |
| | | 441 | | } |
| | | 442 | | |
| | 0 | 443 | | _db.Entry(order).State = EntityState.Detached; |
| | 0 | 444 | | await _db.ForumAutoOrders.Where(x => x.Id == order.Id).ExecuteDeleteAsync(ct); |
| | 0 | 445 | | order = winningOrder; |
| | 0 | 446 | | await _db.SaveChangesAsync(ct); |
| | | 447 | | } |
| | | 448 | | else |
| | | 449 | | { |
| | 0 | 450 | | throw; |
| | | 451 | | } |
| | 0 | 452 | | } |
| | | 453 | | |
| | 5 | 454 | | success++; |
| | 5 | 455 | | var orderLabel = order.Did.HasValue ? order.Did.Value.ToString(CultureInfo.InvariantCulture) : $"eoid {o |
| | 5 | 456 | | messages.Add($"{line.Article}: заказ Форум-Авто {orderLabel} создан."); |
| | 5 | 457 | | await RecordHistoryAsync(line.StudioOrderId, ToQueueItem(providerKey, line), true, null, ApiSupplierBask |
| | 5 | 458 | | await TrackOrderedAsync(line.StudioOrderId, providerKey, orderLabel, changedBy, ct, line.DeliveryDaysMin |
| | 5 | 459 | | } |
| | 1 | 460 | | catch (Exception ex) when (ex is ForumAutoApiException or HttpRequestException or DbUpdateException) |
| | | 461 | | { |
| | 1 | 462 | | failed++; |
| | 1 | 463 | | messages.Add($"{line.Article}: {ex.Message}"); |
| | 1 | 464 | | if (line.CartItemId.HasValue) |
| | | 465 | | { |
| | 1 | 466 | | var cartItem = await _db.ForumAutoCartItems.FirstOrDefaultAsync(x => x.Id == line.CartItemId.Value, |
| | 1 | 467 | | if (cartItem != null) |
| | | 468 | | { |
| | 3 | 469 | | cartItem.Status = ForumAutoCartItemStatus.Failed; cartItem.LastError = ex.Message; cartItem.Upda |
| | | 470 | | } |
| | | 471 | | } |
| | 1 | 472 | | await RecordHistoryAsync(line.StudioOrderId, ToQueueItem(providerKey, line), false, ex.Message, ApiSuppl |
| | | 473 | | } |
| | 6 | 474 | | } |
| | | 475 | | |
| | 5 | 476 | | if (_db.Entry(order).State != EntityState.Detached) |
| | | 477 | | { |
| | 5 | 478 | | order.Status = success == 0 ? "Failed" : failed == 0 ? "Created" : "PartiallyFailed"; |
| | 5 | 479 | | await _db.SaveChangesAsync(ct); |
| | | 480 | | } |
| | 5 | 481 | | return (success, failed, messages); |
| | 5 | 482 | | } |
| | | 483 | | |
| | | 484 | | /// <summary>Отменяет заказ у поставщика целиком (без восстановления). Пока без обязательной кнопки в UI.</summary> |
| | | 485 | | public async Task<bool> CancelOrderAsync(string providerKey, long localOrderId, string changedBy, CancellationToken |
| | | 486 | | { |
| | 1 | 487 | | var order = await _db.ForumAutoOrders.FirstOrDefaultAsync(x => x.Id == localOrderId && x.ProviderKey == provider |
| | 1 | 488 | | if (order?.Did is null) return false; |
| | 1 | 489 | | var client = ResolveClient(providerKey); |
| | 1 | 490 | | var response = await client.CancelOrderAsync(order.Did.Value, ct); |
| | 1 | 491 | | if (response.Value<int?>("status") != 1) return false; |
| | 1 | 492 | | order.Status = "Cancelled"; |
| | 1 | 493 | | await _db.SaveChangesAsync(ct); |
| | 1 | 494 | | return true; |
| | 1 | 495 | | } |
| | | 496 | | |
| | | 497 | | public async Task<bool> RemoveAsync(long id, string providerKey, CancellationToken ct = default) |
| | | 498 | | { |
| | 0 | 499 | | var item = await _db.ForumAutoCartItems.FirstOrDefaultAsync(x => x.Id == id && x.ProviderKey == providerKey && F |
| | 0 | 500 | | if (item is null) return false; |
| | 0 | 501 | | item.Status = ForumAutoCartItemStatus.Removed; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.Utc |
| | 0 | 502 | | await _db.SaveChangesAsync(ct); |
| | 0 | 503 | | await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct); |
| | 0 | 504 | | InvalidateCount(); return true; |
| | 0 | 505 | | } |
| | | 506 | | |
| | | 507 | | /// <summary>Убирает из корзины выбранные строки (или всю корзину провайдера, если ids не заданы).</summary> |
| | | 508 | | public async Task<ForumAutoCartOperationResult> RemoveManyAsync(string providerKey, IReadOnlyCollection<long>? cartI |
| | | 509 | | { |
| | 1 | 510 | | if (!IsProvider(providerKey)) return new(0, 0, 0, ["Неизвестный аккаунт Форум-Авто."]); |
| | 1 | 511 | | var selectionError = ValidateSelection(cartItemIds, out var selectedIds); |
| | 1 | 512 | | if (selectionError is not null) return new(0, 0, 0, [selectionError]); |
| | | 513 | | |
| | 1 | 514 | | var items = (await GetTrackedActiveAsync(providerKey, selectedIds, ct)) |
| | 2 | 515 | | .Where(x => x.Status != ForumAutoCartItemStatus.Processing) |
| | 1 | 516 | | .ToList(); |
| | 4 | 517 | | foreach (var item in items) |
| | | 518 | | { |
| | 1 | 519 | | item.Status = ForumAutoCartItemStatus.Removed; |
| | 1 | 520 | | item.ActiveStudioOrderId = null; |
| | 1 | 521 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 522 | | } |
| | 1 | 523 | | await _db.SaveChangesAsync(ct); |
| | 5 | 524 | | foreach (var item in items) await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct); |
| | 1 | 525 | | var removed = items.Count; |
| | 1 | 526 | | InvalidateCount(); |
| | 1 | 527 | | if (removed == 0) return new(0, 0, 0, ["Нечего удалять: строки уже удалены или сейчас оформляются."]); |
| | 1 | 528 | | var skipped = selectedIds is null ? 0 : selectedIds.Count - removed; |
| | 1 | 529 | | var messages = new List<string> { $"Удалено из корзины {ProviderName(providerKey)}: {removed}." }; |
| | 1 | 530 | | if (skipped > 0) messages.Add($"Пропущено (оформляются или уже удалены): {skipped}."); |
| | 1 | 531 | | return new(removed, skipped, 0, messages, providerKey); |
| | 1 | 532 | | } |
| | | 533 | | |
| | | 534 | | private Task<List<ForumAutoCartItem>> GetTrackedActiveAsync(string providerKey, IReadOnlyCollection<long>? cartItemI |
| | | 535 | | { |
| | 6 | 536 | | var query = _db.ForumAutoCartItems.Where(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Con |
| | 6 | 537 | | if (cartItemIds is not null) query = query.Where(x => cartItemIds.Contains(x.Id)); |
| | 6 | 538 | | return query.OrderBy(x => x.Id).ToListAsync(ct); |
| | | 539 | | } |
| | | 540 | | |
| | | 541 | | /// <summary> |
| | | 542 | | /// Аннулирует у Форум-Авто заказ (<c>cancelOrder</c> по did), в котором лежит заказ Studio2, отменённый в Ozon. |
| | | 543 | | /// Метод отменяет did целиком, поэтому вызывается, только если все заказы Studio2 этого did входят в |
| | | 544 | | /// <paramref name="cancellableOrderIds"/>; иначе — <see cref="SupplierOrderCancelOutcome.Failed"/> с просьбой снять |
| | | 545 | | /// </summary> |
| | | 546 | | public async Task<SupplierOrderCancelResult> CancelOrderAsync(string providerKey, int orderId, IReadOnlySet<int> can |
| | | 547 | | CancellationToken ct = default) |
| | | 548 | | { |
| | 0 | 549 | | if (!IsProvider(providerKey)) return SupplierOrderCancelResult.Failed($"Неизвестный аккаунт Форум-Авто: {provide |
| | 0 | 550 | | var dids = await _db.ForumAutoOrderItems.AsNoTracking() |
| | 0 | 551 | | .Where(x => x.StudioOrderId == orderId && x.ForumAutoOrder != null && x.ForumAutoOrder.ProviderKey == provid |
| | 0 | 552 | | .Select(x => x.ForumAutoOrder!.Did!.Value).Distinct().ToListAsync(ct); |
| | 0 | 553 | | if (dids.Count == 0) return SupplierOrderCancelResult.Failed("в Studio2 нет номера заказа Форум-Авто (did)."); |
| | | 554 | | |
| | 0 | 555 | | var neighbours = await _db.ForumAutoOrderItems.AsNoTracking() |
| | 0 | 556 | | .Where(x => x.ForumAutoOrder != null && x.ForumAutoOrder.ProviderKey == providerKey && x.ForumAutoOrder.Did |
| | 0 | 557 | | && dids.Contains(x.ForumAutoOrder.Did.Value) && x.StudioOrderId != orderId) |
| | 0 | 558 | | .Select(x => x.StudioOrderId).Distinct().ToListAsync(ct); |
| | 0 | 559 | | var live = neighbours.Where(x => !cancellableOrderIds.Contains(x)).ToList(); |
| | 0 | 560 | | if (live.Count > 0) |
| | 0 | 561 | | return SupplierOrderCancelResult.Failed( |
| | 0 | 562 | | $"в заказе {string.Join(", ", dids)} есть другие заказы Studio2 ({string.Join(", ", live.Select(x => "#" |
| | | 563 | | |
| | 0 | 564 | | var client = ResolveClient(providerKey); |
| | 0 | 565 | | var cancelled = new List<int>(); |
| | 0 | 566 | | foreach (var did in dids) |
| | | 567 | | { |
| | | 568 | | try |
| | | 569 | | { |
| | 0 | 570 | | await client.CancelOrderAsync(did, ct); |
| | 0 | 571 | | cancelled.Add(did); |
| | 0 | 572 | | } |
| | 0 | 573 | | catch (ForumAutoApiException ex) |
| | | 574 | | { |
| | 0 | 575 | | var done = cancelled.Count > 0 ? $"; уже аннулированы: {string.Join(", ", cancelled)}" : string.Empty; |
| | 0 | 576 | | return SupplierOrderCancelResult.Failed($"заказ {did}: {ex.Message}{done}"); |
| | | 577 | | } |
| | | 578 | | } |
| | 0 | 579 | | return SupplierOrderCancelResult.Cancelled($"заказ {string.Join(", ", cancelled)}: аннулирован"); |
| | 0 | 580 | | } |
| | | 581 | | |
| | 12 | 582 | | private ForumAutoDataManager ResolveClient(string providerKey) => providerKey switch |
| | 12 | 583 | | { |
| | 10 | 584 | | ApiSupplierSettings.ForumAutoProviderKey => _forumAuto, |
| | 0 | 585 | | ApiSupplierSettings.ForumAutoInterpartsProviderKey => _forumAutoInterparts, |
| | 0 | 586 | | ApiSupplierSettings.ForumAutoPiterProviderKey => _forumAutoPiter, |
| | 0 | 587 | | ApiSupplierSettings.ForumAutoRostovProviderKey => _forumAutoRostov, |
| | 2 | 588 | | ApiSupplierSettings.ForumAutoIstraProviderKey => _forumAutoIstra, |
| | 0 | 589 | | _ => throw new ArgumentException("Неизвестный аккаунт Форум-Авто.", nameof(providerKey)) |
| | 12 | 590 | | }; |
| | | 591 | | |
| | 8 | 592 | | private static bool IsProvider(string key) => key is ApiSupplierSettings.ForumAutoProviderKey or ApiSupplierSettings |
| | | 593 | | |
| | 14 | 594 | | private static string ProviderName(string key) => key switch |
| | 14 | 595 | | { |
| | 0 | 596 | | ApiSupplierSettings.ForumAutoInterpartsProviderKey => "Форум-Авто (Интерпартс)", |
| | 0 | 597 | | ApiSupplierSettings.ForumAutoPiterProviderKey => "Форум-Авто (Питер)", |
| | 0 | 598 | | ApiSupplierSettings.ForumAutoRostovProviderKey => "Форум-Авто (Ростов)", |
| | 3 | 599 | | ApiSupplierSettings.ForumAutoIstraProviderKey => "Форум-Авто (Истра)", |
| | 11 | 600 | | _ => "Форум-Авто (Союз)" |
| | 14 | 601 | | }; |
| | | 602 | | |
| | 10 | 603 | | private static string CartUrl(string providerKey) => providerKey switch |
| | 10 | 604 | | { |
| | 0 | 605 | | ApiSupplierSettings.ForumAutoInterpartsProviderKey => "/SupplierCart?provider=forumautointerparts&supplierTab=ba |
| | 0 | 606 | | ApiSupplierSettings.ForumAutoPiterProviderKey => "/SupplierCart?provider=forumautopiter&supplierTab=basket", |
| | 0 | 607 | | ApiSupplierSettings.ForumAutoRostovProviderKey => "/SupplierCart?provider=forumautorostov&supplierTab=basket", |
| | 2 | 608 | | ApiSupplierSettings.ForumAutoIstraProviderKey => "/SupplierCart?provider=forumautoistra&supplierTab=basket", |
| | 8 | 609 | | _ => "/SupplierCart?provider=forumauto&supplierTab=basket" |
| | 10 | 610 | | }; |
| | | 611 | | |
| | | 612 | | private static string? ValidateSelection(IReadOnlyCollection<long>? ids, out IReadOnlyCollection<long>? normalizedId |
| | | 613 | | { |
| | 18 | 614 | | if (ids is null) { normalizedIds = null; return null; } |
| | 0 | 615 | | normalizedIds = ids.Where(id => id > 0).Distinct().ToArray(); |
| | 0 | 616 | | return normalizedIds.Count == 0 ? "Выберите хотя бы одну позицию корзины." : null; |
| | | 617 | | } |
| | | 618 | | |
| | | 619 | | private static string Describe(ForumAutoCartQueueItem item) |
| | | 620 | | { |
| | 3 | 621 | | var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber; |
| | 3 | 622 | | var product = string.IsNullOrWhiteSpace(item.ProductName) ? item.Article : item.ProductName; |
| | 3 | 623 | | var client = string.IsNullOrWhiteSpace(item.ClientName) ? string.Empty : $", {item.ClientName}"; |
| | 3 | 624 | | return $"{item.Article}: заказ {order}, {product}{client}"; |
| | | 625 | | } |
| | | 626 | | |
| | 2 | 627 | | private static ForumAutoCartQueueItem ToQueueItem(ForumAutoCartItem item) => new( |
| | 2 | 628 | | item.StudioOrderId, item.ProviderKey, item.Tid, item.Article, item.Brand, item.ProductName, |
| | 2 | 629 | | item.StudioOrder?.ShipmentNumber, null, item.Quantity, item.SourcePrice, item.CurrencyCode, |
| | 2 | 630 | | item.DeliveryAtUtc?.ToString("O", CultureInfo.InvariantCulture)); |
| | | 631 | | |
| | 6 | 632 | | private static ForumAutoCartQueueItem ToQueueItem(string providerKey, ForumAutoPlacementLine line) => new( |
| | 6 | 633 | | line.StudioOrderId, providerKey, line.Tid, line.Article, line.Brand, line.ProductName, |
| | 6 | 634 | | line.ShipmentNumber, line.ClientName, line.Quantity, line.SourcePrice, line.CurrencyCode, null, |
| | 6 | 635 | | line.DeliveryDaysMin, line.DeliveryDaysMax, line.Direction); |
| | | 636 | | |
| | | 637 | | /// <summary>Пишет запись в orders_history в общей форме API-поставщиков. Ошибка журнала не должна |
| | | 638 | | /// ломать уже выполненное размещение — строка корзины к этому моменту уже сохранена.</summary> |
| | | 639 | | private async Task RecordHistoryAsync(int orderId, ForumAutoCartQueueItem item, bool success, string? error, |
| | | 640 | | string cartStatus, string? supplierOrderNumber, string changedBy, CancellationToken ct) |
| | | 641 | | { |
| | | 642 | | try |
| | | 643 | | { |
| | 10 | 644 | | var displayName = ProviderName(item.ProviderKey); |
| | 10 | 645 | | var entry = new ApiSupplierBasketHistoryEntry( |
| | 10 | 646 | | item.ProviderKey, displayName, LogoUrl, success, error, |
| | 10 | 647 | | ApiItemId: item.Tid, Article: item.Article, ProductName: item.ProductName ?? item.Brand, |
| | 10 | 648 | | Price: item.SourcePrice, CurrencyCode: item.CurrencyCode, Quantity: item.Quantity, |
| | 10 | 649 | | ShipmentNumber: item.ShipmentNumber, ClientName: item.ClientName, |
| | 10 | 650 | | CartStatus: cartStatus, SupplierOrderNumber: supplierOrderNumber, SupplierCartUrl: CartUrl(item.Provider |
| | | 651 | | |
| | 10 | 652 | | await _history.AddOrderHistory(new OrderHistory |
| | 10 | 653 | | { |
| | 10 | 654 | | OrderId = orderId, |
| | 10 | 655 | | ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | 10 | 656 | | ColumnDisplayName = $"Корзина {displayName}", |
| | 10 | 657 | | OldValue = null, |
| | 10 | 658 | | NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions), |
| | 10 | 659 | | ChangedAt = DateTime.Now, |
| | 10 | 660 | | ChangedBy = changedBy |
| | 10 | 661 | | }); |
| | 10 | 662 | | } |
| | 0 | 663 | | catch |
| | | 664 | | { |
| | | 665 | | // Не ломаем размещение в корзине из-за журнала истории. |
| | 0 | 666 | | } |
| | 10 | 667 | | } |
| | | 668 | | |
| | | 669 | | private static DateTime? ParseApiDate(string? value) => |
| | 2 | 670 | | DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compa |
| | 2 | 671 | | DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed) ? parse |
| | | 672 | | |
| | 9 | 673 | | private void InvalidateCount() => _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | | 674 | | } |