| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Caching.Memory; |
| | | 3 | | using Microsoft.Extensions.Options; |
| | | 4 | | using Newtonsoft.Json.Linq; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | using Services.ApiServices._1CApi.OData; |
| | | 8 | | using Services.BackgroundJobsServices; |
| | | 9 | | |
| | | 10 | | namespace OzonOrdersWeb.WebServices.SystemAlerts; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// Контроль заказов Ozon: дедлайн отгрузки без обработки, всплеск отмен, сбои синхронизации возвратов, |
| | | 14 | | /// сброс ключевых полей заказа в NULL и «потерянный» расчёт себестоимости у проведённых заказов. |
| | | 15 | | /// </summary> |
| | 10 | 16 | | public sealed class OrderOperationsSystemAlertSource(ApplicationDbContext context, IOptions<SystemAlertsOptions> options |
| | | 17 | | { |
| | | 18 | | private const string AlertsUrl = "/Studio2/JobLogs?tab=alerts"; |
| | | 19 | | private const int MaxOrdersInLine = 8; |
| | 1 | 20 | | private static readonly string[] NullResetColumns = ["OzonClientId", "SupplierId", "ShipmentWarehouseId", "AppStatus |
| | | 21 | | |
| | | 22 | | public async Task<IReadOnlyList<SystemAlertFinding>> CollectAsync(CancellationToken cancellationToken = default) |
| | | 23 | | { |
| | 10 | 24 | | var settings = options.Value; |
| | 10 | 25 | | if (!settings.OrderChecksEnabled) return []; |
| | | 26 | | |
| | 10 | 27 | | var now = DateTime.UtcNow; |
| | 10 | 28 | | var findings = new List<SystemAlertFinding>(); |
| | 10 | 29 | | await AddShippingDeadlinesAsync(findings, settings, now, cancellationToken); |
| | 10 | 30 | | await AddCancellationSpikeAsync(findings, settings, now, cancellationToken); |
| | 10 | 31 | | await AddReturnSyncFailuresAsync(findings, settings, now, cancellationToken); |
| | 10 | 32 | | await AddNullResetsAsync(findings, settings, cancellationToken); |
| | 10 | 33 | | await AddPostedCostProblemsAsync(findings, settings, now, cancellationToken); |
| | 10 | 34 | | return findings; |
| | 10 | 35 | | } |
| | | 36 | | |
| | | 37 | | /// <summary> |
| | | 38 | | /// 7. Дедлайн отгрузки (<c>ShippingDate</c> хранится в московском времени) скоро или уже прошёл, а заказ |
| | | 39 | | /// не обработан: нет статуса приложения и ни одного заказа у поставщика. |
| | | 40 | | /// </summary> |
| | | 41 | | private async Task AddShippingDeadlinesAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTi |
| | | 42 | | { |
| | 10 | 43 | | var nowMsk = TimeZoneInfo.ConvertTimeFromUtc(now, RussianTimeZoneHelper.Zone); |
| | 10 | 44 | | var from = nowMsk.AddHours(-Math.Max(1, settings.SlaOverdueMaxHours)); |
| | 10 | 45 | | var to = nowMsk.AddHours(Math.Max(1, settings.SlaWarningHours)); |
| | 10 | 46 | | var unhandled = settings.SlaUnhandledAppStatusNames; |
| | | 47 | | |
| | 10 | 48 | | var rows = await context.Set<Order>().AsNoTracking() |
| | 10 | 49 | | .Where(x => x.ShippingDate != null && x.ShippingDate >= from && x.ShippingDate <= to) |
| | 10 | 50 | | .Where(x => x.AppStatusId == null || (x.AppStatus != null && unhandled.Contains(x.AppStatus.Name))) |
| | 10 | 51 | | .Where(x => x.ClientStatus == null || (!x.ClientStatus.IsFinal && !x.ClientStatus.Name.Contains("тмен") |
| | 10 | 52 | | && !x.ClientStatus.Name.Contains("оставлен") && !x.ClientStatus.Name. |
| | 10 | 53 | | && !x.ClientStatus.Name.Contains("ыполнен"))) |
| | 10 | 54 | | .Where(x => !context.OrderSupplierStates.Any(s => s.OrderId == x.Id && s.Stage != OzonDomains.Models.Supplie |
| | 10 | 55 | | .Select(x => new { x.Id, ShippingDate = x.ShippingDate!.Value }) |
| | 10 | 56 | | .ToListAsync(ct); |
| | 18 | 57 | | if (rows.Count == 0) return; |
| | | 58 | | |
| | 2 | 59 | | var dangerBefore = nowMsk.AddHours(Math.Max(0, settings.SlaDangerHours)); |
| | 5 | 60 | | var overdue = rows.Count(x => x.ShippingDate < nowMsk); |
| | 5 | 61 | | var urgent = rows.Count(x => x.ShippingDate >= nowMsk && x.ShippingDate <= dangerBefore); |
| | 2 | 62 | | var severity = overdue + urgent > 0 ? "danger" : "warning"; |
| | 5 | 63 | | var soonest = rows.OrderBy(x => x.ShippingDate).ToList(); |
| | 2 | 64 | | var lines = new List<string> |
| | 2 | 65 | | { |
| | 2 | 66 | | $"Просрочено: {overdue}; дедлайн в ближайшие {settings.SlaDangerHours} ч: {urgent}; в ближайшие {settings.Sl |
| | 3 | 67 | | "Ближайшие: " + string.Join(", ", soonest.Take(MaxOrdersInLine).Select(x => $"#{x.Id} ({x.ShippingDate:dd.MM |
| | 2 | 68 | | }; |
| | 2 | 69 | | findings.Add(new SystemAlertFinding("orders-shipping-deadline", "orders", severity, |
| | 2 | 70 | | $"Дедлайн отгрузки близко, а заказ не обработан: {rows.Count}", lines, |
| | 3 | 71 | | SystemAlertLinks.Orders(rows.Select(x => x.Id)), Debounced: true, |
| | 5 | 72 | | Orders: SystemAlertOrderRef.Build(rows.Select(x => (x.Id, |
| | 5 | 73 | | $"Дедлайн отгрузки {x.ShippingDate:dd.MM HH:mm}" + (x.ShippingDate < nowMsk ? " — просрочен" : string.Em |
| | 10 | 74 | | } |
| | | 75 | | |
| | | 76 | | /// <summary>8а. Всплеск отмен за последний час относительно недельной нормы.</summary> |
| | | 77 | | private async Task AddCancellationSpikeAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTi |
| | | 78 | | { |
| | 10 | 79 | | var hourAgo = now.AddHours(-1); |
| | 10 | 80 | | var baselineFrom = now.AddDays(-7); |
| | 10 | 81 | | var recent = await context.OzonPostingCancellations.AsNoTracking().CountAsync(x => x.OccurredAt >= hourAgo, ct); |
| | 18 | 82 | | if (recent < Math.Max(1, settings.CancelSpikeMinCount)) return; |
| | | 83 | | |
| | 2 | 84 | | var earlier = await context.OzonPostingCancellations.AsNoTracking() |
| | 2 | 85 | | .CountAsync(x => x.OccurredAt >= baselineFrom && x.OccurredAt < hourAgo, ct); |
| | 2 | 86 | | var perHour = earlier / (7d * 24 - 1); |
| | 3 | 87 | | if (recent < Math.Max(1, settings.CancelSpikeFactor) * Math.Max(perHour, 1)) return; |
| | | 88 | | |
| | 1 | 89 | | var cancelledOrders = await context.OzonPostingCancellationOrders.AsNoTracking() |
| | 1 | 90 | | .Where(x => x.Cancellation.OccurredAt >= hourAgo).Select(x => x.OrderId).Distinct().ToListAsync(ct); |
| | 1 | 91 | | findings.Add(new SystemAlertFinding("ozon-cancellations-spike", "orders", "warning", |
| | 1 | 92 | | "Всплеск отмен отправлений в Ozon", |
| | 1 | 93 | | [$"За последний час: {recent}; обычно около {perHour:0.#} в час (за 7 дней)"], |
| | 1 | 94 | | cancelledOrders.Count > 0 ? SystemAlertLinks.Orders(cancelledOrders) : AlertsUrl, Debounced: true, |
| | 1 | 95 | | Orders: SystemAlertOrderRef.Build(cancelledOrders.Select(x => (x, "Отменён в Ozon за последний час (всплеск |
| | 10 | 96 | | } |
| | | 97 | | |
| | | 98 | | /// <summary>8б. Сбои синхронизации возвратов: ошибки после последнего успешного запуска.</summary> |
| | | 99 | | private async Task AddReturnSyncFailuresAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT |
| | | 100 | | { |
| | 10 | 101 | | var since = now.AddHours(-settings.WindowHoursFor("order-returns-sync")); |
| | 10 | 102 | | var runs = context.OrderReturnSyncRuns.AsNoTracking().Where(x => x.StartedAtUtc >= since); |
| | 10 | 103 | | var lastSuccess = await runs.Where(x => x.Status == OrderReturnSyncRunStatus.Success) |
| | 10 | 104 | | .Select(x => (DateTime?)x.StartedAtUtc).MaxAsync(ct); |
| | 10 | 105 | | var failedRuns = runs.Where(x => x.Status == OrderReturnSyncRunStatus.Failed || x.Status == OrderReturnSyncRunSt |
| | 12 | 106 | | if (lastSuccess.HasValue) failedRuns = failedRuns.Where(x => x.StartedAtUtc > lastSuccess.Value); |
| | 10 | 107 | | var failed = await failedRuns.OrderByDescending(x => x.StartedAtUtc).Select(x => new { x.StartedAtUtc, x.ErrorMe |
| | 19 | 108 | | if (failed.Count == 0) return; |
| | | 109 | | |
| | 1 | 110 | | findings.Add(new SystemAlertFinding("order-returns-sync", "orders", |
| | 1 | 111 | | failed.Count >= Math.Max(1, settings.DangerFailureCount) ? "danger" : "warning", |
| | 1 | 112 | | "Синхронизация возвратов Ozon завершается с ошибкой", |
| | 1 | 113 | | [$"Ошибок после последнего успешного запуска: {failed.Count}", "Последняя: " + (failed[0].ErrorMessage ?? "б |
| | 1 | 114 | | "/Studio2/OrderReturns")); |
| | 10 | 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 | | { |
| | 10 | 123 | | var since = (await GetDatabaseNowAsync(ct)).AddHours(-settings.WindowHoursFor("orders-null-reset")); |
| | 10 | 124 | | var resets = await context.OrderHistories.AsNoTracking() |
| | 10 | 125 | | .Where(x => x.ChangedAt >= since && x.NewValue == "NULL" && x.OldValue != null && x.OldValue != "NULL" |
| | 10 | 126 | | && NullResetColumns.Contains(x.ColumnName!)) |
| | 10 | 127 | | .Select(x => new { x.OrderId, x.ColumnName }) |
| | 10 | 128 | | .ToListAsync(ct); |
| | 19 | 129 | | if (resets.Count == 0) return; |
| | | 130 | | |
| | 7 | 131 | | var orders = resets.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList(); |
| | 7 | 132 | | var columns = resets.GroupBy(x => x.ColumnName).Select(x => $"{x.Key}: {x.Count()}"); |
| | 1 | 133 | | findings.Add(new SystemAlertFinding("orders-null-reset", "orders", |
| | 1 | 134 | | orders.Count >= Math.Max(1, settings.NullResetDangerCount) ? "danger" : "warning", |
| | 1 | 135 | | $"У заказов сбросились ключевые поля в NULL: {orders.Count}", |
| | 1 | 136 | | ["Поля: " + string.Join("; ", columns), |
| | 3 | 137 | | "Заказы: " + string.Join(", ", orders.Take(MaxOrdersInLine).Select(x => "#" + x)), |
| | 1 | 138 | | "Восстановление: навык restore-order-null-fields"], |
| | 1 | 139 | | SystemAlertLinks.Orders(orders), |
| | 4 | 140 | | Orders: SystemAlertOrderRef.Build(resets.Select(x => (x.OrderId, "Сброшено в NULL: " + x.ColumnName))))); |
| | 10 | 141 | | } |
| | | 142 | | |
| | | 143 | | /// <summary>10. Проведённые в 1С заказы без расчёта себестоимости и убыточные проведения.</summary> |
| | | 144 | | private async Task AddPostedCostProblemsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateT |
| | | 145 | | { |
| | 10 | 146 | | var since = now.AddHours(-settings.WindowHoursFor("posted-cost-lost")); |
| | 10 | 147 | | var posted = context.OrderHistoryOneCPostings.AsNoTracking().Where(x => x.IsPosted && x.PostedAt >= since) |
| | 10 | 148 | | .Select(x => x.OrderId).Distinct(); |
| | 10 | 149 | | var orders = context.Set<Order>().AsNoTracking().Where(x => posted.Contains(x.Id)); |
| | | 150 | | |
| | 10 | 151 | | var lost = await orders.Where(x => x.PurchasePrice > 0 && x.CostPrice == null).Select(x => x.Id).ToListAsync(ct) |
| | 10 | 152 | | if (lost.Count > 0) |
| | 2 | 153 | | findings.Add(new SystemAlertFinding("posted-cost-lost", "orders", |
| | 2 | 154 | | lost.Count >= Math.Max(1, settings.PostedCostLostDangerCount) ? "danger" : "warning", |
| | 2 | 155 | | $"Проведены в 1С без расчёта себестоимости: {lost.Count}", |
| | 2 | 156 | | ["Закупочная цена есть, себестоимость пустая — расчётные поля могли потеряться при проведении.", |
| | 6 | 157 | | "Заказы: " + string.Join(", ", lost.OrderByDescending(x => x).Take(MaxOrdersInLine).Select(x => "#" |
| | 2 | 158 | | SystemAlertLinks.Orders(lost), |
| | 5 | 159 | | Orders: SystemAlertOrderRef.Build(lost.Select(x => (x, "Проведён без себестоимости при заданной закупочн |
| | | 160 | | |
| | 10 | 161 | | var loss = await orders.Where(x => x.MaxProfit < 0).Select(x => x.Id).ToListAsync(ct); |
| | 10 | 162 | | if (loss.Count >= Math.Max(1, settings.PostedLossMinCount)) |
| | 1 | 163 | | findings.Add(new SystemAlertFinding("posted-negative-margin", "orders", "warning", |
| | 1 | 164 | | $"Проведены убыточные заказы (отрицательная прибыль даже при максимальной комиссии): {loss.Count}", |
| | 6 | 165 | | ["Заказы: " + string.Join(", ", loss.OrderByDescending(x => x).Take(MaxOrdersInLine).Select(x => "#" + x |
| | 1 | 166 | | SystemAlertLinks.Orders(loss), |
| | 4 | 167 | | Orders: SystemAlertOrderRef.Build(loss.Select(x => (x, "Убыточное проведение: прибыль отрицательна даже |
| | | 168 | | |
| | 10 | 169 | | var noCost = await orders.Where(x => x.CostPrice == null && x.PurchasePrice == null).Select(x => x.Id).ToListAsy |
| | 10 | 170 | | if (noCost.Count > 0) |
| | 1 | 171 | | findings.Add(new SystemAlertFinding("posted-without-cost", "orders", "info", |
| | 1 | 172 | | $"Проведены в 1С без закупочной цены и себестоимости: {noCost.Count}", ["Для таких заказов прибыль не по |
| | 1 | 173 | | SystemAlertLinks.Orders(noCost), |
| | 2 | 174 | | Orders: SystemAlertOrderRef.Build(noCost.Select(x => (x, "Проведён без закупочной цены и себестоимости") |
| | 10 | 175 | | } |
| | | 176 | | |
| | | 177 | | /// <summary>Время БД: <c>orders_history.ChangedAt</c> пишет триггер по часам сервера БД, а не приложения.</summary> |
| | | 178 | | private async Task<DateTime> GetDatabaseNowAsync(CancellationToken ct) |
| | | 179 | | { |
| | | 180 | | try |
| | | 181 | | { |
| | 10 | 182 | | return await context.Database.SqlQueryRaw<DateTime>("SELECT NOW(6) AS Value").FirstAsync(ct); |
| | | 183 | | } |
| | 10 | 184 | | catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) |
| | | 185 | | { |
| | 10 | 186 | | return DateTime.UtcNow; |
| | | 187 | | } |
| | 10 | 188 | | } |
| | | 189 | | } |
| | | 190 | | |
| | | 191 | | /// <summary> |
| | | 192 | | /// Остатки и 1С: расписания BitrixStock, массовое обнуление остатков при выгрузке, свежесть snapshot склада |
| | | 193 | | /// и «зонд» 1С OData (включая фильтр по GUID — на нём уже была тихая регрессия). |
| | | 194 | | /// </summary> |
| | | 195 | | public 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 | | } |