< Summary

Information
Class: OzonOrdersWeb.TransactionServices.SupplierOrderGuard
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/SupplierOrderGuard.cs
Line coverage
84%
Covered lines: 248
Uncovered lines: 46
Coverable lines: 294
Total lines: 638
Line coverage: 84.3%
Branch coverage
54%
Covered branches: 128
Total branches: 234
Branch coverage: 54.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
get_CurrentDispatch()100%11100%
EvaluateAsync()83.33%2424100%
ResolveCartTargetsAsync()14.28%46509822.03%
BeginDispatchAsync()100%1212100%
CompleteDispatchAsync()100%1414100%
BuildRaceConflict(...)50%22100%
.ctor(...)100%11100%
get_OrderId()100%11100%
get_Unverified()100%11100%
get_OrderId()100%11100%
LoadOrderedEvidenceAsync()100%88100%
AddIntentEvidenceAsync()100%1818100%
LookupAsync()100%22100%
LoadActiveCartsAsync()100%11100%
BuildConflict(...)92.85%1414100%
Fingerprint(...)100%66100%
RecordConfirmationsAsync()91.66%1212100%

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
 58public sealed record SupplierOrderGuardResult(
 59    IReadOnlyList<SupplierOrderConflict> Blocking,
 60    IReadOnlyList<SupplierOrderConflict> Confirmed,
 61    IReadOnlyList<string> Warnings)
 62{
 63    public static SupplierOrderGuardResult Empty { get; } = new([], [], []);
 64
 65    public bool IsAllowed => Blocking.Count == 0;
 66
 67    /// <summary>Заказы Studio2, по которым пользователь подтвердил повтор у того же поставщика.</summary>
 68    public IReadOnlySet<int> ConfirmedSameProviderOrderIds(string providerKey) => Confirmed
 69        .Where(x => x.SameProvider && string.Equals(x.RequestedProviderKey, providerKey, StringComparison.OrdinalIgnoreC
 70        .Select(x => x.OrderId)
 71        .ToHashSet();
 72
 73    public string BlockingMessage() =>
 74        (Blocking.All(x => x.Unverified)
 75            ? "Возможно, заказ уже создан у поставщика — требуется сверка, повторное оформление заблокировано. "
 76            : "Заказ уже оформлен у поставщика, повторное оформление заблокировано. ") +
 77        string.Join(" ", Blocking.Take(5).Select(x => x.Message)) +
 78        (Blocking.Count > 5 ? $" … и ещё {Blocking.Count - 5}." : string.Empty) +
 79        " Чтобы оформить повторно, подтвердите это явно.";
 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
 1175    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;
 66181    private readonly HashSet<string> _recordedConfirmations = new(StringComparer.Ordinal);
 182
 183    public SupplierOrderGuard(ApplicationDbContext db, ILogger<SupplierOrderGuard> logger)
 28184        : this(db, logger, null, [])
 185    {
 28186    }
 187
 66188    public SupplierOrderGuard(ApplicationDbContext db, ILogger<SupplierOrderGuard> logger,
 66189        ISupplierOrderIntentStore? intents, IEnumerable<ISupplierOrderLookup> lookups)
 190    {
 66191        _db = db;
 66192        _logger = logger;
 66193        _intents = intents;
 66194        _lookups = lookups.ToList();
 66195    }
 196
 63197    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    {
 67203        var wanted = targets
 71204            .Where(x => x.OrderId > 0 && !string.IsNullOrWhiteSpace(x.ProviderKey))
 67205            .Distinct()
 67206            .ToList();
 67207        if (wanted.Count == 0) return SupplierOrderGuardResult.Empty;
 208
 138209        var orderIds = wanted.Select(x => x.OrderId).Distinct().ToList();
 67210        var evidence = await LoadOrderedEvidenceAsync(orderIds, ct);
 67211        var otherCarts = await LoadActiveCartsAsync(orderIds, ct);
 67212        var shipments = await _db.Orders.AsNoTracking()
 67213            .Where(x => orderIds.Contains(x.Id))
 67214            .Select(x => new { x.Id, x.ShipmentNumber, x.Article, x.ProductName })
 137215            .ToDictionaryAsync(x => x.Id, ct);
 216
 67217        var conflicts = new List<SupplierOrderConflict>();
 67218        var warnings = new List<string>();
 276219        foreach (var target in wanted)
 220        {
 71221            var info = shipments.GetValueOrDefault(target.OrderId);
 71222            var shipment = info?.ShipmentNumber;
 298223            foreach (var existing in evidence.Where(x => x.OrderId == target.OrderId))
 224            {
 50225                conflicts.Add(BuildConflict(target, existing, shipment, info?.Article, info?.ProductName));
 226            }
 227
 146228            foreach (var cart in otherCarts.Where(x => x.OrderId == target.OrderId
 73229                         && !string.Equals(x.ProviderKey, target.ProviderKey, StringComparison.OrdinalIgnoreCase)))
 230            {
 1231                warnings.Add($"Заказ {shipment ?? $"ID {target.OrderId}"} также лежит в корзине {OrderSupplierStateServi
 1232                             "не оформите его там повторно.");
 233            }
 234        }
 235
 117236        conflicts = conflicts.DistinctBy(x => x.Id).ToList();
 67237        var confirmedIds = confirmation?.ConflictIds ?? new HashSet<string>();
 117238        var confirmed = conflicts.Where(x => confirmedIds.Contains(x.Id)).ToList();
 117239        var blocking = conflicts.Where(x => !confirmedIds.Contains(x.Id)).ToList();
 240
 67241        await RecordConfirmationsAsync(confirmed, userName, ct);
 67242        return new SupplierOrderGuardResult(blocking, confirmed, warnings.Distinct().ToList());
 67243    }
 244
 245    public async Task<IReadOnlyList<SupplierOrderTarget>> ResolveCartTargetsAsync(
 246        string providerKey, IReadOnlyCollection<string>? cartItemIds, CancellationToken ct)
 247    {
 11248        var all = cartItemIds is null;
 33249        var textIds = (cartItemIds ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToL
 22250        var ids = textIds.Select(x => long.TryParse(x, NumberStyles.None, CultureInfo.InvariantCulture, out var id) ? id
 11251            .OfType<long>().ToList();
 11252        if (!all && textIds.Count == 0) return [];
 253
 254        List<int> orderIds;
 255        switch (providerKey)
 256        {
 257            case ApiSupplierSettings.ArmtekProviderKey:
 258            case ApiSupplierSettings.ArmtekByProviderKey:
 10259                orderIds = await _db.ArmtekCartItems.AsNoTracking()
 10260                    .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x.
 10261                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 10262                break;
 263            case ApiSupplierSettings.MlAutoProviderKey:
 0264                orderIds = await _db.MlAutoCartItems.AsNoTracking()
 0265                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0266                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0267                break;
 268            case ApiSupplierSettings.MlAutoRuProviderKey:
 0269                orderIds = await _db.MlAutoRuCartItems.AsNoTracking()
 0270                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0271                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0272                break;
 273            case ApiSupplierSettings.ForumAutoProviderKey:
 274            case ApiSupplierSettings.ForumAutoInterpartsProviderKey:
 275            case ApiSupplierSettings.ForumAutoPiterProviderKey:
 276            case ApiSupplierSettings.ForumAutoRostovProviderKey:
 277            case ApiSupplierSettings.ForumAutoIstraProviderKey:
 0278                orderIds = await _db.ForumAutoCartItems.AsNoTracking()
 0279                    .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x.
 0280                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0281                break;
 282            case ApiSupplierSettings.FavoritPartsProviderKey:
 283            case ApiSupplierSettings.FavoritPartsIstraProviderKey:
 284            case ApiSupplierSettings.FavoritPartsRostovProviderKey:
 0285                orderIds = await _db.FavoritPartsCartItems.AsNoTracking()
 0286                    .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x.
 0287                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0288                break;
 289            case ApiSupplierSettings.ProfitLigaProviderKey:
 0290                orderIds = await _db.ProfitLigaCartItems.AsNoTracking()
 0291                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0292                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0293                break;
 294            case ApiSupplierSettings.NikeiProviderKey:
 0295                orderIds = await _db.NikeiCartItems.AsNoTracking()
 0296                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0297                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0298                break;
 299            case ApiSupplierSettings.MikadoMskHod20ProviderKey:
 0300                orderIds = await _db.MikadoCartItems.AsNoTracking()
 0301                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0302                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0303                break;
 304            case ApiSupplierSettings.ZZapMoscowProviderKey:
 0305                orderIds = await _db.ZZapCartItems.AsNoTracking()
 0306                    .Where(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId != null && (all || ids.Contains(x.
 0307                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0308                break;
 309            case ApiSupplierSettings.MoskvorechieIstraProviderKey:
 0310                orderIds = await _db.MoskvorechieIstraCartItems.AsNoTracking()
 0311                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0312                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0313                break;
 314            case ApiSupplierSettings.MotexProviderKey:
 0315                orderIds = await _db.MotexCartItems.AsNoTracking()
 0316                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0317                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0318                break;
 319            case ApiSupplierSettings.BergProviderKey:
 0320                orderIds = await _db.BergCartItems.AsNoTracking()
 0321                    .Where(x => x.ActiveStudioOrderId != null && (all || ids.Contains(x.Id)))
 0322                    .Select(x => x.StudioOrderId).ToListAsync(ct);
 0323                break;
 324            case ApiSupplierSettings.AvdProviderKey:
 325            case ApiSupplierSettings.JapartsProviderKey:
 326            case ApiSupplierSettings.ShateMProviderKey:
 327                // Корзина у самого поставщика: строка связана с заказом Studio2 только через состояние отслеживания.
 1328                orderIds = await _db.OrderSupplierStates.AsNoTracking()
 1329                    .Where(x => x.ProviderKey == providerKey && x.ActiveOrderId != null && x.Stage == OrderSupplierStage
 1330                                && (all || (x.ExternalBasketItemId != null && textIds.Contains(x.ExternalBasketItemId)))
 1331                    .Select(x => x.OrderId).ToListAsync(ct);
 1332                break;
 333            default:
 0334                _logger.LogWarning("SupplierOrderGuard: неизвестный провайдер {ProviderKey}, заказы корзины не определен
 0335                return [];
 336        }
 337
 23338        return orderIds.Distinct().Select(x => new SupplierOrderTarget(x, providerKey, SupplierOrderAction.Order)).ToLis
 11339    }
 340
 341    public async Task<SupplierDispatchStart> BeginDispatchAsync(
 342        string requestId, string source, IReadOnlyCollection<SupplierOrderTarget> targets,
 343        SupplierOrderGuardResult? evaluated, string? userName, CancellationToken ct)
 344    {
 26345        var orderTargets = targets
 25346            .Where(x => x.Intent == SupplierOrderAction.Order && x.OrderId > 0 && !string.IsNullOrWhiteSpace(x.ProviderK
 25347            .Select(x => (x.ProviderKey, x.OrderId))
 26348            .Distinct()
 26349            .ToList();
 26350        if (_intents is null || orderTargets.Count == 0)
 351        {
 4352            var empty = new SupplierDispatch(requestId, source, userName, []);
 4353            CurrentDispatch = empty;
 4354            return new SupplierDispatchStart(empty, []);
 355        }
 356
 357        // Подтверждённый повтор у того же поставщика заменяет прежнее намерение (иначе уникальный индекс не даст создат
 22358        var supersede = (evaluated?.Confirmed ?? [])
 2359            .Where(x => x.SameProvider)
 2360            .Select(x => (x.RequestedProviderKey, x.OrderId))
 22361            .ToHashSet();
 22362        var begin = await _intents.BeginAsync(requestId, source, userName, orderTargets, supersede, ct);
 363
 22364        if (begin.Busy.Count > 0)
 365        {
 6366            foreach (var started in begin.Started)
 1367                await _intents.ResolveAsync(started.Id, SupplierOrderIntentStatus.Released, null,
 1368                    "Не отправлено: другой запрос уже оформляет этот заказ.", userName);
 369
 2370            _logger.LogWarning("Параллельное оформление: заказы {Orders} уже оформляются другим запросом",
 4371                string.Join(", ", begin.Busy.Select(x => $"{x.StudioOrderId}@{x.ProviderKey}")));
 2372            return new SupplierDispatchStart(null, begin.Busy.Select(BuildRaceConflict).ToList());
 373        }
 374
 20375        var dispatch = new SupplierDispatch(requestId, source, userName, begin.Started);
 20376        CurrentDispatch = dispatch;
 20377        return new SupplierDispatchStart(dispatch, []);
 26378    }
 379
 380    public async Task CompleteDispatchAsync(SupplierDispatch dispatch)
 381    {
 382        try
 383        {
 23384            if (_intents is null || dispatch.Intents.Count == 0) return;
 385
 26386            var orderIds = dispatch.Intents.Select(x => x.StudioOrderId).Distinct().ToList();
 13387            var ordered = await _db.OrderSupplierStates.AsNoTracking()
 13388                .Where(x => orderIds.Contains(x.OrderId) && x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ord
 13389                .Select(x => new { x.OrderId, x.ProviderKey, x.ExternalOrderNumber })
 13390                .ToListAsync(CancellationToken.None);
 391
 52392            foreach (var intent in dispatch.Intents)
 393            {
 18394                var state = ordered.FirstOrDefault(x => x.OrderId == intent.StudioOrderId
 18395                    && string.Equals(x.ProviderKey, intent.ProviderKey, StringComparison.OrdinalIgnoreCase));
 13396                if (state is not null)
 397                {
 5398                    await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Placed, state.ExternalOrderNumber,
 5399                        "Заказ создан у поставщика.", dispatch.User);
 400                }
 8401                else if (!dispatch.UncertainProviders.Contains(intent.ProviderKey, StringComparer.OrdinalIgnoreCase))
 402                {
 403                    // Ответ получен (или запрос до поставщика не дошёл), заказа нет — повтор разрешён.
 2404                    await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Failed, null,
 2405                        "Поставщик не создал заказ.", dispatch.User);
 406                }
 407                // Иначе исход неизвестен: Placing остаётся и требует сверки.
 408            }
 13409        }
 410        finally
 411        {
 36412            if (ReferenceEquals(CurrentDispatch, dispatch)) CurrentDispatch = null;
 413        }
 18414    }
 415
 416    private SupplierOrderConflict BuildRaceConflict(SupplierOrderIntentInfo busy)
 417    {
 2418        var name = OrderSupplierStateService.DescribeProvider(busy.ProviderKey);
 2419        var when = DateTime.SpecifyKind(busy.CreatedAtUtc, DateTimeKind.Utc).ToLocalTime().ToString("dd.MM.yyyy HH:mm", 
 2420        var by = string.IsNullOrWhiteSpace(busy.RequestedBy) ? "автор неизвестен" : busy.RequestedBy;
 2421        var message = $"Заказ ID {busy.StudioOrderId}: у {name} уже есть запрос на оформление от {when} ({by}) — " +
 2422                      "возможно, заказ уже создан у поставщика. Дождитесь завершения того запроса, обновите страницу и п
 2423        return new SupplierOrderConflict(
 2424            Fingerprint(busy.StudioOrderId, busy.ProviderKey, busy.ProviderKey, busy.ExternalOrderNumber, busy.CreatedAt
 2425            busy.StudioOrderId, null, SupplierOrderAction.Order, busy.ProviderKey, name, busy.ProviderKey, name,
 2426            busy.ExternalOrderNumber, busy.CreatedAtUtc, busy.RequestedBy, true, message,
 2427            busy.Status == SupplierOrderIntentStatus.Placing);
 428    }
 429
 50430    private sealed record OrderedEvidence(
 725431        int OrderId, string ProviderKey, string? OrderNumber, DateTime? OrderedAtUtc, string? OrderedBy,
 163432        bool Unverified = false, string? Details = null);
 433
 7434    private sealed record ActiveCart(int OrderId, string ProviderKey);
 435
 436    private async Task<List<OrderedEvidence>> LoadOrderedEvidenceAsync(IReadOnlyCollection<int> orderIds, CancellationTo
 437    {
 67438        var states = await _db.OrderSupplierStates.AsNoTracking()
 67439            .Where(x => orderIds.Contains(x.OrderId))
 67440            .Select(x => new
 67441            {
 67442                x.Id, x.OrderId, x.ProviderKey, x.Stage, x.ActiveOrderId, x.ExternalOrderNumber,
 67443                x.OrderedAt, x.LastStatusChangeAt, x.UpdatedAt, x.CreatedBy
 67444            })
 67445            .ToListAsync(ct);
 446
 104447        var orderedStates = states.Where(x => x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ordered).ToList()
 100448        var orderedStateIds = orderedStates.Select(x => x.Id).ToList();
 67449        var transitions = orderedStateIds.Count == 0
 67450            ? []
 67451            : await _db.OrderSupplierStateHistory.AsNoTracking()
 67452                .Where(x => orderedStateIds.Contains(x.OrderSupplierStateId) && x.ToStage == OrderSupplierStage.Ordered)
 67453                .Select(x => new { x.OrderSupplierStateId, x.PerformedAt, x.PerformedBy })
 67454                .ToListAsync(ct);
 67455        var firstOrderedBy = transitions
 1456            .GroupBy(x => x.OrderSupplierStateId)
 70457            .ToDictionary(x => x.Key, x => x.OrderBy(t => t.PerformedAt).First());
 458
 67459        var result = orderedStates.Select(x =>
 67460        {
 33461            var transition = firstOrderedBy.GetValueOrDefault(x.Id);
 33462            return new OrderedEvidence(
 33463                x.OrderId, x.ProviderKey, x.ExternalOrderNumber,
 33464                x.OrderedAt ?? transition?.PerformedAt ?? x.LastStatusChangeAt,
 33465                transition?.PerformedBy ?? x.CreatedBy);
 67466        }).ToList();
 467
 104468        await AddIntentEvidenceAsync(orderIds, states.Select(x => (x.OrderId, x.ProviderKey, x.Stage, x.ActiveOrderId, x
 469
 470        // Состояние не записалось (сбой журнала) — заказ всё равно виден по строкам заказов Armtek.
 67471        var untrackedSince = DateTime.UtcNow - UntrackedOrderWindow;
 67472        var armtek = await _db.ArmtekOrderItems.AsNoTracking()
 67473            .Where(x => orderIds.Contains(x.StudioOrderId) && x.ArmtekOrder!.CreatedAtUtc >= untrackedSince
 67474                        && (x.Error == null || x.Error == "" || x.Error == "0"))
 67475            .Select(x => new { x.StudioOrderId, x.ArmtekOrder!.ProviderKey, x.ArmtekOrder.Vbeln, x.ArmtekOrder.CreatedAt
 67476            .ToListAsync(ct);
 137477        foreach (var group in armtek.GroupBy(x => (x.StudioOrderId, x.ProviderKey)))
 478        {
 1479            var (orderId, providerKey) = group.Key;
 1480            var providerHasState = states.Any(x => x.OrderId == orderId && x.ProviderKey == providerKey);
 1481            if (providerHasState || result.Any(x => x.OrderId == orderId && x.ProviderKey == providerKey)) continue;
 482
 1483            result.Add(new OrderedEvidence(
 1484                orderId, providerKey, string.Join(", ", group.Select(x => x.Vbeln).Distinct()),
 2485                group.Min(x => x.CreatedAtUtc), group.First().CreatedBy));
 486        }
 487
 67488        return result;
 67489    }
 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    {
 102501        if (_intents is null) return;
 502
 106503        foreach (var intent in await _intents.GetActiveAsync(orderIds, ct))
 504        {
 23505            var ordered = states.FirstOrDefault(x => x.OrderId == intent.StudioOrderId
 23506                && string.Equals(x.ProviderKey, intent.ProviderKey, StringComparison.OrdinalIgnoreCase)
 23507                && x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Ordered);
 21508            if (ordered.ProviderKey is not null)
 509            {
 510                // Evidence по состоянию уже есть; намерение только догоняем до Placed.
 1511                if (intent.Status == SupplierOrderIntentStatus.Placing)
 1512                    await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Placed, ordered.Number,
 1513                        "Подтверждено состоянием заказа у поставщика.", null);
 1514                continue;
 515            }
 516
 20517            if (intent.Status == SupplierOrderIntentStatus.Placed)
 518            {
 3519                var closedAfter = states.Any(x => x.OrderId == intent.StudioOrderId
 3520                    && string.Equals(x.ProviderKey, intent.ProviderKey, StringComparison.OrdinalIgnoreCase)
 3521                    && x.Stage == OrderSupplierStage.Closed && x.UpdatedAt >= intent.CreatedAtUtc);
 2522                if (closedAfter)
 523                {
 1524                    await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Released, intent.ExternalOrderNumbe
 1525                        "Заказ закрыт поставщиком — повторное оформление разрешено.", null);
 1526                    continue;
 527                }
 528
 1529                evidence.Add(new OrderedEvidence(intent.StudioOrderId, intent.ProviderKey, intent.ExternalOrderNumber,
 1530                    intent.UpdatedAtUtc, intent.RequestedBy));
 1531                continue;
 532            }
 533
 534            // Placing без результата: обрыв, падение процесса или таймаут. Пробуем сверку с поставщиком.
 18535            var lookup = await LookupAsync(intent, ct);
 18536            switch (lookup.Status)
 537            {
 538                case SupplierOrderLookupStatus.Found:
 2539                    await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Placed, lookup.ExternalOrderNumber,
 2540                        "Сверка с поставщиком: заказ найден.", null);
 2541                    evidence.Add(new OrderedEvidence(intent.StudioOrderId, intent.ProviderKey, lookup.ExternalOrderNumbe
 2542                        intent.CreatedAtUtc, intent.RequestedBy));
 2543                    break;
 544                case SupplierOrderLookupStatus.NotFound:
 3545                    await _intents.ResolveAsync(intent.Id, SupplierOrderIntentStatus.Released, null,
 3546                        "Сверка с поставщиком: заказ не найден — повторное оформление разрешено.", null);
 3547                    break;
 548                default:
 13549                    evidence.Add(new OrderedEvidence(intent.StudioOrderId, intent.ProviderKey, null,
 13550                        intent.CreatedAtUtc, intent.RequestedBy, Unverified: true, Details: lookup.Details));
 551                    break;
 552            }
 18553        }
 67554    }
 555
 556    private async Task<SupplierOrderLookupResult> LookupAsync(SupplierOrderIntentInfo intent, CancellationToken ct)
 557    {
 25558        var lookup = _lookups.FirstOrDefault(x => x.ProviderKeys.Contains(intent.ProviderKey, StringComparer.OrdinalIgno
 18559        if (lookup is null)
 11560            return SupplierOrderLookupResult.Unavailable("Автоматическая сверка с этим поставщиком недоступна.");
 561        try
 562        {
 7563            return await lookup.FindAsync(new SupplierOrderLookupRequest(intent.ProviderKey, intent.StudioOrderId, inten
 564        }
 1565        catch (Exception ex) when (ex is not OperationCanceledException)
 566        {
 1567            _logger.LogWarning(ex, "Сверка заказа {OrderId} у {ProviderKey} не удалась", intent.StudioOrderId, intent.Pr
 1568            return SupplierOrderLookupResult.Unavailable($"Автоматическая сверка не удалась: {ex.Message}");
 569        }
 18570    }
 571
 572    private async Task<List<ActiveCart>> LoadActiveCartsAsync(IReadOnlyCollection<int> orderIds, CancellationToken ct) =
 67573        await _db.OrderSupplierStates.AsNoTracking()
 67574            .Where(x => orderIds.Contains(x.OrderId) && x.ActiveOrderId != null && x.Stage == OrderSupplierStage.Cart)
 67575            .Select(x => new ActiveCart(x.OrderId, x.ProviderKey))
 67576            .ToListAsync(ct);
 577
 578    private static SupplierOrderConflict BuildConflict(SupplierOrderTarget target, OrderedEvidence existing, string? shi
 579    {
 50580        var same = string.Equals(target.ProviderKey, existing.ProviderKey, StringComparison.OrdinalIgnoreCase);
 50581        var requestedName = OrderSupplierStateService.DescribeProvider(target.ProviderKey);
 50582        var existingName = OrderSupplierStateService.DescribeProvider(existing.ProviderKey);
 50583        var label = string.IsNullOrWhiteSpace(shipment) ? $"ID {target.OrderId}" : shipment;
 50584        var number = string.IsNullOrWhiteSpace(existing.OrderNumber) ? "номер не записан" : $"№ {existing.OrderNumber}";
 50585        var when = existing.OrderedAtUtc is { } at
 50586            ? DateTime.SpecifyKind(at, DateTimeKind.Utc).ToLocalTime().ToString("dd.MM.yyyy HH:mm", CultureInfo.Invarian
 50587            : "время неизвестно";
 50588        var by = string.IsNullOrWhiteSpace(existing.OrderedBy) ? "автор неизвестен" : existing.OrderedBy;
 50589        var action = target.Intent == SupplierOrderAction.Cart ? "добавляете в корзину" : "оформляете";
 590
 50591        var message = existing.Unverified
 50592            ? $"Заказ {label}: запрос к {existingName} от {when} ({by}) не завершился ответом — возможно, заказ уже созд
 50593              $"требуется сверка. {existing.Details}".TrimEnd()
 50594            : same
 50595                ? $"Заказ {label}: у {existingName} уже оформлен заказ ({number}, {when}, {by}) — повторное оформление с
 50596                : $"Заказ {label}: уже заказан у {existingName} ({number}, {when}, {by}), а вы {action} его у {requested
 597
 50598        var fingerprint = Fingerprint(target.OrderId, target.ProviderKey, existing.ProviderKey, existing.OrderNumber, ex
 50599        return new SupplierOrderConflict(
 50600            fingerprint, target.OrderId, shipment, target.Intent, target.ProviderKey, requestedName,
 50601            existing.ProviderKey, existingName, existing.OrderNumber, existing.OrderedAtUtc, existing.OrderedBy, same, m
 50602            existing.Unverified, article, productName);
 603    }
 604
 605    /// <summary>Намеренно не включает намерение (корзина/заказ): подтверждение относится к паре «заказ — поставщики».</
 606    private static string Fingerprint(int orderId, string requested, string existing, string? number, DateTime? orderedA
 607    {
 52608        var raw = string.Join('|', orderId, requested.ToLowerInvariant(), existing.ToLowerInvariant(),
 52609            number ?? string.Empty, orderedAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) ?? string.E
 52610        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
 611    }
 612
 613    private async Task RecordConfirmationsAsync(IReadOnlyCollection<SupplierOrderConflict> confirmed, string? userName, 
 614    {
 75615        var fresh = confirmed.Where(x => _recordedConfirmations.Add(x.Id)).ToList();
 127616        if (fresh.Count == 0) return;
 617
 7618        var user = string.IsNullOrWhiteSpace(userName) ? "unknown" : userName;
 28619        foreach (var conflict in fresh)
 620        {
 7621            _db.OrderHistories.Add(new OrderHistory
 7622            {
 7623                OrderId = conflict.OrderId,
 7624                ColumnName = ConfirmedHistoryColumnName,
 7625                ColumnDisplayName = "Подтверждён повторный заказ у поставщика",
 7626                OldValue = $"Уже заказано: {conflict.ExistingProviderName}, {(string.IsNullOrWhiteSpace(conflict.Existin
 7627                NewValue = $"Подтвердил {user}: {(conflict.Intent == SupplierOrderAction.Cart ? "корзина" : "заказ")} у 
 7628                ChangedAt = DateTime.Now,
 7629                ChangedBy = user.Length <= 255 ? user : user[..255]
 7630            });
 7631            _logger.LogWarning(
 7632                "Пользователь {User} подтвердил повторный заказ: заказ {OrderId} уже заказан у {Existing} ({Number}), оф
 7633                user, conflict.OrderId, conflict.ExistingProviderKey, conflict.ExistingOrderNumber, conflict.RequestedPr
 634        }
 635
 7636        await _db.SaveChangesAsync(ct);
 67637    }
 638}