S8PurchaseDeliveryEndToEndTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  5. using Microsoft.Extensions.Logging.Abstractions;
  6. using Xunit;
  7. namespace Admin.NET.Test.S8;
  8. /// <summary>
  9. /// S8-RULE01-PURCHASE-DELIVERY-CLOSURE-1:Rule 01 的**端到端链路**契约。
  10. ///
  11. /// <b>与既有 S8PurchaseDeliveryProviderMappingTests 的分工</b>(不是重复覆盖):
  12. /// 那个文件把 Provider 映射与 Evaluator 判定**分开**验证 —— canonical 映射直接构造行,
  13. /// 判定直接调 <c>EvaluateRows</c>。两端各自正确,不等于串起来正确:
  14. /// 中间还隔着 Catalog 查找、能力校验、Provider 解析、RowSet 传递。
  15. /// 那一段目前没有任何用例覆盖,本文件补的就是它。
  16. ///
  17. /// 链路:
  18. /// <code>
  19. /// dataset_code
  20. /// → S8DatasetCatalog(真实 S8BusinessDatasetDefinitions,非 fake 定义)
  21. /// → S8MonitoringDataProviderRegistry
  22. /// → Provider.LoadAsync → S8MonitoringRowSet(canonical)
  23. /// → S8TimeoutRuleEvaluator → S8RuleHit
  24. /// </code>
  25. ///
  26. /// <b>Provider 用 stub 而非真 S8PurchaseDeliveryDataProvider</b>:后者需要 ISqlSugarClient 与真库,
  27. /// 而本套件的硬约束是不接 DB。stub 只负责"吐出与真 Provider 相同形状的 canonical 行"——
  28. /// 真 Provider 的 SQL→canonical 映射由 Mapping 套件用真实样本逐字段锁定,
  29. /// 两者合起来才构成完整证据。stub 刻意复用真实 Catalog 定义与真实能力声明,
  30. /// 因此 Catalog / Capability / Gateway 校验这几段走的都是**生产代码本身**。
  31. ///
  32. /// 全程不出现:SQL expression、Legacy Provider、物理数据源配置、rule.Expression。
  33. /// </summary>
  34. public class S8PurchaseDeliveryEndToEndTests
  35. {
  36. private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
  37. private const string RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";
  38. private const long TenantId = 838257186181189L;
  39. private const long FactoryId = 838257186320453L;
  40. /// <summary>评估基准时刻。固定值而非 DateTime.Now:判定结果必须可复现。</summary>
  41. private static readonly DateTime EvaluatedAt = new(2026, 9, 6, 12, 0, 0);
  42. /// <summary>Rule 01 在库中的真实 params_json(逐字,含三个终态)。</summary>
  43. private static S8TimeoutParams Params() => S8TimeoutParams.Parse(
  44. """
  45. {"dueAtField":"due_at","statusField":"status",
  46. "completedStates":["COMPLETED","CLOSED","CANCELLED"],
  47. "objectCodeField":"related_object_code","objectIdField":"source_object_id",
  48. "graceMinutes":0,"exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL"}
  49. """);
  50. private static AdoS8WatchRule Rule01() => new()
  51. {
  52. Id = 1329909460023L,
  53. RuleCode = RuleCode,
  54. SceneCode = "S4",
  55. WatchObjectType = "PURCHASE_ORDER_LINE",
  56. SourceObjectType = "PURCHASE_ORDER_LINE",
  57. Severity = "SERIOUS",
  58. RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
  59. DatasetCode = PurchaseDelivery,
  60. TenantId = TenantId,
  61. FactoryId = FactoryId,
  62. Enabled = false,
  63. ParamsJson = "{}"
  64. };
  65. /// <summary>
  66. /// 吐出与真 Provider 同形状 canonical 行的 stub。
  67. /// 能力声明取自 <see cref="S8BusinessDatasetDefinitions"/> 的同一实例,
  68. /// 保证 Gateway 的第 ⑨ 步(Provider 能力 vs 目录能力)走真实比对而非自证。
  69. /// </summary>
  70. private sealed class StubPurchaseDeliveryProvider : IS8MonitoringDataProvider
  71. {
  72. private readonly IReadOnlyList<(string PoNo, string PoLine, DateTime? NeedDate, DateTime? DueDate, string Status)> _rows;
  73. public StubPurchaseDeliveryProvider(
  74. params (string PoNo, string PoLine, DateTime? NeedDate, DateTime? DueDate, string Status)[] rows) => _rows = rows;
  75. public string DatasetCode => PurchaseDelivery;
  76. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  77. public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  78. {
  79. // 与真 Provider 一致:作用域必须显式给出,缺失即失败。
  80. request.EnsureValid();
  81. var mapped = _rows.Select(r => S8MonitoringRow.FromValues(
  82. S8PurchaseDeliveryDataProvider.ToCanonicalValues(
  83. new S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow
  84. {
  85. SourceObjectId = $"{r.PoNo}#{r.PoLine}",
  86. RelatedObjectCode = r.PoNo,
  87. RelatedObjectName = "珩睿",
  88. DueAt = r.NeedDate ?? r.DueDate,
  89. Status = r.Status,
  90. DueAtSource = r.NeedDate == null ? "due_date" : "need_date",
  91. PoNo = r.PoNo,
  92. PoLine = r.PoLine,
  93. NeedDate = r.NeedDate,
  94. DueDate = r.DueDate,
  95. StatDate = new DateTime(2026, 9, 6)
  96. }))).ToList();
  97. return Task.FromResult(S8MonitoringRowSet.FromRows(mapped));
  98. }
  99. }
  100. private static S8MonitoringDataGateway Gateway(IS8MonitoringDataProvider provider) =>
  101. new(new S8MonitoringDataProviderRegistry(new[] { provider }),
  102. // 真实目录定义,不是测试 fake —— Catalog 里 PURCHASE_DELIVERY 的编码 / 能力 /
  103. // IsActive 若被改坏,本用例必须红。
  104. new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
  105. NullLogger<S8MonitoringDataGateway>.Instance);
  106. private static async Task<List<S8RuleHit>> RunChainAsync(StubPurchaseDeliveryProvider provider)
  107. {
  108. var rule = Rule01();
  109. var data = await Gateway(provider).LoadAsync(
  110. TenantId, FactoryId, rule, S8TimeoutRuleEvaluator.RuleTypeCode, maxRows: 1000);
  111. return S8TimeoutRuleEvaluator.EvaluateRows(
  112. data.RowSet, Params(), rule, TenantId, FactoryId, EvaluatedAt);
  113. }
  114. // ============================================================
  115. // 三类判定:经完整链路,而非直接喂 EvaluateRows
  116. // ============================================================
  117. /// <summary>Case A:未到期 → 不命中。真实样本 PO202608280001#1(due 2026-09-17 / OPEN)。</summary>
  118. [Fact]
  119. public async Task CaseA_NotYetDue_ProducesNoHit()
  120. {
  121. var hits = await RunChainAsync(new StubPurchaseDeliveryProvider(
  122. ("PO202608280001", "1", new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN")));
  123. Assert.Empty(hits);
  124. }
  125. /// <summary>Case B:已到期且未完成 → 命中。真实样本 PO202608260002#3(need 2026-08-17 / DELAYED)。</summary>
  126. [Fact]
  127. public async Task CaseB_OverdueAndNotCompleted_ProducesHit()
  128. {
  129. var hits = await RunChainAsync(new StubPurchaseDeliveryProvider(
  130. ("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED")));
  131. var hit = Assert.Single(hits);
  132. Assert.Equal("PO202608260002#3", hit.SourceObjectId);
  133. Assert.Equal("PO202608260002", hit.RelatedObjectCode);
  134. Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
  135. Assert.Equal(RuleCode, hit.SourceRuleCode);
  136. Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode);
  137. }
  138. /// <summary>Case C:已到期但已完成 → 不命中。判定必须由 evaluator 做出,Provider 不预先过滤。</summary>
  139. [Fact]
  140. public async Task CaseC_OverdueButCompleted_ProducesNoHit()
  141. {
  142. var hits = await RunChainAsync(new StubPurchaseDeliveryProvider(
  143. ("PO202608260002", "1", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED")));
  144. Assert.Empty(hits);
  145. }
  146. // ============================================================
  147. // 混合快照:一次 tick 的真实形态
  148. // ============================================================
  149. /// <summary>
  150. /// 一个快照里三类行同时存在时,链路必须只挑出应命中的那一条。
  151. /// 这条比三个单行用例更接近真实 tick —— 单行用例无法暴露"Provider 少返了行"
  152. /// 或"evaluator 把整批一起放过"这类整体性错误。
  153. /// </summary>
  154. [Fact]
  155. public async Task MixedSnapshot_SelectsOnlyOverdueIncomplete()
  156. {
  157. var hits = await RunChainAsync(new StubPurchaseDeliveryProvider(
  158. ("PO-A", "1", new DateTime(2026, 8, 1), null, "DELAYED"), // 逾期未完成 → 命中
  159. ("PO-A", "2", new DateTime(2026, 8, 1), null, "COMPLETED"), // 逾期已完成 → 不命中
  160. ("PO-B", "1", new DateTime(2026, 12, 1), null, "OPEN"), // 未到期 → 不命中
  161. ("PO-C", "1", new DateTime(2026, 8, 1), null, "PARTIAL"))); // 部分交付 → 命中(非终态)
  162. Assert.Equal(2, hits.Count);
  163. Assert.Equal(new[] { "PO-A#1", "PO-C#1" }, hits.Select(h => h.SourceObjectId).OrderBy(x => x).ToArray());
  164. }
  165. // ============================================================
  166. // 链路纯净性:不得出现任何 Legacy 概念
  167. // ============================================================
  168. /// <summary>
  169. /// 同一对象跨两次刷新,DedupKey 必须逐字不变 —— 否则同一条延期会在每个快照日
  170. /// 重复建单,而不是刷新既有异常。这是 Rule 01 上线后最贵的错误形态。
  171. /// </summary>
  172. [Fact]
  173. public async Task DedupKey_IsStableAcrossRefreshes()
  174. {
  175. var row = ("PO202608260002", "3", (DateTime?)new DateTime(2026, 8, 17, 4, 33, 7), (DateTime?)null, "DELAYED");
  176. var first = await RunChainAsync(new StubPurchaseDeliveryProvider(row));
  177. var second = await RunChainAsync(new StubPurchaseDeliveryProvider(row));
  178. Assert.Equal(Assert.Single(first).DedupKey, Assert.Single(second).DedupKey);
  179. Assert.Contains(RuleCode, Assert.Single(first).DedupKey);
  180. Assert.Contains("PO202608260002#3", Assert.Single(first).DedupKey);
  181. }
  182. /// <summary>
  183. /// Rule 01 走完整链路时不得依赖任何 Legacy 字段。
  184. /// 实体上这三个属性已被物理删除(1.0.487 同步删列),此处用反射钉住不会被加回来 ——
  185. /// 一旦有人恢复 Expression,"S8 不执行任意 SQL" 这条边界就重新出现缺口。
  186. /// </summary>
  187. [Fact]
  188. public void Chain_CarriesNoLegacyConcepts()
  189. {
  190. var props = typeof(AdoS8WatchRule).GetProperties().Select(p => p.Name).ToArray();
  191. Assert.DoesNotContain("Expression", props);
  192. Assert.DoesNotContain("DataSourceId", props);
  193. Assert.DoesNotContain("DataAccessMode", props);
  194. // Gateway 的依赖里不得出现任何能执行 SQL 的东西。
  195. var deps = Assert.Single(typeof(S8MonitoringDataGateway).GetConstructors())
  196. .GetParameters().Select(p => p.ParameterType.Name).ToArray();
  197. Assert.DoesNotContain("IS8LegacySqlDataProvider", deps);
  198. Assert.DoesNotContain("ISqlSugarClient", deps);
  199. }
  200. /// <summary>
  201. /// 目录侧自证:Rule 01 依赖的 dataset 必须是 active 且声明支持 TIMEOUT。
  202. /// 若有人把 PURCHASE_DELIVERY 停用或去掉 SupportsTimeout,链路会在 Gateway 处失败,
  203. /// 但失败原因埋在异常文本里;这条把它变成显式断言。
  204. /// </summary>
  205. [Fact]
  206. public void Catalog_DeclaresPurchaseDeliveryReadyForTimeout()
  207. {
  208. var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() });
  209. var definition = catalog.Find(PurchaseDelivery);
  210. Assert.NotNull(definition);
  211. Assert.True(definition!.IsActive);
  212. Assert.True(definition.Capabilities.SupportsTimeout);
  213. Assert.Empty(definition.Capabilities.MissingFieldsFor(S8TimeoutRuleEvaluator.RuleTypeCode));
  214. // 用户可见的是业务名,不是物理表名。
  215. Assert.Equal("采购交付(订单行级)", definition.DisplayName);
  216. Assert.DoesNotContain("dwd_", definition.DisplayName);
  217. Assert.DoesNotContain("mdp_", definition.DisplayName);
  218. }
  219. }