S8RuleDefinition.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  2. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  3. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  4. /// <summary>
  5. /// S8-RULE-GOVERNANCE-BATCH1:监控规则的**代码定义**。
  6. ///
  7. /// <para><b>它解决什么</b>:在此之前,一条规则的全部业务语义都存在 <c>ado_s8_watch_rule</c> 里——
  8. /// <c>rule_type</c> / <c>dataset_code</c> / <c>source_object_type</c> 是列,
  9. /// 而 <c>due_at</c> 取哪一列、哪些状态算已完成、去重身份是什么、建什么异常类型,
  10. /// 全部埋在 <c>params_json</c> 这个自由文本里。于是「改判定口径」与「改轮询间隔」
  11. /// 走的是同一个配置接口、同一份权限、同一次保存 —— 业务用户可以在页面上把
  12. /// <c>completedStates</c> 改成 <c>["OPEN"]</c>,规则立刻换一套业务含义,而没有任何评审。</para>
  13. ///
  14. /// <para><b>本类的定位</b>:规则的业务语义从此**只存在于代码里**,随 git 走,随 release 发。
  15. /// 数据库上那几个同名列降级为 <b>provisioning 维护的只读投影</b>:它们仍然被 SQL 谓词与既有索引使用
  16. /// (如 <c>PickReadyRulesAsync</c> 的 <c>dataset_code != ''</c>),但**不再是运行时语义的真源**。
  17. /// 有人手工改了那几个列,规则的行为不变。</para>
  18. ///
  19. /// <para><b>为什么镜像 <see cref="S8DatasetDefinition"/> 而不是另造一套</b>:
  20. /// 数据集侧早已是「代码声明定义 + Catalog 聚合 + 运行期按声明校验」,
  21. /// 规则侧是同一个问题的同一种解法。仓内不应出现两种风格的 Catalog。</para>
  22. ///
  23. /// <para><b>刻意不做的事</b>:不引入 DSL、不引入表达式、不引入
  24. /// <c>Dictionary&lt;string, object&gt;</c>。定义是强类型的 —— 它要能被编译器和单元测试检查,
  25. /// 而不是被"运行到那一行才知道配错了"检查。</para>
  26. /// </summary>
  27. public sealed class S8RuleDefinition
  28. {
  29. /// <summary>规则人读编码。全链身份:dedup_key / detection_log.rule_code / exception.source_rule_code 都引用它。</summary>
  30. public string RuleCode { get; init; } = string.Empty;
  31. /// <summary>业务名称。<c>ado_s8_watch_rule</c> 无此列,配置页的「规则名称」只能来自这里。</summary>
  32. public string DisplayName { get; init; } = string.Empty;
  33. /// <summary>业务说明。同上,配置页「业务说明」的唯一来源。面向业务读者,不写表名 / 列名 / SQL。</summary>
  34. public string Description { get; init; } = string.Empty;
  35. /// <summary>取数数据集编码。必须在 <see cref="IS8DatasetCatalog"/> 中已定义。</summary>
  36. public string DatasetCode { get; init; } = string.Empty;
  37. /// <summary>规则类型 TIMEOUT / SHORTAGE / OUT_OF_RANGE。调度器据此分派 evaluator。</summary>
  38. public string RuleType { get; init; } = string.Empty;
  39. /// <summary>报警机制 MANUAL_REPORT / DATE / RATIO / VALUE_RANGE。由 <see cref="RuleType"/> 决定,不独立选择。</summary>
  40. public string RuleMechanism { get; init; } = string.Empty;
  41. /// <summary>源对象类型。**参与 dedup_key**,改动会让历史异常与新异常断代。</summary>
  42. public string SourceObjectType { get; init; } = string.Empty;
  43. /// <summary>场景编码 S1–S7。</summary>
  44. public string SceneCode { get; init; } = string.Empty;
  45. /// <summary>S_STAGE 维度节点。由 <see cref="SceneCode"/> 派生,不独立选择。</summary>
  46. public string StageCode { get; init; } = string.Empty;
  47. /// <summary>ORDER_FLOW 维度节点;可空。</summary>
  48. public string? OrderFlowCode { get; init; }
  49. /// <summary>建单时使用的异常类型编码。决定 SLA / 通知分层 / Workflow 绑定,因此必须是定义而非参数。</summary>
  50. public string ExceptionTypeCode { get; init; } = string.Empty;
  51. /// <summary>
  52. /// 判定基准的**业务语言**说明,供配置页只读展示。
  53. ///
  54. /// <para>刻意与 <see cref="S8TimeoutSemantics.DueAtColumn"/> 分开:那是 canonical 列名(技术契约),
  55. /// 直接摆给规则管理员看等于泄漏实现细节,而且 <c>due_at</c> 三个字也回答不了
  56. /// 「这个日期到底是谁承诺的」。这里写的是业务读者能据以判断"口径对不对"的那句话。</para>
  57. ///
  58. /// <para>不得写入表名 / 列名 / SQL 片段 —— 配置页面向的是业务管理员,不是 DBA。</para>
  59. /// </summary>
  60. public string JudgementSummary { get; init; } = string.Empty;
  61. /// <summary>
  62. /// 去重身份的业务语言说明(如"采购订单号 + 行号")。
  63. /// 让管理员能判断"同一个对象反复报警会不会被合并",而不必理解 dedup_key 的拼接格式。
  64. /// </summary>
  65. public string DedupIdentitySummary { get; init; } = string.Empty;
  66. /// <summary>TIMEOUT 判定语义。<see cref="RuleType"/> = TIMEOUT 时必填。</summary>
  67. public S8TimeoutSemantics? Timeout { get; init; }
  68. /// <summary>SHORTAGE 判定语义。<see cref="RuleType"/> = SHORTAGE 时必填。</summary>
  69. public S8ShortageSemantics? Shortage { get; init; }
  70. /// <summary>OUT_OF_RANGE 判定语义。<see cref="RuleType"/> = OUT_OF_RANGE 时必填。</summary>
  71. public S8OutOfRangeSemantics? OutOfRange { get; init; }
  72. /// <summary>
  73. /// S8-RULE-LIFECYCLE-CREATE-GATE-1:<b>预警型规则</b>声明 —— 只在到期日之前允许<b>首次立案</b>。
  74. ///
  75. /// <para><b>它修的是什么</b>:Runtime 此前只有一个二值概念「本轮是否命中」,
  76. /// 而它同时承担了三件事:能不能建单、要不要保持 active、算不算恢复。
  77. /// 对「事前预警」类规则这会得出一个荒谬的结论 —— 风险<b>兑现</b>的那一刻
  78. /// (时间跨过到期日),如果数据集把过期行滤掉,dedup_key 就从 hits 里消失,
  79. /// 恢复判定(<c>ReconcileRecoveriesForRuleAsync</c>)看到的与「风险解除」<b>完全一样</b>,
  80. /// 于是把它标成已恢复。Rule 02 上已实测复现(真库两条命中,跨过交期后数据集返回 0 行,
  81. /// 而 ETA 一天没改、仍晚于交期)。</para>
  82. ///
  83. /// <para><b>正确的拆法</b>:把「未到期」从<b>风险存在条件</b>降级为<b>首次立案条件</b>。
  84. /// 数据集照常返回全部风险事实(过期的也返回),evaluator 照常判命中,
  85. /// 只是过期行产出的命中带 <c>CreateEligible = false</c>:
  86. /// 它<b>仍在 hits 里</b>(因而保护既有异常不被误判恢复),只是不用来开新案子。</para>
  87. ///
  88. /// <para><b>为什么不是「过滤掉过期行」</b>:那正是缺陷本身。被过滤的行不在 hits 里,
  89. /// 与「不再命中」不可区分 —— 这条注释存在的意义就是防止有人日后为了
  90. /// 「少建几条历史异常」把时间条件加回数据集。</para>
  91. ///
  92. /// <para><b>默认 <c>false</c>,因此未声明的规则行为逐字不变</b>(Rule 01 即是)。
  93. /// 判定基准是 canonical <see cref="S8CanonicalColumns.DueAt"/> 列,
  94. /// 故声明本项的规则其数据集必须 <c>HasDueAt = true</c>。</para>
  95. /// </summary>
  96. public bool CreateOnlyBeforeDueAt { get; init; }
  97. /// <summary>本规则开放给租户调整的运行参数白名单与取值域。</summary>
  98. public S8RuleParameterPolicy Parameters { get; init; } = new();
  99. }
  100. /// <summary>
  101. /// TIMEOUT 判定语义。
  102. ///
  103. /// <para><b>这些是列名,但不是"让用户填的列名"</b>:它们描述 canonical 行契约里哪一列承载到期时间、
  104. /// 哪一列承载状态。Provider 按 <see cref="S8CanonicalColumns"/> 产出行,因此默认值就是 canonical 名;
  105. /// 声明出来是为了让「判定读了哪一列」这件事有一处可被测试断言的书面记录,
  106. /// 而不是散在 evaluator 的 if 分支里。</para>
  107. /// </summary>
  108. public sealed class S8TimeoutSemantics
  109. {
  110. /// <summary>到期时间列。</summary>
  111. public string DueAtColumn { get; init; } = S8CanonicalColumns.DueAt;
  112. /// <summary>状态列。</summary>
  113. public string StatusColumn { get; init; } = S8CanonicalColumns.Status;
  114. /// <summary>去重身份列(写入 <c>exception.source_object_id</c> 并参与 dedup_key)。</summary>
  115. public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId;
  116. /// <summary>关联单号列(写入 <c>exception.related_object_code</c>)。</summary>
  117. public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode;
  118. /// <summary>
  119. /// 视为「已完成、不再超期」的状态集合。比对**忽略大小写**(沿用既有 evaluator 口径)。
  120. /// 这是最典型的「看起来像参数、实际是业务定义」的字段:改一个值,同一条规则就换了一套业务含义。
  121. /// </summary>
  122. public IReadOnlyList<string> CompletedStates { get; init; } = Array.Empty<string>();
  123. }
  124. /// <summary>SHORTAGE 判定语义。当前无任何规则使用(仓内尚无 SHORTAGE 定义),保留以保证三类 evaluator 结构一致。</summary>
  125. public sealed class S8ShortageSemantics
  126. {
  127. public string TargetQtyColumn { get; init; } = S8CanonicalColumns.TargetQty;
  128. public string ActualQtyColumn { get; init; } = S8CanonicalColumns.ActualQty;
  129. public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId;
  130. public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode;
  131. /// <summary>绝对容差。缺口需**大于**该值才命中。</summary>
  132. public decimal ToleranceAbs { get; init; }
  133. /// <summary>比例容差(0–1)。缺口占目标的比例需**大于**该值才命中。</summary>
  134. public decimal ToleranceRatio { get; init; }
  135. }
  136. /// <summary>OUT_OF_RANGE 判定语义。当前无任何规则使用,同上。</summary>
  137. public sealed class S8OutOfRangeSemantics
  138. {
  139. public string MeasuredValueColumn { get; init; } = S8CanonicalColumns.MeasuredValue;
  140. public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId;
  141. public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode;
  142. /// <summary>行内下限列;为空表示使用 <see cref="LowerBound"/> 固定值。</summary>
  143. public string? LowerBoundColumn { get; init; }
  144. /// <summary>行内上限列;为空表示使用 <see cref="UpperBound"/> 固定值。</summary>
  145. public string? UpperBoundColumn { get; init; }
  146. public decimal? LowerBound { get; init; }
  147. public decimal? UpperBound { get; init; }
  148. public decimal ToleranceAbs { get; init; }
  149. public decimal ToleranceRatio { get; init; }
  150. }
  151. /// <summary>
  152. /// 运行参数白名单与取值域。
  153. ///
  154. /// <para>这里回答的是一个很窄的问题:<b>租户管理员能改什么,改到什么范围</b>。
  155. /// 不在本策略里的字段,就没有任何 API 能改到——不是"前端没做入口",是写模型里根本不存在该字段。</para>
  156. ///
  157. /// <para>取值域随定义走而不是写死在 service:不同规则对「宽限多久算合理」的判断本就不同,
  158. /// 而把它写在 service 的 if 里,等于把业务口径藏进了实现。</para>
  159. /// </summary>
  160. public sealed class S8RuleParameterPolicy
  161. {
  162. public int PollIntervalSecondsMin { get; init; } = 60;
  163. public int PollIntervalSecondsMax { get; init; } = 86400;
  164. public int PollIntervalSecondsDefault { get; init; } = 300;
  165. public int TriggerCountRequiredMin { get; init; } = 1;
  166. public int TriggerCountRequiredMax { get; init; } = 10;
  167. public int TriggerCountRequiredDefault { get; init; } = 1;
  168. public int RecoverCountRequiredMin { get; init; } = 1;
  169. public int RecoverCountRequiredMax { get; init; } = 10;
  170. public int RecoverCountRequiredDefault { get; init; } = 1;
  171. /// <summary>
  172. /// 宽限分钟下限恒为 0。**刻意不设业务上限**:本批没有依据判断「多久算过长」,
  173. /// 凭空设一个上限会在没有任何证据的情况下否掉合法配置。
  174. /// </summary>
  175. public int GraceMinutesMin { get; init; } = 0;
  176. public int GraceMinutesMax { get; init; } = int.MaxValue;
  177. public int GraceMinutesDefault { get; init; } = 0;
  178. /// <summary>
  179. /// 允许的严重度。**刻意只有两值**:<c>S8SeverityCode.IsValid</c> 是宽松六值版
  180. /// (含 LOW/MEDIUM/HIGH/CRITICAL),那是给 legacy 查询参数兼容用的,
  181. /// 拿来当写入门禁会直接放行 legacy 值(DB 里 severity='HIGH' 那一行正是这样进来的)。
  182. /// </summary>
  183. public IReadOnlyList<string> AllowedSeverities { get; init; } =
  184. new[] { S8SeverityCode.Follow, S8SeverityCode.Serious };
  185. public string SeverityDefault { get; init; } = S8SeverityCode.Follow;
  186. /// <summary>是否允许配置发生 / 责任部门兜底。</summary>
  187. public bool AllowsDepartmentDefaults { get; init; } = true;
  188. /// <summary>
  189. /// S8-RULE-READINESS-1:这两个部门参数是否为<b>启用的前置条件</b>。
  190. ///
  191. /// <para>默认 <c>false</c>,且刻意如此:这不是 S8 的全局规则,而是<b>逐规则</b>的声明。
  192. /// 数据集自带部门列的规则(命中行能直接给出 occurrence / responsible)不需要它们,
  193. /// 在全局写死"必须配部门"会把那类规则一起误伤。</para>
  194. ///
  195. /// <para>置 <c>true</c> 的判据只有一条:<b>该规则的数据集拿不出部门</b>,
  196. /// 因而不配这两项就必然建单失败。Rule 01 正是如此 ——
  197. /// <c>dwd_supplier_delivery</c> 没有任何部门列。</para>
  198. ///
  199. /// <para>由 <see cref="S8RuleReadinessGate"/> 在 Enable / RunNow / Scheduler 三条入口统一消费。
  200. /// 与 <see cref="AllowsDepartmentDefaults"/> 是两件事:后者管"能不能配",本项管"必须配"。</para>
  201. /// </summary>
  202. public bool RequiresDepartmentDefaultsForEnable { get; init; }
  203. /// <summary>
  204. /// S8-RESPONSIBILITY-POOL-1:启用前是否必须已配置<b>处理账号池</b>。
  205. ///
  206. /// <para><b>本项是把既有判据显式化,不是新增门禁。</b>处理池判据原先写在
  207. /// <see cref="S8RuleReadinessGate"/> 里、却位于 <see cref="RequiresDepartmentDefaultsForEnable"/>
  208. /// 的提前返回<b>之后</b> —— 于是它事实上只对"必须配部门"的规则生效,
  209. /// 对其余规则被静默跳过。这种「两件事被一个开关捆在一起」的耦合,
  210. /// 在新增复核 / 升级两条判据时会立刻放大成三重耦合。</para>
  211. ///
  212. /// <para>拆出独立开关后,各条判据由各自的声明驱动;当前取值刻意与拆分前的
  213. /// <b>实际行为完全一致</b>(仅 Rule 01 为 <c>true</c>),本批不顺带改变任何规则的启用条件。</para>
  214. /// </summary>
  215. public bool RequiresHandlerPoolForEnable { get; init; }
  216. /// <summary>
  217. /// S8-RESPONSIBILITY-POOL-1:该规则产生的异常,处理流程是否<b>包含复核环节</b>。
  218. ///
  219. /// <para>为 <c>true</c> 时,启用前必须已配置至少一名有效的<b>复核账号</b> ——
  220. /// 否则处理人做完点「提交复核」会发现候选人列表是空的,单据卡在
  221. /// IN_PROGRESS 出不去,而规则本身一路显示正常。</para>
  222. ///
  223. /// <para><b>不做成全局要求</b>:将来若有规则处理完即闭环、根本不走复核,
  224. /// 强制它配复核人只会逼出一个占位账号 —— 那比没有配置更危险。</para>
  225. /// </summary>
  226. public bool RequiresVerification { get; init; }
  227. /// <summary>
  228. /// S8-RESPONSIBILITY-POOL-1:该规则的异常是否参与<b>超时自动升级</b>。
  229. ///
  230. /// <para>为 <c>true</c> 时,启用前必须已配置至少一名有效的<b>升级账号</b>,
  231. /// 且超时升级作业按<b>本规则的升级账号池</b>解析收件人。</para>
  232. ///
  233. /// <para><b>取代 <c>exception_type.escalate_role_code</c> 成为 S8 规则链路的升级 authority</b>:
  234. /// 旧字段是跨租户扫描时按异常类型查的,实测会把 B 租户的 <c>escalate_role_code</c>
  235. /// 用到 A 租户的异常上(见 <c>S8TimeoutAutoEscalationService</c> 原注释)。
  236. /// 责任池天然带 <c>tenant_id</c>,不存在这条越界路径。旧字段保留供其它模块兼容,
  237. /// S8 规则链路不再依赖。</para>
  238. /// </summary>
  239. public bool SupportsTimeoutEscalation { get; init; }
  240. }