< Summary

Information
Class: OzonOrdersWeb.TransactionServices.SupplierOrderGuardResult
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/SupplierOrderGuard.cs
Line coverage
93%
Covered lines: 15
Uncovered lines: 1
Coverable lines: 16
Total lines: 638
Line coverage: 93.7%
Branch coverage
66%
Covered branches: 4
Total branches: 6
Branch coverage: 66.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Blocking()100%11100%
get_Confirmed()100%11100%
get_Warnings()100%11100%
get_Empty()100%210%
get_IsAllowed()100%11100%
ConfirmedSameProviderOrderIds(...)50%22100%
BlockingMessage()75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/SupplierOrderGuard.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Security.Cryptography;
 3using System.Text;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Logging;
 6using OzonDomains.Models;
 7using OzonDomains.Models.SupplierStatuses;
 8using OzonRepositories.Context;
 9using OzonOrdersWeb.WebServices.OrderHistory;
 10using Services.SupplierStatusServices;
 11
 12namespace OzonOrdersWeb.TransactionServices;
 13
 14/// <summary>Что именно пытаются сделать с заказом у поставщика.</summary>
 15public enum SupplierOrderAction
 16{
 17    /// <summary>Отправить реальный заказ поставщику (прямое оформление, «Создать заказ» в корзине).</summary>
 18    Order,
 19
 20    /// <summary>Положить позицию в корзину поставщика (заказ создаётся отдельным шагом).</summary>
 21    Cart
 22}
 23
 24/// <summary>Заказ Studio2, который собираются оформить (или положить в корзину) у поставщика.</summary>
 25public sealed record SupplierOrderTarget(int OrderId, string ProviderKey, SupplierOrderAction Intent = SupplierOrderActi
 26
 27/// <summary>
 28/// Конфликт: заказ Studio2 уже заказан у поставщика. <see cref="Id"/> — отпечаток именно этого конфликта
 29/// (заказ, поставщик, номер и время прежнего заказа): подтверждение пользователя привязано к нему и не
 30/// переносится на другой заказ или на изменившееся состояние.
 31/// </summary>
 32public 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
 6758public sealed record SupplierOrderGuardResult(
 12859    IReadOnlyList<SupplierOrderConflict> Blocking,
 3060    IReadOnlyList<SupplierOrderConflict> Confirmed,
 7661    IReadOnlyList<string> Warnings)
 62{
 063    public static SupplierOrderGuardResult Empty { get; } = new([], [], []);
 64
 4565    public bool IsAllowed => Blocking.Count == 0;
 66
 67    /// <summary>Заказы Studio2, по которым пользователь подтвердил повтор у того же поставщика.</summary>
 1668    public IReadOnlySet<int> ConfirmedSameProviderOrderIds(string providerKey) => Confirmed
 269        .Where(x => x.SameProvider && string.Equals(x.RequestedProviderKey, providerKey, StringComparison.OrdinalIgnoreC
 170        .Select(x => x.OrderId)
 1671        .ToHashSet();
 72
 73    public string BlockingMessage() =>
 2874        (Blocking.All(x => x.Unverified)
 1475            ? "Возможно, заказ уже создан у поставщика — требуется сверка, повторное оформление заблокировано. "
 1476            : "Заказ уже оформлен у поставщика, повторное оформление заблокировано. ") +
 1477        string.Join(" ", Blocking.Take(5).Select(x => x.Message)) +
 1478        (Blocking.Count > 5 ? $" … и ещё {Blocking.Count - 5}." : string.Empty) +
 1479        " Чтобы оформить повторно, подтвердите это явно.";
 80}
 81
 82/// <summary>
 83/// Подтверждение пользователя: отпечатки конфликтов, которые он увидел и разрешил. Создаётся только из запроса
 84/// пользователя; фоновые задачи и повторные запросы без него передают <c>null</c> и блокируются снова.
 85/// </summary>
 86public 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>
 100public 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"/>: либо отправка может начинаться, либо конфликт-го
 125public 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>
 136public 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>
 170public 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}