| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using OzonDomains.Models; |
| | | 4 | | using OzonDomains.Models.SupplierStatuses; |
| | | 5 | | using OzonRepositories.Context; |
| | | 6 | | |
| | | 7 | | namespace Services.SupplierStatusServices; |
| | | 8 | | |
| | | 9 | | public interface ISupplierOrderVerificationService |
| | | 10 | | { |
| | | 11 | | /// <summary> |
| | | 12 | | /// Независимо проверяет, что заказы проведённой транзакции реально созданы у API-поставщиков: |
| | | 13 | | /// повторно читает поставщика через <see cref="ISupplierStatusProvider"/> и сверяет ответ с состоянием |
| | | 14 | | /// <c>OrderSupplierStates</c>. Только читает у поставщика; результат пишется в историю заказа. |
| | | 15 | | /// Никогда не бросает исключений: проверка вторична и не должна ломать уже проведённую транзакцию. |
| | | 16 | | /// </summary> |
| | | 17 | | /// <param name="orderIds">Заказы Studio2 проведённой транзакции.</param> |
| | | 18 | | /// <param name="requiredOrderIds"> |
| | | 19 | | /// Заказы, которые были в очередях поставщиков: если состояния у поставщика по ним нет — это «не найдено». |
| | | 20 | | /// </param> |
| | | 21 | | /// <param name="sinceUtc">Состояния, не менявшиеся с этого момента, считаются старыми и не проверяются.</param> |
| | | 22 | | /// <param name="writeHistory">Записывать ли результат системной записью в историю заказа.</param> |
| | | 23 | | Task<SupplierVerificationSummary> VerifyAsync( |
| | | 24 | | IReadOnlyCollection<int> orderIds, |
| | | 25 | | IReadOnlyCollection<int>? requiredOrderIds = null, |
| | | 26 | | DateTime? sinceUtc = null, |
| | | 27 | | bool writeHistory = true, |
| | | 28 | | CancellationToken ct = default); |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | public sealed class SupplierOrderVerificationService : ISupplierOrderVerificationService |
| | | 32 | | { |
| | | 33 | | /// <summary>Потолок ожидания одного поставщика: медленный API не должен подвешивать проведение.</summary> |
| | 1 | 34 | | private static readonly TimeSpan ProviderTimeout = TimeSpan.FromSeconds(15); |
| | | 35 | | |
| | 1 | 36 | | private static readonly TimeSpan DefaultLookback = TimeSpan.FromMinutes(30); |
| | | 37 | | |
| | | 38 | | /// <summary> |
| | | 39 | | /// Провайдеры, у которых стадия корзины — локальная таблица Studio2: поставщик о позиции ещё не знает, |
| | | 40 | | /// поэтому «подтвердить» её через API нельзя (иначе получилось бы ложное «подтверждено»). |
| | | 41 | | /// </summary> |
| | 1 | 42 | | private static readonly HashSet<string> LocalCartProviders = new(StringComparer.Ordinal) |
| | 1 | 43 | | { |
| | 1 | 44 | | ApiSupplierSettings.ArmtekProviderKey, ApiSupplierSettings.ArmtekByProviderKey, |
| | 1 | 45 | | ApiSupplierSettings.MlAutoProviderKey, ApiSupplierSettings.MlAutoRuProviderKey, |
| | 1 | 46 | | ApiSupplierSettings.ForumAutoProviderKey, ApiSupplierSettings.ForumAutoInterpartsProviderKey, |
| | 1 | 47 | | ApiSupplierSettings.ForumAutoPiterProviderKey, ApiSupplierSettings.ForumAutoRostovProviderKey, |
| | 1 | 48 | | ApiSupplierSettings.ForumAutoIstraProviderKey, ApiSupplierSettings.BergProviderKey, |
| | 1 | 49 | | ApiSupplierSettings.MotexProviderKey, ApiSupplierSettings.ProfitLigaProviderKey, |
| | 1 | 50 | | ApiSupplierSettings.ZZapMoscowProviderKey |
| | 1 | 51 | | }; |
| | | 52 | | |
| | | 53 | | private readonly ApplicationDbContext _db; |
| | | 54 | | private readonly IReadOnlyDictionary<string, ISupplierStatusProvider> _providers; |
| | | 55 | | private readonly ILogger<SupplierOrderVerificationService> _logger; |
| | | 56 | | |
| | 13 | 57 | | public SupplierOrderVerificationService( |
| | 13 | 58 | | ApplicationDbContext db, |
| | 13 | 59 | | IEnumerable<ISupplierStatusProvider> providers, |
| | 13 | 60 | | ILogger<SupplierOrderVerificationService> logger) |
| | | 61 | | { |
| | 13 | 62 | | _db = db; |
| | 26 | 63 | | _providers = providers.ToDictionary(x => x.ProviderKey, StringComparer.Ordinal); |
| | 13 | 64 | | _logger = logger; |
| | 13 | 65 | | } |
| | | 66 | | |
| | | 67 | | public async Task<SupplierVerificationSummary> VerifyAsync( |
| | | 68 | | IReadOnlyCollection<int> orderIds, |
| | | 69 | | IReadOnlyCollection<int>? requiredOrderIds = null, |
| | | 70 | | DateTime? sinceUtc = null, |
| | | 71 | | bool writeHistory = true, |
| | | 72 | | CancellationToken ct = default) |
| | | 73 | | { |
| | | 74 | | try |
| | | 75 | | { |
| | 31 | 76 | | var ids = orderIds.Concat(requiredOrderIds ?? []).Where(x => x > 0).Distinct().ToList(); |
| | 13 | 77 | | if (ids.Count == 0) return SupplierVerificationSummary.Empty; |
| | | 78 | | |
| | 13 | 79 | | var since = sinceUtc ?? DateTime.UtcNow - DefaultLookback; |
| | 13 | 80 | | var states = await _db.OrderSupplierStates |
| | 13 | 81 | | .AsNoTracking() |
| | 13 | 82 | | .Where(x => ids.Contains(x.OrderId) |
| | 13 | 83 | | && (x.Stage == OrderSupplierStage.Cart || x.Stage == OrderSupplierStage.Ordered) |
| | 13 | 84 | | && x.UpdatedAt >= since) |
| | 13 | 85 | | .Select(x => new OrderSupplierStateRef( |
| | 13 | 86 | | x.Id, x.OrderId, x.ProviderKey, x.Stage, x.CartItemId, x.ExternalBasketItemId, |
| | 13 | 87 | | x.ExternalOrderNumber, x.ExternalOrderId, x.ExternalLineId, x.SupplierStatusId)) |
| | 13 | 88 | | .ToListAsync(ct); |
| | | 89 | | |
| | 25 | 90 | | var stateIds = states.Select(s => s.StateId).ToList(); |
| | 13 | 91 | | var quantityByState = await _db.OrderSupplierStates |
| | 13 | 92 | | .AsNoTracking() |
| | 13 | 93 | | .Where(x => stateIds.Contains(x.Id)) |
| | 13 | 94 | | .Select(x => new { x.Id, x.QuantityOrdered }) |
| | 37 | 95 | | .ToDictionaryAsync(x => x.Id, x => x.QuantityOrdered, ct); |
| | | 96 | | |
| | 13 | 97 | | var orderInfo = await _db.Orders |
| | 13 | 98 | | .AsNoTracking() |
| | 13 | 99 | | .Where(x => ids.Contains(x.Id)) |
| | 13 | 100 | | .Select(x => new OrderInfo(x.Id, x.ShipmentNumber, x.Article, x.ProductName)) |
| | 26 | 101 | | .ToDictionaryAsync(x => x.Id, ct); |
| | | 102 | | |
| | 13 | 103 | | var verifiedAt = DateTime.Now; |
| | 13 | 104 | | var rows = new List<SupplierVerificationRow>(); |
| | | 105 | | |
| | | 106 | | // Провайдеры делят один scoped DbContext (локальные читалки корзин), поэтому опрашиваем последовательно. |
| | 62 | 107 | | foreach (var group in states.GroupBy(x => new { x.ProviderKey, x.Stage })) |
| | | 108 | | { |
| | 12 | 109 | | var batch = group.ToList(); |
| | 12 | 110 | | var snapshots = await FetchAsync(group.Key.ProviderKey, group.Key.Stage, batch, ct); |
| | 48 | 111 | | foreach (var state in batch) |
| | | 112 | | { |
| | 12 | 113 | | orderInfo.TryGetValue(state.OrderId, out var info); |
| | 12 | 114 | | quantityByState.TryGetValue(state.StateId, out var quantityOrdered); |
| | 12 | 115 | | rows.Add(Evaluate(state, quantityOrdered, snapshots, info, verifiedAt)); |
| | | 116 | | } |
| | 12 | 117 | | } |
| | | 118 | | |
| | | 119 | | // Заказ был в очереди поставщика, а состояния у поставщика нет — заказ так и не появился. |
| | 25 | 120 | | var coveredOrderIds = states.Select(x => x.OrderId).ToHashSet(); |
| | 32 | 121 | | foreach (var orderId in (requiredOrderIds ?? []).Distinct().Where(x => x > 0 && !coveredOrderIds.Contains(x) |
| | | 122 | | { |
| | 1 | 123 | | orderInfo.TryGetValue(orderId, out var info); |
| | 1 | 124 | | rows.Add(new SupplierVerificationRow( |
| | 1 | 125 | | orderId, info?.ShipmentNumber, info?.Article, info?.ProductName, |
| | 1 | 126 | | string.Empty, SupplierProviderNames.Describe(string.Empty), "—", |
| | 1 | 127 | | SupplierVerificationStatus.NotFound, |
| | 1 | 128 | | "Заказ был в очереди поставщика, но записи о нём у поставщика нет.", |
| | 1 | 129 | | null, null, |
| | 1 | 130 | | ["✖ Запись состояния у поставщика не найдена: позиция не была добавлена в корзину или заказ не оформ |
| | 1 | 131 | | verifiedAt)); |
| | | 132 | | } |
| | | 133 | | |
| | 39 | 134 | | rows = rows.OrderByDescending(x => x.IsProblem).ThenBy(x => x.OrderId).ToList(); |
| | 24 | 135 | | if (writeHistory && rows.Count > 0) await WriteHistoryAsync(rows, ct); |
| | 13 | 136 | | return new SupplierVerificationSummary(rows); |
| | | 137 | | } |
| | 0 | 138 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 139 | | { |
| | 0 | 140 | | _logger.LogError(ex, "Проверка заказов у поставщиков не удалась для {Count} заказов", orderIds.Count); |
| | 0 | 141 | | return SupplierVerificationSummary.Empty; |
| | | 142 | | } |
| | 13 | 143 | | } |
| | | 144 | | |
| | | 145 | | private async Task<FetchOutcome> FetchAsync( |
| | | 146 | | string providerKey, string stage, IReadOnlyList<OrderSupplierStateRef> batch, CancellationToken ct) |
| | | 147 | | { |
| | 12 | 148 | | if (!_providers.TryGetValue(providerKey, out var provider)) |
| | 0 | 149 | | return FetchOutcome.Failed($"Нет провайдера чтения для «{providerKey}»", notVerifiable: true); |
| | | 150 | | |
| | 12 | 151 | | if (stage == OrderSupplierStage.Cart && LocalCartProviders.Contains(providerKey)) |
| | 1 | 152 | | return FetchOutcome.Failed( |
| | 1 | 153 | | "Корзина локальная: у поставщика заказа ещё нет, проверка будет после оформления", notVerifiable: true); |
| | | 154 | | |
| | | 155 | | try |
| | | 156 | | { |
| | 11 | 157 | | using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); |
| | 11 | 158 | | timeout.CancelAfter(ProviderTimeout); |
| | 11 | 159 | | var snapshots = stage == OrderSupplierStage.Cart |
| | 11 | 160 | | ? await provider.FetchCartAsync(batch, timeout.Token) |
| | 11 | 161 | | : await provider.FetchOrderAsync(batch, timeout.Token); |
| | 10 | 162 | | return FetchOutcome.Ok(snapshots); |
| | | 163 | | } |
| | 0 | 164 | | catch (OperationCanceledException) when (!ct.IsCancellationRequested) |
| | | 165 | | { |
| | 0 | 166 | | return FetchOutcome.Failed($"Поставщик не ответил за {ProviderTimeout.TotalSeconds:0} с"); |
| | | 167 | | } |
| | 1 | 168 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 169 | | { |
| | 1 | 170 | | _logger.LogWarning(ex, "Проверка поставщика {ProviderKey} ({Stage}) не удалась", providerKey, stage); |
| | 1 | 171 | | return FetchOutcome.Failed($"Ошибка API: {ex.Message}"); |
| | | 172 | | } |
| | 12 | 173 | | } |
| | | 174 | | |
| | | 175 | | private static SupplierVerificationRow Evaluate( |
| | | 176 | | OrderSupplierStateRef state, decimal? quantityOrdered, FetchOutcome outcome, OrderInfo? info, DateTime verifiedA |
| | | 177 | | { |
| | | 178 | | SupplierVerificationRow Row(string status, string summary, IReadOnlyList<string> checks, |
| | 12 | 179 | | string? number = null, string? supplierStatus = null) => new( |
| | 12 | 180 | | state.OrderId, info?.ShipmentNumber, info?.Article, info?.ProductName, state.ProviderKey, |
| | 12 | 181 | | SupplierProviderNames.Describe(state.ProviderKey), state.Stage, status, summary, |
| | 12 | 182 | | number ?? state.ExternalOrderNumber, supplierStatus, checks, verifiedAt); |
| | | 183 | | |
| | 12 | 184 | | if (outcome.Error is not null) |
| | | 185 | | { |
| | 2 | 186 | | var status = outcome.NotVerifiable ? SupplierVerificationStatus.NotVerifiable : SupplierVerificationStatus.A |
| | 2 | 187 | | return Row(status, outcome.Error, [$"{(outcome.NotVerifiable ? "•" : "✖")} {outcome.Error}"]); |
| | | 188 | | } |
| | | 189 | | |
| | 18 | 190 | | var snapshot = outcome.Snapshots.FirstOrDefault(x => x.OrderId == state.OrderId); |
| | 10 | 191 | | if (snapshot is null) |
| | | 192 | | { |
| | 2 | 193 | | var where = state.Stage == OrderSupplierStage.Cart ? "в корзине поставщика" : "среди заказов поставщика"; |
| | 2 | 194 | | return Row(SupplierVerificationStatus.NotFound, $"Позиция не найдена {where}.", |
| | 2 | 195 | | [$"✖ Поставщик не вернул позицию {where}"]); |
| | | 196 | | } |
| | | 197 | | |
| | 8 | 198 | | if (!string.IsNullOrWhiteSpace(snapshot.Error)) |
| | 1 | 199 | | return Row(SupplierVerificationStatus.ApiError, snapshot.Error!, [$"✖ {snapshot.Error}"], |
| | 1 | 200 | | snapshot.ExternalOrderNumber, snapshot.StatusName); |
| | | 201 | | |
| | 7 | 202 | | return state.Stage == OrderSupplierStage.Cart |
| | 7 | 203 | | ? EvaluateCart(snapshot, Row) |
| | 7 | 204 | | : EvaluateOrdered(state, quantityOrdered, snapshot, Row); |
| | | 205 | | } |
| | | 206 | | |
| | | 207 | | private static SupplierVerificationRow EvaluateCart( |
| | | 208 | | SupplierStatusSnapshot snapshot, |
| | | 209 | | Func<string, string, IReadOnlyList<string>, string?, string?, SupplierVerificationRow> row) |
| | | 210 | | { |
| | 2 | 211 | | var checks = new List<string>(); |
| | 2 | 212 | | if (snapshot.ExternalCode == SupplierCartStatusCode.Removed) |
| | | 213 | | { |
| | 1 | 214 | | checks.Add("✖ Позиции нет в корзине поставщика"); |
| | 1 | 215 | | return row(SupplierVerificationStatus.NotFound, "Позиции нет в корзине поставщика.", checks, null, snapshot. |
| | | 216 | | } |
| | | 217 | | |
| | 1 | 218 | | checks.Add("✔ Позиция найдена в корзине поставщика"); |
| | 1 | 219 | | if (snapshot.ExternalCode == SupplierCartStatusCode.Failed) |
| | | 220 | | { |
| | 0 | 221 | | checks.Add($"✖ Статус в корзине: {snapshot.StatusName}"); |
| | 0 | 222 | | return row(SupplierVerificationStatus.Mismatch, $"Поставщик отклонил позицию: {snapshot.StatusName}.", check |
| | | 223 | | } |
| | | 224 | | |
| | 1 | 225 | | return row(SupplierVerificationStatus.Confirmed, "Позиция в корзине поставщика.", checks, null, snapshot.StatusN |
| | | 226 | | } |
| | | 227 | | |
| | | 228 | | private static SupplierVerificationRow EvaluateOrdered( |
| | | 229 | | OrderSupplierStateRef state, decimal? quantityOrdered, SupplierStatusSnapshot snapshot, |
| | | 230 | | Func<string, string, IReadOnlyList<string>, string?, string?, SupplierVerificationRow> row) |
| | | 231 | | { |
| | 5 | 232 | | var checks = new List<string> { "✔ Заказ найден в API поставщика" }; |
| | 5 | 233 | | var problems = new List<string>(); |
| | 5 | 234 | | var number = snapshot.ExternalOrderNumber ?? state.ExternalOrderNumber; |
| | | 235 | | |
| | 5 | 236 | | if (string.IsNullOrWhiteSpace(number)) |
| | | 237 | | { |
| | 0 | 238 | | problems.Add("Поставщик не вернул номер заказа"); |
| | 0 | 239 | | checks.Add("✖ Номер заказа отсутствует"); |
| | | 240 | | } |
| | 5 | 241 | | else if (!string.IsNullOrWhiteSpace(state.ExternalOrderNumber) |
| | 5 | 242 | | && !string.IsNullOrWhiteSpace(snapshot.ExternalOrderNumber) |
| | 5 | 243 | | && !string.Equals(state.ExternalOrderNumber.Trim(), snapshot.ExternalOrderNumber.Trim(), StringComparis |
| | | 244 | | { |
| | 1 | 245 | | problems.Add($"Номер заказа не совпал: в Studio2 {state.ExternalOrderNumber}, у поставщика {snapshot.Externa |
| | 1 | 246 | | checks.Add($"✖ Номер заказа: Studio2 {state.ExternalOrderNumber}, поставщик {snapshot.ExternalOrderNumber}") |
| | | 247 | | } |
| | | 248 | | else |
| | | 249 | | { |
| | 4 | 250 | | checks.Add($"✔ Номер заказа у поставщика: {number}"); |
| | | 251 | | } |
| | | 252 | | |
| | 5 | 253 | | if (snapshot.QuantityRejected is > 0) |
| | | 254 | | { |
| | 1 | 255 | | problems.Add($"Поставщик отказал по количеству: {snapshot.QuantityRejected:0.##}"); |
| | 1 | 256 | | checks.Add($"✖ Отказ по количеству: {snapshot.QuantityRejected:0.##}"); |
| | | 257 | | } |
| | 4 | 258 | | else if (quantityOrdered is > 0 && snapshot.QuantityConfirmed is { } confirmed && confirmed < quantityOrdered) |
| | | 259 | | { |
| | 1 | 260 | | problems.Add($"Подтверждено {confirmed:0.##} из {quantityOrdered:0.##}"); |
| | 1 | 261 | | checks.Add($"✖ Подтверждено {confirmed:0.##} из {quantityOrdered:0.##}"); |
| | | 262 | | } |
| | 3 | 263 | | else if (snapshot.QuantityConfirmed is { } ok) |
| | | 264 | | { |
| | 0 | 265 | | checks.Add($"✔ Подтверждённое количество: {ok:0.##}"); |
| | | 266 | | } |
| | | 267 | | |
| | 5 | 268 | | var status = problems.Count == 0 ? SupplierVerificationStatus.Confirmed : SupplierVerificationStatus.Mismatch; |
| | 5 | 269 | | var summary = problems.Count == 0 |
| | 5 | 270 | | ? $"Заказ {number} подтверждён у поставщика." |
| | 5 | 271 | | : string.Join("; ", problems) + "."; |
| | 5 | 272 | | return row(status, summary, checks, number, snapshot.StatusName); |
| | | 273 | | } |
| | | 274 | | |
| | | 275 | | private async Task WriteHistoryAsync(IReadOnlyList<SupplierVerificationRow> rows, CancellationToken ct) |
| | | 276 | | { |
| | 11 | 277 | | var added = new List<OrderHistory>(rows.Count); |
| | | 278 | | try |
| | | 279 | | { |
| | 46 | 280 | | foreach (var row in rows) |
| | | 281 | | { |
| | 12 | 282 | | var entry = new OrderHistory |
| | 12 | 283 | | { |
| | 12 | 284 | | OrderId = row.OrderId, |
| | 12 | 285 | | ColumnName = SupplierVerificationHistoryEntry.HistoryColumnName, |
| | 12 | 286 | | ColumnDisplayName = $"Проверка заказа у поставщика · {row.ProviderName}", |
| | 12 | 287 | | NewValue = SupplierVerificationHistoryEntry.FromRow(row).ToJson(), |
| | 12 | 288 | | ChangedAt = DateTime.Now, |
| | 12 | 289 | | ChangedBy = SupplierVerificationHistoryEntry.SystemAuthor |
| | 12 | 290 | | }; |
| | 12 | 291 | | added.Add(entry); |
| | 12 | 292 | | _db.OrderHistories.Add(entry); |
| | | 293 | | } |
| | | 294 | | |
| | 11 | 295 | | await _db.SaveChangesAsync(ct); |
| | 11 | 296 | | } |
| | 0 | 297 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 298 | | { |
| | 0 | 299 | | _logger.LogWarning(ex, "Не удалось записать проверку поставщика в историю ({Count} записей)", rows.Count); |
| | | 300 | | // Незавершённые записи не должны остаться в трекере и сломать следующий SaveChanges в этом скоупе. |
| | 0 | 301 | | foreach (var entry in added) |
| | 0 | 302 | | _db.Entry(entry).State = EntityState.Detached; |
| | 0 | 303 | | } |
| | 11 | 304 | | } |
| | | 305 | | |
| | 62 | 306 | | private sealed record OrderInfo(int Id, string? ShipmentNumber, string? Article, string? ProductName); |
| | | 307 | | |
| | | 308 | | private sealed class FetchOutcome |
| | | 309 | | { |
| | 32 | 310 | | public IReadOnlyList<SupplierStatusSnapshot> Snapshots { get; private init; } = []; |
| | 18 | 311 | | public string? Error { get; private init; } |
| | 6 | 312 | | public bool NotVerifiable { get; private init; } |
| | | 313 | | |
| | 10 | 314 | | public static FetchOutcome Ok(IReadOnlyList<SupplierStatusSnapshot> snapshots) => new() { Snapshots = snapshots |
| | | 315 | | |
| | | 316 | | public static FetchOutcome Failed(string error, bool notVerifiable = false) => |
| | 2 | 317 | | new() { Error = error, NotVerifiable = notVerifiable }; |
| | | 318 | | } |
| | | 319 | | } |