< Summary

Information
Class: OzonOrdersWeb.WebServices.SystemAlerts.OrderOperationsSystemAlertSource
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/OperationsAlertSources.cs
Line coverage
100%
Covered lines: 122
Uncovered lines: 0
Coverable lines: 122
Total lines: 347
Line coverage: 100%
Branch coverage
80%
Covered branches: 29
Total branches: 36
Branch coverage: 80.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
CollectAsync()50%22100%
AddShippingDeadlinesAsync()75%44100%
AddCancellationSpikeAsync()83.33%66100%
AddReturnSyncFailuresAsync()75%88100%
AddNullResetsAsync()75%44100%
AddPostedCostProblemsAsync()87.5%88100%
GetDatabaseNowAsync()100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Caching.Memory;
 3using Microsoft.Extensions.Options;
 4using Newtonsoft.Json.Linq;
 5using OzonDomains.Models;
 6using OzonRepositories.Context;
 7using Services.ApiServices._1CApi.OData;
 8using Services.BackgroundJobsServices;
 9
 10namespace OzonOrdersWeb.WebServices.SystemAlerts;
 11
 12/// <summary>
 13/// Контроль заказов Ozon: дедлайн отгрузки без обработки, всплеск отмен, сбои синхронизации возвратов,
 14/// сброс ключевых полей заказа в NULL и «потерянный» расчёт себестоимости у проведённых заказов.
 15/// </summary>
 1016public sealed class OrderOperationsSystemAlertSource(ApplicationDbContext context, IOptions<SystemAlertsOptions> options
 17{
 18    private const string AlertsUrl = "/Studio2/JobLogs?tab=alerts";
 19    private const int MaxOrdersInLine = 8;
 120    private static readonly string[] NullResetColumns = ["OzonClientId", "SupplierId", "ShipmentWarehouseId", "AppStatus
 21
 22    public async Task<IReadOnlyList<SystemAlertFinding>> CollectAsync(CancellationToken cancellationToken = default)
 23    {
 1024        var settings = options.Value;
 1025        if (!settings.OrderChecksEnabled) return [];
 26
 1027        var now = DateTime.UtcNow;
 1028        var findings = new List<SystemAlertFinding>();
 1029        await AddShippingDeadlinesAsync(findings, settings, now, cancellationToken);
 1030        await AddCancellationSpikeAsync(findings, settings, now, cancellationToken);
 1031        await AddReturnSyncFailuresAsync(findings, settings, now, cancellationToken);
 1032        await AddNullResetsAsync(findings, settings, cancellationToken);
 1033        await AddPostedCostProblemsAsync(findings, settings, now, cancellationToken);
 1034        return findings;
 1035    }
 36
 37    /// <summary>
 38    /// 7. Дедлайн отгрузки (<c>ShippingDate</c> хранится в московском времени) скоро или уже прошёл, а заказ
 39    /// не обработан: нет статуса приложения и ни одного заказа у поставщика.
 40    /// </summary>
 41    private async Task AddShippingDeadlinesAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTi
 42    {
 1043        var nowMsk = TimeZoneInfo.ConvertTimeFromUtc(now, RussianTimeZoneHelper.Zone);
 1044        var from = nowMsk.AddHours(-Math.Max(1, settings.SlaOverdueMaxHours));
 1045        var to = nowMsk.AddHours(Math.Max(1, settings.SlaWarningHours));
 1046        var unhandled = settings.SlaUnhandledAppStatusNames;
 47
 1048        var rows = await context.Set<Order>().AsNoTracking()
 1049            .Where(x => x.ShippingDate != null && x.ShippingDate >= from && x.ShippingDate <= to)
 1050            .Where(x => x.AppStatusId == null || (x.AppStatus != null && unhandled.Contains(x.AppStatus.Name)))
 1051            .Where(x => x.ClientStatus == null || (!x.ClientStatus.IsFinal && !x.ClientStatus.Name.Contains("тмен")
 1052                                                   && !x.ClientStatus.Name.Contains("оставлен") && !x.ClientStatus.Name.
 1053                                                   && !x.ClientStatus.Name.Contains("ыполнен")))
 1054            .Where(x => !context.OrderSupplierStates.Any(s => s.OrderId == x.Id && s.Stage != OzonDomains.Models.Supplie
 1055            .Select(x => new { x.Id, ShippingDate = x.ShippingDate!.Value })
 1056            .ToListAsync(ct);
 1857        if (rows.Count == 0) return;
 58
 259        var dangerBefore = nowMsk.AddHours(Math.Max(0, settings.SlaDangerHours));
 560        var overdue = rows.Count(x => x.ShippingDate < nowMsk);
 561        var urgent = rows.Count(x => x.ShippingDate >= nowMsk && x.ShippingDate <= dangerBefore);
 262        var severity = overdue + urgent > 0 ? "danger" : "warning";
 563        var soonest = rows.OrderBy(x => x.ShippingDate).ToList();
 264        var lines = new List<string>
 265        {
 266            $"Просрочено: {overdue}; дедлайн в ближайшие {settings.SlaDangerHours} ч: {urgent}; в ближайшие {settings.Sl
 367            "Ближайшие: " + string.Join(", ", soonest.Take(MaxOrdersInLine).Select(x => $"#{x.Id} ({x.ShippingDate:dd.MM
 268        };
 269        findings.Add(new SystemAlertFinding("orders-shipping-deadline", "orders", severity,
 270            $"Дедлайн отгрузки близко, а заказ не обработан: {rows.Count}", lines,
 371            SystemAlertLinks.Orders(rows.Select(x => x.Id)), Debounced: true,
 572            Orders: SystemAlertOrderRef.Build(rows.Select(x => (x.Id,
 573                $"Дедлайн отгрузки {x.ShippingDate:dd.MM HH:mm}" + (x.ShippingDate < nowMsk ? " — просрочен" : string.Em
 1074    }
 75
 76    /// <summary>8а. Всплеск отмен за последний час относительно недельной нормы.</summary>
 77    private async Task AddCancellationSpikeAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTi
 78    {
 1079        var hourAgo = now.AddHours(-1);
 1080        var baselineFrom = now.AddDays(-7);
 1081        var recent = await context.OzonPostingCancellations.AsNoTracking().CountAsync(x => x.OccurredAt >= hourAgo, ct);
 1882        if (recent < Math.Max(1, settings.CancelSpikeMinCount)) return;
 83
 284        var earlier = await context.OzonPostingCancellations.AsNoTracking()
 285            .CountAsync(x => x.OccurredAt >= baselineFrom && x.OccurredAt < hourAgo, ct);
 286        var perHour = earlier / (7d * 24 - 1);
 387        if (recent < Math.Max(1, settings.CancelSpikeFactor) * Math.Max(perHour, 1)) return;
 88
 189        var cancelledOrders = await context.OzonPostingCancellationOrders.AsNoTracking()
 190            .Where(x => x.Cancellation.OccurredAt >= hourAgo).Select(x => x.OrderId).Distinct().ToListAsync(ct);
 191        findings.Add(new SystemAlertFinding("ozon-cancellations-spike", "orders", "warning",
 192            "Всплеск отмен отправлений в Ozon",
 193            [$"За последний час: {recent}; обычно около {perHour:0.#} в час (за 7 дней)"],
 194            cancelledOrders.Count > 0 ? SystemAlertLinks.Orders(cancelledOrders) : AlertsUrl, Debounced: true,
 195            Orders: SystemAlertOrderRef.Build(cancelledOrders.Select(x => (x, "Отменён в Ozon за последний час (всплеск 
 1096    }
 97
 98    /// <summary>8б. Сбои синхронизации возвратов: ошибки после последнего успешного запуска.</summary>
 99    private async Task AddReturnSyncFailuresAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT
 100    {
 10101        var since = now.AddHours(-settings.WindowHoursFor("order-returns-sync"));
 10102        var runs = context.OrderReturnSyncRuns.AsNoTracking().Where(x => x.StartedAtUtc >= since);
 10103        var lastSuccess = await runs.Where(x => x.Status == OrderReturnSyncRunStatus.Success)
 10104            .Select(x => (DateTime?)x.StartedAtUtc).MaxAsync(ct);
 10105        var failedRuns = runs.Where(x => x.Status == OrderReturnSyncRunStatus.Failed || x.Status == OrderReturnSyncRunSt
 12106        if (lastSuccess.HasValue) failedRuns = failedRuns.Where(x => x.StartedAtUtc > lastSuccess.Value);
 10107        var failed = await failedRuns.OrderByDescending(x => x.StartedAtUtc).Select(x => new { x.StartedAtUtc, x.ErrorMe
 19108        if (failed.Count == 0) return;
 109
 1110        findings.Add(new SystemAlertFinding("order-returns-sync", "orders",
 1111            failed.Count >= Math.Max(1, settings.DangerFailureCount) ? "danger" : "warning",
 1112            "Синхронизация возвратов Ozon завершается с ошибкой",
 1113            [$"Ошибок после последнего успешного запуска: {failed.Count}", "Последняя: " + (failed[0].ErrorMessage ?? "б
 1114            "/Studio2/OrderReturns"));
 10115    }
 116
 117    /// <summary>
 118    /// 9. Канарейка бага «обнуление полей»: триггер orders_history пишет NewValue='NULL', когда у заказа
 119    /// сбросились клиент Ozon, поставщик, склад или статус. Восстановление — навык restore-order-null-fields.
 120    /// </summary>
 121    private async Task AddNullResetsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, CancellationT
 122    {
 10123        var since = (await GetDatabaseNowAsync(ct)).AddHours(-settings.WindowHoursFor("orders-null-reset"));
 10124        var resets = await context.OrderHistories.AsNoTracking()
 10125            .Where(x => x.ChangedAt >= since && x.NewValue == "NULL" && x.OldValue != null && x.OldValue != "NULL"
 10126                        && NullResetColumns.Contains(x.ColumnName!))
 10127            .Select(x => new { x.OrderId, x.ColumnName })
 10128            .ToListAsync(ct);
 19129        if (resets.Count == 0) return;
 130
 7131        var orders = resets.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList();
 7132        var columns = resets.GroupBy(x => x.ColumnName).Select(x => $"{x.Key}: {x.Count()}");
 1133        findings.Add(new SystemAlertFinding("orders-null-reset", "orders",
 1134            orders.Count >= Math.Max(1, settings.NullResetDangerCount) ? "danger" : "warning",
 1135            $"У заказов сбросились ключевые поля в NULL: {orders.Count}",
 1136            ["Поля: " + string.Join("; ", columns),
 3137                "Заказы: " + string.Join(", ", orders.Take(MaxOrdersInLine).Select(x => "#" + x)),
 1138                "Восстановление: навык restore-order-null-fields"],
 1139            SystemAlertLinks.Orders(orders),
 4140            Orders: SystemAlertOrderRef.Build(resets.Select(x => (x.OrderId, "Сброшено в NULL: " + x.ColumnName)))));
 10141    }
 142
 143    /// <summary>10. Проведённые в 1С заказы без расчёта себестоимости и убыточные проведения.</summary>
 144    private async Task AddPostedCostProblemsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT
 145    {
 10146        var since = now.AddHours(-settings.WindowHoursFor("posted-cost-lost"));
 10147        var posted = context.OrderHistoryOneCPostings.AsNoTracking().Where(x => x.IsPosted && x.PostedAt >= since)
 10148            .Select(x => x.OrderId).Distinct();
 10149        var orders = context.Set<Order>().AsNoTracking().Where(x => posted.Contains(x.Id));
 150
 10151        var lost = await orders.Where(x => x.PurchasePrice > 0 && x.CostPrice == null).Select(x => x.Id).ToListAsync(ct)
 10152        if (lost.Count > 0)
 2153            findings.Add(new SystemAlertFinding("posted-cost-lost", "orders",
 2154                lost.Count >= Math.Max(1, settings.PostedCostLostDangerCount) ? "danger" : "warning",
 2155                $"Проведены в 1С без расчёта себестоимости: {lost.Count}",
 2156                ["Закупочная цена есть, себестоимость пустая — расчётные поля могли потеряться при проведении.",
 6157                    "Заказы: " + string.Join(", ", lost.OrderByDescending(x => x).Take(MaxOrdersInLine).Select(x => "#" 
 2158                SystemAlertLinks.Orders(lost),
 5159                Orders: SystemAlertOrderRef.Build(lost.Select(x => (x, "Проведён без себестоимости при заданной закупочн
 160
 10161        var loss = await orders.Where(x => x.MaxProfit < 0).Select(x => x.Id).ToListAsync(ct);
 10162        if (loss.Count >= Math.Max(1, settings.PostedLossMinCount))
 1163            findings.Add(new SystemAlertFinding("posted-negative-margin", "orders", "warning",
 1164                $"Проведены убыточные заказы (отрицательная прибыль даже при максимальной комиссии): {loss.Count}",
 6165                ["Заказы: " + string.Join(", ", loss.OrderByDescending(x => x).Take(MaxOrdersInLine).Select(x => "#" + x
 1166                SystemAlertLinks.Orders(loss),
 4167                Orders: SystemAlertOrderRef.Build(loss.Select(x => (x, "Убыточное проведение: прибыль отрицательна даже 
 168
 10169        var noCost = await orders.Where(x => x.CostPrice == null && x.PurchasePrice == null).Select(x => x.Id).ToListAsy
 10170        if (noCost.Count > 0)
 1171            findings.Add(new SystemAlertFinding("posted-without-cost", "orders", "info",
 1172                $"Проведены в 1С без закупочной цены и себестоимости: {noCost.Count}", ["Для таких заказов прибыль не по
 1173                SystemAlertLinks.Orders(noCost),
 2174                Orders: SystemAlertOrderRef.Build(noCost.Select(x => (x, "Проведён без закупочной цены и себестоимости")
 10175    }
 176
 177    /// <summary>Время БД: <c>orders_history.ChangedAt</c> пишет триггер по часам сервера БД, а не приложения.</summary>
 178    private async Task<DateTime> GetDatabaseNowAsync(CancellationToken ct)
 179    {
 180        try
 181        {
 10182            return await context.Database.SqlQueryRaw<DateTime>("SELECT NOW(6) AS Value").FirstAsync(ct);
 183        }
 10184        catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
 185        {
 10186            return DateTime.UtcNow;
 187        }
 10188    }
 189}
 190
 191/// <summary>
 192/// Остатки и 1С: расписания BitrixStock, массовое обнуление остатков при выгрузке, свежесть snapshot склада
 193/// и «зонд» 1С OData (включая фильтр по GUID — на нём уже была тихая регрессия).
 194/// </summary>
 195public sealed class StockAndOneCSystemAlertSource(
 196    ApplicationDbContext context,
 197    OneCODataConfig oneCConfig,
 198    IOneCODataClient oneCClient,
 199    IMemoryCache cache,
 200    IOptions<SystemAlertsOptions> options) : ISystemAlertSource
 201{
 202    private const string AlertsUrl = "/Studio2/JobLogs?tab=alerts";
 203    private const string ProbeCacheKey = "system-alerts-onec-probe";
 204    private const string WarehouseEndpoint = "Catalog_СтруктурныеЕдиницы";
 205
 206    public async Task<IReadOnlyList<SystemAlertFinding>> CollectAsync(CancellationToken cancellationToken = default)
 207    {
 208        var settings = options.Value;
 209        if (!settings.StockChecksEnabled) return [];
 210
 211        var now = DateTime.UtcNow;
 212        var findings = new List<SystemAlertFinding>();
 213        await AddAutoScheduleProblemsAsync(findings, settings, now, cancellationToken);
 214        await AddPushZeroingAsync(findings, settings, now, cancellationToken);
 215        await AddStockSnapshotStaleAsync(findings, settings, now, cancellationToken);
 216        findings.AddRange(await ProbeOneCAsync(settings, cancellationToken));
 217        return findings;
 218    }
 219
 220    /// <summary>11а. Активное расписание не запустилось в срок или его последний запуск завершился ошибкой.</summary>
 221    private async Task AddAutoScheduleProblemsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, Dat
 222    {
 223        var overdueBefore = now.AddMinutes(-Math.Max(1, settings.BitrixAutoScheduleOverdueMinutes));
 224        var overdue = await context.BitrixStockAutoSchedules.AsNoTracking()
 225            .Where(x => x.IsActive && x.NextRunAt != null && x.NextRunAt < overdueBefore)
 226            .Select(x => new { x.Name, NextRunAt = x.NextRunAt!.Value }).ToListAsync(ct);
 227        if (overdue.Count > 0)
 228            findings.Add(new SystemAlertFinding("bitrix-autoschedule-overdue", "stock", "danger",
 229                $"Расписания остатков BitrixStock не запустились в срок: {overdue.Count}",
 230                overdue.OrderBy(x => x.NextRunAt).Take(5).Select(x => $"{x.Name}: ожидался {x.NextRunAt:yyyy-MM-dd HH:mm
 231                "/Studio2/BitrixStock", Debounced: true));
 232
 233        var since = now.AddHours(-settings.WindowHoursFor("bitrix-autoschedule-failed"));
 234        var latestRuns = await context.BitrixStockAutoScheduleRuns.AsNoTracking()
 235            .Where(x => x.StartedAt >= since && x.Schedule!.IsActive)
 236            .GroupBy(x => x.ScheduleId)
 237            .Select(g => g.OrderByDescending(r => r.StartedAt).Select(r => new { r.ScheduleId, r.Status, r.ErrorMessage,
 238            .ToListAsync(ct);
 239        var failed = latestRuns.Where(x => x.Status is BitrixStockAutoScheduleRunStatus.Failed or BitrixStockAutoSchedul
 240        if (failed.Count > 0)
 241            findings.Add(new SystemAlertFinding("bitrix-autoschedule-failed", "stock", "warning",
 242                $"Последний запуск расписания остатков BitrixStock завершился с ошибкой: {failed.Count}",
 243                failed.Take(5).Select(x => $"{x.ScheduleName}: {x.ErrorMessage ?? x.Status}").ToList(), "/Studio2/Bitrix
 244    }
 245
 246    /// <summary>11б. Массовое обнуление: выгрузка, где большая доля артикулов отправлена с нулевым остатком.</summary>
 247    private async Task AddPushZeroingAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime now
 248    {
 249        var since = now.AddHours(-settings.WindowHoursFor("bitrix-push-zeroing"));
 250        var minItems = Math.Max(1, settings.PushZeroingMinItems);
 251        var stats = await context.BitrixStockPushItems.AsNoTracking()
 252            .Where(x => x.Execution!.StartedAt >= since)
 253            .GroupBy(x => x.ExecutionId)
 254            .Select(g => new { ExecutionId = g.Key, Total = g.Count(), Zeroed = g.Count(i => i.PreviousStock > 0 && i.Ta
 255            .Where(x => x.Total >= minItems)
 256            .ToListAsync(ct);
 257        var anomalies = stats.Where(x => x.Zeroed * 100.0 / x.Total >= settings.PushZeroingSharePercent).OrderByDescendi
 258        if (anomalies.Count == 0) return;
 259
 260        findings.Add(new SystemAlertFinding("bitrix-push-mass-zeroing", "stock", "danger",
 261            "Выгрузка остатков массово обнуляет артикулы",
 262            anomalies.Take(3).Select(x => $"Выгрузка #{x.ExecutionId}: {x.Zeroed} из {x.Total} ({x.Zeroed * 100 / x.Tota
 263            $"/Studio2/BitrixStock?tab=pushHistory"));
 264    }
 265
 266    /// <summary>12б. Snapshot остатков склада давно не обновлялся успешным запуском.</summary>
 267    private async Task AddStockSnapshotStaleAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT
 268    {
 269        var lastSuccess = await context.WarehouseStockSyncRuns.AsNoTracking()
 270            .Where(x => x.Status == WarehouseStockSyncRunStatus.Success)
 271            .Select(x => (DateTime?)x.StartedAtUtc).MaxAsync(ct);
 272        if (!lastSuccess.HasValue || lastSuccess.Value >= now.AddHours(-Math.Max(1, settings.WarehouseStockStaleHours)))
 273
 274        findings.Add(new SystemAlertFinding("warehouse-stock-stale", "stock", "warning",
 275            "Остатки склада давно не обновлялись",
 276            [$"Последнее успешное обновление: {lastSuccess.Value:yyyy-MM-dd HH:mm} UTC ({(now - lastSuccess.Value).Total
 277            "/Studio2/JobLogs?tab=stocksync", Debounced: true));
 278    }
 279
 280    /// <summary>
 281    /// 12а. Лёгкий зонд 1С OData: список справочника и поиск по GUID. Регрессия 11.09.2026: 500 на <c>eq guid</c>
 282    /// и молчаливое отбрасывание GUID-условия (запрос возвращал не одну запись, а всё подряд).
 283    /// Результат кэшируется, чтобы не дёргать 1С на каждый пересчёт.
 284    /// </summary>
 285    private async Task<IReadOnlyList<SystemAlertFinding>> ProbeOneCAsync(SystemAlertsOptions settings, CancellationToken
 286    {
 287        if (!settings.OneCProbeEnabled || string.IsNullOrWhiteSpace(oneCConfig.BaseUrl)) return [];
 288        if (cache.TryGetValue(ProbeCacheKey, out IReadOnlyList<SystemAlertFinding>? cached) && cached != null) return ca
 289
 290        // Клиент 1С не принимает токен отмены и сам повторяет запросы до ~14 с при 5xx: ограничиваем зонд по времени,
 291        // иначе недоступная 1С растянула бы пересчёт всех алертов на минуты.
 292        var probe = RunProbeAsync(settings);
 293        var timeout = TimeSpan.FromSeconds(Math.Max(1, settings.OneCProbeTimeoutSeconds));
 294        IReadOnlyList<SystemAlertFinding> findings;
 295        if (await Task.WhenAny(probe, Task.Delay(timeout, ct)) == probe)
 296        {
 297            findings = await probe;
 298        }
 299        else
 300        {
 301            _ = probe.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
 302            findings =
 303            [
 304                new SystemAlertFinding("onec-odata-unavailable", "onec", "danger", "1С OData недоступна или отвечает оши
 305                    [$"Нет ответа за {timeout.TotalSeconds:0} с"], SystemAlertLinks.OneCStock, Debounced: true)
 306            ];
 307        }
 308
 309        cache.Set(ProbeCacheKey, findings, TimeSpan.FromMinutes(Math.Max(1, settings.OneCProbeIntervalMinutes)));
 310        return findings;
 311    }
 312
 313    private async Task<IReadOnlyList<SystemAlertFinding>> RunProbeAsync(SystemAlertsOptions settings)
 314    {
 315        var findings = new List<SystemAlertFinding>();
 316        var started = System.Diagnostics.Stopwatch.GetTimestamp();
 317        try
 318        {
 319            var first = await oneCClient.GetSinglePageAsync<JObject>(WarehouseEndpoint, 1,
 320                new Dictionary<string, string> { ["$select"] = "Ref_Key" });
 321            var key = first.FirstOrDefault()?.Value<string>("Ref_Key");
 322            if (Guid.TryParse(key, out var guid))
 323            {
 324                var byGuid = await oneCClient.GetSinglePageAsync<JObject>(WarehouseEndpoint, 5,
 325                    new Dictionary<string, string> { ["$select"] = "Ref_Key", ["$filter"] = $"Ref_Key eq guid'{guid}'" }
 326                if (byGuid.Count != 1)
 327                    findings.Add(new SystemAlertFinding("onec-odata-filter", "onec", "danger",
 328                        "1С OData неверно обрабатывает фильтр по GUID",
 329                        [$"Запрос по одному Ref_Key вернул записей: {byGuid.Count}, ожидалась 1. Остатки и приходы могут
 330                        SystemAlertLinks.OneCStock, Debounced: true));
 331            }
 332
 333            var elapsedMs = (long)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds;
 334            if (elapsedMs >= settings.OneCProbeSlowMs)
 335                findings.Add(new SystemAlertFinding("onec-odata-slow", "onec", "warning", "1С OData отвечает медленно",
 336                    [$"Два лёгких запроса заняли {elapsedMs} мс"], SystemAlertLinks.OneCStock, Debounced: true));
 337        }
 338        catch (Exception ex)
 339        {
 340            var message = ex.Message.Length > 200 ? ex.Message[..199] + "…" : ex.Message;
 341            findings.Add(new SystemAlertFinding("onec-odata-unavailable", "onec", "danger",
 342                "1С OData недоступна или отвечает ошибкой", [message], SystemAlertLinks.OneCStock, Debounced: true));
 343        }
 344
 345        return findings;
 346    }
 347}