| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using System.Text.Json.Serialization; |
| | | 5 | | using Microsoft.EntityFrameworkCore; |
| | | 6 | | using Microsoft.Extensions.Caching.Memory; |
| | | 7 | | using Microsoft.Extensions.Logging; |
| | | 8 | | using OzonDomains.Models; |
| | | 9 | | using OzonDomains.Models.SupplierStatuses; |
| | | 10 | | using OzonRepositories.Context; |
| | | 11 | | using Services.ApiServices.ArmtekApi; |
| | | 12 | | using Services.ApiServices.ArmtekApi.Models.Response; |
| | | 13 | | using Services.ApiSupplierSettingsServices; |
| | | 14 | | using Services.DataServices; |
| | | 15 | | using Services.SupplierStatusServices; |
| | | 16 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart; |
| | | 17 | | |
| | | 18 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 19 | | |
| | | 20 | | public sealed record ArmtekCartQueueItem( |
| | | 21 | | int OrderId, string ProviderKey, string ArtId, string Pin, string Brand, string? Keyzak, |
| | | 22 | | string? WarehouseName, string? PartnerWarehouseCode, string? ProductName, string? ShipmentNumber, string? ClientName |
| | | 23 | | decimal? SourcePrice, string? CurrencyCode, string? DeliveryAtUtc, string? ZasylWarehouseName = null, |
| | | 24 | | int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, string? Direction = null); |
| | | 25 | | |
| | | 26 | | /// <summary>Шапка заказа Armtek: покупатель и адрес доставки. Позиции с разными парами уходят разными запросами.</summa |
| | | 27 | | public sealed record ArmtekDestination(string Kunrg, string Kunza); |
| | | 28 | | |
| | | 29 | | public sealed record ArmtekCartOperationResult( |
| | | 30 | | int Added, |
| | | 31 | | int Skipped, |
| | | 32 | | int Failed, |
| | | 33 | | IReadOnlyList<string> Messages, |
| | | 34 | | IReadOnlyCollection<string>? ProviderKeys = null) |
| | | 35 | | { |
| | | 36 | | public string ToHtmlBlock() |
| | | 37 | | { |
| | | 38 | | var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>"); |
| | | 39 | | var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty; |
| | | 40 | | var cartLinks = (ProviderKeys ?? Array.Empty<string>()) |
| | | 41 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | | 42 | | .Select(provider => provider switch |
| | | 43 | | { |
| | | 44 | | "Armtek" => "<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?provider=armtek&supplierT |
| | | 45 | | "ArmtekBy" => "<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?provider=armtekby&suppl |
| | | 46 | | _ => string.Empty |
| | | 47 | | }) |
| | | 48 | | .Where(link => !string.IsNullOrEmpty(link)); |
| | | 49 | | var verificationNotice = Added > 0 |
| | | 50 | | ? "<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'></ |
| | | 51 | | : string.Empty; |
| | | 52 | | return "<div class='alert alert-info border-primary-subtle mb-0'>" + |
| | | 53 | | "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина Армтек</div> |
| | | 54 | | $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b |
| | | 55 | | $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" + |
| | | 56 | | $"<div class='d-flex flex-wrap gap-2 mt-2'>{string.Join(string.Empty, cartLinks)}</div>{verificationNotic |
| | | 57 | | } |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | /// <summary>Безопасное для UI представление тела createOrder (без учётных данных).</summary> |
| | 0 | 61 | | public sealed record ArmtekOrderPreviewItem( |
| | 0 | 62 | | [property: JsonPropertyName("PIN")] string Pin, |
| | 0 | 63 | | [property: JsonPropertyName("BRAND")] string Brand, |
| | 0 | 64 | | [property: JsonPropertyName("KWMENG")] decimal Quantity, |
| | 0 | 65 | | [property: JsonPropertyName("KEYZAK")] string? Keyzak, |
| | 0 | 66 | | [property: JsonPropertyName("PRICEMAX")] decimal? PriceMax, |
| | 0 | 67 | | [property: JsonPropertyName("DATEMAX")] string? DateMax); |
| | | 68 | | public sealed record ArmtekOrderPreview( |
| | | 69 | | [property: JsonPropertyName("providerKey")] string ProviderKey, |
| | | 70 | | [property: JsonPropertyName("VKORG")] string Vkorg, |
| | | 71 | | [property: JsonPropertyName("KUNRG")] string Kunrg, |
| | | 72 | | [property: JsonPropertyName("KUNZA")] string Kunza, |
| | | 73 | | [property: JsonPropertyName("INCOTERMS")] string Incoterms, |
| | | 74 | | [property: JsonPropertyName("DBTYP")] string Dbtyp, |
| | | 75 | | [property: JsonPropertyName("ITEMS")] IReadOnlyCollection<ArmtekOrderPreviewItem> Items); |
| | | 76 | | public sealed record ArmtekDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages); |
| | | 77 | | |
| | | 78 | | /// <summary>Актуальное предложение из повторной проценки Armtek для замены строки корзины.</summary> |
| | | 79 | | public sealed record ArmtekCartRequoteOffer(string Keyzak, string ArtId, string WarehouseCode, string WarehouseName, str |
| | | 80 | | decimal Price, string CurrencyCode, decimal? AvailableQuantity, DateTime? DeliveryAtUtc, bool IsOrderable); |
| | | 81 | | public sealed record ArmtekCartRequoteResult(string OriginalWarehouse, string DeliveryAddress, IReadOnlyList<ArmtekCartR |
| | | 82 | | |
| | | 83 | | /// <summary>Локальная корзина Armtek. В API Armtek корзины нет: только test-order и real order.</summary> |
| | | 84 | | public sealed class ArmtekCartService |
| | | 85 | | { |
| | | 86 | | private static readonly SemaphoreSlim RefreshTestConcurrency = new(3, 3); |
| | | 87 | | |
| | | 88 | | /// <summary> |
| | | 89 | | /// Срок, в течение которого строка ArmtekOrderItems без записи в отслеживании статусов считается активным заказом. |
| | | 90 | | /// Старше — это заказы до появления отслеживания, их повторное оформление не блокируем. |
| | | 91 | | /// </summary> |
| | | 92 | | private static readonly TimeSpan UntrackedOrderWindow = TimeSpan.FromDays(7); |
| | | 93 | | private readonly ApplicationDbContext _db; |
| | | 94 | | private readonly ArmtekDataManager _armtek; |
| | | 95 | | private readonly ArmtekByDataManager _armtekBy; |
| | | 96 | | private readonly IOrderHistoryDataService _history; |
| | | 97 | | private readonly IMemoryCache _cache; |
| | | 98 | | private readonly ArmtekDeliveryAddressMappingService _deliveryAddressMappings; |
| | | 99 | | private readonly IOrderSupplierStateService _supplierState; |
| | | 100 | | private readonly ILogger<ArmtekCartService> _logger; |
| | | 101 | | private readonly ISupplierOrderGuard? _guard; |
| | | 102 | | |
| | | 103 | | public ArmtekCartService(ApplicationDbContext db, ArmtekDataManager armtek, ArmtekByDataManager armtekBy, IOrderHist |
| | | 104 | | ISupplierOrderGuard? guard = null) |
| | | 105 | | { |
| | | 106 | | _guard = guard; |
| | | 107 | | _db = db; |
| | | 108 | | _armtek = armtek; |
| | | 109 | | _armtekBy = armtekBy; |
| | | 110 | | _history = history; |
| | | 111 | | _cache = cache; |
| | | 112 | | _deliveryAddressMappings = deliveryAddressMappings; |
| | | 113 | | _supplierState = supplierState; |
| | | 114 | | _logger = logger; |
| | | 115 | | } |
| | | 116 | | |
| | | 117 | | /// <summary> |
| | | 118 | | /// Отражает состояние строки корзины в отслеживании статусов поставщика. |
| | | 119 | | /// Промежуточные переходы внутри корзины (Ready/Processing/Failed) отдельно не пишем: |
| | | 120 | | /// их подхватывает LocalCartStatusReader, читая Status строки напрямую. Здесь фиксируем |
| | | 121 | | /// только появление строки, оформление и уход из корзины. Сбой журнала не ломает корзину. |
| | | 122 | | /// </summary> |
| | | 123 | | private async Task TrackCartAsync(int orderId, string providerKey, string cartStatusCode, long cartItemId, string? u |
| | | 124 | | int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null) |
| | | 125 | | { |
| | | 126 | | try |
| | | 127 | | { |
| | | 128 | | await _supplierState.MarkInCartAsync(orderId, providerKey, cartStatusCode, cartItemId, user: user, |
| | | 129 | | deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct); |
| | | 130 | | } |
| | | 131 | | catch (Exception ex) |
| | | 132 | | { |
| | | 133 | | _logger.LogWarning(ex, "{Provider}: не удалось записать состояние корзины для заказа {OrderId}", providerKey |
| | | 134 | | } |
| | | 135 | | } |
| | | 136 | | |
| | | 137 | | private async Task TrackOrderedAsync(int orderId, string providerKey, string? orderNumbers, string? user, Cancellati |
| | | 138 | | int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null) |
| | | 139 | | { |
| | | 140 | | try |
| | | 141 | | { |
| | | 142 | | await _supplierState.MarkOrderedAsync(orderId, providerKey, orderNumbers, user: user, |
| | | 143 | | deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct); |
| | | 144 | | } |
| | | 145 | | catch (Exception ex) |
| | | 146 | | { |
| | | 147 | | _logger.LogWarning(ex, "{Provider}: не удалось привязать заказ {OrderId} к заказу поставщика", providerKey, |
| | | 148 | | } |
| | | 149 | | } |
| | | 150 | | |
| | | 151 | | private async Task TrackRemovedAsync(int orderId, string providerKey, string? user, CancellationToken ct) |
| | | 152 | | { |
| | | 153 | | try |
| | | 154 | | { |
| | | 155 | | await _supplierState.RemoveFromCartAsync(orderId, providerKey, user, ct); |
| | | 156 | | } |
| | | 157 | | catch (Exception ex) |
| | | 158 | | { |
| | | 159 | | _logger.LogWarning(ex, "{Provider}: не удалось закрыть состояние корзины для заказа {OrderId}", providerKey, |
| | | 160 | | } |
| | | 161 | | } |
| | | 162 | | |
| | | 163 | | public async Task<ArmtekCartOperationResult> QueueAsync(IReadOnlyCollection<ArmtekCartQueueItem> queue, string chang |
| | | 164 | | { |
| | | 165 | | var messages = new List<string>(); |
| | | 166 | | var added = 0; var skipped = 0; var failed = 0; |
| | | 167 | | foreach (var item in queue) |
| | | 168 | | { |
| | | 169 | | if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 || string.IsNullOrWhiteSpace(it |
| | | 170 | | { |
| | | 171 | | failed++; |
| | | 172 | | const string incompleteError = "Неполные данные предложения Armtek."; |
| | | 173 | | messages.Add($"{Describe(item)} — неполные данные предложения Armtek."); |
| | | 174 | | if (IsProvider(item.ProviderKey) && item.OrderId > 0) |
| | | 175 | | await RecordHistoryAsync(item.OrderId, item, false, incompleteError, ApiSupplierBasketCartStatus.Fai |
| | | 176 | | supplierOrderNumber: null, buyerKunnr: null, deliveryAddressName: null, changedBy, ct); |
| | | 177 | | continue; |
| | | 178 | | } |
| | | 179 | | var exists = await _db.ArmtekCartItems.AnyAsync(x => x.ProviderKey == item.ProviderKey && x.StudioOrderId == |
| | | 180 | | if (exists) { skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}.") |
| | | 181 | | |
| | | 182 | | // Клиент и адрес доставки берём той же связью «склад отгрузки → адрес Armtek», что и проценка, |
| | | 183 | | // и фиксируем в строке: поздние правки справочника не должны менять уже собранную корзину. |
| | | 184 | | var deliveryMapping = await _deliveryAddressMappings.ResolveAsync(item.ProviderKey, item.OrderId, item.Zasyl |
| | | 185 | | var cartItem = new ArmtekCartItem |
| | | 186 | | { |
| | | 187 | | ProviderKey = item.ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, ArtId |
| | | 188 | | Brand = item.Brand, Keyzak = item.Keyzak, WarehouseName = item.WarehouseName, |
| | | 189 | | PartnerWarehouseCode = item.PartnerWarehouseCode, ProductName = item.ProductName, |
| | | 190 | | BuyerKunnr = deliveryMapping?.BuyerKunnr, DeliveryAddressKunnr = deliveryMapping?.DeliveryAddressKunnr, |
| | | 191 | | Quantity = item.Quantity, SourcePrice = item.SourcePrice, CurrencyCode = item.CurrencyCode ?? "RUB", |
| | | 192 | | DeliveryAtUtc = ParseApiDate(item.DeliveryAtUtc), CreatedBy = changedBy |
| | | 193 | | }; |
| | | 194 | | _db.ArmtekCartItems.Add(cartItem); |
| | | 195 | | try |
| | | 196 | | { |
| | | 197 | | await _db.SaveChangesAsync(ct); |
| | | 198 | | } |
| | | 199 | | catch (DbUpdateException) |
| | | 200 | | { |
| | | 201 | | _db.Entry(cartItem).State = EntityState.Detached; |
| | | 202 | | skipped++; messages.Add($"{Describe(item)} — уже в корзине {ProviderName(item.ProviderKey)}."); |
| | | 203 | | continue; |
| | | 204 | | } |
| | | 205 | | added++; |
| | | 206 | | messages.Add(deliveryMapping is null |
| | | 207 | | ? $"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}, но склад отгрузки не связан |
| | | 208 | | : $"{Describe(item)} — добавлено в корзину {ProviderName(item.ProviderKey)}; требуется проверка перед оф |
| | | 209 | | await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending, |
| | | 210 | | supplierOrderNumber: null, buyerKunnr: deliveryMapping?.BuyerKunnr, |
| | | 211 | | deliveryAddressName: deliveryMapping?.DeliveryAddressKunnr, changedBy, ct); |
| | | 212 | | await TrackCartAsync(item.OrderId, item.ProviderKey, ArmtekCartItemStatus.Pending, cartItem.Id, changedBy, c |
| | | 213 | | item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction); |
| | | 214 | | } |
| | | 215 | | InvalidateCount(); |
| | | 216 | | var providerKeys = queue |
| | | 217 | | .Where(item => IsProvider(item.ProviderKey)) |
| | | 218 | | .Select(item => item.ProviderKey) |
| | | 219 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | | 220 | | .ToArray(); |
| | | 221 | | return new ArmtekCartOperationResult(added, skipped, failed, messages, providerKeys); |
| | | 222 | | } |
| | | 223 | | |
| | | 224 | | public Task<List<ArmtekCartItem>> GetActiveAsync(string providerKey, CancellationToken ct = default) => |
| | | 225 | | _db.ArmtekCartItems.AsNoTracking().Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo) |
| | | 226 | | .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer) |
| | | 227 | | .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient) |
| | | 228 | | .Where(x => x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(x.Status)) |
| | | 229 | | .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct); |
| | | 230 | | |
| | | 231 | | /// <summary>Повторная проценка позиции для зафиксированных клиента и адреса доставки, без привязки к старому складу |
| | | 232 | | public async Task<ArmtekCartRequoteResult> RequoteAsync(string providerKey, long cartItemId, CancellationToken ct = |
| | | 233 | | { |
| | | 234 | | var item = await _db.ArmtekCartItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cartItemId && x.ProviderKey |
| | | 235 | | ?? throw new InvalidOperationException("Позиция корзины не найдена или уже оформляется."); |
| | | 236 | | if (string.IsNullOrWhiteSpace(item.BuyerKunnr) || string.IsNullOrWhiteSpace(item.DeliveryAddressKunnr)) |
| | | 237 | | throw new InvalidOperationException("Сначала выберите клиента и адрес доставки Armtek для этой позиции."); |
| | | 238 | | |
| | | 239 | | var (client, vkorg) = ResolveClient(providerKey); |
| | | 240 | | var response = await client.SearchAsync(item.BuyerKunnr, NormalizePin(item.Pin), item.Brand.Trim(), vkorg, |
| | | 241 | | queryType: 1, kunnrZa: item.DeliveryAddressKunnr, incotermsSelfPickup: false, ct: ct); |
| | | 242 | | var error = BuildError(response); |
| | | 243 | | if (!string.IsNullOrWhiteSpace(error)) throw new ArmtekApiException(error); |
| | | 244 | | |
| | | 245 | | var stores = await client.GetStoreListAsync(vkorg, ct); |
| | | 246 | | var storeNames = (stores.Resp ?? []).Where(x => !string.IsNullOrWhiteSpace(x.SklCode)) |
| | | 247 | | .GroupBy(x => x.SklCode, StringComparer.Ordinal).ToDictionary(x => x.Key, x => x.First().SklName, StringComp |
| | | 248 | | |
| | | 249 | | var rawOffers = (response.Resp ?? []) |
| | | 250 | | .Where(x => string.Equals(NormalizePin(x.Pin), NormalizePin(item.Pin), StringComparison.OrdinalIgnoreCase) |
| | | 251 | | && string.Equals(x.Brand, item.Brand, StringComparison.OrdinalIgnoreCase)) |
| | | 252 | | .Select(x => new { Offer = x, Price = ParseDecimal(x.Price) }) |
| | | 253 | | .Where(x => x.Price is not null && HasEnoughStock(x.Offer.Rvalue, item.Quantity) && !string.IsNullOrWhiteSpa |
| | | 254 | | .OrderBy(x => x.Price) |
| | | 255 | | .ThenBy(x => ParseApiDate(x.Offer.DeliveryDate) ?? DateTime.MaxValue) |
| | | 256 | | .Take(8) |
| | | 257 | | .Select(x => new { x.Offer, Price = x.Price!.Value }) |
| | | 258 | | .ToArray(); |
| | | 259 | | var destination = new ArmtekDestination(item.BuyerKunnr, item.DeliveryAddressKunnr); |
| | | 260 | | var confirmed = await Task.WhenAll(rawOffers.Select(async x => new |
| | | 261 | | { |
| | | 262 | | x.Offer, |
| | | 263 | | x.Price, |
| | | 264 | | IsOrderable = await CheckRefreshedOfferAsync(client, vkorg, destination, item, x.Offer, ct) is not null |
| | | 265 | | })); |
| | | 266 | | var offers = confirmed |
| | | 267 | | .Select(x => new ArmtekCartRequoteOffer(x.Offer.Keyzak, x.Offer.ArtId, x.Offer.PartnerWarehouseCode, |
| | | 268 | | storeNames.GetValueOrDefault(x.Offer.PartnerWarehouseCode, "Склад Armtek"), x.Offer.Name, |
| | | 269 | | x.Price, string.IsNullOrWhiteSpace(x.Offer.Currency) ? item.CurrencyCode : x.Offer.Currency, |
| | | 270 | | ParseStock(x.Offer.Rvalue), ParseApiDate(x.Offer.DeliveryDate), x.IsOrderable)) |
| | | 271 | | .ToArray(); |
| | | 272 | | return new ArmtekCartRequoteResult( |
| | | 273 | | string.IsNullOrWhiteSpace(item.WarehouseName) ? item.PartnerWarehouseCode ?? "—" : $"{item.WarehouseName} ({ |
| | | 274 | | item.DeliveryAddressName ?? item.DeliveryAddressKunnr ?? "—", offers); |
| | | 275 | | } |
| | | 276 | | |
| | | 277 | | /// <summary>Заменяет отклонённое предложение на выбранный вариант свежей проценки и требует новую проверку.</summar |
| | | 278 | | public async Task<bool> ReplaceWithRequotedOfferAsync(string providerKey, long cartItemId, string keyzak, Cancellati |
| | | 279 | | { |
| | | 280 | | var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId && x.ProviderKey == providerKey |
| | | 281 | | if (item is null || item.Status == ArmtekCartItemStatus.Processing) return false; |
| | | 282 | | var requote = await RequoteAsync(providerKey, cartItemId, ct); |
| | | 283 | | var offer = requote.Offers.FirstOrDefault(x => string.Equals(x.Keyzak, keyzak, StringComparison.Ordinal) && x.Is |
| | | 284 | | if (offer is null) throw new InvalidOperationException("Выбранное предложение уже не актуально. Выполните процен |
| | | 285 | | |
| | | 286 | | item.ArtId = offer.ArtId; |
| | | 287 | | item.Keyzak = offer.Keyzak; |
| | | 288 | | item.PartnerWarehouseCode = offer.WarehouseCode; |
| | | 289 | | item.ProductName = string.IsNullOrWhiteSpace(offer.Name) ? item.ProductName : offer.Name; |
| | | 290 | | item.SourcePrice = offer.Price; |
| | | 291 | | item.CurrencyCode = offer.CurrencyCode; |
| | | 292 | | item.DeliveryAtUtc = offer.DeliveryAtUtc; |
| | | 293 | | item.Status = ArmtekCartItemStatus.Pending; |
| | | 294 | | item.TestedAtUtc = null; |
| | | 295 | | item.TestResultJson = null; |
| | | 296 | | item.LastError = "Выбрано актуальное предложение из новой проценки. Перед оформлением выполните проверку."; |
| | | 297 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 298 | | await _db.SaveChangesAsync(ct); |
| | | 299 | | return true; |
| | | 300 | | } |
| | | 301 | | |
| | | 302 | | public Task<List<ArmtekOrder>> GetOrdersAsync(string providerKey, CancellationToken ct = default) => |
| | | 303 | | _db.ArmtekOrders.AsNoTracking().Include(x => x.Items).ThenInclude(x => x.StudioOrder) |
| | | 304 | | .Where(x => x.ProviderKey == providerKey).OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct); |
| | | 305 | | |
| | | 306 | | /// <summary>Строит фактические параметры прямого оформления по актуальной связи склада и адреса Armtek.</summary> |
| | | 307 | | public async Task<(IReadOnlyList<ArmtekOrderPreview> Previews, IReadOnlyList<string> Errors)> BuildDirectOrderPrevie |
| | | 308 | | IReadOnlyCollection<ArmtekCartQueueItem> queue, CancellationToken ct = default) |
| | | 309 | | { |
| | | 310 | | var prepared = await PrepareDirectOrderItemsAsync(queue, ct); |
| | | 311 | | if (prepared.Errors.Count > 0) return ([], prepared.Errors); |
| | | 312 | | return (prepared.Items.GroupBy(x => new { x.Item.ProviderKey, x.Vkorg, x.Destination }) |
| | | 313 | | .Select(group => new ArmtekOrderPreview(group.Key.ProviderKey, group.Key.Vkorg, group.Key.Destination.Kunrg, |
| | | 314 | | group.Key.Destination.Kunza, "0", "3", group.Select(x => new ArmtekOrderPreviewItem(x.Item.Pin, x.Item.B |
| | | 315 | | x.Item.Quantity, x.Item.Keyzak, x.Item.SourcePrice, FormatDateMax(x.Item.DeliveryAtUtc))).ToArray()) |
| | | 316 | | .ToArray(), []); |
| | | 317 | | } |
| | | 318 | | |
| | | 319 | | /// <summary> |
| | | 320 | | /// Оформляет позиции без локальной корзины. Вызывается до проведения статусов Studio2. |
| | | 321 | | /// Позиции, по которым у Armtek уже есть активный заказ, повторно не отправляются: повтор запроса |
| | | 322 | | /// (перезагрузка страницы, двойной клик) иначе создал бы у поставщика второй заказ с теми же позициями. |
| | | 323 | | /// </summary> |
| | | 324 | | /// <param name="confirmedReorderOrderIds">Заказы Studio2, по которым пользователь явно подтвердил повторный заказ |
| | | 325 | | /// (см. <see cref="ISupplierOrderGuard"/>): для них защита от дубля не срабатывает. Фоновые вызовы передают <c>null |
| | | 326 | | public async Task<ArmtekDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ArmtekCartQueueItem> queue, |
| | | 327 | | string changedBy, CancellationToken ct = default, IReadOnlySet<int>? confirmedReorderOrderIds = null) |
| | | 328 | | { |
| | | 329 | | var alreadyOrdered = await FindActiveSupplierOrdersAsync(queue, ct); |
| | | 330 | | if (confirmedReorderOrderIds is { Count: > 0 }) |
| | | 331 | | { |
| | | 332 | | foreach (var key in alreadyOrdered.Keys.Where(x => confirmedReorderOrderIds.Contains(x.OrderId)).ToList()) |
| | | 333 | | alreadyOrdered.Remove(key); |
| | | 334 | | } |
| | | 335 | | var messages = new List<string>(); |
| | | 336 | | if (alreadyOrdered.Count > 0) |
| | | 337 | | { |
| | | 338 | | messages.AddRange(queue |
| | | 339 | | .Where(x => alreadyOrdered.ContainsKey((x.ProviderKey, x.OrderId))) |
| | | 340 | | .Select(x => $"{x.Pin}: заказ Армтек {alreadyOrdered[(x.ProviderKey, x.OrderId)]} уже оформлен ранее — п |
| | | 341 | | "Если заказ нужно оформить заново, сначала отмените его у поставщика.")); |
| | | 342 | | queue = queue.Where(x => !alreadyOrdered.ContainsKey((x.ProviderKey, x.OrderId))).ToArray(); |
| | | 343 | | if (queue.Count == 0) return new(true, messages); |
| | | 344 | | } |
| | | 345 | | |
| | | 346 | | var prepared = await PrepareDirectOrderItemsAsync(queue, ct); |
| | | 347 | | if (prepared.Errors.Count > 0) return new(false, prepared.Errors); |
| | | 348 | | |
| | | 349 | | var groups = prepared.Items.GroupBy(x => new { x.Item.ProviderKey, x.Vkorg, x.Destination }).ToArray(); |
| | | 350 | | foreach (var group in groups) |
| | | 351 | | { |
| | | 352 | | var requestItems = group.Select(x => ToRequestItem(x.Item)).ToArray(); |
| | | 353 | | var response = await ResolveClient(group.Key.ProviderKey).Client.CreateTestOrderAsync(group.Key.Destination. |
| | | 354 | | requestItems, group.Key.Vkorg, group.Key.Destination.Kunza, false, ct); |
| | | 355 | | LogApiExchange(group.Key.ProviderKey, "createTestOrder/direct", group.Key.Vkorg, group.Key.Destination, requ |
| | | 356 | | var error = BuildError(response); |
| | | 357 | | foreach (var (preparedItem, index) in group.Select((item, index) => (item, index))) |
| | | 358 | | { |
| | | 359 | | error ??= GetItemError(FindResponseItem(response, preparedItem.Item, index)); |
| | | 360 | | if (!string.IsNullOrWhiteSpace(error)) |
| | | 361 | | return new(false, [$"{preparedItem.Item.Pin}: {error}"]); |
| | | 362 | | } |
| | | 363 | | } |
| | | 364 | | |
| | | 365 | | foreach (var group in groups) |
| | | 366 | | { |
| | | 367 | | var requestItems = group.Select(x => ToRequestItem(x.Item)).ToArray(); |
| | | 368 | | var response = await ResolveClient(group.Key.ProviderKey).Client.CreateOrderAsync(group.Key.Destination.Kunr |
| | | 369 | | requestItems, group.Key.Vkorg, group.Key.Destination.Kunza, false, ct, |
| | | 370 | | OrderComment(group.Select(x => x.Item.OrderId))); |
| | | 371 | | LogApiExchange(group.Key.ProviderKey, "createOrder/direct", group.Key.Vkorg, group.Key.Destination, requestI |
| | | 372 | | var responseError = BuildError(response); |
| | | 373 | | if (!string.IsNullOrWhiteSpace(responseError)) return new(false, [responseError]); |
| | | 374 | | |
| | | 375 | | // Заказ у Armtek уже создан: отмена запроса не должна оборвать запись его номера в БД, |
| | | 376 | | // иначе повтор не увидит первый заказ и оформит второй. |
| | | 377 | | var persistCt = CancellationToken.None; |
| | | 378 | | var requestJson = JsonSerializer.Serialize(requestItems); |
| | | 379 | | var responseJson = JsonSerializer.Serialize(response); |
| | | 380 | | foreach (var (preparedItem, index) in group.Select((item, index) => (item, index))) |
| | | 381 | | { |
| | | 382 | | var apiItem = FindResponseItem(response, preparedItem.Item, index); |
| | | 383 | | var itemError = GetItemError(apiItem); |
| | | 384 | | var allocations = apiItem?.Result.Where(x => !string.IsNullOrWhiteSpace(x.Vbeln) && !HasApiItemError(x.E |
| | | 385 | | if (!string.IsNullOrWhiteSpace(itemError) || allocations.Count == 0) |
| | | 386 | | return new(false, [$"{preparedItem.Item.Pin}: {itemError ?? "Армтек не вернул номер созданного заказ |
| | | 387 | | |
| | | 388 | | foreach (var allocation in allocations) |
| | | 389 | | { |
| | | 390 | | var order = await GetOrCreateOrderAsync(preparedItem.Item.ProviderKey, group.Key.Vkorg, group.Key.De |
| | | 391 | | allocation.Vbeln!, changedBy, requestJson, responseJson, persistCt); |
| | | 392 | | _db.ArmtekOrderItems.Add(new OzonDomains.Models.ArmtekOrderItem |
| | | 393 | | { |
| | | 394 | | ArmtekOrderId = order.Id, StudioOrderId = preparedItem.Item.OrderId, Posnr = allocation.Posnr, |
| | | 395 | | Pin = preparedItem.Item.Pin, Brand = preparedItem.Item.Brand, |
| | | 396 | | Quantity = ParseDecimal(allocation.Quantity) ?? preparedItem.Item.Quantity, |
| | | 397 | | Price = ParseDecimal(allocation.Price), CurrencyCode = allocation.Currency, |
| | | 398 | | DeliveryAtUtc = ParseDate(allocation.DeliveryDate), Error = allocation.Error |
| | | 399 | | }); |
| | | 400 | | } |
| | | 401 | | var supplierOrderNumbers = string.Join(", ", allocations.Select(x => x.Vbeln).Distinct()); |
| | | 402 | | // Прямое оформление обходит локальную корзину, поэтому фиксируем состояние |
| | | 403 | | // сразу здесь, а не ждём фонового опроса поставщика. |
| | | 404 | | await TrackOrderedAsync(preparedItem.Item.OrderId, preparedItem.Item.ProviderKey, |
| | | 405 | | supplierOrderNumbers, changedBy, persistCt, |
| | | 406 | | preparedItem.Item.DeliveryDaysMin, preparedItem.Item.DeliveryDaysMax, preparedItem.Item.Direction); |
| | | 407 | | messages.Add($"{preparedItem.Item.Pin}: заказ Армтек {supplierOrderNumbers} создан."); |
| | | 408 | | } |
| | | 409 | | await _db.SaveChangesAsync(persistCt); |
| | | 410 | | } |
| | | 411 | | return new(true, messages); |
| | | 412 | | } |
| | | 413 | | |
| | | 414 | | public async Task<ArmtekCartOperationResult> TestAsync(string providerKey, string changedBy, IReadOnlyCollection<lon |
| | | 415 | | { |
| | | 416 | | var selectionError = ValidateSelection(cartItemIds, out var selectedIds); |
| | | 417 | | if (selectionError is not null) return new(0, 0, 0, [selectionError]); |
| | | 418 | | var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct); |
| | | 419 | | if (selectedIds is not null && items.Count != selectedIds.Count) |
| | | 420 | | return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите проверку."]); |
| | | 421 | | if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]); |
| | | 422 | | var destinationError = BuildDestinationError(items); |
| | | 423 | | if (destinationError is not null) return new(0, 0, items.Count, [destinationError]); |
| | | 424 | | |
| | | 425 | | var (client, vkorg) = ResolveClient(providerKey); |
| | | 426 | | var ready = 0; |
| | | 427 | | var failed = 0; |
| | | 428 | | var messages = new List<string>(); |
| | | 429 | | // KUNRG/KUNZA — параметры шапки заказа, поэтому на каждый адрес доставки уходит отдельный запрос. |
| | | 430 | | foreach (var group in GroupByDestination(items)) |
| | | 431 | | { |
| | | 432 | | var groupItems = group.ToList(); |
| | | 433 | | var requestItems = ToRequestItems(groupItems); |
| | | 434 | | var response = await client.CreateTestOrderAsync(group.Key.Kunrg, requestItems, vkorg, group.Key.Kunza, fals |
| | | 435 | | LogApiExchange(providerKey, "createTestOrder", vkorg, group.Key, requestItems, response); |
| | | 436 | | var responseError = BuildError(response); |
| | | 437 | | var resultJson = JsonSerializer.Serialize(response); |
| | | 438 | | foreach (var (item, index) in groupItems.Select((item, index) => (item, index))) |
| | | 439 | | { |
| | | 440 | | var apiItem = FindResponseItem(response, item, index); |
| | | 441 | | var error = responseError ?? GetItemError(apiItem); |
| | | 442 | | item.TestedAtUtc = DateTime.UtcNow; item.TestResultJson = resultJson; item.UpdatedAtUtc = DateTime.UtcNo |
| | | 443 | | if (string.IsNullOrWhiteSpace(error)) |
| | | 444 | | { |
| | | 445 | | item.Status = ArmtekCartItemStatus.Ready; |
| | | 446 | | item.LastError = null; |
| | | 447 | | ready++; |
| | | 448 | | messages.Add($"{item.Pin}: проверено у поставщика ({DescribeDestination(item)}). Можно оформлять зак |
| | | 449 | | } |
| | | 450 | | else |
| | | 451 | | { |
| | | 452 | | var refresh = await TryRefreshOfferAsync(client, vkorg, group.Key, item, ct); |
| | | 453 | | if (refresh is not null) |
| | | 454 | | { |
| | | 455 | | var refreshMessage = ApplyRefreshedOffer(item, refresh.Offer, error); |
| | | 456 | | item.Status = ArmtekCartItemStatus.Ready; |
| | | 457 | | item.TestedAtUtc = DateTime.UtcNow; |
| | | 458 | | item.TestResultJson = JsonSerializer.Serialize(refresh.TestResponse); |
| | | 459 | | item.LastError = refreshMessage; |
| | | 460 | | ready++; |
| | | 461 | | messages.Add($"{item.Pin}: {refreshMessage}"); |
| | | 462 | | await RecordHistoryAsync(item.StudioOrderId, ToQueueItem(item), true, refreshMessage, |
| | | 463 | | ApiSupplierBasketCartStatus.Ready, supplierOrderNumber: null, item.BuyerKunnr, |
| | | 464 | | item.DeliveryAddressName ?? item.DeliveryAddressKunnr, changedBy, ct); |
| | | 465 | | continue; |
| | | 466 | | } |
| | | 467 | | |
| | | 468 | | item.Status = ArmtekCartItemStatus.Failed; |
| | | 469 | | item.LastError = $"{error} для Арт. {item.Pin} · {item.Brand}."; |
| | | 470 | | failed++; |
| | | 471 | | messages.Add(item.LastError); |
| | | 472 | | } |
| | | 473 | | } |
| | | 474 | | } |
| | | 475 | | await _db.SaveChangesAsync(ct); |
| | | 476 | | return new(ready, 0, failed, messages); |
| | | 477 | | } |
| | | 478 | | |
| | | 479 | | /// <summary>Меняет адрес доставки строки корзины: связь склада могла отсутствовать или устареть.</summary> |
| | | 480 | | public async Task<bool> UpdateDeliveryAddressAsync(long id, string providerKey, string buyerKunnr, string deliveryAd |
| | | 481 | | string? deliveryAddressName, CancellationToken ct = default) |
| | | 482 | | { |
| | | 483 | | if (!IsProvider(providerKey) || string.IsNullOrWhiteSpace(buyerKunnr) || string.IsNullOrWhiteSpace(deliveryAddre |
| | | 484 | | return false; |
| | | 485 | | var item = await _db.ArmtekCartItems.FirstOrDefaultAsync( |
| | | 486 | | x => x.Id == id && x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains(x.Status), ct); |
| | | 487 | | if (item is null || item.Status == ArmtekCartItemStatus.Processing) return false; |
| | | 488 | | |
| | | 489 | | item.BuyerKunnr = buyerKunnr.Trim(); |
| | | 490 | | item.DeliveryAddressKunnr = deliveryAddressKunnr.Trim(); |
| | | 491 | | item.DeliveryAddressName = string.IsNullOrWhiteSpace(deliveryAddressName) ? null : deliveryAddressName.Trim(); |
| | | 492 | | // Адрес влияет на цену и склад: подтверждение Armtek больше не действительно. |
| | | 493 | | item.Status = ArmtekCartItemStatus.Pending; |
| | | 494 | | item.TestedAtUtc = null; |
| | | 495 | | item.TestResultJson = null; |
| | | 496 | | item.LastError = null; |
| | | 497 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 498 | | await _db.SaveChangesAsync(ct); |
| | | 499 | | return true; |
| | | 500 | | } |
| | | 501 | | |
| | | 502 | | public async Task<ArmtekCartOperationResult> CheckoutAsync(string providerKey, string changedBy, IReadOnlyCollection |
| | | 503 | | { |
| | | 504 | | var selectionError = ValidateSelection(cartItemIds, out var selectedIds); |
| | | 505 | | if (selectionError is not null) return new(0, 0, 0, [selectionError]); |
| | | 506 | | var items = await GetTrackedActiveAsync(providerKey, selectedIds, ct); |
| | | 507 | | if (selectedIds is not null && items.Count != selectedIds.Count) |
| | | 508 | | return new(0, 0, selectedIds.Count, ["Состав корзины изменился. Обновите страницу и повторите оформление."]) |
| | | 509 | | if (items.Count == 0) return new(0, 0, 0, ["Корзина пуста."]); |
| | | 510 | | if (items.Any(x => x.Status != ArmtekCartItemStatus.Ready || x.TestedAtUtc < DateTime.UtcNow.AddMinutes(-5))) |
| | | 511 | | return new(0, 0, items.Count, ["Перед оформлением выполните свежую проверку Armtek."]); |
| | | 512 | | var destinationError = BuildDestinationError(items); |
| | | 513 | | if (destinationError is not null) return new(0, 0, items.Count, [destinationError]); |
| | | 514 | | |
| | | 515 | | var ids = items.Select(x => x.Id).ToArray(); |
| | | 516 | | var locked = await _db.ArmtekCartItems.Where(x => ids.Contains(x.Id) && x.Status == ArmtekCartItemStatus.Ready) |
| | | 517 | | .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ArmtekCartItemStatus.Processing).SetProperty(x => x.Up |
| | | 518 | | if (locked != items.Count) return new(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]); |
| | | 519 | | |
| | | 520 | | try |
| | | 521 | | { |
| | | 522 | | var (client, vkorg) = ResolveClient(providerKey); |
| | | 523 | | var success = 0; var failed = 0; var messages = new List<string>(); |
| | | 524 | | foreach (var group in GroupByDestination(items)) |
| | | 525 | | { |
| | | 526 | | var groupItems = group.ToList(); |
| | | 527 | | var requestItems = ToRequestItems(groupItems); |
| | | 528 | | var response = await client.CreateOrderAsync(group.Key.Kunrg, requestItems, vkorg, group.Key.Kunza, fals |
| | | 529 | | OrderComment(groupItems.Select(x => x.StudioOrderId))); |
| | | 530 | | LogApiExchange(providerKey, "createOrder", vkorg, group.Key, requestItems, response); |
| | | 531 | | var responseJson = JsonSerializer.Serialize(response); |
| | | 532 | | var requestJson = JsonSerializer.Serialize(requestItems); |
| | | 533 | | var apiError = BuildError(response); |
| | | 534 | | for (var index = 0; index < groupItems.Count; index++) |
| | | 535 | | { |
| | | 536 | | var cartItem = groupItems[index]; |
| | | 537 | | var apiItem = FindResponseItem(response, cartItem, index); |
| | | 538 | | var allocations = apiItem?.Result.Where(x => !string.IsNullOrWhiteSpace(x.Vbeln) && !HasApiItemError |
| | | 539 | | var itemError = apiError ?? GetItemError(apiItem); |
| | | 540 | | if (allocations.Count == 0 || !string.IsNullOrWhiteSpace(itemError)) |
| | | 541 | | { |
| | | 542 | | cartItem.Status = ArmtekCartItemStatus.Failed; |
| | | 543 | | cartItem.LastError = itemError ?? "Армтек не вернул номер созданного заказа."; |
| | | 544 | | failed++; messages.Add($"{cartItem.Pin}: {cartItem.LastError}"); |
| | | 545 | | continue; |
| | | 546 | | } |
| | | 547 | | foreach (var allocation in allocations) |
| | | 548 | | { |
| | | 549 | | var order = await GetOrCreateOrderAsync(providerKey, vkorg, group.Key, allocation.Vbeln!, change |
| | | 550 | | _db.ArmtekOrderItems.Add(new OzonDomains.Models.ArmtekOrderItem |
| | | 551 | | { |
| | | 552 | | ArmtekOrderId = order.Id, ArmtekCartItemId = cartItem.Id, StudioOrderId = cartItem.StudioOrd |
| | | 553 | | Posnr = allocation.Posnr, Pin = cartItem.Pin, Brand = cartItem.Brand, |
| | | 554 | | Quantity = ParseDecimal(allocation.Quantity) ?? cartItem.Quantity, Price = ParseDecimal(allo |
| | | 555 | | CurrencyCode = allocation.Currency, DeliveryAtUtc = ParseDate(allocation.DeliveryDate), Erro |
| | | 556 | | }); |
| | | 557 | | } |
| | | 558 | | cartItem.Status = ArmtekCartItemStatus.Ordered; cartItem.ActiveStudioOrderId = null; cartItem.LastEr |
| | | 559 | | var orderNumbers = string.Join(", ", allocations.Select(x => x.Vbeln).Distinct()); |
| | | 560 | | success++; messages.Add($"{cartItem.Pin}: заказ Армтек {orderNumbers} создан ({DescribeDestination(c |
| | | 561 | | await RecordHistoryAsync(cartItem.StudioOrderId, ToQueueItem(cartItem), true, null, |
| | | 562 | | ApiSupplierBasketCartStatus.Ordered, orderNumbers, cartItem.BuyerKunnr, |
| | | 563 | | cartItem.DeliveryAddressName ?? cartItem.DeliveryAddressKunnr, changedBy, ct); |
| | | 564 | | await TrackOrderedAsync(cartItem.StudioOrderId, providerKey, orderNumbers, changedBy, ct); |
| | | 565 | | } |
| | | 566 | | } |
| | | 567 | | await _db.SaveChangesAsync(ct); |
| | | 568 | | InvalidateCount(); |
| | | 569 | | return new(success, 0, failed, messages); |
| | | 570 | | } |
| | | 571 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException) |
| | | 572 | | { |
| | | 573 | | // Обрыв соединения мог случиться уже после того, как Армтек принял заказ — исход неизвестен. |
| | | 574 | | if (ex is HttpRequestException) _guard?.CurrentDispatch?.MarkUncertain(providerKey); |
| | | 575 | | await _db.ArmtekCartItems.Where(x => ids.Contains(x.Id) && x.Status == ArmtekCartItemStatus.Processing) |
| | | 576 | | .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ArmtekCartItemStatus.Failed).SetProperty(x => x.La |
| | | 577 | | return new(0, 0, items.Count, [$"Армтек: {ex.Message}"]); |
| | | 578 | | } |
| | | 579 | | } |
| | | 580 | | |
| | | 581 | | public async Task<bool> RemoveAsync(long id, string providerKey, CancellationToken ct = default) |
| | | 582 | | { |
| | | 583 | | var item = await _db.ArmtekCartItems.FirstOrDefaultAsync(x => x.Id == id && x.ProviderKey == providerKey && Armt |
| | | 584 | | if (item is null) return false; |
| | | 585 | | item.Status = ArmtekCartItemStatus.Removed; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.UtcNow |
| | | 586 | | await _db.SaveChangesAsync(ct); |
| | | 587 | | await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct); |
| | | 588 | | InvalidateCount(); return true; |
| | | 589 | | } |
| | | 590 | | |
| | | 591 | | /// <summary>Убирает из корзины выбранные строки (или всю корзину провайдера, если ids не заданы).</summary> |
| | | 592 | | public async Task<ArmtekCartOperationResult> RemoveManyAsync(string providerKey, IReadOnlyCollection<long>? cartItem |
| | | 593 | | { |
| | | 594 | | if (!IsProvider(providerKey)) return new(0, 0, 0, ["Неизвестный поставщик Армтек."]); |
| | | 595 | | var selectionError = ValidateSelection(cartItemIds, out var selectedIds); |
| | | 596 | | if (selectionError is not null) return new(0, 0, 0, [selectionError]); |
| | | 597 | | |
| | | 598 | | // Оформляемые строки не трогаем: заказ уже ушёл в Armtek и его нельзя отменить удалением из корзины. |
| | | 599 | | var items = (await GetTrackedActiveAsync(providerKey, selectedIds, ct)) |
| | | 600 | | .Where(x => x.Status != ArmtekCartItemStatus.Processing) |
| | | 601 | | .ToList(); |
| | | 602 | | foreach (var item in items) |
| | | 603 | | { |
| | | 604 | | item.Status = ArmtekCartItemStatus.Removed; |
| | | 605 | | item.ActiveStudioOrderId = null; |
| | | 606 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 607 | | } |
| | | 608 | | await _db.SaveChangesAsync(ct); |
| | | 609 | | foreach (var item in items) |
| | | 610 | | { |
| | | 611 | | await TrackRemovedAsync(item.StudioOrderId, providerKey, null, ct); |
| | | 612 | | } |
| | | 613 | | var removed = items.Count; |
| | | 614 | | InvalidateCount(); |
| | | 615 | | if (removed == 0) |
| | | 616 | | return new(0, 0, 0, ["Нечего удалять: строки уже удалены или сейчас оформляются."]); |
| | | 617 | | var skipped = selectedIds is null ? 0 : selectedIds.Count - removed; |
| | | 618 | | var messages = new List<string> { $"Удалено из корзины {ProviderName(providerKey)}: {removed}." }; |
| | | 619 | | if (skipped > 0) messages.Add($"Пропущено (оформляются или уже удалены): {skipped}."); |
| | | 620 | | return new(removed, skipped, 0, messages); |
| | | 621 | | } |
| | | 622 | | |
| | | 623 | | public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.ArmtekCartItems.CountAsync(x => ArmtekCartI |
| | | 624 | | private Task<List<ArmtekCartItem>> GetTrackedActiveAsync(string providerKey, IReadOnlyCollection<long>? cartItemIds, |
| | | 625 | | { |
| | | 626 | | var query = _db.ArmtekCartItems.Where(x => x.ProviderKey == providerKey && ArmtekCartItemStatus.Active.Contains( |
| | | 627 | | if (cartItemIds is not null) |
| | | 628 | | query = query.Where(x => cartItemIds.Contains(x.Id)); |
| | | 629 | | return query.OrderBy(x => x.Id).ToListAsync(ct); |
| | | 630 | | } |
| | | 631 | | private (ArmtekDataManager Client, string Vkorg) ResolveClient(string providerKey) |
| | | 632 | | { |
| | | 633 | | if (!IsProvider(providerKey)) throw new ArgumentException("Неизвестный Armtek provider.", nameof(providerKey)); |
| | | 634 | | var client = providerKey == "ArmtekBy" ? (ArmtekDataManager)_armtekBy : _armtek; |
| | | 635 | | return (client, providerKey == "ArmtekBy" ? "2000" : "4000"); |
| | | 636 | | } |
| | | 637 | | private static string? BuildDestinationError(IReadOnlyCollection<ArmtekCartItem> items) |
| | | 638 | | { |
| | | 639 | | var unlinked = items |
| | | 640 | | .Where(x => string.IsNullOrWhiteSpace(x.BuyerKunnr) || string.IsNullOrWhiteSpace(x.DeliveryAddressKunnr)) |
| | | 641 | | .Select(x => x.Pin).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); |
| | | 642 | | return unlinked.Length == 0 |
| | | 643 | | ? null |
| | | 644 | | : $"Не указан клиент и адрес доставки Armtek: {string.Join(", ", unlinked)}. Выберите адрес в корзине либо с |
| | | 645 | | } |
| | | 646 | | private static IEnumerable<IGrouping<ArmtekDestination, ArmtekCartItem>> GroupByDestination(IEnumerable<ArmtekCartIt |
| | | 647 | | items.GroupBy(x => new ArmtekDestination(x.BuyerKunnr!, x.DeliveryAddressKunnr!)); |
| | | 648 | | |
| | | 649 | | /// <summary> |
| | | 650 | | /// Активные заказы Armtek по позициям очереди: ключ — (провайдер, id заказа Studio2), значение — номера заказов Arm |
| | | 651 | | /// Активный — заказ в стадии Ordered отслеживания статусов; а если отслеживание не записалось (сбой журнала) — |
| | | 652 | | /// есть строка ArmtekOrderItems и заказ не закрыт поставщиком (стадия Closed). |
| | | 653 | | /// </summary> |
| | | 654 | | private async Task<Dictionary<(string ProviderKey, int OrderId), string>> FindActiveSupplierOrdersAsync( |
| | | 655 | | IReadOnlyCollection<ArmtekCartQueueItem> queue, CancellationToken ct) |
| | | 656 | | { |
| | | 657 | | var result = new Dictionary<(string ProviderKey, int OrderId), string>(); |
| | | 658 | | foreach (var group in queue.Where(x => IsProvider(x.ProviderKey) && x.OrderId > 0).GroupBy(x => x.ProviderKey)) |
| | | 659 | | { |
| | | 660 | | var providerKey = group.Key; |
| | | 661 | | var orderIds = group.Select(x => x.OrderId).Distinct().ToList(); |
| | | 662 | | |
| | | 663 | | var states = await _db.OrderSupplierStates.AsNoTracking() |
| | | 664 | | .Where(x => x.ProviderKey == providerKey && orderIds.Contains(x.OrderId)) |
| | | 665 | | .Select(x => new { x.OrderId, x.Stage, x.ActiveOrderId, x.ExternalOrderNumber }) |
| | | 666 | | .ToListAsync(ct); |
| | | 667 | | var ordered = states |
| | | 668 | | .Where(x => x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ordered) |
| | | 669 | | .ToDictionary(x => x.OrderId, x => x.ExternalOrderNumber); |
| | | 670 | | var closed = states.Where(x => x.Stage == OrderSupplierStage.Closed).Select(x => x.OrderId).ToHashSet(); |
| | | 671 | | |
| | | 672 | | var untrackedSince = DateTime.UtcNow - UntrackedOrderWindow; |
| | | 673 | | var placed = await _db.ArmtekOrderItems.AsNoTracking() |
| | | 674 | | .Where(x => orderIds.Contains(x.StudioOrderId) && x.ArmtekOrder!.ProviderKey == providerKey |
| | | 675 | | && x.ArmtekOrder.CreatedAtUtc >= untrackedSince |
| | | 676 | | && (x.Error == null || x.Error == "" || x.Error == "0")) |
| | | 677 | | .Select(x => new { x.StudioOrderId, x.ArmtekOrder!.Vbeln }) |
| | | 678 | | .ToListAsync(ct); |
| | | 679 | | var placedNumbers = placed |
| | | 680 | | .GroupBy(x => x.StudioOrderId) |
| | | 681 | | .ToDictionary(x => x.Key, x => string.Join(", ", x.Select(i => i.Vbeln).Distinct())); |
| | | 682 | | |
| | | 683 | | foreach (var orderId in orderIds) |
| | | 684 | | { |
| | | 685 | | if (ordered.TryGetValue(orderId, out var stateNumber)) |
| | | 686 | | { |
| | | 687 | | result[(providerKey, orderId)] = !string.IsNullOrWhiteSpace(stateNumber) |
| | | 688 | | ? stateNumber |
| | | 689 | | : placedNumbers.GetValueOrDefault(orderId) ?? "(номер не записан)"; |
| | | 690 | | } |
| | | 691 | | else if (placedNumbers.TryGetValue(orderId, out var numbers) && !closed.Contains(orderId)) |
| | | 692 | | { |
| | | 693 | | result[(providerKey, orderId)] = numbers; |
| | | 694 | | } |
| | | 695 | | } |
| | | 696 | | } |
| | | 697 | | return result; |
| | | 698 | | } |
| | | 699 | | |
| | | 700 | | private async Task<(List<ArmtekPreparedDirectItem> Items, List<string> Errors)> PrepareDirectOrderItemsAsync( |
| | | 701 | | IReadOnlyCollection<ArmtekCartQueueItem> queue, CancellationToken ct) |
| | | 702 | | { |
| | | 703 | | var items = new List<ArmtekPreparedDirectItem>(); |
| | | 704 | | var errors = new List<string>(); |
| | | 705 | | foreach (var item in queue) |
| | | 706 | | { |
| | | 707 | | if (!IsProvider(item.ProviderKey) || item.OrderId <= 0 || item.Quantity <= 0 || |
| | | 708 | | string.IsNullOrWhiteSpace(item.ArtId) || string.IsNullOrWhiteSpace(item.Pin) || string.IsNullOrWhiteSpac |
| | | 709 | | string.IsNullOrWhiteSpace(item.Keyzak)) |
| | | 710 | | { |
| | | 711 | | errors.Add($"{Describe(item)}: неполные данные предложения Armtek."); |
| | | 712 | | continue; |
| | | 713 | | } |
| | | 714 | | var destination = await _deliveryAddressMappings.ResolveAsync(item.ProviderKey, item.OrderId, item.ZasylWare |
| | | 715 | | if (destination is null || string.IsNullOrWhiteSpace(destination.BuyerKunnr) || string.IsNullOrWhiteSpace(de |
| | | 716 | | { |
| | | 717 | | errors.Add($"{item.Pin}: не указан клиент и адрес доставки Armtek для склада отгрузки."); |
| | | 718 | | continue; |
| | | 719 | | } |
| | | 720 | | var (_, vkorg) = ResolveClient(item.ProviderKey); |
| | | 721 | | items.Add(new ArmtekPreparedDirectItem(item with { Pin = NormalizePin(item.Pin), Brand = item.Brand.Trim() } |
| | | 722 | | new ArmtekDestination(destination.BuyerKunnr, destination.DeliveryAddressKunnr))); |
| | | 723 | | } |
| | | 724 | | if (queue.GroupBy(x => new { x.ProviderKey, x.OrderId }).Any(group => group.Count() > 1)) |
| | | 725 | | errors.Add("Для одного заказа выбрано несколько предложений Armtek одного поставщика."); |
| | | 726 | | return (items, errors); |
| | | 727 | | } |
| | | 728 | | |
| | | 729 | | private sealed record ArmtekPreparedDirectItem(ArmtekCartQueueItem Item, string Vkorg, ArmtekDestination Destination |
| | | 730 | | private static ArmtekOrderRequestItem ToRequestItem(ArmtekCartQueueItem item) => new( |
| | | 731 | | item.Pin, item.Brand, item.Quantity, item.Keyzak, item.SourcePrice, FormatDateMax(item.DeliveryAtUtc), |
| | | 732 | | StudioOrderCommentLink.BuildFor(item.ProviderKey, string.IsNullOrWhiteSpace(item.ShipmentNumber) ? "заказ" : ite |
| | | 733 | | |
| | | 734 | | /// <summary>Метка Studio2 в <c>TEXT_ORD</c>: по ней заказ находится у поставщика при сверке перед повтором.</summar |
| | | 735 | | private static string OrderComment(IEnumerable<int> studioOrderIds) => |
| | | 736 | | StudioOrderCommentLink.BuildBatch(studioOrderIds, StudioOrderCommentLink.MaxLengthFor("Armtek") ?? 100); |
| | | 737 | | private static string? FormatDateMax(string? value) => ParseApiDate(value)?.ToString("yyyyMMddHHmmss", CultureInfo.I |
| | | 738 | | private static string DescribeDestination(ArmtekCartItem item) => |
| | | 739 | | $"клиент {item.BuyerKunnr}, адрес {item.DeliveryAddressName ?? item.DeliveryAddressKunnr}"; |
| | | 740 | | private async Task<ArmtekOrder> GetOrCreateOrderAsync(string providerKey, string vkorg, ArmtekDestination destinatio |
| | | 741 | | { |
| | | 742 | | var existing = await _db.ArmtekOrders.FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.Vbeln == vbeln, |
| | | 743 | | if (existing is not null) return existing; |
| | | 744 | | var order = new ArmtekOrder { ProviderKey = providerKey, Vkorg = vkorg, Kunrg = destination.Kunrg, Kunza = desti |
| | | 745 | | _db.ArmtekOrders.Add(order); await _db.SaveChangesAsync(ct); return order; |
| | | 746 | | } |
| | | 747 | | /// <summary>Пишет запись в orders_history в общей форме API-поставщиков. Ошибка журнала не должна |
| | | 748 | | /// ломать уже выполненное размещение — строка корзины к этому моменту уже сохранена.</summary> |
| | | 749 | | private async Task RecordHistoryAsync(int orderId, ArmtekCartQueueItem item, bool success, string? error, |
| | | 750 | | string cartStatus, string? supplierOrderNumber, string? buyerKunnr, string? deliveryAddressName, |
| | | 751 | | string changedBy, CancellationToken ct) |
| | | 752 | | { |
| | | 753 | | try |
| | | 754 | | { |
| | | 755 | | var displayName = ProviderName(item.ProviderKey); |
| | | 756 | | var entry = new ApiSupplierBasketHistoryEntry( |
| | | 757 | | item.ProviderKey, |
| | | 758 | | displayName, |
| | | 759 | | ApiSupplierBasketHistoryEntry.ResolveLogoUrl(item.ProviderKey), |
| | | 760 | | success, |
| | | 761 | | error, |
| | | 762 | | ApiItemId: null, |
| | | 763 | | Article: item.Pin, |
| | | 764 | | ProductName: item.ProductName ?? item.Brand, |
| | | 765 | | Price: item.SourcePrice, |
| | | 766 | | CurrencyCode: item.CurrencyCode, |
| | | 767 | | Quantity: item.Quantity, |
| | | 768 | | ShipmentNumber: item.ShipmentNumber, |
| | | 769 | | ClientName: item.ClientName, |
| | | 770 | | WarehouseName: item.WarehouseName, |
| | | 771 | | BuyerKunnr: buyerKunnr, |
| | | 772 | | DeliveryAddressName: deliveryAddressName, |
| | | 773 | | CartStatus: cartStatus, |
| | | 774 | | SupplierOrderNumber: supplierOrderNumber, |
| | | 775 | | SupplierCartUrl: CartUrl(item.ProviderKey)); |
| | | 776 | | |
| | | 777 | | await _history.AddOrderHistory(new OrderHistory |
| | | 778 | | { |
| | | 779 | | OrderId = orderId, |
| | | 780 | | ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | | 781 | | ColumnDisplayName = $"Корзина {displayName}", |
| | | 782 | | OldValue = null, |
| | | 783 | | NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions), |
| | | 784 | | ChangedAt = DateTime.Now, |
| | | 785 | | ChangedBy = changedBy |
| | | 786 | | }); |
| | | 787 | | } |
| | | 788 | | catch |
| | | 789 | | { |
| | | 790 | | // Не ломаем размещение в корзине из-за журнала истории. |
| | | 791 | | } |
| | | 792 | | } |
| | | 793 | | |
| | | 794 | | private static string CartUrl(string providerKey) => |
| | | 795 | | providerKey == "ArmtekBy" ? "/SupplierCart?provider=armtekby&supplierTab=basket" : "/SupplierCart?provider=armte |
| | | 796 | | private static IReadOnlyCollection<ArmtekOrderRequestItem> ToRequestItems(IEnumerable<ArmtekCartItem> items) => item |
| | | 797 | | NormalizePin(x.Pin), x.Brand.Trim(), x.Quantity, x.Keyzak, x.SourcePrice, |
| | | 798 | | x.DeliveryAtUtc?.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture), |
| | | 799 | | StudioOrderCommentLink.BuildFor(x.ProviderKey, "заказ", x.StudioOrderId))).ToArray(); |
| | | 800 | | private static ArmtekCreateOrderItemResult? FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekRe |
| | | 801 | | response.Resp?.Items.FirstOrDefault(x => string.Equals(x.ArtId, cartItem.ArtId, StringComparison.Ordinal)) |
| | | 802 | | ?? response.Resp?.Items.FirstOrDefault(x => |
| | | 803 | | string.Equals(NormalizePin(x.Pin), NormalizePin(cartItem.Pin), StringComparison.OrdinalIgnoreCase) && |
| | | 804 | | string.Equals(x.Brand, cartItem.Brand, StringComparison.OrdinalIgnoreCase)) |
| | | 805 | | ?? response.Resp?.Items.ElementAtOrDefault(fallbackIndex); |
| | | 806 | | private static ArmtekCreateOrderItemResult? FindResponseItem(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekRe |
| | | 807 | | response.Resp?.Items.FirstOrDefault(x => string.Equals(x.ArtId, item.ArtId, StringComparison.Ordinal)) |
| | | 808 | | ?? response.Resp?.Items.FirstOrDefault(x => |
| | | 809 | | string.Equals(NormalizePin(x.Pin), NormalizePin(item.Pin), StringComparison.OrdinalIgnoreCase) && |
| | | 810 | | string.Equals(x.Brand, item.Brand, StringComparison.OrdinalIgnoreCase)) |
| | | 811 | | ?? response.Resp?.Items.ElementAtOrDefault(fallbackIndex); |
| | | 812 | | private static bool HasApiItemError(string? error) => !string.IsNullOrWhiteSpace(error) && error != "0"; |
| | | 813 | | private static string? GetItemError(ArmtekCreateOrderItemResult? item) |
| | | 814 | | { |
| | | 815 | | if (item is null) return "Армтек не вернул результат проверки позиции."; |
| | | 816 | | if (HasApiItemError(item.Error)) return item.ErrorMessage ?? item.Error; |
| | | 817 | | if (item.Result.Count == 0) return "Армтек не подтвердил наличие позиции."; |
| | | 818 | | return ParseDecimal(item.Remain) is > 0 ? $"Армтек подтвердил только часть количества (остаток: {item.Remain})." |
| | | 819 | | } |
| | | 820 | | /// <summary> |
| | | 821 | | /// Отклоняет у Армтек позиции заказа Studio2 (<c>editOrder</c>, количество 0) — для заказов, отменённых в Ozon. |
| | | 822 | | /// Перед вызовом перечитывает заказ (<c>getOrder</c> с <c>EDIT=1</c>): отгруженную позицию не трогает, а возвращает |
| | | 823 | | /// <see cref="SupplierOrderCancelOutcome.ReturnNeeded"/>. Причина — значение по умолчанию из <c>ABGRU_ITEMS</c>. |
| | | 824 | | /// </summary> |
| | | 825 | | public async Task<SupplierOrderCancelResult> RejectOrderPositionsAsync(string providerKey, int orderId, string? note |
| | | 826 | | { |
| | | 827 | | if (!IsProvider(providerKey)) return SupplierOrderCancelResult.Failed($"Неизвестный провайдер Armtek: {providerK |
| | | 828 | | var links = await _db.ArmtekOrderItems.AsNoTracking() |
| | | 829 | | .Where(x => x.StudioOrderId == orderId && x.ArmtekOrder != null && x.ArmtekOrder.ProviderKey == providerKey |
| | | 830 | | .Select(x => new { x.Posnr, x.ArmtekOrder!.Vbeln, x.ArmtekOrder.Kunrg, x.ArmtekOrder.Vkorg }) |
| | | 831 | | .ToListAsync(ct); |
| | | 832 | | if (links.Count == 0) return SupplierOrderCancelResult.Failed("в Studio2 нет связки с заказом Армтек (номер и по |
| | | 833 | | |
| | | 834 | | var (client, defaultVkorg) = ResolveClient(providerKey); |
| | | 835 | | var messages = new List<string>(); |
| | | 836 | | var outcome = SupplierOrderCancelOutcome.Cancelled; |
| | | 837 | | foreach (var group in links.GroupBy(x => new { x.Vbeln, x.Kunrg, x.Vkorg })) |
| | | 838 | | { |
| | | 839 | | var vkorg = string.IsNullOrWhiteSpace(group.Key.Vkorg) ? defaultVkorg : group.Key.Vkorg; |
| | | 840 | | try |
| | | 841 | | { |
| | | 842 | | var order = await client.GetOrderAsync(group.Key.Kunrg, group.Key.Vbeln, vkorg, edit: true, ct); |
| | | 843 | | if (order.HasErrors || order.Resp is null) |
| | | 844 | | return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ArmtekMessages(order.Messages) ? |
| | | 845 | | |
| | | 846 | | var positions = group.Select(x => x.Posnr!).ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | | 847 | | var items = order.Resp.Items.Where(x => x.Posnr != null && positions.Contains(x.Posnr)).ToList(); |
| | | 848 | | if (items.Count == 0) return SupplierOrderCancelResult.Failed($"в заказе {group.Key.Vbeln} нет позиций { |
| | | 849 | | if (items.Any(x => ParseDecimal(x.KwmengR) is > 0)) |
| | | 850 | | { |
| | | 851 | | outcome = SupplierOrderCancelOutcome.ReturnNeeded; |
| | | 852 | | messages.Add($"заказ {group.Key.Vbeln}: позиция уже отгружена — нужен возврат (createReturnInvoice)" |
| | | 853 | | continue; |
| | | 854 | | } |
| | | 855 | | var toReject = items.Where(x => ParseDecimal(x.Kwmeng) is > 0 && ParseDecimal(x.KwmengRej) != ParseDecim |
| | | 856 | | if (toReject.Count == 0) |
| | | 857 | | { |
| | | 858 | | messages.Add($"заказ {group.Key.Vbeln}: позиции уже отклонены"); |
| | | 859 | | continue; |
| | | 860 | | } |
| | | 861 | | |
| | | 862 | | var abgru = order.Resp.AbgruItems.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Default))?.Abgru |
| | | 863 | | ?? order.Resp.AbgruItems.FirstOrDefault()?.Abgru; |
| | | 864 | | if (string.IsNullOrWhiteSpace(abgru)) |
| | | 865 | | return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: Армтек не дал причин отклонения ( |
| | | 866 | | |
| | | 867 | | var edit = await client.EditOrderAsync(group.Key.Kunrg, group.Key.Vbeln, toReject.Select(x => (x.Posnr!, |
| | | 868 | | abgru, vkorg, note, ct); |
| | | 869 | | if (edit.HasErrors) |
| | | 870 | | return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ArmtekMessages(edit.Messages) ?? |
| | | 871 | | messages.Add($"заказ {group.Key.Vbeln}, поз. {string.Join(", ", toReject.Select(x => x.Posnr))}: отклоне |
| | | 872 | | } |
| | | 873 | | catch (ArmtekApiException ex) |
| | | 874 | | { |
| | | 875 | | return SupplierOrderCancelResult.Failed($"заказ {group.Key.Vbeln}: {ex.Message}"); |
| | | 876 | | } |
| | | 877 | | } |
| | | 878 | | var text = string.Join("; ", messages); |
| | | 879 | | return outcome == SupplierOrderCancelOutcome.ReturnNeeded ? SupplierOrderCancelResult.ReturnNeeded(text) : Suppl |
| | | 880 | | } |
| | | 881 | | |
| | | 882 | | private static string? ArmtekMessages(IEnumerable<Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekMessage> mess |
| | | 883 | | { |
| | | 884 | | var text = string.Join("; ", messages.Where(x => x.Type is "E" or "A").Select(x => x.Text).Where(x => !string.Is |
| | | 885 | | return text.Length == 0 ? null : text; |
| | | 886 | | } |
| | | 887 | | |
| | | 888 | | private static bool IsProvider(string key) => key is "Armtek" or "ArmtekBy"; |
| | | 889 | | private static string NormalizePin(string value) => string.Concat(value.Where(character => !char.IsWhiteSpace(charac |
| | | 890 | | private static string? ValidateSelection(IReadOnlyCollection<long>? ids, out IReadOnlyCollection<long>? normalizedId |
| | | 891 | | { |
| | | 892 | | if (ids is null) |
| | | 893 | | { |
| | | 894 | | normalizedIds = null; |
| | | 895 | | return null; |
| | | 896 | | } |
| | | 897 | | |
| | | 898 | | normalizedIds = ids.Where(id => id > 0).Distinct().ToArray(); |
| | | 899 | | return normalizedIds.Count == 0 ? "Выберите хотя бы одну позицию корзины." : null; |
| | | 900 | | } |
| | | 901 | | private static string ProviderName(string key) => key == "ArmtekBy" ? "Армтек BY" : "Армтек RU"; |
| | | 902 | | private static string? BuildError<T>(Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse<T> response) => r |
| | | 903 | | private static decimal? ParseDecimal(string? value) => decimal.TryParse(value?.Replace(',', '.'), NumberStyles.Any, |
| | | 904 | | /// <summary>После отказа test-order повторно берём только тот же артикул и бренд у того же клиента/адреса. |
| | | 905 | | /// Проценка Armtek может вернуть устаревший склад, поэтому сохраняем только вариант, который затем сам Armtek подтв |
| | | 906 | | private async Task<ArmtekRefreshedOffer?> TryRefreshOfferAsync(ArmtekDataManager client, string vkorg, |
| | | 907 | | ArmtekDestination destination, ArmtekCartItem item, CancellationToken ct) |
| | | 908 | | { |
| | | 909 | | try |
| | | 910 | | { |
| | | 911 | | var response = await client.SearchAsync(destination.Kunrg, NormalizePin(item.Pin), item.Brand.Trim(), vkorg, |
| | | 912 | | queryType: 1, kunnrZa: destination.Kunza, incotermsSelfPickup: false, ct: ct); |
| | | 913 | | if (response.HasErrors) |
| | | 914 | | return null; |
| | | 915 | | |
| | | 916 | | var candidates = (response.Resp ?? []) |
| | | 917 | | .Where(offer => string.Equals(NormalizePin(offer.Pin), NormalizePin(item.Pin), StringComparison.OrdinalI |
| | | 918 | | && string.Equals(offer.Brand, item.Brand, StringComparison.OrdinalIgnoreCase) |
| | | 919 | | && (string.IsNullOrWhiteSpace(item.ArtId) || string.Equals(offer.ArtId, item.ArtId, Stri |
| | | 920 | | .Where(offer => ParseDecimal(offer.Price) is not null && HasEnoughStock(offer.Rvalue, item.Quantity)) |
| | | 921 | | .OrderByDescending(offer => string.Equals(offer.PartnerWarehouseCode, item.PartnerWarehouseCode, StringC |
| | | 922 | | .ThenBy(offer => ParseApiDate(offer.DeliveryDate) ?? DateTime.MaxValue) |
| | | 923 | | .ThenBy(offer => ParseDecimal(offer.Price)) |
| | | 924 | | .Take(8) |
| | | 925 | | .ToArray(); |
| | | 926 | | var checkedOffers = await Task.WhenAll(candidates.Select(offer => CheckRefreshedOfferAsync( |
| | | 927 | | client, vkorg, destination, item, offer, ct))); |
| | | 928 | | return checkedOffers.FirstOrDefault(offer => offer is not null); |
| | | 929 | | } |
| | | 930 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException) |
| | | 931 | | { |
| | | 932 | | return null; |
| | | 933 | | } |
| | | 934 | | } |
| | | 935 | | private async Task<ArmtekRefreshedOffer?> CheckRefreshedOfferAsync(ArmtekDataManager client, string vkorg, |
| | | 936 | | ArmtekDestination destination, ArmtekCartItem item, ArmtekSearchItem offer, CancellationToken ct) |
| | | 937 | | { |
| | | 938 | | await RefreshTestConcurrency.WaitAsync(ct); |
| | | 939 | | try |
| | | 940 | | { |
| | | 941 | | var requestItems = new[] { new ArmtekOrderRequestItem(NormalizePin(item.Pin), item.Brand.Trim(), item.Quanti |
| | | 942 | | offer.Keyzak, ParseDecimal(offer.Price), ParseApiDate(offer.DeliveryDate)?.ToString("yyyyMMddHHmmss", Cu |
| | | 943 | | var testResponse = await client.CreateTestOrderAsync(destination.Kunrg, requestItems, vkorg, destination.Kun |
| | | 944 | | LogApiExchange(item.ProviderKey, "refreshTestOrder", vkorg, destination, requestItems, testResponse); |
| | | 945 | | var testItem = FindResponseItem(testResponse, item); |
| | | 946 | | return string.IsNullOrWhiteSpace(BuildError(testResponse) ?? GetItemError(testItem)) |
| | | 947 | | ? new ArmtekRefreshedOffer(offer, testResponse) |
| | | 948 | | : null; |
| | | 949 | | } |
| | | 950 | | finally |
| | | 951 | | { |
| | | 952 | | RefreshTestConcurrency.Release(); |
| | | 953 | | } |
| | | 954 | | } |
| | | 955 | | private static bool HasEnoughStock(string? stock, decimal quantity) |
| | | 956 | | { |
| | | 957 | | var available = ParseStock(stock); |
| | | 958 | | return available is null || available.Value >= quantity; |
| | | 959 | | } |
| | | 960 | | |
| | | 961 | | /// <summary>Остаток Armtek приходит строкой и может нести знак «не менее» с любой стороны («>10», «10>»).</su |
| | | 962 | | private static decimal? ParseStock(string? stock) |
| | | 963 | | { |
| | | 964 | | if (string.IsNullOrWhiteSpace(stock)) return null; |
| | | 965 | | var normalized = stock.Trim().Trim('>', '<', '~', '=', ' ').Replace(',', '.'); |
| | | 966 | | return decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var available) ? availab |
| | | 967 | | } |
| | | 968 | | private static string ApplyRefreshedOffer(ArmtekCartItem item, ArmtekSearchItem offer, string originalError) |
| | | 969 | | { |
| | | 970 | | var oldKeyzak = item.Keyzak; |
| | | 971 | | var oldPrice = item.SourcePrice; |
| | | 972 | | item.ArtId = offer.ArtId; |
| | | 973 | | item.Keyzak = offer.Keyzak; |
| | | 974 | | item.WarehouseName = null; |
| | | 975 | | item.PartnerWarehouseCode = offer.PartnerWarehouseCode; |
| | | 976 | | item.SourcePrice = ParseDecimal(offer.Price); |
| | | 977 | | item.CurrencyCode = string.IsNullOrWhiteSpace(offer.Currency) ? item.CurrencyCode : offer.Currency; |
| | | 978 | | item.DeliveryAtUtc = ParseApiDate(offer.DeliveryDate); |
| | | 979 | | if (!string.IsNullOrWhiteSpace(offer.Name)) item.ProductName = offer.Name; |
| | | 980 | | |
| | | 981 | | var price = item.SourcePrice.HasValue |
| | | 982 | | ? $"цена {oldPrice?.ToString("0.##", CultureInfo.InvariantCulture) ?? "—"} → {item.SourcePrice.Value.ToStrin |
| | | 983 | | : "цена не получена"; |
| | | 984 | | var delivery = item.DeliveryAtUtc.HasValue |
| | | 985 | | ? $", срок {item.DeliveryAtUtc.Value:dd.MM.yyyy HH:mm}" |
| | | 986 | | : string.Empty; |
| | | 987 | | return $"проверка отклонена: {originalError}. Найдено и повторно подтверждено актуальное предложение: ключ {oldK |
| | | 988 | | } |
| | | 989 | | |
| | | 990 | | private sealed record ArmtekRefreshedOffer(ArmtekSearchItem Offer, |
| | | 991 | | Services.ApiServices.ArmtekApi.Models.Envelope.ArmtekResponse<ArmtekCreateOrderResponse> TestResponse); |
| | | 992 | | private static DateTime? ParseDate(string? value) => DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.Inv |
| | | 993 | | private static DateTime? ParseApiDate(string? value) => |
| | | 994 | | DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compa |
| | | 995 | | DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed) ? parse |
| | | 996 | | private static string Describe(ArmtekCartQueueItem item) |
| | | 997 | | { |
| | | 998 | | var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber; |
| | | 999 | | var product = string.IsNullOrWhiteSpace(item.ProductName) ? item.Pin : item.ProductName; |
| | | 1000 | | var client = string.IsNullOrWhiteSpace(item.ClientName) ? string.Empty : $", {item.ClientName}"; |
| | | 1001 | | return $"{item.Pin}: заказ {order}, {product}{client}"; |
| | | 1002 | | } |
| | | 1003 | | private static ArmtekCartQueueItem ToQueueItem(ArmtekCartItem item) => new(item.StudioOrderId, item.ProviderKey, ite |
| | | 1004 | | private void LogApiExchange<T>(string providerKey, string operation, string vkorg, ArmtekDestination destination, IR |
| | | 1005 | | _logger.LogInformation("Armtek cart {Provider} {Operation}. Request: {Request}. Response: {Response}", |
| | | 1006 | | providerKey, operation, |
| | | 1007 | | JsonSerializer.Serialize(new |
| | | 1008 | | { |
| | | 1009 | | VKORG = vkorg, |
| | | 1010 | | KUNRG = destination.Kunrg, |
| | | 1011 | | KUNZA = destination.Kunza, |
| | | 1012 | | INCOTERMS = "0", |
| | | 1013 | | ITEMS = requestItems.Select(item => new |
| | | 1014 | | { |
| | | 1015 | | item.Pin, |
| | | 1016 | | item.Brand, |
| | | 1017 | | Quantity = item.Quantity.ToString("0.####", CultureInfo.InvariantCulture), |
| | | 1018 | | item.Keyzak, |
| | | 1019 | | PriceMax = item.PriceMax?.ToString("0.####", CultureInfo.InvariantCulture), |
| | | 1020 | | DateMax = item.DateMax |
| | | 1021 | | }) |
| | | 1022 | | }), |
| | | 1023 | | JsonSerializer.Serialize(response)); |
| | | 1024 | | private void InvalidateCount() => _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | | 1025 | | } |