< Summary

Information
Class: OzonOrdersWeb.WebServices.SystemAlerts.StockAndOneCSystemAlertSource
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/OperationsAlertSources.cs
Line coverage
84%
Covered lines: 85
Uncovered lines: 16
Coverable lines: 101
Total lines: 347
Line coverage: 84.1%
Branch coverage
65%
Covered branches: 25
Total branches: 38
Branch coverage: 65.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CollectAsync()50%22100%
AddAutoScheduleProblemsAsync()75%4488.88%
AddPushZeroingAsync()100%22100%
AddStockSnapshotStaleAsync()100%44100%
ProbeOneCAsync()90%151064.28%
RunProbeAsync()60%121072%

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>
 16public sealed class OrderOperationsSystemAlertSource(ApplicationDbContext context, IOptions<SystemAlertsOptions> options
 17{
 18    private const string AlertsUrl = "/Studio2/JobLogs?tab=alerts";
 19    private const int MaxOrdersInLine = 8;
 20    private static readonly string[] NullResetColumns = ["OzonClientId", "SupplierId", "ShipmentWarehouseId", "AppStatus
 21
 22    public async Task<IReadOnlyList<SystemAlertFinding>> CollectAsync(CancellationToken cancellationToken = default)
 23    {
 24        var settings = options.Value;
 25        if (!settings.OrderChecksEnabled) return [];
 26
 27        var now = DateTime.UtcNow;
 28        var findings = new List<SystemAlertFinding>();
 29        await AddShippingDeadlinesAsync(findings, settings, now, cancellationToken);
 30        await AddCancellationSpikeAsync(findings, settings, now, cancellationToken);
 31        await AddReturnSyncFailuresAsync(findings, settings, now, cancellationToken);
 32        await AddNullResetsAsync(findings, settings, cancellationToken);
 33        await AddPostedCostProblemsAsync(findings, settings, now, cancellationToken);
 34        return findings;
 35    }
 36
 37    /// <summary>
 38    /// 7. Дедлайн отгрузки (<c>ShippingDate</c> хранится в московском времени) скоро или уже прошёл, а заказ
 39    /// не обработан: нет статуса приложения и ни одного заказа у поставщика.
 40    /// </summary>
 41    private async Task AddShippingDeadlinesAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTi
 42    {
 43        var nowMsk = TimeZoneInfo.ConvertTimeFromUtc(now, RussianTimeZoneHelper.Zone);
 44        var from = nowMsk.AddHours(-Math.Max(1, settings.SlaOverdueMaxHours));
 45        var to = nowMsk.AddHours(Math.Max(1, settings.SlaWarningHours));
 46        var unhandled = settings.SlaUnhandledAppStatusNames;
 47
 48        var rows = await context.Set<Order>().AsNoTracking()
 49            .Where(x => x.ShippingDate != null && x.ShippingDate >= from && x.ShippingDate <= to)
 50            .Where(x => x.AppStatusId == null || (x.AppStatus != null && unhandled.Contains(x.AppStatus.Name)))
 51            .Where(x => x.ClientStatus == null || (!x.ClientStatus.IsFinal && !x.ClientStatus.Name.Contains("тмен")
 52                                                   && !x.ClientStatus.Name.Contains("оставлен") && !x.ClientStatus.Name.
 53                                                   && !x.ClientStatus.Name.Contains("ыполнен")))
 54            .Where(x => !context.OrderSupplierStates.Any(s => s.OrderId == x.Id && s.Stage != OzonDomains.Models.Supplie
 55            .Select(x => new { x.Id, ShippingDate = x.ShippingDate!.Value })
 56            .ToListAsync(ct);
 57        if (rows.Count == 0) return;
 58
 59        var dangerBefore = nowMsk.AddHours(Math.Max(0, settings.SlaDangerHours));
 60        var overdue = rows.Count(x => x.ShippingDate < nowMsk);
 61        var urgent = rows.Count(x => x.ShippingDate >= nowMsk && x.ShippingDate <= dangerBefore);
 62        var severity = overdue + urgent > 0 ? "danger" : "warning";
 63        var soonest = rows.OrderBy(x => x.ShippingDate).ToList();
 64        var lines = new List<string>
 65        {
 66            $"Просрочено: {overdue}; дедлайн в ближайшие {settings.SlaDangerHours} ч: {urgent}; в ближайшие {settings.Sl
 67            "Ближайшие: " + string.Join(", ", soonest.Take(MaxOrdersInLine).Select(x => $"#{x.Id} ({x.ShippingDate:dd.MM
 68        };
 69        findings.Add(new SystemAlertFinding("orders-shipping-deadline", "orders", severity,
 70            $"Дедлайн отгрузки близко, а заказ не обработан: {rows.Count}", lines,
 71            SystemAlertLinks.Orders(rows.Select(x => x.Id)), Debounced: true,
 72            Orders: SystemAlertOrderRef.Build(rows.Select(x => (x.Id,
 73                $"Дедлайн отгрузки {x.ShippingDate:dd.MM HH:mm}" + (x.ShippingDate < nowMsk ? " — просрочен" : string.Em
 74    }
 75
 76    /// <summary>8а. Всплеск отмен за последний час относительно недельной нормы.</summary>
 77    private async Task AddCancellationSpikeAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTi
 78    {
 79        var hourAgo = now.AddHours(-1);
 80        var baselineFrom = now.AddDays(-7);
 81        var recent = await context.OzonPostingCancellations.AsNoTracking().CountAsync(x => x.OccurredAt >= hourAgo, ct);
 82        if (recent < Math.Max(1, settings.CancelSpikeMinCount)) return;
 83
 84        var earlier = await context.OzonPostingCancellations.AsNoTracking()
 85            .CountAsync(x => x.OccurredAt >= baselineFrom && x.OccurredAt < hourAgo, ct);
 86        var perHour = earlier / (7d * 24 - 1);
 87        if (recent < Math.Max(1, settings.CancelSpikeFactor) * Math.Max(perHour, 1)) return;
 88
 89        var cancelledOrders = await context.OzonPostingCancellationOrders.AsNoTracking()
 90            .Where(x => x.Cancellation.OccurredAt >= hourAgo).Select(x => x.OrderId).Distinct().ToListAsync(ct);
 91        findings.Add(new SystemAlertFinding("ozon-cancellations-spike", "orders", "warning",
 92            "Всплеск отмен отправлений в Ozon",
 93            [$"За последний час: {recent}; обычно около {perHour:0.#} в час (за 7 дней)"],
 94            cancelledOrders.Count > 0 ? SystemAlertLinks.Orders(cancelledOrders) : AlertsUrl, Debounced: true,
 95            Orders: SystemAlertOrderRef.Build(cancelledOrders.Select(x => (x, "Отменён в Ozon за последний час (всплеск 
 96    }
 97
 98    /// <summary>8б. Сбои синхронизации возвратов: ошибки после последнего успешного запуска.</summary>
 99    private async Task AddReturnSyncFailuresAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT
 100    {
 101        var since = now.AddHours(-settings.WindowHoursFor("order-returns-sync"));
 102        var runs = context.OrderReturnSyncRuns.AsNoTracking().Where(x => x.StartedAtUtc >= since);
 103        var lastSuccess = await runs.Where(x => x.Status == OrderReturnSyncRunStatus.Success)
 104            .Select(x => (DateTime?)x.StartedAtUtc).MaxAsync(ct);
 105        var failedRuns = runs.Where(x => x.Status == OrderReturnSyncRunStatus.Failed || x.Status == OrderReturnSyncRunSt
 106        if (lastSuccess.HasValue) failedRuns = failedRuns.Where(x => x.StartedAtUtc > lastSuccess.Value);
 107        var failed = await failedRuns.OrderByDescending(x => x.StartedAtUtc).Select(x => new { x.StartedAtUtc, x.ErrorMe
 108        if (failed.Count == 0) return;
 109
 110        findings.Add(new SystemAlertFinding("order-returns-sync", "orders",
 111            failed.Count >= Math.Max(1, settings.DangerFailureCount) ? "danger" : "warning",
 112            "Синхронизация возвратов Ozon завершается с ошибкой",
 113            [$"Ошибок после последнего успешного запуска: {failed.Count}", "Последняя: " + (failed[0].ErrorMessage ?? "б
 114            "/Studio2/OrderReturns"));
 115    }
 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    {
 123        var since = (await GetDatabaseNowAsync(ct)).AddHours(-settings.WindowHoursFor("orders-null-reset"));
 124        var resets = await context.OrderHistories.AsNoTracking()
 125            .Where(x => x.ChangedAt >= since && x.NewValue == "NULL" && x.OldValue != null && x.OldValue != "NULL"
 126                        && NullResetColumns.Contains(x.ColumnName!))
 127            .Select(x => new { x.OrderId, x.ColumnName })
 128            .ToListAsync(ct);
 129        if (resets.Count == 0) return;
 130
 131        var orders = resets.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList();
 132        var columns = resets.GroupBy(x => x.ColumnName).Select(x => $"{x.Key}: {x.Count()}");
 133        findings.Add(new SystemAlertFinding("orders-null-reset", "orders",
 134            orders.Count >= Math.Max(1, settings.NullResetDangerCount) ? "danger" : "warning",
 135            $"У заказов сбросились ключевые поля в NULL: {orders.Count}",
 136            ["Поля: " + string.Join("; ", columns),
 137                "Заказы: " + string.Join(", ", orders.Take(MaxOrdersInLine).Select(x => "#" + x)),
 138                "Восстановление: навык restore-order-null-fields"],
 139            SystemAlertLinks.Orders(orders),
 140            Orders: SystemAlertOrderRef.Build(resets.Select(x => (x.OrderId, "Сброшено в NULL: " + x.ColumnName)))));
 141    }
 142
 143    /// <summary>10. Проведённые в 1С заказы без расчёта себестоимости и убыточные проведения.</summary>
 144    private async Task AddPostedCostProblemsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT
 145    {
 146        var since = now.AddHours(-settings.WindowHoursFor("posted-cost-lost"));
 147        var posted = context.OrderHistoryOneCPostings.AsNoTracking().Where(x => x.IsPosted && x.PostedAt >= since)
 148            .Select(x => x.OrderId).Distinct();
 149        var orders = context.Set<Order>().AsNoTracking().Where(x => posted.Contains(x.Id));
 150
 151        var lost = await orders.Where(x => x.PurchasePrice > 0 && x.CostPrice == null).Select(x => x.Id).ToListAsync(ct)
 152        if (lost.Count > 0)
 153            findings.Add(new SystemAlertFinding("posted-cost-lost", "orders",
 154                lost.Count >= Math.Max(1, settings.PostedCostLostDangerCount) ? "danger" : "warning",
 155                $"Проведены в 1С без расчёта себестоимости: {lost.Count}",
 156                ["Закупочная цена есть, себестоимость пустая — расчётные поля могли потеряться при проведении.",
 157                    "Заказы: " + string.Join(", ", lost.OrderByDescending(x => x).Take(MaxOrdersInLine).Select(x => "#" 
 158                SystemAlertLinks.Orders(lost),
 159                Orders: SystemAlertOrderRef.Build(lost.Select(x => (x, "Проведён без себестоимости при заданной закупочн
 160
 161        var loss = await orders.Where(x => x.MaxProfit < 0).Select(x => x.Id).ToListAsync(ct);
 162        if (loss.Count >= Math.Max(1, settings.PostedLossMinCount))
 163            findings.Add(new SystemAlertFinding("posted-negative-margin", "orders", "warning",
 164                $"Проведены убыточные заказы (отрицательная прибыль даже при максимальной комиссии): {loss.Count}",
 165                ["Заказы: " + string.Join(", ", loss.OrderByDescending(x => x).Take(MaxOrdersInLine).Select(x => "#" + x
 166                SystemAlertLinks.Orders(loss),
 167                Orders: SystemAlertOrderRef.Build(loss.Select(x => (x, "Убыточное проведение: прибыль отрицательна даже 
 168
 169        var noCost = await orders.Where(x => x.CostPrice == null && x.PurchasePrice == null).Select(x => x.Id).ToListAsy
 170        if (noCost.Count > 0)
 171            findings.Add(new SystemAlertFinding("posted-without-cost", "orders", "info",
 172                $"Проведены в 1С без закупочной цены и себестоимости: {noCost.Count}", ["Для таких заказов прибыль не по
 173                SystemAlertLinks.Orders(noCost),
 174                Orders: SystemAlertOrderRef.Build(noCost.Select(x => (x, "Проведён без закупочной цены и себестоимости")
 175    }
 176
 177    /// <summary>Время БД: <c>orders_history.ChangedAt</c> пишет триггер по часам сервера БД, а не приложения.</summary>
 178    private async Task<DateTime> GetDatabaseNowAsync(CancellationToken ct)
 179    {
 180        try
 181        {
 182            return await context.Database.SqlQueryRaw<DateTime>("SELECT NOW(6) AS Value").FirstAsync(ct);
 183        }
 184        catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
 185        {
 186            return DateTime.UtcNow;
 187        }
 188    }
 189}
 190
 191/// <summary>
 192/// Остатки и 1С: расписания BitrixStock, массовое обнуление остатков при выгрузке, свежесть snapshot склада
 193/// и «зонд» 1С OData (включая фильтр по GUID — на нём уже была тихая регрессия).
 194/// </summary>
 7195public sealed class StockAndOneCSystemAlertSource(
 7196    ApplicationDbContext context,
 7197    OneCODataConfig oneCConfig,
 7198    IOneCODataClient oneCClient,
 7199    IMemoryCache cache,
 7200    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    {
 8208        var settings = options.Value;
 8209        if (!settings.StockChecksEnabled) return [];
 210
 8211        var now = DateTime.UtcNow;
 8212        var findings = new List<SystemAlertFinding>();
 8213        await AddAutoScheduleProblemsAsync(findings, settings, now, cancellationToken);
 8214        await AddPushZeroingAsync(findings, settings, now, cancellationToken);
 8215        await AddStockSnapshotStaleAsync(findings, settings, now, cancellationToken);
 8216        findings.AddRange(await ProbeOneCAsync(settings, cancellationToken));
 8217        return findings;
 8218    }
 219
 220    /// <summary>11а. Активное расписание не запустилось в срок или его последний запуск завершился ошибкой.</summary>
 221    private async Task AddAutoScheduleProblemsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, Dat
 222    {
 8223        var overdueBefore = now.AddMinutes(-Math.Max(1, settings.BitrixAutoScheduleOverdueMinutes));
 8224        var overdue = await context.BitrixStockAutoSchedules.AsNoTracking()
 8225            .Where(x => x.IsActive && x.NextRunAt != null && x.NextRunAt < overdueBefore)
 8226            .Select(x => new { x.Name, NextRunAt = x.NextRunAt!.Value }).ToListAsync(ct);
 8227        if (overdue.Count > 0)
 1228            findings.Add(new SystemAlertFinding("bitrix-autoschedule-overdue", "stock", "danger",
 1229                $"Расписания остатков BitrixStock не запустились в срок: {overdue.Count}",
 2230                overdue.OrderBy(x => x.NextRunAt).Take(5).Select(x => $"{x.Name}: ожидался {x.NextRunAt:yyyy-MM-dd HH:mm
 1231                "/Studio2/BitrixStock", Debounced: true));
 232
 8233        var since = now.AddHours(-settings.WindowHoursFor("bitrix-autoschedule-failed"));
 8234        var latestRuns = await context.BitrixStockAutoScheduleRuns.AsNoTracking()
 8235            .Where(x => x.StartedAt >= since && x.Schedule!.IsActive)
 8236            .GroupBy(x => x.ScheduleId)
 8237            .Select(g => g.OrderByDescending(r => r.StartedAt).Select(r => new { r.ScheduleId, r.Status, r.ErrorMessage,
 8238            .ToListAsync(ct);
 8239        var failed = latestRuns.Where(x => x.Status is BitrixStockAutoScheduleRunStatus.Failed or BitrixStockAutoSchedul
 8240        if (failed.Count > 0)
 0241            findings.Add(new SystemAlertFinding("bitrix-autoschedule-failed", "stock", "warning",
 0242                $"Последний запуск расписания остатков BitrixStock завершился с ошибкой: {failed.Count}",
 0243                failed.Take(5).Select(x => $"{x.ScheduleName}: {x.ErrorMessage ?? x.Status}").ToList(), "/Studio2/Bitrix
 8244    }
 245
 246    /// <summary>11б. Массовое обнуление: выгрузка, где большая доля артикулов отправлена с нулевым остатком.</summary>
 247    private async Task AddPushZeroingAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime now
 248    {
 8249        var since = now.AddHours(-settings.WindowHoursFor("bitrix-push-zeroing"));
 8250        var minItems = Math.Max(1, settings.PushZeroingMinItems);
 8251        var stats = await context.BitrixStockPushItems.AsNoTracking()
 8252            .Where(x => x.Execution!.StartedAt >= since)
 8253            .GroupBy(x => x.ExecutionId)
 8254            .Select(g => new { ExecutionId = g.Key, Total = g.Count(), Zeroed = g.Count(i => i.PreviousStock > 0 && i.Ta
 8255            .Where(x => x.Total >= minItems)
 8256            .ToListAsync(ct);
 11257        var anomalies = stats.Where(x => x.Zeroed * 100.0 / x.Total >= settings.PushZeroingSharePercent).OrderByDescendi
 15258        if (anomalies.Count == 0) return;
 259
 1260        findings.Add(new SystemAlertFinding("bitrix-push-mass-zeroing", "stock", "danger",
 1261            "Выгрузка остатков массово обнуляет артикулы",
 1262            anomalies.Take(3).Select(x => $"Выгрузка #{x.ExecutionId}: {x.Zeroed} из {x.Total} ({x.Zeroed * 100 / x.Tota
 1263            $"/Studio2/BitrixStock?tab=pushHistory"));
 8264    }
 265
 266    /// <summary>12б. Snapshot остатков склада давно не обновлялся успешным запуском.</summary>
 267    private async Task AddStockSnapshotStaleAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT
 268    {
 8269        var lastSuccess = await context.WarehouseStockSyncRuns.AsNoTracking()
 8270            .Where(x => x.Status == WarehouseStockSyncRunStatus.Success)
 8271            .Select(x => (DateTime?)x.StartedAtUtc).MaxAsync(ct);
 15272        if (!lastSuccess.HasValue || lastSuccess.Value >= now.AddHours(-Math.Max(1, settings.WarehouseStockStaleHours)))
 273
 1274        findings.Add(new SystemAlertFinding("warehouse-stock-stale", "stock", "warning",
 1275            "Остатки склада давно не обновлялись",
 1276            [$"Последнее успешное обновление: {lastSuccess.Value:yyyy-MM-dd HH:mm} UTC ({(now - lastSuccess.Value).Total
 1277            "/Studio2/JobLogs?tab=stocksync", Debounced: true));
 8278    }
 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    {
 13287        if (!settings.OneCProbeEnabled || string.IsNullOrWhiteSpace(oneCConfig.BaseUrl)) return [];
 4288        if (cache.TryGetValue(ProbeCacheKey, out IReadOnlyList<SystemAlertFinding>? cached) && cached != null) return ca
 289
 290        // Клиент 1С не принимает токен отмены и сам повторяет запросы до ~14 с при 5xx: ограничиваем зонд по времени,
 291        // иначе недоступная 1С растянула бы пересчёт всех алертов на минуты.
 2292        var probe = RunProbeAsync(settings);
 2293        var timeout = TimeSpan.FromSeconds(Math.Max(1, settings.OneCProbeTimeoutSeconds));
 294        IReadOnlyList<SystemAlertFinding> findings;
 2295        if (await Task.WhenAny(probe, Task.Delay(timeout, ct)) == probe)
 296        {
 2297            findings = await probe;
 298        }
 299        else
 300        {
 0301            _ = probe.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
 0302            findings =
 0303            [
 0304                new SystemAlertFinding("onec-odata-unavailable", "onec", "danger", "1С OData недоступна или отвечает оши
 0305                    [$"Нет ответа за {timeout.TotalSeconds:0} с"], SystemAlertLinks.OneCStock, Debounced: true)
 0306            ];
 307        }
 308
 2309        cache.Set(ProbeCacheKey, findings, TimeSpan.FromMinutes(Math.Max(1, settings.OneCProbeIntervalMinutes)));
 2310        return findings;
 8311    }
 312
 313    private async Task<IReadOnlyList<SystemAlertFinding>> RunProbeAsync(SystemAlertsOptions settings)
 314    {
 2315        var findings = new List<SystemAlertFinding>();
 2316        var started = System.Diagnostics.Stopwatch.GetTimestamp();
 317        try
 318        {
 2319            var first = await oneCClient.GetSinglePageAsync<JObject>(WarehouseEndpoint, 1,
 2320                new Dictionary<string, string> { ["$select"] = "Ref_Key" });
 2321            var key = first.FirstOrDefault()?.Value<string>("Ref_Key");
 2322            if (Guid.TryParse(key, out var guid))
 323            {
 2324                var byGuid = await oneCClient.GetSinglePageAsync<JObject>(WarehouseEndpoint, 5,
 2325                    new Dictionary<string, string> { ["$select"] = "Ref_Key", ["$filter"] = $"Ref_Key eq guid'{guid}'" }
 2326                if (byGuid.Count != 1)
 1327                    findings.Add(new SystemAlertFinding("onec-odata-filter", "onec", "danger",
 1328                        "1С OData неверно обрабатывает фильтр по GUID",
 1329                        [$"Запрос по одному Ref_Key вернул записей: {byGuid.Count}, ожидалась 1. Остатки и приходы могут
 1330                        SystemAlertLinks.OneCStock, Debounced: true));
 331            }
 332
 2333            var elapsedMs = (long)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds;
 2334            if (elapsedMs >= settings.OneCProbeSlowMs)
 0335                findings.Add(new SystemAlertFinding("onec-odata-slow", "onec", "warning", "1С OData отвечает медленно",
 0336                    [$"Два лёгких запроса заняли {elapsedMs} мс"], SystemAlertLinks.OneCStock, Debounced: true));
 2337        }
 0338        catch (Exception ex)
 339        {
 0340            var message = ex.Message.Length > 200 ? ex.Message[..199] + "…" : ex.Message;
 0341            findings.Add(new SystemAlertFinding("onec-odata-unavailable", "onec", "danger",
 0342                "1С OData недоступна или отвечает ошибкой", [message], SystemAlertLinks.OneCStock, Debounced: true));
 0343        }
 344
 2345        return findings;
 2346    }
 347}