| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | using OzonDomains.Models; |
| | | 7 | | using OzonDomains.Models.SupplierStatuses; |
| | | 8 | | using OzonRepositories.Context; |
| | | 9 | | using OzonOrdersWeb.WebServices.OrderHistory; |
| | | 10 | | using Services.SupplierStatusServices; |
| | | 11 | | |
| | | 12 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 13 | | |
| | | 14 | | /// <summary>Что именно пытаются сделать с заказом у поставщика.</summary> |
| | | 15 | | public enum SupplierOrderAction |
| | | 16 | | { |
| | | 17 | | /// <summary>Отправить реальный заказ поставщику (прямое оформление, «Создать заказ» в корзине).</summary> |
| | | 18 | | Order, |
| | | 19 | | |
| | | 20 | | /// <summary>Положить позицию в корзину поставщика (заказ создаётся отдельным шагом).</summary> |
| | | 21 | | Cart |
| | | 22 | | } |
| | | 23 | | |
| | | 24 | | /// <summary>Заказ Studio2, который собираются оформить (или положить в корзину) у поставщика.</summary> |
| | | 25 | | public sealed record SupplierOrderTarget(int OrderId, string ProviderKey, SupplierOrderAction Intent = SupplierOrderActi |
| | | 26 | | |
| | | 27 | | /// <summary> |
| | | 28 | | /// Конфликт: заказ Studio2 уже заказан у поставщика. <see cref="Id"/> — отпечаток именно этого конфликта |
| | | 29 | | /// (заказ, поставщик, номер и время прежнего заказа): подтверждение пользователя привязано к нему и не |
| | | 30 | | /// переносится на другой заказ или на изменившееся состояние. |
| | | 31 | | /// </summary> |
| | | 32 | | public sealed record SupplierOrderConflict( |
| | | 33 | | string Id, |
| | | 34 | | int OrderId, |
| | | 35 | | string? ShipmentNumber, |
| | | 36 | | SupplierOrderAction Intent, |
| | | 37 | | string RequestedProviderKey, |
| | | 38 | | string RequestedProviderName, |
| | | 39 | | string ExistingProviderKey, |
| | | 40 | | string ExistingProviderName, |
| | | 41 | | string? ExistingOrderNumber, |
| | | 42 | | DateTime? OrderedAtUtc, |
| | | 43 | | string? OrderedBy, |
| | | 44 | | bool SameProvider, |
| | | 45 | | string Message, |
| | | 46 | | bool Unverified = false, |
| | | 47 | | string? Article = null, |
| | | 48 | | string? ProductName = null) |
| | | 49 | | { |
| | | 50 | | /// <summary>Логотип поставщика, у которого заказ уже оформлен, — тот же источник, что в истории заказа и проверке п |
| | | 51 | | public string? ExistingProviderLogoUrl => OrderHistoryGridRows.SupplierProviderLogoUrl(ExistingProviderKey); |
| | | 52 | | |
| | | 53 | | /// <summary>Логотип поставщика, у которого заказ оформляют сейчас.</summary> |
| | | 54 | | public string? RequestedProviderLogoUrl => OrderHistoryGridRows.SupplierProviderLogoUrl(RequestedProviderKey); |
| | | 55 | | } |
| | | 56 | | |
| | | 57 | | /// <summary>Итог проверки: что блокирует оформление, что подтверждено пользователем, какие есть предупреждения.</summar |
| | 67 | 58 | | public sealed record SupplierOrderGuardResult( |
| | 128 | 59 | | IReadOnlyList<SupplierOrderConflict> Blocking, |
| | 30 | 60 | | IReadOnlyList<SupplierOrderConflict> Confirmed, |
| | 76 | 61 | | IReadOnlyList<string> Warnings) |
| | | 62 | | { |
| | 0 | 63 | | public static SupplierOrderGuardResult Empty { get; } = new([], [], []); |
| | | 64 | | |
| | 45 | 65 | | public bool IsAllowed => Blocking.Count == 0; |
| | | 66 | | |
| | | 67 | | /// <summary>Заказы Studio2, по которым пользователь подтвердил повтор у того же поставщика.</summary> |
| | 16 | 68 | | public IReadOnlySet<int> ConfirmedSameProviderOrderIds(string providerKey) => Confirmed |
| | 2 | 69 | | .Where(x => x.SameProvider && string.Equals(x.RequestedProviderKey, providerKey, StringComparison.OrdinalIgnoreC |
| | 1 | 70 | | .Select(x => x.OrderId) |
| | 16 | 71 | | .ToHashSet(); |
| | | 72 | | |
| | | 73 | | public string BlockingMessage() => |
| | 28 | 74 | | (Blocking.All(x => x.Unverified) |
| | 14 | 75 | | ? "Возможно, заказ уже создан у поставщика — требуется сверка, повторное оформление заблокировано. " |
| | 14 | 76 | | : "Заказ уже оформлен у поставщика, повторное оформление заблокировано. ") + |
| | 14 | 77 | | string.Join(" ", Blocking.Take(5).Select(x => x.Message)) + |
| | 14 | 78 | | (Blocking.Count > 5 ? $" … и ещё {Blocking.Count - 5}." : string.Empty) + |
| | 14 | 79 | | " Чтобы оформить повторно, подтвердите это явно."; |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | /// <summary> |
| | | 83 | | /// Подтверждение пользователя: отпечатки конфликтов, которые он увидел и разрешил. Создаётся только из запроса |
| | | 84 | | /// пользователя; фоновые задачи и повторные запросы без него передают <c>null</c> и блокируются снова. |
| | | 85 | | /// </summary> |
| | | 86 | | public sealed record SupplierOrderConfirmation(IReadOnlySet<string> ConflictIds) |
| | | 87 | | { |
| | | 88 | | public static SupplierOrderConfirmation? FromIds(IEnumerable<string?>? ids) |
| | | 89 | | { |
| | | 90 | | var set = (ids ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()).ToHashSet(StringComparer. |
| | | 91 | | return set.Count == 0 ? null : new SupplierOrderConfirmation(set); |
| | | 92 | | } |
| | | 93 | | } |
| | | 94 | | |
| | | 95 | | /// <summary> |
| | | 96 | | /// Отправка заказов поставщикам в рамках одного запроса: намерения записаны (Placing) до обращения к поставщику. |
| | | 97 | | /// После ответа вызывающий код завершает отправку (<see cref="ISupplierOrderGuard.CompleteDispatchAsync"/>); |
| | | 98 | | /// если завершения не случилось (обрыв, исключение, падение процесса), намерения остаются Placing и блокируют повтор. |
| | | 99 | | /// </summary> |
| | | 100 | | public sealed class SupplierDispatch |
| | | 101 | | { |
| | | 102 | | private readonly HashSet<string> _uncertain = new(StringComparer.OrdinalIgnoreCase); |
| | | 103 | | |
| | | 104 | | public SupplierDispatch(string requestId, string source, string? user, IReadOnlyList<SupplierOrderIntentInfo> intent |
| | | 105 | | { |
| | | 106 | | RequestId = requestId; |
| | | 107 | | Source = source; |
| | | 108 | | User = user; |
| | | 109 | | Intents = intents; |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | public string RequestId { get; } |
| | | 113 | | public string Source { get; } |
| | | 114 | | public string? User { get; } |
| | | 115 | | public IReadOnlyList<SupplierOrderIntentInfo> Intents { get; } |
| | | 116 | | |
| | | 117 | | /// <summary>Поставщики, чей исход неизвестен (обрыв соединения после отправки): их намерения остаются Placing.</sum |
| | | 118 | | public IReadOnlyCollection<string> UncertainProviders => _uncertain; |
| | | 119 | | |
| | | 120 | | /// <summary>Отметить, что поставщик мог принять заказ, но ответа нет — повтор потребует сверки.</summary> |
| | | 121 | | public void MarkUncertain(string providerKey) => _uncertain.Add(providerKey); |
| | | 122 | | } |
| | | 123 | | |
| | | 124 | | /// <summary>Итог <see cref="ISupplierOrderGuard.BeginDispatchAsync"/>: либо отправка может начинаться, либо конфликт-го |
| | | 125 | | public sealed record SupplierDispatchStart(SupplierDispatch? Dispatch, IReadOnlyList<SupplierOrderConflict> Conflicts) |
| | | 126 | | { |
| | | 127 | | public bool CanProceed => Conflicts.Count == 0; |
| | | 128 | | |
| | | 129 | | public string Message => string.Join(" ", Conflicts.Select(x => x.Message)); |
| | | 130 | | } |
| | | 131 | | |
| | | 132 | | /// <summary> |
| | | 133 | | /// Единая серверная защита от повторных заказов у API-поставщиков. Любой путь, который отправляет заказ поставщику |
| | | 134 | | /// или кладёт позицию в его корзину, сначала проходит <see cref="EvaluateAsync"/>. |
| | | 135 | | /// </summary> |
| | | 136 | | public interface ISupplierOrderGuard |
| | | 137 | | { |
| | | 138 | | /// <param name="targets">Что собираются оформить.</param> |
| | | 139 | | /// <param name="confirmation">Подтверждение пользователя; <c>null</c> — подтверждения нет (всегда для фоновых задач |
| | | 140 | | /// <param name="userName">Кто подтверждает — для истории заказа и лога.</param> |
| | | 141 | | Task<SupplierOrderGuardResult> EvaluateAsync( |
| | | 142 | | IReadOnlyCollection<SupplierOrderTarget> targets, SupplierOrderConfirmation? confirmation, |
| | | 143 | | string? userName, CancellationToken ct); |
| | | 144 | | |
| | | 145 | | /// <summary>Текущая отправка в этом запросе (DI-scope): сервисы поставщиков отмечают через неё неопределённый исход |
| | | 146 | | SupplierDispatch? CurrentDispatch { get; } |
| | | 147 | | |
| | | 148 | | /// <summary> |
| | | 149 | | /// Записывает намерения (Placing) ДО обращения к поставщику — отдельной транзакцией. Вызывается после |
| | | 150 | | /// <see cref="EvaluateAsync"/>; подтверждённые пользователем повторы у того же поставщика заменяют прежнее намерени |
| | | 151 | | /// Если другой запрос успел раньше (уникальный индекс), возвращает конфликты вместо отправки. |
| | | 152 | | /// </summary> |
| | | 153 | | Task<SupplierDispatchStart> BeginDispatchAsync( |
| | | 154 | | string requestId, string source, IReadOnlyCollection<SupplierOrderTarget> targets, |
| | | 155 | | SupplierOrderGuardResult? evaluated, string? userName, CancellationToken ct); |
| | | 156 | | |
| | | 157 | | /// <summary>Фиксирует исход после ответа поставщика: Placed (с номером заказа) либо Failed; неопределённый исход ос |
| | | 158 | | Task CompleteDispatchAsync(SupplierDispatch dispatch); |
| | | 159 | | |
| | | 160 | | /// <summary>Заказы Studio2 из строк корзины поставщика. <paramref name="cartItemIds"/> = <c>null</c> — вся активная |
| | | 161 | | Task<IReadOnlyList<SupplierOrderTarget>> ResolveCartTargetsAsync( |
| | | 162 | | string providerKey, IReadOnlyCollection<string>? cartItemIds, CancellationToken ct); |
| | | 163 | | } |
| | | 164 | | |
| | | 165 | | /// <summary> |
| | | 166 | | /// Источники «заказано»: <c>OrderSupplierStates</c> (стадия Ordered, пишется всеми провайдерами при оформлении) |
| | | 167 | | /// и, на случай сбоя записи состояния, свежие строки <c>ArmtekOrderItems</c>. «В корзине» заказом не считается: |
| | | 168 | | /// корзина у другого поставщика даёт только предупреждение. |
| | | 169 | | /// </summary> |
| | | 170 | | public sealed class SupplierOrderGuard : ISupplierOrderGuard |
| | | 171 | | { |
| | | 172 | | public const string ConfirmedHistoryColumnName = "SupplierOrderRepeatConfirmed"; |
| | | 173 | | |
| | | 174 | | /// <summary>Строки ArmtekOrderItems старше окна без записи в отслеживании считаются заказами до его появления.</sum |
| | | 175 | | private static readonly TimeSpan UntrackedOrderWindow = TimeSpan.FromDays(7); |
| | | 176 | | |
| | | 177 | | private readonly ApplicationDbContext _db; |
| | | 178 | | private readonly ILogger<SupplierOrderGuard> _logger; |
| | | 179 | | private readonly ISupplierOrderIntentStore? _intents; |
| | | 180 | | private readonly IReadOnlyList<ISupplierOrderLookup> _lookups; |
| | | 181 | | private readonly HashSet<string> _recordedConfirmations = new(StringComparer.Ordinal); |
| | | 182 | | |
| | | 183 | | public SupplierOrderGuard(ApplicationDbContext db, ILogger<SupplierOrderGuard> logger) |
| | | 184 | | : this(db, logger, null, []) |
| | | 185 | | { |
| | | 186 | | } |
| | | 187 | | |
| | | 188 | | public SupplierOrderGuard(ApplicationDbContext db, ILogger<SupplierOrderGuard> logger, |
| | | 189 | | ISupplierOrderIntentStore? intents, IEnumerable<ISupplierOrderLookup> lookups) |
| | | 190 | | { |
| | | 191 | | _db = db; |
| | | 192 | | _logger = logger; |
| | | 193 | | _intents = intents; |
| | | 194 | | _lookups = lookups.ToList(); |
| | | 195 | | } |
| | | 196 | | |
| | | 197 | | public SupplierDispatch? CurrentDispatch { get; private set; } |
| | | 198 | | |
| | | 199 | | public async Task<SupplierOrderGuardResult> EvaluateAsync( |
| | | 200 | | IReadOnlyCollection<SupplierOrderTarget> targets, SupplierOrderConfirmation? confirmation, |
| | | 201 | | string? userName, CancellationToken ct) |
| | | 202 | | { |
| | | 203 | | var wanted = targets |
| | | 204 | | .Where(x => x.OrderId > 0 && !string.IsNullOrWhiteSpace(x.ProviderKey)) |
| | | 205 | | .Distinct() |
| | | 206 | | .ToList(); |
| | | 207 | | if (wanted.Count == 0) return SupplierOrderGuardResult.Empty; |
| | | 208 | | |
| | | 209 | | var orderIds = wanted.Select(x => x.OrderId).Distinct().ToList(); |
| | | 210 | | var evidence = await LoadOrderedEvidenceAsync(orderIds, ct); |
| | | 211 | | var otherCarts = await LoadActiveCartsAsync(orderIds, ct); |
| | | 212 | | var shipments = await _db.Orders.AsNoTracking() |
| | | 213 | | .Where(x => orderIds.Contains(x.Id)) |
| | | 214 | | .Select(x => new { x.Id, x.ShipmentNumber, x.Article, x.ProductName }) |
| | | 215 | | .ToDictionaryAsync(x => x.Id, ct); |
| | | 216 | | |
| | | 217 | | var conflicts = new List<SupplierOrderConflict>(); |
| | | 218 | | var warnings = new List<string>(); |
| | | 219 | | foreach (var target in wanted) |
| | | 220 | | { |
| | | 221 | | var info = shipments.GetValueOrDefault(target.OrderId); |
| | | 222 | | var shipment = info?.ShipmentNumber; |
| | | 223 | | foreach (var existing in evidence.Where(x => x.OrderId == target.OrderId)) |
| | | 224 | | { |
| | | 225 | | conflicts.Add(BuildConflict(target, existing, shipment, info?.Article, info?.ProductName)); |
| | | 226 | | } |
| | | 227 | | |
| | | 228 | | foreach (var cart in otherCarts.Where(x => x.OrderId == target.OrderId |
| | | 229 | | && !string.Equals(x.ProviderKey, target.ProviderKey, StringComparison.OrdinalIgnoreCase))) |
| | | 230 | | { |
| | | 231 | | warnings.Add($"Заказ {shipment ?? $"ID {target.OrderId}"} также лежит в корзине {OrderSupplierStateServi |
| | | 232 | | "не оформите его там повторно."); |
| | | 233 | | } |
| | | 234 | | } |
| | | 235 | | |
| | | 236 | | conflicts = conflicts.DistinctBy(x => x.Id).ToList(); |
| | | 237 | | var confirmedIds = confirmation?.ConflictIds ?? new HashSet<string>(); |
| | | 238 | | var confirmed = conflicts.Where(x => confirmedIds.Contains(x.Id)).ToList(); |
| | | 239 | | var blocking = conflicts.Where(x => !confirmedIds.Contains(x.Id)).ToList(); |
| | | 240 | | |
| | | 241 | | await RecordConfirmationsAsync(confirmed, userName, ct); |
| | | 242 | | return new SupplierOrderGuardResult(blocking, confirmed, warnings.Distinct().ToList()); |
| | | 243 | | } |
| | | 244 | | |
| | | 245 | | public async Task<IReadOnlyList<SupplierOrderTarget>> ResolveCartTargetsAsync( |
| | | 246 | | string providerKey, IReadOnlyCollection<string>? cartItemIds, CancellationToken ct) |
| | | 247 | | { |
| | | 248 | | var all = cartItemIds is null; |
| | | 249 | | var textIds = (cartItemIds ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToL |
| | | 250 | | var ids = textIds.Select(x => long.TryParse(x, NumberStyles.None, CultureInfo.InvariantCulture, out var id) ? id |
| | | 251 | | .OfType<long>().ToList(); |
| | | 252 | | if (!all && textIds.Count == 0) return []; |
| | | 253 | | |
| | | 254 | | List<int> orderIds; |
| | | 255 | | switch (providerKey) |
| | | 256 | | { |
| | | 257 | | case ApiSupplierSettings.ArmtekProviderKey: |
| | | 258 | | case ApiSupplierSettings.ArmtekByProviderKey: |
| | | 259 | | orderIds = await _db.ArmtekCartItems.AsNoTracking() |
| | | 260 | | .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x. |
| | | 261 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 262 | | break; |
| | | 263 | | case ApiSupplierSettings.MlAutoProviderKey: |
| | | 264 | | orderIds = await _db.MlAutoCartItems.AsNoTracking() |
| | | 265 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 266 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 267 | | break; |
| | | 268 | | case ApiSupplierSettings.MlAutoRuProviderKey: |
| | | 269 | | orderIds = await _db.MlAutoRuCartItems.AsNoTracking() |
| | | 270 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 271 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 272 | | break; |
| | | 273 | | case ApiSupplierSettings.ForumAutoProviderKey: |
| | | 274 | | case ApiSupplierSettings.ForumAutoInterpartsProviderKey: |
| | | 275 | | case ApiSupplierSettings.ForumAutoPiterProviderKey: |
| | | 276 | | case ApiSupplierSettings.ForumAutoRostovProviderKey: |
| | | 277 | | case ApiSupplierSettings.ForumAutoIstraProviderKey: |
| | | 278 | | orderIds = await _db.ForumAutoCartItems.AsNoTracking() |
| | | 279 | | .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x. |
| | | 280 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 281 | | break; |
| | | 282 | | case ApiSupplierSettings.FavoritPartsProviderKey: |
| | | 283 | | case ApiSupplierSettings.FavoritPartsIstraProviderKey: |
| | | 284 | | case ApiSupplierSettings.FavoritPartsRostovProviderKey: |
| | | 285 | | orderIds = await _db.FavoritPartsCartItems.AsNoTracking() |
| | | 286 | | .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x. |
| | | 287 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 288 | | break; |
| | | 289 | | case ApiSupplierSettings.ProfitLigaProviderKey: |
| | | 290 | | orderIds = await _db.ProfitLigaCartItems.AsNoTracking() |
| | | 291 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 292 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 293 | | break; |
| | | 294 | | case ApiSupplierSettings.NikeiProviderKey: |
| | | 295 | | orderIds = await _db.NikeiCartItems.AsNoTracking() |
| | | 296 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 297 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 298 | | break; |
| | | 299 | | case ApiSupplierSettings.MikadoMskHod20ProviderKey: |
| | | 300 | | orderIds = await _db.MikadoCartItems.AsNoTracking() |
| | | 301 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 302 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 303 | | break; |
| | | 304 | | case ApiSupplierSettings.ZZapMoscowProviderKey: |
| | | 305 | | orderIds = await _db.ZZapCartItems.AsNoTracking() |
| | | 306 | | .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x. |
| | | 307 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 308 | | break; |
| | | 309 | | case ApiSupplierSettings.MoskvorechieIstraProviderKey: |
| | | 310 | | orderIds = await _db.MoskvorechieIstraCartItems.AsNoTracking() |
| | | 311 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 312 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 313 | | break; |
| | | 314 | | case ApiSupplierSettings.MotexProviderKey: |
| | | 315 | | orderIds = await _db.MotexCartItems.AsNoTracking() |
| | | 316 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 317 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 318 | | break; |
| | | 319 | | case ApiSupplierSettings.BergProviderKey: |
| | | 320 | | orderIds = await _db.BergCartItems.AsNoTracking() |
| | | 321 | | .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id))) |
| | | 322 | | .Select(x => x.StudioOrderId).ToListAsync(ct); |
| | | 323 | | break; |
| | | 324 | | case ApiSupplierSettings.AvdProviderKey: |
| | | 325 | | case ApiSupplierSettings.JapartsProviderKey: |
| | | 326 | | case ApiSupplierSettings.ShateMProviderKey: |
| | | 327 | | // Корзина у самого поставщика: строка связана с заказом Studio2 только через состояние отслеживания. |
| | | 328 | | orderIds = await _db.OrderSupplierStates.AsNoTracking() |
| | | 329 | | .Where(x => x.ProviderKey == providerKey && x.ActiveOrderId != null && x.Stage == OrderSupplierStage |
| | | 330 | | && (all || (x.ExternalBasketItemId != null && textIds.Contains(x.ExternalBasketItemId))) |
| | | 331 | | .Select(x => x.OrderId).ToListAsync(ct); |
| | | 332 | | break; |
| | | 333 | | default: |
| | | 334 | | _logger.LogWarning("SupplierOrderGuard: неизвестный провайдер {ProviderKey}, заказы корзины не определен |
| | | 335 | | return []; |
| | | 336 | | } |
| | | 337 | | |
| | | 338 | | return orderIds.Distinct().Select(x => new SupplierOrderTarget(x, providerKey, SupplierOrderAction.Order)).ToLis |
| | | 339 | | } |
| | | 340 | | |
| | | 341 | | public async Task<SupplierDispatchStart> BeginDispatchAsync( |
| | | 342 | | string requestId, string source, IReadOnlyCollection<SupplierOrderTarget> targets, |
| | | 343 | | SupplierOrderGuardResult? evaluated, string? userName, CancellationToken ct) |
| | | 344 | | { |
| | | 345 | | var orderTargets = targets |
| | | 346 | | .Where(x => x.Intent == SupplierOrderAction.Order && x.OrderId > 0 && !string.IsNullOrWhiteSpace(x.ProviderK |
| | | 347 | | .Select(x => (x.ProviderKey, x.OrderId)) |
| | | 348 | | .Distinct() |
| | | 349 | | .ToList(); |
| | | 350 | | if (_intents is null || orderTargets.Count == 0) |
| | | 351 | | { |
| | | 352 | | var empty = new SupplierDispatch(requestId, source, userName, []); |
| | | 353 | | CurrentDispatch = empty; |
| | | 354 | | return new SupplierDispatchStart(empty, []); |
| | | 355 | | } |
| | | 356 | | |
| | | 357 | | // Подтверждённый повтор у того же поставщика заменяет прежнее намерение (иначе уникальный индекс не даст создат |
| | | 358 | | var supersede = (evaluated?.Confirmed ?? []) |
| | | 359 | | .Where(x => x.SameProvider) |
| | | 360 | | .Select(x => (x.RequestedProviderKey, x.OrderId)) |
| | | 361 | | .ToHashSet(); |
| | | 362 | | var begin = await _intents.BeginAsync(requestId, source, userName, orderTargets, supersede, ct); |
| | | 363 | | |
| | | 364 | | if (begin.Busy.Count > 0) |
| | | 365 | | { |
| | | 366 | | foreach (var started in begin.Started) |
| | | 367 | | await _intents.ResolveAsync(started.Id, SupplierOrderIntentStatus.Released, null, |
| | | 368 | | "Не отправлено: другой запрос уже оформляет этот заказ.", userName); |
| | | 369 | | |
| | | 370 | | _logger.LogWarning("Параллельное оформление: заказы {Orders} уже оформляются другим запросом", |
| | | 371 | | string.Join(", ", begin.Busy.Select(x => $"{x.StudioOrderId}@{x.ProviderKey}"))); |
| | | 372 | | return new SupplierDispatchStart(null, begin.Busy.Select(BuildRaceConflict).ToList()); |
| | | 373 | | } |
| | | 374 | | |
| | | 375 | | var dispatch = new SupplierDispatch(requestId, source, userName, begin.Started); |
| | | 376 | | CurrentDispatch = dispatch; |
| | | 377 | | return new SupplierDispatchStart(dispatch, []); |
| | | 378 | | } |
| | | 379 | | |
| | | 380 | | public async Task CompleteDispatchAsync(SupplierDispatch dispatch) |
| | | 381 | | { |
| | | 382 | | try |
| | | 383 | | { |
| | | 384 | | if (_intents is null || dispatch.Intents.Count == 0) return; |
| | | 385 | | |
| | | 386 | | var orderIds = dispatch.Intents.Select(x => x.StudioOrderId).Distinct().ToList(); |
| | | 387 | | var ordered = await _db.OrderSupplierStates.AsNoTracking() |
| | | 388 | | .Where(x => orderIds.Contains(x.OrderId) && x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ord |
| | | 389 | | .Select(x => new { x.OrderId, x.ProviderKey, x.ExternalOrderNumber }) |
| | | 390 | | .ToListAsync(CancellationToken.None); |
| | | 391 | | |
| | | 392 | | foreach (var intent in dispatch.Intents) |
| | | 393 | | { |
| | | 394 | | var state = ordered.FirstOrDefault(x => x.OrderId == intent.StudioOrderId |
| | | 395 | | && string.Equals(x.ProviderKey, intent.ProviderKey, StringComparison.OrdinalIgnoreCase)); |
| | | 396 | | if (state is not null) |
| | | 397 | | { |
| | | 398 | | await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Placed, state.ExternalOrderNumber, |
| | | 399 | | "Заказ создан у поставщика.", dispatch.User); |
| | | 400 | | } |
| | | 401 | | else if (!dispatch.UncertainProviders.Contains(intent.ProviderKey, StringComparer.OrdinalIgnoreCase)) |
| | | 402 | | { |
| | | 403 | | // Ответ получен (или запрос до поставщика не дошёл), заказа нет — повтор разрешён. |
| | | 404 | | await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Failed, null, |
| | | 405 | | "Поставщик не создал заказ.", dispatch.User); |
| | | 406 | | } |
| | | 407 | | // Иначе исход неизвестен: Placing остаётся и требует сверки. |
| | | 408 | | } |
| | | 409 | | } |
| | | 410 | | finally |
| | | 411 | | { |
| | | 412 | | if (ReferenceEquals(CurrentDispatch, dispatch)) CurrentDispatch = null; |
| | | 413 | | } |
| | | 414 | | } |
| | | 415 | | |
| | | 416 | | private SupplierOrderConflict BuildRaceConflict(SupplierOrderIntentInfo busy) |
| | | 417 | | { |
| | | 418 | | var name = OrderSupplierStateService.DescribeProvider(busy.ProviderKey); |
| | | 419 | | var when = DateTime.SpecifyKind(busy.CreatedAtUtc, DateTimeKind.Utc).ToLocalTime().ToString("dd.MM.yyyy HH:mm", |
| | | 420 | | var by = string.IsNullOrWhiteSpace(busy.RequestedBy) ? "автор неизвестен" : busy.RequestedBy; |
| | | 421 | | var message = $"Заказ ID {busy.StudioOrderId}: у {name} уже есть запрос на оформление от {when} ({by}) — " + |
| | | 422 | | "возможно, заказ уже создан у поставщика. Дождитесь завершения того запроса, обновите страницу и п |
| | | 423 | | return new SupplierOrderConflict( |
| | | 424 | | Fingerprint(busy.StudioOrderId, busy.ProviderKey, busy.ProviderKey, busy.ExternalOrderNumber, busy.CreatedAt |
| | | 425 | | busy.StudioOrderId, null, SupplierOrderAction.Order, busy.ProviderKey, name, busy.ProviderKey, name, |
| | | 426 | | busy.ExternalOrderNumber, busy.CreatedAtUtc, busy.RequestedBy, true, message, |
| | | 427 | | busy.Status == SupplierOrderIntentStatus.Placing); |
| | | 428 | | } |
| | | 429 | | |
| | | 430 | | private sealed record OrderedEvidence( |
| | | 431 | | int OrderId, string ProviderKey, string? OrderNumber, DateTime? OrderedAtUtc, string? OrderedBy, |
| | | 432 | | bool Unverified = false, string? Details = null); |
| | | 433 | | |
| | | 434 | | private sealed record ActiveCart(int OrderId, string ProviderKey); |
| | | 435 | | |
| | | 436 | | private async Task<List<OrderedEvidence>> LoadOrderedEvidenceAsync(IReadOnlyCollection<int> orderIds, CancellationTo |
| | | 437 | | { |
| | | 438 | | var states = await _db.OrderSupplierStates.AsNoTracking() |
| | | 439 | | .Where(x => orderIds.Contains(x.OrderId)) |
| | | 440 | | .Select(x => new |
| | | 441 | | { |
| | | 442 | | x.Id, x.OrderId, x.ProviderKey, x.Stage, x.ActiveOrderId, x.ExternalOrderNumber, |
| | | 443 | | x.OrderedAt, x.LastStatusChangeAt, x.UpdatedAt, x.CreatedBy |
| | | 444 | | }) |
| | | 445 | | .ToListAsync(ct); |
| | | 446 | | |
| | | 447 | | var orderedStates = states.Where(x => x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ordered).ToList() |
| | | 448 | | var orderedStateIds = orderedStates.Select(x => x.Id).ToList(); |
| | | 449 | | var transitions = orderedStateIds.Count == 0 |
| | | 450 | | ? [] |
| | | 451 | | : await _db.OrderSupplierStateHistory.AsNoTracking() |
| | | 452 | | .Where(x => orderedStateIds.Contains(x.OrderSupplierStateId) && x.ToStage == OrderSupplierStage.Ordered) |
| | | 453 | | .Select(x => new { x.OrderSupplierStateId, x.PerformedAt, x.PerformedBy }) |
| | | 454 | | .ToListAsync(ct); |
| | | 455 | | var firstOrderedBy = transitions |
| | | 456 | | .GroupBy(x => x.OrderSupplierStateId) |
| | | 457 | | .ToDictionary(x => x.Key, x => x.OrderBy(t => t.PerformedAt).First()); |
| | | 458 | | |
| | | 459 | | var result = orderedStates.Select(x => |
| | | 460 | | { |
| | | 461 | | var transition = firstOrderedBy.GetValueOrDefault(x.Id); |
| | | 462 | | return new OrderedEvidence( |
| | | 463 | | x.OrderId, x.ProviderKey, x.ExternalOrderNumber, |
| | | 464 | | x.OrderedAt ?? transition?.PerformedAt ?? x.LastStatusChangeAt, |
| | | 465 | | transition?.PerformedBy ?? x.CreatedBy); |
| | | 466 | | }).ToList(); |
| | | 467 | | |
| | | 468 | | await AddIntentEvidenceAsync(orderIds, states.Select(x => (x.OrderId, x.ProviderKey, x.Stage, x.ActiveOrderId, x |
| | | 469 | | |
| | | 470 | | // Состояние не записалось (сбой журнала) — заказ всё равно виден по строкам заказов Armtek. |
| | | 471 | | var untrackedSince = DateTime.UtcNow - UntrackedOrderWindow; |
| | | 472 | | var armtek = await _db.ArmtekOrderItems.AsNoTracking() |
| | | 473 | | .Where(x => orderIds.Contains(x.StudioOrderId) && x.ArmtekOrder!.CreatedAtUtc >= untrackedSince |
| | | 474 | | && (x.Error == null || x.Error == "" || x.Error == "0")) |
| | | 475 | | .Select(x => new { x.StudioOrderId, x.ArmtekOrder!.ProviderKey, x.ArmtekOrder.Vbeln, x.ArmtekOrder.CreatedAt |
| | | 476 | | .ToListAsync(ct); |
| | | 477 | | foreach (var group in armtek.GroupBy(x => (x.StudioOrderId, x.ProviderKey))) |
| | | 478 | | { |
| | | 479 | | var (orderId, providerKey) = group.Key; |
| | | 480 | | var providerHasState = states.Any(x => x.OrderId == orderId && x.ProviderKey == providerKey); |
| | | 481 | | if (providerHasState || result.Any(x => x.OrderId == orderId && x.ProviderKey == providerKey)) continue; |
| | | 482 | | |
| | | 483 | | result.Add(new OrderedEvidence( |
| | | 484 | | orderId, providerKey, string.Join(", ", group.Select(x => x.Vbeln).Distinct()), |
| | | 485 | | group.Min(x => x.CreatedAtUtc), group.First().CreatedBy)); |
| | | 486 | | } |
| | | 487 | | |
| | | 488 | | return result; |
| | | 489 | | } |
| | | 490 | | |
| | | 491 | | /// <summary> |
| | | 492 | | /// Намерения outbox (Placing/Placed) как источник «заказано»: они видны даже когда состояние у поставщика не записа |
| | | 493 | | /// Попутно чинит их по фактам: состояние Ordered — это Placed; заказ, закрытый поставщиком, освобождает намерение; |
| | | 494 | | /// зависшее Placing сверяется с поставщиком (<see cref="ISupplierOrderLookup"/>) — нашли, не нашли или сверка недос |
| | | 495 | | /// </summary> |
| | | 496 | | private async Task AddIntentEvidenceAsync( |
| | | 497 | | IReadOnlyCollection<int> orderIds, |
| | | 498 | | IReadOnlyList<(int OrderId, string ProviderKey, string Stage, int? ActiveOrderId, string? Number, DateTime? Upda |
| | | 499 | | List<OrderedEvidence> evidence, CancellationToken ct) |
| | | 500 | | { |
| | | 501 | | if (_intents is null) return; |
| | | 502 | | |
| | | 503 | | foreach (var intent in await _intents.GetActiveAsync(orderIds, ct)) |
| | | 504 | | { |
| | | 505 | | var ordered = states.FirstOrDefault(x => x.OrderId == intent.StudioOrderId |
| | | 506 | | && string.Equals(x.ProviderKey, intent.ProviderKey, StringComparison.OrdinalIgnoreCase) |
| | | 507 | | && x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ordered); |
| | | 508 | | if (ordered.ProviderKey is not null) |
| | | 509 | | { |
| | | 510 | | // Evidence по состоянию уже есть; намерение только догоняем до Placed. |
| | | 511 | | if (intent.Status == SupplierOrderIntentStatus.Placing) |
| | | 512 | | await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Placed, ordered.Number, |
| | | 513 | | "Подтверждено состоянием заказа у поставщика.", null); |
| | | 514 | | continue; |
| | | 515 | | } |
| | | 516 | | |
| | | 517 | | if (intent.Status == SupplierOrderIntentStatus.Placed) |
| | | 518 | | { |
| | | 519 | | var closedAfter = states.Any(x => x.OrderId == intent.StudioOrderId |
| | | 520 | | && string.Equals(x.ProviderKey, intent.ProviderKey, StringComparison.OrdinalIgnoreCase) |
| | | 521 | | && x.Stage == OrderSupplierStage.Closed && x.UpdatedAt >= intent.CreatedAtUtc); |
| | | 522 | | if (closedAfter) |
| | | 523 | | { |
| | | 524 | | await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Released, intent.ExternalOrderNumbe |
| | | 525 | | "Заказ закрыт поставщиком — повторное оформление разрешено.", null); |
| | | 526 | | continue; |
| | | 527 | | } |
| | | 528 | | |
| | | 529 | | evidence.Add(new OrderedEvidence(intent.StudioOrderId, intent.ProviderKey, intent.ExternalOrderNumber, |
| | | 530 | | intent.UpdatedAtUtc, intent.RequestedBy)); |
| | | 531 | | continue; |
| | | 532 | | } |
| | | 533 | | |
| | | 534 | | // Placing без результата: обрыв, падение процесса или таймаут. Пробуем сверку с поставщиком. |
| | | 535 | | var lookup = await LookupAsync(intent, ct); |
| | | 536 | | switch (lookup.Status) |
| | | 537 | | { |
| | | 538 | | case SupplierOrderLookupStatus.Found: |
| | | 539 | | await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Placed, lookup.ExternalOrderNumber, |
| | | 540 | | "Сверка с поставщиком: заказ найден.", null); |
| | | 541 | | evidence.Add(new OrderedEvidence(intent.StudioOrderId, intent.ProviderKey, lookup.ExternalOrderNumbe |
| | | 542 | | intent.CreatedAtUtc, intent.RequestedBy)); |
| | | 543 | | break; |
| | | 544 | | case SupplierOrderLookupStatus.NotFound: |
| | | 545 | | await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Released, null, |
| | | 546 | | "Сверка с поставщиком: заказ не найден — повторное оформление разрешено.", null); |
| | | 547 | | break; |
| | | 548 | | default: |
| | | 549 | | evidence.Add(new OrderedEvidence(intent.StudioOrderId, intent.ProviderKey, null, |
| | | 550 | | intent.CreatedAtUtc, intent.RequestedBy, Unverified: true, Details: lookup.Details)); |
| | | 551 | | break; |
| | | 552 | | } |
| | | 553 | | } |
| | | 554 | | } |
| | | 555 | | |
| | | 556 | | private async Task<SupplierOrderLookupResult> LookupAsync(SupplierOrderIntentInfo intent, CancellationToken ct) |
| | | 557 | | { |
| | | 558 | | var lookup = _lookups.FirstOrDefault(x => x.ProviderKeys.Contains(intent.ProviderKey, StringComparer.OrdinalIgno |
| | | 559 | | if (lookup is null) |
| | | 560 | | return SupplierOrderLookupResult.Unavailable("Автоматическая сверка с этим поставщиком недоступна."); |
| | | 561 | | try |
| | | 562 | | { |
| | | 563 | | return await lookup.FindAsync(new SupplierOrderLookupRequest(intent.ProviderKey, intent.StudioOrderId, inten |
| | | 564 | | } |
| | | 565 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 566 | | { |
| | | 567 | | _logger.LogWarning(ex, "Сверка заказа {OrderId} у {ProviderKey} не удалась", intent.StudioOrderId, intent.Pr |
| | | 568 | | return SupplierOrderLookupResult.Unavailable($"Автоматическая сверка не удалась: {ex.Message}"); |
| | | 569 | | } |
| | | 570 | | } |
| | | 571 | | |
| | | 572 | | private async Task<List<ActiveCart>> LoadActiveCartsAsync(IReadOnlyCollection<int> orderIds, CancellationToken ct) = |
| | | 573 | | await _db.OrderSupplierStates.AsNoTracking() |
| | | 574 | | .Where(x => orderIds.Contains(x.OrderId) && x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Cart) |
| | | 575 | | .Select(x => new ActiveCart(x.OrderId, x.ProviderKey)) |
| | | 576 | | .ToListAsync(ct); |
| | | 577 | | |
| | | 578 | | private static SupplierOrderConflict BuildConflict(SupplierOrderTarget target, OrderedEvidence existing, string? shi |
| | | 579 | | { |
| | | 580 | | var same = string.Equals(target.ProviderKey, existing.ProviderKey, StringComparison.OrdinalIgnoreCase); |
| | | 581 | | var requestedName = OrderSupplierStateService.DescribeProvider(target.ProviderKey); |
| | | 582 | | var existingName = OrderSupplierStateService.DescribeProvider(existing.ProviderKey); |
| | | 583 | | var label = string.IsNullOrWhiteSpace(shipment) ? $"ID {target.OrderId}" : shipment; |
| | | 584 | | var number = string.IsNullOrWhiteSpace(existing.OrderNumber) ? "номер не записан" : $"№ {existing.OrderNumber}"; |
| | | 585 | | var when = existing.OrderedAtUtc is { } at |
| | | 586 | | ? DateTime.SpecifyKind(at, DateTimeKind.Utc).ToLocalTime().ToString("dd.MM.yyyy HH:mm", CultureInfo.Invarian |
| | | 587 | | : "время неизвестно"; |
| | | 588 | | var by = string.IsNullOrWhiteSpace(existing.OrderedBy) ? "автор неизвестен" : existing.OrderedBy; |
| | | 589 | | var action = target.Intent == SupplierOrderAction.Cart ? "добавляете в корзину" : "оформляете"; |
| | | 590 | | |
| | | 591 | | var message = existing.Unverified |
| | | 592 | | ? $"Заказ {label}: запрос к {existingName} от {when} ({by}) не завершился ответом — возможно, заказ уже созд |
| | | 593 | | $"требуется сверка. {existing.Details}".TrimEnd() |
| | | 594 | | : same |
| | | 595 | | ? $"Заказ {label}: у {existingName} уже оформлен заказ ({number}, {when}, {by}) — повторное оформление с |
| | | 596 | | : $"Заказ {label}: уже заказан у {existingName} ({number}, {when}, {by}), а вы {action} его у {requested |
| | | 597 | | |
| | | 598 | | var fingerprint = Fingerprint(target.OrderId, target.ProviderKey, existing.ProviderKey, existing.OrderNumber, ex |
| | | 599 | | return new SupplierOrderConflict( |
| | | 600 | | fingerprint, target.OrderId, shipment, target.Intent, target.ProviderKey, requestedName, |
| | | 601 | | existing.ProviderKey, existingName, existing.OrderNumber, existing.OrderedAtUtc, existing.OrderedBy, same, m |
| | | 602 | | existing.Unverified, article, productName); |
| | | 603 | | } |
| | | 604 | | |
| | | 605 | | /// <summary>Намеренно не включает намерение (корзина/заказ): подтверждение относится к паре «заказ — поставщики».</ |
| | | 606 | | private static string Fingerprint(int orderId, string requested, string existing, string? number, DateTime? orderedA |
| | | 607 | | { |
| | | 608 | | var raw = string.Join('|', orderId, requested.ToLowerInvariant(), existing.ToLowerInvariant(), |
| | | 609 | | number ?? string.Empty, orderedAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) ?? string.E |
| | | 610 | | return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); |
| | | 611 | | } |
| | | 612 | | |
| | | 613 | | private async Task RecordConfirmationsAsync(IReadOnlyCollection<SupplierOrderConflict> confirmed, string? userName, |
| | | 614 | | { |
| | | 615 | | var fresh = confirmed.Where(x => _recordedConfirmations.Add(x.Id)).ToList(); |
| | | 616 | | if (fresh.Count == 0) return; |
| | | 617 | | |
| | | 618 | | var user = string.IsNullOrWhiteSpace(userName) ? "unknown" : userName; |
| | | 619 | | foreach (var conflict in fresh) |
| | | 620 | | { |
| | | 621 | | _db.OrderHistories.Add(new OrderHistory |
| | | 622 | | { |
| | | 623 | | OrderId = conflict.OrderId, |
| | | 624 | | ColumnName = ConfirmedHistoryColumnName, |
| | | 625 | | ColumnDisplayName = "Подтверждён повторный заказ у поставщика", |
| | | 626 | | OldValue = $"Уже заказано: {conflict.ExistingProviderName}, {(string.IsNullOrWhiteSpace(conflict.Existin |
| | | 627 | | NewValue = $"Подтвердил {user}: {(conflict.Intent == SupplierOrderAction.Cart ? "корзина" : "заказ")} у |
| | | 628 | | ChangedAt = DateTime.Now, |
| | | 629 | | ChangedBy = user.Length <= 255 ? user : user[..255] |
| | | 630 | | }); |
| | | 631 | | _logger.LogWarning( |
| | | 632 | | "Пользователь {User} подтвердил повторный заказ: заказ {OrderId} уже заказан у {Existing} ({Number}), оф |
| | | 633 | | user, conflict.OrderId, conflict.ExistingProviderKey, conflict.ExistingOrderNumber, conflict.RequestedPr |
| | | 634 | | } |
| | | 635 | | |
| | | 636 | | await _db.SaveChangesAsync(ct); |
| | | 637 | | } |
| | | 638 | | } |