S8WatchSchedulerService.cs 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  4. using Microsoft.Extensions.Logging;
  5. using SqlSugar;
  6. using System.Data;
  7. using System.Globalization;
  8. using System.Text.Json;
  9. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  10. /// <summary>
  11. /// 监视规则轮询调度服务(首轮存根)。
  12. /// 后续接入 Admin.NET 定时任务机制后,由调度器周期调用 <see cref="RunOnceAsync"/>,
  13. /// 按各规则的 PollIntervalSeconds 逐条评估并生成异常记录。
  14. /// </summary>
  15. public class S8WatchSchedulerService : ITransient
  16. {
  17. private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
  18. private readonly SqlSugarRepository<AdoS8AlertRule> _alertRuleRep;
  19. private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
  20. private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
  21. private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
  22. private readonly S8NotificationService _notificationService;
  23. private readonly S8ManualReportService _manualReportService;
  24. private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
  25. private readonly S8ShortageRuleEvaluator _shortageEvaluator;
  26. private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
  27. private readonly ILogger<S8WatchSchedulerService> _logger;
  28. private readonly SqlSugarRepository<AdoS8DetectionLog> _detectionLogRep;
  29. private const string DetectionTriggerSource = "WATCH_SCHEDULER";
  30. private const string DetectResultCreated = "CREATED";
  31. private const string DetectResultRefreshed = "REFRESHED";
  32. private const string DetectResultRecovered = "RECOVERED";
  33. private const string DetectResultNoHit = "NO_HIT";
  34. private const string DetectResultEvaluateFailed = "EVALUATE_FAILED";
  35. private const string DefaultTriggerType = "VALUE_DEVIATION";
  36. private const string SqlDataSourceType = "SQL";
  37. // G01-05 未闭环状态集合:复用自 S8ExceptionService 当前 pendingStatuses 事实口径
  38. // (见 S8ExceptionService.GetPagedAsync 中 pendingStatuses 的定义,两处必须保持一致)。
  39. // 这不是“自定义未闭环集合”;若现有口径调整,两处需同步修改。
  40. private static readonly string[] UnclosedExceptionStatuses =
  41. { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION" };
  42. public S8WatchSchedulerService(
  43. SqlSugarRepository<AdoS8WatchRule> ruleRep,
  44. SqlSugarRepository<AdoS8AlertRule> alertRuleRep,
  45. SqlSugarRepository<AdoS8DataSource> dataSourceRep,
  46. SqlSugarRepository<AdoS8Exception> exceptionRep,
  47. SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
  48. S8NotificationService notificationService,
  49. S8ManualReportService manualReportService,
  50. S8TimeoutRuleEvaluator timeoutEvaluator,
  51. S8ShortageRuleEvaluator shortageEvaluator,
  52. S8OutOfRangeRuleEvaluator outOfRangeEvaluator,
  53. ILogger<S8WatchSchedulerService> logger,
  54. SqlSugarRepository<AdoS8DetectionLog> detectionLogRep)
  55. {
  56. _ruleRep = ruleRep;
  57. _alertRuleRep = alertRuleRep;
  58. _dataSourceRep = dataSourceRep;
  59. _exceptionRep = exceptionRep;
  60. _exceptionTypeRep = exceptionTypeRep;
  61. _notificationService = notificationService;
  62. _manualReportService = manualReportService;
  63. _timeoutEvaluator = timeoutEvaluator;
  64. _shortageEvaluator = shortageEvaluator;
  65. _outOfRangeEvaluator = outOfRangeEvaluator;
  66. _logger = logger;
  67. _detectionLogRep = detectionLogRep;
  68. }
  69. public async Task<List<S8WatchExecutionRule>> LoadExecutionRulesAsync(long tenantId, long factoryId)
  70. {
  71. // R3 OUT_OF_RANGE 重写后,新三类(OUT_OF_RANGE/TIMEOUT/SHORTAGE)改走 ProcessRulesByTypeAsync。
  72. // 旧 AlertRule 兼容主链此处只装载 RuleType 为空/未分类的历史规则,避免新旧双跑导致重复建单。
  73. var watchRules = await _ruleRep.AsQueryable()
  74. .Where(x => x.TenantId == tenantId
  75. && x.FactoryId == factoryId
  76. && x.Enabled
  77. && x.SceneCode == S8SceneCode.S2S6Production
  78. && (x.RuleType == null || x.RuleType == ""))
  79. .ToListAsync();
  80. var deviceRules = watchRules
  81. .Where(x => IsDeviceWatchObjectType(x.WatchObjectType))
  82. .ToList();
  83. if (deviceRules.Count == 0) return new();
  84. var dataSourceIds = deviceRules
  85. .Select(x => x.DataSourceId)
  86. .Distinct()
  87. .ToList();
  88. var dataSources = await _dataSourceRep.AsQueryable()
  89. .Where(x => x.TenantId == tenantId
  90. && x.FactoryId == factoryId
  91. && x.Enabled
  92. && dataSourceIds.Contains(x.Id))
  93. .ToListAsync();
  94. var dataSourceMap = dataSources.ToDictionary(x => x.Id);
  95. if (dataSourceMap.Count == 0) return new();
  96. var alertRules = (await _alertRuleRep.AsQueryable()
  97. .Where(x => x.TenantId == tenantId
  98. && x.FactoryId == factoryId
  99. && x.SceneCode == S8SceneCode.S2S6Production)
  100. .ToListAsync())
  101. .Where(IsSupportedAlertRule)
  102. .ToList();
  103. // G-01 首版 AlertRule 冲突口径(C 收口):
  104. // 当前场景存在多条可运行 AlertRule 时,视为“当前规则配置冲突”并跳过该规则,
  105. // 不按“首条”继续运行,也不扩大为“整场景停摆”。
  106. // 当前模型下所有 device watchRule 共享同场景 AlertRule,故冲突态下所有 device 规则均跳过,
  107. // 但此处按“逐规则跳过”的语义实现,避免被误读为“整场景 return empty 停摆”。
  108. var alertRule = alertRules.Count == 1 ? alertRules[0] : null;
  109. var executionRules = new List<S8WatchExecutionRule>();
  110. foreach (var watchRule in deviceRules.OrderBy(x => x.Id))
  111. {
  112. // 配置冲突:当前规则跳过(不停摆其他规则)。
  113. if (alertRule == null)
  114. continue;
  115. if (!dataSourceMap.TryGetValue(watchRule.DataSourceId, out var dataSource))
  116. continue;
  117. if (!IsSupportedSqlDataSource(dataSource))
  118. continue;
  119. executionRules.Add(new S8WatchExecutionRule
  120. {
  121. WatchRuleId = watchRule.Id,
  122. WatchRuleCode = watchRule.RuleCode,
  123. SceneCode = watchRule.SceneCode,
  124. TriggerType = DefaultTriggerType,
  125. WatchObjectType = watchRule.WatchObjectType.Trim(),
  126. DataSourceId = dataSource.Id,
  127. DataSourceCode = dataSource.DataSourceCode,
  128. DataSourceType = dataSource.Type,
  129. DataSourceConnection = dataSource.Endpoint?.Trim() ?? string.Empty,
  130. QueryExpression = watchRule.Expression?.Trim() ?? string.Empty,
  131. PollIntervalSeconds = watchRule.PollIntervalSeconds,
  132. AlertRuleId = alertRule.Id,
  133. AlertRuleCode = alertRule.RuleCode,
  134. TriggerCondition = alertRule.TriggerCondition!.Trim(),
  135. ThresholdValue = alertRule.ThresholdVal!.Trim(),
  136. Severity = alertRule.Severity
  137. });
  138. }
  139. return executionRules;
  140. }
  141. public async Task<List<S8WatchDeviceQueryResult>> QueryDeviceRowsAsync(long tenantId, long factoryId)
  142. {
  143. var executionRules = await LoadExecutionRulesAsync(tenantId, factoryId);
  144. var results = new List<S8WatchDeviceQueryResult>();
  145. foreach (var rule in executionRules)
  146. results.Add(await QueryDeviceRowsAsync(rule));
  147. return results;
  148. }
  149. public async Task<S8WatchDeviceQueryResult> QueryDeviceRowsAsync(S8WatchExecutionRule rule)
  150. {
  151. if (!string.Equals(rule.DataSourceType, SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
  152. return S8WatchDeviceQueryResult.Fail(rule, "数据源类型不是 SQL,已跳过");
  153. if (string.IsNullOrWhiteSpace(rule.QueryExpression))
  154. return S8WatchDeviceQueryResult.Fail(rule, "查询表达式为空,已跳过");
  155. try
  156. {
  157. using var db = CreateSqlQueryScope(rule.DataSourceConnection);
  158. var table = await db.Ado.GetDataTableAsync(rule.QueryExpression);
  159. if (!HasRequiredColumns(table))
  160. return S8WatchDeviceQueryResult.Fail(rule, "查询结果缺少 required columns: related_object_code/current_value");
  161. var rows = table.Rows.Cast<DataRow>()
  162. .Select(MapDeviceRow)
  163. .Where(x => !string.IsNullOrWhiteSpace(x.RelatedObjectCode))
  164. .ToList();
  165. return S8WatchDeviceQueryResult.Ok(rule, rows);
  166. }
  167. catch (Exception ex)
  168. {
  169. return S8WatchDeviceQueryResult.Fail(rule, $"查询执行失败: {ex.Message}");
  170. }
  171. }
  172. /// <summary>
  173. /// G01-04:基于设备级结果行集做首版 VALUE_DEVIATION 单阈值判定,
  174. /// 产出命中结果对象列表,供 G01-05 去重与 G01-06 建单消费。
  175. /// 本方法不做去重、不做建单、不做严重度重算、不做时间线。
  176. /// </summary>
  177. public async Task<List<S8WatchHitResult>> EvaluateHitsAsync(long tenantId, long factoryId)
  178. {
  179. var executionRules = await LoadExecutionRulesAsync(tenantId, factoryId);
  180. var ruleMap = executionRules.ToDictionary(x => x.WatchRuleId);
  181. var queryResults = new List<S8WatchDeviceQueryResult>();
  182. foreach (var rule in executionRules)
  183. queryResults.Add(await QueryDeviceRowsAsync(rule));
  184. var hits = new List<S8WatchHitResult>();
  185. foreach (var queryResult in queryResults)
  186. {
  187. // G01-03 查询失败:跳过,不进入判定。
  188. if (!queryResult.Success) continue;
  189. if (!ruleMap.TryGetValue(queryResult.WatchRuleId, out var rule)) continue;
  190. // 判定参数缺失:跳过当前规则。
  191. if (string.IsNullOrWhiteSpace(rule.TriggerCondition)
  192. || string.IsNullOrWhiteSpace(rule.ThresholdValue))
  193. continue;
  194. // 比较符非法:跳过当前规则。
  195. var op = TryParseTriggerCondition(rule.TriggerCondition);
  196. if (op == null) continue;
  197. // ThresholdValue 非法:跳过当前规则。
  198. if (!TryParseDecimal(rule.ThresholdValue, out var threshold)) continue;
  199. foreach (var row in queryResult.Rows)
  200. {
  201. // CurrentValue 非法(null / 无法解析数值):跳过当前行,不进入判定。
  202. if (row.CurrentValue == null) continue;
  203. // 未命中:不进入后续链路。
  204. if (!EvaluateHit(row.CurrentValue.Value, op, threshold)) continue;
  205. hits.Add(new S8WatchHitResult
  206. {
  207. SourceRuleId = rule.WatchRuleId,
  208. SourceRuleCode = rule.WatchRuleCode,
  209. AlertRuleId = rule.AlertRuleId,
  210. DataSourceId = rule.DataSourceId,
  211. RelatedObjectCode = row.RelatedObjectCode,
  212. CurrentValue = row.CurrentValue.Value,
  213. ThresholdValue = threshold,
  214. TriggerCondition = op,
  215. Severity = rule.Severity,
  216. OccurrenceDeptId = row.OccurrenceDeptId,
  217. ResponsibleDeptId = row.ResponsibleDeptId,
  218. SourcePayload = row.SourcePayload
  219. });
  220. }
  221. }
  222. return hits;
  223. }
  224. /// <summary>
  225. /// G01-05:未闭环异常去重最小实现。
  226. /// 消费 G01-04 产出的 <see cref="S8WatchHitResult"/>,按 (SourceRuleId + RelatedObjectCode)
  227. /// 在未闭环状态集合内判重,只回答“是否允许建单”。
  228. /// 首版明确不做:原单刷新 / 时间线追加 / payload 更新 / 次数累计 / 严重度重算 / 状态修复。
  229. /// </summary>
  230. public async Task<List<S8WatchDedupResult>> EvaluateDedupAsync(long tenantId, long factoryId)
  231. {
  232. var hits = await EvaluateHitsAsync(tenantId, factoryId);
  233. var results = new List<S8WatchDedupResult>(hits.Count);
  234. foreach (var hit in hits)
  235. {
  236. // 防御性分支:正常情况下 SourceRuleId 与 RelatedObjectCode 已由上游
  237. // (G01-02 规则装配 + G01-03 查询结果列校验)保证;此处仅作兜底,
  238. // 不是首版正常路径。
  239. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  240. {
  241. results.Add(new S8WatchDedupResult
  242. {
  243. Hit = hit,
  244. CanCreate = false,
  245. MatchedExceptionId = null,
  246. Reason = "missing_dedup_key"
  247. });
  248. continue;
  249. }
  250. long matchedId;
  251. try
  252. {
  253. matchedId = await FindPendingExceptionIdAsync(
  254. tenantId, factoryId, hit.SourceRuleId, hit.RelatedObjectCode);
  255. }
  256. catch
  257. {
  258. // 去重查询失败:首版偏保守,宁可阻止也不重复建单,不扩展为补偿。
  259. results.Add(new S8WatchDedupResult
  260. {
  261. Hit = hit,
  262. CanCreate = false,
  263. MatchedExceptionId = null,
  264. Reason = "query_failed"
  265. });
  266. continue;
  267. }
  268. if (matchedId > 0)
  269. {
  270. // 命中已有未闭环异常 → 阻止建单。
  271. results.Add(new S8WatchDedupResult
  272. {
  273. Hit = hit,
  274. CanCreate = false,
  275. MatchedExceptionId = matchedId,
  276. Reason = "duplicate_pending"
  277. });
  278. }
  279. else
  280. {
  281. // 未命中 → 允许建单,交 G01-06 消费。
  282. results.Add(new S8WatchDedupResult
  283. {
  284. Hit = hit,
  285. CanCreate = true,
  286. MatchedExceptionId = null,
  287. Reason = "no_pending"
  288. });
  289. }
  290. }
  291. return results;
  292. }
  293. // 取任意一条匹配的未闭环异常 Id 作为“是否存在重复单”的拦截依据。
  294. // 首版只需要“存在性”,不关心“最早 / 最新”;不在 G01-05 处理排序语义。
  295. private async Task<long> FindPendingExceptionIdAsync(
  296. long tenantId, long factoryId, long sourceRuleId, string relatedObjectCode)
  297. {
  298. // SqlSugar 表达式翻译要求 Contains 数组变量必须可访问;此处将类级常量
  299. // 承接到方法内局部变量,仅为表达式翻译服务,值与 UnclosedExceptionStatuses 一致。
  300. var statuses = UnclosedExceptionStatuses;
  301. var ids = await _exceptionRep.AsQueryable()
  302. .Where(x => x.TenantId == tenantId
  303. && x.FactoryId == factoryId
  304. && !x.IsDeleted
  305. && x.SourceRuleId == sourceRuleId
  306. && x.RelatedObjectCode == relatedObjectCode
  307. && statuses.Contains(x.Status))
  308. .Select(x => x.Id)
  309. .Take(1)
  310. .ToListAsync();
  311. return ids.Count > 0 ? ids[0] : 0L;
  312. }
  313. /// <summary>
  314. /// G01-06:自动建单入口。消费 G01-05 去重结果,对 CanCreate==true 的命中
  315. /// 复用 S8ManualReportService.CreateFromWatchAsync(同一主链的自动建单分支)落成标准 AdoS8Exception。
  316. /// CanCreate==false 直接跳过;创建失败返回最小失败结果,不补偿、不重试、不对账。
  317. /// </summary>
  318. public async Task<List<S8WatchCreationResult>> CreateExceptionsAsync(long tenantId, long factoryId)
  319. {
  320. var dedupResults = await EvaluateDedupAsync(tenantId, factoryId);
  321. var results = new List<S8WatchCreationResult>(dedupResults.Count);
  322. foreach (var dedup in dedupResults)
  323. {
  324. if (!dedup.CanCreate)
  325. {
  326. results.Add(new S8WatchCreationResult
  327. {
  328. DedupResult = dedup,
  329. Created = false,
  330. Skipped = true,
  331. CreatedExceptionId = null,
  332. Reason = dedup.Reason,
  333. ErrorMessage = null
  334. });
  335. continue;
  336. }
  337. try
  338. {
  339. var entity = await _manualReportService.CreateFromWatchAsync(dedup.Hit);
  340. results.Add(new S8WatchCreationResult
  341. {
  342. DedupResult = dedup,
  343. Created = true,
  344. Skipped = false,
  345. CreatedExceptionId = entity.Id,
  346. Reason = "auto_created",
  347. ErrorMessage = null
  348. });
  349. }
  350. catch (Exception ex)
  351. {
  352. results.Add(new S8WatchCreationResult
  353. {
  354. DedupResult = dedup,
  355. Created = false,
  356. Skipped = false,
  357. CreatedExceptionId = null,
  358. Reason = "create_failed",
  359. ErrorMessage = ex.Message
  360. });
  361. }
  362. }
  363. // R3-OUT_OF_RANGE-REWRITE-1:三类正式 evaluator 路径。
  364. // 旧 AlertRule 兼容主链(上方 dedupResults 循环)已在 LoadExecutionRulesAsync 处过滤掉
  365. // OUT_OF_RANGE/TIMEOUT/SHORTAGE,只保留未分类历史规则;故新旧不双跑。
  366. // R6 RunId:本次 CreateExceptionsAsync 调用对应的统一关联 id,落入 detection_log。
  367. var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
  368. results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, runId));
  369. results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, runId));
  370. results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, runId));
  371. return results;
  372. }
  373. /// <summary>
  374. /// R2 TIMEOUT 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
  375. /// </summary>
  376. public Task<List<S8WatchCreationResult>> ProcessTimeoutRulesAsync(long tenantId, long factoryId) =>
  377. ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
  378. /// <summary>
  379. /// R3 SHORTAGE 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
  380. /// </summary>
  381. public Task<List<S8WatchCreationResult>> ProcessShortageRulesAsync(long tenantId, long factoryId) =>
  382. ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
  383. /// <summary>
  384. /// R3-OUT_OF_RANGE-REWRITE-1:OUT_OF_RANGE 类规则主链。
  385. /// 复用 <see cref="ProcessRulesByTypeAsync"/>,并对历史 dedup_key=NULL 的旧记录做 compat fallback:
  386. /// (source_rule_id=rule.Id AND related_object_code=hit AND status!=CLOSED AND dedup_key IS NULL AND is_deleted=0)
  387. /// 命中则 backfill 6 列,避免重复建单。RunId 由内部生成。
  388. /// </summary>
  389. public Task<List<S8WatchCreationResult>> ProcessOutOfRangeRulesAsync(long tenantId, long factoryId) =>
  390. ProcessRulesByTypeAsync(tenantId, factoryId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
  391. /// <summary>
  392. /// R2/R3 通用规则主链:装载 enabled WatchRule.RuleType=ruleType → evaluator → dedup_key 去重 → 建单/刷新。
  393. /// dedup 命中:UPDATE last_detected_at + source_payload,不重复建单;
  394. /// dedup 未命中:校验 ExceptionTypeCode 是否在 baseline(tenant=0/factory=0 全局或本租户工厂),缺则跳过;
  395. /// 通过 → S8ManualReportService.CreateFromHitAsync 落标准 AdoS8Exception,新列全部回填。
  396. /// 不做 SLA 升级、不做事件触发、不做 RecoveredAt。
  397. /// </summary>
  398. private async Task<List<S8WatchCreationResult>> ProcessRulesByTypeAsync(
  399. long tenantId, long factoryId, IS8RuleEvaluator evaluator, string ruleType, string runId)
  400. {
  401. var results = new List<S8WatchCreationResult>();
  402. var rules = await _ruleRep.AsQueryable()
  403. .Where(x => x.TenantId == tenantId
  404. && x.FactoryId == factoryId
  405. && x.Enabled
  406. && x.RuleType == ruleType)
  407. .ToListAsync();
  408. if (rules.Count == 0) return results;
  409. var alertRules = (await _alertRuleRep.AsQueryable()
  410. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
  411. .ToListAsync()).AsReadOnly();
  412. foreach (var rule in rules.OrderBy(x => x.Id))
  413. {
  414. List<S8RuleHit> hits;
  415. try
  416. {
  417. hits = await evaluator.EvaluateAsync(tenantId, factoryId, rule, alertRules);
  418. }
  419. catch (Exception ex)
  420. {
  421. // R6 EVALUATE_FAILED 日志:evaluator 抛 S8RuleEvaluatorException 走这里,包含 reason+message。
  422. var failureReason = ex is S8RuleEvaluatorException sre ? sre.Reason : ex.GetType().Name;
  423. await WriteDetectionLogAsync(new AdoS8DetectionLog
  424. {
  425. TenantId = tenantId, FactoryId = factoryId,
  426. RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
  427. SourceObjectType = rule.SourceObjectType,
  428. DetectResult = DetectResultEvaluateFailed,
  429. DetectedAt = DateTime.Now,
  430. FailureReason = failureReason,
  431. FailureMessage = Truncate(ex.Message, 1000),
  432. RunId = runId, TriggerSource = DetectionTriggerSource
  433. });
  434. results.Add(BuildSkipResult(rule, "evaluate_failed", ex.Message));
  435. continue;
  436. }
  437. // R5-RECOVERY-MINIMAL-1:evaluator 成功执行后调和 recovered_at。
  438. // 仅在 evaluator 成功(throw 不会到这里)时才能判定"未命中"。仅写 recovered_at + updated_at,
  439. // 绝不动 status / assignee / verifier / source_payload / last_detected_at。
  440. // R6 reconcile 返回 recoveredIds 用于决定是否写 NO_HIT。
  441. List<long> recoveredIds = new();
  442. try
  443. {
  444. recoveredIds = await ReconcileRecoveriesForRuleAsync(tenantId, factoryId, rule, ruleType, hits, runId);
  445. }
  446. catch (Exception ex)
  447. {
  448. _logger.LogWarning(ex, "recovery_reconcile_failed ruleCode={RuleCode} ruleType={RuleType}", rule.RuleCode, ruleType);
  449. }
  450. // R6 NO_HIT 日志:evaluator 成功且无 hit、且本轮 reconcile 未写任何 RECOVERED → 每条 rule 一条 NO_HIT。
  451. if (hits.Count == 0 && recoveredIds.Count == 0)
  452. {
  453. await WriteDetectionLogAsync(new AdoS8DetectionLog
  454. {
  455. TenantId = tenantId, FactoryId = factoryId,
  456. RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
  457. SourceObjectType = rule.SourceObjectType,
  458. DetectResult = DetectResultNoHit,
  459. DetectedAt = DateTime.Now,
  460. RunId = runId, TriggerSource = DetectionTriggerSource
  461. });
  462. }
  463. foreach (var hit in hits)
  464. {
  465. if (string.IsNullOrWhiteSpace(hit.DedupKey))
  466. {
  467. results.Add(BuildSkipResult(rule, "missing_dedup_key", null, hit));
  468. continue;
  469. }
  470. long matchedId;
  471. try
  472. {
  473. matchedId = await FindOpenExceptionByDedupKeyAsync(tenantId, factoryId, hit.DedupKey);
  474. }
  475. catch (Exception ex)
  476. {
  477. results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
  478. continue;
  479. }
  480. if (matchedId > 0)
  481. {
  482. try
  483. {
  484. await RefreshDetectionAsync(matchedId, hit);
  485. await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, matchedId, runId));
  486. results.Add(BuildSkippedDuplicate(rule, hit, matchedId));
  487. }
  488. catch (Exception ex)
  489. {
  490. results.Add(BuildSkipResult(rule, "refresh_failed", ex.Message, hit));
  491. }
  492. continue;
  493. }
  494. // R3-OUT_OF_RANGE-REWRITE-1 compat fallback:dedup_key 未命中且 ruleType=OUT_OF_RANGE 时,
  495. // 尝试匹配历史 dedup_key=NULL 的旧 AlertRule 主链记录(如 id=34),命中则 backfill 6 列,避免重复建单。
  496. if (string.Equals(ruleType, S8OutOfRangeRuleEvaluator.RuleTypeCode, StringComparison.OrdinalIgnoreCase))
  497. {
  498. long compatId;
  499. try
  500. {
  501. compatId = await FindLegacyOutOfRangeExceptionAsync(tenantId, factoryId, rule.Id, hit.RelatedObjectCode);
  502. }
  503. catch (Exception ex)
  504. {
  505. results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
  506. continue;
  507. }
  508. if (compatId > 0)
  509. {
  510. try
  511. {
  512. await BackfillLegacyExceptionAsync(compatId, hit);
  513. await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, compatId, runId));
  514. results.Add(BuildSkippedDuplicate(rule, hit, compatId));
  515. }
  516. catch (Exception ex)
  517. {
  518. results.Add(BuildSkipResult(rule, "refresh_failed", ex.Message, hit));
  519. }
  520. continue;
  521. }
  522. }
  523. bool typeExists;
  524. try
  525. {
  526. typeExists = await _exceptionTypeRep.AsQueryable()
  527. .Where(t => t.TypeCode == hit.ExceptionTypeCode
  528. && (t.TenantId == 0 || t.TenantId == tenantId)
  529. && (t.FactoryId == 0 || t.FactoryId == factoryId)
  530. && t.Enabled)
  531. .AnyAsync();
  532. }
  533. catch (Exception ex)
  534. {
  535. results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
  536. continue;
  537. }
  538. if (!typeExists)
  539. {
  540. results.Add(BuildSkipResult(rule, "exception_type_missing", null, hit));
  541. continue;
  542. }
  543. try
  544. {
  545. var entity = await _manualReportService.CreateFromHitAsync(hit);
  546. await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
  547. results.Add(BuildCreatedResult(rule, hit, entity.Id));
  548. }
  549. catch (Exception ex)
  550. {
  551. results.Add(BuildSkipResult(rule, "create_failed", ex.Message, hit));
  552. }
  553. }
  554. }
  555. return results;
  556. }
  557. private async Task<long> FindOpenExceptionByDedupKeyAsync(long tenantId, long factoryId, string dedupKey)
  558. {
  559. var ids = await _exceptionRep.AsQueryable()
  560. .Where(x => x.TenantId == tenantId
  561. && x.FactoryId == factoryId
  562. && !x.IsDeleted
  563. && x.Status != "CLOSED"
  564. && x.DedupKey == dedupKey)
  565. .Select(x => x.Id)
  566. .Take(1)
  567. .ToListAsync();
  568. return ids.Count > 0 ? ids[0] : 0L;
  569. }
  570. /// <summary>
  571. /// R3 OUT_OF_RANGE compat fallback 查找:用 (source_rule_id, related_object_code, status!=CLOSED,
  572. /// dedup_key IS NULL, is_deleted=0) 严格条件定位旧 AlertRule 主链留下的历史记录。
  573. /// </summary>
  574. private async Task<long> FindLegacyOutOfRangeExceptionAsync(long tenantId, long factoryId, long sourceRuleId, string relatedObjectCode)
  575. {
  576. if (string.IsNullOrWhiteSpace(relatedObjectCode)) return 0L;
  577. var ids = await _exceptionRep.AsQueryable()
  578. .Where(x => x.TenantId == tenantId
  579. && x.FactoryId == factoryId
  580. && !x.IsDeleted
  581. && x.Status != "CLOSED"
  582. && x.DedupKey == null
  583. && x.SourceRuleId == sourceRuleId
  584. && x.RelatedObjectCode == relatedObjectCode)
  585. .Select(x => x.Id)
  586. .Take(1)
  587. .ToListAsync();
  588. return ids.Count > 0 ? ids[0] : 0L;
  589. }
  590. /// <summary>
  591. /// R3 OUT_OF_RANGE compat fallback backfill:把历史记录的 R1 新 6 列(dedup_key/source_rule_code/
  592. /// source_object_type/source_object_id/source_payload/last_detected_at)写入,并刷新 updated_at。
  593. /// </summary>
  594. private async Task BackfillLegacyExceptionAsync(long exceptionId, S8RuleHit hit)
  595. {
  596. await _exceptionRep.Context.Updateable<AdoS8Exception>()
  597. .SetColumns(x => new AdoS8Exception
  598. {
  599. DedupKey = hit.DedupKey,
  600. SourceRuleCode = hit.SourceRuleCode,
  601. SourceObjectType = hit.SourceObjectType,
  602. SourceObjectId = hit.SourceObjectId,
  603. SourcePayload = hit.SourcePayload,
  604. LastDetectedAt = hit.DetectedAt,
  605. UpdatedAt = DateTime.Now
  606. })
  607. .Where(x => x.Id == exceptionId)
  608. .ExecuteCommandAsync();
  609. }
  610. /// <summary>
  611. /// R5 恢复时间最小闭环:对当前 rule 下未关闭、有 dedup_key、recovered_at 仍为 NULL 的异常,
  612. /// 凡不在本轮 hits.dedup_key 集合内的,写入 recovered_at = now、updated_at = now。
  613. /// 仅写这 2 列;不动 status / assignee / verifier / source_payload / last_detected_at;
  614. /// recovered_at 一旦写入,本轮不做复发清空。
  615. /// R6 返回 recoveredIds 供上游决定是否写 NO_HIT 日志,并对每个 recovered exception 写一条 RECOVERED 日志。
  616. /// </summary>
  617. private async Task<List<long>> ReconcileRecoveriesForRuleAsync(
  618. long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, List<S8RuleHit> hits, string runId)
  619. {
  620. var hitDedupKeys = hits
  621. .Where(h => !string.IsNullOrWhiteSpace(h.DedupKey))
  622. .Select(h => h.DedupKey)
  623. .ToHashSet(StringComparer.Ordinal);
  624. var candidates = await _exceptionRep.AsQueryable()
  625. .Where(x => x.TenantId == tenantId
  626. && x.FactoryId == factoryId
  627. && !x.IsDeleted
  628. && x.Status != "CLOSED"
  629. && x.SourceRuleCode == rule.RuleCode
  630. && x.DedupKey != null
  631. && x.RecoveredAt == null)
  632. .Select(x => new { x.Id, x.DedupKey, x.SourceObjectType, x.SourceObjectId, x.RelatedObjectCode })
  633. .ToListAsync();
  634. if (candidates.Count == 0) return new List<long>();
  635. var now = DateTime.Now;
  636. var recoveredIds = new List<long>();
  637. foreach (var c in candidates)
  638. {
  639. if (hitDedupKeys.Contains(c.DedupKey!)) continue;
  640. await _exceptionRep.Context.Updateable<AdoS8Exception>()
  641. .SetColumns(x => new AdoS8Exception
  642. {
  643. RecoveredAt = now,
  644. UpdatedAt = now
  645. })
  646. .Where(x => x.Id == c.Id)
  647. .ExecuteCommandAsync();
  648. recoveredIds.Add(c.Id);
  649. await WriteDetectionLogAsync(new AdoS8DetectionLog
  650. {
  651. TenantId = tenantId, FactoryId = factoryId,
  652. RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
  653. SourceObjectType = c.SourceObjectType, SourceObjectId = c.SourceObjectId,
  654. RelatedObjectCode = c.RelatedObjectCode, DedupKey = c.DedupKey,
  655. DetectResult = DetectResultRecovered,
  656. ExceptionId = c.Id,
  657. DetectedAt = now,
  658. PayloadSnapshot = JsonSerializer.Serialize(new { ruleId = rule.Id, ruleCode = rule.RuleCode, reason = "no_longer_hit" }),
  659. RunId = runId, TriggerSource = DetectionTriggerSource,
  660. Remark = "Rule no longer hit; recovered_at marked"
  661. });
  662. }
  663. if (recoveredIds.Count > 0)
  664. {
  665. _logger.LogInformation(
  666. "rule_recovered ruleCode={RuleCode} ruleType={RuleType} recoveredCount={Count} recoveredIds={Ids}",
  667. rule.RuleCode, ruleType, recoveredIds.Count, string.Join(",", recoveredIds));
  668. }
  669. return recoveredIds;
  670. }
  671. /// <summary>R6 通用 hit 日志构造(CREATED / REFRESHED 共用)。</summary>
  672. private static AdoS8DetectionLog BuildHitLog(
  673. long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, S8RuleHit hit,
  674. string detectResult, long exceptionId, string runId) => new()
  675. {
  676. TenantId = tenantId, FactoryId = factoryId,
  677. RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
  678. SourceObjectType = hit.SourceObjectType, SourceObjectId = hit.SourceObjectId,
  679. RelatedObjectCode = hit.RelatedObjectCode, DedupKey = hit.DedupKey,
  680. DetectResult = detectResult,
  681. ExceptionId = exceptionId,
  682. DetectedAt = hit.DetectedAt,
  683. PayloadSnapshot = hit.SourcePayload,
  684. RunId = runId,
  685. TriggerSource = DetectionTriggerSource
  686. };
  687. /// <summary>R6 日志写入:失败仅 LogWarning,不阻断主链;不抛异常。</summary>
  688. private async Task WriteDetectionLogAsync(AdoS8DetectionLog log)
  689. {
  690. try
  691. {
  692. await _detectionLogRep.InsertAsync(log);
  693. }
  694. catch (Exception ex)
  695. {
  696. _logger.LogWarning(ex,
  697. "detection_log_write_failed runId={RunId} detectResult={Result} ruleCode={RuleCode} exceptionId={ExceptionId}",
  698. log.RunId, log.DetectResult, log.RuleCode, log.ExceptionId);
  699. }
  700. }
  701. private static string Truncate(string? s, int max) =>
  702. string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s.Substring(0, max));
  703. private async Task RefreshDetectionAsync(long exceptionId, S8RuleHit hit)
  704. {
  705. await _exceptionRep.Context.Updateable<AdoS8Exception>()
  706. .SetColumns(x => new AdoS8Exception
  707. {
  708. LastDetectedAt = hit.DetectedAt,
  709. SourcePayload = hit.SourcePayload,
  710. UpdatedAt = DateTime.Now
  711. })
  712. .Where(x => x.Id == exceptionId)
  713. .ExecuteCommandAsync();
  714. }
  715. private static S8WatchCreationResult BuildCreatedResult(AdoS8WatchRule rule, S8RuleHit hit, long exceptionId) =>
  716. new()
  717. {
  718. DedupResult = new S8WatchDedupResult
  719. {
  720. Hit = ToWatchHit(rule, hit),
  721. CanCreate = true,
  722. MatchedExceptionId = null,
  723. Reason = "no_pending"
  724. },
  725. Created = true,
  726. Skipped = false,
  727. CreatedExceptionId = exceptionId,
  728. Reason = "auto_created",
  729. ErrorMessage = null
  730. };
  731. private static S8WatchCreationResult BuildSkippedDuplicate(AdoS8WatchRule rule, S8RuleHit hit, long matchedId) =>
  732. new()
  733. {
  734. DedupResult = new S8WatchDedupResult
  735. {
  736. Hit = ToWatchHit(rule, hit),
  737. CanCreate = false,
  738. MatchedExceptionId = matchedId,
  739. Reason = "duplicate_pending"
  740. },
  741. Created = false,
  742. Skipped = true,
  743. CreatedExceptionId = null,
  744. Reason = "duplicate_pending",
  745. ErrorMessage = null
  746. };
  747. private static S8WatchCreationResult BuildSkipResult(AdoS8WatchRule rule, string reason, string? error, S8RuleHit? hit = null) =>
  748. new()
  749. {
  750. DedupResult = new S8WatchDedupResult
  751. {
  752. Hit = hit != null ? ToWatchHit(rule, hit) : new S8WatchHitResult { SourceRuleId = rule.Id, SourceRuleCode = rule.RuleCode },
  753. CanCreate = false,
  754. MatchedExceptionId = null,
  755. Reason = reason
  756. },
  757. Created = false,
  758. Skipped = true,
  759. CreatedExceptionId = null,
  760. Reason = reason,
  761. ErrorMessage = error
  762. };
  763. private static S8WatchHitResult ToWatchHit(AdoS8WatchRule rule, S8RuleHit hit) => new()
  764. {
  765. SourceRuleId = hit.SourceRuleId == 0 ? rule.Id : hit.SourceRuleId,
  766. SourceRuleCode = string.IsNullOrEmpty(hit.SourceRuleCode) ? rule.RuleCode : hit.SourceRuleCode,
  767. DataSourceId = hit.DataSourceId,
  768. RelatedObjectCode = hit.RelatedObjectCode,
  769. Severity = hit.Severity,
  770. OccurrenceDeptId = hit.OccurrenceDeptId,
  771. ResponsibleDeptId = hit.ResponsibleDeptId,
  772. SourcePayload = hit.SourcePayload
  773. };
  774. /// <summary>
  775. /// 单次轮询入口。当前仅完成规则读取与组装,返回可执行规则数量,不做实际数据采集。
  776. /// </summary>
  777. public async Task<int> RunOnceAsync()
  778. {
  779. var executionRules = await LoadExecutionRulesAsync(1, 1);
  780. return executionRules.Count;
  781. }
  782. // G01-04 首版最小比较符集合:>, >=, <, <=。
  783. // 允许首尾空格;非此集合的一律视为“比较符非法”,由调用方跳过。
  784. private static string? TryParseTriggerCondition(string raw)
  785. {
  786. var normalized = raw.Trim();
  787. return normalized switch
  788. {
  789. ">" => ">",
  790. ">=" => ">=",
  791. "<" => "<",
  792. "<=" => "<=",
  793. _ => null
  794. };
  795. }
  796. private static bool TryParseDecimal(string raw, out decimal value) =>
  797. decimal.TryParse(raw.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out value);
  798. private static bool EvaluateHit(decimal current, string op, decimal threshold) => op switch
  799. {
  800. ">" => current > threshold,
  801. ">=" => current >= threshold,
  802. "<" => current < threshold,
  803. "<=" => current <= threshold,
  804. _ => false
  805. };
  806. private static bool IsSupportedAlertRule(AdoS8AlertRule alertRule) =>
  807. !string.IsNullOrWhiteSpace(alertRule.TriggerCondition)
  808. && !string.IsNullOrWhiteSpace(alertRule.ThresholdVal);
  809. private static bool IsSupportedSqlDataSource(AdoS8DataSource dataSource) =>
  810. dataSource.Enabled
  811. && string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase)
  812. && !string.IsNullOrWhiteSpace(dataSource.Endpoint);
  813. private static bool IsDeviceWatchObjectType(string? watchObjectType)
  814. {
  815. if (string.IsNullOrWhiteSpace(watchObjectType)) return false;
  816. var normalized = watchObjectType.Trim().ToUpperInvariant();
  817. return normalized is "DEVICE" or "EQUIPMENT" || watchObjectType.Trim() == "设备";
  818. }
  819. private SqlSugarScope CreateSqlQueryScope(string connectionString)
  820. {
  821. var dbType = _ruleRep.Context.CurrentConnectionConfig.DbType;
  822. return new SqlSugarScope(new ConnectionConfig
  823. {
  824. ConfigId = $"s8-watch-sql-{Guid.NewGuid():N}",
  825. DbType = dbType,
  826. ConnectionString = connectionString,
  827. InitKeyType = InitKeyType.Attribute,
  828. IsAutoCloseConnection = true
  829. });
  830. }
  831. private static bool HasRequiredColumns(DataTable table) =>
  832. TryGetColumnName(table.Columns, "related_object_code") != null
  833. && TryGetColumnName(table.Columns, "current_value") != null;
  834. private static S8WatchDeviceRow MapDeviceRow(DataRow row)
  835. {
  836. var columns = row.Table.Columns;
  837. var relatedObjectCodeColumn = TryGetColumnName(columns, "related_object_code");
  838. var currentValueColumn = TryGetColumnName(columns, "current_value");
  839. var occurrenceDeptIdColumn = TryGetColumnName(columns, "occurrence_dept_id");
  840. var responsibleDeptIdColumn = TryGetColumnName(columns, "responsible_dept_id");
  841. return new S8WatchDeviceRow
  842. {
  843. RelatedObjectCode = ReadString(row, relatedObjectCodeColumn),
  844. CurrentValue = ReadDecimal(row, currentValueColumn),
  845. OccurrenceDeptId = ReadLong(row, occurrenceDeptIdColumn),
  846. ResponsibleDeptId = ReadLong(row, responsibleDeptIdColumn),
  847. SourcePayload = BuildSourcePayload(row)
  848. };
  849. }
  850. private static string? TryGetColumnName(DataColumnCollection columns, string expectedName)
  851. {
  852. var normalizedExpected = NormalizeColumnName(expectedName);
  853. foreach (DataColumn column in columns)
  854. {
  855. if (NormalizeColumnName(column.ColumnName) == normalizedExpected)
  856. return column.ColumnName;
  857. }
  858. return null;
  859. }
  860. private static string NormalizeColumnName(string columnName) =>
  861. columnName.Replace("_", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
  862. private static string ReadString(DataRow row, string? columnName)
  863. {
  864. if (string.IsNullOrWhiteSpace(columnName)) return string.Empty;
  865. var value = row[columnName];
  866. return value == DBNull.Value ? string.Empty : Convert.ToString(value)?.Trim() ?? string.Empty;
  867. }
  868. private static long? ReadLong(DataRow row, string? columnName)
  869. {
  870. if (string.IsNullOrWhiteSpace(columnName)) return null;
  871. var value = row[columnName];
  872. if (value == DBNull.Value) return null;
  873. return long.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), out var result) ? result : null;
  874. }
  875. private static decimal? ReadDecimal(DataRow row, string? columnName)
  876. {
  877. if (string.IsNullOrWhiteSpace(columnName)) return null;
  878. var value = row[columnName];
  879. if (value == DBNull.Value) return null;
  880. return decimal.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var result)
  881. ? result
  882. : null;
  883. }
  884. private static string BuildSourcePayload(DataRow row)
  885. {
  886. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  887. foreach (DataColumn column in row.Table.Columns)
  888. {
  889. var value = row[column];
  890. payload[column.ColumnName] = value == DBNull.Value ? null : value;
  891. }
  892. return JsonSerializer.Serialize(payload);
  893. }
  894. }
  895. public sealed class S8WatchExecutionRule
  896. {
  897. public long WatchRuleId { get; set; }
  898. public string WatchRuleCode { get; set; } = string.Empty;
  899. public string SceneCode { get; set; } = string.Empty;
  900. public string TriggerType { get; set; } = string.Empty;
  901. public string WatchObjectType { get; set; } = string.Empty;
  902. public long DataSourceId { get; set; }
  903. public string DataSourceCode { get; set; } = string.Empty;
  904. public string DataSourceType { get; set; } = string.Empty;
  905. public string DataSourceConnection { get; set; } = string.Empty;
  906. public string QueryExpression { get; set; } = string.Empty;
  907. public int PollIntervalSeconds { get; set; }
  908. public long AlertRuleId { get; set; }
  909. public string AlertRuleCode { get; set; } = string.Empty;
  910. public string TriggerCondition { get; set; } = string.Empty;
  911. public string ThresholdValue { get; set; } = string.Empty;
  912. public string Severity { get; set; } = string.Empty;
  913. }
  914. public sealed class S8WatchDeviceQueryResult
  915. {
  916. public long WatchRuleId { get; set; }
  917. public string WatchRuleCode { get; set; } = string.Empty;
  918. public bool Success { get; set; }
  919. public string? FailureReason { get; set; }
  920. public List<S8WatchDeviceRow> Rows { get; set; } = new();
  921. public static S8WatchDeviceQueryResult Ok(S8WatchExecutionRule rule, List<S8WatchDeviceRow> rows) =>
  922. new()
  923. {
  924. WatchRuleId = rule.WatchRuleId,
  925. WatchRuleCode = rule.WatchRuleCode,
  926. Success = true,
  927. Rows = rows
  928. };
  929. public static S8WatchDeviceQueryResult Fail(S8WatchExecutionRule rule, string reason) =>
  930. new()
  931. {
  932. WatchRuleId = rule.WatchRuleId,
  933. WatchRuleCode = rule.WatchRuleCode,
  934. Success = false,
  935. FailureReason = reason
  936. };
  937. }
  938. public sealed class S8WatchDeviceRow
  939. {
  940. public string RelatedObjectCode { get; set; } = string.Empty;
  941. public decimal? CurrentValue { get; set; }
  942. public long? OccurrenceDeptId { get; set; }
  943. public long? ResponsibleDeptId { get; set; }
  944. public string SourcePayload { get; set; } = string.Empty;
  945. }
  946. /// <summary>
  947. /// G01-05 去重结果对象。仅服务 G01-06 建单前拦截,由 CanCreate 单决策位决定是否建单。
  948. /// 只服务首版唯一场景 S2S6_PRODUCTION + 唯一 trigger_type VALUE_DEVIATION + 设备对象。
  949. /// 不预留多 trigger_type / 平台化去重扩展结构。
  950. /// Reason 值域:no_pending / duplicate_pending / missing_dedup_key / query_failed。
  951. /// </summary>
  952. public sealed class S8WatchDedupResult
  953. {
  954. public S8WatchHitResult Hit { get; set; } = new();
  955. public bool CanCreate { get; set; }
  956. public long? MatchedExceptionId { get; set; }
  957. public string Reason { get; set; } = string.Empty;
  958. }
  959. /// <summary>
  960. /// G01-06 建单结果对象。仅服务 G-01 首版主线验收,由 Created / Skipped 两位决定结局。
  961. /// 只服务首版唯一场景 S2S6_PRODUCTION + 唯一 trigger_type VALUE_DEVIATION + 设备对象。
  962. /// 不预留多 trigger_type / 平台化工单扩展结构。
  963. /// Reason 值域:auto_created / create_failed / 透传自 DedupResult.Reason。
  964. /// </summary>
  965. public sealed class S8WatchCreationResult
  966. {
  967. public S8WatchDedupResult DedupResult { get; set; } = new();
  968. public bool Created { get; set; }
  969. public bool Skipped { get; set; }
  970. public long? CreatedExceptionId { get; set; }
  971. public string Reason { get; set; } = string.Empty;
  972. public string? ErrorMessage { get; set; }
  973. }
  974. /// <summary>
  975. /// G01-04 命中结果对象。承载 G01-05 去重与 G01-06 建单所需最小追溯字段,
  976. /// 仅服务首版唯一场景 S2S6_PRODUCTION + 唯一 trigger_type VALUE_DEVIATION + 设备对象。
  977. /// 不预留多 trigger_type / 多场景 / 平台化扩展结构。
  978. /// </summary>
  979. public sealed class S8WatchHitResult
  980. {
  981. public long SourceRuleId { get; set; }
  982. public string SourceRuleCode { get; set; } = string.Empty;
  983. public long AlertRuleId { get; set; }
  984. public long DataSourceId { get; set; }
  985. public string RelatedObjectCode { get; set; } = string.Empty;
  986. public decimal CurrentValue { get; set; }
  987. public decimal ThresholdValue { get; set; }
  988. public string TriggerCondition { get; set; } = string.Empty;
  989. public string Severity { get; set; } = string.Empty;
  990. public long? OccurrenceDeptId { get; set; }
  991. public long? ResponsibleDeptId { get; set; }
  992. public string SourcePayload { get; set; } = string.Empty;
  993. }