using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess; namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; /// /// S8-RULE-GOVERNANCE-BATCH1:监控规则的**代码定义**。 /// /// 它解决什么:在此之前,一条规则的全部业务语义都存在 ado_s8_watch_rule 里—— /// rule_type / dataset_code / source_object_type 是列, /// 而 due_at 取哪一列、哪些状态算已完成、去重身份是什么、建什么异常类型, /// 全部埋在 params_json 这个自由文本里。于是「改判定口径」与「改轮询间隔」 /// 走的是同一个配置接口、同一份权限、同一次保存 —— 业务用户可以在页面上把 /// completedStates 改成 ["OPEN"],规则立刻换一套业务含义,而没有任何评审。 /// /// 本类的定位:规则的业务语义从此**只存在于代码里**,随 git 走,随 release 发。 /// 数据库上那几个同名列降级为 provisioning 维护的只读投影:它们仍然被 SQL 谓词与既有索引使用 /// (如 PickReadyRulesAsyncdataset_code != ''),但**不再是运行时语义的真源**。 /// 有人手工改了那几个列,规则的行为不变。 /// /// 为什么镜像 而不是另造一套: /// 数据集侧早已是「代码声明定义 + Catalog 聚合 + 运行期按声明校验」, /// 规则侧是同一个问题的同一种解法。仓内不应出现两种风格的 Catalog。 /// /// 刻意不做的事:不引入 DSL、不引入表达式、不引入 /// Dictionary<string, object>。定义是强类型的 —— 它要能被编译器和单元测试检查, /// 而不是被"运行到那一行才知道配错了"检查。 /// public sealed class S8RuleDefinition { /// 规则人读编码。全链身份:dedup_key / detection_log.rule_code / exception.source_rule_code 都引用它。 public string RuleCode { get; init; } = string.Empty; /// 业务名称。ado_s8_watch_rule 无此列,配置页的「规则名称」只能来自这里。 public string DisplayName { get; init; } = string.Empty; /// 业务说明。同上,配置页「业务说明」的唯一来源。面向业务读者,不写表名 / 列名 / SQL。 public string Description { get; init; } = string.Empty; /// 取数数据集编码。必须在 中已定义。 public string DatasetCode { get; init; } = string.Empty; /// 规则类型 TIMEOUT / SHORTAGE / OUT_OF_RANGE。调度器据此分派 evaluator。 public string RuleType { get; init; } = string.Empty; /// 报警机制 MANUAL_REPORT / DATE / RATIO / VALUE_RANGE。由 决定,不独立选择。 public string RuleMechanism { get; init; } = string.Empty; /// 源对象类型。**参与 dedup_key**,改动会让历史异常与新异常断代。 public string SourceObjectType { get; init; } = string.Empty; /// 场景编码 S1–S7。 public string SceneCode { get; init; } = string.Empty; /// S_STAGE 维度节点。由 派生,不独立选择。 public string StageCode { get; init; } = string.Empty; /// ORDER_FLOW 维度节点;可空。 public string? OrderFlowCode { get; init; } /// 建单时使用的异常类型编码。决定 SLA / 通知分层 / Workflow 绑定,因此必须是定义而非参数。 public string ExceptionTypeCode { get; init; } = string.Empty; /// /// 判定基准的**业务语言**说明,供配置页只读展示。 /// /// 刻意与 分开:那是 canonical 列名(技术契约), /// 直接摆给规则管理员看等于泄漏实现细节,而且 due_at 三个字也回答不了 /// 「这个日期到底是谁承诺的」。这里写的是业务读者能据以判断"口径对不对"的那句话。 /// /// 不得写入表名 / 列名 / SQL 片段 —— 配置页面向的是业务管理员,不是 DBA。 /// public string JudgementSummary { get; init; } = string.Empty; /// /// 去重身份的业务语言说明(如"采购订单号 + 行号")。 /// 让管理员能判断"同一个对象反复报警会不会被合并",而不必理解 dedup_key 的拼接格式。 /// public string DedupIdentitySummary { get; init; } = string.Empty; /// TIMEOUT 判定语义。 = TIMEOUT 时必填。 public S8TimeoutSemantics? Timeout { get; init; } /// SHORTAGE 判定语义。 = SHORTAGE 时必填。 public S8ShortageSemantics? Shortage { get; init; } /// OUT_OF_RANGE 判定语义。 = OUT_OF_RANGE 时必填。 public S8OutOfRangeSemantics? OutOfRange { get; init; } /// /// S8-RULE-LIFECYCLE-CREATE-GATE-1:预警型规则声明 —— 只在到期日之前允许首次立案。 /// /// 它修的是什么:Runtime 此前只有一个二值概念「本轮是否命中」, /// 而它同时承担了三件事:能不能建单、要不要保持 active、算不算恢复。 /// 对「事前预警」类规则这会得出一个荒谬的结论 —— 风险兑现的那一刻 /// (时间跨过到期日),如果数据集把过期行滤掉,dedup_key 就从 hits 里消失, /// 恢复判定(ReconcileRecoveriesForRuleAsync)看到的与「风险解除」完全一样, /// 于是把它标成已恢复。Rule 02 上已实测复现(真库两条命中,跨过交期后数据集返回 0 行, /// 而 ETA 一天没改、仍晚于交期)。 /// /// 正确的拆法:把「未到期」从风险存在条件降级为首次立案条件。 /// 数据集照常返回全部风险事实(过期的也返回),evaluator 照常判命中, /// 只是过期行产出的命中带 CreateEligible = false: /// 它仍在 hits 里(因而保护既有异常不被误判恢复),只是不用来开新案子。 /// /// 为什么不是「过滤掉过期行」:那正是缺陷本身。被过滤的行不在 hits 里, /// 与「不再命中」不可区分 —— 这条注释存在的意义就是防止有人日后为了 /// 「少建几条历史异常」把时间条件加回数据集。 /// /// 默认 false,因此未声明的规则行为逐字不变(Rule 01 即是)。 /// 判定基准是 canonical 列, /// 故声明本项的规则其数据集必须 HasDueAt = true /// public bool CreateOnlyBeforeDueAt { get; init; } /// 本规则开放给租户调整的运行参数白名单与取值域。 public S8RuleParameterPolicy Parameters { get; init; } = new(); } /// /// TIMEOUT 判定语义。 /// /// 这些是列名,但不是"让用户填的列名":它们描述 canonical 行契约里哪一列承载到期时间、 /// 哪一列承载状态。Provider 按 产出行,因此默认值就是 canonical 名; /// 声明出来是为了让「判定读了哪一列」这件事有一处可被测试断言的书面记录, /// 而不是散在 evaluator 的 if 分支里。 /// public sealed class S8TimeoutSemantics { /// 到期时间列。 public string DueAtColumn { get; init; } = S8CanonicalColumns.DueAt; /// 状态列。 public string StatusColumn { get; init; } = S8CanonicalColumns.Status; /// 去重身份列(写入 exception.source_object_id 并参与 dedup_key)。 public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId; /// 关联单号列(写入 exception.related_object_code)。 public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode; /// /// 视为「已完成、不再超期」的状态集合。比对**忽略大小写**(沿用既有 evaluator 口径)。 /// 这是最典型的「看起来像参数、实际是业务定义」的字段:改一个值,同一条规则就换了一套业务含义。 /// public IReadOnlyList CompletedStates { get; init; } = Array.Empty(); } /// SHORTAGE 判定语义。当前无任何规则使用(仓内尚无 SHORTAGE 定义),保留以保证三类 evaluator 结构一致。 public sealed class S8ShortageSemantics { public string TargetQtyColumn { get; init; } = S8CanonicalColumns.TargetQty; public string ActualQtyColumn { get; init; } = S8CanonicalColumns.ActualQty; public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId; public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode; /// 绝对容差。缺口需**大于**该值才命中。 public decimal ToleranceAbs { get; init; } /// 比例容差(0–1)。缺口占目标的比例需**大于**该值才命中。 public decimal ToleranceRatio { get; init; } } /// OUT_OF_RANGE 判定语义。当前无任何规则使用,同上。 public sealed class S8OutOfRangeSemantics { public string MeasuredValueColumn { get; init; } = S8CanonicalColumns.MeasuredValue; public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId; public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode; /// 行内下限列;为空表示使用 固定值。 public string? LowerBoundColumn { get; init; } /// 行内上限列;为空表示使用 固定值。 public string? UpperBoundColumn { get; init; } public decimal? LowerBound { get; init; } public decimal? UpperBound { get; init; } public decimal ToleranceAbs { get; init; } public decimal ToleranceRatio { get; init; } } /// /// 运行参数白名单与取值域。 /// /// 这里回答的是一个很窄的问题:租户管理员能改什么,改到什么范围。 /// 不在本策略里的字段,就没有任何 API 能改到——不是"前端没做入口",是写模型里根本不存在该字段。 /// /// 取值域随定义走而不是写死在 service:不同规则对「宽限多久算合理」的判断本就不同, /// 而把它写在 service 的 if 里,等于把业务口径藏进了实现。 /// public sealed class S8RuleParameterPolicy { public int PollIntervalSecondsMin { get; init; } = 60; public int PollIntervalSecondsMax { get; init; } = 86400; public int PollIntervalSecondsDefault { get; init; } = 300; public int TriggerCountRequiredMin { get; init; } = 1; public int TriggerCountRequiredMax { get; init; } = 10; public int TriggerCountRequiredDefault { get; init; } = 1; public int RecoverCountRequiredMin { get; init; } = 1; public int RecoverCountRequiredMax { get; init; } = 10; public int RecoverCountRequiredDefault { get; init; } = 1; /// /// 宽限分钟下限恒为 0。**刻意不设业务上限**:本批没有依据判断「多久算过长」, /// 凭空设一个上限会在没有任何证据的情况下否掉合法配置。 /// public int GraceMinutesMin { get; init; } = 0; public int GraceMinutesMax { get; init; } = int.MaxValue; public int GraceMinutesDefault { get; init; } = 0; /// /// 允许的严重度。**刻意只有两值**:S8SeverityCode.IsValid 是宽松六值版 /// (含 LOW/MEDIUM/HIGH/CRITICAL),那是给 legacy 查询参数兼容用的, /// 拿来当写入门禁会直接放行 legacy 值(DB 里 severity='HIGH' 那一行正是这样进来的)。 /// public IReadOnlyList AllowedSeverities { get; init; } = new[] { S8SeverityCode.Follow, S8SeverityCode.Serious }; public string SeverityDefault { get; init; } = S8SeverityCode.Follow; /// 是否允许配置发生 / 责任部门兜底。 public bool AllowsDepartmentDefaults { get; init; } = true; /// /// S8-RULE-READINESS-1:这两个部门参数是否为启用的前置条件。 /// /// 默认 false,且刻意如此:这不是 S8 的全局规则,而是逐规则的声明。 /// 数据集自带部门列的规则(命中行能直接给出 occurrence / responsible)不需要它们, /// 在全局写死"必须配部门"会把那类规则一起误伤。 /// /// true 的判据只有一条:该规则的数据集拿不出部门, /// 因而不配这两项就必然建单失败。Rule 01 正是如此 —— /// dwd_supplier_delivery 没有任何部门列。 /// /// 在 Enable / RunNow / Scheduler 三条入口统一消费。 /// 与 是两件事:后者管"能不能配",本项管"必须配"。 /// public bool RequiresDepartmentDefaultsForEnable { get; init; } /// /// S8-RESPONSIBILITY-POOL-1:启用前是否必须已配置处理账号池。 /// /// 本项是把既有判据显式化,不是新增门禁。处理池判据原先写在 /// 里、却位于 /// 的提前返回之后 —— 于是它事实上只对"必须配部门"的规则生效, /// 对其余规则被静默跳过。这种「两件事被一个开关捆在一起」的耦合, /// 在新增复核 / 升级两条判据时会立刻放大成三重耦合。 /// /// 拆出独立开关后,各条判据由各自的声明驱动;当前取值刻意与拆分前的 /// 实际行为完全一致(仅 Rule 01 为 true),本批不顺带改变任何规则的启用条件。 /// public bool RequiresHandlerPoolForEnable { get; init; } /// /// S8-RESPONSIBILITY-POOL-1:该规则产生的异常,处理流程是否包含复核环节。 /// /// true 时,启用前必须已配置至少一名有效的复核账号 —— /// 否则处理人做完点「提交复核」会发现候选人列表是空的,单据卡在 /// IN_PROGRESS 出不去,而规则本身一路显示正常。 /// /// 不做成全局要求:将来若有规则处理完即闭环、根本不走复核, /// 强制它配复核人只会逼出一个占位账号 —— 那比没有配置更危险。 /// public bool RequiresVerification { get; init; } /// /// S8-RESPONSIBILITY-POOL-1:该规则的异常是否参与超时自动升级。 /// /// true 时,启用前必须已配置至少一名有效的升级账号, /// 且超时升级作业按本规则的升级账号池解析收件人。 /// /// 取代 exception_type.escalate_role_code 成为 S8 规则链路的升级 authority: /// 旧字段是跨租户扫描时按异常类型查的,实测会把 B 租户的 escalate_role_code /// 用到 A 租户的异常上(见 S8TimeoutAutoEscalationService 原注释)。 /// 责任池天然带 tenant_id,不存在这条越界路径。旧字段保留供其它模块兼容, /// S8 规则链路不再依赖。 /// public bool SupportsTimeoutEscalation { get; init; } }