| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using Microsoft.EntityFrameworkCore; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonDomains.Models.SupplierStatuses; |
| | | 7 | | using OzonRepositories.Context; |
| | | 8 | | using Services.ApiServices.ProfitLigaApi; |
| | | 9 | | using Services.ApiSupplierSettingsServices; |
| | | 10 | | using Services.DataServices; |
| | | 11 | | using Services.SupplierStatusServices; |
| | | 12 | | |
| | | 13 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 14 | | |
| | | 15 | | /// <summary>Позиция, выбранная в проценке и переданная на сервер при проведении транзакции.</summary> |
| | | 16 | | public sealed record ProfitLigaQueueItem( |
| | | 17 | | int OrderId, string ArticleId, string WarehouseId, string? ProductCode, string Article, string Brand, |
| | | 18 | | string? ProductName, string? WarehouseName, string? ShipmentNumber, string? ClientName, |
| | | 19 | | decimal Quantity, int Multi, decimal? SourcePrice, string? CurrencyCode, |
| | | 20 | | string? ZasylWarehouseName = null, int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, |
| | | 21 | | string? Direction = null); |
| | | 22 | | |
| | | 23 | | public sealed record ProfitLigaCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages) |
| | | 24 | | { |
| | | 25 | | public bool IsSuccess => Failed == 0; |
| | | 26 | | |
| | | 27 | | public string ToHtmlBlock() |
| | | 28 | | { |
| | | 29 | | if (Added == 0 && Skipped == 0 && Failed == 0) return string.Empty; |
| | | 30 | | var css = Failed > 0 ? "alert-warning" : "alert-success"; |
| | | 31 | | var summary = Failed == 0 |
| | | 32 | | ? $"Добавлено в гибридную корзину Studio2 и Профит-Лиги: {Added}. Пропущено: {Skipped}." |
| | | 33 | | : $"Добавлено: {Added}, пропущено: {Skipped}, ошибок: {Failed}."; |
| | | 34 | | var details = Messages.Count == 0 |
| | | 35 | | ? string.Empty |
| | | 36 | | : "<br/>" + string.Join("<br/>", Messages.Select(System.Net.WebUtility.HtmlEncode)); |
| | | 37 | | return $"<div class='alert {css} mb-0'><strong>Корзина Профит-Лиги.</strong> " + |
| | | 38 | | $"{summary}{details}</div>"; |
| | | 39 | | } |
| | | 40 | | } |
| | | 41 | | |
| | | 42 | | public sealed record ProfitLigaDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages); |
| | | 43 | | |
| | | 44 | | /// <summary> |
| | | 45 | | /// Гибридная корзина Профит-Лиги: каждая позиция одновременно хранится в ProfitLigaCartItems и |
| | | 46 | | /// добавляется в корзину поставщика. Studio2 остаётся источником связи с заказом и точкой доставки. |
| | | 47 | | /// Перед оформлением API-корзина временно собирается только из выбранной группы, потому что |
| | | 48 | | /// POST /cart/order оформляет её целиком. |
| | | 49 | | /// Аналога createTestOrder у API нет, поэтому проверка перед заказом — это повторная проценка |
| | | 50 | | /// (/search/crosses) со сверкой цены и остатка. |
| | | 51 | | /// </summary> |
| | | 52 | | public sealed class ProfitLigaCartService |
| | | 53 | | { |
| | | 54 | | public const string ProviderKey = "ProfitLiga"; |
| | | 55 | | private const string SupplierDisplayName = "Профит-Лига"; |
| | | 56 | | private const string LogoUrl = "/images/profit-liga-logo.svg"; |
| | | 57 | | private const string CartUrl = "/SupplierCart?provider=profitliga&supplierTab=basket"; |
| | | 58 | | private const string CurrencyCode = "RUB"; |
| | | 59 | | |
| | | 60 | | /// <summary>Окно действия проверки — как у Armtek, ML-Auto и FavoritParts.</summary> |
| | 1 | 61 | | private static readonly TimeSpan ReadyWindow = TimeSpan.FromMinutes(5); |
| | | 62 | | |
| | | 63 | | private readonly ApplicationDbContext _db; |
| | | 64 | | private readonly ProfitLigaDataManager _profitLiga; |
| | | 65 | | private readonly ProfitLigaPointMappingService _points; |
| | | 66 | | private readonly IOrderHistoryDataService _history; |
| | | 67 | | private readonly IOrderSupplierStateService _supplierState; |
| | | 68 | | private readonly IOrderSupplierStatusSyncService _supplierStatusSync; |
| | | 69 | | private readonly ILogger<ProfitLigaCartService> _logger; |
| | | 70 | | |
| | 49 | 71 | | public ProfitLigaCartService( |
| | 49 | 72 | | ApplicationDbContext db, |
| | 49 | 73 | | ProfitLigaDataManager profitLiga, |
| | 49 | 74 | | ProfitLigaPointMappingService points, |
| | 49 | 75 | | IOrderHistoryDataService history, |
| | 49 | 76 | | IOrderSupplierStateService supplierState, |
| | 49 | 77 | | IOrderSupplierStatusSyncService supplierStatusSync, |
| | 49 | 78 | | ILogger<ProfitLigaCartService> logger) |
| | | 79 | | { |
| | 49 | 80 | | _db = db; |
| | 49 | 81 | | _profitLiga = profitLiga; |
| | 49 | 82 | | _points = points; |
| | 49 | 83 | | _history = history; |
| | 49 | 84 | | _supplierState = supplierState; |
| | 49 | 85 | | _supplierStatusSync = supplierStatusSync; |
| | 49 | 86 | | _logger = logger; |
| | 49 | 87 | | } |
| | | 88 | | |
| | | 89 | | // ---------- чтение ---------- |
| | | 90 | | |
| | | 91 | | public Task<List<ProfitLigaCartItem>> GetActiveAsync(CancellationToken ct = default) => |
| | 1 | 92 | | _db.ProfitLigaCartItems.AsNoTracking().Include(x => x.StudioOrder) |
| | 1 | 93 | | .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status)) |
| | 1 | 94 | | .OrderByDescending(x => x.CreatedAtUtc) |
| | 1 | 95 | | .ToListAsync(ct); |
| | | 96 | | |
| | | 97 | | public Task<int> CountActiveAsync(CancellationToken ct = default) => |
| | 1 | 98 | | _db.ProfitLigaCartItems.CountAsync(x => ProfitLigaCartItemStatus.Active.Contains(x.Status), ct); |
| | | 99 | | |
| | | 100 | | public Task<List<ProfitLigaOrder>> GetOrdersAsync(CancellationToken ct = default) => |
| | 1 | 101 | | _db.ProfitLigaOrders.AsNoTracking().Include(x => x.Items) |
| | 1 | 102 | | .OrderByDescending(x => x.CreatedAtUtc).Take(200).ToListAsync(ct); |
| | | 103 | | |
| | | 104 | | /// <summary>Состояние зеркала API; используется для проверки целостности гибридной корзины.</summary> |
| | | 105 | | public Task<IReadOnlyList<ProfitLigaCartLine>> GetApiCartAsync(CancellationToken ct = default) => |
| | 1 | 106 | | _profitLiga.GetCartAsync(ct); |
| | | 107 | | |
| | | 108 | | public Task<Newtonsoft.Json.Linq.JToken> GetApiOrdersAsync(int page = 0, CancellationToken ct = default) => |
| | 1 | 109 | | _profitLiga.GetOrdersAsync(page, ct: ct); |
| | | 110 | | |
| | | 111 | | // ---------- постановка в корзину ---------- |
| | | 112 | | |
| | | 113 | | /// <summary>Кладёт позицию одновременно в локальную и API-корзину. Если API не принял строку, |
| | | 114 | | /// локальная запись не создаётся: две части гибридной корзины не расходятся.</summary> |
| | | 115 | | public async Task<ProfitLigaCartOperationResult> QueueAsync(IReadOnlyCollection<ProfitLigaQueueItem> queue, |
| | | 116 | | string changedBy, CancellationToken ct = default) |
| | | 117 | | { |
| | 14 | 118 | | if (queue.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, []); |
| | | 119 | | |
| | 36 | 120 | | int added = 0, skipped = 0, failed = 0; |
| | 12 | 121 | | var messages = new List<string>(); |
| | | 122 | | |
| | 48 | 123 | | foreach (var item in queue) |
| | | 124 | | { |
| | 12 | 125 | | var label = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber!; |
| | | 126 | | |
| | 12 | 127 | | if (string.IsNullOrWhiteSpace(item.ProductCode)) |
| | | 128 | | { |
| | 2 | 129 | | failed++; |
| | 2 | 130 | | messages.Add($"{label}: у предложения нет кода номенклатуры — Профит-Лига не примет его в корзину."); |
| | 2 | 131 | | continue; |
| | | 132 | | } |
| | | 133 | | |
| | 10 | 134 | | if (await _db.ProfitLigaCartItems.AnyAsync( |
| | 10 | 135 | | x => x.StudioOrderId == item.OrderId && ProfitLigaCartItemStatus.Active.Contains(x.Status), ct)) |
| | | 136 | | { |
| | 1 | 137 | | skipped++; |
| | 1 | 138 | | messages.Add($"{label}: позиция Профит-Лиги уже в корзине."); |
| | 1 | 139 | | continue; |
| | | 140 | | } |
| | | 141 | | |
| | 9 | 142 | | var point = await _points.ResolveAsync(item.OrderId, item.ZasylWarehouseName, ct); |
| | | 143 | | ProfitLigaCartOperation apiOperation; |
| | | 144 | | try |
| | | 145 | | { |
| | 9 | 146 | | apiOperation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity, |
| | 9 | 147 | | item.ProductCode, $"Studio2 {label} (ID {item.OrderId})", ct); |
| | 8 | 148 | | } |
| | 1 | 149 | | catch (ProfitLigaApiException ex) |
| | | 150 | | { |
| | 1 | 151 | | failed++; |
| | 1 | 152 | | messages.Add($"{label}: не удалось добавить позицию в корзину Профит-Лиги. {ex.Message}"); |
| | 1 | 153 | | continue; |
| | | 154 | | } |
| | 8 | 155 | | if (!apiOperation.IsSuccess) |
| | | 156 | | { |
| | 1 | 157 | | failed++; |
| | 1 | 158 | | messages.Add($"{label}: Профит-Лига не добавила позицию в корзину — {apiOperation.Describe()}."); |
| | 1 | 159 | | continue; |
| | | 160 | | } |
| | | 161 | | |
| | 7 | 162 | | var cartItem = new ProfitLigaCartItem |
| | 7 | 163 | | { |
| | 7 | 164 | | StudioOrderId = item.OrderId, |
| | 7 | 165 | | ActiveStudioOrderId = item.OrderId, |
| | 7 | 166 | | ArticleId = item.ArticleId, |
| | 7 | 167 | | WarehouseId = item.WarehouseId, |
| | 7 | 168 | | ProductCode = item.ProductCode, |
| | 7 | 169 | | Article = item.Article, |
| | 7 | 170 | | Brand = item.Brand, |
| | 7 | 171 | | ProductName = item.ProductName, |
| | 7 | 172 | | WarehouseName = item.WarehouseName, |
| | 7 | 173 | | PointCode = point?.PointCode, |
| | 7 | 174 | | PointName = point?.PointName, |
| | 7 | 175 | | Quantity = item.Quantity, |
| | 7 | 176 | | Multi = item.Multi, |
| | 7 | 177 | | SourcePrice = item.SourcePrice, |
| | 7 | 178 | | CurrencyCode = string.IsNullOrWhiteSpace(item.CurrencyCode) ? CurrencyCode : item.CurrencyCode!, |
| | 7 | 179 | | CreatedBy = changedBy |
| | 7 | 180 | | }; |
| | | 181 | | |
| | 7 | 182 | | _db.ProfitLigaCartItems.Add(cartItem); |
| | | 183 | | try |
| | | 184 | | { |
| | 7 | 185 | | await _db.SaveChangesAsync(ct); |
| | 7 | 186 | | } |
| | 0 | 187 | | catch (DbUpdateException) |
| | | 188 | | { |
| | | 189 | | // Гонка по уникальному индексу ActiveStudioOrderId: позицию уже положил параллельный запрос. |
| | 0 | 190 | | _db.Entry(cartItem).State = EntityState.Detached; |
| | 0 | 191 | | await RollbackApiCartAsync([cartItem], ct); |
| | 0 | 192 | | skipped++; |
| | 0 | 193 | | messages.Add($"{label}: позиция Профит-Лиги уже в корзине."); |
| | 0 | 194 | | continue; |
| | | 195 | | } |
| | | 196 | | |
| | 7 | 197 | | added++; |
| | 7 | 198 | | await RecordHistoryAsync(cartItem, label, true, null, ApiSupplierBasketCartStatus.Pending, null, changedBy); |
| | 14 | 199 | | await TrackAsync(() => _supplierState.MarkInCartAsync(item.OrderId, ProviderKey, |
| | 14 | 200 | | SupplierCartStatusCode.Pending, cartItem.Id, user: changedBy, |
| | 14 | 201 | | deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, |
| | 14 | 202 | | direction: item.Direction, ct: ct)); |
| | 7 | 203 | | } |
| | | 204 | | |
| | 12 | 205 | | return new ProfitLigaCartOperationResult(added, skipped, failed, messages); |
| | 13 | 206 | | } |
| | | 207 | | |
| | | 208 | | // ---------- проверка ---------- |
| | | 209 | | |
| | | 210 | | /// <summary>Свежая перепроценка выбранных строк: у API нет dry-run, поэтому сверяем цену, остаток |
| | | 211 | | /// и кратность повторным /search/crosses по тому же складу поставщика.</summary> |
| | | 212 | | public async Task<ProfitLigaCartOperationResult> TestAsync(IReadOnlyCollection<long> cartItemIds, |
| | | 213 | | string changedBy, CancellationToken ct = default) |
| | | 214 | | { |
| | 9 | 215 | | var items = await LoadForActionAsync(cartItemIds, ct); |
| | 10 | 216 | | if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для проверки."]); |
| | | 217 | | |
| | 16 | 218 | | int ready = 0, failed = 0; |
| | 8 | 219 | | var messages = new List<string>(); |
| | | 220 | | |
| | 32 | 221 | | foreach (var item in items) |
| | | 222 | | { |
| | 8 | 223 | | var label = $"Арт. {item.Article} · {item.Brand}"; |
| | | 224 | | try |
| | | 225 | | { |
| | 8 | 226 | | var offer = await FindOfferAsync(item, ct); |
| | 7 | 227 | | if (offer is null) |
| | | 228 | | { |
| | 2 | 229 | | item.Status = ProfitLigaCartItemStatus.Failed; |
| | 2 | 230 | | item.LastError = $"{label}: предложение на складе «{item.WarehouseName ?? item.WarehouseId}» больше |
| | 2 | 231 | | failed++; |
| | 2 | 232 | | messages.Add(item.LastError); |
| | 2 | 233 | | continue; |
| | | 234 | | } |
| | | 235 | | |
| | 5 | 236 | | if (offer.Quantity < item.Quantity) |
| | | 237 | | { |
| | 1 | 238 | | item.Status = ProfitLigaCartItemStatus.Failed; |
| | 1 | 239 | | item.LastError = $"{label}: на складе {offer.Quantity.ToString("0.##", CultureInfo.InvariantCulture) |
| | 1 | 240 | | failed++; |
| | 1 | 241 | | messages.Add(item.LastError); |
| | 1 | 242 | | continue; |
| | | 243 | | } |
| | | 244 | | |
| | 4 | 245 | | if (offer.Multi > 1 && item.Quantity % offer.Multi != 0) |
| | | 246 | | { |
| | 1 | 247 | | item.Status = ProfitLigaCartItemStatus.Failed; |
| | 1 | 248 | | item.LastError = $"{label}: количество должно быть кратно {offer.Multi}."; |
| | 1 | 249 | | failed++; |
| | 1 | 250 | | messages.Add(item.LastError); |
| | 1 | 251 | | continue; |
| | | 252 | | } |
| | | 253 | | |
| | 3 | 254 | | var priceChanged = item.SourcePrice != offer.Price; |
| | 3 | 255 | | item.SourcePrice = offer.Price; |
| | 3 | 256 | | item.Multi = offer.Multi; |
| | 3 | 257 | | item.ProductCode = offer.ProductCode ?? item.ProductCode; |
| | 3 | 258 | | item.DeliveryAtUtc = offer.DeliveryAtUtc; |
| | 3 | 259 | | item.Status = ProfitLigaCartItemStatus.Ready; |
| | 3 | 260 | | item.LastError = null; |
| | 3 | 261 | | item.TestedAtUtc = DateTime.UtcNow; |
| | 3 | 262 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | 3 | 263 | | item.TestResultJson = JsonSerializer.Serialize(offer); |
| | 3 | 264 | | ready++; |
| | 3 | 265 | | messages.Add(priceChanged |
| | 3 | 266 | | ? $"{label}: подтверждено, цена обновлена до {offer.Price.ToString("0.##", CultureInfo.InvariantCult |
| | 3 | 267 | | : $"{label}: подтверждено."); |
| | 3 | 268 | | } |
| | 1 | 269 | | catch (ProfitLigaApiException ex) |
| | | 270 | | { |
| | 1 | 271 | | item.Status = ProfitLigaCartItemStatus.Failed; |
| | 1 | 272 | | item.LastError = $"{label}: {ex.Message}"; |
| | 1 | 273 | | failed++; |
| | 1 | 274 | | messages.Add(item.LastError); |
| | 1 | 275 | | } |
| | 4 | 276 | | } |
| | | 277 | | |
| | 8 | 278 | | await _db.SaveChangesAsync(ct); |
| | 32 | 279 | | foreach (var item in items) |
| | | 280 | | { |
| | 16 | 281 | | await TrackAsync(() => _supplierState.MarkInCartAsync(item.StudioOrderId, ProviderKey, |
| | 16 | 282 | | item.Status, item.Id, user: changedBy, ct: ct)); |
| | | 283 | | } |
| | 8 | 284 | | return new ProfitLigaCartOperationResult(ready, 0, failed, messages); |
| | 9 | 285 | | } |
| | | 286 | | |
| | | 287 | | // ---------- оформление ---------- |
| | | 288 | | |
| | | 289 | | /// <summary>Оформляет выбранные строки. Гибридное зеркало временно освобождается, чтобы |
| | | 290 | | /// /cart/order не захватил позиции другой точки доставки, затем оставшиеся строки возвращаются в API.</summary> |
| | | 291 | | public async Task<ProfitLigaCartOperationResult> CheckoutAsync(IReadOnlyCollection<long> cartItemIds, |
| | | 292 | | string? methodId, string? paymentId, string changedBy, CancellationToken ct = default) |
| | | 293 | | { |
| | 15 | 294 | | var items = await LoadForActionAsync(cartItemIds, ct); |
| | 16 | 295 | | if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для оформления."]); |
| | | 296 | | |
| | 29 | 297 | | var stale = items.Where(x => x.Status != ProfitLigaCartItemStatus.Ready |
| | 29 | 298 | | || !x.TestedAtUtc.HasValue |
| | 29 | 299 | | || x.TestedAtUtc.Value < DateTime.UtcNow - ReadyWindow).ToList(); |
| | 14 | 300 | | if (stale.Count > 0) |
| | 1 | 301 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, |
| | 1 | 302 | | ["Перед оформлением выполните свежую проверку Профит-Лиги (действует 5 минут)."]); |
| | | 303 | | |
| | 26 | 304 | | if (items.Any(x => string.IsNullOrWhiteSpace(x.PointCode))) |
| | 1 | 305 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, |
| | 1 | 306 | | ["Для части позиций не выбрана торговая точка. Задайте связь склада и точки в настройках API-поставщика. |
| | | 307 | | |
| | 12 | 308 | | var apiCart = await _profitLiga.GetCartAsync(ct); |
| | 12 | 309 | | var activeItems = await _db.ProfitLigaCartItems.AsNoTracking() |
| | 12 | 310 | | .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status)) |
| | 12 | 311 | | .ToListAsync(ct); |
| | 16 | 312 | | if (apiCart.Any(line => !activeItems.Any(item => IsSameApiLine(item, line)))) |
| | 1 | 313 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, |
| | 1 | 314 | | ["В API-корзине Профит-Лиги есть позиции не из Studio2. Удалите их у поставщика и повторите оформление." |
| | | 315 | | |
| | 22 | 316 | | var ids = items.Select(x => x.Id).ToList(); |
| | 11 | 317 | | var locked = await _db.ProfitLigaCartItems |
| | 11 | 318 | | .Where(x => ids.Contains(x.Id) && x.Status == ProfitLigaCartItemStatus.Ready) |
| | 11 | 319 | | .ExecuteUpdateAsync(s => s |
| | 11 | 320 | | .SetProperty(x => x.Status, ProfitLigaCartItemStatus.Processing) |
| | 11 | 321 | | .SetProperty(x => x.UpdatedAtUtc, DateTime.UtcNow), ct); |
| | 11 | 322 | | if (locked != items.Count) |
| | 0 | 323 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, ["Корзина уже оформляется в другом запросе."]); |
| | | 324 | | |
| | 55 | 325 | | foreach (var item in items) item.Status = ProfitLigaCartItemStatus.Processing; |
| | | 326 | | |
| | 11 | 327 | | var clearResult = await RemoveApiLinesAsync(apiCart, ct); |
| | 11 | 328 | | if (clearResult.Failed > 0) |
| | | 329 | | { |
| | 1 | 330 | | await ReleaseAsync(items, "Не удалось подготовить корзину поставщика к оформлению.", ct); |
| | 1 | 331 | | await RestoreApiMirrorAsync(ct); |
| | 1 | 332 | | return clearResult; |
| | | 333 | | } |
| | | 334 | | |
| | 20 | 335 | | int ordered = 0, failed = 0; |
| | 10 | 336 | | var messages = new List<string>(); |
| | | 337 | | ProfitLigaOrderParams parameters; |
| | | 338 | | try |
| | | 339 | | { |
| | 10 | 340 | | parameters = await _profitLiga.GetOrderParamsAsync(ct); |
| | 9 | 341 | | } |
| | 1 | 342 | | catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException or TaskCanceledException) |
| | | 343 | | { |
| | 1 | 344 | | await ReleaseAsync(items, ex.Message, ct); |
| | 1 | 345 | | await RestoreApiMirrorAsync(ct); |
| | 1 | 346 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, |
| | 1 | 347 | | [$"Профит-Лига: не удалось получить параметры оформления. {ex.Message}"]); |
| | | 348 | | } |
| | | 349 | | |
| | | 350 | | // Точка доставки — состояние аккаунта, поэтому одна группа = один поход в /cart/point и /cart/order. |
| | 45 | 351 | | foreach (var group in items.GroupBy(x => x.PointCode!, StringComparer.Ordinal)) |
| | | 352 | | { |
| | 9 | 353 | | var groupItems = group.ToList(); |
| | | 354 | | try |
| | | 355 | | { |
| | 9 | 356 | | var result = await CheckoutGroupAsync(group.Key, groupItems, parameters, methodId, paymentId, changedBy, |
| | 9 | 357 | | ordered += result.Added; |
| | 9 | 358 | | failed += result.Failed; |
| | 9 | 359 | | messages.AddRange(result.Messages); |
| | 9 | 360 | | } |
| | 0 | 361 | | catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException or TaskCanceledException) |
| | | 362 | | { |
| | 0 | 363 | | _logger.LogWarning(ex, "Профит-Лига: оформление точки {PointCode} не выполнено", group.Key); |
| | 0 | 364 | | await ReleaseAsync(groupItems, ex.Message, ct); |
| | 0 | 365 | | failed += groupItems.Count; |
| | 0 | 366 | | messages.Add($"Профит-Лига: {ex.Message}"); |
| | | 367 | | } |
| | 9 | 368 | | } |
| | | 369 | | |
| | 9 | 370 | | await _db.SaveChangesAsync(ct); |
| | 9 | 371 | | await RestoreApiMirrorAsync(ct); |
| | 9 | 372 | | await SynchronizeOrderStatusesAsync(items, messages, ct); |
| | 9 | 373 | | return new ProfitLigaCartOperationResult(ordered, 0, failed, messages); |
| | 15 | 374 | | } |
| | | 375 | | |
| | | 376 | | /// <summary>«Оформить с проведением»: кладёт позиции в локальную корзину и сразу оформляет их.</summary> |
| | | 377 | | public async Task<ProfitLigaDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<ProfitLigaQueueItem> queue |
| | | 378 | | string changedBy, CancellationToken ct = default) |
| | | 379 | | { |
| | 3 | 380 | | var queued = await QueueAsync(queue, changedBy, ct); |
| | 3 | 381 | | if (queued.Added == 0) |
| | 1 | 382 | | return new ProfitLigaDirectCheckoutResult(false, queued.Messages.Count > 0 |
| | 1 | 383 | | ? queued.Messages |
| | 1 | 384 | | : ["Профит-Лига: не удалось поставить позиции в корзину."]); |
| | | 385 | | |
| | 4 | 386 | | var orderIds = queue.Select(x => x.OrderId).ToList(); |
| | 2 | 387 | | var ids = await _db.ProfitLigaCartItems |
| | 2 | 388 | | .Where(x => orderIds.Contains(x.StudioOrderId) && ProfitLigaCartItemStatus.Active.Contains(x.Status)) |
| | 2 | 389 | | .Select(x => x.Id).ToListAsync(ct); |
| | | 390 | | |
| | 2 | 391 | | var tested = await TestAsync(ids, changedBy, ct); |
| | 2 | 392 | | if (tested.Added == 0) |
| | 1 | 393 | | return new ProfitLigaDirectCheckoutResult(false, [.. queued.Messages, .. tested.Messages]); |
| | | 394 | | |
| | 1 | 395 | | var checkedOut = await CheckoutAsync(ids, null, null, changedBy, ct); |
| | 1 | 396 | | return new ProfitLigaDirectCheckoutResult(checkedOut.IsSuccess && checkedOut.Added > 0, |
| | 1 | 397 | | [.. queued.Messages, .. tested.Messages, .. checkedOut.Messages]); |
| | 3 | 398 | | } |
| | | 399 | | |
| | | 400 | | private async Task<ProfitLigaCartOperationResult> CheckoutGroupAsync(string pointCode, |
| | | 401 | | List<ProfitLigaCartItem> items, ProfitLigaOrderParams parameters, string? methodId, string? paymentId, |
| | | 402 | | string changedBy, CancellationToken ct) |
| | | 403 | | { |
| | 22 | 404 | | var point = parameters.AllPoints.FirstOrDefault(x => string.Equals(x.Code, pointCode, StringComparison.Ordinal)) |
| | 9 | 405 | | if (point is null) |
| | | 406 | | { |
| | 1 | 407 | | await ReleaseAsync(items, "Торговая точка недоступна в текущих настройках заказа Профит-Лиги.", ct); |
| | 1 | 408 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, |
| | 1 | 409 | | ["Профит-Лига: сохранённая торговая точка больше не доступна. Обновите связь в настройках."]); |
| | | 410 | | } |
| | | 411 | | |
| | 8 | 412 | | var method = ResolveMethod(parameters, point, methodId); |
| | 8 | 413 | | var payment = ResolvePayment(parameters, paymentId); |
| | 8 | 414 | | if (method is null || payment is null) |
| | | 415 | | { |
| | 0 | 416 | | await ReleaseAsync(items, "Профит-Лига не вернула способы доставки или оплаты.", ct); |
| | 0 | 417 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, |
| | 0 | 418 | | ["Профит-Лига: не удалось определить способ доставки или оплаты."]); |
| | | 419 | | } |
| | | 420 | | |
| | 8 | 421 | | await _profitLiga.SetPointAsync(pointCode, ct); |
| | | 422 | | |
| | 8 | 423 | | var placed = new List<ProfitLigaCartItem>(); |
| | 8 | 424 | | var messages = new List<string>(); |
| | 32 | 425 | | foreach (var item in items) |
| | | 426 | | { |
| | 8 | 427 | | var comment = BuildComment(item); |
| | 8 | 428 | | var operation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity, |
| | 8 | 429 | | item.ProductCode ?? string.Empty, comment, ct); |
| | 24 | 430 | | if (operation.IsSuccess) { placed.Add(item); continue; } |
| | | 431 | | |
| | 0 | 432 | | messages.Add($"Арт. {item.Article} · {item.Brand}: {operation.Describe()}."); |
| | 0 | 433 | | await ReleaseAsync([item], operation.Describe(), ct); |
| | 0 | 434 | | } |
| | | 435 | | |
| | 8 | 436 | | if (placed.Count == 0) |
| | | 437 | | { |
| | 0 | 438 | | await RollbackApiCartAsync(placed, ct); |
| | 0 | 439 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, messages); |
| | | 440 | | } |
| | | 441 | | |
| | 8 | 442 | | var created = await _profitLiga.CreateOrderAsync(method.Id, payment.Id, |
| | 8 | 443 | | point.IsPickup ? null : point.Code, point.IsPickup ? null : point.Address, |
| | 8 | 444 | | point.IsPickup ? point.Code : null, ct); |
| | | 445 | | |
| | 8 | 446 | | if (!created.IsSuccess) |
| | | 447 | | { |
| | | 448 | | // Заказ не создан — строки остались в корзине поставщика и помешали бы следующей попытке. |
| | 1 | 449 | | await RollbackApiCartAsync(placed, ct); |
| | 1 | 450 | | var error = string.IsNullOrWhiteSpace(created.Error) ? "заказ не создан." : created.Error!; |
| | 1 | 451 | | await ReleaseAsync(placed, error, ct); |
| | 1 | 452 | | messages.Add($"Профит-Лига: {error}"); |
| | 1 | 453 | | return new ProfitLigaCartOperationResult(0, 0, items.Count, messages); |
| | | 454 | | } |
| | | 455 | | |
| | 7 | 456 | | var numbers = string.Join(", ", created.OrderNumbers); |
| | 7 | 457 | | var order = await CreateOrderRecordAsync(created, point, method, payment, placed, changedBy, ct); |
| | | 458 | | |
| | 28 | 459 | | foreach (var item in placed) |
| | | 460 | | { |
| | 7 | 461 | | item.Status = ProfitLigaCartItemStatus.Ordered; |
| | 7 | 462 | | item.ActiveStudioOrderId = null; |
| | 7 | 463 | | item.LastError = null; |
| | 7 | 464 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | 7 | 465 | | await RecordHistoryAsync(item, ItemLabel(item), true, null, ApiSupplierBasketCartStatus.Ordered, numbers, ch |
| | 14 | 466 | | await TrackAsync(() => _supplierState.MarkOrderedAsync(item.StudioOrderId, ProviderKey, numbers, |
| | 14 | 467 | | user: changedBy, direction: item.WarehouseName, ct: ct)); |
| | 7 | 468 | | } |
| | | 469 | | |
| | 7 | 470 | | messages.Add($"Профит-Лига: создан заказ {numbers} ({placed.Count} поз.)."); |
| | | 471 | | _ = order; |
| | 7 | 472 | | return new ProfitLigaCartOperationResult(placed.Count, 0, items.Count - placed.Count, messages); |
| | 9 | 473 | | } |
| | | 474 | | |
| | | 475 | | /// <summary>Сразу после оформления читаем /orders/list, не заставляя пользователя ждать Hangfire.</summary> |
| | | 476 | | private async Task SynchronizeOrderStatusesAsync(IReadOnlyCollection<ProfitLigaCartItem> items, |
| | | 477 | | List<string> messages, CancellationToken ct) |
| | | 478 | | { |
| | 18 | 479 | | var orderIds = items.Where(x => x.Status == ProfitLigaCartItemStatus.Ordered) |
| | 16 | 480 | | .Select(x => x.StudioOrderId).Distinct().ToList(); |
| | 11 | 481 | | if (orderIds.Count == 0) return; |
| | | 482 | | |
| | | 483 | | try |
| | | 484 | | { |
| | 7 | 485 | | await _supplierStatusSync.SyncOrdersAsync(orderIds, ct); |
| | 6 | 486 | | var statuses = await _db.OrderSupplierStates.AsNoTracking() |
| | 6 | 487 | | .Include(x => x.SupplierStatus) |
| | 6 | 488 | | .Where(x => x.ProviderKey == ProviderKey && x.Stage == OrderSupplierStage.Ordered && |
| | 6 | 489 | | orderIds.Contains(x.OrderId) && x.LastSyncedAt != null && x.SupplierStatus != null) |
| | 6 | 490 | | .Select(x => x.SupplierStatus!.Name) |
| | 6 | 491 | | .Distinct() |
| | 6 | 492 | | .ToListAsync(ct); |
| | 6 | 493 | | if (statuses.Count > 0) |
| | 1 | 494 | | messages.Add($"Профит-Лига: статус в Studio2 обновлён — {string.Join(", ", statuses)}."); |
| | 6 | 495 | | } |
| | 1 | 496 | | catch (Exception ex) |
| | | 497 | | { |
| | | 498 | | // Заказ уже создан у поставщика: сбой немедленного опроса не должен превращать оформление в ошибку. |
| | 1 | 499 | | _logger.LogWarning(ex, "Профит-Лига: не удалось сразу обновить статус заказов {OrderIds}", |
| | 1 | 500 | | string.Join(", ", orderIds)); |
| | 1 | 501 | | } |
| | 9 | 502 | | } |
| | | 503 | | |
| | | 504 | | /// <summary>Один номер из orders[] — одна строка ProfitLigaOrders: поставщик разбивает |
| | | 505 | | /// корзину по складам и возвращает несколько номеров на один вызов.</summary> |
| | | 506 | | private async Task<ProfitLigaOrder> CreateOrderRecordAsync(ProfitLigaCreateOrderResult created, |
| | | 507 | | ProfitLigaPoint point, ProfitLigaDeliveryMethod method, ProfitLigaPaymentMethod payment, |
| | | 508 | | List<ProfitLigaCartItem> items, string changedBy, CancellationToken ct) |
| | | 509 | | { |
| | 7 | 510 | | ProfitLigaOrder? first = null; |
| | 7 | 511 | | var isFirst = true; |
| | | 512 | | |
| | 30 | 513 | | foreach (var number in created.OrderNumbers) |
| | | 514 | | { |
| | 8 | 515 | | var order = new ProfitLigaOrder |
| | 8 | 516 | | { |
| | 8 | 517 | | OrderNumber = number, |
| | 8 | 518 | | PointCode = point.Code, |
| | 8 | 519 | | PointName = point.DisplayName, |
| | 8 | 520 | | DeliveryMethodId = method.Id, |
| | 8 | 521 | | DeliveryMethodName = method.Name, |
| | 8 | 522 | | PaymentId = payment.Id, |
| | 8 | 523 | | PaymentName = payment.Name, |
| | 8 | 524 | | Status = "Создан", |
| | 8 | 525 | | CreatedBy = changedBy, |
| | 8 | 526 | | ResponseJson = created.ResponseJson |
| | 8 | 527 | | }; |
| | | 528 | | |
| | | 529 | | // Состав по номерам API не раскрывает — позиции пишем к первому заказу, |
| | | 530 | | // а точная разбивка приезжает позже из /orders/list через статус-провайдер. |
| | 8 | 531 | | if (isFirst) |
| | | 532 | | { |
| | 28 | 533 | | foreach (var item in items) |
| | 7 | 534 | | order.Items.Add(new ProfitLigaOrderItem |
| | 7 | 535 | | { |
| | 7 | 536 | | ProfitLigaCartItemId = item.Id, |
| | 7 | 537 | | StudioOrderId = item.StudioOrderId, |
| | 7 | 538 | | Article = item.Article, |
| | 7 | 539 | | Brand = item.Brand, |
| | 7 | 540 | | ArticleId = item.ArticleId, |
| | 7 | 541 | | ProductCode = item.ProductCode, |
| | 7 | 542 | | Quantity = item.Quantity, |
| | 7 | 543 | | Price = item.SourcePrice, |
| | 7 | 544 | | CurrencyCode = item.CurrencyCode |
| | 7 | 545 | | }); |
| | 7 | 546 | | isFirst = false; |
| | | 547 | | } |
| | | 548 | | |
| | 8 | 549 | | _db.ProfitLigaOrders.Add(order); |
| | 8 | 550 | | first ??= order; |
| | | 551 | | } |
| | | 552 | | |
| | 7 | 553 | | await _db.SaveChangesAsync(ct); |
| | 7 | 554 | | return first!; |
| | 7 | 555 | | } |
| | | 556 | | |
| | | 557 | | // ---------- удаление ---------- |
| | | 558 | | |
| | | 559 | | public async Task<ProfitLigaCartOperationResult> RemoveManyAsync(IReadOnlyCollection<long> cartItemIds, |
| | | 560 | | bool removeAll, string changedBy, CancellationToken ct = default) |
| | | 561 | | { |
| | 6 | 562 | | var query = _db.ProfitLigaCartItems.Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status)); |
| | 11 | 563 | | if (!removeAll) query = query.Where(x => cartItemIds.Contains(x.Id)); |
| | | 564 | | |
| | 6 | 565 | | var items = await query.Include(x => x.StudioOrder).ToListAsync(ct); |
| | | 566 | | // Строку, которая прямо сейчас оформляется, удалять нельзя — её судьбу решает CheckoutAsync. |
| | 12 | 567 | | items = items.Where(x => x.Status != ProfitLigaCartItemStatus.Processing).ToList(); |
| | 8 | 568 | | if (items.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Нет позиций для удаления."]); |
| | | 569 | | |
| | 4 | 570 | | var removed = new List<ProfitLigaCartItem>(); |
| | 4 | 571 | | var messages = new List<string>(); |
| | 18 | 572 | | foreach (var item in items) |
| | | 573 | | { |
| | | 574 | | try |
| | | 575 | | { |
| | 5 | 576 | | var operation = await _profitLiga.RemoveFromCartAsync(item.ArticleId, item.WarehouseId, ct); |
| | 4 | 577 | | if (!operation.IsSuccess) |
| | | 578 | | { |
| | 1 | 579 | | messages.Add($"{ItemLabel(item)}: {operation.Describe()}."); |
| | 1 | 580 | | continue; |
| | | 581 | | } |
| | 3 | 582 | | } |
| | 1 | 583 | | catch (ProfitLigaApiException ex) |
| | | 584 | | { |
| | 1 | 585 | | messages.Add($"{ItemLabel(item)}: {ex.Message}"); |
| | 1 | 586 | | continue; |
| | | 587 | | } |
| | 3 | 588 | | item.Status = ProfitLigaCartItemStatus.Removed; |
| | 3 | 589 | | item.ActiveStudioOrderId = null; |
| | 3 | 590 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | 6 | 591 | | await TrackAsync(() => _supplierState.RemoveFromCartAsync(item.StudioOrderId, ProviderKey, changedBy, ct)); |
| | 3 | 592 | | removed.Add(item); |
| | 3 | 593 | | } |
| | | 594 | | |
| | 4 | 595 | | await _db.SaveChangesAsync(ct); |
| | 4 | 596 | | return new ProfitLigaCartOperationResult(removed.Count, 0, items.Count - removed.Count, |
| | 4 | 597 | | [$"Удалено позиций: {removed.Count}.", .. messages]); |
| | 6 | 598 | | } |
| | | 599 | | |
| | | 600 | | /// <summary>Очистка реальной корзины поставщика построчно — операции «очистить всё» у API нет.</summary> |
| | | 601 | | public async Task<ProfitLigaCartOperationResult> ClearApiCartAsync(IReadOnlyCollection<string>? keys, |
| | | 602 | | CancellationToken ct = default) |
| | | 603 | | { |
| | 4 | 604 | | var lines = await _profitLiga.GetCartAsync(ct); |
| | 4 | 605 | | var target = keys is null || keys.Count == 0 |
| | 4 | 606 | | ? lines |
| | 6 | 607 | | : lines.Where(x => keys.Contains(x.Key, StringComparer.Ordinal)).ToList(); |
| | 5 | 608 | | if (target.Count == 0) return new ProfitLigaCartOperationResult(0, 0, 0, ["Корзина Профит-Лиги уже пуста."]); |
| | | 609 | | |
| | 6 | 610 | | int removed = 0, failed = 0; |
| | 3 | 611 | | var messages = new List<string>(); |
| | 16 | 612 | | foreach (var line in target) |
| | | 613 | | { |
| | | 614 | | try |
| | | 615 | | { |
| | 5 | 616 | | var operation = await _profitLiga.RemoveFromCartAsync(line.ArticleId, line.WarehouseId, ct); |
| | 10 | 617 | | if (operation.IsSuccess) { removed++; continue; } |
| | 1 | 618 | | failed++; |
| | 1 | 619 | | messages.Add($"{line.Article}: {operation.Describe()}."); |
| | 1 | 620 | | } |
| | 1 | 621 | | catch (ProfitLigaApiException ex) |
| | | 622 | | { |
| | 1 | 623 | | failed++; |
| | 1 | 624 | | messages.Add($"{line.Article}: {ex.Message}"); |
| | 1 | 625 | | } |
| | 2 | 626 | | } |
| | | 627 | | |
| | 3 | 628 | | messages.Insert(0, $"Удалено из корзины поставщика: {removed}."); |
| | 3 | 629 | | return new ProfitLigaCartOperationResult(removed, 0, failed, messages); |
| | 4 | 630 | | } |
| | | 631 | | |
| | | 632 | | // ---------- вспомогательное ---------- |
| | | 633 | | |
| | | 634 | | private Task<List<ProfitLigaCartItem>> LoadForActionAsync(IReadOnlyCollection<long> cartItemIds, |
| | | 635 | | CancellationToken ct) => |
| | 24 | 636 | | _db.ProfitLigaCartItems.Include(x => x.StudioOrder) |
| | 24 | 637 | | .Where(x => cartItemIds.Contains(x.Id) && ProfitLigaCartItemStatus.Active.Contains(x.Status)) |
| | 24 | 638 | | .ToListAsync(ct); |
| | | 639 | | |
| | | 640 | | /// <summary>Ищет то же предложение (тот же склад поставщика) в свежей проценке.</summary> |
| | | 641 | | private async Task<ProfitLigaOfferSnapshot?> FindOfferAsync(ProfitLigaCartItem item, CancellationToken ct) |
| | | 642 | | { |
| | 8 | 643 | | var response = await _profitLiga.SearchCrossesAsync(item.Article, item.Brand, replaces: false, ct); |
| | 7 | 644 | | var offers = ProfitLigaPriceMapper.MapOriginals(response, item.Article, item.Brand, null, null); |
| | 7 | 645 | | var match = offers.FirstOrDefault(x => |
| | 13 | 646 | | string.Equals(x.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.WarehouseIdKey), item.WarehouseId, |
| | 13 | 647 | | StringComparison.Ordinal)); |
| | 9 | 648 | | if (match is null) return null; |
| | | 649 | | |
| | 5 | 650 | | var quantity = decimal.TryParse(match.Rest, NumberStyles.Any, CultureInfo.InvariantCulture, out var rest) ? rest |
| | 5 | 651 | | var multi = int.TryParse(match.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.MultiKey), |
| | 5 | 652 | | NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedMulti) ? parsedMulti : 0; |
| | | 653 | | |
| | 5 | 654 | | return new ProfitLigaOfferSnapshot( |
| | 5 | 655 | | match.SourcePrice ?? 0m, |
| | 5 | 656 | | quantity, |
| | 5 | 657 | | multi, |
| | 5 | 658 | | match.ProviderData?.GetValueOrDefault(ProfitLigaPriceMapper.ProductCodeKey), |
| | 5 | 659 | | match.DeliveryDaysMax > 0 ? DateTime.UtcNow.AddDays(match.DeliveryDaysMax) : null); |
| | 7 | 660 | | } |
| | | 661 | | |
| | | 662 | | /// <summary>Возврат строк из Processing в Failed, чтобы корзина не зависла в оформлении.</summary> |
| | | 663 | | private async Task ReleaseAsync(IReadOnlyCollection<ProfitLigaCartItem> items, string error, CancellationToken ct) |
| | | 664 | | { |
| | 16 | 665 | | foreach (var item in items) |
| | | 666 | | { |
| | 4 | 667 | | item.Status = ProfitLigaCartItemStatus.Failed; |
| | 4 | 668 | | item.LastError = error.Length > 1000 ? error[..1000] : error; |
| | 4 | 669 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 670 | | } |
| | 4 | 671 | | await _db.SaveChangesAsync(ct); |
| | 4 | 672 | | } |
| | | 673 | | |
| | | 674 | | /// <summary>Убирает из корзины поставщика строки, добавленные неудачной попыткой оформления.</summary> |
| | | 675 | | private async Task RollbackApiCartAsync(IReadOnlyCollection<ProfitLigaCartItem> placed, CancellationToken ct) |
| | | 676 | | { |
| | 4 | 677 | | foreach (var item in placed) |
| | | 678 | | { |
| | 2 | 679 | | try { await _profitLiga.RemoveFromCartAsync(item.ArticleId, item.WarehouseId, ct); } |
| | 0 | 680 | | catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException) |
| | | 681 | | { |
| | 0 | 682 | | _logger.LogWarning(ex, "Профит-Лига: не удалось откатить позицию {ArticleId} из корзины поставщика", |
| | 0 | 683 | | item.ArticleId); |
| | 0 | 684 | | } |
| | 1 | 685 | | } |
| | 1 | 686 | | } |
| | | 687 | | |
| | | 688 | | private static bool IsSameApiLine(ProfitLigaCartItem item, ProfitLigaCartLine line) => |
| | 3 | 689 | | string.Equals(item.ArticleId, line.ArticleId, StringComparison.Ordinal) && |
| | 3 | 690 | | string.Equals(item.WarehouseId, line.WarehouseId, StringComparison.Ordinal); |
| | | 691 | | |
| | | 692 | | private async Task<ProfitLigaCartOperationResult> RemoveApiLinesAsync( |
| | | 693 | | IReadOnlyCollection<ProfitLigaCartLine> lines, CancellationToken ct) |
| | | 694 | | { |
| | 11 | 695 | | var removed = 0; |
| | 11 | 696 | | var messages = new List<string>(); |
| | 24 | 697 | | foreach (var line in lines) |
| | | 698 | | { |
| | | 699 | | try |
| | | 700 | | { |
| | 1 | 701 | | var operation = await _profitLiga.RemoveFromCartAsync(line.ArticleId, line.WarehouseId, ct); |
| | 1 | 702 | | if (operation.IsSuccess) { removed++; continue; } |
| | 1 | 703 | | messages.Add($"{line.Article}: {operation.Describe()}."); |
| | 1 | 704 | | } |
| | 0 | 705 | | catch (ProfitLigaApiException ex) |
| | | 706 | | { |
| | 0 | 707 | | messages.Add($"{line.Article}: {ex.Message}"); |
| | 0 | 708 | | } |
| | 1 | 709 | | } |
| | | 710 | | |
| | 11 | 711 | | return new ProfitLigaCartOperationResult(removed, 0, lines.Count - removed, messages); |
| | 11 | 712 | | } |
| | | 713 | | |
| | | 714 | | private async Task RestoreApiMirrorAsync(CancellationToken ct) |
| | | 715 | | { |
| | 11 | 716 | | var active = await _db.ProfitLigaCartItems.AsNoTracking() |
| | 11 | 717 | | .Where(x => ProfitLigaCartItemStatus.Active.Contains(x.Status)) |
| | 11 | 718 | | .ToListAsync(ct); |
| | 11 | 719 | | var actual = await _profitLiga.GetCartAsync(ct); |
| | 33 | 720 | | foreach (var item in active.Where(item => !actual.Any(line => IsSameApiLine(item, line)))) |
| | | 721 | | { |
| | 3 | 722 | | var operation = await _profitLiga.AddToCartAsync(item.ArticleId, item.WarehouseId, item.Quantity, |
| | 3 | 723 | | item.ProductCode ?? string.Empty, $"Studio2 ID {item.StudioOrderId}", ct); |
| | 3 | 724 | | if (!operation.IsSuccess) |
| | 0 | 725 | | _logger.LogWarning("Профит-Лига: не удалось восстановить API-зеркало позиции {CartItemId}: {Status}", |
| | 0 | 726 | | item.Id, operation.Status); |
| | 3 | 727 | | } |
| | 11 | 728 | | } |
| | | 729 | | |
| | | 730 | | private static ProfitLigaDeliveryMethod? ResolveMethod(ProfitLigaOrderParams parameters, ProfitLigaPoint point, |
| | | 731 | | string? methodId) |
| | | 732 | | { |
| | 8 | 733 | | if (!string.IsNullOrWhiteSpace(methodId)) |
| | 2 | 734 | | return parameters.Methods.FirstOrDefault(x => string.Equals(x.Id, methodId, StringComparison.Ordinal)); |
| | | 735 | | |
| | | 736 | | // Точка самовывоза требует способа «самовывоз», торговая точка — доставки. |
| | 21 | 737 | | var pickup = parameters.Methods.FirstOrDefault(x => x.Name.Contains("самовывоз", StringComparison.OrdinalIgnoreC |
| | 14 | 738 | | var delivery = parameters.Methods.FirstOrDefault(x => x.Name.Contains("доставка", StringComparison.OrdinalIgnore |
| | 7 | 739 | | return point.IsPickup ? pickup ?? parameters.Methods.FirstOrDefault() |
| | 7 | 740 | | : delivery ?? parameters.Methods.FirstOrDefault(); |
| | | 741 | | } |
| | | 742 | | |
| | | 743 | | private static ProfitLigaPaymentMethod? ResolvePayment(ProfitLigaOrderParams parameters, string? paymentId) => |
| | 8 | 744 | | !string.IsNullOrWhiteSpace(paymentId) |
| | 2 | 745 | | ? parameters.Payments.FirstOrDefault(x => string.Equals(x.Id, paymentId, StringComparison.Ordinal)) |
| | 7 | 746 | | : parameters.Payments.FirstOrDefault(x => x.Name.Contains("безнал", StringComparison.OrdinalIgnoreCase)) |
| | 8 | 747 | | ?? parameters.Payments.FirstOrDefault(); |
| | | 748 | | |
| | | 749 | | /// <summary>Комментарий — единственный способ увидеть заказ Studio2 в интерфейсе поставщика.</summary> |
| | | 750 | | private static string BuildComment(ProfitLigaCartItem item) |
| | | 751 | | { |
| | 8 | 752 | | var label = string.IsNullOrWhiteSpace(item.StudioOrder?.ShipmentNumber) |
| | 8 | 753 | | ? $"ID {item.StudioOrderId}" |
| | 8 | 754 | | : item.StudioOrder!.ShipmentNumber; |
| | 8 | 755 | | var comment = $"Studio2 {label} (ID {item.StudioOrderId})"; |
| | 8 | 756 | | return comment.Length <= ProfitLigaDataManager.CommentMaxLength |
| | 8 | 757 | | ? comment |
| | 8 | 758 | | : comment[..ProfitLigaDataManager.CommentMaxLength]; |
| | | 759 | | } |
| | | 760 | | |
| | | 761 | | private static string ItemLabel(ProfitLigaCartItem item) => |
| | 9 | 762 | | string.IsNullOrWhiteSpace(item.StudioOrder?.ShipmentNumber) |
| | 9 | 763 | | ? $"ID {item.StudioOrderId}" |
| | 9 | 764 | | : item.StudioOrder!.ShipmentNumber!; |
| | | 765 | | |
| | | 766 | | private async Task RecordHistoryAsync(ProfitLigaCartItem item, string label, bool success, string? error, |
| | | 767 | | string cartStatus, string? orderNumber, string changedBy) |
| | | 768 | | { |
| | | 769 | | try |
| | | 770 | | { |
| | 14 | 771 | | var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, SupplierDisplayName, LogoUrl, success, error, |
| | 14 | 772 | | item.ArticleId, item.Article, item.ProductName ?? item.Brand, item.SourcePrice, item.CurrencyCode, |
| | 14 | 773 | | item.Quantity, label, WarehouseName: item.WarehouseName, DeliveryAddressName: item.PointName, |
| | 14 | 774 | | CartStatus: cartStatus, SupplierOrderNumber: orderNumber, SupplierCartUrl: CartUrl); |
| | 14 | 775 | | await _history.AddOrderHistory(new OrderHistory |
| | 14 | 776 | | { |
| | 14 | 777 | | OrderId = item.StudioOrderId, |
| | 14 | 778 | | ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | 14 | 779 | | ColumnDisplayName = "Корзина Профит-Лиги", |
| | 14 | 780 | | NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions), |
| | 14 | 781 | | ChangedAt = DateTime.Now, |
| | 14 | 782 | | ChangedBy = changedBy |
| | 14 | 783 | | }); |
| | 13 | 784 | | } |
| | 1 | 785 | | catch (Exception ex) |
| | | 786 | | { |
| | | 787 | | // Журнал не должен ломать уже выполненную работу с корзиной поставщика. |
| | 1 | 788 | | _logger.LogWarning(ex, "Профит-Лига: не удалось записать историю по заказу {OrderId}", item.StudioOrderId); |
| | 1 | 789 | | } |
| | 14 | 790 | | } |
| | | 791 | | |
| | | 792 | | private async Task TrackAsync(Func<Task> action) |
| | | 793 | | { |
| | 49 | 794 | | try { await action(); } |
| | 3 | 795 | | catch (Exception ex) { _logger.LogWarning(ex, "Профит-Лига: не удалось обновить состояние заказа у поставщика"); |
| | 25 | 796 | | } |
| | | 797 | | |
| | 42 | 798 | | private sealed record ProfitLigaOfferSnapshot(decimal Price, decimal Quantity, int Multi, string? ProductCode, |
| | 11 | 799 | | DateTime? DeliveryAtUtc); |
| | | 800 | | } |