< Summary

Information
Class: OzonOrdersWeb.WebServices.SystemAlerts.OzonOrderRelevance
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/SupplierOrderAlertSources.cs
Line coverage
100%
Covered lines: 16
Uncovered lines: 0
Coverable lines: 16
Total lines: 401
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
NotHandledByOzon(...)100%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/SupplierOrderAlertSources.cs

#LineLine coverage
 1using System.Linq.Expressions;
 2using Microsoft.EntityFrameworkCore;
 3using Microsoft.Extensions.Options;
 4using OzonDomains.Models;
 5using OzonDomains.Models.SupplierStatuses;
 6using OzonRepositories.Context;
 7
 8namespace OzonOrdersWeb.WebServices.SystemAlerts;
 9
 10public enum SupplierStatusOutcome
 11{
 12    Other,
 13    Rejected,
 14    Delivered
 15}
 16
 17/// <summary>
 18/// В справочнике статусов поставщиков нет признака «отказ»: статусы приходят из API как есть
 19/// (у ML-Auto — просто «Код 6»). Классификация идёт по коду статуса (точно) и по названию (по ключевым словам).
 20/// </summary>
 21public static class SupplierStatusClassifier
 22{
 23    public static SupplierStatusOutcome Classify(string providerKey, string? externalCode, string? name, SystemAlertsOpt
 24    {
 25        if (!string.IsNullOrWhiteSpace(externalCode))
 26        {
 27            if (settings.SupplierRejectedStatusCodes.TryGetValue(providerKey, out var rejected) &&
 28                rejected.Contains(externalCode, StringComparer.OrdinalIgnoreCase))
 29                return SupplierStatusOutcome.Rejected;
 30            if (settings.SupplierDeliveredStatusCodes.TryGetValue(providerKey, out var delivered) &&
 31                delivered.Contains(externalCode, StringComparer.OrdinalIgnoreCase))
 32                return SupplierStatusOutcome.Delivered;
 33        }
 34
 35        var text = $"{name} {externalCode}".ToLowerInvariant();
 36        if (settings.SupplierRejectedKeywords.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase)) &&
 37            !settings.SupplierRejectedExcludeKeywords.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase)))
 38            return SupplierStatusOutcome.Rejected;
 39        return settings.SupplierDeliveredKeywords.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase))
 40            ? SupplierStatusOutcome.Delivered
 41            : SupplierStatusOutcome.Other;
 42    }
 43}
 44
 45/// <summary>
 46/// Исход заказов у API-поставщиков: не оформился, поставщик отказал, статус неизвестен, просрочка поставки,
 47/// расхождение с отменой в Ozon, двойной заказ. Анализируются только свежие заказы (<see cref="SystemAlertsOptions.Supp
 48/// </summary>
 49public sealed class SupplierOrdersSystemAlertSource(ApplicationDbContext context, IOptions<SystemAlertsOptions> options)
 50{
 51    private const int MaxOrdersInLine = 8;
 52
 53    /// <summary>
 54    /// Состояния заказов у поставщиков, по которым ещё нужны действия. Заказ, который в Ozon уже доставлен или
 55    /// отменён, считается обработанным: ошибки поставщика по нему не показываем.
 56    /// </summary>
 57    private IQueryable<OrderSupplierState> ActiveStates(SystemAlertsOptions settings, bool excludeCancelled = true) =>
 58        context.OrderSupplierStates.AsNoTracking().Where(OzonOrderRelevance.NotHandledByOzon(settings, excludeCancelled)
 59
 60    public async Task<IReadOnlyList<SystemAlertFinding>> CollectAsync(CancellationToken cancellationToken = default)
 61    {
 62        var settings = options.Value;
 63        if (!settings.SupplierChecksEnabled) return [];
 64
 65        var now = DateTime.UtcNow;
 66        var maxAgeSince = now.AddDays(-Math.Max(1, settings.SupplierOrderMaxAgeDays));
 67        var statuses = await LoadStatusesAsync(settings, cancellationToken);
 68        var Provider = await LoadProviderNamesAsync(cancellationToken);
 69
 70        var findings = new List<SystemAlertFinding>();
 71        await AddCartProblemsAsync(findings, settings, now, maxAgeSince, statuses, Provider, cancellationToken);
 72        await AddRejectedAsync(findings, settings, now, statuses, Provider, cancellationToken);
 73        await AddBlindAsync(findings, settings, now, Provider, cancellationToken);
 74        await AddOverdueAndStaleAsync(findings, settings, now, maxAgeSince, statuses, Provider, cancellationToken);
 75        await AddOzonCancelledAsync(findings, settings, now, statuses, Provider, cancellationToken);
 76        await AddDoubleOrdersAsync(findings, settings, maxAgeSince, statuses, Provider, cancellationToken);
 77        return findings;
 78    }
 79
 80    /// <summary>
 81    /// Заказы алерта <c>ozon-cancelled-supplier-live</c> построчно: отправление отменено в Ozon, а заказ у поставщика н
 82    /// Источник для автоотмены у поставщика (<see cref="OzonCancelledSupplierOrderHandler"/>).
 83    /// </summary>
 84    public async Task<IReadOnlyList<OzonCancelledSupplierOrder>> FindOzonCancelledLiveAsync(CancellationToken cancellati
 85    {
 86        var settings = options.Value;
 87        if (!settings.SupplierChecksEnabled) return [];
 88        var statuses = await LoadStatusesAsync(settings, cancellationToken);
 89        var provider = await LoadProviderNamesAsync(cancellationToken);
 90        return await FindOzonCancelledLiveAsync(settings, DateTime.UtcNow, statuses, provider, cancellationToken);
 91    }
 92
 93    private async Task<Func<string, string>> LoadProviderNamesAsync(CancellationToken cancellationToken)
 94    {
 95        var names = await context.ApiSupplierSettings.AsNoTracking()
 96            .Where(x => x.DisplayName != null)
 97            .ToDictionaryAsync(x => x.ProviderKey, x => x.DisplayName!, StringComparer.OrdinalIgnoreCase, cancellationTo
 98        return key => names.TryGetValue(key, out var display) && !string.IsNullOrWhiteSpace(display) ? display : key;
 99    }
 100
 101    private async Task<List<OzonCancelledSupplierOrder>> FindOzonCancelledLiveAsync(SystemAlertsOptions settings, DateTi
 102        StatusCatalog statuses, Func<string, string> provider, CancellationToken ct)
 103    {
 104        var since = now.AddHours(-settings.WindowHoursFor("ozon-cancelled-supplier-live"));
 105        var rejectedIds = statuses.Rejected;
 106        var rows = await (from link in context.OzonPostingCancellationOrders.AsNoTracking()
 107                          join state in ActiveStates(settings, excludeCancelled: false) on link.OrderId equals state.Ord
 108                          where link.Cancellation.OccurredAt >= since && state.Stage == OrderSupplierStage.Ordered
 109                                && (state.SupplierStatusId == null || !rejectedIds.Contains(state.SupplierStatusId.Value
 110                          select new
 111                          {
 112                              state.Id, state.OrderId, state.ProviderKey, state.SupplierStatusId, state.ExternalOrderNum
 113                              state.ExternalLineId, state.OrderedAt, CancelledAt = link.Cancellation.OccurredAt,
 114                              ShipmentNumber = state.Order != null ? state.Order.ShipmentNumber : null,
 115                              SupplierName = state.Order != null && state.Order.Supplier != null ? state.Order.Supplier.
 116                              AcceptedAt = state.Order != null ? state.Order.ProcessingDate : null,
 117                              Article = state.Order != null ? state.Order.Article : null,
 118                              ProductName = state.Order != null ? state.Order.ProductName : null,
 119                              Quantity = state.Order != null ? state.Order.Quantity : null,
 120                              PurchasePrice = state.Order != null ? state.Order.PurchasePrice : null
 121                          }).ToListAsync(ct);
 122        // Одно отправление может отменяться несколькими событиями: одна строка на состояние, дата — последней отмены.
 123        return rows.GroupBy(x => x.Id).Select(g => g.OrderByDescending(x => x.CancelledAt).First())
 124            .OrderByDescending(x => x.OrderId)
 125            .Select(x => new OzonCancelledSupplierOrder(x.OrderId, x.ShipmentNumber, x.ProviderKey, provider(x.ProviderK
 126                statuses.NameOf(x.SupplierStatusId), statuses.IsDelivered(x.SupplierStatusId),
 127                x.ExternalOrderNumber, x.ExternalOrderId, x.ExternalLineId)
 128            {
 129                SupplierName = x.SupplierName, AcceptedAt = x.AcceptedAt, OrderedAt = x.OrderedAt, CancelledAt = x.Cance
 130                Article = x.Article, ProductName = x.ProductName, Quantity = x.Quantity, PurchasePrice = x.PurchasePrice
 131            })
 132            .ToList();
 133    }
 134
 135    private sealed record StatusInfo(int Id, string ProviderKey, string Kind, string? Code, string Name, bool IsFinal, S
 136
 137    private sealed class StatusCatalog(List<StatusInfo> all)
 138    {
 139        public Dictionary<int, StatusInfo> ById { get; } = all.ToDictionary(x => x.Id);
 140        public List<int> Rejected { get; } = all.Where(x => x.Kind == SupplierStatusKind.Order && x.Outcome == SupplierS
 141        public List<int> Delivered { get; } = all.Where(x => x.Kind == SupplierStatusKind.Order && x.Outcome == Supplier
 142        public List<int> Final { get; } = all.Where(x => x.IsFinal).Select(x => x.Id).ToList();
 143        public List<int> CartFailed { get; } = all.Where(x => x.Kind == SupplierStatusKind.Cart && x.Code == SupplierCar
 144
 145        public string NameOf(int? id) => id.HasValue && ById.TryGetValue(id.Value, out var s) ? s.Name : "статус неизвес
 146        public bool IsDelivered(int? id) => id.HasValue && ById.TryGetValue(id.Value, out var s) && s.Outcome == Supplie
 147    }
 148
 149    private async Task<StatusCatalog> LoadStatusesAsync(SystemAlertsOptions settings, CancellationToken cancellationToke
 150    {
 151        var rows = await context.SupplierStatuses.AsNoTracking()
 152            .Select(x => new { x.Id, x.ProviderKey, x.StatusKind, x.ExternalCode, x.Name, x.IsFinal })
 153            .ToListAsync(cancellationToken);
 154        return new StatusCatalog(rows.Select(x => new StatusInfo(x.Id, x.ProviderKey, x.StatusKind, x.ExternalCode, x.Na
 155            x.StatusKind == SupplierStatusKind.Order
 156                ? SupplierStatusClassifier.Classify(x.ProviderKey, x.ExternalCode, x.Name, settings)
 157                : SupplierStatusOutcome.Other)).ToList());
 158    }
 159
 160    /// <summary>1. Не оформился: позиции корзины в Failed, ошибки опроса корзины и «залежавшиеся» позиции.</summary>
 161    private async Task AddCartProblemsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime no
 162        DateTime maxAgeSince, StatusCatalog statuses, Func<string, string> provider, CancellationToken ct)
 163    {
 164        var failedSince = now.AddHours(-settings.WindowHoursFor("supplier-cart-failed"));
 165        var failedIds = statuses.CartFailed;
 166        var failed = await ActiveStates(settings)
 167            .Where(x => x.Stage == OrderSupplierStage.Cart && x.UpdatedAt >= failedSince
 168                        && ((x.SupplierStatusId != null && failedIds.Contains(x.SupplierStatusId.Value)) || x.SyncError 
 169            .Select(x => new { x.OrderId, x.ProviderKey, x.SyncError })
 170            .ToListAsync(ct);
 171        if (failed.Count > 0)
 172            findings.Add(Grouped("supplier-cart-failed", "suppliers", Severity(failed.Count, settings.DangerFailureCount
 173                "Не удалось оформить заказ у поставщика: позиции в корзине с ошибкой",
 174                failed.Select(x => (x.OrderId, x.ProviderKey, x.SyncError)), provider, "Ошибка оформления"));
 175
 176        var staleBefore = now.AddHours(-Math.Max(1, settings.SupplierCartStaleHours));
 177        var stale = await ActiveStates(settings)
 178            .Where(x => x.Stage == OrderSupplierStage.Cart && x.CreatedAt >= maxAgeSince && x.CreatedAt < staleBefore
 179                        && (x.SupplierStatusId == null || !failedIds.Contains(x.SupplierStatusId.Value)) && x.SyncError 
 180                        && (x.SupplierStatusId == null || !statuses.Final.Contains(x.SupplierStatusId.Value)))
 181            .Select(x => new { x.OrderId, x.ProviderKey })
 182            .ToListAsync(ct);
 183        if (stale.Count > 0)
 184            findings.Add(Grouped("supplier-cart-stale", "suppliers", "warning",
 185                $"Позиции лежат в корзине поставщика и не заказаны дольше {settings.SupplierCartStaleHours} ч",
 186                stale.Select(x => (x.OrderId, x.ProviderKey, (string?)null)), provider, "Лежит в корзине без заказа"));
 187    }
 188
 189    /// <summary>2. Поставщик не смог продать — по одному алерту на поставщика, чтобы заглушать их независимо.</summary>
 190    private async Task AddRejectedAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime now,
 191        StatusCatalog statuses, Func<string, string> provider, CancellationToken ct)
 192    {
 193        var since = now.AddHours(-settings.WindowHoursFor("supplier-rejected"));
 194        var rejectedIds = statuses.Rejected;
 195        var rows = await ActiveStates(settings)
 196            .Where(x => x.Stage != OrderSupplierStage.Cart && (x.LastStatusChangeAt ?? x.UpdatedAt) >= since
 197                        && ((x.SupplierStatusId != null && rejectedIds.Contains(x.SupplierStatusId.Value))
 198                            || x.QuantityRejected > 0
 199                            || (x.Stage == OrderSupplierStage.Closed && x.QuantityConfirmed != null && x.QuantityConfirm
 200            // Уже перезаказан у другого поставщика: отказ отработан.
 201            .Where(x => !context.OrderSupplierStates.Any(o => o.OrderId == x.OrderId && o.Id != x.Id && o.Stage != Order
 202                                                              && o.CreatedAt >= x.CreatedAt
 203                                                              && (o.SupplierStatusId == null || !rejectedIds.Contains(o.
 204            // Отправление отменено в Ozon: переставлять заказ некуда, это случай ozon-cancelled-supplier-live.
 205            .Where(x => !context.OzonPostingCancellationOrders.Any(c => c.OrderId == x.OrderId))
 206            .Where(x => x.Order == null || x.Order.AppStatus == null || !x.Order.AppStatus.IsFinal)
 207            .Select(x => new { x.OrderId, x.ProviderKey, x.SupplierStatusId })
 208            .ToListAsync(ct);
 209
 210        foreach (var group in rows.GroupBy(x => x.ProviderKey, StringComparer.OrdinalIgnoreCase))
 211        {
 212            var orderIds = group.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList();
 213            var reasons = group.GroupBy(x => statuses.NameOf(x.SupplierStatusId)).OrderByDescending(x => x.Count())
 214                .Take(3).Select(x => $"{x.Key} ({x.Count()})");
 215            findings.Add(new SystemAlertFinding(
 216                "supplier-rejected-" + group.Key.ToLowerInvariant(), "suppliers",
 217                Severity(orderIds.Count, settings.SupplierRejectedDangerCount),
 218                $"Поставщик {provider(group.Key)} не смог выполнить заказы: {orderIds.Count}",
 219                [$"Заказы: {OrderList(orderIds)}", "Причины: " + string.Join("; ", reasons), "Нужно перезаказать у друго
 220                OrderUrl(orderIds),
 221                Orders: SystemAlertOrderRef.Build(group.Select(x => (x.OrderId,
 222                    $"{provider(group.Key)}: {statuses.NameOf(x.SupplierStatusId)}"
 223                    + (x.SupplierStatusId.HasValue && rejectedIds.Contains(x.SupplierStatusId.Value) ? string.Empty : " 
 224        }
 225    }
 226
 227    /// <summary>3. Слепая зона: заказ у поставщика есть, а статус мы получить не можем.</summary>
 228    private async Task AddBlindAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime now,
 229        Func<string, string> provider, CancellationToken ct)
 230    {
 231        var since = now.AddHours(-settings.WindowHoursFor("supplier-sync-blind"));
 232        var rows = await ActiveStates(settings)
 233            .Where(x => x.Stage == OrderSupplierStage.Ordered && x.SyncError != null && x.LastSyncedAt >= since)
 234            .Select(x => new { x.OrderId, x.ProviderKey, x.SyncError })
 235            .ToListAsync(ct);
 236        // Если сам API поставщика отвечал ошибками (авторизация, лимиты, 5xx) — первоисточник это аудит API, иначе — за
 237        var providersWithApiErrors = (await context.ApiSupplierRequestAuditEntries.AsNoTracking()
 238            .Where(x => !x.IsSuccess && x.OccurredAtUtc >= since).Select(x => x.ProviderKey).Distinct().ToListAsync(ct))
 239            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 240
 241        foreach (var group in rows.GroupBy(x => x.ProviderKey, StringComparer.OrdinalIgnoreCase))
 242        {
 243            var orderIds = group.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList();
 244            var topError = group.GroupBy(x => Shorten(x.SyncError)).OrderByDescending(x => x.Count()).First().Key;
 245            findings.Add(new SystemAlertFinding(
 246                "supplier-sync-blind-" + group.Key.ToLowerInvariant(), "suppliers",
 247                Severity(orderIds.Count, settings.SupplierBlindDangerCount),
 248                $"Не получаем статусы заказов у поставщика {provider(group.Key)}: {orderIds.Count}",
 249                [$"Заказы: {OrderList(orderIds)}", "Ошибка: " + topError],
 250                providersWithApiErrors.Contains(group.Key) ? SystemAlertLinks.ApiAuditFailures(group.Key) : OrderUrl(ord
 251                Orders: SystemAlertOrderRef.Build(group.Select(x => (x.OrderId, $"{provider(group.Key)}: {Shorten(x.Sync
 252        }
 253    }
 254
 255    /// <summary>4. Просрочка поставки и статус, который давно не менялся.</summary>
 256    private async Task AddOverdueAndStaleAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime
 257        DateTime maxAgeSince, StatusCatalog statuses, Func<string, string> provider, CancellationToken ct)
 258    {
 259        var closedIds = statuses.Rejected.Concat(statuses.Delivered).Concat(statuses.Final).Distinct().ToList();
 260        var open = ActiveStates(settings)
 261            .Where(x => x.Stage == OrderSupplierStage.Ordered && (x.OrderedAt ?? x.CreatedAt) >= maxAgeSince
 262                        && (x.SupplierStatusId == null || !closedIds.Contains(x.SupplierStatusId.Value))
 263                        && x.SyncError == null);
 264
 265        var overdueBefore = now.AddHours(-Math.Max(1, settings.SupplierDeliveryGraceHours));
 266        var overdue = await open.Where(x => x.ExpectedDeliveryAt != null && x.ExpectedDeliveryAt < overdueBefore)
 267            .Select(x => new { x.OrderId, x.ProviderKey }).ToListAsync(ct);
 268        if (overdue.Count > 0)
 269            findings.Add(Grouped("supplier-delivery-overdue", "suppliers", "warning", "Поставка у поставщика просрочена"
 270                overdue.Select(x => (x.OrderId, x.ProviderKey, (string?)null)), provider, "Поставка просрочена", orderSe
 271
 272        var staleBefore = now.AddDays(-Math.Max(1, settings.SupplierStatusStaleDays));
 273        var stale = await open.Where(x => (x.LastStatusChangeAt ?? x.OrderedAt ?? x.CreatedAt) < staleBefore
 274                                          && (x.ExpectedDeliveryAt == null || x.ExpectedDeliveryAt >= overdueBefore))
 275            .Select(x => new { x.OrderId, x.ProviderKey }).ToListAsync(ct);
 276        if (stale.Count > 0)
 277            findings.Add(Grouped("supplier-status-stale", "suppliers", "warning",
 278                $"Статус заказа у поставщика не менялся дольше {settings.SupplierStatusStaleDays} дн.",
 279                stale.Select(x => (x.OrderId, x.ProviderKey, (string?)null)), provider, "Статус не менялся", orderSeveri
 280    }
 281
 282    /// <summary>5. Отправление отменено в Ozon, а заказ у поставщика ещё жив — его нужно отменить (или оформить возврат
 283    private async Task AddOzonCancelledAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime n
 284        StatusCatalog statuses, Func<string, string> provider, CancellationToken ct)
 285    {
 286        var rows = await FindOzonCancelledLiveAsync(settings, now, statuses, provider, ct);
 287        if (rows.Count == 0) return;
 288
 289        var orderIds = rows.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList();
 290        var lines = rows.Take(MaxOrdersInLine)
 291            .Select(x => $"#{x.OrderId} — {x.ProviderName}: {x.StatusName}" + (x.Delivered ? " (уже отгружен — нужен воз
 292            .ToList();
 293        if (orderIds.Count > MaxOrdersInLine) lines.Add($"…и ещё {orderIds.Count - MaxOrdersInLine}");
 294        findings.Add(new SystemAlertFinding("ozon-cancelled-supplier-live", "suppliers", "danger",
 295            $"Отправление отменено в Ozon, а заказ у поставщика активен: {orderIds.Count}", lines, OrderUrl(orderIds),
 296            Orders: SystemAlertOrderRef.Build(rows.Select(x => (x.OrderId,
 297                $"Отменён в Ozon, у {x.ProviderName} активен: {x.StatusName}" + (x.Delivered ? " — нужен возврат" : stri
 298    }
 299
 300    /// <summary>6. Один заказ Studio2 оформлен у двух поставщиков одновременно (перезаказ после отказа не считается).</
 301    private async Task AddDoubleOrdersAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime ma
 302        Func<string, string> provider, CancellationToken ct)
 303    {
 304        var rejectedIds = statuses.Rejected;
 305        var live = await ActiveStates(settings)
 306            .Where(x => x.Stage == OrderSupplierStage.Ordered && (x.OrderedAt ?? x.CreatedAt) >= maxAgeSince
 307                        && (x.SupplierStatusId == null || !rejectedIds.Contains(x.SupplierStatusId.Value)))
 308            .Select(x => new { x.OrderId, x.ProviderKey })
 309            .ToListAsync(ct);
 310        var doubles = live.GroupBy(x => x.OrderId)
 311            .Where(g => g.Select(x => x.ProviderKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
 312            .OrderByDescending(g => g.Key).ToList();
 313        if (doubles.Count == 0) return;
 314
 315        var lines = doubles.Take(MaxOrdersInLine)
 316            .Select(g => $"#{g.Key}: " + string.Join(", ", g.Select(x => provider(x.ProviderKey)).Distinct()))
 317            .ToList();
 318        if (doubles.Count > MaxOrdersInLine) lines.Add($"…и ещё {doubles.Count - MaxOrdersInLine}");
 319        findings.Add(new SystemAlertFinding("supplier-double-order", "suppliers", "danger",
 320            $"Заказ оформлен у нескольких поставщиков одновременно: {doubles.Count}", lines, OrderUrl(doubles.Select(g =
 321            Orders: SystemAlertOrderRef.Build(doubles.Select(g => (g.Key,
 322                "Оформлен сразу у: " + string.Join(", ", g.Select(x => provider(x.ProviderKey)).Distinct()))))));
 323    }
 324
 325    private static SystemAlertFinding Grouped(string key, string domain, string severity, string title,
 326        IEnumerable<(int OrderId, string ProviderKey, string? Error)> rows, Func<string, string> provider, string reason
 327        string? orderSeverity = null)
 328    {
 329        var list = rows.ToList();
 330        var lines = list.GroupBy(x => x.ProviderKey, StringComparer.OrdinalIgnoreCase).OrderByDescending(g => g.Count())
 331            .Select(g =>
 332            {
 333                var ids = g.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList();
 334                var error = g.Select(x => x.Error).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
 335                return $"{provider(g.Key)}: {ids.Count} — {OrderList(ids)}" + (error == null ? string.Empty : $" ({Short
 336            }).ToList();
 337        return new SystemAlertFinding(key, domain, severity, title, lines, OrderUrl(list.Select(x => x.OrderId).Distinct
 338            Orders: SystemAlertOrderRef.Build(list.Select(x => (x.OrderId,
 339                $"{reasonLabel}: {provider(x.ProviderKey)}" + (string.IsNullOrWhiteSpace(x.Error) ? string.Empty : $" — 
 340    }
 341
 342    private static string Severity(int count, int dangerThreshold) => count >= Math.Max(1, dangerThreshold) ? "danger" :
 343
 344    private static string OrderList(IReadOnlyList<int> ids) =>
 345        string.Join(", ", ids.Take(MaxOrdersInLine).Select(x => "#" + x)) + (ids.Count > MaxOrdersInLine ? $" …(+{ids.Co
 346
 347    /// <summary>Ссылка ведёт в сетку заказов, ограниченную затронутыми заказами.</summary>
 348    private static string OrderUrl(IReadOnlyList<int> ids) => SystemAlertLinks.Orders(ids);
 349
 350    private static string Shorten(string? text) =>
 351        string.IsNullOrWhiteSpace(text) ? "—" : text.Length <= 120 ? text.Trim() : text[..119] + "…";
 352}
 353
 354/// <summary>Заказ Studio2, отменённый в Ozon, при неотказанном заказе у поставщика.</summary>
 355/// <param name="Delivered">Поставщик уже отгрузил (статус классифицирован как выполненный) — отменять поздно, нужен воз
 356public sealed record OzonCancelledSupplierOrder(int OrderId, string? ShipmentNumber, string ProviderKey, string Provider
 357    string StatusName, bool Delivered, string? ExternalOrderNumber, string? ExternalOrderId, string? ExternalLineId)
 358{
 359    /// <summary>Поставщик заказа Studio2 (справочник поставщиков), если отличается от имени провайдера API.</summary>
 360    public string? SupplierName { get; init; }
 361    public DateTime? AcceptedAt { get; init; }
 362    /// <summary>Когда заказ оформлен у поставщика (UTC).</summary>
 363    public DateTime? OrderedAt { get; init; }
 364    /// <summary>Когда пришла отмена отправления из Ozon (UTC).</summary>
 365    public DateTime? CancelledAt { get; init; }
 366    public string? Article { get; init; }
 367    public string? ProductName { get; init; }
 368    public int? Quantity { get; init; }
 369    public decimal? PurchasePrice { get; init; }
 370}
 371
 372/// <summary>Условие «заказ в Ozon ещё не обработан»: не доставлен и (опционально) не отменён.</summary>
 373public static class OzonOrderRelevance
 374{
 1375    private static readonly System.Reflection.MethodInfo StringContains =
 1376        typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!;
 377
 378    /// <summary>
 379    /// <c>x =&gt; x.Order == null || x.Order.ClientStatus == null || !(ClientStatus.IsFinal || имя содержит основу «дос
 380    /// [|| «отмен»])</c>. Собрано деревом выражений, чтобы переводилось в SQL и принимало набор ключевых слов из настро
 381    /// </summary>
 382    public static Expression<Func<OrderSupplierState, bool>> NotHandledByOzon(SystemAlertsOptions settings, bool exclude
 383    {
 146384        var state = Expression.Parameter(typeof(OrderSupplierState), "x");
 146385        var order = Expression.Property(state, nameof(OrderSupplierState.Order));
 146386        var status = Expression.Property(order, nameof(Order.ClientStatus));
 146387        var name = Expression.Property(status, nameof(ClientStatus.Name));
 388
 146389        Expression handled = Expression.Property(status, nameof(ClientStatus.IsFinal));
 146390        var stems = excludeCancelled ? settings.OzonDeliveredStatusKeywords.Concat(settings.OzonCancelledStatusKeywords)
 1966391        foreach (var stem in stems.Where(x => !string.IsNullOrWhiteSpace(x)))
 558392            handled = Expression.OrElse(handled, Expression.Call(name, StringContains, Expression.Constant(stem)));
 393
 146394        var body = Expression.OrElse(
 146395            Expression.Equal(order, Expression.Constant(null, typeof(Order))),
 146396            Expression.OrElse(
 146397                Expression.Equal(status, Expression.Constant(null, typeof(ClientStatus))),
 146398                Expression.Not(handled)));
 146399        return Expression.Lambda<Func<OrderSupplierState, bool>>(body, state);
 400    }
 401}