< Summary

Information
Class: Services.SupplierStatusServices.SupplierOrderVerificationService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SupplierStatusServices/SupplierOrderVerificationService.cs
Line coverage
90%
Covered lines: 151
Uncovered lines: 16
Coverable lines: 167
Total lines: 319
Line coverage: 90.4%
Branch coverage
80%
Covered branches: 68
Total branches: 84
Branch coverage: 80.9%
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%
VerifyAsync()84.61%262693.02%
FetchAsync()87.5%8882.35%
Row()62.5%88100%
Evaluate(...)92.85%1414100%
EvaluateCart(...)75%4477.77%
EvaluateOrdered(...)89.28%292888%
WriteHistoryAsync()50%4476.19%
get_Id()100%11100%
get_Snapshots()100%11100%
get_Error()100%11100%
get_NotVerifiable()100%11100%
Ok(...)100%11100%
Failed(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SupplierStatusServices/SupplierOrderVerificationService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Logging;
 3using OzonDomains.Models;
 4using OzonDomains.Models.SupplierStatuses;
 5using OzonRepositories.Context;
 6
 7namespace Services.SupplierStatusServices;
 8
 9public interface ISupplierOrderVerificationService
 10{
 11    /// <summary>
 12    /// Независимо проверяет, что заказы проведённой транзакции реально созданы у API-поставщиков:
 13    /// повторно читает поставщика через <see cref="ISupplierStatusProvider"/> и сверяет ответ с состоянием
 14    /// <c>OrderSupplierStates</c>. Только читает у поставщика; результат пишется в историю заказа.
 15    /// Никогда не бросает исключений: проверка вторична и не должна ломать уже проведённую транзакцию.
 16    /// </summary>
 17    /// <param name="orderIds">Заказы Studio2 проведённой транзакции.</param>
 18    /// <param name="requiredOrderIds">
 19    /// Заказы, которые были в очередях поставщиков: если состояния у поставщика по ним нет — это «не найдено».
 20    /// </param>
 21    /// <param name="sinceUtc">Состояния, не менявшиеся с этого момента, считаются старыми и не проверяются.</param>
 22    /// <param name="writeHistory">Записывать ли результат системной записью в историю заказа.</param>
 23    Task<SupplierVerificationSummary> VerifyAsync(
 24        IReadOnlyCollection<int> orderIds,
 25        IReadOnlyCollection<int>? requiredOrderIds = null,
 26        DateTime? sinceUtc = null,
 27        bool writeHistory = true,
 28        CancellationToken ct = default);
 29}
 30
 31public sealed class SupplierOrderVerificationService : ISupplierOrderVerificationService
 32{
 33    /// <summary>Потолок ожидания одного поставщика: медленный API не должен подвешивать проведение.</summary>
 134    private static readonly TimeSpan ProviderTimeout = TimeSpan.FromSeconds(15);
 35
 136    private static readonly TimeSpan DefaultLookback = TimeSpan.FromMinutes(30);
 37
 38    /// <summary>
 39    /// Провайдеры, у которых стадия корзины — локальная таблица Studio2: поставщик о позиции ещё не знает,
 40    /// поэтому «подтвердить» её через API нельзя (иначе получилось бы ложное «подтверждено»).
 41    /// </summary>
 142    private static readonly HashSet<string> LocalCartProviders = new(StringComparer.Ordinal)
 143    {
 144        ApiSupplierSettings.ArmtekProviderKey, ApiSupplierSettings.ArmtekByProviderKey,
 145        ApiSupplierSettings.MlAutoProviderKey, ApiSupplierSettings.MlAutoRuProviderKey,
 146        ApiSupplierSettings.ForumAutoProviderKey, ApiSupplierSettings.ForumAutoInterpartsProviderKey,
 147        ApiSupplierSettings.ForumAutoPiterProviderKey, ApiSupplierSettings.ForumAutoRostovProviderKey,
 148        ApiSupplierSettings.ForumAutoIstraProviderKey, ApiSupplierSettings.BergProviderKey,
 149        ApiSupplierSettings.MotexProviderKey, ApiSupplierSettings.ProfitLigaProviderKey,
 150        ApiSupplierSettings.ZZapMoscowProviderKey
 151    };
 52
 53    private readonly ApplicationDbContext _db;
 54    private readonly IReadOnlyDictionary<string, ISupplierStatusProvider> _providers;
 55    private readonly ILogger<SupplierOrderVerificationService> _logger;
 56
 1357    public SupplierOrderVerificationService(
 1358        ApplicationDbContext db,
 1359        IEnumerable<ISupplierStatusProvider> providers,
 1360        ILogger<SupplierOrderVerificationService> logger)
 61    {
 1362        _db = db;
 2663        _providers = providers.ToDictionary(x => x.ProviderKey, StringComparer.Ordinal);
 1364        _logger = logger;
 1365    }
 66
 67    public async Task<SupplierVerificationSummary> VerifyAsync(
 68        IReadOnlyCollection<int> orderIds,
 69        IReadOnlyCollection<int>? requiredOrderIds = null,
 70        DateTime? sinceUtc = null,
 71        bool writeHistory = true,
 72        CancellationToken ct = default)
 73    {
 74        try
 75        {
 3176            var ids = orderIds.Concat(requiredOrderIds ?? []).Where(x => x > 0).Distinct().ToList();
 1377            if (ids.Count == 0) return SupplierVerificationSummary.Empty;
 78
 1379            var since = sinceUtc ?? DateTime.UtcNow - DefaultLookback;
 1380            var states = await _db.OrderSupplierStates
 1381                .AsNoTracking()
 1382                .Where(x => ids.Contains(x.OrderId)
 1383                            && (x.Stage == OrderSupplierStage.Cart || x.Stage == OrderSupplierStage.Ordered)
 1384                            && x.UpdatedAt >= since)
 1385                .Select(x => new OrderSupplierStateRef(
 1386                    x.Id, x.OrderId, x.ProviderKey, x.Stage, x.CartItemId, x.ExternalBasketItemId,
 1387                    x.ExternalOrderNumber, x.ExternalOrderId, x.ExternalLineId, x.SupplierStatusId))
 1388                .ToListAsync(ct);
 89
 2590            var stateIds = states.Select(s => s.StateId).ToList();
 1391            var quantityByState = await _db.OrderSupplierStates
 1392                .AsNoTracking()
 1393                .Where(x => stateIds.Contains(x.Id))
 1394                .Select(x => new { x.Id, x.QuantityOrdered })
 3795                .ToDictionaryAsync(x => x.Id, x => x.QuantityOrdered, ct);
 96
 1397            var orderInfo = await _db.Orders
 1398                .AsNoTracking()
 1399                .Where(x => ids.Contains(x.Id))
 13100                .Select(x => new OrderInfo(x.Id, x.ShipmentNumber, x.Article, x.ProductName))
 26101                .ToDictionaryAsync(x => x.Id, ct);
 102
 13103            var verifiedAt = DateTime.Now;
 13104            var rows = new List<SupplierVerificationRow>();
 105
 106            // Провайдеры делят один scoped DbContext (локальные читалки корзин), поэтому опрашиваем последовательно.
 62107            foreach (var group in states.GroupBy(x => new { x.ProviderKey, x.Stage }))
 108            {
 12109                var batch = group.ToList();
 12110                var snapshots = await FetchAsync(group.Key.ProviderKey, group.Key.Stage, batch, ct);
 48111                foreach (var state in batch)
 112                {
 12113                    orderInfo.TryGetValue(state.OrderId, out var info);
 12114                    quantityByState.TryGetValue(state.StateId, out var quantityOrdered);
 12115                    rows.Add(Evaluate(state, quantityOrdered, snapshots, info, verifiedAt));
 116                }
 12117            }
 118
 119            // Заказ был в очереди поставщика, а состояния у поставщика нет — заказ так и не появился.
 25120            var coveredOrderIds = states.Select(x => x.OrderId).ToHashSet();
 32121            foreach (var orderId in (requiredOrderIds ?? []).Distinct().Where(x => x > 0 && !coveredOrderIds.Contains(x)
 122            {
 1123                orderInfo.TryGetValue(orderId, out var info);
 1124                rows.Add(new SupplierVerificationRow(
 1125                    orderId, info?.ShipmentNumber, info?.Article, info?.ProductName,
 1126                    string.Empty, SupplierProviderNames.Describe(string.Empty), "—",
 1127                    SupplierVerificationStatus.NotFound,
 1128                    "Заказ был в очереди поставщика, но записи о нём у поставщика нет.",
 1129                    null, null,
 1130                    ["✖ Запись состояния у поставщика не найдена: позиция не была добавлена в корзину или заказ не оформ
 1131                    verifiedAt));
 132            }
 133
 39134            rows = rows.OrderByDescending(x => x.IsProblem).ThenBy(x => x.OrderId).ToList();
 24135            if (writeHistory && rows.Count > 0) await WriteHistoryAsync(rows, ct);
 13136            return new SupplierVerificationSummary(rows);
 137        }
 0138        catch (Exception ex) when (ex is not OperationCanceledException)
 139        {
 0140            _logger.LogError(ex, "Проверка заказов у поставщиков не удалась для {Count} заказов", orderIds.Count);
 0141            return SupplierVerificationSummary.Empty;
 142        }
 13143    }
 144
 145    private async Task<FetchOutcome> FetchAsync(
 146        string providerKey, string stage, IReadOnlyList<OrderSupplierStateRef> batch, CancellationToken ct)
 147    {
 12148        if (!_providers.TryGetValue(providerKey, out var provider))
 0149            return FetchOutcome.Failed($"Нет провайдера чтения для «{providerKey}»", notVerifiable: true);
 150
 12151        if (stage == OrderSupplierStage.Cart && LocalCartProviders.Contains(providerKey))
 1152            return FetchOutcome.Failed(
 1153                "Корзина локальная: у поставщика заказа ещё нет, проверка будет после оформления", notVerifiable: true);
 154
 155        try
 156        {
 11157            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
 11158            timeout.CancelAfter(ProviderTimeout);
 11159            var snapshots = stage == OrderSupplierStage.Cart
 11160                ? await provider.FetchCartAsync(batch, timeout.Token)
 11161                : await provider.FetchOrderAsync(batch, timeout.Token);
 10162            return FetchOutcome.Ok(snapshots);
 163        }
 0164        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
 165        {
 0166            return FetchOutcome.Failed($"Поставщик не ответил за {ProviderTimeout.TotalSeconds:0} с");
 167        }
 1168        catch (Exception ex) when (ex is not OperationCanceledException)
 169        {
 1170            _logger.LogWarning(ex, "Проверка поставщика {ProviderKey} ({Stage}) не удалась", providerKey, stage);
 1171            return FetchOutcome.Failed($"Ошибка API: {ex.Message}");
 172        }
 12173    }
 174
 175    private static SupplierVerificationRow Evaluate(
 176        OrderSupplierStateRef state, decimal? quantityOrdered, FetchOutcome outcome, OrderInfo? info, DateTime verifiedA
 177    {
 178        SupplierVerificationRow Row(string status, string summary, IReadOnlyList<string> checks,
 12179            string? number = null, string? supplierStatus = null) => new(
 12180            state.OrderId, info?.ShipmentNumber, info?.Article, info?.ProductName, state.ProviderKey,
 12181            SupplierProviderNames.Describe(state.ProviderKey), state.Stage, status, summary,
 12182            number ?? state.ExternalOrderNumber, supplierStatus, checks, verifiedAt);
 183
 12184        if (outcome.Error is not null)
 185        {
 2186            var status = outcome.NotVerifiable ? SupplierVerificationStatus.NotVerifiable : SupplierVerificationStatus.A
 2187            return Row(status, outcome.Error, [$"{(outcome.NotVerifiable ? "•" : "✖")} {outcome.Error}"]);
 188        }
 189
 18190        var snapshot = outcome.Snapshots.FirstOrDefault(x => x.OrderId == state.OrderId);
 10191        if (snapshot is null)
 192        {
 2193            var where = state.Stage == OrderSupplierStage.Cart ? "в корзине поставщика" : "среди заказов поставщика";
 2194            return Row(SupplierVerificationStatus.NotFound, $"Позиция не найдена {where}.",
 2195                [$"✖ Поставщик не вернул позицию {where}"]);
 196        }
 197
 8198        if (!string.IsNullOrWhiteSpace(snapshot.Error))
 1199            return Row(SupplierVerificationStatus.ApiError, snapshot.Error!, [$"✖ {snapshot.Error}"],
 1200                snapshot.ExternalOrderNumber, snapshot.StatusName);
 201
 7202        return state.Stage == OrderSupplierStage.Cart
 7203            ? EvaluateCart(snapshot, Row)
 7204            : EvaluateOrdered(state, quantityOrdered, snapshot, Row);
 205    }
 206
 207    private static SupplierVerificationRow EvaluateCart(
 208        SupplierStatusSnapshot snapshot,
 209        Func<string, string, IReadOnlyList<string>, string?, string?, SupplierVerificationRow> row)
 210    {
 2211        var checks = new List<string>();
 2212        if (snapshot.ExternalCode == SupplierCartStatusCode.Removed)
 213        {
 1214            checks.Add("✖ Позиции нет в корзине поставщика");
 1215            return row(SupplierVerificationStatus.NotFound, "Позиции нет в корзине поставщика.", checks, null, snapshot.
 216        }
 217
 1218        checks.Add("✔ Позиция найдена в корзине поставщика");
 1219        if (snapshot.ExternalCode == SupplierCartStatusCode.Failed)
 220        {
 0221            checks.Add($"✖ Статус в корзине: {snapshot.StatusName}");
 0222            return row(SupplierVerificationStatus.Mismatch, $"Поставщик отклонил позицию: {snapshot.StatusName}.", check
 223        }
 224
 1225        return row(SupplierVerificationStatus.Confirmed, "Позиция в корзине поставщика.", checks, null, snapshot.StatusN
 226    }
 227
 228    private static SupplierVerificationRow EvaluateOrdered(
 229        OrderSupplierStateRef state, decimal? quantityOrdered, SupplierStatusSnapshot snapshot,
 230        Func<string, string, IReadOnlyList<string>, string?, string?, SupplierVerificationRow> row)
 231    {
 5232        var checks = new List<string> { "✔ Заказ найден в API поставщика" };
 5233        var problems = new List<string>();
 5234        var number = snapshot.ExternalOrderNumber ?? state.ExternalOrderNumber;
 235
 5236        if (string.IsNullOrWhiteSpace(number))
 237        {
 0238            problems.Add("Поставщик не вернул номер заказа");
 0239            checks.Add("✖ Номер заказа отсутствует");
 240        }
 5241        else if (!string.IsNullOrWhiteSpace(state.ExternalOrderNumber)
 5242                 && !string.IsNullOrWhiteSpace(snapshot.ExternalOrderNumber)
 5243                 && !string.Equals(state.ExternalOrderNumber.Trim(), snapshot.ExternalOrderNumber.Trim(), StringComparis
 244        {
 1245            problems.Add($"Номер заказа не совпал: в Studio2 {state.ExternalOrderNumber}, у поставщика {snapshot.Externa
 1246            checks.Add($"✖ Номер заказа: Studio2 {state.ExternalOrderNumber}, поставщик {snapshot.ExternalOrderNumber}")
 247        }
 248        else
 249        {
 4250            checks.Add($"✔ Номер заказа у поставщика: {number}");
 251        }
 252
 5253        if (snapshot.QuantityRejected is > 0)
 254        {
 1255            problems.Add($"Поставщик отказал по количеству: {snapshot.QuantityRejected:0.##}");
 1256            checks.Add($"✖ Отказ по количеству: {snapshot.QuantityRejected:0.##}");
 257        }
 4258        else if (quantityOrdered is > 0 && snapshot.QuantityConfirmed is { } confirmed && confirmed < quantityOrdered)
 259        {
 1260            problems.Add($"Подтверждено {confirmed:0.##} из {quantityOrdered:0.##}");
 1261            checks.Add($"✖ Подтверждено {confirmed:0.##} из {quantityOrdered:0.##}");
 262        }
 3263        else if (snapshot.QuantityConfirmed is { } ok)
 264        {
 0265            checks.Add($"✔ Подтверждённое количество: {ok:0.##}");
 266        }
 267
 5268        var status = problems.Count == 0 ? SupplierVerificationStatus.Confirmed : SupplierVerificationStatus.Mismatch;
 5269        var summary = problems.Count == 0
 5270            ? $"Заказ {number} подтверждён у поставщика."
 5271            : string.Join("; ", problems) + ".";
 5272        return row(status, summary, checks, number, snapshot.StatusName);
 273    }
 274
 275    private async Task WriteHistoryAsync(IReadOnlyList<SupplierVerificationRow> rows, CancellationToken ct)
 276    {
 11277        var added = new List<OrderHistory>(rows.Count);
 278        try
 279        {
 46280            foreach (var row in rows)
 281            {
 12282                var entry = new OrderHistory
 12283                {
 12284                    OrderId = row.OrderId,
 12285                    ColumnName = SupplierVerificationHistoryEntry.HistoryColumnName,
 12286                    ColumnDisplayName = $"Проверка заказа у поставщика · {row.ProviderName}",
 12287                    NewValue = SupplierVerificationHistoryEntry.FromRow(row).ToJson(),
 12288                    ChangedAt = DateTime.Now,
 12289                    ChangedBy = SupplierVerificationHistoryEntry.SystemAuthor
 12290                };
 12291                added.Add(entry);
 12292                _db.OrderHistories.Add(entry);
 293            }
 294
 11295            await _db.SaveChangesAsync(ct);
 11296        }
 0297        catch (Exception ex) when (ex is not OperationCanceledException)
 298        {
 0299            _logger.LogWarning(ex, "Не удалось записать проверку поставщика в историю ({Count} записей)", rows.Count);
 300            // Незавершённые записи не должны остаться в трекере и сломать следующий SaveChanges в этом скоупе.
 0301            foreach (var entry in added)
 0302                _db.Entry(entry).State = EntityState.Detached;
 0303        }
 11304    }
 305
 62306    private sealed record OrderInfo(int Id, string? ShipmentNumber, string? Article, string? ProductName);
 307
 308    private sealed class FetchOutcome
 309    {
 32310        public IReadOnlyList<SupplierStatusSnapshot> Snapshots { get; private init; } = [];
 18311        public string? Error { get; private init; }
 6312        public bool NotVerifiable { get; private init; }
 313
 10314        public static FetchOutcome Ok(IReadOnlyList<SupplierStatusSnapshot> snapshots) => new() { Snapshots = snapshots 
 315
 316        public static FetchOutcome Failed(string error, bool notVerifiable = false) =>
 2317            new() { Error = error, NotVerifiable = notVerifiable };
 318    }
 319}