| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Text.Encodings.Web; |
| | | 4 | | using System.Text.Json; |
| | | 5 | | using Microsoft.EntityFrameworkCore; |
| | | 6 | | using Microsoft.Extensions.Caching.Memory; |
| | | 7 | | using Microsoft.Extensions.Logging; |
| | | 8 | | using Newtonsoft.Json.Linq; |
| | | 9 | | using OzonDomains.Models; |
| | | 10 | | using OzonDomains.Models.SupplierStatuses; |
| | | 11 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart; |
| | | 12 | | using OzonRepositories.Context; |
| | | 13 | | using Services.ApiServices.FavoritPartsApi; |
| | | 14 | | using Services.BackgroundJobsServices; |
| | | 15 | | using Services.DataServices; |
| | | 16 | | using Services.SignalRServices; |
| | | 17 | | using Services.SupplierStatusServices; |
| | | 18 | | |
| | | 19 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 20 | | |
| | | 21 | | public sealed record FavoritPartsQueueItem( |
| | | 22 | | int OrderId, string GoodsId, string WarehouseGroupId, string Article, string Brand, string? ProductName, |
| | | 23 | | string? ShipmentNumber, string? ClientName, decimal Quantity, decimal? SourcePrice, string? CurrencyCode, |
| | | 24 | | int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, string? Direction = null); |
| | | 25 | | |
| | | 26 | | public sealed record FavoritPartsCartOperationResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages) |
| | | 27 | | { |
| | | 28 | | public string ToHtmlBlock(string providerRoute = "favoritparts", string supplierName = "FavoritParts") |
| | | 29 | | { |
| | | 30 | | if (Added == 0 && Skipped == 0 && Failed == 0) return string.Empty; |
| | | 31 | | var rows = Messages.Take(20).Select(message => $"<li>{WebUtility.HtmlEncode(message)}</li>"); |
| | | 32 | | var tail = Messages.Count > 20 ? $"<li>… и ещё {Messages.Count - 20}</li>" : string.Empty; |
| | | 33 | | var cartLink = $"<a class='btn btn-outline-primary btn-sm' href='/SupplierCart?provider={WebUtility.UrlEncode(pr |
| | | 34 | | var verificationNotice = Added > 0 |
| | | 35 | | ? $"<div class='alert alert-warning py-2 px-3 mt-2 mb-0 small'><i class='fas fa-triangle-exclamation me-1'>< |
| | | 36 | | : string.Empty; |
| | | 37 | | return "<div class='alert alert-info border-primary-subtle mb-0'>" + |
| | | 38 | | $"<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина {WebUtility |
| | | 39 | | $"<div class='small mb-1'>Добавлено: <b>{Added}</b>, уже было в корзине: <b>{Skipped}</b>, с ошибками: <b |
| | | 40 | | $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{tail}</ul>" + |
| | | 41 | | $"<div class='d-flex flex-wrap gap-2 mt-2'>{cartLink}</div>{verificationNotice}</div>"; |
| | | 42 | | } |
| | | 43 | | } |
| | | 44 | | |
| | 0 | 45 | | public sealed record FavoritPartsDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages); |
| | | 46 | | |
| | | 47 | | /// <summary>Строка реальной серверной корзины FavoritParts (GET ws/v1/cart/), развёрнутая из вложенных |
| | | 48 | | /// goods/warehouseGroup/warehouseShipping словарей ответа. Key — составной идентификатор |
| | | 49 | | /// "{goods}|{warehouseGroup}", используется вместо суррогатного Id локальной таблицы: источник истины — |
| | | 50 | | /// сам API, а не локальный кэш. Одна строка корзины может относиться сразу к нескольким заказам Studio2 |
| | | 51 | | /// (cart/add не суммирует количество, поэтому одинаковый товар с одного склада хранения кладётся одной |
| | | 52 | | /// строкой на суммарное количество) — привязка хранится в FavoritPartsCartItems, а comment остаётся |
| | | 53 | | /// вспомогательным каналом для строк, добавленных до перехода на агрегацию.</summary> |
| | | 54 | | public sealed record FavoritPartsApiCartLine( |
| | | 55 | | Guid Goods, Guid WarehouseGroup, Guid WarehouseShipping, |
| | | 56 | | decimal Count, decimal Price, decimal Stock, bool NotRefund, DateTimeOffset? ShipmentDate, string? Comment, |
| | | 57 | | string? GoodsName, string? Brand, string? Number, int Rate, |
| | | 58 | | string? WarehouseGroupCode, string? WarehouseShippingName) |
| | | 59 | | { |
| | | 60 | | public string Key => FavoritPartsCartService.BuildCartKey(Goods, WarehouseGroup); |
| | | 61 | | public int? StudioOrderId => StudioOrderCommentLink.ExtractOrderId(Comment); |
| | | 62 | | /// <summary>Дата отгрузки в московском поясе — в этом же виде её ждёт POST order/ (ShippingDate).</summary> |
| | | 63 | | public DateOnly? ShippingDate => FavoritPartsPriceMapper.ToMoscowDate(ShipmentDate); |
| | | 64 | | } |
| | | 65 | | |
| | | 66 | | /// <summary>Реальная серверная корзина FavoritParts (ws/v1/cart/*) + реальное оформление заказа |
| | | 67 | | /// (POST ws/v1/order/). Один заказ FavoritParts = один склад отгрузки + одна дата отгрузки — обе |
| | | 68 | | /// становятся известны только из ответа GET cart/, поэтому группировка перед созданием заказа |
| | | 69 | | /// происходит в момент оформления (CreateOrdersFromLinesAsync), а не в момент добавления в корзину. |
| | | 70 | | /// Корзина и заказы на /SupplierCart читаются напрямую из API (GetApiCartAsync/GetApiOrdersAsync); |
| | | 71 | | /// локальная таблица FavoritPartsCartItems держит привязку строк корзины к заказам Studio2 — по |
| | | 72 | | /// документации cart/add устанавливает последнее переданное количество, а не сумму, поэтому одна |
| | | 73 | | /// строка корзины может обслуживать несколько заказов Studio2, и comment на всех уже не хватает.</summary> |
| | | 74 | | public class FavoritPartsCartService |
| | | 75 | | { |
| | | 76 | | protected string ProviderKey { get; } |
| | | 77 | | private readonly string _supplierDisplayName; |
| | | 78 | | private readonly string _providerRoute; |
| | | 79 | | private const string LogoUrl = "/images/favorit-parts-logo.png"; |
| | | 80 | | private string ProfileCacheKey => $"{ProviderKey}:Profile"; |
| | | 81 | | /// <summary>Ограничение поля comment в cart/add и в строке заказа (документация: строка <100 символов).</summary |
| | | 82 | | public const int CommentMaxLength = 100; |
| | | 83 | | |
| | | 84 | | private static readonly TimeZoneInfo MoscowZone = RussianTimeZoneHelper.Zone; |
| | | 85 | | private static readonly JsonSerializerOptions JsonOptions = new() |
| | | 86 | | { |
| | | 87 | | Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping |
| | | 88 | | }; |
| | | 89 | | |
| | | 90 | | private readonly ApplicationDbContext _db; |
| | | 91 | | private readonly FavoritPartsDataManager _favoritParts; |
| | | 92 | | private readonly IOrderHistoryDataService _history; |
| | | 93 | | private readonly IMemoryCache _cache; |
| | | 94 | | private readonly IOrderSupplierStateService _supplierState; |
| | | 95 | | private readonly ILogger<FavoritPartsCartService> _logger; |
| | | 96 | | |
| | | 97 | | public FavoritPartsCartService(ApplicationDbContext db, FavoritPartsDataManager favoritParts, |
| | | 98 | | IOrderHistoryDataService history, IMemoryCache cache, IOrderSupplierStateService supplierState, |
| | | 99 | | ILogger<FavoritPartsCartService> logger, string providerKey = ApiSupplierSettings.FavoritPartsProviderKey, |
| | | 100 | | string supplierDisplayName = "Фаворит Союз", string providerRoute = "favoritparts") |
| | | 101 | | { |
| | | 102 | | _db = db; |
| | | 103 | | _favoritParts = favoritParts; |
| | | 104 | | _history = history; |
| | | 105 | | _cache = cache; |
| | | 106 | | _supplierState = supplierState; |
| | | 107 | | _logger = logger; |
| | | 108 | | ProviderKey = providerKey; |
| | | 109 | | _supplierDisplayName = supplierDisplayName; |
| | | 110 | | _providerRoute = providerRoute; |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | public static string BuildCartKey(Guid goods, Guid warehouseGroup) => |
| | | 114 | | $"{goods:D}|{warehouseGroup:D}".ToLowerInvariant(); |
| | | 115 | | |
| | | 116 | | internal static DateOnly MoscowToday => |
| | | 117 | | DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, MoscowZone).DateTime); |
| | | 118 | | |
| | | 119 | | /// <summary>Читает реальную корзину FavoritParts и разворачивает её в строки, готовые к отображению.</summary> |
| | | 120 | | public async Task<IReadOnlyList<FavoritPartsApiCartLine>> GetApiCartAsync(CancellationToken ct = default) => |
| | | 121 | | ParseCartLines(await _favoritParts.GetCartAsync(ct)); |
| | | 122 | | |
| | | 123 | | public Task<JToken> GetApiOrdersAsync(bool active, bool rowsOrder, CancellationToken ct = default) => |
| | | 124 | | _favoritParts.GetOrdersAsync(active: active, rowsOrder: rowsOrder, ct: ct); |
| | | 125 | | |
| | | 126 | | public Task<JToken> GetSingleApiOrderAsync(Guid externalOrderId, CancellationToken ct = default) => |
| | | 127 | | _favoritParts.GetOrderAsync(externalOrderId, ct); |
| | | 128 | | |
| | | 129 | | /// <summary>Количество строк в реальной корзине — для индикатора в боковом меню.</summary> |
| | | 130 | | public async Task<int> CountApiCartAsync(CancellationToken ct = default) |
| | | 131 | | { |
| | | 132 | | var response = await _favoritParts.GetCartAsync(ct); |
| | | 133 | | return response is JObject obj && obj["cart"] is JArray cart ? cart.Count : 0; |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | /// <summary>Заказы Studio2, стоящие за строками корзины: ключ строки → список (заказ, количество). |
| | | 137 | | /// Основной источник — локальная таблица привязок, comment остаётся запасным вариантом |
| | | 138 | | /// для строк, добавленных вне Studio2 или до перехода на агрегацию.</summary> |
| | | 139 | | public async Task<IReadOnlyDictionary<string, IReadOnlyList<FavoritPartsCartLink>>> ResolveStudioOrdersAsync( |
| | | 140 | | IReadOnlyList<FavoritPartsApiCartLine> lines, CancellationToken ct = default) |
| | | 141 | | { |
| | | 142 | | var result = new Dictionary<string, IReadOnlyList<FavoritPartsCartLink>>(); |
| | | 143 | | if (lines.Count == 0) return result; |
| | | 144 | | |
| | | 145 | | var local = await LoadActiveLocalItemsAsync(ct); |
| | | 146 | | var byKey = local.GroupBy(x => BuildCartKey(x.GoodsId, x.WarehouseGroupId)) |
| | | 147 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 148 | | |
| | | 149 | | // Строки, добавленные до появления Id в комментарии ("Studio2 {номер отправления}"), |
| | | 150 | | // ищем по номеру отправления — иначе их нечем оформить. |
| | | 151 | | var pendingLabels = lines |
| | | 152 | | .Where(x => !byKey.ContainsKey(x.Key) && x.StudioOrderId is null) |
| | | 153 | | .Select(x => StudioOrderCommentLink.ExtractOrderLabel(x.Comment)) |
| | | 154 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | | 155 | | .Select(x => x!).Distinct().ToList(); |
| | | 156 | | var byShipmentNumber = pendingLabels.Count == 0 |
| | | 157 | | ? new Dictionary<string, int>() |
| | | 158 | | : await _db.Orders.AsNoTracking() |
| | | 159 | | .Where(o => o.ShipmentNumber != null && pendingLabels.Contains(o.ShipmentNumber)) |
| | | 160 | | .GroupBy(o => o.ShipmentNumber!) |
| | | 161 | | .Select(g => new { ShipmentNumber = g.Key, OrderId = g.Min(o => o.Id) }) |
| | | 162 | | .ToDictionaryAsync(x => x.ShipmentNumber, x => x.OrderId, ct); |
| | | 163 | | |
| | | 164 | | foreach (var line in lines) |
| | | 165 | | { |
| | | 166 | | if (byKey.TryGetValue(line.Key, out var rows) && rows.Count > 0) |
| | | 167 | | { |
| | | 168 | | result[line.Key] = rows |
| | | 169 | | .Select(x => new FavoritPartsCartLink(x.ActiveStudioOrderId!.Value, x.Quantity, x.Id)) |
| | | 170 | | .OrderBy(x => x.StudioOrderId).ToList(); |
| | | 171 | | continue; |
| | | 172 | | } |
| | | 173 | | if (line.StudioOrderId is int fromComment) |
| | | 174 | | { |
| | | 175 | | result[line.Key] = new[] { new FavoritPartsCartLink(fromComment, line.Count, null) }; |
| | | 176 | | continue; |
| | | 177 | | } |
| | | 178 | | |
| | | 179 | | var label = StudioOrderCommentLink.ExtractOrderLabel(line.Comment); |
| | | 180 | | if (label is not null && byShipmentNumber.TryGetValue(label, out var byNumber)) |
| | | 181 | | result[line.Key] = new[] { new FavoritPartsCartLink(byNumber, line.Count, null) }; |
| | | 182 | | } |
| | | 183 | | return result; |
| | | 184 | | } |
| | | 185 | | |
| | | 186 | | /// <summary>Добавляет позиции в реальную корзину FavoritParts (без оформления заказа) — |
| | | 187 | | /// вызывается после успешного проведения транзакции. Заказ создаётся отдельно на /SupplierCart.</summary> |
| | | 188 | | public async Task<FavoritPartsCartOperationResult> QueueAsync(IReadOnlyCollection<FavoritPartsQueueItem> queue, |
| | | 189 | | string changedBy, CancellationToken ct = default) |
| | | 190 | | { |
| | | 191 | | if (queue.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, Array.Empty<string>()); |
| | | 192 | | |
| | | 193 | | var messages = new List<string>(); |
| | | 194 | | int added = 0, skipped = 0, failed = 0; |
| | | 195 | | |
| | | 196 | | var (groups, invalid) = GroupQueue(queue); |
| | | 197 | | foreach (var item in invalid) |
| | | 198 | | { |
| | | 199 | | failed++; |
| | | 200 | | messages.Add($"{OrderLabel(item)}: не найден товар/склад FavoritParts."); |
| | | 201 | | } |
| | | 202 | | |
| | | 203 | | var remote = await TryLoadRemoteCountsAsync(ct); |
| | | 204 | | foreach (var group in groups) |
| | | 205 | | { |
| | | 206 | | var fresh = new List<FavoritPartsQueueItem>(); |
| | | 207 | | foreach (var item in group.Items) |
| | | 208 | | { |
| | | 209 | | if (await WasSuccessfullyPlacedAsync(item.OrderId, ct)) |
| | | 210 | | { |
| | | 211 | | skipped++; |
| | | 212 | | messages.Add($"{OrderLabel(item)}: уже добавлен в корзину FavoritParts ранее."); |
| | | 213 | | continue; |
| | | 214 | | } |
| | | 215 | | fresh.Add(item); |
| | | 216 | | } |
| | | 217 | | if (fresh.Count == 0) continue; |
| | | 218 | | |
| | | 219 | | var (count, comment) = await BuildCartAddArgumentsAsync(group.GoodsId, group.WarehouseGroupId, |
| | | 220 | | fresh, remote, ct); |
| | | 221 | | |
| | | 222 | | var offerError = await GetOfferValidationErrorAsync(fresh[0].Article, fresh[0].Brand, |
| | | 223 | | group.GoodsId, group.WarehouseGroupId, count, ct); |
| | | 224 | | if (offerError is not null) |
| | | 225 | | { |
| | | 226 | | foreach (var item in fresh) |
| | | 227 | | { |
| | | 228 | | failed++; |
| | | 229 | | messages.Add($"{OrderLabel(item)}: {offerError}"); |
| | | 230 | | await TryRecordHistoryAsync(item, OrderLabel(item), success: false, offerError, changedBy); |
| | | 231 | | } |
| | | 232 | | continue; |
| | | 233 | | } |
| | | 234 | | |
| | | 235 | | var error = await AddToCartAsync(group.GoodsId, group.WarehouseGroupId, count, comment, ct); |
| | | 236 | | if (error is not null) |
| | | 237 | | { |
| | | 238 | | _logger.LogWarning(error, |
| | | 239 | | "FavoritParts: cart/add отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} qty={Quantity} s |
| | | 240 | | group.GoodsId, group.WarehouseGroupId, count, error.StatusCode, string.Join(",", fresh.Select(x => x |
| | | 241 | | foreach (var item in fresh) |
| | | 242 | | { |
| | | 243 | | failed++; |
| | | 244 | | messages.Add($"{OrderLabel(item)}: {error.Message}"); |
| | | 245 | | await TryRecordHistoryAsync(item, OrderLabel(item), success: false, |
| | | 246 | | $"{error.Message} [goods={group.GoodsId:D} wh={group.WarehouseGroupId:D} qty={count} status={err |
| | | 247 | | } |
| | | 248 | | continue; |
| | | 249 | | } |
| | | 250 | | |
| | | 251 | | foreach (var item in fresh) |
| | | 252 | | { |
| | | 253 | | await UpsertLocalCartItemAsync(item, group.GoodsId, group.WarehouseGroupId, changedBy, ct, remote); |
| | | 254 | | added++; |
| | | 255 | | await TrackCartAsync(item.OrderId, SupplierCartStatusCode.Pending, changedBy, ct, |
| | | 256 | | item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction); |
| | | 257 | | await TryRecordHistoryAsync(item, OrderLabel(item), success: true, errorMessage: null, changedBy); |
| | | 258 | | } |
| | | 259 | | _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | | 260 | | } |
| | | 261 | | |
| | | 262 | | return new FavoritPartsCartOperationResult(added, skipped, failed, messages); |
| | | 263 | | } |
| | | 264 | | |
| | | 265 | | /// <summary>Добавляет выбранные позиции в корзину и сразу оформляет заказ(ы) — для «Оформить с проведением».</summa |
| | | 266 | | public async Task<FavoritPartsDirectCheckoutResult> CheckoutDirectAsync(IReadOnlyCollection<FavoritPartsQueueItem> q |
| | | 267 | | string changedBy, CancellationToken ct = default) |
| | | 268 | | { |
| | | 269 | | if (queue.Count == 0) |
| | | 270 | | return new FavoritPartsDirectCheckoutResult(false, ["Не выбраны позиции FavoritParts для оформления."]); |
| | | 271 | | |
| | | 272 | | var messages = new List<string>(); |
| | | 273 | | var lines = new List<CheckoutLine>(); |
| | | 274 | | var (groups, invalid) = GroupQueue(queue); |
| | | 275 | | foreach (var item in invalid) |
| | | 276 | | messages.Add($"{OrderLabel(item)}: не найден товар/склад FavoritParts."); |
| | | 277 | | |
| | | 278 | | var remote = await TryLoadRemoteCountsAsync(ct); |
| | | 279 | | foreach (var group in groups) |
| | | 280 | | { |
| | | 281 | | var (count, comment) = await BuildCartAddArgumentsAsync(group.GoodsId, group.WarehouseGroupId, |
| | | 282 | | group.Items, remote, ct); |
| | | 283 | | |
| | | 284 | | var offerError = await GetOfferValidationErrorAsync(group.Items[0].Article, group.Items[0].Brand, |
| | | 285 | | group.GoodsId, group.WarehouseGroupId, count, ct); |
| | | 286 | | if (offerError is not null) |
| | | 287 | | { |
| | | 288 | | foreach (var item in group.Items) |
| | | 289 | | { |
| | | 290 | | messages.Add($"{OrderLabel(item)}: {offerError}"); |
| | | 291 | | await TryRecordHistoryAsync(item, OrderLabel(item), success: false, offerError, changedBy); |
| | | 292 | | } |
| | | 293 | | continue; |
| | | 294 | | } |
| | | 295 | | |
| | | 296 | | var error = await AddToCartAsync(group.GoodsId, group.WarehouseGroupId, count, comment, ct); |
| | | 297 | | if (error is not null) |
| | | 298 | | { |
| | | 299 | | _logger.LogWarning(error, |
| | | 300 | | "FavoritParts: cart/add отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} qty={Quantity} s |
| | | 301 | | group.GoodsId, group.WarehouseGroupId, count, error.StatusCode, string.Join(",", group.Items.Select( |
| | | 302 | | foreach (var item in group.Items) |
| | | 303 | | { |
| | | 304 | | messages.Add($"{OrderLabel(item)}: {error.Message}"); |
| | | 305 | | await TryRecordHistoryAsync(item, OrderLabel(item), success: false, |
| | | 306 | | $"{error.Message} [goods={group.GoodsId:D} wh={group.WarehouseGroupId:D} qty={count} status={err |
| | | 307 | | } |
| | | 308 | | continue; |
| | | 309 | | } |
| | | 310 | | |
| | | 311 | | foreach (var item in group.Items) |
| | | 312 | | { |
| | | 313 | | var cartItem = await UpsertLocalCartItemAsync(item, group.GoodsId, group.WarehouseGroupId, changedBy, ct |
| | | 314 | | lines.Add(new CheckoutLine(item.OrderId, OrderLabel(item), group.GoodsId, group.WarehouseGroupId, |
| | | 315 | | item.Article, item.Brand, item.Quantity, item.CurrencyCode ?? "RUB", cartItem.Id)); |
| | | 316 | | } |
| | | 317 | | } |
| | | 318 | | |
| | | 319 | | if (lines.Count == 0) |
| | | 320 | | return new FavoritPartsDirectCheckoutResult(false, messages.Count > 0 ? messages : ["Не удалось добавить поз |
| | | 321 | | |
| | | 322 | | var orderResult = await CreateOrdersFromLinesAsync(lines, changedBy, ct); |
| | | 323 | | messages.AddRange(orderResult.Messages); |
| | | 324 | | return new FavoritPartsDirectCheckoutResult(orderResult.OrderedCount > 0, messages); |
| | | 325 | | } |
| | | 326 | | |
| | | 327 | | /// <summary>Свежая сверка выбранных строк реальной корзины (цена/остаток) без оформления заказа. |
| | | 328 | | /// Ничего не пишет в БД — Checkout сам перепроверяет корзину перед созданием заказа.</summary> |
| | | 329 | | public async Task<FavoritPartsCartOperationResult> TestAsync(IReadOnlyCollection<string>? selectedKeys, |
| | | 330 | | CancellationToken ct = default) |
| | | 331 | | { |
| | | 332 | | IReadOnlyList<FavoritPartsApiCartLine> lines; |
| | | 333 | | try { lines = await GetApiCartAsync(ct); } |
| | | 334 | | catch (FavoritPartsApiException ex) |
| | | 335 | | { |
| | | 336 | | return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag |
| | | 337 | | } |
| | | 338 | | |
| | | 339 | | var targeted = selectedKeys is null ? lines : lines.Where(l => selectedKeys.Contains(l.Key)).ToList(); |
| | | 340 | | if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Корзина пуста."]); |
| | | 341 | | |
| | | 342 | | var messages = targeted.Select(l => |
| | | 343 | | { |
| | | 344 | | var label = string.IsNullOrWhiteSpace(l.Number) ? l.Goods.ToString() : l.Number; |
| | | 345 | | var shortage = l.Stock < l.Count ? $" — в корзине {l.Count:0.##}, доступно меньше!" : string.Empty; |
| | | 346 | | return $"{label}: остаток {l.Stock:0.##}, цена {l.Price:0.##} RUB.{shortage}"; |
| | | 347 | | }).ToList(); |
| | | 348 | | return new FavoritPartsCartOperationResult(targeted.Count, 0, 0, messages); |
| | | 349 | | } |
| | | 350 | | |
| | | 351 | | /// <summary>Оформляет заказ(ы) по выбранным строкам реальной корзины (или по всем, если ничего не выбрано). |
| | | 352 | | /// Строки без привязки к заказу Studio2 пропускаются — оформлять их нечего.</summary> |
| | | 353 | | public async Task<FavoritPartsCartOperationResult> CheckoutAsync(string changedBy, |
| | | 354 | | IReadOnlyCollection<string>? selectedKeys, CancellationToken ct = default) |
| | | 355 | | { |
| | | 356 | | IReadOnlyList<FavoritPartsApiCartLine> lines; |
| | | 357 | | try { lines = await GetApiCartAsync(ct); } |
| | | 358 | | catch (FavoritPartsApiException ex) |
| | | 359 | | { |
| | | 360 | | return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag |
| | | 361 | | } |
| | | 362 | | |
| | | 363 | | var targeted = selectedKeys is null ? lines : lines.Where(l => selectedKeys.Contains(l.Key)).ToList(); |
| | | 364 | | if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Корзина пуста."]); |
| | | 365 | | |
| | | 366 | | var messages = new List<string>(); |
| | | 367 | | var links = await ResolveStudioOrdersAsync(targeted, ct); |
| | | 368 | | var withOrder = new List<(FavoritPartsApiCartLine Line, FavoritPartsCartLink Link)>(); |
| | | 369 | | foreach (var line in targeted) |
| | | 370 | | { |
| | | 371 | | if (!links.TryGetValue(line.Key, out var lineLinks) || lineLinks.Count == 0) |
| | | 372 | | { |
| | | 373 | | messages.Add($"{(string.IsNullOrWhiteSpace(line.Number) ? line.Goods.ToString() : line.Number)}: нет при |
| | | 374 | | continue; |
| | | 375 | | } |
| | | 376 | | withOrder.AddRange(lineLinks.Select(link => (line, link))); |
| | | 377 | | } |
| | | 378 | | if (withOrder.Count == 0) return new FavoritPartsCartOperationResult(0, 0, targeted.Count, messages); |
| | | 379 | | |
| | | 380 | | var orderIds = withOrder.Select(x => x.Link.StudioOrderId).Distinct().ToList(); |
| | | 381 | | var orders = await _db.Orders.AsNoTracking() |
| | | 382 | | .Where(o => orderIds.Contains(o.Id)).ToDictionaryAsync(o => o.Id, ct); |
| | | 383 | | |
| | | 384 | | var checkoutLines = withOrder.Select(x => |
| | | 385 | | { |
| | | 386 | | orders.TryGetValue(x.Link.StudioOrderId, out var order); |
| | | 387 | | var orderLabel = !string.IsNullOrWhiteSpace(order?.ShipmentNumber) ? order!.ShipmentNumber! : $"ID {x.Link.S |
| | | 388 | | return new CheckoutLine(x.Link.StudioOrderId, orderLabel, x.Line.Goods, x.Line.WarehouseGroup, |
| | | 389 | | x.Line.Number ?? string.Empty, x.Line.Brand ?? string.Empty, x.Link.Quantity, "RUB", x.Link.CartItemId); |
| | | 390 | | }).ToList(); |
| | | 391 | | |
| | | 392 | | var result = await CreateOrdersFromLinesAsync(checkoutLines, changedBy, ct); |
| | | 393 | | messages.AddRange(result.Messages); |
| | | 394 | | return new FavoritPartsCartOperationResult(result.OrderedCount, 0, checkoutLines.Count - result.OrderedCount, me |
| | | 395 | | } |
| | | 396 | | |
| | | 397 | | /// <summary>Удаляет выбранные позиции (или все) из реальной корзины FavoritParts построчно — |
| | | 398 | | /// у API нет операции «очистить всё», как и у АВД.</summary> |
| | | 399 | | public async Task<FavoritPartsCartOperationResult> RemoveManyAsync(IReadOnlyCollection<string>? selectedKeys, |
| | | 400 | | bool removeAll, CancellationToken ct = default) |
| | | 401 | | { |
| | | 402 | | IReadOnlyList<FavoritPartsApiCartLine> lines; |
| | | 403 | | try { lines = await GetApiCartAsync(ct); } |
| | | 404 | | catch (FavoritPartsApiException ex) |
| | | 405 | | { |
| | | 406 | | return new FavoritPartsCartOperationResult(0, 0, 0, [$"Не удалось прочитать корзину FavoritParts: {ex.Messag |
| | | 407 | | } |
| | | 408 | | |
| | | 409 | | var targeted = removeAll ? lines : lines.Where(l => selectedKeys?.Contains(l.Key) == true).ToList(); |
| | | 410 | | if (targeted.Count == 0) return new FavoritPartsCartOperationResult(0, 0, 0, ["Нечего удалять."]); |
| | | 411 | | |
| | | 412 | | var links = await ResolveStudioOrdersAsync(targeted, ct); |
| | | 413 | | var removed = 0; var failed = 0; var messages = new List<string>(); |
| | | 414 | | foreach (var line in targeted) |
| | | 415 | | { |
| | | 416 | | var label = string.IsNullOrWhiteSpace(line.Number) ? line.Goods.ToString() : line.Number; |
| | | 417 | | try |
| | | 418 | | { |
| | | 419 | | await _favoritParts.CartAddAsync(line.Goods, line.WarehouseGroup, 0, null, ct); |
| | | 420 | | } |
| | | 421 | | catch (FavoritPartsApiException ex) |
| | | 422 | | { |
| | | 423 | | _logger.LogWarning(ex, |
| | | 424 | | "FavoritParts: cart/add count=0 отклонён (goods={GoodsId} warehouseGroup={WarehouseGroupId} status={ |
| | | 425 | | line.Goods, line.WarehouseGroup, ex.StatusCode); |
| | | 426 | | |
| | | 427 | | // Сервис мог оборвать ответ уже после применения изменения — сверяемся с корзиной, |
| | | 428 | | // прежде чем показывать пользователю ошибку. |
| | | 429 | | if (await IsMissingFromCartAsync(line, ct)) |
| | | 430 | | { |
| | | 431 | | messages.Add($"{label}: удалена (поставщик вернул ошибку «{ex.Message}», но позиции в корзине уже не |
| | | 432 | | } |
| | | 433 | | else |
| | | 434 | | { |
| | | 435 | | failed++; |
| | | 436 | | messages.Add($"{label}: {ex.Message}"); |
| | | 437 | | continue; |
| | | 438 | | } |
| | | 439 | | } |
| | | 440 | | removed++; |
| | | 441 | | // Строка корзины могла обслуживать несколько заказов Studio2 — закрываем состояние у всех. |
| | | 442 | | var lineLinks = links.TryGetValue(line.Key, out var l) ? l : Array.Empty<FavoritPartsCartLink>(); |
| | | 443 | | foreach (var link in lineLinks) |
| | | 444 | | { |
| | | 445 | | await CloseLocalCartItemAsync(link.CartItemId, FavoritPartsCartItemStatus.Removed, ct); |
| | | 446 | | await TrackRemovedAsync(link.StudioOrderId, null, ct); |
| | | 447 | | } |
| | | 448 | | } |
| | | 449 | | if (removed > 0) _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | | 450 | | |
| | | 451 | | return new FavoritPartsCartOperationResult(removed, 0, failed, messages); |
| | | 452 | | } |
| | | 453 | | |
| | | 454 | | /// <summary>Перечитывает корзину и проверяет, что строки в ней действительно нет. |
| | | 455 | | /// Если проверка сама не удалась — считаем строку оставшейся (ошибку не проглатываем).</summary> |
| | | 456 | | private async Task<bool> IsMissingFromCartAsync(FavoritPartsApiCartLine line, CancellationToken ct) |
| | | 457 | | { |
| | | 458 | | try |
| | | 459 | | { |
| | | 460 | | var actual = await GetApiCartAsync(ct); |
| | | 461 | | return actual.All(x => x.Goods != line.Goods || x.WarehouseGroup != line.WarehouseGroup); |
| | | 462 | | } |
| | | 463 | | catch (FavoritPartsApiException) |
| | | 464 | | { |
| | | 465 | | return false; |
| | | 466 | | } |
| | | 467 | | } |
| | | 468 | | |
| | | 469 | | /// <summary>Заказ Studio2 за строкой корзины FavoritParts: количество и (если есть) строка локальной привязки.</sum |
| | | 470 | | public sealed record FavoritPartsCartLink(int StudioOrderId, decimal Quantity, long? CartItemId); |
| | | 471 | | |
| | | 472 | | internal sealed record CheckoutLine(int StudioOrderId, string OrderLabel, Guid GoodsId, Guid WarehouseGroupId, |
| | | 473 | | string Article, string Brand, decimal Quantity, string CurrencyCode, long? CartItemId = null); |
| | | 474 | | |
| | | 475 | | private sealed record OrderCreationSummary(int OrderedCount, IReadOnlyList<string> Messages); |
| | | 476 | | |
| | | 477 | | /// <summary>Позиции очереди, схлопнутые по (товар, склад хранения): cart/add не суммирует количество, |
| | | 478 | | /// а устанавливает последнее переданное, поэтому один и тот же товар от разных заказов Studio2 |
| | | 479 | | /// обязан уйти в корзину одной строкой на суммарное количество.</summary> |
| | | 480 | | public sealed record FavoritPartsQueueGroup(Guid GoodsId, Guid WarehouseGroupId, IReadOnlyList<FavoritPartsQueueItem |
| | | 481 | | { |
| | | 482 | | public decimal Quantity => Items.Sum(x => x.Quantity); |
| | | 483 | | } |
| | | 484 | | |
| | | 485 | | public static (IReadOnlyList<FavoritPartsQueueGroup> Groups, IReadOnlyList<FavoritPartsQueueItem> Invalid) GroupQueu |
| | | 486 | | IEnumerable<FavoritPartsQueueItem> queue) |
| | | 487 | | { |
| | | 488 | | var groups = new List<FavoritPartsQueueGroup>(); |
| | | 489 | | var invalid = new List<FavoritPartsQueueItem>(); |
| | | 490 | | var index = new Dictionary<(Guid, Guid), List<FavoritPartsQueueItem>>(); |
| | | 491 | | |
| | | 492 | | foreach (var item in queue) |
| | | 493 | | { |
| | | 494 | | if (!Guid.TryParse(item.GoodsId, out var goodsId) || goodsId == Guid.Empty || |
| | | 495 | | !Guid.TryParse(item.WarehouseGroupId, out var warehouseGroupId) || warehouseGroupId == Guid.Empty) |
| | | 496 | | { |
| | | 497 | | invalid.Add(item); |
| | | 498 | | continue; |
| | | 499 | | } |
| | | 500 | | |
| | | 501 | | var key = (goodsId, warehouseGroupId); |
| | | 502 | | if (!index.TryGetValue(key, out var items)) |
| | | 503 | | { |
| | | 504 | | items = new List<FavoritPartsQueueItem>(); |
| | | 505 | | index[key] = items; |
| | | 506 | | groups.Add(new FavoritPartsQueueGroup(goodsId, warehouseGroupId, items)); |
| | | 507 | | } |
| | | 508 | | items.Add(item); |
| | | 509 | | } |
| | | 510 | | return (groups, invalid); |
| | | 511 | | } |
| | | 512 | | |
| | | 513 | | /// <summary>Количество и комментарий для cart/add. Количество абсолютное: к тому, что уже лежит в строке |
| | | 514 | | /// корзины, прибавляем новые заказы и вычитаем их же прежние (пере)размещения, иначе чужие позиции |
| | | 515 | | /// в этой строке (другие заказы Studio2, ручное добавление на сайте поставщика) будут затёрты.</summary> |
| | | 516 | | private async Task<(decimal Count, string Comment)> BuildCartAddArgumentsAsync(Guid goodsId, Guid warehouseGroupId, |
| | | 517 | | IReadOnlyList<FavoritPartsQueueItem> items, IReadOnlyDictionary<string, FavoritPartsApiCartLine> remote, |
| | | 518 | | CancellationToken ct) |
| | | 519 | | { |
| | | 520 | | var key = BuildCartKey(goodsId, warehouseGroupId); |
| | | 521 | | var remoteLine = remote.GetValueOrDefault(key); |
| | | 522 | | var orderIds = items.Select(x => x.OrderId).ToHashSet(); |
| | | 523 | | |
| | | 524 | | var replaced = await _db.FavoritPartsCartItems |
| | | 525 | | .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null |
| | | 526 | | && orderIds.Contains(x.ActiveStudioOrderId.Value) |
| | | 527 | | && x.GoodsId == goodsId && x.WarehouseGroupId == warehouseGroupId |
| | | 528 | | && FavoritPartsCartItemStatus.Active.Contains(x.Status)) |
| | | 529 | | .SumAsync(x => (decimal?)x.Quantity, ct) ?? 0m; |
| | | 530 | | |
| | | 531 | | var count = Math.Max(0m, (remoteLine?.Count ?? 0m) - replaced) + items.Sum(x => x.Quantity); |
| | | 532 | | // Комментарий существующей строки не трогаем: он привязывает её к заказу, размещённому первым. |
| | | 533 | | var comment = !string.IsNullOrWhiteSpace(remoteLine?.Comment) |
| | | 534 | | ? remoteLine!.Comment! |
| | | 535 | | : StudioOrderCommentLink.Build(OrderLabel(items[0]), items[0].OrderId, CommentMaxLength); |
| | | 536 | | return (count, comment); |
| | | 537 | | } |
| | | 538 | | |
| | | 539 | | private async Task<IReadOnlyDictionary<string, FavoritPartsApiCartLine>> TryLoadRemoteCountsAsync(CancellationToken |
| | | 540 | | { |
| | | 541 | | try |
| | | 542 | | { |
| | | 543 | | var lines = await GetApiCartAsync(ct); |
| | | 544 | | return lines.GroupBy(x => x.Key).ToDictionary(g => g.Key, g => g.First()); |
| | | 545 | | } |
| | | 546 | | catch (FavoritPartsApiException ex) |
| | | 547 | | { |
| | | 548 | | // Без снимка корзины считаем её пустой: хуже чужого количества только не размещённый заказ. |
| | | 549 | | _logger.LogWarning(ex, "FavoritParts: не удалось прочитать корзину перед добавлением позиций"); |
| | | 550 | | return new Dictionary<string, FavoritPartsApiCartLine>(); |
| | | 551 | | } |
| | | 552 | | } |
| | | 553 | | |
| | | 554 | | /// <summary>Сверяет строки с актуальным ответом GET cart/ (цена/остаток/склад отгрузки могли измениться), |
| | | 555 | | /// группирует по (WarehouseShipping, ShippingDate) — обязательное требование API — и создаёт заказ на |
| | | 556 | | /// каждую группу отдельным POST order/. Внутри группы позиции схлопываются по (товар, склад хранения): |
| | | 557 | | /// строка корзины одна, и двух строк GoodsList на неё быть не должно.</summary> |
| | | 558 | | private async Task<OrderCreationSummary> CreateOrdersFromLinesAsync(IReadOnlyList<CheckoutLine> lines, |
| | | 559 | | string changedBy, CancellationToken ct) |
| | | 560 | | { |
| | | 561 | | var messages = new List<string>(); |
| | | 562 | | Dictionary<(Guid Goods, Guid WarehouseGroup), FavoritPartsApiCartLine> snapshot; |
| | | 563 | | try { snapshot = (await GetApiCartAsync(ct)).GroupBy(l => (l.Goods, l.WarehouseGroup)).ToDictionary(g => g.Key, |
| | | 564 | | catch (FavoritPartsApiException ex) |
| | | 565 | | { |
| | | 566 | | messages.Add($"Не удалось прочитать корзину FavoritParts: {ex.Message}"); |
| | | 567 | | return new OrderCreationSummary(0, messages); |
| | | 568 | | } |
| | | 569 | | |
| | | 570 | | var matched = new List<(CheckoutLine Line, FavoritPartsApiCartLine Snapshot)>(); |
| | | 571 | | foreach (var line in lines) |
| | | 572 | | { |
| | | 573 | | if (!snapshot.TryGetValue((line.GoodsId, line.WarehouseGroupId), out var snap)) |
| | | 574 | | { |
| | | 575 | | messages.Add($"{line.OrderLabel}: позиция не найдена в актуальной корзине FavoritParts (возможно, уже оф |
| | | 576 | | continue; |
| | | 577 | | } |
| | | 578 | | // ShippingDate обязателен и проверяется сервисом на «не раньше расчётной»: подставлять сегодня |
| | | 579 | | // вместо неизвестной даты нельзя — заказ будет отклонён с 400 либо уедет не на ту дату. |
| | | 580 | | if (snap.ShippingDate is null) |
| | | 581 | | { |
| | | 582 | | messages.Add($"{line.OrderLabel}: FavoritParts не вернул дату отгрузки по позиции — заказ не создан, обн |
| | | 583 | | continue; |
| | | 584 | | } |
| | | 585 | | matched.Add((line, snap)); |
| | | 586 | | } |
| | | 587 | | if (matched.Count == 0) return new OrderCreationSummary(0, messages); |
| | | 588 | | |
| | | 589 | | (Guid TradePointId, int PaymentType, int DeliveryType, int TransportType) defaults; |
| | | 590 | | try { defaults = await GetOrderDefaultsAsync(ct); } |
| | | 591 | | catch (FavoritPartsApiException ex) |
| | | 592 | | { |
| | | 593 | | messages.Add($"Не удалось получить профиль FavoritParts (торговая точка/способ оплаты): {ex.Message}"); |
| | | 594 | | return new OrderCreationSummary(0, messages); |
| | | 595 | | } |
| | | 596 | | |
| | | 597 | | var orderedCount = 0; |
| | | 598 | | foreach (var group in matched.GroupBy(x => (x.Snapshot.WarehouseShipping, |
| | | 599 | | ShippingDate: x.Snapshot.ShippingDate!.Value))) |
| | | 600 | | { |
| | | 601 | | // Остаток мог упасть после добавления в корзину (документация предупреждает об этом явно): |
| | | 602 | | // заказ с количеством больше доступного поставщик снимет уже после создания. |
| | | 603 | | var goodsGroups = new List<(FavoritPartsApiCartLine Snapshot, List<CheckoutLine> Lines)>(); |
| | | 604 | | foreach (var byGoods in group.GroupBy(x => (x.Line.GoodsId, x.Line.WarehouseGroupId))) |
| | | 605 | | { |
| | | 606 | | var snap = byGoods.First().Snapshot; |
| | | 607 | | var required = byGoods.Sum(x => x.Line.Quantity); |
| | | 608 | | var available = Math.Min(snap.Stock, snap.Count); |
| | | 609 | | if (required > available) |
| | | 610 | | { |
| | | 611 | | var label = string.IsNullOrWhiteSpace(snap.Number) ? snap.Goods.ToString() : snap.Number; |
| | | 612 | | foreach (var (line, _) in byGoods) |
| | | 613 | | messages.Add($"{line.OrderLabel}: {label} — нужно {required:0.##} шт., в корзине доступно {avail |
| | | 614 | | continue; |
| | | 615 | | } |
| | | 616 | | goodsGroups.Add((snap, byGoods.Select(x => x.Line).ToList())); |
| | | 617 | | } |
| | | 618 | | if (goodsGroups.Count == 0) continue; |
| | | 619 | | |
| | | 620 | | var request = new FavoritPartsOrderRequest |
| | | 621 | | { |
| | | 622 | | WarehouseShipping = group.Key.WarehouseShipping, |
| | | 623 | | ShippingDate = group.Key.ShippingDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture), |
| | | 624 | | TradePoint = defaults.TradePointId, |
| | | 625 | | PaymentType = defaults.PaymentType, |
| | | 626 | | DeliveryType = defaults.DeliveryType, |
| | | 627 | | TransportType = defaults.TransportType, |
| | | 628 | | Comment = "Studio2", |
| | | 629 | | GoodsList = goodsGroups.Select(x => new FavoritPartsOrderGoodsLine |
| | | 630 | | { |
| | | 631 | | Goods = x.Snapshot.Goods, |
| | | 632 | | WarehouseGroup = x.Snapshot.WarehouseGroup, |
| | | 633 | | Count = x.Lines.Sum(line => line.Quantity), |
| | | 634 | | Comment = StudioOrderCommentLink.Build(x.Lines[0].OrderLabel, x.Lines[0].StudioOrderId, CommentMaxLe |
| | | 635 | | }).ToList() |
| | | 636 | | }; |
| | | 637 | | |
| | | 638 | | Guid externalOrderId; |
| | | 639 | | try { externalOrderId = await _favoritParts.CreateOrderAsync(request, ct); } |
| | | 640 | | catch (FavoritPartsApiException ex) |
| | | 641 | | { |
| | | 642 | | foreach (var line in goodsGroups.SelectMany(x => x.Lines)) messages.Add($"{line.OrderLabel}: {ex.Message |
| | | 643 | | continue; |
| | | 644 | | } |
| | | 645 | | |
| | | 646 | | var order = new FavoritPartsOrder |
| | | 647 | | { |
| | | 648 | | ProviderKey = ProviderKey, |
| | | 649 | | ExternalOrderId = externalOrderId, |
| | | 650 | | WarehouseShippingId = group.Key.WarehouseShipping, |
| | | 651 | | ShippingDate = group.Key.ShippingDate, |
| | | 652 | | TradePointId = defaults.TradePointId, |
| | | 653 | | Status = "Заказан", |
| | | 654 | | CreatedBy = changedBy, |
| | | 655 | | RequestJson = JsonSerializer.Serialize(request) |
| | | 656 | | }; |
| | | 657 | | _db.FavoritPartsOrders.Add(order); |
| | | 658 | | |
| | | 659 | | var externalOrderLabel = FavoritPartsGuid.Format(externalOrderId); |
| | | 660 | | string? postError = null; |
| | | 661 | | try |
| | | 662 | | { |
| | | 663 | | var orderDetails = await _favoritParts.GetOrderAsync(externalOrderId, ct); |
| | | 664 | | if (orderDetails is JObject obj) |
| | | 665 | | { |
| | | 666 | | postError = obj.Value<string>("postError"); |
| | | 667 | | var orderNumber = obj.Value<string>("number"); |
| | | 668 | | var status = (obj["status"] as JObject)?.Value<string>("name"); |
| | | 669 | | if (!string.IsNullOrWhiteSpace(orderNumber)) |
| | | 670 | | { |
| | | 671 | | order.Number = orderNumber.Length > 40 ? orderNumber[..40] : orderNumber; |
| | | 672 | | externalOrderLabel = order.Number; |
| | | 673 | | } |
| | | 674 | | if (!string.IsNullOrWhiteSpace(status)) |
| | | 675 | | order.Status = status.Length > 30 ? status[..30] : status; |
| | | 676 | | |
| | | 677 | | order.ResponseJson = JsonSerializer.Serialize(new |
| | | 678 | | { |
| | | 679 | | number = order.Number, |
| | | 680 | | status = order.Status, |
| | | 681 | | postError, |
| | | 682 | | fetchedAtUtc = DateTime.UtcNow |
| | | 683 | | }, JsonOptions); |
| | | 684 | | } |
| | | 685 | | } |
| | | 686 | | catch (Exception ex) |
| | | 687 | | { |
| | | 688 | | _logger.LogDebug(ex, "FavoritParts: не удалось уточнить статус созданного заказа {ExternalOrderId}", ext |
| | | 689 | | } |
| | | 690 | | |
| | | 691 | | var hasPostError = !string.IsNullOrWhiteSpace(postError); |
| | | 692 | | if (hasPostError) |
| | | 693 | | { |
| | | 694 | | _logger.LogInformation( |
| | | 695 | | "FavoritParts: заказ {ExternalOrderId} создан на сайте поставщика, но не проведён: {PostError}", |
| | | 696 | | externalOrderId, postError); |
| | | 697 | | |
| | | 698 | | try |
| | | 699 | | { |
| | | 700 | | await NotificationService.NotifyAsync( |
| | | 701 | | "FavoritParts", |
| | | 702 | | "#D97706", |
| | | 703 | | $"Заказ {externalOrderLabel} не проведён у поставщика: {postError}", |
| | | 704 | | "fa-triangle-exclamation"); |
| | | 705 | | } |
| | | 706 | | catch |
| | | 707 | | { |
| | | 708 | | // Игнорируем отсутствие активного NotificationService (например, в фоновом runner без веб-контекста |
| | | 709 | | } |
| | | 710 | | } |
| | | 711 | | |
| | | 712 | | foreach (var (snap, groupLines) in goodsGroups) |
| | | 713 | | { |
| | | 714 | | foreach (var line in groupLines) |
| | | 715 | | { |
| | | 716 | | order.Items.Add(new FavoritPartsOrderItem |
| | | 717 | | { |
| | | 718 | | StudioOrderId = line.StudioOrderId, |
| | | 719 | | FavoritPartsCartItemId = line.CartItemId, |
| | | 720 | | GoodsId = line.GoodsId, |
| | | 721 | | WarehouseGroupId = line.WarehouseGroupId, |
| | | 722 | | Brand = line.Brand, |
| | | 723 | | Quantity = line.Quantity, |
| | | 724 | | Price = snap.Price, |
| | | 725 | | CurrencyCode = line.CurrencyCode |
| | | 726 | | }); |
| | | 727 | | await CloseLocalCartItemAsync(line.CartItemId, FavoritPartsCartItemStatus.Ordered, ct); |
| | | 728 | | if (hasPostError) |
| | | 729 | | { |
| | | 730 | | messages.Add($"{line.OrderLabel}: заказ FavoritParts №{externalOrderLabel} создан на сайте, но Н |
| | | 731 | | } |
| | | 732 | | else |
| | | 733 | | { |
| | | 734 | | messages.Add($"{line.OrderLabel}: заказ FavoritParts №{externalOrderLabel} создан."); |
| | | 735 | | } |
| | | 736 | | orderedCount++; |
| | | 737 | | } |
| | | 738 | | } |
| | | 739 | | |
| | | 740 | | // Сохраняем сразу: заказ у поставщика уже создан, и падение на следующей группе не должно |
| | | 741 | | // откатить связь с ним — иначе заказ существует, а в Studio2 о нём нет записи. |
| | | 742 | | await _db.SaveChangesAsync(ct); |
| | | 743 | | foreach (var line in goodsGroups.SelectMany(x => x.Lines)) |
| | | 744 | | await TrackOrderedAsync(line.StudioOrderId, externalOrderLabel, changedBy, ct); |
| | | 745 | | } |
| | | 746 | | |
| | | 747 | | if (orderedCount > 0) _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | | 748 | | return new OrderCreationSummary(orderedCount, messages); |
| | | 749 | | } |
| | | 750 | | |
| | | 751 | | /// <summary>Разворачивает ответ GET cart/ (cart[] + goods{}/warehouseGroup{}/warehouseShipping{} справочники |
| | | 752 | | /// по GUID-ключу) в плоский список строк. Ключи справочников приходят в том же регистре, что и cart[].goods, |
| | | 753 | | /// но JSON-объект в Newtonsoft ищет свойство точным совпадением — сверяем без учёта регистра на всякий случай.</sum |
| | | 754 | | private static List<FavoritPartsApiCartLine> ParseCartLines(JToken response) |
| | | 755 | | { |
| | | 756 | | var result = new List<FavoritPartsApiCartLine>(); |
| | | 757 | | if (response is not JObject obj || obj["cart"] is not JArray cart) return result; |
| | | 758 | | var goodsDict = obj["goods"] as JObject; |
| | | 759 | | var warehouseGroupDict = obj["warehouseGroup"] as JObject; |
| | | 760 | | var warehouseShippingDict = obj["warehouseShipping"] as JObject; |
| | | 761 | | |
| | | 762 | | foreach (var row in cart.OfType<JObject>()) |
| | | 763 | | { |
| | | 764 | | var goodsRaw = row.Value<string>("goods"); |
| | | 765 | | var warehouseGroupRaw = row.Value<string>("warehouseGroup"); |
| | | 766 | | if (!Guid.TryParse(goodsRaw, out var goods) || !Guid.TryParse(warehouseGroupRaw, out var warehouseGroup)) |
| | | 767 | | continue; |
| | | 768 | | Guid.TryParse(row.Value<string>("warehouseShipping"), out var warehouseShipping); |
| | | 769 | | |
| | | 770 | | var goodsInfo = FindByGuid(goodsDict, goodsRaw); |
| | | 771 | | var warehouseGroupInfo = FindByGuid(warehouseGroupDict, warehouseGroupRaw); |
| | | 772 | | var warehouseShippingInfo = FindByGuid(warehouseShippingDict, row.Value<string>("warehouseShipping")); |
| | | 773 | | |
| | | 774 | | result.Add(new FavoritPartsApiCartLine( |
| | | 775 | | goods, warehouseGroup, warehouseShipping, |
| | | 776 | | row.Value<decimal?>("count") ?? 0m, |
| | | 777 | | row.Value<decimal?>("price") ?? 0m, |
| | | 778 | | row.Value<decimal?>("stock") ?? 0m, |
| | | 779 | | row.Value<bool?>("notRefund") ?? false, |
| | | 780 | | FavoritPartsPriceMapper.ReadOffset(row, "dateShipment"), |
| | | 781 | | row.Value<string>("comment"), |
| | | 782 | | goodsInfo?.Value<string>("Name"), |
| | | 783 | | goodsInfo?.Value<string>("Brand"), |
| | | 784 | | goodsInfo?.Value<string>("Number"), |
| | | 785 | | goodsInfo?.Value<int?>("Rate") ?? 1, |
| | | 786 | | warehouseGroupInfo?.Value<string>("code"), |
| | | 787 | | warehouseShippingInfo?.Value<string>("name"))); |
| | | 788 | | } |
| | | 789 | | return result; |
| | | 790 | | } |
| | | 791 | | |
| | | 792 | | private static JObject? FindByGuid(JObject? dict, string? rawId) |
| | | 793 | | { |
| | | 794 | | if (dict is null || string.IsNullOrEmpty(rawId)) return null; |
| | | 795 | | if (dict[rawId] is JObject exact) return exact; |
| | | 796 | | var prop = dict.Properties().FirstOrDefault(p => string.Equals(p.Name, rawId, StringComparison.OrdinalIgnoreCase |
| | | 797 | | return prop?.Value as JObject; |
| | | 798 | | } |
| | | 799 | | |
| | | 800 | | /// <summary>Проверяет актуальность выбранного товара и кратность упаковки из свежей проценки. |
| | | 801 | | /// Никогда не подменяет склад: если предложение ещё есть, в корзину идёт именно выбранная строка.</summary> |
| | | 802 | | private async Task<string?> GetOfferValidationErrorAsync(string article, string brand, Guid goodsId, |
| | | 803 | | Guid warehouseGroupId, decimal quantity, CancellationToken ct) |
| | | 804 | | { |
| | | 805 | | JToken response; |
| | | 806 | | try { response = await _favoritParts.SearchGoodsAsync(article, brand, analogues: false, ct); } |
| | | 807 | | catch (FavoritPartsApiException) { return null; } // сбой самой проверки не должен блокировать добавление |
| | | 808 | | |
| | | 809 | | var offer = FavoritPartsPriceMapper.Map(response, article, null, null).FirstOrDefault(o => |
| | | 810 | | string.Equals(o.ProviderData?.GetValueOrDefault("goodsId"), goodsId.ToString(), StringComparison.OrdinalIgno |
| | | 811 | | string.Equals(o.ProviderData?.GetValueOrDefault("warehouseGroupId"), warehouseGroupId.ToString(), StringComp |
| | | 812 | | if (offer is null) |
| | | 813 | | return "предложение FavoritParts больше не актуально (истёк срок цены/остатка). Обновите проценку и повторит |
| | | 814 | | |
| | | 815 | | var rate = offer.MinQuantity; |
| | | 816 | | return rate > 1 && quantity % rate != 0 |
| | | 817 | | ? $"FavoritParts принимает эту позицию только кратно {rate} шт.; в строке корзины получается {quantity:0.##} |
| | | 818 | | : null; |
| | | 819 | | } |
| | | 820 | | |
| | | 821 | | /// <summary>Отправляет один прямой запрос cart/add/ для выбранного товара и склада. |
| | | 822 | | /// Повторные запросы намеренно не выполняются: результат поставщика возвращается пользователю |
| | | 823 | | /// от единственной попытки.</summary> |
| | | 824 | | private async Task<FavoritPartsApiException?> AddToCartAsync(Guid goodsId, Guid warehouseGroupId, |
| | | 825 | | decimal quantity, string comment, CancellationToken ct) |
| | | 826 | | { |
| | | 827 | | try |
| | | 828 | | { |
| | | 829 | | await _favoritParts.CartAddAsync(goodsId, warehouseGroupId, quantity, comment, ct); |
| | | 830 | | return null; |
| | | 831 | | } |
| | | 832 | | catch (FavoritPartsApiException ex) { return ex; } |
| | | 833 | | } |
| | | 834 | | |
| | | 835 | | private Task<List<FavoritPartsCartItem>> LoadActiveLocalItemsAsync(CancellationToken ct) => |
| | | 836 | | _db.FavoritPartsCartItems.AsNoTracking() |
| | | 837 | | .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null |
| | | 838 | | && FavoritPartsCartItemStatus.Active.Contains(x.Status)) |
| | | 839 | | .ToListAsync(ct); |
| | | 840 | | |
| | | 841 | | /// <summary>Привязка строки корзины к заказу Studio2: одна активная строка на заказ |
| | | 842 | | /// (защищено уникальным индексом ProviderKey + ActiveStudioOrderId).</summary> |
| | | 843 | | private async Task<FavoritPartsCartItem> UpsertLocalCartItemAsync(FavoritPartsQueueItem item, Guid goodsId, |
| | | 844 | | Guid warehouseGroupId, string changedBy, CancellationToken ct, |
| | | 845 | | IReadOnlyDictionary<string, FavoritPartsApiCartLine>? remote = null) |
| | | 846 | | { |
| | | 847 | | var cartItem = await _db.FavoritPartsCartItems |
| | | 848 | | .FirstOrDefaultAsync(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId == item.OrderId, ct); |
| | | 849 | | if (cartItem is not null && remote is not null && |
| | | 850 | | (cartItem.GoodsId != goodsId || cartItem.WarehouseGroupId != warehouseGroupId)) |
| | | 851 | | { |
| | | 852 | | await ReleasePositionAsync(cartItem, remote, ct); |
| | | 853 | | } |
| | | 854 | | if (cartItem is null) |
| | | 855 | | { |
| | | 856 | | cartItem = new FavoritPartsCartItem |
| | | 857 | | { |
| | | 858 | | ProviderKey = ProviderKey, |
| | | 859 | | StudioOrderId = item.OrderId, |
| | | 860 | | ActiveStudioOrderId = item.OrderId, |
| | | 861 | | CreatedBy = changedBy |
| | | 862 | | }; |
| | | 863 | | _db.FavoritPartsCartItems.Add(cartItem); |
| | | 864 | | } |
| | | 865 | | |
| | | 866 | | cartItem.GoodsId = goodsId; |
| | | 867 | | cartItem.WarehouseGroupId = warehouseGroupId; |
| | | 868 | | cartItem.Article = Truncate(item.Article, 40) ?? string.Empty; |
| | | 869 | | cartItem.Brand = Truncate(item.Brand, 80) ?? string.Empty; |
| | | 870 | | cartItem.ProductName = Truncate(item.ProductName, 160); |
| | | 871 | | cartItem.Quantity = item.Quantity; |
| | | 872 | | cartItem.SourcePrice = item.SourcePrice; |
| | | 873 | | cartItem.CurrencyCode = item.CurrencyCode ?? "RUB"; |
| | | 874 | | cartItem.Status = FavoritPartsCartItemStatus.Pending; |
| | | 875 | | cartItem.LastError = null; |
| | | 876 | | cartItem.UpdatedAtUtc = DateTime.UtcNow; |
| | | 877 | | await _db.SaveChangesAsync(ct); |
| | | 878 | | return cartItem; |
| | | 879 | | } |
| | | 880 | | |
| | | 881 | | /// <summary>Заказ перевыставили на другой товар/склад: уменьшаем количество прежней строки корзины |
| | | 882 | | /// на его долю, иначе она осталась бы висеть и ушла бы в заказ повторно.</summary> |
| | | 883 | | private async Task ReleasePositionAsync(FavoritPartsCartItem previous, |
| | | 884 | | IReadOnlyDictionary<string, FavoritPartsApiCartLine> remote, CancellationToken ct) |
| | | 885 | | { |
| | | 886 | | if (!remote.TryGetValue(BuildCartKey(previous.GoodsId, previous.WarehouseGroupId), out var line)) return; |
| | | 887 | | var rest = Math.Max(0m, line.Count - previous.Quantity); |
| | | 888 | | try |
| | | 889 | | { |
| | | 890 | | await _favoritParts.CartAddAsync(previous.GoodsId, previous.WarehouseGroupId, rest, line.Comment, ct); |
| | | 891 | | } |
| | | 892 | | catch (FavoritPartsApiException ex) |
| | | 893 | | { |
| | | 894 | | _logger.LogWarning(ex, |
| | | 895 | | "FavoritParts: не удалось освободить прежнюю позицию корзины заказа {OrderId} (goods={GoodsId} warehouse |
| | | 896 | | previous.ActiveStudioOrderId, previous.GoodsId, previous.WarehouseGroupId); |
| | | 897 | | } |
| | | 898 | | } |
| | | 899 | | |
| | | 900 | | private async Task CloseLocalCartItemAsync(long? cartItemId, string status, CancellationToken ct) |
| | | 901 | | { |
| | | 902 | | if (cartItemId is null) return; |
| | | 903 | | var cartItem = await _db.FavoritPartsCartItems.FirstOrDefaultAsync(x => x.Id == cartItemId.Value, ct); |
| | | 904 | | if (cartItem is null) return; |
| | | 905 | | cartItem.Status = status; |
| | | 906 | | cartItem.ActiveStudioOrderId = null; |
| | | 907 | | cartItem.UpdatedAtUtc = DateTime.UtcNow; |
| | | 908 | | } |
| | | 909 | | |
| | | 910 | | private async Task<(Guid TradePointId, int PaymentType, int DeliveryType, int TransportType)> GetOrderDefaultsAsync( |
| | | 911 | | { |
| | | 912 | | if (_cache.TryGetValue(ProfileCacheKey, out (Guid, int, int, int) cached)) return cached; |
| | | 913 | | |
| | | 914 | | var profile = await _favoritParts.GetProfileAsync(ct); |
| | | 915 | | var tradePointDef = profile.Value<string>("tradePointDef"); |
| | | 916 | | if (!Guid.TryParse(tradePointDef, out var tradePointId)) |
| | | 917 | | throw new FavoritPartsApiException("В профиле FavoritParts не задана торговая точка по умолчанию (tradePoint |
| | | 918 | | |
| | | 919 | | // Способы оплаты 1 (наличная), 2 (безналичная) и 3 (карта) сервис принимает — проверено на боевом |
| | | 920 | | // API, хотя в списке ошибок документации упомянуты только 1 и 2. Не заданный (0) считаем безналичным. |
| | | 921 | | var paymentType = profile.Value<int?>("paymentType") is int payment and (1 or 2 or 3) ? payment : 2; |
| | | 922 | | var deliveryType = profile.Value<int?>("deliveryType") is > 0 ? profile.Value<int>("deliveryType") : 1; |
| | | 923 | | var transportType = deliveryType == 2 ? 0 : (profile.Value<int?>("transportType") is > 0 ? profile.Value<int>("t |
| | | 924 | | |
| | | 925 | | var value = (tradePointId, paymentType, deliveryType, transportType); |
| | | 926 | | _cache.Set(ProfileCacheKey, value, TimeSpan.FromMinutes(10)); |
| | | 927 | | return value; |
| | | 928 | | } |
| | | 929 | | |
| | | 930 | | private static string OrderLabel(FavoritPartsQueueItem item) => |
| | | 931 | | !string.IsNullOrWhiteSpace(item.ShipmentNumber) ? item.ShipmentNumber! : $"ID {item.OrderId}"; |
| | | 932 | | |
| | | 933 | | private static string? Truncate(string? value, int maxLength) => |
| | | 934 | | value is null || value.Length <= maxLength ? value : value[..maxLength]; |
| | | 935 | | |
| | | 936 | | private async Task TrackCartAsync(int orderId, string cartStatusCode, string? user, CancellationToken ct, |
| | | 937 | | int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null) |
| | | 938 | | { |
| | | 939 | | try |
| | | 940 | | { |
| | | 941 | | await _supplierState.MarkInCartAsync(orderId, ProviderKey, cartStatusCode, user: user, |
| | | 942 | | deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction, ct: ct); |
| | | 943 | | } |
| | | 944 | | catch (Exception ex) |
| | | 945 | | { |
| | | 946 | | _logger.LogWarning(ex, "FavoritParts: не удалось записать состояние корзины для заказа {OrderId}", orderId); |
| | | 947 | | } |
| | | 948 | | } |
| | | 949 | | |
| | | 950 | | private async Task TrackOrderedAsync(int orderId, string externalOrderId, string? user, CancellationToken ct) |
| | | 951 | | { |
| | | 952 | | try |
| | | 953 | | { |
| | | 954 | | await _supplierState.MarkOrderedAsync(orderId, ProviderKey, externalOrderId, |
| | | 955 | | externalOrderId: externalOrderId, user: user, ct: ct); |
| | | 956 | | } |
| | | 957 | | catch (Exception ex) |
| | | 958 | | { |
| | | 959 | | _logger.LogWarning(ex, "FavoritParts: не удалось привязать заказ {OrderId} к заказу поставщика", orderId); |
| | | 960 | | } |
| | | 961 | | } |
| | | 962 | | |
| | | 963 | | private async Task TrackRemovedAsync(int orderId, string? user, CancellationToken ct) |
| | | 964 | | { |
| | | 965 | | try |
| | | 966 | | { |
| | | 967 | | await _supplierState.RemoveFromCartAsync(orderId, ProviderKey, user, ct); |
| | | 968 | | } |
| | | 969 | | catch (Exception ex) |
| | | 970 | | { |
| | | 971 | | _logger.LogWarning(ex, "FavoritParts: не удалось закрыть состояние корзины для заказа {OrderId}", orderId); |
| | | 972 | | } |
| | | 973 | | } |
| | | 974 | | |
| | | 975 | | private async Task<bool> WasSuccessfullyPlacedAsync(int orderId, CancellationToken ct) |
| | | 976 | | { |
| | | 977 | | var history = await _history.GetByOrderIdAsync(orderId); |
| | | 978 | | return history.Any(entry => |
| | | 979 | | { |
| | | 980 | | if (!string.Equals(entry.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName, StringComparison.Ordin |
| | | 981 | | || string.IsNullOrWhiteSpace(entry.NewValue)) |
| | | 982 | | return false; |
| | | 983 | | |
| | | 984 | | var placement = ApiSupplierBasketHistoryEntry.TryParse(entry.NewValue); |
| | | 985 | | return placement is { Success: true } && string.Equals(placement.ProviderKey, ProviderKey, StringComparison. |
| | | 986 | | }); |
| | | 987 | | } |
| | | 988 | | |
| | | 989 | | private async Task TryRecordHistoryAsync(FavoritPartsQueueItem item, string orderLabel, bool success, |
| | | 990 | | string? errorMessage, string changedBy) |
| | | 991 | | { |
| | | 992 | | try |
| | | 993 | | { |
| | | 994 | | var entry = new ApiSupplierBasketHistoryEntry( |
| | | 995 | | ProviderKey, _supplierDisplayName, LogoUrl, success, errorMessage, |
| | | 996 | | ApiItemId: item.GoodsId, item.Article, item.ProductName, item.SourcePrice, |
| | | 997 | | item.CurrencyCode ?? "RUB", item.Quantity, orderLabel, |
| | | 998 | | ClientName: item.ClientName, |
| | | 999 | | CartStatus: success ? ApiSupplierBasketCartStatus.Pending : ApiSupplierBasketCartStatus.Failed, |
| | | 1000 | | SupplierCartUrl: $"/SupplierCart?provider={_providerRoute}&supplierTab=basket"); |
| | | 1001 | | |
| | | 1002 | | await _history.AddOrderHistory(new OrderHistory |
| | | 1003 | | { |
| | | 1004 | | OrderId = item.OrderId, |
| | | 1005 | | ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | | 1006 | | ColumnDisplayName = $"Корзина {_supplierDisplayName}", |
| | | 1007 | | OldValue = null, |
| | | 1008 | | NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions), |
| | | 1009 | | ChangedAt = DateTime.Now, |
| | | 1010 | | ChangedBy = changedBy |
| | | 1011 | | }); |
| | | 1012 | | } |
| | | 1013 | | catch |
| | | 1014 | | { |
| | | 1015 | | // Не ломаем размещение в корзине из-за журнала истории. |
| | | 1016 | | } |
| | | 1017 | | } |
| | | 1018 | | } |