< Summary

Information
Class: OzonOrdersWeb.TransactionServices.SupplierOrderIntentBeginResult
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/SupplierOrderIntentStore.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 221
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
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_Started()100%11100%
get_NotRecorded()100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Logging;
 3using OzonDomains.Models.SupplierStatuses;
 4using OzonRepositories.Context;
 5
 6namespace OzonOrdersWeb.TransactionServices;
 7
 8public sealed record SupplierOrderIntentInfo(
 9    long Id, string RequestId, string ProviderKey, int StudioOrderId, string Status,
 10    string? ExternalOrderNumber, string? RequestedBy, DateTime CreatedAtUtc, DateTime UpdatedAtUtc);
 11
 12/// <summary>Итог <see cref="ISupplierOrderIntentStore.BeginAsync"/>.</summary>
 13/// <param name="Started">Созданные намерения (Placing).</param>
 14/// <param name="Busy">Активные намерения, из-за которых новое не создано: другой запрос оказался быстрее.</param>
 15/// <param name="Recorded"><c>false</c> — таблицы намерений ещё нет (миграция не применена), запись не велась.</param>
 4416public sealed record SupplierOrderIntentBeginResult(
 10917    IReadOnlyList<SupplierOrderIntentInfo> Started, IReadOnlyList<SupplierOrderIntentInfo> Busy, bool Recorded)
 18{
 319    public static SupplierOrderIntentBeginResult NotRecorded { get; } = new([], [], false);
 20}
 21
 22/// <summary>
 23/// Хранилище намерений оформить заказ у поставщика (outbox). Каждая операция выполняется в собственном
 24/// контексте и сразу фиксируется — запись не откатывается вместе с основной транзакцией запроса.
 25/// </summary>
 26public interface ISupplierOrderIntentStore
 27{
 28    Task<IReadOnlyList<SupplierOrderIntentInfo>> GetActiveAsync(IReadOnlyCollection<int> orderIds, CancellationToken ct)
 29
 30    /// <summary>
 31    /// Создаёт намерения Placing. Если по (поставщик, заказ) уже есть активное — оно попадает в <c>Busy</c>,
 32    /// кроме пар из <paramref name="supersede"/> (пользователь явно подтвердил повтор): тогда старое закрывается как Su
 33    /// </summary>
 34    Task<SupplierOrderIntentBeginResult> BeginAsync(
 35        string requestId, string source, string? user,
 36        IReadOnlyCollection<(string ProviderKey, int OrderId)> targets,
 37        IReadOnlySet<(string ProviderKey, int OrderId)> supersede, CancellationToken ct);
 38
 39    /// <summary>Переводит намерение в итоговый статус; для не-активных статусов освобождает уникальный индекс.</summary
 40    Task ResolveAsync(long id, string status, string? externalOrderNumber, string? note, string? resolvedBy);
 41}
 42
 43/// <summary>
 44/// Реализация на <c>IDbContextFactory</c>. Пока таблицы <c>SupplierOrderIntents</c> нет (миграция ещё не применена),
 45/// работает «в холостую»: ничего не пишет и не находит, чтобы деплой кода раньше схемы не ломал оформление заказов;
 46/// проверка таблицы повторяется раз в две минуты. Любая другая ошибка БД пробрасывается — заказ без записи
 47/// намерения поставщику не отправляется.
 48/// </summary>
 49public sealed class SupplierOrderIntentStore : ISupplierOrderIntentStore
 50{
 51    private static readonly TimeSpan RetryAfterMissingTable = TimeSpan.FromMinutes(2);
 52
 53    private readonly IDbContextFactory<ApplicationDbContext> _factory;
 54    private readonly ILogger<SupplierOrderIntentStore> _logger;
 55    private long _unavailableUntilTicks;
 56
 57    public SupplierOrderIntentStore(IDbContextFactory<ApplicationDbContext> factory, ILogger<SupplierOrderIntentStore> l
 58    {
 59        _factory = factory;
 60        _logger = logger;
 61    }
 62
 63    private bool IsDown => DateTime.UtcNow.Ticks < Interlocked.Read(ref _unavailableUntilTicks);
 64
 65    public async Task<IReadOnlyList<SupplierOrderIntentInfo>> GetActiveAsync(IReadOnlyCollection<int> orderIds, Cancella
 66    {
 67        if (IsDown || orderIds.Count == 0) return [];
 68        try
 69        {
 70            await using var db = await _factory.CreateDbContextAsync(ct);
 71            var ids = orderIds.ToList();
 72            var rows = await db.SupplierOrderIntents.AsNoTracking()
 73                .Where(x => ids.Contains(x.StudioOrderId) && x.ActiveStudioOrderId != null)
 74                .ToListAsync(ct);
 75            return rows.Select(ToInfo).ToList();
 76        }
 77        catch (Exception ex) when (TryDegrade(ex))
 78        {
 79            return [];
 80        }
 81    }
 82
 83    public async Task<SupplierOrderIntentBeginResult> BeginAsync(
 84        string requestId, string source, string? user,
 85        IReadOnlyCollection<(string ProviderKey, int OrderId)> targets,
 86        IReadOnlySet<(string ProviderKey, int OrderId)> supersede, CancellationToken ct)
 87    {
 88        if (IsDown || targets.Count == 0) return SupplierOrderIntentBeginResult.NotRecorded;
 89        try
 90        {
 91            await using var db = await _factory.CreateDbContextAsync(ct);
 92            var started = new List<SupplierOrderIntentInfo>();
 93            var busy = new List<SupplierOrderIntentInfo>();
 94            var by = Trim(user, 450);
 95            foreach (var (providerKey, orderId) in targets.Distinct())
 96            {
 97                var active = await db.SupplierOrderIntents
 98                    .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId == orderId, ct);
 99                if (active is not null)
 100                {
 101                    if (!supersede.Contains((providerKey, orderId)))
 102                    {
 103                        busy.Add(ToInfo(active));
 104                        continue;
 105                    }
 106
 107                    active.Status = SupplierOrderIntentStatus.Superseded;
 108                    active.ActiveStudioOrderId = null;
 109                    active.UpdatedAtUtc = DateTime.UtcNow;
 110                    active.ResolvedBy = by;
 111                    active.Note = "Заменено новым намерением после явного подтверждения пользователя.";
 112                    await db.SaveChangesAsync(ct);
 113                }
 114
 115                var now = DateTime.UtcNow;
 116                var row = new SupplierOrderIntent
 117                {
 118                    RequestId = Trim(requestId, 64) ?? string.Empty,
 119                    ProviderKey = providerKey,
 120                    StudioOrderId = orderId,
 121                    ActiveStudioOrderId = orderId,
 122                    Status = SupplierOrderIntentStatus.Placing,
 123                    Source = Trim(source, 30) ?? string.Empty,
 124                    RequestedBy = by,
 125                    CreatedAtUtc = now,
 126                    UpdatedAtUtc = now
 127                };
 128                db.SupplierOrderIntents.Add(row);
 129                try
 130                {
 131                    await db.SaveChangesAsync(ct);
 132                    started.Add(ToInfo(row));
 133                }
 134                catch (DbUpdateException ex) when (IsUniqueViolation(ex))
 135                {
 136                    // Гонка: между проверкой и вставкой активное намерение успел создать другой запрос или инстанс.
 137                    db.Entry(row).State = EntityState.Detached;
 138                    var winner = await db.SupplierOrderIntents.AsNoTracking()
 139                        .FirstOrDefaultAsync(x => x.ProviderKey == providerKey && x.ActiveStudioOrderId == orderId, ct);
 140                    busy.Add(winner is null ? ToInfo(row) : ToInfo(winner));
 141                }
 142            }
 143
 144            return new SupplierOrderIntentBeginResult(started, busy, true);
 145        }
 146        catch (Exception ex) when (TryDegrade(ex))
 147        {
 148            return SupplierOrderIntentBeginResult.NotRecorded;
 149        }
 150    }
 151
 152    public async Task ResolveAsync(long id, string status, string? externalOrderNumber, string? note, string? resolvedBy
 153    {
 154        if (IsDown) return;
 155        try
 156        {
 157            await using var db = await _factory.CreateDbContextAsync(CancellationToken.None);
 158            var row = await db.SupplierOrderIntents.FirstOrDefaultAsync(x => x.Id == id, CancellationToken.None);
 159            if (row is null) return;
 160
 161            row.Status = status;
 162            row.ActiveStudioOrderId = SupplierOrderIntentStatus.IsActive(status) ? row.StudioOrderId : null;
 163            if (!string.IsNullOrWhiteSpace(externalOrderNumber)) row.ExternalOrderNumber = Trim(externalOrderNumber, 200
 164            row.Note = Trim(note, 500);
 165            row.ResolvedBy = Trim(resolvedBy, 450);
 166            row.UpdatedAtUtc = DateTime.UtcNow;
 167            await db.SaveChangesAsync(CancellationToken.None);
 168        }
 169        catch (Exception ex) when (TryDegrade(ex))
 170        {
 171            // Таблицы нет — писать некуда.
 172        }
 173    }
 174
 175    private bool TryDegrade(Exception ex)
 176    {
 177        if (!IsMissingTable(ex)) return false;
 178        Interlocked.Exchange(ref _unavailableUntilTicks, (DateTime.UtcNow + RetryAfterMissingTable).Ticks);
 179        _logger.LogWarning(
 180            "SupplierOrderIntents: таблицы нет — миграция AddSupplierOrderIntents не применена. Намерения заказов не зап
 181            "защита от обрыва запроса работает без outbox (повторная проверка через {Minutes} мин)", RetryAfterMissingTa
 182        return true;
 183    }
 184
 185    public static bool IsMissingTable(Exception ex)
 186    {
 187        for (var e = ex; e is not null; e = e.InnerException)
 188        {
 189            var message = e.Message;
 190            if (message.Contains(nameof(ApplicationDbContext.SupplierOrderIntents), StringComparison.OrdinalIgnoreCase)
 191                && (message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase)
 192                    || message.Contains("no such table", StringComparison.OrdinalIgnoreCase)))
 193                return true;
 194        }
 195
 196        return false;
 197    }
 198
 199    public static bool IsUniqueViolation(Exception ex)
 200    {
 201        for (var e = ex; e is not null; e = e.InnerException)
 202        {
 203            var message = e.Message;
 204            if (message.Contains("Duplicate entry", StringComparison.OrdinalIgnoreCase)
 205                || message.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase)
 206                || message.Contains("unique index", StringComparison.OrdinalIgnoreCase))
 207                return true;
 208
 209            // MySqlException.Number == 1062 (ER_DUP_ENTRY) без ссылки на сборку коннектора.
 210            if (e.GetType().GetProperty("Number")?.GetValue(e) is int number && number == 1062) return true;
 211        }
 212
 213        return false;
 214    }
 215
 216    private static SupplierOrderIntentInfo ToInfo(SupplierOrderIntent x) => new(
 217        x.Id, x.RequestId, x.ProviderKey, x.StudioOrderId, x.Status, x.ExternalOrderNumber, x.RequestedBy, x.CreatedAtUt
 218
 219    private static string? Trim(string? value, int maxLength) =>
 220        string.IsNullOrWhiteSpace(value) ? null : value.Length <= maxLength ? value : value[..maxLength];
 221}