S8OrderDeliveryRule02Tests.cs 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749
  1. using System.Reflection;
  2. using System.Text.RegularExpressions;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  5. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  6. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  8. using Xunit;
  9. namespace Admin.NET.Test.S8;
  10. /// <summary>
  11. /// S8-RULE02-ORDER-DELIVERY-WARNING-1:订单交付延期预警的结构契约。
  12. ///
  13. /// <para>本文件锁定的每一条,都对应一种「代码照样跑、结论却是错的」的失效形态:</para>
  14. /// <list type="bullet">
  15. /// <item><b>ETA 必须来自源系统</b>——一旦有人用「交期前 N 天」「历史均值」「已完工数量倒推」
  16. /// 补一个 ETA,规则会从"预测"退化成"临期提醒",而输出格式完全一样,看不出来。</item>
  17. /// <item><b>只判未到期</b>——去掉 <c>plan_date &gt;= NOW()</c> 会让 385 条历史逾期单一次性涌进来,
  18. /// 且永远不会恢复。这不是"多报几条",是这条规则从预警变成了积压清单。</item>
  19. /// <item><b>身份是订单行</b>——退化成订单号会让同单多行只剩一条异常,其余行静默消失。</item>
  20. /// <item><b>DedupKey 预算</b>——超预算的键被数据库截断后,两个订单行折叠成一条异常,无任何报错。</item>
  21. /// <item><b>SQL 别名映射</b>——本仓 <c>EnableUnderLine = false</c>,别名写错会静默映射为 null,
  22. /// 行数照常返回但命中恒为 0(Rule 01 真实踩过一次)。</item>
  23. /// </list>
  24. ///
  25. /// <para>不接 DB、不接 DI。真库行为已由实施前只读核验覆盖(390 行 / 40 行有 ETA / 命中 2 条),
  26. /// 本文件锁定的是结构。</para>
  27. /// </summary>
  28. public class S8OrderDeliveryRule02Tests
  29. {
  30. private const long TenantId = 838257186181189;
  31. private const string RuleCode = S8OrderDeliveryRuleDefinitions.OrderDeliveryDelayWarningCode;
  32. private static readonly S8RuleCatalog Catalog =
  33. new(new IS8RuleDefinitionSource[]
  34. {
  35. new S8PurchaseDeliveryRuleDefinitions(),
  36. new S8OrderDeliveryRuleDefinitions()
  37. });
  38. private static S8RuleDefinition Definition => Catalog.GetRequired(RuleCode);
  39. private static AdoS8WatchRule NewRule() => new()
  40. {
  41. Id = 900201,
  42. RuleCode = RuleCode,
  43. SceneCode = "S1",
  44. WatchObjectType = S8OrderDeliveryRuleDefinitions.SalesOrderLineObjectType,
  45. SourceObjectType = S8OrderDeliveryRuleDefinitions.SalesOrderLineObjectType,
  46. Severity = "FOLLOW",
  47. RuleType = S8OutOfRangeRuleEvaluator.RuleTypeCode,
  48. DatasetCode = S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode
  49. };
  50. private static S8EffectiveRule Effective(AdoS8WatchRule rule) =>
  51. S8EffectiveRule.Resolve(rule, Definition);
  52. /// <summary>
  53. /// 构造一行订单行事实。<paramref name="delayDays"/> 是 Provider 在 SQL 里算好的
  54. /// <c>DATEDIFF(sys_capacity_date, plan_date)</c>,此处显式传入以便逐值锁定判定边界。
  55. /// </summary>
  56. private static S8OrderDeliveryDataProvider.OrderDeliveryRow Row(
  57. string billNo, int entrySeq, DateTime planDate, DateTime? sysCapacityDate,
  58. decimal? delayDays, decimal qty = 1000m, string itemName = "集控备机",
  59. string customName = "内部客户") => new()
  60. {
  61. SourceObjectId = $"{billNo}#{entrySeq}",
  62. RelatedObjectCode = billNo,
  63. RelatedObjectName = customName,
  64. DueAt = planDate,
  65. DelayDays = delayDays,
  66. BillNo = billNo,
  67. EntrySeq = entrySeq,
  68. PlanDate = planDate,
  69. SysCapacityDate = sysCapacityDate,
  70. Qty = qty,
  71. ItemNumber = "IT-0001",
  72. ItemName = itemName,
  73. CustomNo = "C-0001",
  74. CustomName = customName,
  75. OrderBillNo = billNo
  76. };
  77. private static S8MonitoringRowSet RowSet(params S8OrderDeliveryDataProvider.OrderDeliveryRow[] rows) =>
  78. S8MonitoringRowSet.FromRows(
  79. rows.Select(r => S8MonitoringRow.FromValues(S8OrderDeliveryDataProvider.ToCanonicalValues(r))));
  80. /// <summary>去掉块注释与行注释后的 SQL —— 断言只应看真正会执行的文本。</summary>
  81. private static string SqlWithoutComments() =>
  82. Regex.Replace(
  83. Regex.Replace(S8OrderDeliveryDataProvider.Sql, @"/\*.*?\*/", " ", RegexOptions.Singleline),
  84. @"--[^\n]*", " ");
  85. // ============================================================
  86. // A. 数据集定义与 Provider 的一致性
  87. // ============================================================
  88. [Fact]
  89. public void T1_Dataset_IsDeclared_WithOutOfRangeCapabilityOnly()
  90. {
  91. var definition = Assert.Single(new S8OrderDeliveryDatasetDefinitions().GetDefinitions());
  92. Assert.Equal(S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode, definition.DatasetCode);
  93. Assert.Equal(S8DatasetStatus.Defined, definition.Status);
  94. var c = definition.Capabilities;
  95. Assert.True(c.SupportsOutOfRange);
  96. Assert.True(c.HasMeasuredValue);
  97. Assert.True(c.HasSourceObjectId);
  98. Assert.True(c.HasRelatedObjectCode);
  99. // TIMEOUT / SHORTAGE 必须关闭:本数据集不表达「到期日已过」也不表达「数量缺口」。
  100. // 多声明一项能力会让配置页把跑不通的规则类型列成可选,用户配完才在运行期失败。
  101. Assert.False(c.SupportsTimeout);
  102. Assert.False(c.SupportsShortage);
  103. }
  104. [Fact]
  105. public void T2_Provider_And_Catalog_ShareTheSameCapabilityInstance()
  106. {
  107. var provider = new S8OrderDeliveryDataProvider(null!, null!);
  108. var definition = Assert.Single(new S8OrderDeliveryDatasetDefinitions().GetDefinitions());
  109. // 同一实例而非"值相等":两处各写一份能力声明迟早会漂移,
  110. // 而漂移的表现是 Enable Gate 与 evaluator 对同一数据集有不同认知。
  111. Assert.Same(definition.Capabilities, provider.Capabilities);
  112. Assert.Equal(definition.DatasetCode, provider.DatasetCode);
  113. }
  114. [Fact]
  115. public void T3_RuleDefinition_PointsAtTheRegisteredDataset()
  116. {
  117. Assert.Equal(S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode, Definition.DatasetCode);
  118. Assert.Equal(new S8OrderDeliveryDataProvider(null!, null!).DatasetCode, Definition.DatasetCode);
  119. }
  120. [Fact]
  121. public void T4_RuleCatalog_AcceptsBothRules_WithoutCodeCollision()
  122. {
  123. // Catalog 构造期即做完整性校验 + 重复 rule_code 检测;能构造出来本身就是断言。
  124. Assert.True(Catalog.IsDefined(RuleCode));
  125. Assert.True(Catalog.IsDefined(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode));
  126. Assert.Equal(2, Catalog.Definitions.Count);
  127. }
  128. // ============================================================
  129. // B. SQL 契约:ETA 来源、时间窗、身份粒度
  130. // ============================================================
  131. [Fact]
  132. public void T5_Sql_RequiresRealEta_AndNeverSynthesizesOne()
  133. {
  134. var sql = SqlWithoutComments();
  135. // ETA 必须来自源系统字段,且缺失即出局。
  136. // (「ETA 是否晚于交期」不在这里判 —— 那是 evaluator 的事,见 T6 / T15 / T16。)
  137. Assert.Contains("s.capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
  138. Assert.Contains("DATEDIFF(s.capacity_date, s.plan_delivery_date)", sql, StringComparison.Ordinal);
  139. // 以下任何一个出现,都意味着有人在自造 ETA:
  140. // DATE_ADD / DATE_SUB / INTERVAL → 用「交期前 N 天」冒充预测
  141. // AVG → 用历史均值造 ETA
  142. // QtyCompleted / work_ord → 用工单完工量倒推(该列真库 267 行全为 0)
  143. // deliver_count → 已发数量,真库 100% 为空
  144. foreach (var forbidden in new[]
  145. {
  146. "DATE_ADD", "DATE_SUB", "INTERVAL", "AVG(",
  147. "QtyCompleted", "qty_completed", "work_ord", "WorkOrd", "deliver_count"
  148. })
  149. {
  150. Assert.DoesNotContain(forbidden, sql, StringComparison.OrdinalIgnoreCase);
  151. }
  152. }
  153. [Fact]
  154. public void T6_Sql_MustNotFilterByTimeOrByDelay_OrFalseRecoveryComesBack()
  155. {
  156. var sql = SqlWithoutComments();
  157. // ⚠️ 本仓最容易被"好心改回去"的一条。
  158. //
  159. // S8 的恢复判定唯一依据是「dedup_key 还在不在本轮 hits 里」
  160. // (ReconcileRecoveriesForRuleAsync),它无法区分「行不在结果集里」与
  161. // 「行还在但不越界」。所以任何被 SQL 滤掉的行 = 风险已解除。
  162. //
  163. // · 加回 plan_date >= NOW() → 订单跨过客户交期时行消失 → 风险**兑现**的那一刻
  164. // 被标成"已恢复"。这是 2026-09-07 实测复现并修掉的缺陷。
  165. // · 加回 sys_capacity_date > plan_date → ETA 修回时行同样消失,恢复结论碰巧对、
  166. // 理由是错的:正确的恢复形态必须是"行还在但不再命中"。
  167. //
  168. // 「只在到期前立案」由规则定义的 CreateOnlyBeforeDueAt 表达,不在取数层。
  169. Assert.DoesNotContain("NOW()", sql, StringComparison.OrdinalIgnoreCase);
  170. Assert.DoesNotContain("CURDATE", sql, StringComparison.OrdinalIgnoreCase);
  171. Assert.DoesNotContain("CURRENT_DATE", sql, StringComparison.OrdinalIgnoreCase);
  172. Assert.DoesNotContain("@Now", sql, StringComparison.OrdinalIgnoreCase);
  173. Assert.DoesNotContain("s.capacity_date > s.plan_delivery_date", sql, StringComparison.Ordinal);
  174. // 反面:取数仍必须要求 ETA 存在 —— 没有 ETA 就没有判定依据,
  175. // 这条过滤与生命周期无关,去掉它会让 350 条无 ETA 的行涌进来。
  176. Assert.Contains("s.capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
  177. }
  178. [Fact]
  179. public void T7_Sql_ScopesByTenant_AndExcludesClosedOrDeletedOrders()
  180. {
  181. var sql = SqlWithoutComments();
  182. Assert.Contains("s.tenant_id = @TenantId", sql, StringComparison.Ordinal);
  183. // 中台标准层把源侧的 o.closed / o.IsDeleted / e.IsDeleted 归一为两个标准列。
  184. Assert.Contains("IFNULL(s.closed, 0) = 0", sql, StringComparison.Ordinal);
  185. Assert.Contains("IFNULL(s.deleted_flag, 0) = 0", sql, StringComparison.Ordinal);
  186. // 租户是唯一的安全边界(Phase 1 拍板)。补 factory_id 过滤会让规则恒返 0 行且不报错。
  187. Assert.DoesNotContain("factory_id", sql, StringComparison.OrdinalIgnoreCase);
  188. }
  189. [Fact]
  190. public void T8_Sql_IsReadOnly()
  191. {
  192. var sql = SqlWithoutComments();
  193. // 必须按整词匹配:软删列名 IsDeleted 内含 "Delete",子串匹配会把一条只读 SQL 判成写操作。
  194. foreach (var write in new[] { "INSERT", "UPDATE", "DELETE", "REPLACE", "TRUNCATE", "ALTER", "DROP" })
  195. Assert.False(Regex.IsMatch(sql, $@"\b{write}\b", RegexOptions.IgnoreCase),
  196. $"取数 SQL 中出现写操作关键字 {write};本 Provider 只允许 SELECT");
  197. }
  198. [Fact]
  199. public void T9_Sql_UsesOrderLineGrain_NotOrderGrain()
  200. {
  201. var sql = SqlWithoutComments();
  202. // 身份 = 单号 + 行号。退化成单号会让同一张订单的多个风险行只留下一条异常。
  203. Assert.Contains("CONCAT(s.order_no, '#', s.order_line)", sql, StringComparison.Ordinal);
  204. Assert.Contains("s.order_line IS NOT NULL", sql, StringComparison.Ordinal);
  205. Assert.Contains("s.order_no <> ''", sql, StringComparison.Ordinal);
  206. // 数据来自中台标准层。原先的「明细 JOIN 主表」已在中台侧内化:
  207. // mdp_std_so 本身就是订单行 ⋈ 订单头的产物,消费者不再需要认识这两张业务表。
  208. Assert.Contains("FROM mdp_std_so s", sql, StringComparison.Ordinal);
  209. Assert.DoesNotContain("crm_seorderentry", sql, StringComparison.Ordinal);
  210. Assert.DoesNotContain("crm_seorder", sql, StringComparison.Ordinal);
  211. Assert.DoesNotContain("JOIN", sql, StringComparison.OrdinalIgnoreCase);
  212. }
  213. [Fact]
  214. public void T10_Sql_DoesNotReadProgress_BecauseItIsNotCompletionRate()
  215. {
  216. // crm_seorderentry.progress 是字符串状态码(0=再评审 1=新建 2=评审 3=确认),
  217. // 不是完成百分比。把它当进度用会得到一个看起来合理、实际无意义的判据。
  218. Assert.DoesNotContain("progress", SqlWithoutComments(), StringComparison.OrdinalIgnoreCase);
  219. }
  220. // ============================================================
  221. // C. SQL 别名 ↔ POCO 映射(EnableUnderLine = false 的静默失败防线)
  222. // ============================================================
  223. [Fact]
  224. public void T11_EverySelectAlias_MapsToRowProperty_AndViceVersa()
  225. {
  226. var aliases = Regex
  227. .Matches(S8OrderDeliveryDataProvider.Sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.IgnoreCase)
  228. .Select(m => m.Groups[1].Value)
  229. .ToArray();
  230. var props = typeof(S8OrderDeliveryDataProvider.OrderDeliveryRow)
  231. .GetProperties(BindingFlags.Public | BindingFlags.Instance)
  232. .Select(p => p.Name)
  233. .ToArray();
  234. Assert.NotEmpty(aliases);
  235. // Ordinal 比较:SqlSugar 的名字匹配不该靠"大概能对上"来赌。
  236. var unmapped = aliases.Where(a => !props.Contains(a, StringComparer.Ordinal)).ToArray();
  237. Assert.True(unmapped.Length == 0,
  238. "以下 SQL 别名在 OrderDeliveryRow 上找不到同名属性,SqlSugar 会静默映射为 null:\n "
  239. + string.Join("\n ", unmapped));
  240. var uncovered = props.Where(p => !aliases.Contains(p, StringComparer.Ordinal)).ToArray();
  241. Assert.True(uncovered.Length == 0,
  242. "以下属性没有任何 SQL 别名覆盖,取数后恒为 null:\n " + string.Join("\n ", uncovered));
  243. }
  244. [Fact]
  245. public void T12_CanonicalMapping_PutsDelayDaysIntoMeasuredValue()
  246. {
  247. var values = S8OrderDeliveryDataProvider.ToCanonicalValues(
  248. Row("SO202609030003", 1, new DateTime(2026, 9, 30), new DateTime(2026, 10, 12), 12m));
  249. // measured_value 是判定输入;放错列 = evaluator 拿不到值 = 命中恒为 0。
  250. Assert.Equal(12m, Assert.IsType<decimal>(values[S8CanonicalColumns.MeasuredValue]));
  251. Assert.Equal("SO202609030003#1", values[S8CanonicalColumns.SourceObjectId]);
  252. Assert.Equal("SO202609030003", values[S8CanonicalColumns.RelatedObjectCode]);
  253. // due_at 随行输出供展示与排序,但不参与判定(数据集能力里 SupportsTimeout=false)。
  254. Assert.Equal(new DateTime(2026, 9, 30), values[S8CanonicalColumns.DueAt]);
  255. // 两个原始日期必须一并落 payload:延期天数是差值,脱离两端就无法复盘。
  256. Assert.Equal(new DateTime(2026, 9, 30), values["plan_date"]);
  257. Assert.Equal(new DateTime(2026, 10, 12), values["sys_capacity_date"]);
  258. }
  259. [Fact]
  260. public void T13_DedupKeyBudget_LeavesRoomForRealOrderLineIds()
  261. {
  262. var request = new S8MonitoringDataRequest
  263. {
  264. TenantId = TenantId,
  265. DatasetCode = S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode,
  266. RuleCode = RuleCode,
  267. RuleType = S8OutOfRangeRuleEvaluator.RuleTypeCode
  268. };
  269. var budget = S8OrderDeliveryDataProvider.ResolveSourceObjectIdBudget(request);
  270. // 预算随租户 / 规则编码变化,故现算而不是写死。真实身份形如 SO202609030003#1(16 字符)。
  271. Assert.True(budget >= "SO202609030003#1".Length,
  272. $"dedup_key 预算 {budget} 已经容不下真实订单行标识,Provider 会开始静默跳过行");
  273. var expectedPrefix =
  274. $"T{TenantId}:R{RuleCode}:{S8OrderDeliveryRuleDefinitions.SalesOrderLineObjectType}:";
  275. Assert.Equal(
  276. S8OrderDeliveryDataProvider.DedupKeyMaxLength - expectedPrefix.Length, budget);
  277. }
  278. // ============================================================
  279. // D. 判定:越界方向、边界值、身份
  280. // ============================================================
  281. [Fact]
  282. public void T14_Hits_WhenSystemEtaIsLaterThanCustomerDueDate()
  283. {
  284. // 真库 dry-run 的两条命中之一:SO202609030003#1,要求 09-30,系统建议 10-12,晚 12 天。
  285. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  286. RowSet(Row("SO202609030003", 1, new DateTime(2026, 9, 30), new DateTime(2026, 10, 12), 12m)),
  287. Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
  288. var hit = Assert.Single(hits);
  289. Assert.Equal("ORDER_DELIVERY_DELAY_WARNING", hit.ExceptionTypeCode);
  290. Assert.Equal("SALES_ORDER_LINE", hit.SourceObjectType);
  291. Assert.Equal("SO202609030003#1", hit.SourceObjectId);
  292. Assert.Equal("SO202609030003", hit.RelatedObjectCode);
  293. Assert.Equal("S1", hit.SceneCode);
  294. Assert.Equal("FOLLOW", hit.Severity);
  295. Assert.Equal(
  296. $"T{TenantId}:R{RuleCode}:SALES_ORDER_LINE:SO202609030003#1", hit.DedupKey);
  297. // 方向与偏差必须落 payload:业务看到的"晚几天"就是这个 deviation。
  298. Assert.Contains("\"direction\":\"ABOVE_UPPER\"", hit.SourcePayload, StringComparison.Ordinal);
  299. Assert.Contains("\"deviation\":12", hit.SourcePayload, StringComparison.Ordinal);
  300. Assert.Contains("delay_days", hit.SourcePayload, StringComparison.Ordinal);
  301. }
  302. [Fact]
  303. public void T15_NoHit_WhenEtaEqualsDueDate()
  304. {
  305. // 上限为 0、容差为 0:正好赶上不算延期。这是边界,写错一个符号就会把
  306. // 全部"刚好按期"的订单报成异常。
  307. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  308. RowSet(Row("SO-ONTIME", 1, new DateTime(2026, 9, 30), new DateTime(2026, 9, 30), 0m)),
  309. Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
  310. Assert.Empty(hits);
  311. }
  312. [Fact]
  313. public void T16_NoHit_WhenEtaIsEarlierThanDueDate()
  314. {
  315. // 提前完成是好事。LowerBound 未设 → 不存在"太早也算异常"这条判据。
  316. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  317. RowSet(Row("SO-EARLY", 1, new DateTime(2026, 9, 30), new DateTime(2026, 9, 20), -10m)),
  318. Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
  319. Assert.Empty(hits);
  320. Assert.Null(Definition.OutOfRange!.LowerBound);
  321. }
  322. [Fact]
  323. public void T17_NoHit_WhenEtaIsMissing()
  324. {
  325. // 没有 ETA = 没有证据。SQL 已经滤掉这类行,此处再锁一次判定层:
  326. // 即便将来有人放宽 SQL,evaluator 也不会凭空判它延期。
  327. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  328. RowSet(Row("SO-NO-ETA", 1, new DateTime(2026, 9, 30), null, null)),
  329. Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
  330. Assert.Empty(hits);
  331. }
  332. [Fact]
  333. public void T18_EachOrderLine_GetsItsOwnException()
  334. {
  335. // 真库 dry-run 的两条命中分属两张订单;这里用同一张单的两行,
  336. // 锁住"同单多行不会被折叠成一条"。
  337. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  338. RowSet(
  339. Row("SO202609030004", 1, new DateTime(2026, 9, 30), new DateTime(2026, 10, 12), 12m),
  340. Row("SO202609030004", 2, new DateTime(2026, 10, 15), new DateTime(2026, 10, 20), 5m)),
  341. Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
  342. Assert.Equal(2, hits.Count);
  343. Assert.Equal(2, hits.Select(h => h.DedupKey).Distinct(StringComparer.Ordinal).Count());
  344. Assert.Contains(hits, h => h.SourceObjectId == "SO202609030004#1");
  345. Assert.Contains(hits, h => h.SourceObjectId == "SO202609030004#2");
  346. }
  347. // ============================================================
  348. // E. 规则定义契约
  349. // ============================================================
  350. [Fact]
  351. public void T19_Rule_IsOutOfRange_WithZeroUpperBoundAndNoInventedTolerance()
  352. {
  353. Assert.Equal(S8OutOfRangeRuleEvaluator.RuleTypeCode, Definition.RuleType);
  354. var semantics = Definition.OutOfRange!;
  355. Assert.Equal(0m, semantics.UpperBound);
  356. // 容差非 0 意味着"晚 N 天以内不报"——本批没有任何业务依据支持这样一个数字,
  357. // 凭空填值会静默过滤掉真实风险。要放宽必须由业务给出天数。
  358. Assert.Equal(0m, semantics.ToleranceAbs);
  359. Assert.Equal(0m, semantics.ToleranceRatio);
  360. // 行内上下限列未启用:上限是"不延期"这个定义本身,不随行变化。
  361. Assert.Null(semantics.UpperBoundColumn);
  362. Assert.Null(semantics.LowerBoundColumn);
  363. }
  364. [Fact]
  365. public void T20_Rule_DeclaresAllFourReadinessGates()
  366. {
  367. var policy = Definition.Parameters!;
  368. // 每一条都对应一种运行期静默断裂,理由见规则定义处的注释。
  369. Assert.True(policy.RequiresDepartmentDefaultsForEnable); // 数据集无部门列 → 建单必失败
  370. Assert.True(policy.RequiresHandlerPoolForEnable); // 无人认领
  371. Assert.True(policy.RequiresVerification); // 复核候选为空,卡在 IN_PROGRESS
  372. Assert.True(policy.SupportsTimeoutEscalation); // SLA 240 分钟 → 必进升级扫描
  373. // 预警不是事故:客户交期尚未到达,仍有处置窗口。与异常类型自身默认严重度一致。
  374. Assert.Equal("FOLLOW", policy.SeverityDefault);
  375. }
  376. [Fact]
  377. public void T21_Rule_CarriesBusinessFacingCopy_WithoutImplementationLeakage()
  378. {
  379. // 配置页展示的是"这条规则按什么判",不是"它怎么实现的"。
  380. Assert.False(string.IsNullOrWhiteSpace(Definition.JudgementSummary));
  381. Assert.False(string.IsNullOrWhiteSpace(Definition.DedupIdentitySummary));
  382. foreach (var leak in new[]
  383. {
  384. "crm_seorderentry", "crm_seorder", "DATEDIFF",
  385. "sys_capacity_date", "plan_date", "measured_value", "SQL"
  386. })
  387. {
  388. Assert.DoesNotContain(leak, Definition.JudgementSummary!, StringComparison.OrdinalIgnoreCase);
  389. Assert.DoesNotContain(leak, Definition.DedupIdentitySummary!, StringComparison.OrdinalIgnoreCase);
  390. }
  391. }
  392. // ============================================================
  393. // F. 生命周期:CREATE → KEEP ACTIVE → RECOVER
  394. //
  395. // 这一组是 S8-RULE-LIFECYCLE-CREATE-GATE-1 的核心回归。它守护的不是
  396. // 「测试能过」,而是一条已经被系统识别出来的延期风险,
  397. // 不会因为客户交期真正到达反而被标记成"已恢复"。
  398. // ============================================================
  399. private const string LifecycleBillNo = "SO-LIFECYCLE-001";
  400. private const int LifecycleEntrySeq = 1;
  401. /// <summary>三个周期共用同一业务键;到期日与 ETA 按周期变化。</summary>
  402. private static S8MonitoringRowSet LifecycleRow(DateTime planDate, DateTime sysCapacityDate) =>
  403. RowSet(Row(LifecycleBillNo, LifecycleEntrySeq, planDate, sysCapacityDate,
  404. (decimal)(sysCapacityDate - planDate).TotalDays));
  405. [Fact]
  406. public void T22_Cycle1_FutureDueDate_Hits_AndIsCreateEligible()
  407. {
  408. // 客户交期 10-31 尚未到达,系统建议交期 11-12 —— 晚 12 天。
  409. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  410. LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
  411. Effective(NewRule()), TenantId, detectedAt: new DateTime(2026, 10, 1));
  412. var hit = Assert.Single(hits);
  413. Assert.True(hit.CreateEligible, "交期未到的风险必须允许首次立案,否则这条规则永远建不出单");
  414. }
  415. [Fact]
  416. public void T23_Cycle2_AfterDueDate_StillHits_ButIsNotCreateEligible()
  417. {
  418. // 时间走到 11-01:客户交期(10-31)已经过去,而系统建议交期(11-12)一天没改。
  419. // 风险不但没解除,是**兑现**了。
  420. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  421. LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
  422. Effective(NewRule()), TenantId, detectedAt: new DateTime(2026, 11, 1));
  423. var hit = Assert.Single(hits);
  424. // ① 仍然是命中 —— 这一条是整个修复的支点。
  425. // 只要它还在 hits 里,ReconcileRecoveriesForRuleAsync 就会因为
  426. // hitDedupKeys.Contains(dedupKey) 而直接 continue,既有异常不累计 miss、不被恢复。
  427. // 把它从 hits 里剔除(哪怕理由是"反正不建单")= 假恢复原地复活。
  428. Assert.NotEmpty(hits);
  429. // ② 但不允许再开一个新案子。
  430. Assert.False(hit.CreateEligible);
  431. }
  432. [Fact]
  433. public void T24_Cycle1And2_ShareTheSameDedupKey_SoTheSameExceptionIsMatched()
  434. {
  435. // 跨交期前后必须是同一个 dedup_key —— 否则 Cycle 2 既找不到既有异常去 refresh,
  436. // 也保护不了它,等于换一种方式把它放掉。
  437. var before = Assert.Single(S8OutOfRangeRuleEvaluator.EvaluateRows(
  438. LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
  439. Effective(NewRule()), TenantId, new DateTime(2026, 10, 1)));
  440. var after = Assert.Single(S8OutOfRangeRuleEvaluator.EvaluateRows(
  441. LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
  442. Effective(NewRule()), TenantId, new DateTime(2026, 11, 1)));
  443. Assert.Equal(before.DedupKey, after.DedupKey);
  444. Assert.Equal(before.SourceObjectId, after.SourceObjectId);
  445. Assert.NotEqual(before.CreateEligible, after.CreateEligible);
  446. }
  447. [Fact]
  448. public void T25_Cycle3_EtaPulledBackWithinDueDate_StopsHitting()
  449. {
  450. // 重新评审把建议交期拉回到 10-31(不晚于要求交期)→ 不再命中。
  451. // 行仍在数据集里(SQL 不滤延期条件),是 evaluator 判它不越界 ——
  452. // 这是本规则唯一合法的恢复形态:连续 RecoverCountRequired 轮未命中后写 recovered_at。
  453. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  454. LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 10, 31)),
  455. Effective(NewRule()), TenantId, detectedAt: new DateTime(2026, 11, 1));
  456. Assert.Empty(hits);
  457. }
  458. [Fact]
  459. public void T26_FullCycle_Create_KeepActive_Recover()
  460. {
  461. var rule = Effective(NewRule());
  462. var due = new DateTime(2026, 10, 31);
  463. var lateEta = new DateTime(2026, 11, 12);
  464. // Cycle 1 —— 交期未到,ETA 晚 → 命中且可立案
  465. var c1 = S8OutOfRangeRuleEvaluator.EvaluateRows(
  466. LifecycleRow(due, lateEta), rule, TenantId, new DateTime(2026, 10, 1));
  467. // Cycle 2 —— 交期已过,ETA 仍晚 → 仍命中,但不可立案(保持 active)
  468. var c2 = S8OutOfRangeRuleEvaluator.EvaluateRows(
  469. LifecycleRow(due, lateEta), rule, TenantId, new DateTime(2026, 11, 1));
  470. // Cycle 3 —— ETA 拉回交期以内 → 不再命中(合法恢复)
  471. var c3 = S8OutOfRangeRuleEvaluator.EvaluateRows(
  472. LifecycleRow(due, due), rule, TenantId, new DateTime(2026, 11, 8));
  473. Assert.True(Assert.Single(c1).CreateEligible);
  474. Assert.False(Assert.Single(c2).CreateEligible);
  475. Assert.Empty(c3);
  476. // 关键否定断言:Cycle 2 不得表现为"不再命中"。
  477. Assert.Contains(Assert.Single(c1).DedupKey, c2.Select(h => h.DedupKey));
  478. }
  479. [Fact]
  480. public void T27_HistoricalRowsHit_ButNoneOfThemMayOpenANewCase()
  481. {
  482. // 真库形态的缩影:2 条未到期 + 7 条已过期,全部 ETA 晚。
  483. // 取消 SQL 时间过滤后 9 条都会命中;允许立案的必须只有那 2 条。
  484. // 用 fixture 表达数量关系,不绑定真实单号。
  485. const int futureCount = 2;
  486. const int historicalCount = 7;
  487. var detectedAt = new DateTime(2026, 9, 7);
  488. var rows = new List<S8OrderDeliveryDataProvider.OrderDeliveryRow>();
  489. for (var i = 0; i < futureCount; i++)
  490. rows.Add(Row($"SO-FUTURE-{i}", 1, detectedAt.AddDays(23), detectedAt.AddDays(35), 12m));
  491. for (var i = 0; i < historicalCount; i++)
  492. rows.Add(Row($"SO-PAST-{i}", 1, detectedAt.AddDays(-30 - i), detectedAt.AddDays(-25 - i), 5m));
  493. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  494. RowSet(rows.ToArray()), Effective(NewRule()), TenantId, detectedAt);
  495. Assert.Equal(futureCount + historicalCount, hits.Count);
  496. Assert.Equal(futureCount, hits.Count(h => h.CreateEligible));
  497. Assert.Equal(historicalCount, hits.Count(h => !h.CreateEligible));
  498. }
  499. [Fact]
  500. public void T28_MissingDueDate_FailsOpen_SoTheRuleNeverGoesSilent()
  501. {
  502. // 到期日缺失时闸门放行。闸门的职责是"到期后别开新案子",
  503. // 不是"没有到期日就别报警"—— 缺列时静默拒绝建单会让规则表现为
  504. // 「每轮都命中、一条异常也建不出来」,正是本仓反复在防的静默失败。
  505. var row = Row("SO-NO-DUE", 1, new DateTime(2026, 10, 31), new DateTime(2026, 11, 12), 12m);
  506. var values = S8OrderDeliveryDataProvider.ToCanonicalValues(row);
  507. values[S8CanonicalColumns.DueAt] = null!;
  508. var hit = Assert.Single(S8OutOfRangeRuleEvaluator.EvaluateRows(
  509. S8MonitoringRowSet.FromRows(new[] { S8MonitoringRow.FromValues(values) }),
  510. Effective(NewRule()), TenantId, new DateTime(2026, 11, 1)));
  511. Assert.True(hit.CreateEligible);
  512. }
  513. [Fact]
  514. public void T29_Rule02_DeclaresTheCreateWindow_AndItsDatasetCanCarryDueAt()
  515. {
  516. Assert.True(Definition.CreateOnlyBeforeDueAt);
  517. // 闸门读的是 canonical due_at,因此数据集必须声明带得出到期日。
  518. var dataset = Assert.Single(new S8OrderDeliveryDatasetDefinitions().GetDefinitions());
  519. Assert.True(dataset.Capabilities.HasDueAt);
  520. }
  521. // ============================================================
  522. // G. Rule01 回归 + 通用契约默认值
  523. // ============================================================
  524. [Fact]
  525. public void T30_Rule01_DoesNotDeclareTheGate_SoItsBehaviourIsByteForByteUnchanged()
  526. {
  527. var rule01 = Catalog.GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
  528. Assert.False(rule01.CreateOnlyBeforeDueAt);
  529. }
  530. [Fact]
  531. public void T31_CreateEligible_DefaultsToTrue_OnAFreshHit()
  532. {
  533. // 默认 true 是"未声明规则行为不变"的唯一保证。
  534. // 一旦有人把默认值改成 false,所有未声明闸门的规则会当场停止建单,
  535. // 且表现为"命中很多、一条也没建"——最难定位的一类静默失败。
  536. Assert.True(new S8RuleHit().CreateEligible);
  537. }
  538. [Fact]
  539. public void T32_TimeoutEvaluator_LeavesEveryHitCreateEligible()
  540. {
  541. // Rule01 走 TIMEOUT,其 evaluator 不参与闸门。逐条证明而不是靠"我没改它"。
  542. var rule01Row = new AdoS8WatchRule
  543. {
  544. Id = 900101,
  545. RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode,
  546. SceneCode = "S4",
  547. WatchObjectType = "PURCHASE_ORDER_LINE",
  548. SourceObjectType = "PURCHASE_ORDER_LINE",
  549. Severity = "SERIOUS",
  550. RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
  551. DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode
  552. };
  553. var effective = S8EffectiveRule.Resolve(
  554. rule01Row, Catalog.GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode));
  555. var overdue = new S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow
  556. {
  557. SourceObjectId = "PO-REG-1#1",
  558. RelatedObjectCode = "PO-REG-1",
  559. RelatedObjectName = "供应商",
  560. DueAt = new DateTime(2026, 8, 1),
  561. Status = "DELAYED",
  562. DueAtSource = "need_date"
  563. };
  564. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  565. S8MonitoringRowSet.FromRows(new[] {
  566. S8MonitoringRow.FromValues(S8PurchaseDeliveryDataProvider.ToCanonicalValues(overdue)) }),
  567. effective, TenantId, new DateTime(2026, 9, 5));
  568. Assert.NotEmpty(hits);
  569. Assert.All(hits, h => Assert.True(h.CreateEligible));
  570. }
  571. // ============================================================
  572. // H. 调度器顺序守卫
  573. //
  574. // 闸门的正确性完全取决于它在 ProcessSingleRuleAsync 里的位置。
  575. // 该方法是私有的、依赖 4 个 repository,本仓对它既有的守护
  576. // (S8SystemRecoveredTimelineTests)同样采用源码文本断言,此处沿用同一标准。
  577. // ============================================================
  578. private static string SchedulerCode()
  579. {
  580. var path = Path.GetFullPath(Path.Combine(
  581. AppContext.BaseDirectory,
  582. "../../../../Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs"));
  583. Assert.True(File.Exists(path), $"调度器源码路径需同步更新:{path}");
  584. // 去掉注释行:本节断言的是**执行顺序**,不能被文档里的同名字样带偏。
  585. return string.Join('\n', File.ReadAllLines(path)
  586. .Where(l =>
  587. {
  588. var t = l.TrimStart();
  589. return !t.StartsWith("///", StringComparison.Ordinal)
  590. && !t.StartsWith("//", StringComparison.Ordinal);
  591. }));
  592. }
  593. [Fact]
  594. public void T33_RecoveryReconcile_RunsBeforeTheHitLoop()
  595. {
  596. var code = SchedulerCode();
  597. var reconcileIdx = code.IndexOf("ReconcileRecoveriesForRuleAsync(tenantId, rule, ruleType, hits, runId)",
  598. StringComparison.Ordinal);
  599. var loopIdx = code.IndexOf("foreach (var hit in hits)", StringComparison.Ordinal);
  600. Assert.True(reconcileIdx > 0 && loopIdx > 0);
  601. Assert.True(reconcileIdx < loopIdx,
  602. "恢复判定必须在 hit 循环之前、且拿到完整 hits —— 否则 CreateEligible=false 的命中" +
  603. "保护不了既有异常,假恢复会原地复活");
  604. }
  605. [Fact]
  606. public void T34_CreateGate_SitsAfterRefresh_AndBeforeCreate()
  607. {
  608. var code = SchedulerCode();
  609. var refreshIdx = code.IndexOf("await RefreshDetectionAsync(matchedId, hit);", StringComparison.Ordinal);
  610. var backfillIdx = code.IndexOf("await BackfillLegacyExceptionAsync(compatId, hit);", StringComparison.Ordinal);
  611. var gateIdx = code.IndexOf("if (!hit.CreateEligible)", StringComparison.Ordinal);
  612. var createIdx = code.IndexOf("await _reportDispatcher.CreateFromHitAsync(tenantId, hit);", StringComparison.Ordinal);
  613. Assert.True(refreshIdx > 0 && backfillIdx > 0 && gateIdx > 0 && createIdx > 0);
  614. // 在刷新之后:跨过交期的既有预警 CreateEligible 变 false,但必须照常 refresh 才能保持 active。
  615. Assert.True(refreshIdx < gateIdx, "闸门必须在既有异常刷新之后,否则跨期预警会停止刷新");
  616. Assert.True(backfillIdx < gateIdx, "闸门必须在 legacy 回填之后,那同样是保活而非建单");
  617. // 在建单之前:历史风险行不得被倒成一批"预警"。
  618. Assert.True(gateIdx < createIdx, "闸门必须在首次建单之前");
  619. // 结果原因必须是可解释的独立值,不能混进 failed 家族。
  620. Assert.Contains("\"create_not_eligible\"", code, StringComparison.Ordinal);
  621. }
  622. [Fact]
  623. public void T35_CreateNotEligible_IsNotCountedAsFailure()
  624. {
  625. var code = SchedulerCode();
  626. // Failed 统计只认这四个 reason;create_not_eligible 不在其中,
  627. // 否则规则每轮都会显示"有失败",而它其实工作得完全正确。
  628. var failedLine = code.Split('\n').First(l => l.Contains("Failed = results.Count(", StringComparison.Ordinal));
  629. Assert.DoesNotContain("create_not_eligible", failedLine, StringComparison.Ordinal);
  630. }
  631. }