S8RuleReadinessGate.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. using Admin.NET.Plugin.AiDOP.Const.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  3. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  4. /// <summary>就绪门禁的失败原因码。与 <see cref="DataAccess.S8DatasetReasonCode"/> 分开:那一族说的是「数据集能不能取数」。</summary>
  5. public static class S8RuleReadinessReasonCode
  6. {
  7. /// <summary>规则声明为启用必需的运行参数尚未配置。</summary>
  8. public const string RequiredParameterMissing = "rule_required_parameter_missing";
  9. /// <summary>已配置的部门不存在于当前租户(或已停用)。</summary>
  10. public const string DepartmentNotInTenant = "rule_department_not_in_tenant";
  11. /// <summary>
  12. /// 规则没有任何处理账号(S8-HANDLER-POOL-1)。
  13. /// <para>启用一条没人负责的规则,结果是异常源源不断地建出来、静静躺在列表里没人认领——
  14. /// 而调度器每一轮都报成功。与"配了部门但没人处理"是同一类静默失败,必须在启用前挡住。</para>
  15. /// </summary>
  16. public const string HandlerPoolEmpty = "rule_handler_pool_empty";
  17. /// <summary>
  18. /// 规则声明了包含复核环节,却没有任何有效的复核账号(S8-RESPONSIBILITY-POOL-1)。
  19. /// <para>处理人做完点「提交复核」会发现候选人列表是空的,单据卡在 IN_PROGRESS 出不去,
  20. /// 而规则本身一路显示「已启用 / 最近结果:成功」。</para>
  21. /// </summary>
  22. public const string ReviewerPoolEmpty = "rule_reviewer_pool_empty";
  23. /// <summary>
  24. /// 规则声明了参与超时升级,却没有任何有效的升级账号(S8-RESPONSIBILITY-POOL-1)。
  25. /// <para>异常超时后没有任何人被通知,异常静静躺到过期 —— 升级作业每一轮都报成功。</para>
  26. /// </summary>
  27. public const string EscalationPoolEmpty = "rule_escalation_pool_empty";
  28. }
  29. /// <summary>就绪判定结果。<see cref="Ok"/> 为 true 时另外两项为 null。</summary>
  30. public sealed class S8RuleReadinessCheck
  31. {
  32. private S8RuleReadinessCheck(bool ok, string? reasonCode, string? message)
  33. {
  34. Ok = ok;
  35. ReasonCode = reasonCode;
  36. Message = message;
  37. }
  38. public bool Ok { get; }
  39. public string? ReasonCode { get; }
  40. public string? Message { get; }
  41. public static S8RuleReadinessCheck Pass() => new(true, null, null);
  42. public static S8RuleReadinessCheck Fail(string reasonCode, string message) => new(false, reasonCode, message);
  43. }
  44. /// <summary>
  45. /// S8-RULE-READINESS-1:规则「能不能真的跑」的统一门禁。
  46. ///
  47. /// <para><b>要解决的问题</b>:Rule 01 的判定语义里没有部门 —— <c>dwd_supplier_delivery</c>
  48. /// 根本没有部门列,命中行也就带不出部门。于是建单时部门只能来自运行参数
  49. /// <c>defaultOccurrenceDeptId</c> / <c>defaultResponsibleDeptId</c>。没配这两项时,
  50. /// 规则会「启用成功、调度成功、一条异常都建不出来」:
  51. /// <c>tick_rule_done ... status=SUCCESS hits=28 created=0 failed=1</c>,
  52. /// 页面显示「已启用 / 最近结果:成功」。这是最难发现的一类失败 —— 每一层都报成功。</para>
  53. ///
  54. /// <para><b>S8-HANDLER-POOL-1 追加一条判据</b>:规则必须至少有一个当前有效的处理账号。
  55. /// 少了它,一条没人负责的规则会源源不断建出异常、静静躺在列表里没人认领,
  56. /// 而调度器每一轮都报成功 —— 与"配了部门但没人处理"同属一类静默失败。</para>
  57. ///
  58. /// <para><b>三条真实执行入口必须共用本门禁</b>:<c>EnableAsync</c> · <c>RunNowAsync</c> ·
  59. /// <c>S8WatchSchedulerService.RunSingleRuleAsync</c>。少接一条就等于留了旁路:
  60. /// 启用被挡但 RunNow 能跑、或启用时合法而部门事后被停用、调度器照样进 evaluator 再 create_failed。
  61. /// Scheduler 侧必须<b>在 evaluator 之前</b> fail-fast,不进入异常创建。</para>
  62. ///
  63. /// <para><b>Preview 刻意不接</b>:预演只做只读试算、不建任何异常,
  64. /// 部门缺失不影响它的正确性。管理员本来就该「先预演看命中,再配部门,再启用」。</para>
  65. ///
  66. /// <para><b>不是全局规则</b>:是否必需由 <see cref="Definitions.S8RuleParameterPolicy.RequiresDepartmentDefaultsForEnable"/>
  67. /// 逐规则声明,默认 <c>false</c>。将来某条规则的数据集自带部门列,它就不需要这两个参数。
  68. /// 在 S8 全局写死「所有规则必须配部门」会把那类规则一起误伤。</para>
  69. ///
  70. /// <para><b>参数不重新解析</b>:只消费调用方传入的 <see cref="S8EffectiveRule"/>
  71. /// (其 <c>Parameters</c> 已由 <see cref="S8RuleRuntimeParameters.Resolve"/> 解析)。
  72. /// 门禁自己再解一次 <c>params_json</c> 就会出现「门禁看到的值」与「evaluator 用的值」不同源,
  73. /// 那是另一种形式的静默背离。</para>
  74. /// </summary>
  75. public sealed class S8RuleReadinessGate : ITransient
  76. {
  77. private readonly IS8DepartmentScopeValidator _deptValidator;
  78. private readonly IS8RuleResponsibilityReader _pools;
  79. public S8RuleReadinessGate(
  80. IS8DepartmentScopeValidator deptValidator,
  81. IS8RuleResponsibilityReader pools)
  82. {
  83. _deptValidator = deptValidator;
  84. _pools = pools;
  85. }
  86. /// <summary>
  87. /// 按生效形态判定就绪。不抛异常,由调用方决定失败语义(400 / run-result)。
  88. ///
  89. /// <para><b>每条判据由各自的声明驱动</b>(S8-RESPONSIBILITY-POOL-1):
  90. /// 拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
  91. /// 判据之间没有业务上的从属关系,不该被一个开关捆在一起 ——
  92. /// 尤其在要再加复核 / 升级两条判据的时候。</para>
  93. /// </summary>
  94. public async Task<S8RuleReadinessCheck> CheckAsync(S8EffectiveRule effective, long tenantId)
  95. {
  96. var policy = effective.Definition.Parameters;
  97. if (policy == null) return S8RuleReadinessCheck.Pass();
  98. var ruleCode = effective.Row.RuleCode ?? string.Empty;
  99. // ① 部门兜底参数(仅数据集拿不出部门的规则声明)。
  100. if (policy.RequiresDepartmentDefaultsForEnable)
  101. {
  102. var deptCheck = await CheckDepartmentsAsync(effective, tenantId);
  103. if (!deptCheck.Ok) return deptCheck;
  104. }
  105. // ② 处理账号池 —— 没人负责的规则会源源不断建单、无人认领,而调度器每轮都报成功。
  106. if (policy.RequiresHandlerPoolForEnable)
  107. {
  108. var check = await CheckPoolAsync(
  109. tenantId, ruleCode, S8ResponsibilityType.Handler,
  110. S8RuleReadinessReasonCode.HandlerPoolEmpty,
  111. "启用失败:请先配置至少一名处理账号。",
  112. "启用失败:已配置的处理账号均已停用或不在当前租户,请重新配置。");
  113. if (!check.Ok) return check;
  114. }
  115. // ③ 复核账号池 —— 空池会让「提交复核」的候选人列表为空,单据卡在 IN_PROGRESS。
  116. if (policy.RequiresVerification)
  117. {
  118. var check = await CheckPoolAsync(
  119. tenantId, ruleCode, S8ResponsibilityType.Reviewer,
  120. S8RuleReadinessReasonCode.ReviewerPoolEmpty,
  121. "启用失败:请先配置至少一名复核账号。",
  122. "启用失败:已配置的复核账号均已停用或不在当前租户,请重新配置。");
  123. if (!check.Ok) return check;
  124. }
  125. // ④ 升级账号池 —— 空池会让超时升级发不给任何人,异常静静躺到过期。
  126. if (policy.SupportsTimeoutEscalation)
  127. {
  128. var check = await CheckPoolAsync(
  129. tenantId, ruleCode, S8ResponsibilityType.Escalation,
  130. S8RuleReadinessReasonCode.EscalationPoolEmpty,
  131. "启用失败:请先配置至少一名升级账号。",
  132. "启用失败:已配置的升级账号均已停用或不在当前租户,请重新配置。");
  133. if (!check.Ok) return check;
  134. }
  135. return S8RuleReadinessCheck.Pass();
  136. }
  137. private async Task<S8RuleReadinessCheck> CheckDepartmentsAsync(S8EffectiveRule effective, long tenantId)
  138. {
  139. var occurrence = effective.Parameters.DefaultOccurrenceDeptId;
  140. var responsible = effective.Parameters.DefaultResponsibleDeptId;
  141. // 未配置 —— 与「配了但非法」分开报,管理员需要知道到底是哪一种。
  142. if (!occurrence.HasValue)
  143. return S8RuleReadinessCheck.Fail(
  144. S8RuleReadinessReasonCode.RequiredParameterMissing,
  145. "启用失败:请先配置默认发生部门。");
  146. if (!responsible.HasValue)
  147. return S8RuleReadinessCheck.Fail(
  148. S8RuleReadinessReasonCode.RequiredParameterMissing,
  149. "启用失败:请先配置默认责任部门。");
  150. // 配了但不属当前租户(或已停用)。判据与运行时建单同源,见 S8DepartmentScopeValidator。
  151. if (!await _deptValidator.ExistsInTenantAsync(occurrence, tenantId))
  152. return S8RuleReadinessCheck.Fail(
  153. S8RuleReadinessReasonCode.DepartmentNotInTenant,
  154. "启用失败:默认发生部门不属于当前租户。");
  155. if (!await _deptValidator.ExistsInTenantAsync(responsible, tenantId))
  156. return S8RuleReadinessCheck.Fail(
  157. S8RuleReadinessReasonCode.DepartmentNotInTenant,
  158. "启用失败:默认责任部门不属于当前租户。");
  159. return S8RuleReadinessCheck.Pass();
  160. }
  161. /// <summary>
  162. /// 责任池判据:至少要有一个<b>当前有效</b>的成员。
  163. ///
  164. /// <para>判据走 <c>GetMembersAsync</c> 而不是「表里有没有行」:账号被停用或移出租户之后,
  165. /// 池子里的那一行还在,但那个人已经不算数了。只数行数会让一条实际上没人负责的规则
  166. /// 通过启用检查 —— 那正是本门禁存在的意义。</para>
  167. ///
  168. /// <para><b>「一个都没配」与「配了但都失效了」分开报</b>:管理员的下一步动作完全不同,
  169. /// 前者是去配人,后者是去查那几个账号出了什么事。</para>
  170. ///
  171. /// <para><b>空池绝不回落到任何角色</b>(fail closed)。回落到 <c>ROLE_QC</c> 之流会让
  172. /// 「我明明没给这条规则配人」和「有人在处理」同时成立,是最难查的一类错配。</para>
  173. /// </summary>
  174. private async Task<S8RuleReadinessCheck> CheckPoolAsync(
  175. long tenantId, string ruleCode, string responsibilityType,
  176. string reasonCode, string emptyMessage, string allInvalidMessage)
  177. {
  178. var members = await _pools.GetMembersAsync(tenantId, ruleCode, responsibilityType);
  179. if (members.Any(m => m.Valid)) return S8RuleReadinessCheck.Pass();
  180. return S8RuleReadinessCheck.Fail(
  181. reasonCode, members.Count == 0 ? emptyMessage : allInvalidMessage);
  182. }
  183. /// <summary>启用 / 立即执行路径用这一个:不就绪即抛 <see cref="S8BizException"/>(→ 400)。</summary>
  184. public async Task EnsureReadyAsync(S8EffectiveRule effective, long tenantId)
  185. {
  186. var result = await CheckAsync(effective, tenantId);
  187. if (!result.Ok)
  188. throw new S8BizException($"[{result.ReasonCode}] {result.Message}");
  189. }
  190. }