| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.Encodings.Web; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | using Newtonsoft.Json.Linq; |
| | | 7 | | using OzonDomains.Models; |
| | | 8 | | using OzonDomains.Models.SupplierStatuses; |
| | | 9 | | using OzonRepositories.Context; |
| | | 10 | | using Services.ApiServices.FavoritPartsApi; |
| | | 11 | | using Services.SignalRServices; |
| | | 12 | | |
| | | 13 | | namespace Services.SupplierStatusServices.Providers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Статусы FavoritParts. Корзина локальная — читается из БД (LocalCartStatusReader); заказ опрашивается |
| | | 17 | | /// через GET /ws/v1/order/{id}/, по одному вызову на локально известный внешний GUID заказа (ответ уже |
| | | 18 | | /// содержит объект status {id, name} — отдельного похода в справочник statuses не нужно). |
| | | 19 | | /// Из состава заказа берётся countCancel — количество, которое поставщик отгрузить не сможет; |
| | | 20 | | /// version/number/postError сохраняются в FavoritPartsOrders, чтобы было видно, проведён ли заказ |
| | | 21 | | /// в учётной системе Фаворита и менялся ли он после создания. |
| | | 22 | | /// </summary> |
| | | 23 | | public class FavoritPartsSupplierStatusProvider : ISupplierStatusProvider |
| | | 24 | | { |
| | | 25 | | /// <summary>Один заказ — отдельный запрос; та же граница, что у Forum-Auto/ML-Auto.</summary> |
| | | 26 | | private const int DetailsConcurrency = 4; |
| | | 27 | | |
| | | 28 | | private readonly ApplicationDbContext _db; |
| | | 29 | | private readonly FavoritPartsDataManager _favoritParts; |
| | | 30 | | private readonly ILogger<FavoritPartsSupplierStatusProvider> _logger; |
| | | 31 | | |
| | 34 | 32 | | public FavoritPartsSupplierStatusProvider( |
| | 34 | 33 | | ApplicationDbContext db, FavoritPartsDataManager favoritParts, |
| | 34 | 34 | | ILogger<FavoritPartsSupplierStatusProvider> logger, |
| | 34 | 35 | | string providerKey = ApiSupplierSettings.FavoritPartsProviderKey) |
| | | 36 | | { |
| | 34 | 37 | | _db = db; |
| | 34 | 38 | | _favoritParts = favoritParts; |
| | 34 | 39 | | _logger = logger; |
| | 34 | 40 | | ProviderKey = providerKey; |
| | 34 | 41 | | } |
| | | 42 | | |
| | 44 | 43 | | public string ProviderKey { get; } |
| | | 44 | | |
| | | 45 | | /// <summary> |
| | | 46 | | /// Корзина FavoritParts серверная, поэтому локальной таблицы недостаточно: позицию могли убрать |
| | | 47 | | /// на сайте поставщика или прямым вызовом API мимо Studio2, и тогда локальная строка навсегда |
| | | 48 | | /// осталась бы в «В корзине». Сверяем локальные привязки с реальным составом GET ws/v1/cart/: |
| | | 49 | | /// чего в корзине нет — закрываем как Removed, в том числе в самой таблице привязок. |
| | | 50 | | /// </summary> |
| | | 51 | | public async Task<IReadOnlyList<SupplierStatusSnapshot>> FetchCartAsync( |
| | | 52 | | IReadOnlyList<OrderSupplierStateRef> states, CancellationToken ct) |
| | | 53 | | { |
| | 21 | 54 | | if (states.Count == 0) return []; |
| | | 55 | | |
| | | 56 | | IReadOnlyCollection<(Guid Goods, Guid WarehouseGroup)> positions; |
| | | 57 | | try |
| | | 58 | | { |
| | 17 | 59 | | positions = await _favoritParts.GetCartPositionsAsync(ct); |
| | 15 | 60 | | } |
| | 2 | 61 | | catch (Exception ex) when (ex is FavoritPartsApiException or HttpRequestException or TaskCanceledException) |
| | | 62 | | { |
| | | 63 | | // Недоступность API не должна выглядеть как выход из корзины — отдаём ошибку синхронизации. |
| | 2 | 64 | | _logger.LogInformation("FavoritParts: не удалось прочитать корзину для сверки статусов: {Error}", ex.Message |
| | 4 | 65 | | return states.Select(state => new SupplierStatusSnapshot |
| | 4 | 66 | | { |
| | 4 | 67 | | OrderId = state.OrderId, Kind = SupplierStatusKind.Cart, StatusName = string.Empty, |
| | 4 | 68 | | Error = $"FavoritParts: {ex.Message}" |
| | 4 | 69 | | }).ToList(); |
| | | 70 | | } |
| | | 71 | | |
| | 30 | 72 | | var orderIds = states.Select(x => x.OrderId).Distinct().ToList(); |
| | 15 | 73 | | var items = await _db.FavoritPartsCartItems |
| | 15 | 74 | | .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null |
| | 15 | 75 | | && orderIds.Contains(x.ActiveStudioOrderId.Value)) |
| | 15 | 76 | | .ToListAsync(ct); |
| | 15 | 77 | | var byOrderId = items |
| | 15 | 78 | | .GroupBy(x => x.ActiveStudioOrderId!.Value) |
| | 58 | 79 | | .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAtUtc).First()); |
| | | 80 | | |
| | 15 | 81 | | var result = new List<SupplierStatusSnapshot>(states.Count); |
| | 60 | 82 | | foreach (var state in states) |
| | | 83 | | { |
| | | 84 | | string code; |
| | 15 | 85 | | if (!byOrderId.TryGetValue(state.OrderId, out var item)) |
| | | 86 | | { |
| | | 87 | | // Привязки нет вовсе — позиция ушла из корзины мимо нас. |
| | 1 | 88 | | code = SupplierCartStatusCode.Removed; |
| | | 89 | | } |
| | 14 | 90 | | else if (!positions.Contains((item.GoodsId, item.WarehouseGroupId))) |
| | | 91 | | { |
| | 2 | 92 | | code = SupplierCartStatusCode.Removed; |
| | 2 | 93 | | item.Status = FavoritPartsCartItemStatus.Removed; |
| | 2 | 94 | | item.ActiveStudioOrderId = null; |
| | 2 | 95 | | item.UpdatedAtUtc = DateTime.UtcNow; |
| | | 96 | | } |
| | | 97 | | else |
| | | 98 | | { |
| | 12 | 99 | | code = string.IsNullOrWhiteSpace(item.Status) ? SupplierCartStatusCode.Pending : item.Status; |
| | | 100 | | } |
| | | 101 | | |
| | 15 | 102 | | result.Add(new SupplierStatusSnapshot |
| | 15 | 103 | | { |
| | 15 | 104 | | OrderId = state.OrderId, |
| | 15 | 105 | | Kind = SupplierStatusKind.Cart, |
| | 15 | 106 | | ExternalCode = code, |
| | 15 | 107 | | StatusName = CartStatusName(code) |
| | 15 | 108 | | }); |
| | | 109 | | } |
| | | 110 | | |
| | 15 | 111 | | await _db.SaveChangesAsync(ct); |
| | 15 | 112 | | return result; |
| | 19 | 113 | | } |
| | | 114 | | |
| | 15 | 115 | | private static string CartStatusName(string code) => code switch |
| | 15 | 116 | | { |
| | 4 | 117 | | SupplierCartStatusCode.Pending => "В корзине, нужна проверка", |
| | 3 | 118 | | SupplierCartStatusCode.Ready => "Проверен, готов к заказу", |
| | 1 | 119 | | SupplierCartStatusCode.Processing => "Оформляется", |
| | 1 | 120 | | SupplierCartStatusCode.Ordered => "Заказан у поставщика", |
| | 1 | 121 | | SupplierCartStatusCode.Failed => "Ошибка проверки", |
| | 4 | 122 | | SupplierCartStatusCode.Removed => "Удалён из корзины", |
| | 1 | 123 | | _ => code |
| | 15 | 124 | | }; |
| | | 125 | | |
| | | 126 | | public async Task<IReadOnlyList<SupplierStatusSnapshot>> FetchOrderAsync( |
| | | 127 | | IReadOnlyList<OrderSupplierStateRef> states, CancellationToken ct) |
| | | 128 | | { |
| | 16 | 129 | | if (states.Count == 0) return []; |
| | | 130 | | |
| | 31 | 131 | | var orderIds = states.Select(x => x.OrderId).Distinct().ToList(); |
| | 14 | 132 | | var links = await _db.FavoritPartsOrderItems |
| | 14 | 133 | | .Where(x => orderIds.Contains(x.StudioOrderId) |
| | 14 | 134 | | && x.FavoritPartsOrder != null && x.FavoritPartsOrder.ProviderKey == ProviderKey |
| | 14 | 135 | | && x.FavoritPartsOrder.ExternalOrderId != null) |
| | 14 | 136 | | .Select(x => new OrderLink(x.StudioOrderId, x.FavoritPartsOrder!.ExternalOrderId!.Value, |
| | 14 | 137 | | x.GoodsId, x.WarehouseGroupId, x.Quantity)) |
| | 14 | 138 | | .ToListAsync(ct); |
| | 15 | 139 | | if (links.Count == 0) return []; |
| | | 140 | | |
| | 28 | 141 | | var byOrder = links.GroupBy(x => x.ExternalOrderId).ToList(); |
| | 13 | 142 | | var gate = new SemaphoreSlim(DetailsConcurrency); |
| | 13 | 143 | | var tasks = byOrder.Select(async group => |
| | 13 | 144 | | { |
| | 14 | 145 | | await gate.WaitAsync(ct); |
| | 13 | 146 | | try |
| | 13 | 147 | | { |
| | 14 | 148 | | var response = await _favoritParts.GetOrderAsync(group.Key, ct); |
| | 13 | 149 | | return (Group: group, Order: response as JObject, Error: (string?)null); |
| | 13 | 150 | | } |
| | 1 | 151 | | catch (Exception ex) when (ex is FavoritPartsApiException or HttpRequestException or TaskCanceledException) |
| | 13 | 152 | | { |
| | 1 | 153 | | _logger.LogInformation("FavoritParts: не удалось прочитать заказ {ExternalOrderId}: {Error}", group.Key, |
| | 1 | 154 | | return (Group: group, Order: (JObject?)null, Error: $"FavoritParts: {ex.Message}"); |
| | 13 | 155 | | } |
| | 13 | 156 | | finally |
| | 13 | 157 | | { |
| | 14 | 158 | | gate.Release(); |
| | 13 | 159 | | } |
| | 27 | 160 | | }); |
| | | 161 | | |
| | 13 | 162 | | var result = new List<SupplierStatusSnapshot>(); |
| | 54 | 163 | | foreach (var (group, order, error) in await Task.WhenAll(tasks)) |
| | | 164 | | { |
| | 14 | 165 | | if (error is not null) |
| | | 166 | | { |
| | 4 | 167 | | foreach (var link in group) |
| | | 168 | | { |
| | 1 | 169 | | result.Add(new SupplierStatusSnapshot |
| | 1 | 170 | | { |
| | 1 | 171 | | OrderId = link.StudioOrderId, Kind = SupplierStatusKind.Order, StatusName = string.Empty, Error |
| | 1 | 172 | | }); |
| | | 173 | | } |
| | | 174 | | continue; |
| | | 175 | | } |
| | | 176 | | |
| | 13 | 177 | | var status = order?["status"] as JObject; |
| | 13 | 178 | | var statusName = status?.Value<string>("name"); |
| | 13 | 179 | | await PersistOrderStateAsync(group.Key, order, statusName, ct); |
| | 13 | 180 | | if (status is null || string.IsNullOrWhiteSpace(statusName)) continue; |
| | | 181 | | |
| | 12 | 182 | | var statusId = status.Value<int?>("id"); |
| | 12 | 183 | | var shippingDate = ReadDateTime(order, "shippingDate"); |
| | 12 | 184 | | var quantities = SplitQuantities(order, group.ToList()); |
| | 12 | 185 | | var orderNumber = order?.Value<string>("number"); |
| | | 186 | | |
| | 50 | 187 | | foreach (var link in group) |
| | | 188 | | { |
| | 13 | 189 | | var (confirmed, rejected) = quantities.TryGetValue(link.StudioOrderId, out var q) ? q : (link.Quantity, |
| | 13 | 190 | | result.Add(new SupplierStatusSnapshot |
| | 13 | 191 | | { |
| | 13 | 192 | | OrderId = link.StudioOrderId, |
| | 13 | 193 | | Kind = SupplierStatusKind.Order, |
| | 13 | 194 | | ExternalCode = statusId?.ToString(CultureInfo.InvariantCulture), |
| | 13 | 195 | | StatusName = statusName, |
| | 13 | 196 | | ExternalOrderNumber = string.IsNullOrWhiteSpace(orderNumber) ? FavoritPartsGuid.Format(group.Key) : |
| | 13 | 197 | | ExternalOrderId = FavoritPartsGuid.Format(group.Key), |
| | 13 | 198 | | QuantityConfirmed = confirmed, |
| | 13 | 199 | | QuantityRejected = rejected, |
| | 13 | 200 | | ExpectedDeliveryAt = shippingDate |
| | 13 | 201 | | }); |
| | | 202 | | } |
| | 12 | 203 | | } |
| | | 204 | | |
| | 13 | 205 | | await _db.SaveChangesAsync(ct); |
| | 13 | 206 | | return result; |
| | 15 | 207 | | } |
| | | 208 | | |
| | 142 | 209 | | private sealed record OrderLink(int StudioOrderId, Guid ExternalOrderId, Guid GoodsId, Guid WarehouseGroupId, decima |
| | | 210 | | |
| | | 211 | | /// <summary> |
| | | 212 | | /// Раскладывает countCancel строки заказа по заказам Studio2. Строка заказа поставщика может обслуживать |
| | | 213 | | /// несколько заказов Studio2 (одинаковый товар с одного склада хранения уходит одной строкой), а поставщик |
| | | 214 | | /// снимает количество со строки целиком и не знает про наше деление — подтверждённое количество раздаём |
| | | 215 | | /// по возрастанию Id заказа, снятое достаётся последним. |
| | | 216 | | /// </summary> |
| | | 217 | | private static Dictionary<int, (decimal Confirmed, decimal Rejected)> SplitQuantities(JObject? order, List<OrderLink |
| | | 218 | | { |
| | 12 | 219 | | var result = new Dictionary<int, (decimal, decimal)>(); |
| | 12 | 220 | | var rows = (order?["goodsList"] as JArray ?? new JArray()).OfType<JObject>().ToList(); |
| | | 221 | | |
| | 61 | 222 | | foreach (var byPosition in links.GroupBy(x => (x.GoodsId, x.WarehouseGroupId))) |
| | | 223 | | { |
| | 17 | 224 | | var row = rows.FirstOrDefault(r => MatchesPosition(r, byPosition.Key.GoodsId, byPosition.Key.WarehouseGroupI |
| | 19 | 225 | | var ordered = row?.Value<decimal?>("count") ?? byPosition.Sum(x => x.Quantity); |
| | 12 | 226 | | var cancelled = row?.Value<decimal?>("countCancel") ?? 0m; |
| | 12 | 227 | | var available = Math.Max(0m, ordered - cancelled); |
| | | 228 | | |
| | 63 | 229 | | foreach (var link in byPosition.OrderBy(x => x.StudioOrderId)) |
| | | 230 | | { |
| | 13 | 231 | | var confirmed = Math.Min(available, link.Quantity); |
| | 13 | 232 | | available -= confirmed; |
| | 13 | 233 | | result[link.StudioOrderId] = (confirmed, link.Quantity - confirmed); |
| | | 234 | | } |
| | | 235 | | } |
| | 12 | 236 | | return result; |
| | | 237 | | } |
| | | 238 | | |
| | 1 | 239 | | private static readonly JsonSerializerOptions JsonOptions = new() |
| | 1 | 240 | | { |
| | 1 | 241 | | Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping |
| | 1 | 242 | | }; |
| | | 243 | | |
| | | 244 | | /// <summary>Номер в учётной системе, статус, идентификатор версии и ошибку проведения храним локально: |
| | | 245 | | /// version меняется при каждом изменении заказа на стороне Фаворита, postError означает, что заказ |
| | | 246 | | /// создан на сайте, но в учётную систему не проведён.</summary> |
| | | 247 | | private async Task PersistOrderStateAsync(Guid externalOrderId, JObject? order, string? statusName, CancellationToke |
| | | 248 | | { |
| | 13 | 249 | | if (order is null) return; |
| | 13 | 250 | | var local = await _db.FavoritPartsOrders |
| | 13 | 251 | | .FirstOrDefaultAsync(x => x.ProviderKey == ProviderKey && x.ExternalOrderId == externalOrderId, ct); |
| | 13 | 252 | | if (local is null) return; |
| | | 253 | | |
| | 13 | 254 | | var number = order.Value<string>("number"); |
| | 13 | 255 | | var version = order.Value<string>("version"); |
| | 13 | 256 | | var postError = order.Value<string>("postError"); |
| | | 257 | | |
| | 13 | 258 | | string? previousPostError = null; |
| | 13 | 259 | | if (!string.IsNullOrWhiteSpace(local.ResponseJson)) |
| | | 260 | | { |
| | | 261 | | try |
| | | 262 | | { |
| | 1 | 263 | | using var doc = JsonDocument.Parse(local.ResponseJson); |
| | 0 | 264 | | if (doc.RootElement.TryGetProperty("postError", out var prop) && prop.ValueKind == JsonValueKind.String) |
| | 0 | 265 | | previousPostError = prop.GetString(); |
| | 0 | 266 | | } |
| | 1 | 267 | | catch |
| | | 268 | | { |
| | | 269 | | // Игнорируем ошибки парсинга прежнего ответа |
| | 1 | 270 | | } |
| | | 271 | | } |
| | | 272 | | |
| | 25 | 273 | | if (!string.IsNullOrWhiteSpace(number)) local.Number = number.Length > 40 ? number[..40] : number; |
| | 25 | 274 | | if (!string.IsNullOrWhiteSpace(statusName)) local.Status = statusName.Length > 30 ? statusName[..30] : statusNam |
| | 13 | 275 | | local.ResponseJson = JsonSerializer.Serialize(new |
| | 13 | 276 | | { |
| | 13 | 277 | | version, |
| | 13 | 278 | | number, |
| | 13 | 279 | | status = statusName, |
| | 13 | 280 | | postError, |
| | 13 | 281 | | fetchedAtUtc = DateTime.UtcNow |
| | 13 | 282 | | }, JsonOptions); |
| | | 283 | | |
| | 13 | 284 | | if (!string.IsNullOrWhiteSpace(postError)) |
| | | 285 | | { |
| | 2 | 286 | | _logger.LogInformation("FavoritParts: заказ {ExternalOrderId} не проведён у поставщика: {PostError}", extern |
| | | 287 | | |
| | 2 | 288 | | if (!string.Equals(previousPostError, postError, StringComparison.Ordinal)) |
| | | 289 | | { |
| | 2 | 290 | | var orderLabel = !string.IsNullOrWhiteSpace(local.Number) ? local.Number : FavoritPartsGuid.Format(exter |
| | | 291 | | try |
| | | 292 | | { |
| | 2 | 293 | | await NotificationService.NotifyAsync( |
| | 2 | 294 | | "FavoritParts", |
| | 2 | 295 | | "#D97706", |
| | 2 | 296 | | $"Заказ {orderLabel} не проведён у поставщика: {postError}", |
| | 2 | 297 | | "fa-triangle-exclamation"); |
| | 0 | 298 | | } |
| | 2 | 299 | | catch |
| | | 300 | | { |
| | | 301 | | // Игнорируем отсутствие активного NotificationService (фоновый runner/тесты) |
| | 2 | 302 | | } |
| | | 303 | | } |
| | | 304 | | } |
| | 13 | 305 | | } |
| | | 306 | | |
| | | 307 | | private static bool MatchesPosition(JObject row, Guid goodsId, Guid warehouseGroupId) |
| | | 308 | | { |
| | 5 | 309 | | if (!Guid.TryParse((row["goods"] as JObject)?.Value<string>("id"), out var rowGoods) || rowGoods != goodsId) |
| | 0 | 310 | | return false; |
| | | 311 | | // Склад хранения в строке заказа приходит не всегда — тогда сверяем только товар. |
| | 5 | 312 | | if (row["warehouseGroup"] is not JObject warehouse || !Guid.TryParse(warehouse.Value<string>("id"), out var rowW |
| | 1 | 313 | | return true; |
| | 4 | 314 | | return rowWarehouse == warehouseGroupId; |
| | | 315 | | } |
| | | 316 | | |
| | | 317 | | /// <summary>Дата отгрузки приходит с оффсетом +0300 — приводим её к московской дате, |
| | | 318 | | /// иначе на UTC-хосте отгрузка «на завтра» показывалась бы сегодняшним вечером.</summary> |
| | | 319 | | private static DateTime? ReadDateTime(JObject? source, string propertyName) |
| | | 320 | | { |
| | 12 | 321 | | if (source is null) return null; |
| | 12 | 322 | | var value = FavoritPartsPriceMapper.ToMoscowDate(FavoritPartsPriceMapper.ReadOffset(source, propertyName)); |
| | 12 | 323 | | return value?.ToDateTime(TimeOnly.MinValue); |
| | | 324 | | } |
| | | 325 | | } |