|
|
@@ -0,0 +1,886 @@
|
|
|
+using System.Text.RegularExpressions;
|
|
|
+using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-RULE03-MFG-MATERIAL-SHORTAGE-1:制造工单当前缺料规则(Rule 03)的判定与定义契约。
|
|
|
+///
|
|
|
+/// <para><b>本文件要钉死的是业务契约,不是"我们加了一个类"</b>。契约只有一句话:
|
|
|
+/// <b>一个工单当前有缺料 → 一条工单级异常</b>。它有四个容易在后续迭代里被悄悄改掉的面:
|
|
|
+/// <list type="number">
|
|
|
+/// <item><b>粒度</b>:缺 20 项也只开一条单。改成行级不会报错,只会让处理人一天点 20 次;</item>
|
|
|
+/// <item><b>身份稳定</b>:持续缺料必须落在同一个 dedup_key 上,否则每轮都开新案子;</item>
|
|
|
+/// <item><b>租户不串</b>:不同租户的同号工单必须是两条异常;</item>
|
|
|
+/// <item><b>不是预警</b>:本规则判「现在缺没缺」,不许被改造成「预计什么时候齐」。</item>
|
|
|
+/// </list></para>
|
|
|
+///
|
|
|
+/// <para><b>为什么能纯函数驱动</b>:<see cref="S8ShortageRuleEvaluator.EvaluateRows"/> 是
|
|
|
+/// internal static 的判定核心,不碰 SQL / DataTable / DI,可以直接喂 canonical 行。
|
|
|
+/// 只有「定义文件里写没写某一行代码」这类无法用调用验证的事,才退化为源码文本断言。</para>
|
|
|
+///
|
|
|
+/// <para><b>⚠️ 文本断言一律走 <see cref="CodeOnly"/></b>:定义文件的注释里刻意复述了
|
|
|
+/// 被证否的字段名(预计齐套日期 kitting_time)与被刻意不声明的开关,
|
|
|
+/// 直接对全文断言会命中自己写的反面教材,得到一个假红。</para>
|
|
|
+/// </summary>
|
|
|
+public class S8MaterialShortageRule03Tests
|
|
|
+{
|
|
|
+ private const string Rule03 = S8MaterialShortageRuleDefinitions.MfgMaterialShortageCode;
|
|
|
+
|
|
|
+ private const long TenantA = 838257186181189L;
|
|
|
+ private const long TenantB = 797403760988229L;
|
|
|
+
|
|
|
+ private static readonly DateTime DetectedAt = new(2026, 9, 8, 10, 30, 0);
|
|
|
+
|
|
|
+ private static readonly string PluginRoot =
|
|
|
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
|
|
|
+
|
|
|
+ /// <summary>只取可执行代码行:注释里会复述反面教材与被证否的字段名,不应算命中。</summary>
|
|
|
+ private static string CodeOnly(string relativePath)
|
|
|
+ {
|
|
|
+ var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
|
+ Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
|
|
|
+ return string.Join('\n', File.ReadAllLines(full)
|
|
|
+ .Where(l =>
|
|
|
+ {
|
|
|
+ var t = l.TrimStart();
|
|
|
+ return !t.StartsWith("///", StringComparison.Ordinal)
|
|
|
+ && !t.StartsWith("//", StringComparison.Ordinal);
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string RuleDefinitionCode() =>
|
|
|
+ CodeOnly("Service/S8/Rules/Definitions/S8MaterialShortageRuleDefinitions.cs");
|
|
|
+
|
|
|
+ private static IS8RuleCatalog Catalog() =>
|
|
|
+ new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8MaterialShortageRuleDefinitions() });
|
|
|
+
|
|
|
+ private static S8RuleDefinition Definition() => Catalog().GetRequired(Rule03);
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 一条 watch_rule 行。投影列刻意写成**错误值** —— 运行语义若真的来自代码定义,
|
|
|
+ /// 这些值不该产生任何影响(与 Rule 01 契约测试同口径)。
|
|
|
+ /// </summary>
|
|
|
+ private static AdoS8WatchRule Rule(string? paramsJson = null) => new()
|
|
|
+ {
|
|
|
+ Id = 1330000000003L,
|
|
|
+ TenantId = TenantA,
|
|
|
+ FactoryId = 838257186320453L,
|
|
|
+ RuleCode = Rule03,
|
|
|
+ RuleType = "TIMEOUT",
|
|
|
+ DatasetCode = "SOMETHING_ELSE",
|
|
|
+ SourceObjectType = "EVIL_OBJECT_TYPE",
|
|
|
+ WatchObjectType = "EVIL_WATCH_TYPE",
|
|
|
+ SceneCode = "S1",
|
|
|
+ // legacy 严重度值:不在本规则 AllowedSeverities(FOLLOW / SERIOUS)内,
|
|
|
+ // 必须回落到定义默认 SERIOUS,而不是被静默降级成 FOLLOW。
|
|
|
+ Severity = "HIGH",
|
|
|
+ PollIntervalSeconds = 600,
|
|
|
+ TriggerCountRequired = 1,
|
|
|
+ RecoverCountRequired = 2,
|
|
|
+ ParamsJson = paramsJson
|
|
|
+ };
|
|
|
+
|
|
|
+ private static S8EffectiveRule Effective(string? paramsJson = null) =>
|
|
|
+ S8EffectiveRule.Resolve(Rule(paramsJson), Definition());
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 一行**已按工单聚合**的用料统计(Provider 的产出形态)。
|
|
|
+ /// target = 当前有效参与用料的物料项数;actual = 其中已备齐的项数。
|
|
|
+ /// </summary>
|
|
|
+ private static S8MonitoringRow WorkOrderRow(
|
|
|
+ string workOrderNo, object? totalItems, object? kittedItems) =>
|
|
|
+ S8MonitoringRow.FromValues(new Dictionary<string, object?>
|
|
|
+ {
|
|
|
+ [S8CanonicalColumns.SourceObjectId] = workOrderNo,
|
|
|
+ [S8CanonicalColumns.RelatedObjectCode] = workOrderNo,
|
|
|
+ [S8CanonicalColumns.RelatedObjectName] = "整机装配工单",
|
|
|
+ [S8CanonicalColumns.TargetQty] = totalItems,
|
|
|
+ [S8CanonicalColumns.ActualQty] = kittedItems
|
|
|
+ });
|
|
|
+
|
|
|
+ private static S8MonitoringRowSet RowSet(params S8MonitoringRow[] rows) =>
|
|
|
+ S8MonitoringRowSet.FromRows(rows);
|
|
|
+
|
|
|
+ private static List<S8RuleHit> Evaluate(S8MonitoringRowSet rowSet, long tenantId = TenantA) =>
|
|
|
+ S8ShortageRuleEvaluator.EvaluateRows(rowSet, Effective(), tenantId, DetectedAt);
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // T1–T3:判定核心(命中 / 稳定 / 恢复)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /// <summary>T1:10 项用料、8 项已备齐 → 命中,且只有一条(工单级,不是 2 条缺料行)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T1_TenItems_TwoShort_ProducesExactlyOneWorkOrderLevelHit()
|
|
|
+ {
|
|
|
+ var hits = Evaluate(RowSet(WorkOrderRow("MO-2026-0001", 10m, 8m)));
|
|
|
+
|
|
|
+ var hit = Assert.Single(hits);
|
|
|
+
|
|
|
+ // 粒度:缺 2 项 ≠ 2 条异常。这一条是整个契约的地基。
|
|
|
+ Assert.Equal(S8MaterialShortageRuleDefinitions.WorkOrderObjectType, hit.SourceObjectType);
|
|
|
+ Assert.Equal("MO-2026-0001", hit.SourceObjectId);
|
|
|
+ Assert.Equal("MO-2026-0001", hit.RelatedObjectCode);
|
|
|
+
|
|
|
+ // 身份三件套全部来自代码定义,DB 投影列写的 EVIL_* 不生效。
|
|
|
+ Assert.Equal(Rule03, hit.SourceRuleCode);
|
|
|
+ Assert.Equal(S8MaterialShortageRuleDefinitions.MfgMaterialShortageExceptionTypeCode, hit.ExceptionTypeCode);
|
|
|
+ Assert.Equal(S8ModuleCode.S2, hit.SceneCode);
|
|
|
+ Assert.DoesNotContain("EVIL", hit.SourceObjectType, StringComparison.Ordinal);
|
|
|
+ Assert.DoesNotContain("EVIL", hit.DedupKey, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 严重度回落定义默认(SERIOUS):DB 行上写的是 legacy 的 HIGH,
|
|
|
+ // 它不在允许集合内,必须回落定义默认而不是被静默降级成 FOLLOW。
|
|
|
+ Assert.Equal(S8SeverityCode.Serious, hit.Severity);
|
|
|
+
|
|
|
+ // 缺口口径:项数之差,不是件数之和。
|
|
|
+ Assert.Contains("\"shortage_qty\":2", hit.SourcePayload, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T2:持续缺料(缺口从 2 项恶化到 4 项、检测时点也变了)→ dedup_key 逐字不变。
|
|
|
+ /// 刷新既有异常由调度器负责,本测试锁的是"它有一个稳定的锚点可锁"。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T2_PersistentShortage_KeepsIdenticalDedupKey_AcrossPolls()
|
|
|
+ {
|
|
|
+ var first = Assert.Single(Evaluate(RowSet(WorkOrderRow("MO-2026-0001", 10m, 8m))));
|
|
|
+
|
|
|
+ var second = Assert.Single(S8ShortageRuleEvaluator.EvaluateRows(
|
|
|
+ RowSet(WorkOrderRow("MO-2026-0001", 10m, 6m)),
|
|
|
+ Effective(),
|
|
|
+ TenantA,
|
|
|
+ DetectedAt.AddHours(3)));
|
|
|
+
|
|
|
+ Assert.Equal(first.DedupKey, second.DedupKey);
|
|
|
+
|
|
|
+ // 逐字钉死 key 的构成:租户 + 规则 + 对象类型 + 工单号,四段而已。
|
|
|
+ // 缺口数量与检测时点都变过了,key 一个字符没动 —— 混进任何一个易变量,
|
|
|
+ // 「同一个问题」就会每轮换身份,异常列表被同一张工单刷屏。
|
|
|
+ Assert.Equal($"T{TenantA}:R{Rule03}:{S8MaterialShortageRuleDefinitions.WorkOrderObjectType}:MO-2026-0001",
|
|
|
+ second.DedupKey);
|
|
|
+ Assert.Equal(4, second.DedupKey.Split(':').Length);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T3:10 项全部备齐 → 不命中。dedup_key 从 hits 消失,调度器据此判恢复。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T3_FullyKitted_ProducesNoHit_SoRecoveryCanFire()
|
|
|
+ {
|
|
|
+ Assert.Empty(Evaluate(RowSet(WorkOrderRow("MO-2026-0001", 10m, 10m))));
|
|
|
+
|
|
|
+ // 反向对照:同一张工单只要退回到 9 项就必须重新命中,
|
|
|
+ // 证明"不命中"来自齐套判定本身,而不是这一行被整体跳过了。
|
|
|
+ Assert.Single(Evaluate(RowSet(WorkOrderRow("MO-2026-0001", 10m, 9m))));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // T4:脏数据保守跳过
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T4:target ≤ 0 或 actual 缺失 → 一律不命中。
|
|
|
+ ///
|
|
|
+ /// <para>这不是"顺手加的健壮性":target=0 意味着「这张工单当前一项用料都没有」,
|
|
|
+ /// 那是取数口径出了问题而不是缺料;actual 缺失意味着「齐套状态算不出来」,
|
|
|
+ /// 此时建单等于凭空给生产计划员派活。两种都必须保守跳过。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData(0, 0)] // 无有效用料行
|
|
|
+ [InlineData(-3, 0)] // 负数:脏数据
|
|
|
+ [InlineData(10, null)] // 齐套项数算不出来
|
|
|
+ [InlineData(null, 8)] // 总项数算不出来
|
|
|
+ [InlineData(null, null)]
|
|
|
+ public void T4_DirtyRows_NeverProduceHits(object? totalItems, object? kittedItems)
|
|
|
+ {
|
|
|
+ var total = totalItems is int t ? (decimal?)t : null;
|
|
|
+ var kitted = kittedItems is int k ? (decimal?)k : null;
|
|
|
+
|
|
|
+ Assert.Empty(Evaluate(RowSet(WorkOrderRow("MO-DIRTY-001", total, kitted))));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T4 补充:工单号缺失同样不建单 —— 没有身份就没有可去重的对象。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T4_MissingWorkOrderNo_NeverProducesHit()
|
|
|
+ {
|
|
|
+ var row = S8MonitoringRow.FromValues(new Dictionary<string, object?>
|
|
|
+ {
|
|
|
+ [S8CanonicalColumns.SourceObjectId] = null,
|
|
|
+ [S8CanonicalColumns.RelatedObjectCode] = " ",
|
|
|
+ [S8CanonicalColumns.TargetQty] = 10m,
|
|
|
+ [S8CanonicalColumns.ActualQty] = 8m
|
|
|
+ });
|
|
|
+
|
|
|
+ Assert.Empty(Evaluate(RowSet(row)));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // T5–T7:工单级粒度与身份边界
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T5:同一工单的用料跨多个 BOM 层级 → 仍只有一条异常。
|
|
|
+ ///
|
|
|
+ /// <para>聚合发生在 Provider(一个工单一行),evaluator 只做减法。
|
|
|
+ /// 本测试用「一行输入 = 已聚合结果」验证下游行为,并同时钉住
|
|
|
+ /// 「去重身份里没有层级段」—— 一旦有人把层级拼进 source_object_id,
|
|
|
+ /// 同一张工单就会按层级裂成多条异常,而全链路无一处报错。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T5_MultiBomLevel_AggregatedByProvider_StillOneHit()
|
|
|
+ {
|
|
|
+ // 12 项用料分布在 3 个 BOM 层级上,已备齐 9 项 —— Provider 聚合后就是这一行。
|
|
|
+ var hit = Assert.Single(Evaluate(RowSet(WorkOrderRow("MO-2026-0002", 12m, 9m))));
|
|
|
+
|
|
|
+ Assert.Equal("MO-2026-0002", hit.SourceObjectId);
|
|
|
+
|
|
|
+ // 身份 = 纯工单号,不带层级 / 行号 / 物料号后缀。
|
|
|
+ Assert.EndsWith(":MO-2026-0002", hit.DedupKey, StringComparison.Ordinal);
|
|
|
+ Assert.DoesNotContain("#", hit.DedupKey, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 定义侧同步钉死:去重身份列就是 canonical 的 source_object_id,
|
|
|
+ // 没有被换成某个复合列。
|
|
|
+ Assert.Equal(S8CanonicalColumns.SourceObjectId, Definition().Shortage!.SourceObjectIdColumn);
|
|
|
+ Assert.Equal("制造工单号", Definition().DedupIdentitySummary);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T6:缺料项数从 1 到 20 变化,异常条数恒为 1 —— 严重程度变,粒度不变。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData(20, 19)] // 缺 1 项
|
|
|
+ [InlineData(20, 10)] // 缺 10 项
|
|
|
+ [InlineData(20, 0)] // 一项都没备齐
|
|
|
+ public void T6_ManyShortItems_StillExactlyOneException(int totalItems, int kittedItems)
|
|
|
+ {
|
|
|
+ var hits = Evaluate(RowSet(WorkOrderRow("MO-2026-0003", (decimal)totalItems, (decimal)kittedItems)));
|
|
|
+
|
|
|
+ var hit = Assert.Single(hits);
|
|
|
+ Assert.Equal("MO-2026-0003", hit.SourceObjectId);
|
|
|
+
|
|
|
+ // 缺料项数只进 payload(供严重程度参考),不进身份。
|
|
|
+ Assert.Contains($"\"shortage_qty\":{totalItems - kittedItems}", hit.SourcePayload, StringComparison.Ordinal);
|
|
|
+ Assert.Equal($"T{TenantA}:R{Rule03}:{S8MaterialShortageRuleDefinitions.WorkOrderObjectType}:MO-2026-0003",
|
|
|
+ hit.DedupKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T7:两张不同工单 → 两条异常、两个不同 dedup_key。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T7_DifferentWorkOrders_ProduceDistinctDedupKeys()
|
|
|
+ {
|
|
|
+ var hits = Evaluate(RowSet(
|
|
|
+ WorkOrderRow("MO-2026-0001", 10m, 8m),
|
|
|
+ WorkOrderRow("MO-2026-0002", 6m, 1m)));
|
|
|
+
|
|
|
+ Assert.Equal(2, hits.Count);
|
|
|
+ Assert.Equal(2, hits.Select(h => h.DedupKey).Distinct(StringComparer.Ordinal).Count());
|
|
|
+ Assert.Contains(hits, h => h.SourceObjectId == "MO-2026-0001");
|
|
|
+ Assert.Contains(hits, h => h.SourceObjectId == "MO-2026-0002");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T8:同一个工单号出现在两个租户 → dedup_key 必须不同。
|
|
|
+ ///
|
|
|
+ /// <para>工单号在跨租户维度上完全可能重号(各租户自成一套编码规则)。
|
|
|
+ /// 隔离靠的是 dedup_key 首段的租户前缀,不是"工单号应该唯一"这种假设。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T8_SameWorkOrderNo_AcrossTenants_DoesNotCollide()
|
|
|
+ {
|
|
|
+ var a = Assert.Single(Evaluate(RowSet(WorkOrderRow("MO-2026-0001", 10m, 8m)), TenantA));
|
|
|
+ var b = Assert.Single(Evaluate(RowSet(WorkOrderRow("MO-2026-0001", 10m, 8m)), TenantB));
|
|
|
+
|
|
|
+ Assert.NotEqual(a.DedupKey, b.DedupKey);
|
|
|
+ Assert.StartsWith($"T{TenantA}:", a.DedupKey, StringComparison.Ordinal);
|
|
|
+ Assert.StartsWith($"T{TenantB}:", b.DedupKey, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 工厂不参与身份(S8-TENANT-ONLY-BATCH5):rule 行上的 FactoryId 不得出现在 key 里。
|
|
|
+ Assert.DoesNotContain("F838257186320453", a.DedupKey, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // T9–T11:定义契约
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /// <summary>T9:Rule 03 的定义契约钉死(改任一项都等于换一套业务含义)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T9_RuleDefinitionContract_IsPinned()
|
|
|
+ {
|
|
|
+ var d = Definition();
|
|
|
+
|
|
|
+ Assert.Equal("RULE_S2_MFG_MATERIAL_SHORTAGE", d.RuleCode);
|
|
|
+ Assert.Equal(S8ShortageRuleEvaluator.RuleTypeCode, d.RuleType);
|
|
|
+ Assert.Equal("SHORTAGE", d.RuleType);
|
|
|
+ Assert.Equal("MFG_MATERIAL_SHORTAGE", d.DatasetCode);
|
|
|
+ Assert.Equal("MFG_MATERIAL_ABNORMAL", d.ExceptionTypeCode);
|
|
|
+ Assert.Equal("WORK_ORDER", d.SourceObjectType);
|
|
|
+ Assert.Equal(S8ModuleCode.S2, d.SceneCode);
|
|
|
+ Assert.Equal(S8ModuleCode.S2, d.StageCode);
|
|
|
+ Assert.Equal("RATIO", d.RuleMechanism);
|
|
|
+
|
|
|
+ // 容差为 0 = 缺 1 项就报。这是"齐套"的定义,不是可调阈值。
|
|
|
+ Assert.NotNull(d.Shortage);
|
|
|
+ Assert.Equal(0m, d.Shortage!.ToleranceAbs);
|
|
|
+ Assert.Equal(0m, d.Shortage.ToleranceRatio);
|
|
|
+ Assert.Equal(S8CanonicalColumns.TargetQty, d.Shortage.TargetQtyColumn);
|
|
|
+ Assert.Equal(S8CanonicalColumns.ActualQty, d.Shortage.ActualQtyColumn);
|
|
|
+
|
|
|
+ // 另两类语义必须为空:SHORTAGE 规则带着 Timeout / OutOfRange 语义
|
|
|
+ // 意味着有人在两套判定之间摇摆,Catalog 不会拦,但行为会随 evaluator 分派漂移。
|
|
|
+ Assert.Null(d.Timeout);
|
|
|
+ Assert.Null(d.OutOfRange);
|
|
|
+
|
|
|
+ // 严重度默认与异常类型种子(SERIOUS)一致,且在允许集合内。
|
|
|
+ Assert.Equal(S8SeverityCode.Serious, d.Parameters.SeverityDefault);
|
|
|
+ Assert.Contains(S8SeverityCode.Serious, d.Parameters.AllowedSeverities);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T9 续:<b>未启用</b> CreateOnlyBeforeDueAt —— 本规则判「现在缺没缺」,不是预警。
|
|
|
+ ///
|
|
|
+ /// <para>双重守护:既断言运行期取值为 false,也断言定义文件的**可执行代码**里
|
|
|
+ /// 根本没有这个赋值。只断言取值的话,将来有人写成
|
|
|
+ /// <c>CreateOnlyBeforeDueAt = false</c> 也能过,而那行代码的存在本身
|
|
|
+ /// 就是在暗示"这里有个开关可以打开"。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T9_CreateOnlyBeforeDueAt_IsNotEnabled_ForCurrentStateRule()
|
|
|
+ {
|
|
|
+ Assert.False(Definition().CreateOnlyBeforeDueAt);
|
|
|
+
|
|
|
+ var code = RuleDefinitionCode();
|
|
|
+ Assert.DoesNotContain("CreateOnlyBeforeDueAt", code, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 被证否的预计齐套日期字段不得出现在可执行代码里(注释里复述反面教材是允许的)。
|
|
|
+ Assert.DoesNotContain("kitting_time", code, StringComparison.OrdinalIgnoreCase);
|
|
|
+ Assert.DoesNotContain(S8CanonicalColumns.DueAt, code, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T10:四条启用就绪门禁全部为 true(缺任何一条都会在运行期变成静默失败)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T10_AllFourReadinessGates_AreRequired()
|
|
|
+ {
|
|
|
+ var p = Definition().Parameters;
|
|
|
+
|
|
|
+ Assert.True(p.RequiresDepartmentDefaultsForEnable, "数据集不带部门,不配兜底部门就建不出单");
|
|
|
+ Assert.True(p.RequiresHandlerPoolForEnable, "空处理池 = 异常建出来没人认领,而调度器每轮报成功");
|
|
|
+ Assert.True(p.RequiresVerification, "空复核池 = 处理人点不了提交复核,单据卡在 IN_PROGRESS");
|
|
|
+ Assert.True(p.SupportsTimeoutEscalation, "异常类型 SLA=60 分钟,必然进超时升级扫描");
|
|
|
+
|
|
|
+ Assert.True(p.AllowsDepartmentDefaults);
|
|
|
+
|
|
|
+ // 轮询间隔必须显著小于 SLA(60 分钟 = 3600 秒),否则缺料发生到立案就已耗尽 SLA。
|
|
|
+ Assert.True(p.PollIntervalSecondsDefault < 3600,
|
|
|
+ $"轮询间隔 {p.PollIntervalSecondsDefault}s 不应达到或超过 SLA 窗口");
|
|
|
+ Assert.InRange(p.PollIntervalSecondsDefault, p.PollIntervalSecondsMin, p.PollIntervalSecondsMax);
|
|
|
+
|
|
|
+ Assert.Equal(1, p.TriggerCountRequiredDefault);
|
|
|
+ Assert.Equal(2, p.RecoverCountRequiredDefault);
|
|
|
+
|
|
|
+ // 宽限分钟是 TIMEOUT 概念,SHORTAGE evaluator 不读;保持 0,不留假旋钮。
|
|
|
+ Assert.Equal(0, p.GraceMinutesDefault);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T11:面向业务管理员的三段文案不得泄漏实现。
|
|
|
+ ///
|
|
|
+ /// <para>判据用**形状**而不是黑名单:这三段是纯业务中文,
|
|
|
+ /// 出现任何连续两个以上的 ASCII 字母就意味着混进了列名 / 表名 / SQL 关键字 / 英文缩写。
|
|
|
+ /// 黑名单只能挡住已经想到的词,形状判据能挡住还没想到的。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T11_BusinessFacingText_LeaksNoImplementation()
|
|
|
+ {
|
|
|
+ var d = Definition();
|
|
|
+
|
|
|
+ foreach (var text in new[] { d.JudgementSummary, d.DedupIdentitySummary, d.Description, d.DisplayName })
|
|
|
+ {
|
|
|
+ Assert.False(string.IsNullOrWhiteSpace(text));
|
|
|
+ Assert.DoesNotMatch("[A-Za-z]{2,}", text);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 兜底黑名单:即便将来形状判据被放宽,这几个词也必须留在实现侧。
|
|
|
+ foreach (var forbidden in new[]
|
|
|
+ {
|
|
|
+ S8CanonicalColumns.TargetQty, S8CanonicalColumns.ActualQty,
|
|
|
+ "lack_qty", "kitting", "select", "join", "sum(", "mrp"
|
|
|
+ })
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(forbidden, d.JudgementSummary, StringComparison.OrdinalIgnoreCase);
|
|
|
+ Assert.DoesNotContain(forbidden, d.Description, StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 判定口径必须写明"按项计数、不加总数量"——这是本规则最容易被误解的一点,
|
|
|
+ // 配置页上看不到这句话,业务就会以为缺 100 件比缺 1 件更严重。
|
|
|
+ Assert.Contains("项", d.JudgementSummary, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("不对欠料数量做加总", d.JudgementSummary, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // T12:既有规则回归(本批不得改动 Rule 01 / Rule 02 的行为)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T12:Rule 01 / Rule 02 的立案闸门与 <see cref="S8RuleHit.CreateEligible"/> 默认值不受本批影响。
|
|
|
+ ///
|
|
|
+ /// <para>Rule 02 的 <c>CreateOnlyBeforeDueAt = true</c> 修的是一个已实测复现的假恢复;
|
|
|
+ /// 新增 Rule 03 时若有人为了"统一"把它一起关掉,那个缺陷会原样回来,
|
|
|
+ /// 而且不会有任何测试失败——除非有这一条。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T12_ExistingRules_KeepTheirCreateGates()
|
|
|
+ {
|
|
|
+ var rule02 = new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8OrderDeliveryRuleDefinitions() })
|
|
|
+ .GetRequired(S8OrderDeliveryRuleDefinitions.OrderDeliveryDelayWarningCode);
|
|
|
+ Assert.True(rule02.CreateOnlyBeforeDueAt, "Rule 02 是事前预警,立案闸门不得被关掉");
|
|
|
+
|
|
|
+ var rule01 = new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
|
|
|
+ .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
|
|
|
+ Assert.False(rule01.CreateOnlyBeforeDueAt, "Rule 01 是事后判定,从未启用立案闸门");
|
|
|
+
|
|
|
+ // 未声明闸门的规则,其命中恒可立案 —— 默认值一旦被改成 false,
|
|
|
+ // 所有未声明的规则会集体停止建单,而调度器仍然每轮报成功。
|
|
|
+ Assert.True(new S8RuleHit().CreateEligible);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T12 续:三个规则定义源可以共存于同一个目录,且 rule_code 互不冲突。
|
|
|
+ /// Catalog 的重复定义校验在构造期执行,这里顺带证明合并注册不会启动崩溃。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void T12_ThreeRuleSources_CoexistWithoutCodeCollision()
|
|
|
+ {
|
|
|
+ var catalog = new S8RuleCatalog(new IS8RuleDefinitionSource[]
|
|
|
+ {
|
|
|
+ new S8PurchaseDeliveryRuleDefinitions(),
|
|
|
+ new S8OrderDeliveryRuleDefinitions(),
|
|
|
+ new S8MaterialShortageRuleDefinitions()
|
|
|
+ });
|
|
|
+
|
|
|
+ Assert.Equal(3, catalog.Definitions.Count);
|
|
|
+ Assert.True(catalog.IsDefined(Rule03));
|
|
|
+ Assert.Equal(3, catalog.Definitions.Select(x => x.RuleCode).Distinct(StringComparer.Ordinal).Count());
|
|
|
+
|
|
|
+ // 三条规则分属三种 rule_type,各自的语义槽位不串。
|
|
|
+ Assert.Equal(3, catalog.Definitions.Select(x => x.RuleType).Distinct(StringComparer.Ordinal).Count());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // L1–L9:成品行(material_role = 'FINISHED_GOOD_ROOT')不得计入缺料
|
|
|
+ //
|
|
|
+ // 背景:每张工单的当前批次里恰好有 1 条成品行,它是**工单自己要产出的东西**,不是投入物料。
|
|
|
+ // 把它计入缺口会让「投入物料全齐、只是成品还没造出来」的工单被判缺料
|
|
|
+ // (authority 迁移前实测 4 个工单属于此类假阳性,含 43 条物料行全部齐套的 M500000006)。
|
|
|
+ //
|
|
|
+ // **该区分由 DWD 的 material_role 列发布,S8 不再自行按层级号推导**(见 C1 authority 变更):
|
|
|
+ // 源系统的口径(哪一行是成品、哪一行有效、版本如何收敛)在 S1 入湖时判定并物化,
|
|
|
+ // S8 侧只做「按角色计数」。因此本组断言的对象是 material_role 而不是 level。
|
|
|
+ //
|
|
|
+ // 判定发生在 Provider SQL 的聚合里,纯函数 evaluator 看不到 material_role。故本组用两层守护:
|
|
|
+ // ① SqlAggregate 复刻 SQL 的角色敏感表达式与 is_current_flag 过滤,驱动 L1–L8 的行为断言;
|
|
|
+ // ② L_SqlContract / L_SourceAuthority 对真实 SQL 文本逐条断言,锁住复刻件不会与 SQL 悄悄分叉。
|
|
|
+ // 少了 ②,①就退化成「测试自己的模拟器」。
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ private const string RootRole = S8MaterialShortageDataProvider.FinishedGoodRootRole;
|
|
|
+ private const string InputRole = S8MaterialShortageDataProvider.InputMaterialRole;
|
|
|
+
|
|
|
+ /// <summary>一条 DWD 明细行:物料角色 + 缺口数量 + 是否属于当前批次。</summary>
|
|
|
+ private readonly record struct DwdLine(string MaterialRole, decimal LackQty, bool IsCurrent = true);
|
|
|
+
|
|
|
+ /// <summary>成品行(<c>FINISHED_GOOD_ROOT</c>):参与分组供取名,但不计入缺口。</summary>
|
|
|
+ private static DwdLine Root(decimal lackQty, bool isCurrent = true) => new(RootRole, lackQty, isCurrent);
|
|
|
+
|
|
|
+ /// <summary>投入物料行(<c>INPUT_MATERIAL</c>):target / actual 只数这一类。</summary>
|
|
|
+ private static DwdLine Input(decimal lackQty, bool isCurrent = true) => new(InputRole, lackQty, isCurrent);
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 复刻 Provider SQL 的工单级聚合(<c>S8MaterialShortageDataProvider.Sql</c>):
|
|
|
+ /// <list type="bullet">
|
|
|
+ /// <item><c>WHERE d.is_current_flag = 1</c> —— 历史批次行不参与;</item>
|
|
|
+ /// <item><c>COUNT(CASE WHEN d.material_role = 'INPUT_MATERIAL' ...)</c>
|
|
|
+ /// / <c>SUM(CASE WHEN d.material_role = 'INPUT_MATERIAL' AND d.lack_qty <= 0 ...)</c>;</item>
|
|
|
+ /// <item><c>HAVING COUNT(CASE WHEN d.material_role = 'INPUT_MATERIAL' ...) > 0</c>
|
|
|
+ /// —— 无投入物料行的工单整组剔除。</item>
|
|
|
+ /// </list>
|
|
|
+ /// 返回 null 表示该工单<b>不产生候选行</b>(被 HAVING 剔除)。
|
|
|
+ /// </summary>
|
|
|
+ private static S8MonitoringRow? SqlAggregate(string workOrderNo, params DwdLine[] lines)
|
|
|
+ {
|
|
|
+ var current = lines.Where(l => l.IsCurrent).ToList(); // WHERE is_current_flag = 1
|
|
|
+ var inputs = current.Where(l => l.MaterialRole == InputRole).ToList();
|
|
|
+ if (inputs.Count == 0) return null; // HAVING
|
|
|
+ return WorkOrderRow(workOrderNo, inputs.Count, inputs.Count(l => l.LackQty <= 0));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>L1:成品行缺 1500,投入物料全部齐套 → 不得命中。变更前正是这类工单被误判。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void L1_FinishedGoodShort_ButAllComponentsReady_ProducesNoHit()
|
|
|
+ {
|
|
|
+ var row = SqlAggregate("M500000006",
|
|
|
+ Root(1500m), // 成品自身待产
|
|
|
+ Input(0m), Input(0m), Input(0m));
|
|
|
+
|
|
|
+ Assert.NotNull(row); // 有投入物料 → 仍是候选
|
|
|
+ Assert.Equal(3m, row!.GetDecimal(S8CanonicalColumns.TargetQty)); // 成品行未计入
|
|
|
+ Assert.Empty(Evaluate(RowSet(row)));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>L2:成品行缺料 + 子件缺料 → 命中,且缺口只由子件决定(2 而不是 3)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void L2_FinishedGoodShort_WithComponentShortage_HitsOnComponentCountOnly()
|
|
|
+ {
|
|
|
+ var row = SqlAggregate("M500000001",
|
|
|
+ Root(3500m),
|
|
|
+ Input(5m), Input(0m), Input(7m), Input(0m));
|
|
|
+
|
|
|
+ var hits = Evaluate(RowSet(row!));
|
|
|
+ var hit = Assert.Single(hits);
|
|
|
+ Assert.Equal(4m, row!.GetDecimal(S8CanonicalColumns.TargetQty));
|
|
|
+ Assert.Equal(2m, row.GetDecimal(S8CanonicalColumns.ActualQty));
|
|
|
+ Assert.Equal($"T{TenantA}:R{Rule03}:WORK_ORDER:M500000001", hit.DedupKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>L3:成品行缺料、全部子件齐套 → 不命中(与 L1 同因,换成多层 BOM)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void L3_FinishedGoodShort_AllChildrenReady_NoHit()
|
|
|
+ {
|
|
|
+ var row = SqlAggregate("M000000003",
|
|
|
+ Root(300m),
|
|
|
+ Input(0m), Input(0m), Input(0m));
|
|
|
+
|
|
|
+ Assert.Equal(3m, row!.GetDecimal(S8CanonicalColumns.TargetQty));
|
|
|
+ Assert.Equal(3m, row.GetDecimal(S8CanonicalColumns.ActualQty));
|
|
|
+ Assert.Empty(Evaluate(RowSet(row)));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L4:只有成品行、没有任何投入物料行(BOM 未匹配 / 无子件明细,真库 13 个)
|
|
|
+ /// → 连候选行都不产生。这是 UATA-A01-WO-P01 的真实形态。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L4_RootOnlyWorkOrder_ProducesNoCandidateRowAtAll()
|
|
|
+ {
|
|
|
+ Assert.Null(SqlAggregate("UATA-A01-WO-P01", Root(1500m)));
|
|
|
+ Assert.Null(SqlAggregate("UATA-WO-001", Root(0m)));
|
|
|
+
|
|
|
+ // 退一步:即便将来有人把 HAVING 拿掉,target = 0 的行也必须被 evaluator 跳过,
|
|
|
+ // 绝不能建出一条 0/0 的空异常。
|
|
|
+ Assert.Empty(Evaluate(RowSet(WorkOrderRow("UATA-A01-WO-P01", 0, 0))));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L5:不论子件原本处于 BOM 的哪一层,只要 DWD 标成 <c>INPUT_MATERIAL</c> 就全部参与计数,
|
|
|
+ /// 一条不漏、一条不多。<b>S8 侧不再区分层级</b>——层级的展开与归类是 S1 入湖时的事。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L5_AllInputMaterials_Participate_RegardlessOfOriginalBomDepth()
|
|
|
+ {
|
|
|
+ var row = SqlAggregate("M500000004",
|
|
|
+ Root(965m),
|
|
|
+ Input(3m), Input(0m),
|
|
|
+ Input(1m), Input(0m), Input(2m));
|
|
|
+
|
|
|
+ Assert.Equal(5m, row!.GetDecimal(S8CanonicalColumns.TargetQty));
|
|
|
+ Assert.Equal(2m, row.GetDecimal(S8CanonicalColumns.ActualQty));
|
|
|
+ Assert.Single(Evaluate(RowSet(row)));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L6:<c>is_current_flag = 0</c> 的历史批次行不参与,哪怕它缺得很多。
|
|
|
+ ///
|
|
|
+ /// <para>这条在迁到 DWD 之后<b>比以前更要命</b>:DWD 按 <c>calc_batch_id</c> 累积全部历史批次
|
|
|
+ /// (实测单租户 3,439 个批次 / 1,019,060 行)。少了当前批次过滤,
|
|
|
+ /// target / actual 会直接变成几千,且不报任何错。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L6_StaleBatchLines_DoNotParticipate()
|
|
|
+ {
|
|
|
+ var row = SqlAggregate("M500000002",
|
|
|
+ Root(200m),
|
|
|
+ Input(0m), Input(0m),
|
|
|
+ Input(999m, isCurrent: false), Input(999m, isCurrent: false),
|
|
|
+ Root(999m, isCurrent: false));
|
|
|
+
|
|
|
+ Assert.Equal(2m, row!.GetDecimal(S8CanonicalColumns.TargetQty));
|
|
|
+ Assert.Empty(Evaluate(RowSet(row)));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L7:子件补齐后必须回到「不命中」,恢复路径不被 level 过滤破坏。
|
|
|
+ /// 关键:工单仍然<b>留在结果集里</b>(还是候选行),只是不再越界 ——
|
|
|
+ /// 而不是整行消失。后者会让恢复的理由变成「源行不见了」。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L7_ComponentBecomesReady_RecoveryPathIntact()
|
|
|
+ {
|
|
|
+ var before = SqlAggregate("M500000003",
|
|
|
+ Root(300m), Input(4m), Input(0m))!;
|
|
|
+ Assert.Single(Evaluate(RowSet(before)));
|
|
|
+
|
|
|
+ var after = SqlAggregate("M500000003",
|
|
|
+ Root(300m), Input(0m), Input(0m))!;
|
|
|
+ Assert.NotNull(after); // 仍是候选行
|
|
|
+ Assert.Equal(2m, after.GetDecimal(S8CanonicalColumns.TargetQty));
|
|
|
+ Assert.Empty(Evaluate(RowSet(after))); // MISS → 触发恢复对账
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>L8:排除成品行之后,粒度仍是「一个工单一条异常」。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void L8_StillOneWorkOrderOneHit_AfterRoleFilter()
|
|
|
+ {
|
|
|
+ var a = SqlAggregate("M500000001", Root(1m),
|
|
|
+ Input(1m), Input(1m), Input(1m))!;
|
|
|
+ var b = SqlAggregate("M500000002", Root(0m), Input(1m))!;
|
|
|
+
|
|
|
+ var hits = Evaluate(RowSet(a, b));
|
|
|
+ Assert.Equal(2, hits.Count);
|
|
|
+ Assert.Equal(2, hits.Select(h => h.DedupKey).Distinct(StringComparer.Ordinal).Count());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>L9:Rule 02 未被本次 authority 迁移波及,仍读它自己的数据集。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void L9_Rule02_Untouched_ByAuthorityMigration()
|
|
|
+ {
|
|
|
+ var rule02 = new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8OrderDeliveryRuleDefinitions() })
|
|
|
+ .GetRequired(S8OrderDeliveryRuleDefinitions.OrderDeliveryDelayWarningCode);
|
|
|
+
|
|
|
+ Assert.True(rule02.CreateOnlyBeforeDueAt);
|
|
|
+ Assert.Equal(S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode, rule02.DatasetCode);
|
|
|
+ Assert.NotEqual(Definition().DatasetCode, rule02.DatasetCode);
|
|
|
+
|
|
|
+ // Rule 02 的取数与 BOM 层级 / 物料角色毫无关系,不得因本次变更被带上这些判据。
|
|
|
+ var rule02Sql = CodeOnly("Service/S8/Rules/DataAccess/Providers/S8OrderDeliveryDataProvider.cs");
|
|
|
+ Assert.DoesNotContain("level", rule02Sql, StringComparison.OrdinalIgnoreCase);
|
|
|
+ Assert.DoesNotContain("material_role", rule02Sql, StringComparison.OrdinalIgnoreCase);
|
|
|
+ Assert.DoesNotContain(DwdTable, rule02Sql, StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // L_* SQL 契约:authority = 数据中台 DWD,单表、零业务源表
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ /// <summary>本 Provider 唯一允许出现的来源表。</summary>
|
|
|
+ private const string DwdTable = S8MaterialShortageDataProvider.SourceTable;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 迁移前的业务源表。<b>它们出现在 Provider 的任何可执行代码里都是回退</b>:
|
|
|
+ /// 一旦 S8 重新直读业务表,源口径就同时存在于 S1 ETL 与 S8 SQL 两处,
|
|
|
+ /// 任一侧调整都会让命中数悄悄改变而不报错。
|
|
|
+ /// </summary>
|
|
|
+ private static readonly string[] BusinessSourceTables =
|
|
|
+ {
|
|
|
+ "b_examine_result", "b_bom_child_examine"
|
|
|
+ };
|
|
|
+
|
|
|
+ /// <summary>Provider 源码(已剔除注释行;注释里复述被淘汰的表名是允许的)。</summary>
|
|
|
+ private static string ProviderCode() =>
|
|
|
+ CodeOnly("Service/S8/Rules/DataAccess/Providers/S8MaterialShortageDataProvider.cs");
|
|
|
+
|
|
|
+ private static string FlatSql() =>
|
|
|
+ string.Join(' ', S8MaterialShortageDataProvider.Sql.Split('\n', '\r').Select(x => x.Trim()));
|
|
|
+
|
|
|
+ /// <summary>SQL 的 <c>WHERE … GROUP BY</c> 之间那一段。</summary>
|
|
|
+ private static string WhereClause()
|
|
|
+ {
|
|
|
+ var flat = FlatSql();
|
|
|
+ var start = flat.IndexOf("WHERE", StringComparison.Ordinal);
|
|
|
+ var end = flat.IndexOf("GROUP BY", StringComparison.Ordinal);
|
|
|
+ Assert.True(start >= 0 && end > start, "SQL 结构变了:找不到 WHERE … GROUP BY");
|
|
|
+ return flat[start..end];
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L_SourceAuthority:<b>Provider 不得引用任何业务源表</b>(C1 的核心验收项)。
|
|
|
+ ///
|
|
|
+ /// <para>断言分两层:SQL 常量本身,以及整个 Provider 的<b>可执行代码</b>——
|
|
|
+ /// 后者能挡住「把表名藏进另一个常量 / 字符串拼接再喂给 SQL」这种绕法。
|
|
|
+ /// 注释行被 <see cref="CodeOnly"/> 剔除,故文档里说明「原来读的是哪两张表」不会误伤。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L_SourceAuthority_ProviderReferencesNoBusinessSourceTable()
|
|
|
+ {
|
|
|
+ var flat = FlatSql();
|
|
|
+ var code = ProviderCode();
|
|
|
+
|
|
|
+ foreach (var table in BusinessSourceTables)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(table, flat, StringComparison.OrdinalIgnoreCase);
|
|
|
+ Assert.DoesNotContain(table, code, StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 源系统语义一律不得在 S8 侧复刻 —— 它们已由 S1 ETL 判定并物化成 DWD 列。
|
|
|
+ foreach (var sourceSemantics in new[] { "is_use", "IsDeleted", "erp_cls", "bom_level", "level >", "level=1" })
|
|
|
+ Assert.DoesNotContain(sourceSemantics, flat, StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L_SingleSource:来源<b>只有</b>一张 DWD 表,且<b>没有任何 JOIN</b>。
|
|
|
+ ///
|
|
|
+ /// <para>「不写业务表名」还不够:用 JOIN 把 authority 从别处捞回来同样是回退。
|
|
|
+ /// 这里直接钉死「一个 FROM、零个 JOIN」的结构。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L_SingleSource_ExactlyOneDwdTable_AndNoJoinAtAll()
|
|
|
+ {
|
|
|
+ var flat = FlatSql();
|
|
|
+
|
|
|
+ Assert.Contains($"FROM {DwdTable} d", flat, StringComparison.Ordinal);
|
|
|
+ Assert.Equal(1, Regex.Matches(flat, @"\bFROM\b", RegexOptions.IgnoreCase).Count);
|
|
|
+ Assert.Empty(Regex.Matches(flat, @"\bJOIN\b", RegexOptions.IgnoreCase));
|
|
|
+
|
|
|
+ // 也不允许用子查询 / CTE 绕过单表约束把别的表拉进来。
|
|
|
+ Assert.DoesNotContain("WITH ", flat, StringComparison.OrdinalIgnoreCase);
|
|
|
+ Assert.DoesNotContain("SELECT", flat[6..], StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L_CurrentBatch:<c>is_current_flag = 1</c> 与租户过滤都必须在 WHERE 里。
|
|
|
+ ///
|
|
|
+ /// <para>这两条都是「不写也能跑、但结果全错且不报错」的类型:
|
|
|
+ /// 缺当前批次过滤 → 全部历史批次被聚进同一组(实测单租户 3,439 批次);
|
|
|
+ /// 缺租户过滤 → <c>morder_no</c> 跨租户重名,两个租户的物料行聚成同一个「工单」。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L_CurrentBatch_AndTenantFilter_AreBothMandatoryInWhere()
|
|
|
+ {
|
|
|
+ var where = WhereClause();
|
|
|
+
|
|
|
+ Assert.Contains("d.is_current_flag = 1", where, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("d.tenant_id = @TenantId", where, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 租户是参数化的,绝不是 SQL 字面量(既有 LEGACY_SQL 规则的已知问题形态)。
|
|
|
+ Assert.DoesNotMatch(@"tenant_id\s*=\s*\d", where);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L_SqlContract:把 <see cref="SqlAggregate"/> 的三条假设钉在真实 SQL 上。
|
|
|
+ /// 这条测试是上面 L1–L8 的地基 —— 若 SQL 改了而复刻件没改,这里先红。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L_SqlContract_RoleFilterIsWhereItIsClaimedToBe()
|
|
|
+ {
|
|
|
+ var flat = FlatSql();
|
|
|
+
|
|
|
+ // ① 计数只数投入物料行,成品行不计入
|
|
|
+ Assert.Contains("COUNT(CASE WHEN d.material_role = 'INPUT_MATERIAL' THEN 1 END)",
|
|
|
+ flat, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("SUM(CASE WHEN d.material_role = 'INPUT_MATERIAL' AND d.lack_qty <= 0 THEN 1 ELSE 0 END)",
|
|
|
+ flat, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // ② 无投入物料行的工单被整组剔除
|
|
|
+ Assert.Contains("HAVING COUNT(CASE WHEN d.material_role = 'INPUT_MATERIAL' THEN 1 END) > 0",
|
|
|
+ flat, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // ③ 成品行仍参与分组供取名 —— 若被挪进 WHERE,成品名会静默退化成工单号
|
|
|
+ Assert.Contains("MAX(CASE WHEN d.material_role = 'FINISHED_GOOD_ROOT' THEN d.item_name",
|
|
|
+ flat, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("MAX(CASE WHEN d.material_role = 'FINISHED_GOOD_ROOT' THEN d.item_number",
|
|
|
+ flat, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // ④ 只针对 WHERE 子句本身断言。
|
|
|
+ // 注意不能用 `WHERE[\s\S]*material_role` 这类跨句正则:`[\s\S]*` 会一路吃到 HAVING,
|
|
|
+ // 命中 HAVING 里**合法**的 material_role,得到一个假红(本测试初版在 level 时代就是这么错的)。
|
|
|
+ var where = WhereClause();
|
|
|
+
|
|
|
+ // 角色过滤不得进 WHERE(否则丢成品名)
|
|
|
+ Assert.DoesNotContain("material_role", where, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 绝不在取数层预筛缺料行:恢复判定依赖「行还在但不越界」
|
|
|
+ Assert.DoesNotContain("lack_qty", where, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // ⑤ 分组粒度 = 租户 + 工单
|
|
|
+ Assert.Contains("GROUP BY d.tenant_id, d.morder_no", flat, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L_Aliases:结果列别名必须<b>逐字</b>等于投影类的属性名(PascalCase)。
|
|
|
+ ///
|
|
|
+ /// <para>本仓 <c>EnableUnderLine = false</c>:别名写成 snake_case 不会报错,
|
|
|
+ /// 只会让<b>每个属性静默映射为 null</b> —— 行数照常返回,target/actual 全空,
|
|
|
+ /// evaluator 逐行跳过,命中恒为 0。这是本次 authority 迁移里最容易踩、
|
|
|
+ /// 且唯一不会有任何报错信号的坑,故用属性反射逐条对齐。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L_Aliases_AreExactlyThePascalCaseProjectionProperties()
|
|
|
+ {
|
|
|
+ var flat = FlatSql();
|
|
|
+
|
|
|
+ // `CAST(... AS DECIMAL(20,0))` 里的 AS 不是别名,必须排除,
|
|
|
+ // 否则会拿 CAST 的目标类型当列别名去找属性(本测试初版就是这么假红的)。
|
|
|
+ var aliases = Regex.Matches(flat, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)")
|
|
|
+ .Select(m => m.Groups[1].Value)
|
|
|
+ .Where(a => !a.StartsWith("DECIMAL", StringComparison.OrdinalIgnoreCase))
|
|
|
+ .ToList();
|
|
|
+
|
|
|
+ var properties = typeof(S8MaterialShortageDataProvider.MaterialShortageRow)
|
|
|
+ .GetProperties()
|
|
|
+ .Select(p => p.Name)
|
|
|
+ .ToHashSet(StringComparer.Ordinal);
|
|
|
+
|
|
|
+ Assert.NotEmpty(aliases);
|
|
|
+
|
|
|
+ foreach (var alias in aliases)
|
|
|
+ {
|
|
|
+ // snake_case 别名 = 静默全 null
|
|
|
+ Assert.DoesNotContain("_", alias, StringComparison.Ordinal);
|
|
|
+ Assert.True(char.IsUpper(alias[0]), $"别名必须 PascalCase:{alias}");
|
|
|
+ Assert.True(properties.Contains(alias),
|
|
|
+ $"别名 {alias} 在 MaterialShortageRow 上没有同名属性,会静默映射为 null");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 判定用的两列一个都不能少。
|
|
|
+ Assert.Contains("TargetQty", aliases);
|
|
|
+ Assert.Contains("ActualQty", aliases);
|
|
|
+ Assert.Contains("SourceObjectId", aliases);
|
|
|
+ Assert.Contains("RelatedObjectCode", aliases);
|
|
|
+ Assert.Contains("RelatedObjectName", aliases);
|
|
|
+
|
|
|
+ // COUNT 回 BIGINT UNSIGNED、SUM 回 DECIMAL,显式 CAST 统一成 decimal? 的来源类型。
|
|
|
+ Assert.Equal(2, Regex.Matches(flat, @"CAST\(.*?AS DECIMAL\(20,0\)\)").Count);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// L_DatasetDefinition:数据集口径文案必须声明中台 authority,且不得再提业务源表。
|
|
|
+ ///
|
|
|
+ /// <para><c>dwd_material_readiness</c> 的「不采用」理由必须<b>保留</b>:
|
|
|
+ /// 那张宽表的缺料语义在 S3 ETL 侧被写死成常量,接上去会永远 0 命中且不报错。
|
|
|
+ /// 该结论不因本数据集迁入中台而失效 —— 恰恰相反,迁入后「统一走中台」
|
|
|
+ /// 会成为改接它的新借口,所以这句话比以前更需要留着。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void L_DatasetDefinition_DeclaresDwdAuthority_AndKeepsReadinessRationale()
|
|
|
+ {
|
|
|
+ var dataset = new S8MaterialShortageDatasetDefinitions().GetDefinitions().Single();
|
|
|
+ var description = dataset.Description!;
|
|
|
+
|
|
|
+ Assert.Equal(S8MaterialShortageDatasetDefinitions.MaterialShortageCode, dataset.DatasetCode);
|
|
|
+ Assert.Contains(DwdTable, description, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("is_current_flag", description, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("material_role", description, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 业务源表不得再出现在面向配置页的口径说明里。
|
|
|
+ foreach (var table in BusinessSourceTables)
|
|
|
+ Assert.DoesNotContain(table, description, StringComparison.OrdinalIgnoreCase);
|
|
|
+
|
|
|
+ // 源系统语义不再由 S8 承担,也不该再写进 S8 的口径。
|
|
|
+ Assert.DoesNotContain("is_use", description, StringComparison.OrdinalIgnoreCase);
|
|
|
+ Assert.DoesNotContain("IsDeleted", description, StringComparison.OrdinalIgnoreCase);
|
|
|
+
|
|
|
+ // 两条必须保留的警告。
|
|
|
+ Assert.Contains("dwd_material_readiness", description, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("不在取数层预筛", description, StringComparison.Ordinal);
|
|
|
+
|
|
|
+ // 能力声明不得因换源而漂移。
|
|
|
+ var caps = S8MaterialShortageDatasetDefinitions.MaterialShortageCapabilities;
|
|
|
+ Assert.True(caps.SupportsShortage);
|
|
|
+ Assert.False(caps.SupportsTimeout);
|
|
|
+ Assert.False(caps.SupportsOutOfRange);
|
|
|
+ Assert.False(caps.HasDueAt);
|
|
|
+ Assert.True(caps.HasTargetQty);
|
|
|
+ Assert.True(caps.HasActualQty);
|
|
|
+ }
|
|
|
+}
|