S8ResultKpiEnabledGateTests.cs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Service.S8;
  3. using Xunit;
  4. namespace Admin.NET.Test.S8;
  5. /// <summary>
  6. /// S8-CONFIG-AUTHORITY-FIX-1:结果 KPI 的「停用即不显示」门禁。
  7. ///
  8. /// 修复前的缺陷:<c>GetResultKpiSummaryAsync</c> 只按 mechanism / is_result_kpi / 租户作用域取行,
  9. /// <b>完全不读 enabled</b>。运行库里 5 条结果 KPI 全部 enabled=0(remark "[RESERVED] S9 result KPI"),
  10. /// 却照常出现在监控总览——配置页面的停用开关对业务零效力。
  11. ///
  12. /// 本文件锁定的契约与失败后果:
  13. /// A. enabled=true → 纳入 → 否则正常指标消失,属功能倒退
  14. /// B. enabled=false → 不纳入 → 否则"停用"仍然显示,用户认为配置坏了
  15. /// C. is_result_kpi=false → 不纳入 → 否则机制字典项(Rule 向导用)会污染结果 KPI 卡片
  16. /// D. 门禁必须在**覆盖解析之后** → 这是最容易写错的一条:
  17. /// 若把 Enabled 下推成 SQL WHERE,租户 override enabled=false 的行会先被滤掉,
  18. /// 分组里只剩 enabled=true 的全局行 → 结果回落成"显示",与停用意图完全相反。
  19. /// E. 不触碰 Rule 机制字典 → 门禁只作用于结果 KPI 路径
  20. ///
  21. /// 不接 DB、不接 DI、不触发任何真实规则。
  22. /// </summary>
  23. public class S8ResultKpiEnabledGateTests
  24. {
  25. private const long TenantId = 838257186181189L;
  26. private const long FactoryId = 838257186320453L;
  27. private static AdoS8MonitorMetric Metric(
  28. string code, bool enabled, bool isResultKpi = true,
  29. long tenantId = 0, long factoryId = 0, string mechanism = "RATIO", int sortNo = 0) => new()
  30. {
  31. MetricCode = code,
  32. MetricName = code,
  33. Mechanism = mechanism,
  34. IsResultKpi = isResultKpi,
  35. Enabled = enabled,
  36. TenantId = tenantId,
  37. FactoryId = factoryId,
  38. SortNo = sortNo
  39. };
  40. private static List<string> CodesOf(IEnumerable<AdoS8MonitorMetric> rows) =>
  41. rows.Select(x => x.MetricCode).ToList();
  42. // ============================================================
  43. // A/B/C. 基础门禁
  44. // ============================================================
  45. [Fact]
  46. public void T1_EnabledResultKpi_IsReturned()
  47. {
  48. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
  49. {
  50. Metric("ORDER_DELIVERY_RATE", enabled: true)
  51. });
  52. Assert.Equal(new[] { "ORDER_DELIVERY_RATE" }, CodesOf(result));
  53. }
  54. [Fact]
  55. public void T2_DisabledResultKpi_IsNotReturned()
  56. {
  57. // 运行库现状:5 条结果 KPI 全部 enabled=0,修复前它们照常显示。
  58. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
  59. {
  60. Metric("IQC_PASS_RATE", enabled: false)
  61. });
  62. Assert.Empty(result);
  63. }
  64. [Fact]
  65. public void T3_NonResultKpi_IsNotReturned()
  66. {
  67. // is_result_kpi=false 的是 Rule 向导用的机制字典项(DATE / VALUE_RANGE),
  68. // 即使 enabled=true 也不属于结果 KPI 卡片。
  69. // 注:调用方 SQL 已按 IsResultKpi 过滤,这里再断言一次,防止将来有人把过滤挪进本方法时写反。
  70. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
  71. {
  72. Metric("ORDER_DUE_AT", enabled: true, isResultKpi: false, mechanism: "DATE")
  73. }.Where(m => m.IsResultKpi));
  74. Assert.Empty(result);
  75. }
  76. // ============================================================
  77. // D. 覆盖解析与门禁的先后顺序 —— 本文件最关键的两条
  78. // ============================================================
  79. [Fact]
  80. public void T4_TenantOverrideDisable_HidesMetric_EvenWhenGlobalEnabled()
  81. {
  82. // 全局启用 + 租户停用 → 租户看不到。
  83. // 若实现把 Enabled 下推成 SQL WHERE,租户行会先被滤掉、只剩全局行,
  84. // 结果反而变成"可见"——本用例正是为了钉死这个反向缺陷。
  85. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
  86. {
  87. Metric("ORDER_DELIVERY_RATE", enabled: true), // 平台全局
  88. Metric("ORDER_DELIVERY_RATE", enabled: false, tenantId: TenantId, factoryId: FactoryId) // 租户覆盖:停用
  89. });
  90. Assert.Empty(result);
  91. }
  92. [Fact]
  93. public void T5_TenantOverrideEnable_ShowsMetric_EvenWhenGlobalDisabled()
  94. {
  95. // 反向:全局停用 + 租户启用 → 租户可见。
  96. // 覆盖语义由 OrderByDescending(FactoryId).ThenByDescending(TenantId) 承载,本用例确认它对两个方向都成立。
  97. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
  98. {
  99. Metric("PO_DELIVERY_RATE", enabled: false),
  100. Metric("PO_DELIVERY_RATE", enabled: true, tenantId: TenantId, factoryId: FactoryId)
  101. });
  102. Assert.Equal(new[] { "PO_DELIVERY_RATE" }, CodesOf(result));
  103. }
  104. /// <summary>
  105. /// 覆盖解析必须"每个 code 只留一条",否则同一 KPI 会在卡片上重复出现 —— 这条不变。
  106. ///
  107. /// <para><b>S8-TENANT-ONLY-BATCH6:优先级判据由「工厂号降序」改为「租户行优先」。</b>
  108. /// 原断言是「工厂行优先级最高」。本批之后租户覆盖行的 <c>factory_id</c> 恒为 0,
  109. /// 按工厂排序会与平台默认打平并退化成任意序,可能让平台默认反超租户覆盖 ——
  110. /// 表现为「配置页显示一种生效值、运行时用另一种」。故优先级只认「是不是租户行」。</para>
  111. ///
  112. /// <para>同租户内仅工厂元数据不同的两行属**歧义配置**:真库取证
  113. /// <c>(tenant_id, metric_code)</c> 重复组为 0,该情形不存在;此处只断言
  114. /// 「收敛为一条且必是租户行」,不再对工厂号做任何承诺。</para>
  115. /// </summary>
  116. [Fact]
  117. public void T5b_EachMetricCode_ResolvesToExactlyOneEffectiveRow()
  118. {
  119. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
  120. {
  121. Metric("WO_COMPLETION_RATE", enabled: true),
  122. Metric("WO_COMPLETION_RATE", enabled: true, tenantId: TenantId),
  123. Metric("WO_COMPLETION_RATE", enabled: true, tenantId: TenantId, factoryId: FactoryId)
  124. });
  125. Assert.Single(result);
  126. // 胜出的必须是租户行,而不是平台默认行;工厂号不再参与优先级。
  127. Assert.Equal(TenantId, result[0].TenantId);
  128. Assert.NotEqual(0L, result[0].TenantId);
  129. }
  130. // ============================================================
  131. // E. Rule 机制字典不受影响
  132. // ============================================================
  133. [Fact]
  134. public void T6_RuleMechanismDictionary_IsUntouchedByTheGate()
  135. {
  136. // 运行库中 ado_s8_monitor_metric 有 8 条 enabled=1 的机制字典项(DATE / VALUE_RANGE),
  137. // 由 S8ConfigDraftService(Rule 向导)消费。本门禁只在结果 KPI 路径调用,
  138. // 这里断言:即使把它们混进候选集,也只会因 IsResultKpi=false 被调用方排除,
  139. // 而不会被本方法当作结果 KPI 返回。
  140. var mixed = new[]
  141. {
  142. Metric("ORDER_DUE_AT", enabled: true, isResultKpi: false, mechanism: "DATE"),
  143. Metric("INV_QTY", enabled: true, isResultKpi: false, mechanism: "VALUE_RANGE"),
  144. Metric("ORDER_FLOW_STAGE_VARIANCE", enabled: true, isResultKpi: false, mechanism: "VALUE_RANGE"),
  145. Metric("ORDER_DELIVERY_RATE", enabled: true)
  146. };
  147. // 模拟调用方 SQL 的 mechanism + is_result_kpi 过滤。
  148. var candidates = mixed.Where(m => m.Mechanism == "RATIO" && m.IsResultKpi);
  149. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(candidates);
  150. Assert.Equal(new[] { "ORDER_DELIVERY_RATE" }, CodesOf(result));
  151. // 机制字典项全部保持 enabled=true,未被本批改动。
  152. Assert.All(mixed.Where(m => !m.IsResultKpi), m => Assert.True(m.Enabled));
  153. }
  154. // ============================================================
  155. // 排序稳定性(卡片顺序不应因门禁而乱)
  156. // ============================================================
  157. [Fact]
  158. public void EnabledMetrics_KeepSortNoThenCodeOrdering()
  159. {
  160. var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
  161. {
  162. Metric("B_RATE", enabled: true, sortNo: 2),
  163. Metric("A_RATE", enabled: true, sortNo: 2),
  164. Metric("Z_RATE", enabled: true, sortNo: 1),
  165. Metric("X_RATE", enabled: false, sortNo: 0) // 停用项不参与排序结果
  166. });
  167. Assert.Equal(new[] { "Z_RATE", "A_RATE", "B_RATE" }, CodesOf(result));
  168. }
  169. }