S8KpiProvenanceTests.cs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. using Admin.NET.Plugin.AiDOP.Service.S8;
  2. using System.Reflection;
  3. using System.Text.RegularExpressions;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  6. /// <summary>
  7. /// S8-REAL-DATA-PRESENTATION-1:结果 KPI 的来源标注守卫。
  8. ///
  9. /// <para><b>修复前的事实</b>(2026-09-21 取证,代码 + 实库双证):
  10. /// <c>ORDER_DELIVERY_RATE</c> / <c>PO_DELIVERY_RATE</c> / <c>WO_COMPLETION_RATE</c> /
  11. /// <c>ORDER_FLOW_CYCLE_RATIO</c> 四项分别读 <c>ado_s8_order_flow_order</c> /
  12. /// <c>_procurement_pivot</c> / <c>_manufacturing_process</c> / <c>_stage</c>,
  13. /// 并以 <c>Source="ORDER_FLOW_CALC"</c>(前端「订单链路计算」)对外宣称是当前实算。
  14. /// 而这四张表唯一的写入方是 SeedData,实库全部行 <c>data_source='SEED'</c>,查询也不过滤 SEED。
  15. /// 另有 envelope 无条件写死 <c>MIXED_BASELINE</c>(前端「当前基线 · 部分订单链路计算」),
  16. /// 即便 items 为空也照样宣称「部分算了」。</para>
  17. ///
  18. /// <para><b>为什么是源码契约测试</b>:<see cref="S8MonitoringService"/> 依赖 SqlSugar 实库,
  19. /// 进程内无法构造;沿用本仓既有手法(反射元数据 + 去注释源码契约),
  20. /// 与 <c>S8DebugTenantGuardTests</c> / <c>S8LegacySymbolsRemovedTests</c> 同范式。
  21. /// 断言一律在**剥离注释后**的源码上进行,避免文档里提到某个词就把守卫变成噪音。</para>
  22. /// </summary>
  23. public class S8KpiProvenanceTests
  24. {
  25. private static string PluginRoot => Path.GetFullPath(
  26. Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  27. private static string ReadCode(string relativePath)
  28. {
  29. var raw = File.ReadAllText(Path.Combine(PluginRoot, relativePath));
  30. var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
  31. return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
  32. }
  33. private static string MonitoringCode => ReadCode("Service/S8/S8MonitoringService.cs");
  34. /// <summary>SEED 订单链路表对应的实体名——它们不得出现在监控/KPI 服务的运行期取数里。</summary>
  35. private static readonly string[] SeedOrderFlowEntities =
  36. {
  37. "AdoS8OrderFlowOrder",
  38. "AdoS8OrderFlowProcurementPivot",
  39. "AdoS8OrderFlowManufacturingProcess",
  40. "AdoS8OrderFlowStage",
  41. };
  42. // ───────────── ⑧ HeadlineKpi_DoesNotReadSeedAsCurrentFact ─────────────
  43. /// <summary>KPI 服务不得再依赖任何 ado_s8_order_flow_* SEED 仓储。</summary>
  44. [Fact]
  45. public void HeadlineKpi_DoesNotReadSeedAsCurrentFact()
  46. {
  47. var code = MonitoringCode;
  48. foreach (var entity in SeedOrderFlowEntities)
  49. Assert.DoesNotContain(entity, code);
  50. Assert.DoesNotContain("ado_s8_order_flow", code);
  51. }
  52. /// <summary>
  53. /// 反射元数据层的同一条守卫:构造函数不得再注入 SEED 订单链路仓储。
  54. /// 改文案绕不过这一条。
  55. /// </summary>
  56. [Fact]
  57. public void MonitoringService_TakesNoSeedOrderFlowRepository()
  58. {
  59. var ctor = Assert.Single(typeof(S8MonitoringService).GetConstructors());
  60. var injected = ctor.GetParameters()
  61. .Select(p => p.ParameterType.IsGenericType
  62. ? p.ParameterType.GetGenericArguments()[0].Name
  63. : p.ParameterType.Name)
  64. .ToList();
  65. foreach (var entity in SeedOrderFlowEntities)
  66. Assert.DoesNotContain(entity, injected);
  67. }
  68. /// <summary>
  69. /// 不得再发射 ORDER_FLOW_CALC / ORDER_FLOW_CALC_EMPTY 这两个「实算」provenance。
  70. /// 判据精确到赋值形态,避免误伤文档中对历史行为的说明。
  71. /// </summary>
  72. [Fact]
  73. public void NoOrderFlowCalcProvenanceIsEmitted()
  74. {
  75. var code = MonitoringCode;
  76. Assert.DoesNotContain("\"ORDER_FLOW_CALC_EMPTY\"", code);
  77. // 唯一允许保留的是 envelope 的**判定**(读 items 的 Source),不是发射点。
  78. var occurrences = Regex.Matches(code, "\"ORDER_FLOW_CALC\"").Count;
  79. Assert.Equal(1, occurrences);
  80. Assert.Contains("i.Source == \"ORDER_FLOW_CALC\"", code);
  81. }
  82. // ───────────── ⑩ MissingRealAuthority_ReturnsNoData ─────────────
  83. /// <summary>
  84. /// 四项无真实 Authority 的 KPI 必须走「不出当前值」的路径:
  85. /// 分发器不再按 metricCode 分支,一律返回 null → 调用方落 PENDING_REAL。
  86. /// </summary>
  87. [Fact]
  88. public void MissingRealAuthority_ReturnsNoData()
  89. {
  90. var code = MonitoringCode;
  91. // 四个指标码不得再出现在任何 switch 分发分支里
  92. foreach (var metric in new[]
  93. {
  94. "\"ORDER_DELIVERY_RATE\" =>",
  95. "\"PO_DELIVERY_RATE\" =>",
  96. "\"WO_COMPLETION_RATE\" =>",
  97. "\"ORDER_FLOW_CYCLE_RATIO\" =>",
  98. })
  99. Assert.DoesNotContain(metric, code);
  100. // 旧的四个计算方法必须整体消失
  101. foreach (var m in new[]
  102. {
  103. "ComputeOrderDeliveryRateAsync",
  104. "ComputePoDeliveryRateAsync",
  105. "ComputeWoCompletionRateAsync",
  106. "ComputeOrderFlowCycleRatioAsync",
  107. })
  108. Assert.DoesNotContain(m, code);
  109. // PENDING_REAL 兜底路径仍在
  110. Assert.Contains("\"PENDING_REAL\"", code);
  111. }
  112. /// <summary>
  113. /// IQC_PASS_RATE 作为如实标注的正向样板必须保持不变:
  114. /// 常量值 + DEMO_BASELINE 标注(前端「当前基线」),不得被顺手改成实算。
  115. /// </summary>
  116. [Fact]
  117. public void ReferenceKpi_IsExplicitlyLabeledReference()
  118. {
  119. var code = MonitoringCode;
  120. Assert.Contains("IQC_PASS_RATE", code);
  121. Assert.Contains("\"DEMO_BASELINE\"", code);
  122. }
  123. // ───────────── envelope provenance ─────────────
  124. /// <summary>
  125. /// envelope 的 Source 必须由 items 真实构成推出,不得写死。
  126. /// 旧实现无条件 MIXED_BASELINE —— items 为空时照样宣称「部分订单链路计算」。
  127. /// </summary>
  128. [Fact]
  129. public void EnvelopeProvenance_IsDerivedFromItems_NotHardcoded()
  130. {
  131. var code = MonitoringCode;
  132. Assert.DoesNotContain("Source = \"MIXED_BASELINE\",", code);
  133. Assert.Contains("hasRealCalc", code);
  134. Assert.Contains("hasRealCalc ? \"MIXED_BASELINE\" : \"DICTIONARY_MOCK\"", code);
  135. }
  136. // ───────────── ⑬ NoDirectBusinessSourceRead ─────────────
  137. /// <summary>
  138. /// 去 seed 不得演变成「直读业务源表」。监控/KPI 服务里不允许出现源系统表名。
  139. /// </summary>
  140. [Fact]
  141. public void NoDirectBusinessSourceRead()
  142. {
  143. var code = MonitoringCode;
  144. foreach (var src in new[]
  145. {
  146. "PurOrdMaster", "PurOrdDetail", "WorkOrdMaster",
  147. "NbrMaster", "NbrDetail", "qms_", "crm_", "mdp_stg_",
  148. })
  149. Assert.DoesNotContain(src, code);
  150. }
  151. // ───────────── ⑫ TenantA_KpiCannotAggregateTenantB ─────────────
  152. /// <summary>
  153. /// 结果 KPI 的字典查询仍须按租户收口(平台默认行 tenant_id=0 与本租户行),
  154. /// 且作用域一律服务端解析,不接受调用方传入。
  155. /// </summary>
  156. [Fact]
  157. public void ResultKpi_StaysTenantScoped()
  158. {
  159. var code = MonitoringCode;
  160. Assert.Contains("x.TenantId == S8ConfigScope.GlobalTenantId", code);
  161. Assert.Contains("x.TenantId == tenantId", code);
  162. var controller = ReadCode("Controllers/S8/AdoS8MonitoringController.cs");
  163. Assert.Contains("_scope.ResolveAsync()", controller);
  164. Assert.DoesNotContain("ClearFilter", controller);
  165. }
  166. }