using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules; /// 就绪门禁的失败原因码。与 分开:那一族说的是「数据集能不能取数」。 public static class S8RuleReadinessReasonCode { /// 规则声明为启用必需的运行参数尚未配置。 public const string RequiredParameterMissing = "rule_required_parameter_missing"; /// 已配置的部门不存在于当前租户(或已停用)。 public const string DepartmentNotInTenant = "rule_department_not_in_tenant"; /// /// 规则没有任何处理账号(S8-HANDLER-POOL-1)。 /// 启用一条没人负责的规则,结果是异常源源不断地建出来、静静躺在列表里没人认领—— /// 而调度器每一轮都报成功。与"配了部门但没人处理"是同一类静默失败,必须在启用前挡住。 /// public const string HandlerPoolEmpty = "rule_handler_pool_empty"; /// /// 规则声明了包含复核环节,却没有任何有效的复核账号(S8-RESPONSIBILITY-POOL-1)。 /// 处理人做完点「提交复核」会发现候选人列表是空的,单据卡在 IN_PROGRESS 出不去, /// 而规则本身一路显示「已启用 / 最近结果:成功」。 /// public const string ReviewerPoolEmpty = "rule_reviewer_pool_empty"; /// /// 规则声明了参与超时升级,却没有任何有效的升级账号(S8-RESPONSIBILITY-POOL-1)。 /// 异常超时后没有任何人被通知,异常静静躺到过期 —— 升级作业每一轮都报成功。 /// public const string EscalationPoolEmpty = "rule_escalation_pool_empty"; } /// 就绪判定结果。 为 true 时另外两项为 null。 public sealed class S8RuleReadinessCheck { private S8RuleReadinessCheck(bool ok, string? reasonCode, string? message) { Ok = ok; ReasonCode = reasonCode; Message = message; } public bool Ok { get; } public string? ReasonCode { get; } public string? Message { get; } public static S8RuleReadinessCheck Pass() => new(true, null, null); public static S8RuleReadinessCheck Fail(string reasonCode, string message) => new(false, reasonCode, message); } /// /// S8-RULE-READINESS-1:规则「能不能真的跑」的统一门禁。 /// /// 要解决的问题:Rule 01 的判定语义里没有部门 —— dwd_supplier_delivery /// 根本没有部门列,命中行也就带不出部门。于是建单时部门只能来自运行参数 /// defaultOccurrenceDeptId / defaultResponsibleDeptId。没配这两项时, /// 规则会「启用成功、调度成功、一条异常都建不出来」: /// tick_rule_done ... status=SUCCESS hits=28 created=0 failed=1, /// 页面显示「已启用 / 最近结果:成功」。这是最难发现的一类失败 —— 每一层都报成功。 /// /// S8-HANDLER-POOL-1 追加一条判据:规则必须至少有一个当前有效的处理账号。 /// 少了它,一条没人负责的规则会源源不断建出异常、静静躺在列表里没人认领, /// 而调度器每一轮都报成功 —— 与"配了部门但没人处理"同属一类静默失败。 /// /// 三条真实执行入口必须共用本门禁EnableAsync · RunNowAsync · /// S8WatchSchedulerService.RunSingleRuleAsync。少接一条就等于留了旁路: /// 启用被挡但 RunNow 能跑、或启用时合法而部门事后被停用、调度器照样进 evaluator 再 create_failed。 /// Scheduler 侧必须在 evaluator 之前 fail-fast,不进入异常创建。 /// /// Preview 刻意不接:预演只做只读试算、不建任何异常, /// 部门缺失不影响它的正确性。管理员本来就该「先预演看命中,再配部门,再启用」。 /// /// 不是全局规则:是否必需由 /// 逐规则声明,默认 false。将来某条规则的数据集自带部门列,它就不需要这两个参数。 /// 在 S8 全局写死「所有规则必须配部门」会把那类规则一起误伤。 /// /// 参数不重新解析:只消费调用方传入的 /// (其 Parameters 已由 解析)。 /// 门禁自己再解一次 params_json 就会出现「门禁看到的值」与「evaluator 用的值」不同源, /// 那是另一种形式的静默背离。 /// public sealed class S8RuleReadinessGate : ITransient { private readonly IS8DepartmentScopeValidator _deptValidator; private readonly IS8RuleResponsibilityReader _pools; public S8RuleReadinessGate( IS8DepartmentScopeValidator deptValidator, IS8RuleResponsibilityReader pools) { _deptValidator = deptValidator; _pools = pools; } /// /// 按生效形态判定就绪。不抛异常,由调用方决定失败语义(400 / run-result)。 /// /// 每条判据由各自的声明驱动(S8-RESPONSIBILITY-POOL-1): /// 拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。 /// 判据之间没有业务上的从属关系,不该被一个开关捆在一起 —— /// 尤其在要再加复核 / 升级两条判据的时候。 /// public async Task CheckAsync(S8EffectiveRule effective, long tenantId) { var policy = effective.Definition.Parameters; if (policy == null) return S8RuleReadinessCheck.Pass(); var ruleCode = effective.Row.RuleCode ?? string.Empty; // ① 部门兜底参数(仅数据集拿不出部门的规则声明)。 if (policy.RequiresDepartmentDefaultsForEnable) { var deptCheck = await CheckDepartmentsAsync(effective, tenantId); if (!deptCheck.Ok) return deptCheck; } // ② 处理账号池 —— 没人负责的规则会源源不断建单、无人认领,而调度器每轮都报成功。 if (policy.RequiresHandlerPoolForEnable) { var check = await CheckPoolAsync( tenantId, ruleCode, S8ResponsibilityType.Handler, S8RuleReadinessReasonCode.HandlerPoolEmpty, "启用失败:请先配置至少一名处理账号。", "启用失败:已配置的处理账号均已停用或不在当前租户,请重新配置。"); if (!check.Ok) return check; } // ③ 复核账号池 —— 空池会让「提交复核」的候选人列表为空,单据卡在 IN_PROGRESS。 if (policy.RequiresVerification) { var check = await CheckPoolAsync( tenantId, ruleCode, S8ResponsibilityType.Reviewer, S8RuleReadinessReasonCode.ReviewerPoolEmpty, "启用失败:请先配置至少一名复核账号。", "启用失败:已配置的复核账号均已停用或不在当前租户,请重新配置。"); if (!check.Ok) return check; } // ④ 升级账号池 —— 空池会让超时升级发不给任何人,异常静静躺到过期。 if (policy.SupportsTimeoutEscalation) { var check = await CheckPoolAsync( tenantId, ruleCode, S8ResponsibilityType.Escalation, S8RuleReadinessReasonCode.EscalationPoolEmpty, "启用失败:请先配置至少一名升级账号。", "启用失败:已配置的升级账号均已停用或不在当前租户,请重新配置。"); if (!check.Ok) return check; } return S8RuleReadinessCheck.Pass(); } private async Task CheckDepartmentsAsync(S8EffectiveRule effective, long tenantId) { var occurrence = effective.Parameters.DefaultOccurrenceDeptId; var responsible = effective.Parameters.DefaultResponsibleDeptId; // 未配置 —— 与「配了但非法」分开报,管理员需要知道到底是哪一种。 if (!occurrence.HasValue) return S8RuleReadinessCheck.Fail( S8RuleReadinessReasonCode.RequiredParameterMissing, "启用失败:请先配置默认发生部门。"); if (!responsible.HasValue) return S8RuleReadinessCheck.Fail( S8RuleReadinessReasonCode.RequiredParameterMissing, "启用失败:请先配置默认责任部门。"); // 配了但不属当前租户(或已停用)。判据与运行时建单同源,见 S8DepartmentScopeValidator。 if (!await _deptValidator.ExistsInTenantAsync(occurrence, tenantId)) return S8RuleReadinessCheck.Fail( S8RuleReadinessReasonCode.DepartmentNotInTenant, "启用失败:默认发生部门不属于当前租户。"); if (!await _deptValidator.ExistsInTenantAsync(responsible, tenantId)) return S8RuleReadinessCheck.Fail( S8RuleReadinessReasonCode.DepartmentNotInTenant, "启用失败:默认责任部门不属于当前租户。"); return S8RuleReadinessCheck.Pass(); } /// /// 责任池判据:至少要有一个当前有效的成员。 /// /// 判据走 GetMembersAsync 而不是「表里有没有行」:账号被停用或移出租户之后, /// 池子里的那一行还在,但那个人已经不算数了。只数行数会让一条实际上没人负责的规则 /// 通过启用检查 —— 那正是本门禁存在的意义。 /// /// 「一个都没配」与「配了但都失效了」分开报:管理员的下一步动作完全不同, /// 前者是去配人,后者是去查那几个账号出了什么事。 /// /// 空池绝不回落到任何角色(fail closed)。回落到 ROLE_QC 之流会让 /// 「我明明没给这条规则配人」和「有人在处理」同时成立,是最难查的一类错配。 /// private async Task CheckPoolAsync( long tenantId, string ruleCode, string responsibilityType, string reasonCode, string emptyMessage, string allInvalidMessage) { var members = await _pools.GetMembersAsync(tenantId, ruleCode, responsibilityType); if (members.Any(m => m.Valid)) return S8RuleReadinessCheck.Pass(); return S8RuleReadinessCheck.Fail( reasonCode, members.Count == 0 ? emptyMessage : allInvalidMessage); } /// 启用 / 立即执行路径用这一个:不就绪即抛 (→ 400)。 public async Task EnsureReadyAsync(S8EffectiveRule effective, long tenantId) { var result = await CheckAsync(effective, tenantId); if (!result.Ok) throw new S8BizException($"[{result.ReasonCode}] {result.Message}"); } }