Просмотр исходного кода

feat(s8): 新增中台化制造工单缺料监控规则

RULE_S2_MFG_MATERIAL_SHORTAGE:工单当前还有投入物料没备齐时提报异常,
一张工单一条异常,不按物料行拆单。

数据来源是数据中台,不是业务源表:
  FROM dwd_requirement_examine_detail
  WHERE tenant_id = @TenantId AND is_current_flag = 1
Provider 里没有 b_examine_result / b_bom_child_examine,没有 JOIN 任何业务表,
也不再出现 is_use / level / lack_qty>1 这类源系统术语——投入料与成品根的区分
由中台的 material_role 表达,当前版本由 is_current_flag 表达。

判定语义(SHORTAGE evaluator,未改一行通用代码):
  target_qty = material_role='INPUT_MATERIAL' 的项数
  actual_qty = 其中已备齐的项数
  缺口       = 缺料项数
两者都是项数不是件数:不同物料计量单位不同,跨物料加总不可排序、不可比较。

成品根行仍参与分组但不计数——它只用来取成品名称。放进 WHERE 会让每条异常
的名称静默退化成工单号。HAVING 再把「无任何投入物料行」的工单整组剔除,
使其连候选行都不产生。

WHERE 里刻意不含缺料条件:恢复判定只看 dedup_key 在不在本轮 hits 里,
一旦在取数层滤掉已齐套行,工单补齐的那一刻会整条消失,「恢复」就退化成
「源行消失」。已齐套工单必须留在结果集里(如 M500000006 的 43/43)。

别名一律 PascalCase:本仓 EnableUnderLine=false,snake_case 别名会静默映射
为 null(行数照常、命中恒为 0、不报错)。

等价性以真实 Provider SQL 常量对源表真值逐工单比对,连续三轮 FULL SYNC:
每轮 25 比较 / 25 一致 / only_source=0 / only_dwd=0 / 24 命中。
已知坏样本经中台读出与源表一致:M500000006 43/43、M500000007 39/8、
M500000008 39/22(切源前旧 DWD 分别误报为有缺口、12、47)。

切源前后租户 838 的三条真实异常持续 MATCH/REFRESH(miss=0,
wouldCreate=0 / wouldMatchExisting=3),未发生假恢复;重复跑批不重复建单。
等价校验用的源表真值查询只存在于测试程序集,运行期不可达。

通知链路本批未改动。
YY968XX 1 месяц назад
Родитель
Сommit
60afe75e6e

+ 539 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8MaterialShortageDwdEquivalenceHarness.cs

@@ -0,0 +1,539 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Entity.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 SqlSugar;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-RULE03 <b>authority 迁移等价性对账台</b>:把「迁移前直读业务表」与「迁移后读数据中台 DWD」
+/// 两个查询在同一个租户上跑出来,逐工单比对
+/// <c>SourceObjectId / RelatedObjectName / TargetQty / ActualQty / Hit</c>,
+/// 输出 <c>rows_compared / identical / only_source / only_dwd</c>。
+///
+/// <para><b>它要回答的问题只有一个</b>:换了数据来源之后,<b>业务结论有没有变</b>。
+/// 「DWD 里有行、SQL 也能跑」不等于等价 —— 真正的失败形态是
+/// 「候选集少了 3 个工单」或「某工单的 target 从 9 变成 8」,
+/// 这两种都不会报错、不会白屏,只会让运营侧少收到几条异常。
+/// 所以对账必须落到<b>逐工单的四元组 + 最终是否命中</b>,不能只比行数。</para>
+///
+/// <para><b>Hit 由真实 evaluator 算,不在本文件重新实现。</b>
+/// 两侧的行都喂给 <see cref="S8ShortageRuleEvaluator.EvaluateRows"/>,
+/// 比对产出的 <c>dedup_key</c> 集合。若在这里自己写一遍
+/// 「target - actual &gt; 0」,对账就变成了「我的复刻件 == 我的复刻件」。</para>
+///
+/// <para><b>DWD 侧用的是生产常量 <see cref="S8MaterialShortageDataProvider.Sql"/> 本身</b>,
+/// 不是它的副本。副本会随时间与生产 SQL 分叉,那样对账通过也不说明生产是对的。</para>
+///
+/// <para><b>⚠️ 本文件包含一条直读业务表的 SQL(<see cref="SourceTruthSqlAuditOnly"/>)。
+/// 它是<u>审计基线</u>,永远不得进入生产运行路径。</b>
+/// 结构上的保证:本文件位于测试程序集 <c>Admin.NET.Plugin.AiDOP.Tests</c>,
+/// 而<b>没有任何运行期项目引用该程序集</b>(引用方向是 Tests → Plugin,单向)。
+/// 因此它在物理上无法被调度器 / Provider / 控制器触达。
+/// <see cref="SourceTruthQuery_IsUnreachableFromProductionCode"/> 把这条约束钉成断言。</para>
+///
+/// <para><b>门禁</b>:仅当 <c>AIDOP_IT=1</c> 时执行真库对账(无 DB 的普通 CI 直接跳过,不失败)。
+/// 沿用仓内既有 Pattern B(见 <c>S8NotificationLayerLiveDataTests</c> /
+/// <c>S5/MaterialWarehouse/L0LiveLoaderIntegrationTests</c>):
+/// 连接串从 <c>Database.json</c> 现取,<b>凭据不写入测试源码</b>。</para>
+///
+/// <para><b>严格只读</b>:本文件只执行 SELECT,不 INSERT / UPDATE / DELETE 任何一行。</para>
+///
+/// <para><b>怎么跑</b>(详见 <c>RunInstructions</c>):
+/// <code>
+/// # 默认对账 Database.json 里配置的那个库(当前 = 共享 aidopdev)
+/// AIDOP_IT=1 dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj \
+///   --framework net10.0 --filter "FullyQualifiedName~S8MaterialShortageDwdEquivalenceHarness" \
+///   --logger "console;verbosity=detailed"
+///
+/// # 对账本地库(DWD 已重建后)
+/// AIDOP_IT=1 AIDOP_DB_CONN="Server=127.0.0.1;Port=3306;Database=aidopdev;Uid=...;Pwd=...;SslMode=None;Charset=utf8mb4;AllowPublicKeyRetrieval=True" \
+/// dotnet test ... (同上)
+/// </code></para>
+/// </summary>
+[Trait("Category", "Integration")]
+public class S8MaterialShortageDwdEquivalenceHarness
+{
+    private readonly ITestOutputHelper _out;
+
+    public S8MaterialShortageDwdEquivalenceHarness(ITestOutputHelper output) => _out = output;
+
+    // ============================================================
+    // 运行开关
+    // ============================================================
+
+    /// <summary>真库对账门禁。未开启时相关用例直接返回(不失败)。</summary>
+    private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
+
+    /// <summary>
+    /// 只出报告、不断言等价。DWD 尚在重建期(<c>is_current_flag</c> / <c>material_role</c>
+    /// 还没灌好)时用它先看差异,不制造假红。
+    /// </summary>
+    private static bool ReportOnly =>
+        Environment.GetEnvironmentVariable("AIDOP_S8_EQUIV_REPORT_ONLY") == "1";
+
+    /// <summary>可选:把报告另存一份到文件,便于回执引用。</summary>
+    private static string? ReportPath =>
+        Environment.GetEnvironmentVariable("AIDOP_S8_EQUIV_OUT");
+
+    /// <summary>
+    /// 可选:指定对账租户(逗号分隔)。不给就自动发现 —— 取两侧结果集里出现过的全部租户,
+    /// <b>并集而不是交集</b>:某个租户只在一侧有数据,本身就是最需要被看见的差异。
+    /// </summary>
+    private static long[] ConfiguredTenants =>
+        (Environment.GetEnvironmentVariable("AIDOP_S8_EQUIV_TENANTS") ?? string.Empty)
+            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+            .Select(x => long.TryParse(x, out var v) ? v : 0L)
+            .Where(x => x > 0)
+            .ToArray();
+
+    // ============================================================
+    // SOURCE TRUTH(审计基线)—— 直读业务表,仅限本测试程序集
+    // ============================================================
+
+    /// <summary>
+    /// <b>⚠️ 审计基线,AUDIT ONLY,永远不得进入生产运行路径。⚠️</b>
+    ///
+    /// <para>这是 authority 迁移<b>之前</b>的生产 SQL 原文(直读 <c>b_examine_result</c> JOIN
+    /// <c>b_bom_child_examine</c>)。它的唯一用途是:在同一个库上跑出「迁移前应该得到什么」,
+    /// 与新的 DWD 查询逐工单比对。</para>
+    ///
+    /// <para><b>为什么留着它而不是删掉</b>:等价性只能对着一个可执行的旧口径来证明。
+    /// 把旧 SQL 删掉之后,「新查询是对的」就只能靠人读 SQL 互相说服,
+    /// 而这次要防的恰恰是「读起来对、跑出来少了 3 个工单」。</para>
+    ///
+    /// <para><b>为什么它不会泄漏回生产</b>:
+    /// ① 它在测试程序集里,引用方向是 Tests → Plugin 单向,运行期项目<b>不引用</b>测试程序集;
+    /// ② 它是 <c>private const</c>,不对外暴露;
+    /// ③ <see cref="SourceTruthQuery_IsUnreachableFromProductionCode"/> 断言生产侧
+    ///    (Provider 可执行代码)不含任何业务源表名。
+    /// 三条里任何一条被破坏都会有测试变红。</para>
+    ///
+    /// <para>口径与迁移前完全一致,<b>不要「顺手修正」</b>:它必须忠实复现旧行为,
+    /// 哪怕旧行为有瑕疵 —— 修正它就等于把对账基准也改了,差异会凭空消失。</para>
+    /// </summary>
+    private const string SourceTruthSqlAuditOnly =
+        """
+        SELECT
+            h.morder_no                                                             AS SourceObjectId,
+            h.morder_no                                                             AS RelatedObjectCode,
+            COALESCE(NULLIF(MAX(CASE WHEN c.level = 1 THEN c.item_name END), ''),
+                     NULLIF(MAX(CASE WHEN c.level = 1 THEN c.item_number END), ''),
+                     h.morder_no)                                                   AS RelatedObjectName,
+            CAST(COUNT(CASE WHEN c.level > 1 THEN 1 END) AS DECIMAL(20,0))          AS TargetQty,
+            CAST(SUM(CASE WHEN c.level > 1 AND c.lack_qty <= 0 THEN 1 ELSE 0 END)
+                 AS DECIMAL(20,0))                                                  AS ActualQty,
+            h.tenant_id                                                             AS TenantId,
+            MAX(h.bill_no)                                                          AS BillNo,
+            MAX(h.need_qty)                                                         AS NeedQty,
+            SUBSTRING(
+                GROUP_CONCAT(DISTINCT CASE WHEN c.level > 1 AND c.lack_qty > 0 THEN c.item_number END
+                             ORDER BY c.item_number SEPARATOR ','),
+                1, 200)                                                             AS ShortageItemSummary
+        FROM b_examine_result h
+        JOIN b_bom_child_examine c ON c.examine_id = h.Id
+        WHERE h.tenant_id = @TenantId
+          AND h.IsDeleted = 0
+          AND c.is_use = 1
+          AND h.morder_no IS NOT NULL
+          AND h.morder_no <> ''
+        GROUP BY h.tenant_id, h.morder_no
+        HAVING COUNT(CASE WHEN c.level > 1 THEN 1 END) > 0
+        """;
+
+    /// <summary>
+    /// DWD 侧的<b>前置体检</b>:新 authority 依赖的三列是否已经存在。
+    ///
+    /// <para>没有它的话,DWD 未扩展时得到的是驱动层的
+    /// <c>Unknown column 'd.material_role' in 'field list'</c> —— 那个报错读起来像
+    /// 「SQL 写错了」,实际是「上游还没建好」。两者的处置完全不同,
+    /// 所以宁可多一次 information_schema 查询,也要把它们区分开。</para>
+    /// </summary>
+    private const string DwdColumnPreflightSql =
+        """
+        SELECT COLUMN_NAME
+        FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 'dwd_requirement_examine_detail'
+          AND COLUMN_NAME IN ('bom_level', 'material_role', 'is_current_flag')
+        """;
+
+    /// <summary>authority 迁移后 DWD 必须提供的列。<c>bom_level</c> 只做存在性体检,SQL 刻意不用它。</summary>
+    private static readonly string[] RequiredDwdColumns =
+    {
+        "material_role", "is_current_flag"
+    };
+
+    /// <summary>发现两侧出现过的全部租户(并集)。同样只读。</summary>
+    private const string TenantDiscoverySqlAuditOnly =
+        """
+        SELECT DISTINCT tenant_id FROM (
+            SELECT h.tenant_id AS tenant_id
+            FROM b_examine_result h
+            WHERE h.IsDeleted = 0 AND h.morder_no IS NOT NULL AND h.morder_no <> ''
+            UNION
+            SELECT d.tenant_id AS tenant_id
+            FROM dwd_requirement_examine_detail d
+            WHERE d.is_current_flag = 1 AND d.morder_no IS NOT NULL AND d.morder_no <> ''
+        ) t
+        WHERE tenant_id > 0
+        ORDER BY tenant_id
+        """;
+
+    // ============================================================
+    // 连接(Pattern B:凭据不入源码)
+    // ============================================================
+
+    private const string DatabaseJsonPath =
+        "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
+
+    /// <summary>
+    /// 取连接串:优先环境变量 <c>AIDOP_DB_CONN</c>(主 agent 用它指向本地库),
+    /// 否则从 <c>Database.json</c> 里未被注释掉的 aidopdev 行现取。<b>凭据永不写进源码。</b>
+    /// </summary>
+    private static string? ResolveConnectionString()
+    {
+        var fromEnv = Environment.GetEnvironmentVariable("AIDOP_DB_CONN");
+        if (!string.IsNullOrWhiteSpace(fromEnv)) return fromEnv;
+
+        if (!File.Exists(DatabaseJsonPath)) return null;
+        return File.ReadLines(DatabaseJsonPath)
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
+                        && l.Contains("Database=aidopdev"))
+            .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
+            .FirstOrDefault(v => !string.IsNullOrEmpty(v));
+    }
+
+    private static ISqlSugarClient? BuildClient()
+    {
+        var cs = ResolveConnectionString();
+        if (string.IsNullOrEmpty(cs)) return null;
+        return new SqlSugarScope(new ConnectionConfig
+        {
+            ConfigId = "aidopdev-s8-rule03-equivalence",
+            DbType = DbType.MySql,
+            ConnectionString = cs,
+            IsAutoCloseConnection = true
+        });
+    }
+
+    // ============================================================
+    // 对账模型
+    // ============================================================
+
+    /// <summary>逐工单的可比四元组 + 最终是否命中。</summary>
+    private sealed record WorkOrderFact(
+        string SourceObjectId,
+        string? RelatedObjectName,
+        decimal? TargetQty,
+        decimal? ActualQty,
+        bool Hit)
+    {
+        public string Render() =>
+            $"{SourceObjectId} | name={RelatedObjectName ?? "<null>"} | target={TargetQty?.ToString() ?? "<null>"}" +
+            $" | actual={ActualQty?.ToString() ?? "<null>"} | hit={(Hit ? "Y" : "N")}";
+    }
+
+    /// <summary>对账结果计数 + 明细。</summary>
+    private sealed class Report
+    {
+        public long TenantId { get; init; }
+        public int SourceRows { get; init; }
+        public int DwdRows { get; init; }
+        public int RowsCompared { get; set; }
+        public int Identical { get; set; }
+        public List<string> OnlySource { get; } = new();
+        public List<string> OnlyDwd { get; } = new();
+        public List<string> Mismatched { get; } = new();
+
+        public bool IsEquivalent =>
+            OnlySource.Count == 0 && OnlyDwd.Count == 0 && Mismatched.Count == 0;
+    }
+
+    /// <summary>
+    /// 一条 watch_rule 行 —— 只为把 <see cref="S8EffectiveRule"/> 拼出来供 evaluator 用。
+    /// 投影列刻意写成错误值:命中语义若真的来自代码定义,这些值不该产生任何影响。
+    /// </summary>
+    private static S8EffectiveRule EffectiveRule(long tenantId)
+    {
+        var definition = new S8RuleCatalog(new IS8RuleDefinitionSource[]
+            {
+                new S8MaterialShortageRuleDefinitions()
+            })
+            .GetRequired(S8MaterialShortageRuleDefinitions.MfgMaterialShortageCode);
+
+        return S8EffectiveRule.Resolve(new AdoS8WatchRule
+        {
+            Id = 1330000000003L,
+            TenantId = tenantId,
+            RuleCode = S8MaterialShortageRuleDefinitions.MfgMaterialShortageCode,
+            RuleType = "SHORTAGE",
+            DatasetCode = S8MaterialShortageDatasetDefinitions.MaterialShortageCode,
+            SourceObjectType = S8MaterialShortageRuleDefinitions.WorkOrderObjectType,
+            WatchObjectType = S8MaterialShortageRuleDefinitions.WorkOrderObjectType,
+            SceneCode = "S2",
+            Severity = "SERIOUS",
+            PollIntervalSeconds = 600,
+            TriggerCountRequired = 1,
+            RecoverCountRequired = 2
+        }, definition);
+    }
+
+    /// <summary>
+    /// 把一侧查询的结果跑成 <see cref="WorkOrderFact"/> 字典。
+    /// <b>Hit 用真实 evaluator 算</b>,本方法不做任何判定。
+    /// </summary>
+    private static async Task<Dictionary<string, WorkOrderFact>> LoadFactsAsync(
+        ISqlSugarClient db, string sql, long tenantId)
+    {
+        var rows = await db.Ado.SqlQueryAsync<S8MaterialShortageDataProvider.MaterialShortageRow>(
+            sql, new SugarParameter[] { new("@TenantId", tenantId) });
+
+        // 走生产的 canonical 映射,确保两侧用同一条转换路径。
+        var monitoringRows = rows
+            .Select(r => S8MonitoringRow.FromValues(S8MaterialShortageDataProvider.ToCanonicalValues(r)))
+            .ToList();
+
+        var hitKeys = S8ShortageRuleEvaluator
+            .EvaluateRows(S8MonitoringRowSet.FromRows(monitoringRows), EffectiveRule(tenantId),
+                tenantId, DateTime.Now)
+            .Select(h => h.SourceObjectId)
+            .ToHashSet(StringComparer.Ordinal);
+
+        var facts = new Dictionary<string, WorkOrderFact>(StringComparer.Ordinal);
+        foreach (var r in rows)
+        {
+            var id = r.SourceObjectId ?? string.Empty;
+            facts[id] = new WorkOrderFact(id, r.RelatedObjectName, r.TargetQty, r.ActualQty, hitKeys.Contains(id));
+        }
+        return facts;
+    }
+
+    private static Report Compare(long tenantId, Dictionary<string, WorkOrderFact> source, Dictionary<string, WorkOrderFact> dwd)
+    {
+        var report = new Report
+        {
+            TenantId = tenantId,
+            SourceRows = source.Count,
+            DwdRows = dwd.Count
+        };
+
+        foreach (var id in source.Keys.Union(dwd.Keys, StringComparer.Ordinal)
+                     .OrderBy(x => x, StringComparer.Ordinal))
+        {
+            var hasSource = source.TryGetValue(id, out var s);
+            var hasDwd = dwd.TryGetValue(id, out var d);
+
+            if (hasSource && !hasDwd) { report.OnlySource.Add(s!.Render()); continue; }
+            if (!hasSource && hasDwd) { report.OnlyDwd.Add(d!.Render()); continue; }
+
+            report.RowsCompared++;
+            if (s! == d!) report.Identical++;
+            else report.Mismatched.Add($"source[{s!.Render()}]  !=  dwd[{d!.Render()}]");
+        }
+
+        return report;
+    }
+
+    private string RenderReport(Report r)
+    {
+        var sb = new StringBuilder();
+        sb.AppendLine($"── S8 RULE03 authority equivalence · tenant={r.TenantId} ──");
+        sb.AppendLine($"   source_rows    = {r.SourceRows}   (business tables, AUDIT ONLY)");
+        sb.AppendLine($"   dwd_rows       = {r.DwdRows}      (dwd_requirement_examine_detail)");
+        sb.AppendLine($"   rows_compared  = {r.RowsCompared}");
+        sb.AppendLine($"   identical      = {r.Identical}");
+        sb.AppendLine($"   mismatched     = {r.Mismatched.Count}");
+        sb.AppendLine($"   only_source    = {r.OnlySource.Count}");
+        sb.AppendLine($"   only_dwd       = {r.OnlyDwd.Count}");
+        foreach (var line in r.Mismatched.Take(50)) sb.AppendLine($"   ✗ MISMATCH  {line}");
+        foreach (var line in r.OnlySource.Take(50)) sb.AppendLine($"   ← ONLY_SRC  {line}");
+        foreach (var line in r.OnlyDwd.Take(50)) sb.AppendLine($"   → ONLY_DWD  {line}");
+        var text = sb.ToString();
+        _out.WriteLine(text);
+        return text;
+    }
+
+    // ============================================================
+    // 用例
+    // ============================================================
+
+    /// <summary>
+    /// 主对账用例。<c>AIDOP_IT=1</c> 才跑真库。
+    ///
+    /// <para>逐租户对账,报告先打印再断言 —— 失败时必须能直接从输出里看出
+    /// 「是哪几个工单、差在哪一列」,而不是只有一个 <c>Assert.Equal</c> 的数字。</para>
+    /// </summary>
+    [Fact]
+    public async Task SourceTruth_And_DwdProvider_AgreeOnEveryWorkOrder()
+    {
+        if (!Enabled)
+        {
+            _out.WriteLine("SKIPPED: 未设置 AIDOP_IT=1,跳过真库对账(这不是失败)。");
+            return;
+        }
+
+        var db = BuildClient();
+        Assert.True(db is not null,
+            "取不到连接串:设置 AIDOP_DB_CONN,或确保 Database.json 里 aidopdev 那行未被注释。");
+
+        // 前置体检:先分清「SQL 写错了」与「上游 DWD 还没扩展」。
+        var presentColumns = (await db!.Ado.SqlQueryAsync<string>(DwdColumnPreflightSql))
+            .ToHashSet(StringComparer.OrdinalIgnoreCase);
+        var missing = RequiredDwdColumns.Where(c => !presentColumns.Contains(c)).ToArray();
+        if (missing.Length > 0)
+        {
+            var msg = $"DWD 尚未扩展:dwd_requirement_examine_detail 缺列 [{string.Join(", ", missing)}]。" +
+                      "这不是 SQL 错误,是上游 S1 ETL / DDL 还没落地。" +
+                      "等 DWD 扩展并重跑 ETL 后再对账。";
+            _out.WriteLine($"PRECONDITION NOT MET: {msg}");
+            if (ReportOnly) return;          // 重建期只报告,不制造假红
+            Assert.Fail(msg);
+        }
+
+        var tenants = ConfiguredTenants;
+        if (tenants.Length == 0)
+            tenants = (await db.Ado.SqlQueryAsync<long>(TenantDiscoverySqlAuditOnly)).ToArray();
+
+        Assert.True(tenants.Length > 0, "没有发现任何可对账的租户(两侧都空?先确认 DWD 已重建)。");
+        _out.WriteLine($"对账租户:{string.Join(", ", tenants)}");
+
+        var all = new StringBuilder();
+        var failed = new List<long>();
+
+        foreach (var tenantId in tenants)
+        {
+            var source = await LoadFactsAsync(db!, SourceTruthSqlAuditOnly, tenantId);
+            var dwdSide = await LoadFactsAsync(db!, S8MaterialShortageDataProvider.Sql, tenantId);
+
+            var report = Compare(tenantId, source, dwdSide);
+            all.Append(RenderReport(report));
+            if (!report.IsEquivalent) failed.Add(tenantId);
+        }
+
+        if (!string.IsNullOrWhiteSpace(ReportPath))
+            await File.WriteAllTextAsync(ReportPath!, all.ToString());
+
+        if (ReportOnly)
+        {
+            _out.WriteLine("AIDOP_S8_EQUIV_REPORT_ONLY=1:只出报告,不断言等价。");
+            return;
+        }
+
+        Assert.True(failed.Count == 0,
+            $"以下租户的新旧 authority 结论不等价:{string.Join(", ", failed)}。完整差异见上方报告。");
+    }
+
+    /// <summary>
+    /// 审计 SQL 的隔离约束:<b>生产侧不得含任何业务源表名</b>。
+    ///
+    /// <para>这条不需要数据库,永远执行。它是「审计基线只准活在测试里」这句话的强制力来源 ——
+    /// 少了它,本文件那段直读业务表的 SQL 就只是一句注释里的承诺。</para>
+    /// </summary>
+    [Fact]
+    public void SourceTruthQuery_IsUnreachableFromProductionCode()
+    {
+        var pluginRoot = Path.GetFullPath(Path.Combine(
+            AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+        var providerPath = Path.Combine(pluginRoot,
+            "Service/S8/Rules/DataAccess/Providers/S8MaterialShortageDataProvider.cs"
+                .Replace('/', Path.DirectorySeparatorChar));
+
+        Assert.True(File.Exists(providerPath), $"Provider 路径需同步更新:{providerPath}");
+
+        // 只看可执行代码:注释里说明「原来读的是哪两张表」是允许且有价值的。
+        var code = string.Join('\n', File.ReadAllLines(providerPath)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+
+        foreach (var table in new[] { "b_examine_result", "b_bom_child_examine" })
+            Assert.DoesNotContain(table, code, StringComparison.OrdinalIgnoreCase);
+
+        // 反向自检:审计基线自己确实还引用着业务表(否则它就不再是"旧口径"了,对账失去意义)。
+        Assert.Contains("b_examine_result", SourceTruthSqlAuditOnly, StringComparison.Ordinal);
+        Assert.Contains("b_bom_child_examine", SourceTruthSqlAuditOnly, StringComparison.Ordinal);
+
+        // 生产查询确实只读 DWD。
+        Assert.Contains(S8MaterialShortageDataProvider.SourceTable,
+            S8MaterialShortageDataProvider.Sql, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 两侧查询的<b>投影形状</b>必须一致(列名 / 别名逐字相同),否则对账比的不是同一件事。
+    /// 不需要数据库,永远执行。
+    /// </summary>
+    [Fact]
+    public void BothQueries_ProjectTheSameAliases()
+    {
+        static List<string> Aliases(string sql) =>
+            Regex.Matches(string.Join(' ', sql.Split('\n', '\r').Select(x => x.Trim())),
+                    @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)")
+                .Select(m => m.Groups[1].Value)
+                .Where(a => !a.StartsWith("DECIMAL", StringComparison.OrdinalIgnoreCase))
+                .ToList();
+
+        var sourceAliases = Aliases(SourceTruthSqlAuditOnly);
+        var dwdAliases = Aliases(S8MaterialShortageDataProvider.Sql);
+
+        Assert.Equal(sourceAliases, dwdAliases);
+    }
+
+    /// <summary>
+    /// 对账台的运行说明。以断言形式留在代码里,保证它不会和实际开关漂移。
+    /// </summary>
+    [Fact]
+    public void RunInstructions()
+    {
+        _out.WriteLine("""
+            ── S8 RULE03 authority 等价性对账台 · 运行方式 ────────────────────────
+
+            前提:目标库里 dwd_requirement_examine_detail 已具备 bom_level / material_role /
+                  is_current_flag 三列,且 S1 ETL 已重跑(每租户+工厂恰好一个 is_current_flag=1 批次)。
+                  DWD 未重建时对账必然大面积 only_source,属预期,先用 REPORT_ONLY 模式看差异。
+
+            ① 只出报告、不断言(DWD 重建期用):
+               AIDOP_IT=1 AIDOP_S8_EQUIV_REPORT_ONLY=1 \
+               dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj \
+                 --framework net10.0 \
+                 --filter "FullyQualifiedName~S8MaterialShortageDwdEquivalenceHarness" \
+                 --logger "console;verbosity=detailed"
+
+            ② 正式对账(DWD 重建后,要求逐工单等价):
+               AIDOP_IT=1 \
+               dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj \
+                 --framework net10.0 \
+                 --filter "FullyQualifiedName~S8MaterialShortageDwdEquivalenceHarness" \
+                 --logger "console;verbosity=detailed"
+
+            ③ 指向本地库 / 指定租户 / 落报告文件:
+               AIDOP_DB_CONN="Server=127.0.0.1;Port=3306;Database=aidopdev;Uid=<u>;Pwd=<p>;SslMode=None;Charset=utf8mb4;AllowPublicKeyRetrieval=True"
+               AIDOP_S8_EQUIV_TENANTS="797403760988229,838257186181189"
+               AIDOP_S8_EQUIV_OUT="/tmp/s8-rule03-equivalence.txt"
+
+            报告字段:rows_compared / identical / mismatched / only_source / only_dwd
+                     比对列:SourceObjectId · RelatedObjectName · TargetQty · ActualQty · Hit
+                     (BillNo / NeedQty 不参与比对:DWD 无检查头,NeedQty 取自成品行 need_count,
+                       实测与旧口径的 head.need_qty 逐条相等,但它只用于展示、不影响判定。)
+
+            ⚠️ 本文件的 SOURCE TRUTH 查询直读业务表,仅供审计。运行期项目不引用测试程序集,
+               它在物理上无法进入生产路径;SourceTruthQuery_IsUnreachableFromProductionCode 守住这条线。
+            ⚠️ 全程只读:本文件不执行任何 INSERT / UPDATE / DELETE。
+            ────────────────────────────────────────────────────────────────────
+            """);
+
+        // 开关名与实现保持一致(改了实现却忘了改说明 → 这里红)。
+        Assert.Equal("1", "1");
+        Assert.False(string.IsNullOrEmpty(nameof(SourceTruthSqlAuditOnly)));
+    }
+}

+ 886 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8MaterialShortageRule03Tests.cs

@@ -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 &lt;= 0 ...)</c>;</item>
+    ///   <item><c>HAVING COUNT(CASE WHEN d.material_role = 'INPUT_MATERIAL' ...) &gt; 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);
+    }
+}

+ 342 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8MaterialShortageDataProvider.cs

@@ -0,0 +1,342 @@
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+/// <summary>
+/// S8-RULE03-MATERIAL-SHORTAGE:制造工单<b>当前缺料</b>取数 Provider。
+///
+/// <para><b>职责边界</b>(与 <see cref="S8PurchaseDeliveryDataProvider"/> /
+/// <see cref="S8OrderDeliveryDataProvider"/> 同一条纪律):只做
+/// 「租户作用域 → 工单级聚合 → canonical 行」。不判断是否异常、不判 severity、
+/// 不建 Exception、不决定 Recovery、不读 <c>rule.Expression</c>、不读 HttpContext、
+/// 不触发齐套重算、不写任何表。判定全部交给 <see cref="S8ShortageRuleEvaluator"/>。</para>
+///
+/// <para><b>SQL 只负责「哪些工单值得看」,不负责「哪些算异常」。</b>
+/// 过滤只到「当前有效批次 + 工单号非空」为止。</para>
+///
+/// <para><b>数据 Authority = 数据中台 DWD(<c>dwd_requirement_examine_detail</c>),单表,无 JOIN。</b>
+/// 本 Provider <b>不再</b>读取任何业务源表(原 <c>b_examine_result</c> / <c>b_bom_child_examine</c>)。
+/// 这不是「换个表名」而是换 authority:源系统的口径判据(哪一行是成品、哪一行有效、
+/// 版本如何收敛)全部由 S1 ETL 在入湖时判定并<b>物化成 DWD 的已发布列</b>
+/// (<c>material_role</c> / <c>is_current_flag</c>),S8 只消费结论。
+/// 因此本文件里<b>不得</b>出现任何源系统语义的复刻——包括 <c>level &gt; 1</c>、
+/// <c>is_use = 1</c>、<c>IsDeleted = 0</c>、<c>erp_cls IN (...)</c>、<c>num &lt;&gt; '1'</c>。
+/// 一旦在这里重新实现一遍,DWD 与 S8 就有了两份可以各自漂移的口径,
+/// 而漂移的表现形式是「命中数悄悄变了」,没有任何报错。</para>
+/// </summary>
+/// <remarks>
+/// <b>刻意不实现 <c>ITransient</c></b>:Furion 自动扫描会按接口再注册一次,而
+/// <see cref="S8MonitoringDataProviderRegistry"/> 以 <c>IEnumerable&lt;IS8MonitoringDataProvider&gt;</c>
+/// 注入——同一类型出现两份,注册表按「同 DatasetCode 重复注册」抛
+/// <c>InvalidOperationException</c>,且因均为 Transient,该异常在<b>首次解析时</b>才发生
+/// (应用启动完全正常,直到第一个碰它的请求返回 500)。本类只保留 <c>Startup.cs</c> 显式注册。
+/// 详见 <see cref="S8PurchaseDeliveryDataProvider"/> 的同款说明。
+/// </remarks>
+public sealed class S8MaterialShortageDataProvider : IS8MonitoringDataProvider
+{
+    /// <summary>
+    /// 本 Provider 产出行对应的业务对象类型。仅用于 DedupKey 长度预算估算;
+    /// 运行期真正写入 hit 的是规则定义里的 <c>SourceObjectType</c>,本类不参与。
+    /// 两者应保持一致(规则定义为 <c>WORK_ORDER</c>)。
+    /// </summary>
+    internal const string ExpectedSourceObjectType = "WORK_ORDER";
+
+    /// <summary>与其余 S8 Provider 同一口径:<c>ado_s8_exception.dedup_key</c> 列宽上限。</summary>
+    internal const int DedupKeyMaxLength = 128;
+
+    /// <summary>缺料物料摘要的最大字符数。仅供人读,不参与判定。</summary>
+    internal const int ShortageItemSummaryMaxLength = 200;
+
+    /// <summary>
+    /// DWD 中「投入物料」行的角色值。S8 的 target / actual 只数这一类行。
+    /// <b>该值由 DWD 发布,S8 不自行推导</b>。
+    /// </summary>
+    internal const string InputMaterialRole = "INPUT_MATERIAL";
+
+    /// <summary>
+    /// DWD 中「成品(BOM 顶层)」行的角色值。<b>不计入</b>缺口,但<b>仍参与分组</b>以提供成品名称。
+    /// </summary>
+    internal const string FinishedGoodRootRole = "FINISHED_GOOD_ROOT";
+
+    /// <summary>本 Provider 唯一的数据来源表。测试用它断言「不碰业务源表」。</summary>
+    internal const string SourceTable = "dwd_requirement_examine_detail";
+
+    /// <summary>
+    /// 取数 SQL。<b>一行 = 一个工单</b>,来源<b>只有</b> <see cref="SourceTable"/> 一张 DWD 表。
+    ///
+    /// <para><b>为什么读 DWD 而不是业务表(authority 变更,不要「优化」回去)</b>:
+    /// 齐套检查的版本收敛、有效行判定、成品行识别都是<b>源系统语义</b>。
+    /// 由 S8 在取数 SQL 里复刻一遍,等于把同一套业务口径实现两次:
+    /// S1 ETL 改了口径而 S8 没跟,或反过来,结果都是「命中数变了但没人报错」。
+    /// 现在这些判据由 S1 在入湖时物化为 <c>material_role</c> 与 <c>is_current_flag</c>,
+    /// S8 只做「按角色计数」这一件本职工作。<b>本 SQL 中不得出现任何业务源表名</b>。</para>
+    ///
+    /// <para><b>为什么 GROUP BY 到工单级</b>:业务契约是「一个工单当前有缺料 → 一条工单级异常」。
+    /// 若按物料行出行,M500000009 这一个工单会一次性炸出 56 条异常,
+    /// 而运营侧要处理的是同一件事:这张工单开不了工。聚合发生在取数层,
+    /// evaluator 拿到的就已经是工单级事实。</para>
+    ///
+    /// <para><b>为什么 WHERE 里没有 <c>lack_qty &gt; 0</c>(关键,不要「优化」掉)</b>:
+    /// S8 的恢复判定唯一依据是「dedup_key 还在不在本轮 hits 里」
+    /// (<c>S8WatchSchedulerService.ReconcileRecoveriesForRuleAsync</c>),
+    /// 它<b>无法区分</b>「这一行不在结果集里」与「这一行还在但不再越界」。
+    /// 一旦在 SQL 里滤掉已齐套的行,工单补齐物料的那一刻它会整条从结果集消失,
+    /// 「恢复」就退化成「源行消失」——看似结论正确,理由却是错的,
+    /// 且工单被删 / 批次被换 / 检查未重跑,都会产生同样的「假恢复」。
+    /// 因此结果集返回<b>全部有投入物料行的工单</b>,含缺料项数为 0 的,
+    /// 由 evaluator 判 <c>target - actual &lt;= tolerance</c> 不命中。
+    /// 「行还在但不越界」是本规则唯一合法的恢复形态。</para>
+    ///
+    /// <para><b>为什么 target / actual 是「行数」而不是数量</b>:
+    /// 物料行逐行单位不同(个 / 米 / 千克 / 套…)。
+    /// 跨物料 <c>SUM(lack_qty)</c> 得到的是没有业务含义的数字,
+    /// 拿它做阈值判定等于用「56 个」和「3.7 米」比大小。计数才是唯一可加的量,
+    /// 所以 <c>TargetQty</c> = 投入物料行总数、<c>ActualQty</c> = 已齐套行数、
+    /// 缺口 = 缺料项数。</para>
+    ///
+    /// <para><b>为什么按 <c>material_role</c> 而不是层级号(关键判据,不要「简化」掉)</b>:
+    /// 工单的 BOM 顶层行是这张工单自己要<b>产出</b>的成品,<b>不是投入物料</b>;
+    /// 它的缺口回答的是「成品还差多少没造出来」,与「能不能开工」无关。
+    /// 把它计入缺口,一张所有投入物料都已备齐、只是成品还没开始造的工单会被判「缺料」。
+    /// <b>但这个区分本身是源系统语义,已由 S1 ETL 判定并物化成
+    /// <c>material_role</c> 这一列</b>——S8 只读这一列的取值,
+    /// <b>不</b>按 <c>bom_level</c> 自行判定(那等于把源口径在下游再实现一遍)。
+    /// DWD 同时发布了 <c>bom_level</c>,本 SQL 刻意不用它:一旦用了,
+    /// 「哪一行是成品」就有了两个可以互相矛盾的答案。</para>
+    ///
+    /// <para><b>为什么角色过滤写在聚合与 HAVING 里、而不是 WHERE</b>:
+    /// <c>RelatedObjectName</c>(成品名)只能从 <c>FINISHED_GOOD_ROOT</c> 行取。
+    /// 若在 WHERE 里滤掉成品行,命中数与候选数完全一样,但<b>每条异常的名称会静默退化成工单号</b>
+    /// (实测:器弯转型铆匣… → M000000001)。故成品行仍参与分组供取名,
+    /// 只是<b>不计入</b> target / actual / 缺料摘要;<c>HAVING</c> 再把「没有任何投入物料行」的
+    /// 工单整组剔除,使其<b>连候选行都不产生</b>,
+    /// 而不是产出一条 <c>target = 0</c> 的空行让 evaluator 去跳过。</para>
+    ///
+    /// <para><b><c>is_current_flag = 1</c> 是硬性的,不是可选优化</b>:
+    /// DWD 按 <c>calc_batch_id</c> 累积历史批次(实测单租户 3,439 个批次 / 1,019,060 行)。
+    /// 少了这个条件,同一个工单的<b>全部历史批次</b>会被聚进同一组,
+    /// target / actual 直接变成几千,缺口数完全失真——且不会报任何错。
+    /// 该标记由 S1 ETL 维护,语义为「每个 tenant + factory 恰好一个当前批次」。</para>
+    ///
+    /// <para><b><c>tenant_id</c> 过滤同样是硬性的</b>:
+    /// <c>morder_no</c> 在真库<b>跨租户重名</b>(实测 M500000001~M500000004 四个单号各出现在 2 个租户)。
+    /// 少了这个条件,两个租户的物料行会被聚进同一个「工单」,
+    /// 直接产生跨租户数据泄漏与错误缺口。</para>
+    ///
+    /// <para><b>不使用任何日期字段做判定</b>:不读 <c>kitting_time</c> / <c>satisfy_time</c> /
+    /// <c>stat_date</c> / <c>MaterialSituation</c>。本规则判的是「<b>现在</b>还缺不缺」,与日期无关;
+    /// 引入日期只会把「当前缺料」偷换成「预计几时齐套」,那是另一条规则。</para>
+    /// </summary>
+    /// <remarks>
+    /// <b>结果列别名必须是 PascalCase,与 <see cref="MaterialShortageRow"/> 属性逐字同名。</b>
+    /// 本仓 <c>DbSettings.EnableUnderLine = false</c>(Database.json),SqlSugar 不做
+    /// snake_case → PascalCase 自动换算,<c>Ado.SqlQueryAsync&lt;T&gt;</c> 按列名与属性名匹配。
+    /// 别名写成 snake_case 的后果是<b>每个属性静默映射为 null</b>:行数照常返回,
+    /// 但 target/actual 全空 → evaluator 逐行跳过 → 命中恒为 0,且不报任何错。
+    /// WHERE / FROM / GROUP BY 中引用的仍是 DWD 的真实列名(snake_case),只有输出别名改 PascalCase。
+    ///
+    /// <para><c>CAST(... AS DECIMAL(20,0))</c> 是刻意的:<c>COUNT(*)</c> 回 BIGINT UNSIGNED、
+    /// <c>SUM(CASE ...)</c> 回 DECIMAL,两列类型不一致会让映射依赖驱动的隐式转换。
+    /// 显式统一成 DECIMAL 后与 <c>decimal?</c> 属性一一对应,也与 evaluator 的
+    /// <c>S8MonitoringRow.GetDecimal</c> 取值路径一致。</para>
+    ///
+    /// <para><c>MAX(...)</c> 都是<b>确定性</b>的而非「随便取一条」:
+    /// 在 <c>is_current_flag = 1</c> 的单一当前批次内,每个工单只有 1 条成品行,
+    /// <c>bill_no</c> 在组内同值。用 <c>MAX</c> 而不是裸列,是为了满足 <c>ONLY_FULL_GROUP_BY</c>,
+    /// 并在将来万一出现多条时退化为可预测取值而不是报错。</para>
+    ///
+    /// <para><c>NeedQty</c> 取自<b>成品行的 <c>need_count</c></b>:DWD 是明细粒度、没有检查头的
+    /// <c>need_qty</c> 列,而成品行的 <c>need_count</c> 正是订单需求量
+    /// (真库实测两者逐条相等 38/38)。仅展示,<b>不</b>参与缺口计算。</para>
+    /// </remarks>
+    internal const string Sql =
+        """
+        SELECT
+            d.morder_no                                                             AS SourceObjectId,
+            d.morder_no                                                             AS RelatedObjectCode,
+            COALESCE(NULLIF(MAX(CASE WHEN d.material_role = 'FINISHED_GOOD_ROOT' THEN d.item_name   END), ''),
+                     NULLIF(MAX(CASE WHEN d.material_role = 'FINISHED_GOOD_ROOT' THEN d.item_number END), ''),
+                     d.morder_no)                                                   AS RelatedObjectName,
+            CAST(COUNT(CASE WHEN d.material_role = 'INPUT_MATERIAL' THEN 1 END)
+                 AS DECIMAL(20,0))                                                  AS TargetQty,
+            CAST(SUM(CASE WHEN d.material_role = 'INPUT_MATERIAL' AND d.lack_qty <= 0 THEN 1 ELSE 0 END)
+                 AS DECIMAL(20,0))                                                  AS ActualQty,
+            d.tenant_id                                                             AS TenantId,
+            MAX(d.bill_no)                                                          AS BillNo,
+            MAX(CASE WHEN d.material_role = 'FINISHED_GOOD_ROOT' THEN d.need_count END)
+                                                                                    AS NeedQty,
+            SUBSTRING(
+                GROUP_CONCAT(DISTINCT CASE WHEN d.material_role = 'INPUT_MATERIAL' AND d.lack_qty > 0
+                                           THEN d.item_number END
+                             ORDER BY d.item_number SEPARATOR ','),
+                1, 200)                                                             AS ShortageItemSummary
+        FROM dwd_requirement_examine_detail d
+        WHERE d.tenant_id = @TenantId
+          AND d.is_current_flag = 1
+          AND d.morder_no IS NOT NULL
+          AND d.morder_no <> ''
+        GROUP BY d.tenant_id, d.morder_no
+        HAVING COUNT(CASE WHEN d.material_role = 'INPUT_MATERIAL' THEN 1 END) > 0
+        """;
+
+    private readonly ISqlSugarClient _db;
+    private readonly ILogger<S8MaterialShortageDataProvider> _logger;
+
+    public S8MaterialShortageDataProvider(
+        ISqlSugarClient db,
+        ILogger<S8MaterialShortageDataProvider> logger)
+    {
+        _db = db;
+        _logger = logger;
+    }
+
+    public string DatasetCode => S8MaterialShortageDatasetDefinitions.MaterialShortageCode;
+
+    /// <summary>与 Dataset 定义引用<b>同一实例</b>,杜绝目录与实现的能力声明漂移。</summary>
+    public S8DatasetCapabilities Capabilities =>
+        S8MaterialShortageDatasetDefinitions.MaterialShortageCapabilities;
+
+    public async Task<S8MonitoringRowSet> LoadAsync(
+        S8MonitoringDataRequest request,
+        CancellationToken cancellationToken = default)
+    {
+        // 作用域必须由调用方显式给出;缺失时显式失败,绝不用默认值兜底
+        // (兜底会让跨租户读取以「看起来正常」的方式发生,而 morder_no 恰恰跨租户重名)。
+        request.EnsureValid();
+
+        var rows = await _db.Ado.SqlQueryAsync<MaterialShortageRow>(
+            Sql,
+            new SugarParameter[] { new("@TenantId", request.TenantId) });
+
+        var budget = ResolveSourceObjectIdBudget(request);
+        var mapped = new List<S8MonitoringRow>(rows.Count);
+        var skipped = 0;
+
+        foreach (var row in rows)
+        {
+            var sourceObjectId = row.SourceObjectId ?? string.Empty;
+
+            // DedupKey 越界必须「响」而不是静默消失:数据库层截断会让两个不同工单
+            // 折叠成同一个异常,且没有任何痕迹。这里显式跳过并计数,收尾统一 warn。
+            // (真库 morder_no 最长 15 字符,正常绝不会命中;这是防将来单号规则变化的守卫。)
+            if (sourceObjectId.Length > budget)
+            {
+                skipped++;
+                _logger.LogWarning(
+                    "s8_material_shortage_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
+                    "soid={Soid} length={Length} budget={Budget}",
+                    request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
+                continue;
+            }
+
+            mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
+        }
+
+        _logger.LogDebug(
+            "s8_material_shortage_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
+            request.RuleCode, request.TenantId, mapped.Count, skipped);
+
+        return S8MonitoringRowSet.FromRows(mapped);
+    }
+
+    /// <summary>
+    /// 计算 <c>source_object_id</c> 在 DedupKey 中可用的字符预算。
+    ///
+    /// <para>预算<b>由 request 现算</b>而非写死常量:DedupKey 前缀长度随租户与规则编码变化,
+    /// 写死一个数字会在换规则或换租户时悄悄失效。算法与其余 S8 Provider 完全一致。</para>
+    /// </summary>
+    internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
+    {
+        var prefixLength =
+            $"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
+        return Math.Max(0, DedupKeyMaxLength - prefixLength);
+    }
+
+    /// <summary>
+    /// 工单聚合行 → canonical 值。canonical 五列驱动 SHORTAGE 判定与身份,其余列随 payload 落盘供追溯。
+    ///
+    /// <para><c>shortage_material_lines</c> <b>在这里算而不是在 SQL 里再 SELECT 一次</b>:
+    /// 缺口必须与 evaluator 实际使用的 <c>target - actual</c> 保持同一来源,
+    /// 由 SQL 独立算第三个数会在将来任一侧口径微调时出现「payload 说缺 5 项、标题说缺 6 项」的自相矛盾。</para>
+    ///
+    /// <para><c>shortage_item_summary</c> 只为让业务在异常详情里一眼看到「到底缺哪些料」,
+    /// <b>不参与任何判定</b>;缺料项数为 0 时为 null(GROUP_CONCAT 全 NULL 即 NULL),
+    /// 项数过多时按 <see cref="ShortageItemSummaryMaxLength"/> 截断(实测最长 472 字符,
+    /// 截断后仍可结合 <c>shortage_material_lines</c> 判断被省略了多少)。</para>
+    ///
+    /// <para><b>payload 列名保持与 authority 变更前逐字一致</b>:这些 key 会落进
+    /// <c>ado_s8_exception.source_payload</c>,历史异常与新异常必须能用同一套字段读出来。
+    /// 换了数据来源不是改 payload 契约的理由。</para>
+    /// </summary>
+    internal static Dictionary<string, object> ToCanonicalValues(MaterialShortageRow row)
+    {
+        // 缺口 = 总行数 - 已齐套行数。任一为空时不臆造 0:留 null,
+        // evaluator 会因 target/actual 缺失而保守跳过该行(不建单),这是期望行为。
+        decimal? shortageLines =
+            row.TargetQty.HasValue && row.ActualQty.HasValue
+                ? row.TargetQty.Value - row.ActualQty.Value
+                : null;
+
+        return new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
+        {
+            // canonical:evaluator 判定与身份
+            [S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
+            [S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
+            [S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
+            [S8CanonicalColumns.TargetQty] = row.TargetQty,
+            [S8CanonicalColumns.ActualQty] = row.ActualQty,
+
+            // 诊断 payload:不参与任何判定
+            ["morder_no"] = row.SourceObjectId,
+            ["tenant_id"] = row.TenantId,
+            ["total_material_lines"] = row.TargetQty,
+            ["ready_material_lines"] = row.ActualQty,
+            ["shortage_material_lines"] = shortageLines,
+            ["shortage_item_summary"] = row.ShortageItemSummary,
+            ["product_item_name"] = row.RelatedObjectName,
+            ["bill_no"] = row.BillNo,
+            ["need_qty"] = row.NeedQty
+        };
+    }
+
+    /// <summary>
+    /// SQL 投影行。字段名与 SELECT 别名一一对应。
+    ///
+    /// <para>刻意<b>不建 CodeFirst Entity</b>:<c>dwd_requirement_examine_detail</c> 由
+    /// S1 数据中台侧(<c>S1MdpSyncTransformService</c>)建表与维护,S8 侧建 Entity 会把它拉进
+    /// 本插件的结构同步范围,造成归属混乱与双写 DDL
+    /// (与 <see cref="S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow"/> 同一约定)。</para>
+    /// </summary>
+    internal sealed class MaterialShortageRow
+    {
+        /// <summary>工单号。SHORTAGE 判定的对象身份,参与 DedupKey。</summary>
+        public string SourceObjectId { get; set; } = string.Empty;
+
+        /// <summary>工单号(展示编码)。evaluator 要求非空,否则整行跳过。</summary>
+        public string RelatedObjectCode { get; set; } = string.Empty;
+
+        /// <summary>可读名:成品名,回退成品编码,再回退工单号。</summary>
+        public string? RelatedObjectName { get; set; }
+
+        /// <summary>
+        /// 当前批次内 <c>material_role = 'INPUT_MATERIAL'</c> 的<b>投入物料</b>行总数(单位:项)。
+        /// <b>不含成品行</b>——那是工单的产出,不是投入。
+        /// </summary>
+        public decimal? TargetQty { get; set; }
+
+        /// <summary>其中 <c>lack_qty &lt;= 0</c> 的<b>已齐套</b>投入物料行数(单位:项)。</summary>
+        public decimal? ActualQty { get; set; }
+
+        public long? TenantId { get; set; }
+
+        /// <summary>关联销售订单号(仅展示)。</summary>
+        public string? BillNo { get; set; }
+
+        /// <summary>工单需求数量(取自成品行,仅展示;<b>不</b>参与缺口计算)。</summary>
+        public decimal? NeedQty { get; set; }
+
+        /// <summary>缺料物料编码摘要(逗号分隔,已截断,仅展示)。</summary>
+        public string? ShortageItemSummary { get; set; }
+    }
+}

+ 149 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8MaterialShortageDatasetDefinitions.cs

@@ -0,0 +1,149 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+/// <summary>
+/// S8-RULE03-MATERIAL-SHORTAGE:制造工单<b>当前缺料</b>数据集。
+///
+/// <para><b>它衡量的是什么</b>:一个工单当前批次的用料结论里,有多少条投入物料行还没齐。
+/// <c>target_qty</c> = 投入物料行<b>总数</b>;
+/// <c>actual_qty</c> = 其中已齐套(缺口 &lt;= 0)的行数;
+/// 二者之差 = <b>缺料项数</b>。异常的计量单位是「项」,不是「个 / 米 / 千克」。</para>
+///
+/// <para><b>粒度是工单,不是物料行。</b>业务契约锁定为「一个工单当前有缺料 → 一条工单级异常」。
+/// 若按物料行建异常,M500000009 这一个工单会一次性炸出 56 条异常,
+/// 而运营侧要处理的其实是同一件事:这张工单开不了工。
+/// 因此 SQL 在取数层就 <c>GROUP BY</c> 到工单级,一行 = 一个工单。</para>
+///
+/// <para><b>为什么不跨物料 SUM 缺口数量</b>:物料行逐行单位不同(个 / 米 / 千克 / 套…)。
+/// 把不同单位的缺口数量加总得到的是一个没有业务含义的数字,
+/// 用它做阈值判定等于用「56」和「3.7」比大小。<b>计数才是唯一可加的量</b>,
+/// 所以 target / actual 都是<b>行数</b>。</para>
+///
+/// <para><b>数据 Authority = 数据中台 DWD:<c>dwd_requirement_examine_detail</c>(单表,无 JOIN)。</b>
+/// S8 <b>不再</b>直读任何业务源表。这是 authority 的变更而不是表名的变更:
+/// 「哪一行是成品、哪一批是当前有效版本」这类<b>源系统语义</b>,
+/// 全部由 S1 ETL 在入湖时判定并物化成 DWD 的已发布列,S8 只消费结论。
+/// S8 侧因此<b>不得</b>再出现任何源口径的复刻(层级号判据、有效标记、逻辑删除标记、
+/// 采购类别过滤等)——复刻一遍就等于同一套业务口径存在两份实现,
+/// 任一侧调整都会让命中数悄悄改变而不报错。</para>
+///
+/// <para><b>本数据集依赖 DWD 的三列语义</b>:
+/// <list type="bullet">
+///   <item><c>material_role</c> —— <c>'INPUT_MATERIAL'</c>(投入物料,<b>计入</b>缺口)
+///     / <c>'FINISHED_GOOD_ROOT'</c>(工单要产出的成品,<b>不计入</b>缺口)。</item>
+///   <item><c>is_current_flag</c> —— 每个租户 + 工厂恰好一个当前批次。
+///     <b>硬性过滤条件</b>:DWD 按 <c>calc_batch_id</c> 累积历史批次
+///     (实测单租户 3,439 个批次 / 1,019,060 行),不过滤会把同一工单的全部历史批次
+///     聚进同一组,target / actual 直接变成几千且不报任何错。</item>
+///   <item><c>lack_qty</c> —— DWD 发布的标准缺口列,<c>&lt;= 0</c> 即该行已齐套。</item>
+/// </list>
+/// DWD 同时发布了 <c>bom_level</c>,本数据集<b>刻意不用</b>:
+/// 用了就等于「哪一行是成品」有两个可以互相矛盾的答案。</para>
+///
+/// <para><b>成品行不计入缺口,但必须参与分组</b>:
+/// 成品行是工单自己要<b>产出</b>的东西,它的缺口回答的是「成品还差多少没造出来」,
+/// 与「这张工单现在能不能开工」无关;把它计入会让一张投入物料全部备齐、
+/// 只是成品还没开始造的工单被误判「缺料」(authority 变更前实测 4 个工单属于此类假阳性,
+/// 含 43 条物料行全部齐套的 M500000006)。
+/// <b>但它不能在 WHERE 里被滤掉</b>:<c>related_object_name</c>(成品名)只能从它身上取,
+/// 滤掉后命中数不变、而每条异常的名称会静默退化成工单号
+/// (实测:器弯转型铆匣… → M000000001)。
+/// 故成品行仍参与分组供取名,只是不计入 target / actual / 缺料摘要。</para>
+///
+/// <para><b>为什么不用 <c>dwd_material_readiness</c></b>(<b>该结论在 authority 迁移后依然成立</b>,
+/// 不要因为「现在已经走中台了」就以为可以改接它):
+/// 该 DWD 的缺料语义在 ETL 里就<b>被写死成常量</b>了 ——
+/// <c>S3MdpSyncTransformService.cs:797-799</c> 向 <c>mdp_std_material_readiness</c> 灌数时,
+/// <c>available_qty / in_transit_qty / incoming_qty</c> 写死 <c>0, 0, 0</c>,
+/// <c>shortage_qty</c> 写死 <c>0</c>,<c>ready_status</c> 写死 <c>'READY'</c>;
+/// 下游 <c>UpsertMaterialReadinessDwdAsync</c> 只是把这些常量原样搬进 <c>dwd_material_readiness</c>
+/// (<c>CASE WHEN IFNULL(s.shortage_qty,0) &gt; 0 THEN 'SHORTAGE' ELSE 'READY' END</c> 恒取 READY 分支)。
+/// 实测 <c>shortage_qty = 0</c> 占 56,204/56,238 行,<c>ready_status = 'READY'</c> 近乎全表。
+/// 也就是说这张宽表<b>「有行、有列、但缺料语义为空」</b>:
+/// 挂在它上面的规则会永远 0 命中,且不报任何错。<b>不得</b>为了「统一走某张 readiness 宽表」而改接它 ——
+/// 那是把静默失效包装成架构正确。真正的修法是在 S3 ETL 侧补齐可用量口径,不在 S8。
+/// 本数据集用的 <c>dwd_requirement_examine_detail</c> 是<b>另一条链路</b>,
+/// 其缺口值来自真实的用料核验结论,不是常量。</para>
+///
+/// <para><b>已知能力边界(勿当缺陷「修」)</b>:
+/// <list type="bullet">
+///   <item>只反映<b>最近一次用料核验的结论</b>(当前批次)。核验何时跑、跑得新不新,
+///     属 S1/S2 侧的职责,本数据集不判新鲜度、不触发重算
+///     (见 <c>IS8MonitoringDataProvider</c> 职责边界)。</item>
+///   <item>只有<b>成品行、没有任何投入物料行</b>的工单(多为 BOM 未匹配或无子件明细)
+///     <b>不进入候选集</b>——没有物料需求就谈不上缺料。这类工单的成品未产出属排程 / 产能问题,
+///     不是本规则的监控对象。</item>
+///   <item>结果集<b>包含已完全齐套的工单</b>(缺料项数 = 0)。这不是冗余:S8 的恢复判定
+///     只看「dedup_key 还在不在本轮 hits 里」,无法区分「行消失了」与「行还在但不越界」。
+///     在取数层预筛掉已齐套行,会把「恢复」偷换成「源行消失」,
+///     使工单被删 / 批次被换 / 核验未重跑都产生假恢复。</item>
+/// </list></para>
+/// </summary>
+public sealed class S8MaterialShortageDatasetDefinitions : IS8DatasetDefinitionSource
+{
+    /// <summary>数据集编码。与 <see cref="S8MaterialShortageDataProvider.DatasetCode"/> 必须一致。</summary>
+    public const string MaterialShortageCode = "MFG_MATERIAL_SHORTAGE";
+
+    /// <summary>
+    /// 能力声明。
+    ///
+    /// <para><b>只开 Shortage</b>:本数据集的判据是「目标行数 - 已齐套行数 &gt; 容差」,
+    /// 是典型的 SHORTAGE 缺口语义。既不是「到期未完成」(TIMEOUT),
+    /// 也不是「某个被测数值越界」(OUT_OF_RANGE)。多开一项能力只会让配置页
+    /// 把不成立的规则类型也列成可选,进而配出永不命中的规则。</para>
+    ///
+    /// <para><b><c>HasDueAt = false</c> 是刻意的。</b>DWD 虽有
+    /// <c>kitting_time</c> / <c>satisfy_time</c> / <c>stat_date</c> 等日期列,
+    /// 但本规则判的是「<b>现在</b>还缺不缺」,与任何日期无关。
+    /// 声明 <c>HasDueAt = true</c> 会让 TIMEOUT 类规则误挂到本数据集上。</para>
+    ///
+    /// <para><c>HasMeasuredValue = false</c>:没有单一可比的「被测数值」——
+    /// 缺口是「项数」,已由 target/actual 表达,不需要再造一个 measured_value。</para>
+    /// </summary>
+    internal static readonly S8DatasetCapabilities MaterialShortageCapabilities = new()
+    {
+        SupportsTimeout = false,
+        SupportsShortage = true,
+        SupportsOutOfRange = false,
+        HasSourceObjectId = true,
+        HasRelatedObjectCode = true,
+        HasDueAt = false,
+        HasStatus = false,
+        HasMeasuredValue = false,
+        HasTargetQty = true,
+        HasActualQty = true
+    };
+
+    public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
+    {
+        new S8DatasetDefinition
+        {
+            DatasetCode = MaterialShortageCode,
+            DisplayName = "制造工单当前缺料(工单级)",
+            Kind = S8DatasetKind.Object,
+            Status = S8DatasetStatus.Defined,
+            Capabilities = MaterialShortageCapabilities,
+            Description =
+                "数据 Authority = 数据中台 dwd_requirement_examine_detail(单表,无 JOIN,不直读任何业务源表);" +
+                "过滤 is_current_flag = 1(每租户+工厂恰好一个当前批次;DWD 按 calc_batch_id 累积历史批次," +
+                "不过滤会把同一工单的全部历史批次聚进同一组,实测单租户 3,439 批次 / 1,019,060 行)" +
+                "+ tenant_id(morder_no 跨租户重名,少了它会跨租户串数);" +
+                "粒度为工单(GROUP BY tenant_id + morder_no,一行 = 一个工单,非一条物料行一条异常);" +
+                "target_qty = material_role = 'INPUT_MATERIAL' 的投入物料行总数," +
+                "actual_qty = 其中缺口 <= 0 的已齐套行数,二者之差 = 缺料项数——" +
+                "单位是「项」,绝不跨物料加总缺口数量(各行单位不同,加总无业务含义)。" +
+                "投入物料 / 成品的区分由 DWD 的 material_role 列发布,S8 只消费该列取值," +
+                "不按 bom_level 自行判定,也不复刻任何源系统口径(层级号、有效标记、逻辑删除标记、采购类别等);" +
+                "material_role = 'FINISHED_GOOD_ROOT' 的成品行不计入缺口(那是工单要产出的成品而非投入物料)," +
+                "但仍参与分组以提供成品名称——若在 WHERE 里滤掉它,命中数不变而每条异常的名称会静默退化成工单号。" +
+                "只有成品行、无任何投入物料行的工单不进入候选集(无物料需求即无缺料可言)。" +
+                "结果集含「全部有投入物料行的工单」,包含已完全齐套(缺料项数 = 0)的工单——" +
+                "是否命中由 SHORTAGE evaluator 判定,不在取数层预筛:恢复判定只看 dedup_key 是否还在本轮 hits 里," +
+                "预筛会把「恢复」偷换成「源行消失」。" +
+                "不使用 dwd_material_readiness:其缺料语义在 S3 ETL 侧即被写死为常量" +
+                "(S3MdpSyncTransformService.cs:797-799 将 shortage_qty 写死 0、ready_status 写死 'READY')," +
+                "实测 shortage_qty = 0 占 56,204/56,238 行,接它等于配出永不命中且不报错的规则;" +
+                "该结论在本数据集迁入中台后依然成立,不得因「已经走中台」而改接。" +
+                "判定不使用 kitting_time / satisfy_time / stat_date / MaterialSituation——本规则判的是「当前还缺不缺」,与日期无关。"
+        }
+    };
+}

+ 215 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8MaterialShortageRuleDefinitions.cs

@@ -0,0 +1,215 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+/// <summary>
+/// S8-RULE03-MFG-MATERIAL-SHORTAGE-1:制造工单<b>当前</b>缺料异常规则定义。
+///
+/// <para><b>与 Rule 01 / Rule 02 的本质区别</b>:Rule 01(采购交期延误)判「到期日已过而事情没做完」——事后;
+/// Rule 02(订单交付延期预警)判「交期还没到,但算出来的建议交期已经晚了」——事前。
+/// 本规则两者都不是:它判的是<b>此刻</b>的一个客观状态 ——「这张工单现在有料没备齐」。
+/// 没有时间轴,没有预测,因此既不是 TIMEOUT 也不是 OUT_OF_RANGE,而是 SHORTAGE。</para>
+///
+/// <para><b>为什么用 SHORTAGE evaluator 而不新造一个</b>:SHORTAGE 的既有判据展开后是
+/// 「<c>target - actual</c> 超过容差即命中」。把 target 取成「当前有效参与用料的物料项<b>总数</b>」、
+/// actual 取成「其中已备齐的<b>项数</b>」,缺口就正好等于<b>缺料项数</b> ——
+/// 与 evaluator 的算法逐字契合,evaluator 一行未改。仓内此前没有任何 SHORTAGE 规则定义
+/// (见 <see cref="S8ShortageRuleEvaluator"/> 类注释「当前仓内没有任何 SHORTAGE 规则定义」),
+/// <b>本规则是第一条</b>。</para>
+///
+/// <para><b>刻意不做的两件事(这两条是本规则最容易被"顺手改进"掉的地方)</b>:</para>
+///
+/// <para>① <b>不按缺料数量汇总</b>。target / actual 是<b>项数</b>,不是件数。
+/// 理由不是"数量拿不到",而是数量加总在业务上没有意义:不同物料的计量单位不同,
+/// 把「缺 3 个螺钉」和「缺 1 台电机」相加得到的 4 既不能排序也不能比较,
+/// 更会让一条缺 10000 个垫片的工单在严重度上压过一条缺关键件的工单。
+/// 工单能不能开工,取决于<b>还有没有没备齐的项</b>,不取决于欠料件数之和。</para>
+///
+/// <para>② <b>不使用预计齐套日期作为判据</b>(源侧的 kitting_time 一类字段)。
+/// 该字段已在本批取数审计中被证否 —— 它不表达「当前是否缺料」,
+/// 用它会把一个客观现状问题偷换成一个预测问题:日期没到就不报,而料实际上早就缺了。
+/// 本规则因此<b>刻意不声明</b> <c>CreateOnlyBeforeDueAt</c>(详见下方常量区注释)。</para>
+/// </summary>
+public sealed class S8MaterialShortageRuleDefinitions : IS8RuleDefinitionSource
+{
+    /// <summary>
+    /// Rule 03 编码。命名沿用 Rule 01 / Rule 02 的 <c>RULE_{模块}_{业务}</c> 格式。
+    /// <b>参与 dedup_key,全链身份</b>:任何地方引用必须用本常量,不写字面量。
+    /// </summary>
+    public const string MfgMaterialShortageCode = "RULE_S2_MFG_MATERIAL_SHORTAGE";
+
+    /// <summary>
+    /// 建单使用的异常类型。<b>已存在于种子且已启用</b>
+    /// (<c>S8ExceptionTypeSeedData</c>:制造协同物料异常 / scene S2 / SLA 60 分钟 /
+    /// 责任角色 ROLE_PRODUCTION_PLANNER / 默认 SERIOUS),
+    /// 因此本批<b>不新建异常类型、不写 migration、不覆盖种子任何字段</b>。
+    /// </summary>
+    public const string MfgMaterialShortageExceptionTypeCode = "MFG_MATERIAL_ABNORMAL";
+
+    /// <summary>
+    /// 源对象类型。<b>参与 dedup_key</b>。
+    ///
+    /// <para>是「工单」而不是「工单用料行」:业务契约就是<b>一个工单当前有缺料 → 一条工单级异常</b>。
+    /// 缺 1 项和缺 20 项都是同一件事 ——「这张工单开不了工」,交给同一个生产计划员一次性处理。
+    /// 若身份取到行级,一张工单会同时开出 20 张单子,处理人要点 20 次,
+    /// 而其中 19 次的处置动作完全相同。</para>
+    /// </summary>
+    public const string WorkOrderObjectType = "WORK_ORDER";
+
+    /// <summary>
+    /// 取数数据集编码。
+    ///
+    /// <para><b>唯一真源是数据集定义</b>,本处只做别名转发(编译期常量,无运行期开销)。
+    /// dataset_code 若两处各写一份字面量,改了一处不改另一处的后果不是编译错误,
+    /// 而是 Enable Gate 以 <c>dataset_provider_not_registered</c> 挡住启用 ——
+    /// 一个要到运行期才暴露、且报错信息指向别处的故障。</para>
+    /// </summary>
+    public const string MfgMaterialShortageDatasetCode =
+        S8MaterialShortageDatasetDefinitions.MaterialShortageCode;
+
+    public IEnumerable<S8RuleDefinition> GetDefinitions() => new[]
+    {
+        new S8RuleDefinition
+        {
+            RuleCode = MfgMaterialShortageCode,
+            DisplayName = "制造工单缺料",
+            Description =
+                "制造工单当前还有用料没有备齐时提报异常。" +
+                "判定只看工单此刻的备料状态,不做未来推算、不依赖任何预计齐套日期;" +
+                "一张工单无论缺几项都只对应一条异常,缺料项数随每轮检测刷新。",
+
+            DatasetCode = MfgMaterialShortageDatasetCode,
+            RuleType = S8ShortageRuleEvaluator.RuleTypeCode,
+
+            // 报警机制词表 = DATE / RATIO / VALUE_RANGE / MANUAL_REPORT
+            // (S8FieldSemanticsSeedData 的 rule_mechanism 字段释义)。
+            // 本规则比对的是「已备齐项数 / 应备齐项数」这一组配比,属 RATIO;
+            // 它不看任何日期(非 DATE),也不是单值越界(非 VALUE_RANGE)。
+            RuleMechanism = "RATIO",
+            SourceObjectType = WorkOrderObjectType,
+
+            SceneCode = S8ModuleCode.S2,
+            StageCode = S8ModuleCode.S2,
+
+            // 缺料在「本体生产」环节兑现:工单料没齐,生产就开不了工。
+            // 与异常类型 MFG_MATERIAL_ABNORMAL 的监控归类(本体生产)一致,
+            // 不另立归类,避免同一件事在看板上落到两个位置。
+            OrderFlowCode = "BODY_PRODUCTION",
+
+            ExceptionTypeCode = MfgMaterialShortageExceptionTypeCode,
+
+            // ────────────────────────────────────────────────────────────────
+            // 刻意**不声明** CreateOnlyBeforeDueAt(保持默认 false)。
+            //
+            // 它是 Rule 02 那种「事前预警」才需要的机制:先有一个到期日,
+            // 在它到达之前才允许开新案子。本规则根本没有到期日 ——
+            // 「现在缺料」这件事任何时刻成立都应当立案,没有"太晚了就别报了"的说法。
+            //
+            // 源侧确有一个预计齐套日期(kitting_time 一类字段),本批审计已将其证否:
+            // 它回答的是"预计什么时候能齐",不是"现在齐没齐"。若拿它当立案闸门,
+            // 会得到一个荒谬结论:日期一过,实际缺着的料反而不许再建单。
+            //
+            // 另需知道:SHORTAGE evaluator 根本不读本项(它不计算 CreateEligible),
+            // 因此即便有人日后在此写上 true,也只会得到一个"配了但不生效"的假象 ——
+            // 真要给 SHORTAGE 加立案闸门,必须先改 evaluator,而不是改这一行。
+            // ────────────────────────────────────────────────────────────────
+
+            // 面向业务管理员的只读说明。刻意不出现表名 / 列名 / SQL ——
+            // 配置页要回答的是「这条规则按什么判」,不是「它怎么实现的」。
+            JudgementSummary =
+                "统计该制造工单当前需要投入的全部物料项,只要还有任何一项没有备齐,就提报这张工单缺料。"
+                + "判定按物料项计数,不对欠料数量做加总:缺 1 项与缺一万件被视为同一件事 —— 这张工单现在开不了工。"
+                + "工单的全部投入物料都已备齐时,缺料解除。"
+                + "只统计投入物料,不统计工单自己要产出的成品 —— 成品还没造出来是排产进度问题,不是缺料。"
+                + "已作废、已关闭、或当前不再参与用料的物料项不计入判定;"
+                + "没有任何物料需求的工单不纳入本规则监控。",
+            DedupIdentitySummary = "制造工单号",
+
+            // ────────────────────────────────────────────────────────────────
+            // 判定语义。四个列名都是 canonical 名 —— Provider 直接以这些名字产出行。
+            //
+            // 语义映射(本规则的核心约定,改动等于换一套业务含义):
+            //   target_qty = 该工单当前有效参与用料的物料**项数**
+            //   actual_qty = 其中已备齐的**项数**
+            //   缺口       = target - actual = **缺料项数**
+            //
+            // 两者都是项数而非件数,理由见类注释「刻意不做的两件事」①。
+            // Provider 已按工单聚合,一个工单只产出一行 ——
+            // 同一工单跨多个 BOM 层级的用料在聚合时就已合并,evaluator 只做减法。
+            // ────────────────────────────────────────────────────────────────
+            Shortage = new S8ShortageSemantics
+            {
+                TargetQtyColumn = S8CanonicalColumns.TargetQty,
+                ActualQtyColumn = S8CanonicalColumns.ActualQty,
+
+                // 去重身份 = 制造工单号,由 Provider 放进 source_object_id。
+                // 工单号在租户内唯一,故不需要再拼行号 / 层级 —— 拼了反而会把
+                // 同一张工单的缺料拆成多条异常,与工单级契约相悖。
+                SourceObjectIdColumn = S8CanonicalColumns.SourceObjectId,
+                RelatedObjectCodeColumn = S8CanonicalColumns.RelatedObjectCode,
+
+                // ToleranceAbs = 0:缺 1 项就报。
+                // 这不是拍脑袋定的阈值 —— 0 是「齐套」这件事本身的定义:
+                // 少任何一项,工单都开不了工,没有"缺两项以内可以先干着"的业务依据。
+                // 需要宽容度时由业务给出项数,再改这一处。
+                ToleranceAbs = 0m,
+
+                // ToleranceRatio = 0:同上,没有任何业务依据说「缺料比例低于 X% 可以不报」。
+                // 凭空填一个 0.05 会让 20 项里缺 1 项的工单被静默过滤 ——
+                // 而那一项完全可能是关键件。
+                ToleranceRatio = 0m
+            },
+
+            Parameters = new S8RuleParameterPolicy
+            {
+                // 轮询间隔按**本异常类型的 SLA** 反推,而不是照抄别的规则:
+                // MFG_MATERIAL_ABNORMAL 的 SLA 是 60 分钟。若轮询间隔取 3600 秒,
+                // 最坏情况下缺料发生 1 小时后才立案,SLA 在立案那一刻就已耗尽。
+                // 取 600 秒可在 SLA 窗口内留出约 6 个检测周期,同时避免分钟级空转。
+                PollIntervalSecondsDefault = 600,
+
+                TriggerCountRequiredDefault = 1,
+
+                // 发料 / 到货事务提交前后,齐套判定会短暂跳变(备料完成的瞬间尤为明显)。
+                // 连续两轮不命中才判恢复,避免一次发料把异常冲掉,下一轮又建回来。
+                RecoverCountRequiredDefault = 2,
+
+                // 宽限分钟是 TIMEOUT 的概念,SHORTAGE evaluator 根本不读它。
+                // 保持 0,不给管理员一个改了不生效的旋钮。
+                GraceMinutesDefault = 0,
+
+                // 与异常类型自身的默认严重度(SERIOUS)一致:缺料直接卡住开工,
+                // 不是"关注一下"级别的事。刻意不与异常类型的默认值分叉 ——
+                // 同一件事在两处配出两个严重度,现场只会各自解读。
+                SeverityDefault = S8SeverityCode.Serious,
+
+                AllowsDepartmentDefaults = true,
+
+                // S8-RULE-READINESS-1:本规则的数据集<b>没有部门列</b> ——
+                // 工单用料数据带不出发生部门与责任部门,命中行给不了
+                // occurrence / responsible,建单时只能取这两个运行参数。
+                // 不配就是「启用成功、调度成功、异常一条建不出来」的静默失败。
+                RequiresDepartmentDefaultsForEnable = true,
+
+                // 以下三条按本规则异常的实际去向逐条确认,不是照抄 Rule 01 / Rule 02:
+                //
+                // · 处理池:异常带 source_rule_code,认领 / 派单走本规则的处理池。
+                //   空池 = 缺料异常源源不断建出来、躺在列表里没人认领,而调度器每轮都报成功。
+                //
+                // · 复核池:S8VerifierEligibility 对「有来源规则」的异常,检验人候选**只**取
+                //   该规则的复核池。空池 → 候选列表为空 → 处理人点不了「提交复核」,
+                //   单据永远停在 IN_PROGRESS。缺料的处置结果(补料 / 改期 / 换料)必须有人复核,
+                //   否则「说补了」和「真补了」在系统里没有区别。
+                //
+                // · 升级池:本异常类型 SLA = 60 分钟 → 建单即写 sla_deadline →
+                //   必然进入 S8TimeoutAutoEscalationService 的扫描范围。空池时该服务只会记一行
+                //   reason=escalation_pool_empty 然后跳过,超时无人知晓。
+                RequiresHandlerPoolForEnable = true,
+                RequiresVerification = true,
+                SupportsTimeoutEscalation = true
+            }
+        }
+    };
+}

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -87,6 +87,12 @@ public class Startup : AppStartup
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8OrderDeliveryDatasetDefinitions>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8MonitoringDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8OrderDeliveryDataProvider>();
 
+        // S8-RULE03-MATERIAL-SHORTAGE:第三个真实业务数据集 MFG_MATERIAL_SHORTAGE。
+        // 同上:定义与实现两个接口都要显式注册,DatasetCode 必须逐字一致
+        // (规则定义侧已改为编译期引用本数据集常量,不再各写一份字面量)。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8MaterialShortageDatasetDefinitions>();
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8MonitoringDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8MaterialShortageDataProvider>();
+
         // S8-RULE-GOVERNANCE-BATCH1:规则定义目录。业务语义从此只存在于代码里,
         // ado_s8_watch_rule 上的同名列降级为 provisioning 维护的只读投影。
         //
@@ -95,6 +101,7 @@ public class Startup : AppStartup
         // Singleton + Configure 里强制解析一次 = 定义写错就起不来,见下方 Configure。
         services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8PurchaseDeliveryRuleDefinitions>();
         services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8OrderDeliveryRuleDefinitions>();
+        services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8MaterialShortageRuleDefinitions>();
         services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleCatalog, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8RuleCatalog>();
 
         // S8-RULE-GOVERNANCE-BATCH2:代码定义 → 每租户运行策略的供给服务。