| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using System.Xml.Linq; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | using Microsoft.Extensions.Logging.Abstractions; |
| | | 7 | | using OzonDomains.Models; |
| | | 8 | | using OzonRepositories.Context; |
| | | 9 | | using Services.ApiServices.MikadoApi; |
| | | 10 | | using Services.DataServices; |
| | | 11 | | using Services.SupplierStatusServices; |
| | | 12 | | |
| | | 13 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 14 | | |
| | 75 | 15 | | public sealed record MikadoQueueItem(int OrderId, string OrderCode, int StockId, string Article, string Brand, |
| | 61 | 16 | | string? ProductName, string? WarehouseName, int Quantity, int MinimumQuantity, decimal? SourcePrice, |
| | 35 | 17 | | int DeliveryDays, int DeliveryType, int ExpressId, string? Direction = null, |
| | 10 | 18 | | int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, |
| | 15 | 19 | | string? ShipmentNumber = null, string? ClientName = null); |
| | | 20 | | public sealed record MikadoCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages) |
| | | 21 | | { public bool IsSuccess => Failed == 0; } |
| | | 22 | | |
| | | 23 | | public sealed class MikadoCartService |
| | | 24 | | { |
| | | 25 | | private const string ProviderKey = ApiSupplierSettings.MikadoMskHod20ProviderKey; |
| | | 26 | | private const string SupplierCartUrl = "/SupplierCart?provider=mikadomskhod20&supplierTab=basket"; |
| | | 27 | | private const string DisplayName = "Микадо МскХод20"; |
| | | 28 | | private const string LogoUrl = "https://mikado-parts.ru/img/MikadoLogo.svg"; |
| | | 29 | | |
| | | 30 | | private readonly ApplicationDbContext _db; |
| | | 31 | | private readonly MikadoDataManager _api; |
| | | 32 | | private readonly IOrderSupplierStateService _supplierState; |
| | | 33 | | private readonly IOrderHistoryDataService _orderHistory; |
| | | 34 | | private readonly ILogger<MikadoCartService> _logger; |
| | | 35 | | public MikadoCartService(ApplicationDbContext db, MikadoDataManager api, |
| | | 36 | | IOrderSupplierStateService supplierState, IOrderHistoryDataService orderHistory, |
| | | 37 | | ILogger<MikadoCartService>? logger = null) => |
| | | 38 | | (_db, _api, _supplierState, _orderHistory, _logger) = (db, api, supplierState, orderHistory, |
| | | 39 | | logger ?? NullLogger<MikadoCartService>.Instance); |
| | | 40 | | |
| | | 41 | | public Task<List<MikadoCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.MikadoCartItems.AsNoTracking |
| | | 42 | | .Where(x => MikadoCartItemStatus.Active.Contains(x.Status)).OrderByDescending(x => x.CreatedAtUtc).ToListAsync(c |
| | | 43 | | public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.MikadoCartItems.CountAsync(x => MikadoCartI |
| | | 44 | | |
| | | 45 | | public async Task<MikadoCartResult> QueueAsync(IReadOnlyCollection<MikadoQueueItem> items, string user, Cancellation |
| | | 46 | | { |
| | | 47 | | var messages = new List<string>(); var added = 0; var skipped = 0; var failed = 0; |
| | | 48 | | foreach (var item in items) |
| | | 49 | | { |
| | | 50 | | if (item.Quantity < Math.Max(1, item.MinimumQuantity)) { failed++; messages.Add($"{item.Article}: количество |
| | | 51 | | if (await _db.MikadoCartItems.AnyAsync(x => x.ActiveStudioOrderId == item.OrderId, ct)) { skipped++; message |
| | | 52 | | try |
| | | 53 | | { |
| | | 54 | | var xml = await _api.AddToBasketStrictAsync(item.OrderCode, item.Quantity, item.DeliveryType, |
| | | 55 | | $"Studio2 ID {item.OrderId}", item.ExpressId, item.StockId, ct); |
| | | 56 | | var result = XDocument.Parse(xml).Descendants().FirstOrDefault(x => x.Name.LocalName == "Basket_Add_Stri |
| | | 57 | | var id = ReadInt(result, "ID"); var accepted = ReadInt(result, "OrderedQTY"); var message = ReadString(r |
| | | 58 | | if (id <= 0 || accepted != item.Quantity) |
| | | 59 | | { |
| | | 60 | | if (id > 0) await _api.DeleteBasketItemAsync(id, ct); |
| | | 61 | | failed++; messages.Add($"{item.Article}: Микадо не принял точное количество. {message}"); continue; |
| | | 62 | | } |
| | | 63 | | var row = new MikadoCartItem { StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId, |
| | | 64 | | OrderCode = item.OrderCode, StockId = item.StockId, ApiBasketItemId = id, Article = item.Article, Br |
| | | 65 | | ProductName = item.ProductName, WarehouseName = item.WarehouseName, Quantity = item.Quantity, Minimu |
| | | 66 | | SourcePrice = item.SourcePrice, DeliveryDays = item.DeliveryDays, DeliveryType = item.DeliveryType, |
| | | 67 | | _db.MikadoCartItems.Add(row); |
| | | 68 | | try { await _db.SaveChangesAsync(ct); } |
| | | 69 | | catch (Exception saveEx) |
| | | 70 | | { |
| | | 71 | | // Позиция уже лежит в корзине Микадо: без компенсации она осталась бы заказанной у поставщика без с |
| | | 72 | | _db.Entry(row).State = EntityState.Detached; |
| | | 73 | | try { await _api.DeleteBasketItemAsync(id, CancellationToken.None); messages.Add($"{item.Article}: п |
| | | 74 | | catch (Exception rollbackEx) { messages.Add($"{item.Article}: ВНИМАНИЕ, позиция осталась в корзине М |
| | | 75 | | failed++; messages.Add($"{item.Article}: не сохранено в Studio2. {saveEx.Message}"); continue; |
| | | 76 | | } |
| | | 77 | | await TrackPlacementAsync(item, row, user, ct); |
| | | 78 | | added++; messages.Add($"{item.Article}: добавлено в корзину Микадо."); |
| | | 79 | | } |
| | | 80 | | catch (Exception ex) when (ex is MikadoApiException or HttpRequestException or System.Xml.XmlException) |
| | | 81 | | { failed++; messages.Add($"{item.Article}: {ex.Message}"); } |
| | | 82 | | } |
| | | 83 | | return new MikadoCartResult(added, skipped, failed, messages); |
| | | 84 | | } |
| | | 85 | | |
| | | 86 | | public async Task<MikadoCartResult> TestAsync(IReadOnlyCollection<long> ids, string? user = null, CancellationToken |
| | | 87 | | { |
| | | 88 | | var rows = await _db.MikadoCartItems.Where(x => ids.Contains(x.Id) && MikadoCartItemStatus.Active.Contains(x.Sta |
| | | 89 | | var messages = new List<string>(); var ok = 0; var failed = 0; |
| | | 90 | | foreach (var row in rows) |
| | | 91 | | { |
| | | 92 | | try |
| | | 93 | | { |
| | | 94 | | var xml = await _api.CodeBrandStockInfoAsync(row.Article, row.Brand, ct); |
| | | 95 | | var line = XDocument.Parse(xml).Descendants().FirstOrDefault(x => x.Name.LocalName == "CodeBrandLine" && |
| | | 96 | | var stock = ReadInt(line, "StockQTY"); var min = Math.Max(1, ReadInt(line, "MinZakazQTY")); |
| | | 97 | | if (line is null || stock < row.Quantity || row.Quantity < min) { row.Status = MikadoCartItemStatus.Fail |
| | | 98 | | if (decimal.TryParse(ReadString(line, "PriceRUR"), NumberStyles.Number, CultureInfo.InvariantCulture, ou |
| | | 99 | | row.MinimumQuantity = min; row.Status = MikadoCartItemStatus.Ready; row.LastError = null; row.TestedAtUt |
| | | 100 | | } |
| | | 101 | | catch (Exception ex) { row.Status = MikadoCartItemStatus.Failed; row.LastError = ex.Message; failed++; messa |
| | | 102 | | } |
| | | 103 | | await _db.SaveChangesAsync(ct); |
| | | 104 | | // Проверка меняет статус позиции — состояние заказа у поставщика должно догнать его тем же действием. |
| | | 105 | | foreach (var row in rows) await TrackCartStatusAsync(row, row.Status, user, ct); |
| | | 106 | | return new MikadoCartResult(ok, 0, failed, messages); |
| | | 107 | | } |
| | | 108 | | |
| | | 109 | | public async Task<MikadoCartResult> RemoveAsync(IReadOnlyCollection<long>? ids, bool all, string? user = null, Cance |
| | | 110 | | { |
| | | 111 | | var rows = await _db.MikadoCartItems.Where(x => MikadoCartItemStatus.Active.Contains(x.Status) && (all || ids!.C |
| | | 112 | | var removed = 0; var failed = 0; var messages = new List<string>(); var closed = new List<MikadoCartItem>(); |
| | | 113 | | foreach (var row in rows) try { await _api.DeleteBasketItemAsync(row.ApiBasketItemId, ct); row.Status = MikadoCa |
| | | 114 | | await _db.SaveChangesAsync(ct); |
| | | 115 | | foreach (var row in closed) await TrackAsync(() => _supplierState.RemoveFromCartAsync(row.StudioOrderId, Provide |
| | | 116 | | return new MikadoCartResult(removed, 0, failed, messages); |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | /// <summary>Первое попадание позиции в корзину Микадо: статус поставщика + карточка в таймлайне заказа.</summary> |
| | | 120 | | private async Task TrackPlacementAsync(MikadoQueueItem item, MikadoCartItem row, string user, CancellationToken ct) |
| | | 121 | | { |
| | | 122 | | var direction = string.IsNullOrWhiteSpace(item.Direction) ? item.WarehouseName : item.Direction; |
| | | 123 | | var deliveryDaysMin = item.DeliveryDaysMin ?? (item.DeliveryDays > 0 ? item.DeliveryDays : null); |
| | | 124 | | var deliveryDaysMax = item.DeliveryDaysMax ?? (item.DeliveryDays > 0 ? item.DeliveryDays : null); |
| | | 125 | | try |
| | | 126 | | { |
| | | 127 | | await _supplierState.MarkInCartAsync(item.OrderId, ProviderKey, ApiSupplierBasketCartStatus.Pending, |
| | | 128 | | row.Id, row.ApiBasketItemId.ToString(CultureInfo.InvariantCulture), user, |
| | | 129 | | deliveryDaysMin, deliveryDaysMax, direction, ct); |
| | | 130 | | var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, DisplayName, LogoUrl, true, null, |
| | | 131 | | row.ApiBasketItemId.ToString(CultureInfo.InvariantCulture), item.Article, item.ProductName, |
| | | 132 | | item.SourcePrice, "RUB", item.Quantity, item.ShipmentNumber, |
| | | 133 | | ClientName: item.ClientName, WarehouseName: direction, CartStatus: ApiSupplierBasketCartStatus.Pending, |
| | | 134 | | SupplierCartUrl: SupplierCartUrl); |
| | | 135 | | await _orderHistory.AddOrderHistory(new OrderHistory |
| | | 136 | | { |
| | | 137 | | OrderId = item.OrderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | | 138 | | ColumnDisplayName = $"Корзина {DisplayName}", NewValue = JsonSerializer.Serialize(entry, ApiSupplierBask |
| | | 139 | | ChangedAt = DateTime.Now, ChangedBy = user |
| | | 140 | | }); |
| | | 141 | | } |
| | | 142 | | catch (Exception exception) |
| | | 143 | | { |
| | | 144 | | // Статус и история вторичны: позиция уже лежит в корзине Микадо и записана в Studio2. |
| | | 145 | | _logger.LogWarning(exception, "Не удалось записать историю корзины Микадо для заказа {OrderId}", item.OrderI |
| | | 146 | | } |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | /// <summary>Синхронизирует состояние заказа у поставщика со статусом строки корзины.</summary> |
| | | 150 | | private Task TrackCartStatusAsync(MikadoCartItem row, string cartStatus, string? user, CancellationToken ct) => |
| | | 151 | | TrackAsync(() => _supplierState.MarkInCartAsync(row.StudioOrderId, ProviderKey, cartStatus, row.Id, |
| | | 152 | | row.ApiBasketItemId.ToString(CultureInfo.InvariantCulture), user, ct: ct)); |
| | | 153 | | |
| | | 154 | | private async Task TrackAsync(Func<Task> action) |
| | | 155 | | { |
| | | 156 | | try { await action(); } |
| | | 157 | | catch (Exception exception) { _logger.LogWarning(exception, "Не удалось обновить состояние заказа у поставщика М |
| | | 158 | | } |
| | | 159 | | private static string? ReadString(XElement? x, string name) => x?.Elements().FirstOrDefault(e => e.Name.LocalName == |
| | | 160 | | private static int ReadInt(XElement? x, string name) => int.TryParse(ReadString(x, name), out var n) ? n : 0; |
| | | 161 | | } |