| | | 1 | | using System.Net; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using Microsoft.EntityFrameworkCore; |
| | | 4 | | using Newtonsoft.Json.Linq; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | using Services.ApiServices.MoskvorechieApi; |
| | | 8 | | using Services.DataServices; |
| | | 9 | | using Services.SupplierStatusServices; |
| | | 10 | | |
| | | 11 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 12 | | |
| | | 13 | | public sealed record MoskvorechieIstraQueueItem(int OrderId, string OfferId, string Article, string Brand, string? Produ |
| | | 14 | | string? ShipmentNumber, string? ClientName, decimal Quantity, decimal MinimumQuantity, decimal? SourcePrice, |
| | | 15 | | int? DeliveryDaysMin, int? DeliveryDaysMax, string? Direction); |
| | | 16 | | |
| | 202 | 17 | | public sealed record MoskvorechieIstraCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages) |
| | | 18 | | { |
| | | 19 | | public string ToHtmlBlock() |
| | | 20 | | { |
| | 2 | 21 | | var css = Failed > 0 ? "alert-warning" : "alert-success"; |
| | 5 | 22 | | var list = string.Join(string.Empty, Messages.Select(x => $"<li>{WebUtility.HtmlEncode(x)}</li>")); |
| | 2 | 23 | | return $"<div class='alert {css} mb-0'><strong>Москворечье: добавлено {Added}, пропущено {Skipped}, ошибок {Fail |
| | | 24 | | } |
| | | 25 | | } |
| | | 26 | | |
| | | 27 | | public sealed class MoskvorechieIstraCartService |
| | | 28 | | { |
| | | 29 | | private readonly ApplicationDbContext _db; |
| | | 30 | | private readonly MoskvorechieIstraDataManager _api; |
| | | 31 | | private readonly IOrderHistoryDataService _orderHistory; |
| | | 32 | | private readonly IOrderSupplierStateService _supplierState; |
| | | 33 | | public MoskvorechieIstraCartService(ApplicationDbContext db, MoskvorechieIstraDataManager api, |
| | | 34 | | IOrderHistoryDataService orderHistory, IOrderSupplierStateService supplierState) => |
| | | 35 | | (_db, _api, _orderHistory, _supplierState) = (db, api, orderHistory, supplierState); |
| | | 36 | | |
| | | 37 | | public async Task<MoskvorechieIstraCartResult> QueueAsync(IReadOnlyCollection<MoskvorechieIstraQueueItem> queue, str |
| | | 38 | | { |
| | | 39 | | var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>(); |
| | | 40 | | foreach (var item in queue) |
| | | 41 | | { |
| | | 42 | | if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.OfferId) || item.Quantity < Math.Max(1, item.Minimum |
| | | 43 | | { failed++; messages.Add($"Заказ {item.OrderId}: неверная позиция или количество меньше минимальной партии." |
| | | 44 | | if (await _db.MoskvorechieIstraCartItems.AnyAsync(x => x.ActiveStudioOrderId == item.OrderId, ct)) |
| | | 45 | | { skipped++; messages.Add($"Заказ {item.OrderId}: уже добавлен в корзину Москворечья."); continue; } |
| | | 46 | | var comment = $"Studio2 {item.ShipmentNumber ?? $"заказ {item.OrderId}"} (ID {item.OrderId})"; |
| | | 47 | | try |
| | | 48 | | { |
| | | 49 | | // API-корзина — единственный источник истины: локальную связь создаём лишь после |
| | | 50 | | // успешного to_basket и повторного my_basket, где подтверждены gid и комментарий Studio2. |
| | | 51 | | var externalBasket = await _api.GetBasketAsync(ct); |
| | | 52 | | var alreadyInExternalBasket = BasketContains(externalBasket, item.Article, item.Brand, item.Quantity, |
| | | 53 | | item.SourcePrice, item.OfferId, comment); |
| | | 54 | | if (!alreadyInExternalBasket) |
| | | 55 | | { |
| | | 56 | | await _api.AddToBasketAsync(item.OfferId, item.Quantity, comment, ct); |
| | | 57 | | externalBasket = await _api.GetBasketAsync(ct); |
| | | 58 | | } |
| | | 59 | | if (!BasketContains(externalBasket, item.Article, item.Brand, item.Quantity, item.SourcePrice, item.Offe |
| | | 60 | | throw new MoskvorechieApiException("API Москворечья не подтвердил позицию в своей корзине. Локальная |
| | | 61 | | _db.MoskvorechieIstraCartItems.Add(new MoskvorechieIstraCartItem |
| | | 62 | | { |
| | | 63 | | StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, ExternalBasketItemId = item.OfferI |
| | | 64 | | Article = item.Article, Brand = item.Brand, ProductName = item.ProductName, Quantity = item.Quantity |
| | | 65 | | SourcePrice = item.SourcePrice, Warehouse = item.Direction, DeliveryDaysMin = item.DeliveryDaysMin, |
| | | 66 | | DeliveryDaysMax = item.DeliveryDaysMax, Comment = comment, CreatedBy = changedBy |
| | | 67 | | }); |
| | | 68 | | await _db.SaveChangesAsync(ct); added++; messages.Add(alreadyInExternalBasket |
| | | 69 | | ? $"Заказ {item.OrderId}: позиция уже была в API-корзине, локальная связь восстановлена." |
| | | 70 | | : $"Заказ {item.OrderId}: добавлен в корзину Москворечья."); |
| | | 71 | | await TrackPlacementAsync(item, comment, changedBy, ct); |
| | | 72 | | } |
| | | 73 | | catch (MoskvorechieApiException ex) { failed++; messages.Add($"Заказ {item.OrderId}: {ex.Message}"); } |
| | | 74 | | } |
| | | 75 | | return new MoskvorechieIstraCartResult(added, skipped, failed, messages); |
| | | 76 | | } |
| | | 77 | | |
| | | 78 | | /// <summary>Сверяет активные связи с <c>my_basket</c>. Исчезнувшие из API строки больше не показываются в Studio2.< |
| | | 79 | | public async Task SynchronizeAsync(CancellationToken ct) |
| | | 80 | | { |
| | | 81 | | var local = await _db.MoskvorechieIstraCartItems |
| | | 82 | | .Where(x => x.ActiveStudioOrderId != null && x.Status != "Removed") |
| | | 83 | | .ToListAsync(ct); |
| | | 84 | | if (local.Count == 0) return; |
| | | 85 | | |
| | | 86 | | var externalBasket = await _api.GetBasketAsync(ct); |
| | | 87 | | var changed = false; |
| | | 88 | | foreach (var item in local) |
| | | 89 | | { |
| | | 90 | | var external = FindBasketRow(externalBasket, item.Article, item.Brand, item.Quantity, item.SourcePrice, |
| | | 91 | | item.ExternalBasketItemId, item.Comment); |
| | | 92 | | if (external is null) |
| | | 93 | | { |
| | | 94 | | item.Status = "Removed"; |
| | | 95 | | item.ActiveStudioOrderId = null; |
| | | 96 | | item.LastError = "Позиция отсутствует в API-корзине Москворечья."; |
| | | 97 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 98 | | changed = true; |
| | | 99 | | continue; |
| | | 100 | | } |
| | | 101 | | |
| | | 102 | | // Обновляем отображаемый снимок реальными значениями my_basket. |
| | | 103 | | item.ProductName = Read(external, "name") ?? item.ProductName; |
| | | 104 | | item.SourcePrice = ReadDecimal(external, "price") ?? item.SourcePrice; |
| | | 105 | | var deliveryDays = ParseDays(Read(external, "delivery")); |
| | | 106 | | item.DeliveryDaysMin = deliveryDays ?? item.DeliveryDaysMin; |
| | | 107 | | item.DeliveryDaysMax = deliveryDays ?? item.DeliveryDaysMax; |
| | | 108 | | item.LastError = null; |
| | | 109 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 110 | | changed = true; |
| | | 111 | | } |
| | | 112 | | if (changed) await _db.SaveChangesAsync(ct); |
| | | 113 | | } |
| | | 114 | | |
| | | 115 | | public async Task<MoskvorechieIstraCartResult> TestAsync(IReadOnlyCollection<long>? cartItemIds, CancellationToken c |
| | | 116 | | { |
| | | 117 | | await SynchronizeAsync(ct); |
| | | 118 | | var items = await SelectedItemsAsync(cartItemIds, ct); |
| | | 119 | | return items.Count == 0 |
| | | 120 | | ? new(0, 0, 1, ["В API-корзине нет выбранных позиций Москворечья."]) |
| | | 121 | | : new(items.Count, 0, 0, items.Select(x => $"{x.Article}: подтверждён в API-корзине.").ToList()); |
| | | 122 | | } |
| | | 123 | | |
| | | 124 | | /// <summary>Обновляет начальный статус каждого известного внешнего заказа через make_order_v2?oids=….</summary> |
| | | 125 | | public async Task SynchronizeOrdersAsync(CancellationToken ct) |
| | | 126 | | { |
| | | 127 | | var orders = await _db.MoskvorechieIstraOrders.Include(x => x.Items) |
| | | 128 | | .Where(x => x.ExternalOrderNumber != null && x.ExternalOrderNumber != "") |
| | | 129 | | .ToListAsync(ct); |
| | | 130 | | if (orders.Count == 0) return; |
| | | 131 | | |
| | | 132 | | foreach (var batch in orders.Chunk(50)) |
| | | 133 | | { |
| | | 134 | | var response = await _api.GetOrderStatesAsync(batch.Select(x => x.ExternalOrderNumber!).ToList(), ct); |
| | | 135 | | foreach (var row in GetOrderRows(response)) |
| | | 136 | | { |
| | | 137 | | var externalId = Read(row, "id"); |
| | | 138 | | if (string.IsNullOrWhiteSpace(externalId)) continue; |
| | | 139 | | var order = batch.FirstOrDefault(x => string.Equals(x.ExternalOrderNumber, externalId, StringComparison. |
| | | 140 | | if (order is null) continue; |
| | | 141 | | |
| | | 142 | | var statusId = Read(row, "status_id"); |
| | | 143 | | var statusText = Read(row, "status_txt"); |
| | | 144 | | order.Status = string.IsNullOrWhiteSpace(statusText) |
| | | 145 | | ? GetOrderStatusName(statusId) |
| | | 146 | | : statusText; |
| | | 147 | | order.ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None); |
| | | 148 | | if (row["pos_list"] is JArray positions) |
| | | 149 | | foreach (var position in positions.OfType<JObject>()) |
| | | 150 | | { |
| | | 151 | | var line = order.Items.FirstOrDefault(x => |
| | | 152 | | string.Equals(x.Article, Read(position, "nr"), StringComparison.OrdinalIgnoreCase) && |
| | | 153 | | string.Equals(x.Brand, Read(position, "firm"), StringComparison.OrdinalIgnoreCase)); |
| | | 154 | | if (line is null) continue; |
| | | 155 | | line.Quantity = ReadDecimal(position, "cnt_chk", "cnt") ?? line.Quantity; |
| | | 156 | | line.Price = ReadDecimal(position, "price_chk", "price") ?? line.Price; |
| | | 157 | | } |
| | | 158 | | } |
| | | 159 | | } |
| | | 160 | | await _db.SaveChangesAsync(ct); |
| | | 161 | | } |
| | | 162 | | |
| | | 163 | | /// <summary>Восстанавливает заказ, созданный до сохранения локального результата, по известному ID портала.</summar |
| | | 164 | | public async Task<MoskvorechieIstraCartResult> ImportOrderAsync(string? externalOrderId, string changedBy, Cancellat |
| | | 165 | | { |
| | | 166 | | var orderId = externalOrderId?.Trim(); |
| | | 167 | | if (string.IsNullOrWhiteSpace(orderId)) return new(0, 0, 1, ["Укажите ID заказа Москворечья."]); |
| | | 168 | | |
| | | 169 | | var response = await _api.GetOrderStatesAsync([orderId], ct); |
| | | 170 | | var row = GetOrderRows(response).FirstOrDefault(x => string.Equals(Read(x, "id"), orderId, StringComparison.Ordi |
| | | 171 | | if (row is null || Read(row, "status_id") == "0") return new(0, 0, 1, ["API Москворечья не нашёл заказ с указанн |
| | | 172 | | |
| | | 173 | | var order = await _db.MoskvorechieIstraOrders.Include(x => x.Items) |
| | | 174 | | .FirstOrDefaultAsync(x => x.ExternalOrderNumber == orderId, ct); |
| | | 175 | | if (order is not null) |
| | | 176 | | { |
| | | 177 | | order.Status = Read(row, "status_txt") ?? GetOrderStatusName(Read(row, "status_id")); |
| | | 178 | | order.ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None); |
| | | 179 | | await _db.SaveChangesAsync(ct); |
| | | 180 | | return new(0, 1, 0, [$"Заказ Москворечья №{orderId} уже был сохранён; статус обновлён."]); |
| | | 181 | | } |
| | | 182 | | |
| | | 183 | | var positionRows = row["pos_list"] is JArray positions ? positions.OfType<JObject>().ToList() : []; |
| | | 184 | | if (positionRows.Count == 0) return new(0, 0, 1, ["API ещё не вернул состав заказа. Повторите импорт после разме |
| | | 185 | | |
| | | 186 | | var links = new List<(JObject Position, MoskvorechieIstraCartItem CartItem)>(); |
| | | 187 | | foreach (var position in positionRows) |
| | | 188 | | { |
| | | 189 | | var article = Read(position, "nr"); |
| | | 190 | | var brand = Read(position, "firm"); |
| | | 191 | | if (string.IsNullOrWhiteSpace(article) || string.IsNullOrWhiteSpace(brand)) |
| | | 192 | | return new(0, 0, 1, ["API вернул позицию без артикула или производителя."]); |
| | | 193 | | var candidates = await _db.MoskvorechieIstraCartItems |
| | | 194 | | .Where(x => x.Article == article && x.Brand == brand && (x.Status == "Pending" || x.Status == "Removed") |
| | | 195 | | .OrderByDescending(x => x.CreatedAtUtc).Take(2).ToListAsync(ct); |
| | | 196 | | if (candidates.Count != 1) |
| | | 197 | | return new(0, 0, 1, [$"Нельзя безопасно связать {article} {brand} с заказом Studio2: найдено строк {cand |
| | | 198 | | links.Add((position, candidates[0])); |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | order = new MoskvorechieIstraOrder |
| | | 202 | | { |
| | | 203 | | ExternalOrderNumber = orderId, |
| | | 204 | | Status = Read(row, "status_txt") ?? GetOrderStatusName(Read(row, "status_id")), |
| | | 205 | | ResponseJson = row.ToString(Newtonsoft.Json.Formatting.None), |
| | | 206 | | CreatedBy = changedBy |
| | | 207 | | }; |
| | | 208 | | _db.MoskvorechieIstraOrders.Add(order); |
| | | 209 | | foreach (var (position, cartItem) in links) |
| | | 210 | | { |
| | | 211 | | var quantity = ReadDecimal(position, "cnt_chk", "cnt") ?? cartItem.Quantity; |
| | | 212 | | var price = ReadDecimal(position, "price_chk", "price") ?? cartItem.SourcePrice; |
| | | 213 | | _db.MoskvorechieIstraOrderItems.Add(new MoskvorechieIstraOrderItem |
| | | 214 | | { |
| | | 215 | | MoskvorechieIstraOrder = order, StudioOrderId = cartItem.StudioOrderId, Article = cartItem.Article, |
| | | 216 | | Brand = cartItem.Brand, Quantity = quantity, Price = price, ExternalBasketItemId = cartItem.ExternalBask |
| | | 217 | | }); |
| | | 218 | | cartItem.Status = "Ordered"; |
| | | 219 | | cartItem.ActiveStudioOrderId = null; |
| | | 220 | | cartItem.UpdatedAtUtc = DateTime.UtcNow; |
| | | 221 | | await _supplierState.MarkOrderedAsync(cartItem.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderK |
| | | 222 | | orderId, externalOrderId: orderId, user: changedBy, ct: ct); |
| | | 223 | | } |
| | | 224 | | await _db.SaveChangesAsync(ct); |
| | | 225 | | return new(links.Count, 0, 0, [$"Заказ Москворечья №{orderId} восстановлен из API и связан с заказами Studio2."] |
| | | 226 | | } |
| | | 227 | | |
| | | 228 | | /// <summary> |
| | | 229 | | /// У API Москворечья нет адресного удаления строки: только <c>clean_basket</c> для всей корзины. |
| | | 230 | | /// Поэтому очистка разрешена лишь когда внешняя корзина полностью состоит из удаляемых строк Studio2. |
| | | 231 | | /// </summary> |
| | | 232 | | public async Task<MoskvorechieIstraCartResult> RemoveAsync(IReadOnlyCollection<long>? cartItemIds, CancellationToken |
| | | 233 | | { |
| | | 234 | | if (cartItemIds is not { Count: > 0 }) |
| | | 235 | | return new(0, 0, 1, ["Выберите хотя бы одну позицию Москворечья."]); |
| | | 236 | | |
| | | 237 | | await SynchronizeAsync(ct); |
| | | 238 | | var selected = await SelectedItemsAsync(cartItemIds, ct); |
| | | 239 | | if (selected.Count == 0) |
| | | 240 | | return new(0, 0, 1, ["Выбранные позиции отсутствуют в API-корзине Москворечья."]); |
| | | 241 | | |
| | | 242 | | var active = await SelectedItemsAsync(null, ct); |
| | | 243 | | var externalBasket = await _api.GetBasketAsync(ct); |
| | | 244 | | var externalRows = GetBasketRows(externalBasket); |
| | | 245 | | var allSelected = active.Count == selected.Count && |
| | | 246 | | externalRows.Count == selected.Count && |
| | | 247 | | selected.All(item => FindBasketRow(externalBasket, item.Article, item.Brand, item.Quantity, |
| | | 248 | | item.SourcePrice, item.ExternalBasketItemId, item.Comment) is not null); |
| | | 249 | | if (!allSelected) |
| | | 250 | | return new(0, 0, 1, ["API Москворечья поддерживает только очистку всей корзины. В ней есть невыбранные либо |
| | | 251 | | |
| | | 252 | | await _api.CleanBasketAsync(ct); |
| | | 253 | | if (GetBasketRows(await _api.GetBasketAsync(ct)).Count != 0) |
| | | 254 | | return new(0, 0, selected.Count, ["Москворечье не подтвердил очистку API-корзины. Локальные строки сохранены |
| | | 255 | | |
| | | 256 | | foreach (var item in selected) |
| | | 257 | | { |
| | | 258 | | item.Status = "Removed"; |
| | | 259 | | item.ActiveStudioOrderId = null; |
| | | 260 | | item.LastError = null; |
| | | 261 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 262 | | } |
| | | 263 | | await _db.SaveChangesAsync(ct); |
| | | 264 | | foreach (var item in selected) |
| | | 265 | | await _supplierState.RemoveFromCartAsync(item.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderKe |
| | | 266 | | return new(selected.Count, 0, 0, ["Позиции удалены из API- и локальной корзины Москворечья."]); |
| | | 267 | | } |
| | | 268 | | |
| | | 269 | | public async Task<MoskvorechieIstraCartResult> CheckoutAsync(string changedBy, IReadOnlyCollection<long>? cartItemId |
| | | 270 | | { |
| | | 271 | | await SynchronizeAsync(ct); |
| | | 272 | | var items = await SelectedItemsAsync(cartItemIds, ct); |
| | | 273 | | if (items.Count == 0) return new(0, 0, 1, ["В API-корзине нет выбранных позиций Москворечья."]); |
| | | 274 | | var response = await _api.MakeOrderAsync(ct); // Вызывается только после TestAsync и явного подтверждения UI. |
| | | 275 | | var result = response["result"] ?? response; |
| | | 276 | | var resultObject = result as JObject; |
| | | 277 | | var status = resultObject?.Value<string>("status"); |
| | | 278 | | if (!string.IsNullOrWhiteSpace(status) && status != "0") |
| | | 279 | | return new(0, 0, items.Count, [$"Москворечье не оформил заказ: {resultObject?.Value<string>("msg") ?? status |
| | | 280 | | var number = FindOrderNumber(result); |
| | | 281 | | if (string.IsNullOrWhiteSpace(number)) |
| | | 282 | | return new(0, 0, items.Count, ["Москворечье не вернул номер созданного заказа."]); |
| | | 283 | | var order = new MoskvorechieIstraOrder { ExternalOrderNumber = number, ResponseJson = response.ToString(Newtonso |
| | | 284 | | _db.MoskvorechieIstraOrders.Add(order); |
| | | 285 | | foreach (var item in items) |
| | | 286 | | { |
| | | 287 | | _db.MoskvorechieIstraOrderItems.Add(new MoskvorechieIstraOrderItem { MoskvorechieIstraOrder = order, StudioO |
| | | 288 | | item.Status = "Ordered"; item.ActiveStudioOrderId = null; item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 289 | | await _supplierState.MarkOrderedAsync(item.StudioOrderId, ApiSupplierSettings.MoskvorechieIstraProviderKey, |
| | | 290 | | } |
| | | 291 | | await _db.SaveChangesAsync(ct); |
| | | 292 | | return new(items.Count, 0, 0, [$"Заказ Москворечья {(number is null ? "принят API" : $"№{number}")} создан."]); |
| | | 293 | | } |
| | | 294 | | |
| | | 295 | | public async Task<MoskvorechieIstraCartResult> CheckoutDirectAsync(IReadOnlyCollection<MoskvorechieIstraQueueItem> q |
| | | 296 | | { |
| | | 297 | | var placed = await QueueAsync(queue, changedBy, ct); |
| | | 298 | | if (placed.Failed > 0) return placed; |
| | | 299 | | var orderIds = queue.Select(x => x.OrderId).Distinct().ToList(); |
| | | 300 | | var ids = await _db.MoskvorechieIstraCartItems.Where(x => x.ActiveStudioOrderId != null && orderIds.Contains(x.S |
| | | 301 | | .Select(x => x.Id).ToListAsync(ct); |
| | | 302 | | return await CheckoutAsync(changedBy, ids, ct); |
| | | 303 | | } |
| | | 304 | | |
| | | 305 | | private async Task<List<MoskvorechieIstraCartItem>> SelectedItemsAsync(IReadOnlyCollection<long>? ids, CancellationT |
| | | 306 | | { |
| | | 307 | | var query = _db.MoskvorechieIstraCartItems.Where(x => x.ActiveStudioOrderId != null && x.Status == "Pending"); |
| | | 308 | | if (ids is { Count: > 0 }) query = query.Where(x => ids.Contains(x.Id)); |
| | | 309 | | return await query.ToListAsync(ct); |
| | | 310 | | } |
| | | 311 | | |
| | | 312 | | private static string? FindOrderNumber(JToken token) |
| | | 313 | | { |
| | | 314 | | var values = token is JContainer container ? container.DescendantsAndSelf().OfType<JValue>() : new[] { token }.O |
| | | 315 | | return values.FirstOrDefault(value => value.Parent is JProperty property && property.Name is "id" or "order" or |
| | | 316 | | } |
| | | 317 | | |
| | | 318 | | private static IReadOnlyList<JObject> GetBasketRows(JToken basket) |
| | | 319 | | { |
| | | 320 | | var result = basket["result"] ?? basket; |
| | | 321 | | return result is JArray array ? array.OfType<JObject>().ToList() : []; |
| | | 322 | | } |
| | | 323 | | |
| | | 324 | | private static IReadOnlyList<JObject> GetOrderRows(JToken response) |
| | | 325 | | { |
| | | 326 | | var result = response["result"] ?? response; |
| | | 327 | | return result is JArray array ? array.OfType<JObject>().ToList() : []; |
| | | 328 | | } |
| | | 329 | | |
| | | 330 | | private static string GetOrderStatusName(string? statusId) => statusId switch |
| | | 331 | | { |
| | | 332 | | "-1" => "Подключение к учётной системе", |
| | | 333 | | "-2" => "Создание заказа", |
| | | 334 | | "-3" => "Резервирование товара", |
| | | 335 | | "-4" => "Создание расходной накладной", |
| | | 336 | | "-9" => "Передача менеджеру", |
| | | 337 | | "0" => "Заказ не найден", |
| | | 338 | | "1" => "Размещён в учётной системе", |
| | | 339 | | "2" => "Создана расходная накладная", |
| | | 340 | | "3" => "Создана заявка", |
| | | 341 | | "9" => "Ручная обработка менеджером", |
| | | 342 | | _ => string.IsNullOrWhiteSpace(statusId) ? "Ожидает статуса" : $"Статус API {statusId}" |
| | | 343 | | }; |
| | | 344 | | |
| | | 345 | | /// <remarks> |
| | | 346 | | /// <c>my_basket</c> возвращает nr/brand/quantity/price, но не gid и не comment (это подтверждено |
| | | 347 | | /// фактическим ответом API). Поэтому сначала используем точные gid+comment, если они есть, а затем |
| | | 348 | | /// сверяем доступный контракт строки корзины. |
| | | 349 | | /// </remarks> |
| | | 350 | | private static bool BasketContains(JToken basket, string article, string brand, decimal quantity, decimal? sourcePri |
| | | 351 | | string offerId, string? comment) => FindBasketRow(basket, article, brand, quantity, sourcePrice, offerId, commen |
| | | 352 | | |
| | | 353 | | internal static JObject? FindBasketRow(JToken basket, string article, string brand, decimal quantity, decimal? sourc |
| | | 354 | | string offerId, string? comment) |
| | | 355 | | { |
| | | 356 | | var tokens = basket is JContainer container ? container.DescendantsAndSelf() : new[] { basket }; |
| | | 357 | | return tokens.OfType<JObject>().FirstOrDefault(row => |
| | | 358 | | { |
| | | 359 | | var ids = row.Properties().Where(property => property.Name.Equals("gid", StringComparison.OrdinalIgnoreCase) |
| | | 360 | | || property.Name.Equals("id", StringComparison.OrdinalIgnoreCase) |
| | | 361 | | || property.Name.Equals("good_id", StringComparison.OrdinalIgnoreCase)) |
| | | 362 | | .Select(property => property.Value.ToString()); |
| | | 363 | | var commentMatches = string.IsNullOrWhiteSpace(comment) || row.Properties() |
| | | 364 | | .Where(property => property.Name.Contains("comment", StringComparison.OrdinalIgnoreCase)) |
| | | 365 | | .Any(property => string.Equals(property.Value.ToString(), comment, StringComparison.Ordinal)); |
| | | 366 | | if (ids.Contains(offerId, StringComparer.Ordinal) && commentMatches) return true; |
| | | 367 | | |
| | | 368 | | var apiArticle = Read(row, "nr", "article", "code"); |
| | | 369 | | var apiBrand = Read(row, "brand", "firm", "producer"); |
| | | 370 | | if (!string.Equals(NormalizeArticle(apiArticle), NormalizeArticle(article), StringComparison.Ordinal) || |
| | | 371 | | !string.Equals(apiBrand, brand, StringComparison.OrdinalIgnoreCase)) return false; |
| | | 372 | | // Остаток может прийти со знаком «не менее» («>500»): без его снятия позиция |
| | | 373 | | // считалась бы недоступной и проведение транзакции срывалось бы на живом товаре. |
| | | 374 | | if (!decimal.TryParse(Read(row, "quantity", "qty", "count")?.Trim().Trim('>', '<', '~', '='), System.Globali |
| | | 375 | | System.Globalization.CultureInfo.InvariantCulture, out var apiQuantity) || apiQuantity < quantity) r |
| | | 376 | | return !sourcePrice.HasValue || |
| | | 377 | | (decimal.TryParse(Read(row, "price"), System.Globalization.NumberStyles.Number, |
| | | 378 | | System.Globalization.CultureInfo.InvariantCulture, out var apiPrice) && |
| | | 379 | | Math.Abs(apiPrice - sourcePrice.Value) < 0.01m); |
| | | 380 | | }); |
| | | 381 | | } |
| | | 382 | | |
| | | 383 | | private static string? Read(JObject row, params string[] names) => row.Properties() |
| | | 384 | | .FirstOrDefault(property => names.Contains(property.Name, StringComparer.OrdinalIgnoreCase))?.Value.ToString(); |
| | | 385 | | |
| | | 386 | | private static string NormalizeArticle(string? value) => new string((value ?? string.Empty) |
| | | 387 | | .Where(char.IsLetterOrDigit) |
| | | 388 | | .Select(char.ToUpperInvariant) |
| | | 389 | | .ToArray()); |
| | | 390 | | |
| | | 391 | | private static decimal? ReadDecimal(JObject row, params string[] names) => decimal.TryParse(Read(row, names), |
| | | 392 | | System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var value) ? va |
| | | 393 | | |
| | | 394 | | private static int? ParseDays(string? value) |
| | | 395 | | { |
| | | 396 | | var digits = new string((value ?? string.Empty).Where(char.IsDigit).ToArray()); |
| | | 397 | | return int.TryParse(digits, out var days) ? days : null; |
| | | 398 | | } |
| | | 399 | | |
| | | 400 | | private async Task TrackPlacementAsync(MoskvorechieIstraQueueItem item, string comment, string changedBy, Cancellati |
| | | 401 | | { |
| | | 402 | | try |
| | | 403 | | { |
| | | 404 | | await _supplierState.MarkInCartAsync(item.OrderId, ApiSupplierSettings.MoskvorechieIstraProviderKey, |
| | | 405 | | ApiSupplierBasketCartStatus.Pending, externalBasketItemId: item.OfferId, user: changedBy, |
| | | 406 | | deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction, |
| | | 407 | | var entry = new ApiSupplierBasketHistoryEntry(ApiSupplierSettings.MoskvorechieIstraProviderKey, |
| | | 408 | | "Москворечье (Истра)", "/images/moskvorechie-logo.gif", true, null, item.OfferId, |
| | | 409 | | item.Article, item.ProductName, item.SourcePrice, "RUB", item.Quantity, item.ShipmentNumber, |
| | | 410 | | ClientName: item.ClientName, WarehouseName: item.Direction, CartStatus: ApiSupplierBasketCartStatus.Pend |
| | | 411 | | SupplierCartUrl: "/SupplierCart?provider=moskvorechieistra&supplierTab=basket"); |
| | | 412 | | await _orderHistory.AddOrderHistory(new OrderHistory |
| | | 413 | | { |
| | | 414 | | OrderId = item.OrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | | 415 | | ColumnDisplayName = "Корзина Москворечье (Истра)", NewValue = JsonSerializer.Serialize(entry, ApiSupplie |
| | | 416 | | ChangedAt = DateTime.Now, ChangedBy = changedBy |
| | | 417 | | }); |
| | | 418 | | } |
| | | 419 | | catch |
| | | 420 | | { |
| | | 421 | | // Запись статуса и истории не должна отменять уже успешно добавленную строку портала. |
| | | 422 | | } |
| | | 423 | | } |
| | | 424 | | } |