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

feat(s8): add stage3 procurement read model | server 1.0.546

Stage-3 一直是空的:真实订单详情把 Stage 3~5 硬写成 null,页面上唯一有内容的
ado_s8_order_flow_procurement_pivot 是 100% SEED(17 个虚构单号、0 命中真实订单、
租户号还比真实租户多 1)。这一批把它换成真数据。

GET /api/aidop/s8/order-flow/orders/by-id/{salesOrderId}/stage3
  summary + lines 一次返回,query-time 组合,不新建任何读模型表 ——
  S8 REAL 路径的既定约定就是活查中台,ado_s8_order_flow_* 只留给 SEED。

三类事实来自标准层:采购身份 mdp_std_purchase_order、WMS 采购完成态
mdp_std_purchase_order_completion(1.0.544)、来料检验 mdp_std_iqc_inspection(1.0.541),
归属链另用 mdp_std_purchase_request / mdp_std_so,供应商名用 mdp_std_supplier。
不读贴源层、不读源业务表、不连 WMS 源库、不回退 SEED。

返回的是 ATTRIBUTED SUBSET,dataScope 恒为 ATTRIBUTED_ONLY。
归属走 purchase_request_no → PR.sales_order_entry_id → SO.order_entry_id,
与采购身份桥同口径,并遵守其明文禁令不走 work_order(采购标准层 208/451 行是
字面量 'null')。COUNT(DISTINCT order_id)=1 才进入订单,多义与归属不到的采购对象
绝不塞进任何订单 —— 全库实测 85 ATTRIBUTED / 364 UNATTRIBUTED / 0 AMBIGUOUS。
因此本接口不宣称 Stage-3 完成,百分比字段刻意叫 knownObjectCompletionRate。

最容易出错的是「缺数据」与「未完成」的区别,三处都守住了:
完成态事实缺行 → NOT_OBSERVED 而不是 NOT_COMPLETED;检验单归属不唯一或多张 →
NOT_OBSERVED / MULTIPLE_UNRESOLVED 而不是替业务取最新或任一通过;两轴任一缺数据 →
对象态 UNKNOWN 而不是未完成。状态派生全放 C# 纯函数,SQL 不藏业务规则。

修了一个会静默放大行数的缺陷:mdp_std_purchase_request.pr_no 不是唯一键(还有
pr_line),外层直接 JOIN 会按申请明细条数复制采购行,改为先聚合成 PrAgg。
SQL 别名一律 PascalCase —— 本库 EnableUnderLine=false,snake_case 别名会静默
映射成 null 而行数正常,测试里加了正则断言守着。

租户只来自 AidopTenantScope.ResolveOrThrow,DTO 无 tenantId 字段,
4 个 CTE 各自带租户谓词,8 处 JOIN 全部租户对齐。

Runtime 实测(超管切租户后调 API,逐项与直连数据库同口径核对):
SO202608240001 → 8 行,PO202608260002 L1 采购已完成、无唯一检验单故对象态停在
UNKNOWN;SO202609030004 → 6 行;SO202608100001 → 7 行中 2 行 NOT_OBSERVED、
5 行 NOT_COMPLETED,三态可区分。40 条契约测试 + 137 条扩大回归全过。
YY968XX 4 дней назад
Родитель
Сommit
52341dcc3c

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.545</AssemblyVersion>
-    <FileVersion>1.0.545</FileVersion>
-    <Version>1.0.545</Version>
+    <AssemblyVersion>1.0.546</AssemblyVersion>
+    <FileVersion>1.0.546</FileVersion>
+    <Version>1.0.546</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 407 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/Supply/S8Stage3ProcurementReadModelTests.cs

@@ -0,0 +1,407 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
+using Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8.Supply;
+
+using S = S8Stage3ProcurementReadService;
+using C = AdoS8Stage3Const;
+
+/// <summary>
+/// S8 Stage-3 最小只读模型的生产契约。
+///
+/// <para>这个模型第一次把三类事实拼在一起:采购身份、WMS 采购完成态、来料检验质量。
+/// 它最危险的失败模式不是报错,而是<b>安静地把「没有数据」说成「没做完」</b>,
+/// 或者把归属不到订单的采购对象偷偷塞进某张订单。本文件守住这些边界:</para>
+///
+/// <list type="number">
+///   <item>缺数据 ≠ 未完成 —— 完成态事实缺失是 NOT_OBSERVED,对象态是 UNKNOWN;</item>
+///   <item>两轴独立 —— 质量合格不能让采购未完成的行变成已完成;</item>
+///   <item>多义不消解 —— 多张检验单一律 MULTIPLE_UNRESOLVED,不取最新/不取任一通过;</item>
+///   <item>归属唯一才用 —— 多订单归属的采购行不进任何订单;</item>
+///   <item>只消费中台 —— 不碰贴源层、源业务表、WMS 源库与 SEED 表。</item>
+/// </list>
+/// </summary>
+public class S8Stage3ProcurementReadModelTests
+{
+    private static string Source(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    private static string Service() => Source("Service", "S8", "OrderFlow", "S8Stage3ProcurementReadService.cs");
+
+    /// <summary>去掉 XML 文档注释后的代码体 —— 文档需要点名被禁的东西来说明边界,那是说明不是违规。</summary>
+    private static string ServiceBody()
+        => string.Join('\n', Service().Split('\n')
+            .Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
+
+    private static string Sql()
+    {
+        var code = Service();
+        var i = code.IndexOf("private const string LineSql", StringComparison.Ordinal);
+        Assert.True(i >= 0, "未找到 LineSql");
+        return code[i..];
+    }
+
+    // ── ① 采购完成态映射(§三十二 5-8)────────────────────────────────────────────
+
+    [Fact]
+    public void 完成态_COMPLETED()
+        => Assert.Equal(C.ProcurementCompleted, S.MapProcurementStatus("COMPLETED"));
+
+    [Fact]
+    public void 完成态_NOT_COMPLETED()
+        => Assert.Equal(C.ProcurementNotCompleted, S.MapProcurementStatus("NOT_COMPLETED"));
+
+    [Fact]
+    public void 完成态_UNKNOWN()
+        => Assert.Equal(C.ProcurementUnknown, S.MapProcurementStatus("UNKNOWN"));
+
+    [Fact]
+    public void 完成态事实缺失必须是NOT_OBSERVED而不是未完成()
+    {
+        // WMS 侧没有这条采购行,通常只是「还没推过去」,把它说成未完成是造假
+        Assert.Equal(C.ProcurementNotObserved, S.MapProcurementStatus(null));
+        Assert.NotEqual(C.ProcurementNotCompleted, S.MapProcurementStatus(null));
+    }
+
+    [Fact]
+    public void 完成态取值域封闭()
+    {
+        var allowed = new[] { C.ProcurementCompleted, C.ProcurementNotCompleted, C.ProcurementUnknown, C.ProcurementNotObserved };
+        foreach (var raw in new string?[] { null, "", "COMPLETED", "NOT_COMPLETED", "UNKNOWN", "怪值" })
+            Assert.Contains(S.MapProcurementStatus(raw), allowed);
+    }
+
+    // ── ② IQC 归属(§三十二 2-4)──────────────────────────────────────────────────
+
+    [Fact]
+    public void 候选唯一且有检验单才算UNIQUE()
+        => Assert.Equal(C.IqcUnique, S.MapIqcAttribution(1, 1));
+
+    [Fact]
+    public void 没有检验单是UNATTRIBUTED()
+        => Assert.Equal(C.IqcUnattributed, S.MapIqcAttribution(1, 0));
+
+    [Fact]
+    public void 候选多条是AMBIGUOUS_不得任选一行()
+        => Assert.Equal(C.IqcAmbiguous, S.MapIqcAttribution(3, 2));
+
+    [Fact]
+    public void 候选为零也不得归属()
+        => Assert.Equal(C.IqcAmbiguous, S.MapIqcAttribution(0, 1));
+
+    // ── ③ 质量映射(§三十二 9-13)────────────────────────────────────────────────
+
+    [Fact]
+    public void 唯一归属且终态PASS_是ACCEPTED()
+        => Assert.Equal(C.QualityAccepted, S.MapQualityStatus(C.IqcUnique, 1, 1, 0));
+
+    [Fact]
+    public void 唯一归属且终态FAIL_是REJECTED()
+        => Assert.Equal(C.QualityRejected, S.MapQualityStatus(C.IqcUnique, 1, 0, 1));
+
+    [Fact]
+    public void 唯一归属但流程未终态_是PENDING()
+        => Assert.Equal(C.QualityPending, S.MapQualityStatus(C.IqcUnique, 1, 0, 0));
+
+    [Fact]
+    public void 零检验单_是NOT_OBSERVED()
+        => Assert.Equal(C.QualityNotObserved, S.MapQualityStatus(C.IqcUnattributed, 0, 0, 0));
+
+    [Fact]
+    public void 多检验单_必须MULTIPLE_UNRESOLVED而不是任一通过()
+    {
+        // 归并规则(取最新 / 任一通过 / 复检覆盖)未由业务冻结,读模型不得替业务拍板
+        Assert.Equal(C.QualityMultipleUnresolved, S.MapQualityStatus(C.IqcUnique, 2, 2, 0));
+        Assert.Equal(C.QualityMultipleUnresolved, S.MapQualityStatus(C.IqcUnique, 3, 1, 2));
+    }
+
+    [Fact]
+    public void 归属多义时质量不得被采纳()
+        => Assert.Equal(C.QualityNotObserved, S.MapQualityStatus(C.IqcAmbiguous, 2, 2, 0));
+
+    // ── ④ 对象态(§三十二 14-16)────────────────────────────────────────────────
+
+    [Fact]
+    public void 采购完成且质量合格_才是COMPLETED()
+        => Assert.Equal(C.ObjectCompleted, S.MapObjectStatus(C.ProcurementCompleted, C.QualityAccepted));
+
+    [Fact]
+    public void 采购完成但质量未观测_必须UNKNOWN而不是完成()
+    {
+        // 这是本模型最重要的一条:不能因为采购做完了就宣称对象完成
+        Assert.Equal(C.ObjectUnknown, S.MapObjectStatus(C.ProcurementCompleted, C.QualityNotObserved));
+    }
+
+    [Fact]
+    public void 质量合格但采购未完成_是NOT_COMPLETED而不是完成()
+    {
+        // 两轴独立的关键回归:IQC202608110001/2 正是这种形状
+        Assert.Equal(C.ObjectNotCompleted, S.MapObjectStatus(C.ProcurementNotCompleted, C.QualityAccepted));
+    }
+
+    [Fact]
+    public void 缺数据一律UNKNOWN_绝不当成未完成()
+    {
+        foreach (var (p, q) in new[]
+                 {
+                     (C.ProcurementNotObserved, C.QualityAccepted),
+                     (C.ProcurementUnknown, C.QualityAccepted),
+                     (C.ProcurementCompleted, C.QualityMultipleUnresolved),
+                     (C.ProcurementNotCompleted, C.QualityNotObserved),
+                 })
+            Assert.Equal(C.ObjectUnknown, S.MapObjectStatus(p, q));
+    }
+
+    [Fact]
+    public void 采购完成且质量不合格_是NOT_COMPLETED()
+        => Assert.Equal(C.ObjectNotCompleted, S.MapObjectStatus(C.ProcurementCompleted, C.QualityRejected));
+
+    [Fact]
+    public void 对象态取值域封闭()
+    {
+        var allowed = new[] { C.ObjectCompleted, C.ObjectNotCompleted, C.ObjectUnknown };
+        foreach (var p in new[] { C.ProcurementCompleted, C.ProcurementNotCompleted, C.ProcurementUnknown, C.ProcurementNotObserved })
+            foreach (var q in new[] { C.QualityAccepted, C.QualityRejected, C.QualityPending, C.QualityNotObserved, C.QualityMultipleUnresolved })
+                Assert.Contains(S.MapObjectStatus(p, q), allowed);
+    }
+
+    // ── ⑤ 租户隔离(§三十二 1)──────────────────────────────────────────────────
+
+    [Fact]
+    public void 租户必须来自服务端上下文()
+    {
+        var code = ServiceBody();
+        Assert.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", code, StringComparison.Ordinal);
+        // 不得由调用方传租户
+        Assert.DoesNotContain("long tenantId,", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("[FromQuery]", code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 所有业务JOIN必须显式对齐租户()
+    {
+        var sql = Sql();
+        foreach (var join in new[]
+                 {
+                     "p.tenant_id = o.tenant_id", "s.tenant_id = p.tenant_id",
+                     "o.tenant_id = a.TenantId", "pa.TenantId = o.tenant_id",
+                     "sup.tenant_id = o.tenant_id", "c.tenant_id = o.tenant_id",
+                     "cd.TenantId = o.tenant_id", "ia.TenantId = o.tenant_id",
+                 })
+            Assert.Contains(join, sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 每个CTE都必须自带租户过滤()
+    {
+        var sql = Sql();
+        Assert.Equal(4, CountOccurrences(sql, "tenant_id = @TenantId"));
+        Assert.Contains("a.TenantId = @TenantId", sql, StringComparison.Ordinal);
+    }
+
+    private static int CountOccurrences(string haystack, string needle)
+    {
+        var n = 0;
+        for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
+             i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
+            n++;
+        return n;
+    }
+
+    // ── ⑥ 订单归属(§三十二 2-4、§十二)─────────────────────────────────────────
+
+    [Fact]
+    public void 多订单归属的采购行不得进入任何订单()
+    {
+        var sql = Sql();
+        Assert.Contains("COUNT(DISTINCT s.order_id)", sql, StringComparison.Ordinal);
+        Assert.Contains("a.OrderCount = 1", sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 归属链走采购申请号_禁止用work_order关联()
+    {
+        var sql = Sql();
+        Assert.Contains("p.pr_no = o.purchase_request_no", sql, StringComparison.Ordinal);
+        Assert.Contains("s.order_entry_id = p.sales_order_entry_id", sql, StringComparison.Ordinal);
+        // 采购标准层的 work_order 含大量字面量 'null',绝不可作关联键
+        Assert.DoesNotContain("= o.work_order", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("o.work_order =", sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 禁止用排序取一条解决多义()
+    {
+        var sql = Sql();
+        Assert.DoesNotContain("LIMIT 1", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("ORDER BY q.", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("MAX(q.", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("MIN(q.", sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 采购申请必须先聚合再JOIN_防止行翻倍()
+    {
+        var sql = Sql();
+        // pr_no 不是唯一键(还有 pr_line),直接 JOIN 会按申请明细条数放大采购行
+        Assert.Contains("PrAgg AS (", sql, StringComparison.Ordinal);
+        Assert.Contains("GROUP BY tenant_id, pr_no", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("LEFT JOIN mdp_std_purchase_request pr", sql, StringComparison.Ordinal);
+    }
+
+    // ── ⑦ IQC 归属键(§十四)────────────────────────────────────────────────────
+
+    [Fact]
+    public void IQC只按采购单号加物料归属()
+    {
+        var sql = Sql();
+        Assert.Contains("TRIM(q.purchase_order_no)", sql, StringComparison.Ordinal);
+        Assert.Contains("TRIM(q.material_code)", sql, StringComparison.Ordinal);
+        // 禁止用这些字段增强匹配
+        foreach (var forbidden in new[] { "q.receipt_no", "q.supplier_code", "q.lot_serial", "q.completion_time" })
+            Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
+    }
+
+    // ── ⑧ 数据来源白名单(§三十三、§三十二 17-20)──────────────────────────────
+
+    [Fact]
+    public void 只读数据中台标准层()
+    {
+        var sql = Sql();
+        var allowed = new[]
+        {
+            "mdp_std_purchase_order", "mdp_std_purchase_order_completion",
+            "mdp_std_iqc_inspection", "mdp_std_purchase_request", "mdp_std_so", "mdp_std_supplier",
+        };
+        foreach (var t in allowed)
+            Assert.Contains(t, sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 禁止贴源层与源业务表与SEED()
+    {
+        var code = ServiceBody();
+        foreach (var forbidden in new[]
+                 {
+                     "mdp_stg_", "PurOrdDetail", "PurOrdMaster", "qms_qcp_",
+                     "DOPDEMORQ_SQLSERVER", "ado_s8_order_flow_procurement_pivot",
+                     "mes_moentry", "MdpSourceScopeFactory",
+                 })
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 禁止用数量重新推导完成()
+    {
+        var code = ServiceBody();
+        foreach (var forbidden in new[] { "received_qty", "receipt_qty", ">= order_qty", "RctQty" })
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 纯读_不得有任何写操作()
+    {
+        var code = ServiceBody();
+        foreach (var forbidden in new[] { "INSERT ", "UPDATE ", "DELETE ", "ExecuteCommandAsync" })
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+    }
+
+    // ── ⑨ 不得输出完成时间与周期(§十八、§十九)────────────────────────────────
+
+    [Fact]
+    public void 不得输出任何完成时间()
+    {
+        var code = ServiceBody();
+        var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
+        foreach (var forbidden in new[]
+                 {
+                     "CompletionTime", "completion_time", "ProcurementCompletionTime",
+                     "Stage3CompletionTime", "observed_at", "source_update_time",
+                 })
+        {
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+            Assert.DoesNotContain(forbidden, dto, StringComparison.Ordinal);
+        }
+    }
+
+    [Fact]
+    public void 不得输出周期类字段()
+    {
+        var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
+        foreach (var forbidden in new[] { "ActualDays", "PlannedDays", "ElapsedDays", "OnTime", "Delayed", "Warning" })
+            Assert.DoesNotContain(forbidden, dto, StringComparison.Ordinal);
+    }
+
+    // ── ⑩ 范围诚实性(§二十、§二十一、§二十二)────────────────────────────────
+
+    [Fact]
+    public void 必须声明ATTRIBUTED_ONLY范围()
+    {
+        var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
+        Assert.Contains("ScopeAttributedOnly = \"ATTRIBUTED_ONLY\"", dto, StringComparison.Ordinal);
+        Assert.Contains("public string DataScope", dto, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 不得宣称Stage3完成或给出裸完成率()
+    {
+        var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
+        Assert.DoesNotContain("Stage3Complete", dto, StringComparison.Ordinal);
+        // 百分比字段必须带 Known 前缀,避免被当成业务完成率
+        Assert.Contains("KnownObjectCompletionRate", dto, StringComparison.Ordinal);
+        Assert.DoesNotContain("public decimal? CompletionRate", dto, StringComparison.Ordinal);
+        Assert.DoesNotContain("public decimal CompletionRate", dto, StringComparison.Ordinal);
+    }
+
+    // ── ⑪ API 形状(§二十七)────────────────────────────────────────────────────
+
+    [Fact]
+    public void 端点复用既有S8订单路由与权限()
+    {
+        var ctrl = Source("Controllers", "S8", "AdoS8OrderFlowController.cs");
+        Assert.Contains("[HttpGet(\"orders/by-id/{salesOrderId}/stage3\")]", ctrl, StringComparison.Ordinal);
+        Assert.Contains("[S8Permission(S8PermissionCatalog.DashboardRead)]", ctrl, StringComparison.Ordinal);
+        Assert.Contains("_stage3Svc.GetStage3Async(salesOrderId", ctrl, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void SQL别名必须PascalCase()
+    {
+        // 本库 EnableUnderLine=false:snake_case 别名会静默映射成 null 而行数正常
+        var sql = Sql();
+        var aliases = System.Text.RegularExpressions.Regex
+            .Matches(sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)")
+            .Select(m => m.Groups[1].Value)
+            .Distinct()
+            .ToList();
+        Assert.NotEmpty(aliases);
+        var bad = aliases.Where(a => a.Contains('_') || !char.IsUpper(a[0])).ToList();
+        Assert.True(bad.Count == 0, "以下别名不是 PascalCase:" + string.Join(", ", bad));
+    }
+
+    // ── ⑫ 上游批次资产不得回归 ──────────────────────────────────────────────────
+
+    [Fact]
+    public void 完成态事实表与IQC事实表的唯一键不得被本批改动()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        var scripts = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts");
+
+        Assert.Contains("UNIQUE KEY `uk_mdp_std_po_completion` (`tenant_id`, `po_no`, `po_line`)",
+            File.ReadAllText(Path.Combine(scripts, "1.0.544.sql")), StringComparison.Ordinal);
+        Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)",
+            File.ReadAllText(Path.Combine(scripts, "1.0.541.sql")), StringComparison.Ordinal);
+    }
+}

+ 22 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8OrderFlowController.cs

@@ -17,11 +17,16 @@ public class AdoS8OrderFlowController : ControllerBase
 {
     private readonly S8OrderFlowService _svc;
     private readonly S8RealOrderArchiveService _realSvc;
+    private readonly S8Stage3ProcurementReadService _stage3Svc;
 
-    public AdoS8OrderFlowController(S8OrderFlowService svc, S8RealOrderArchiveService realSvc)
+    public AdoS8OrderFlowController(
+        S8OrderFlowService svc,
+        S8RealOrderArchiveService realSvc,
+        S8Stage3ProcurementReadService stage3Svc)
     {
         _svc = svc;
         _realSvc = realSvc;
+        _stage3Svc = stage3Svc;
     }
 
     /// <summary>
@@ -46,6 +51,22 @@ public class AdoS8OrderFlowController : ControllerBase
     public async Task<IActionResult> RealOrderDetailAsync(long salesOrderId)
         => Ok(await _realSvc.GetRealOrderAsync(salesOrderId));
 
+    /// <summary>
+    /// Stage-3(材料采购)只读模型 —— <b>REAL runtime</b>,query-time 组合数据中台标准层:
+    /// <c>mdp_std_purchase_order</c>(采购身份)× <c>mdp_std_purchase_order_completion</c>(WMS 采购完成态)
+    /// × <c>mdp_std_iqc_inspection</c>(来料检验质量)。
+    ///
+    /// <para><b>返回的是 ATTRIBUTED SUBSET</b>:只含经「采购行 → 采购申请 → 销售订单行」唯一归属到本订单
+    /// 的采购行,<c>dataScope</c> 恒为 <c>ATTRIBUTED_ONLY</c>。归属不到或多义的采购对象不会被塞进任何订单,
+    /// 因此本接口<b>不宣称</b>该订单采购已完整完成,也不输出完成时间与周期天数。</para>
+    ///
+    /// <para>定位 Authority = 当前租户 + salesOrderId(= <c>crm_seorder.Id</c>),与上面的订单详情同口径。</para>
+    /// </summary>
+    [HttpGet("orders/by-id/{salesOrderId}/stage3")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
+    public async Task<IActionResult> RealOrderStage3Async(long salesOrderId, CancellationToken cancellationToken = default)
+        => Ok(await _stage3Svc.GetStage3Async(salesOrderId, cancellationToken));
+
     /// <summary>
     /// 单订单详情(SEED 路径,按 orderCode 定位)。
     /// <para>⚠️ 仅供 SEED fixture / 链路全景等既有消费方;<b>REAL runtime 不得回退到这里</b>。</para>

+ 137 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/OrderFlow/AdoS8Stage3ProcurementDtos.cs

@@ -0,0 +1,137 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
+
+/// <summary>
+/// S8 Stage-3(材料采购)只读模型:一张销售订单 → 已可靠归属的采购行 + 采购完成 + 质量。
+///
+/// <para><b>这是 ATTRIBUTED SUBSET,不是完整采购全集。</b><see cref="DataScope"/> 恒为
+/// <c>ATTRIBUTED_ONLY</c>:只有经 <c>PO → 采购申请 → 销售订单行</c> 唯一归属成功的采购行才在这里,
+/// 归属不到或多义的采购对象<b>不会</b>被塞进任何订单。因此本模型<b>不能</b>用来宣称
+/// 「该订单的采购已 100% 完成」—— 完整 Required Set 尚未冻结。</para>
+/// </summary>
+public class AdoS8Stage3ProcurementDto
+{
+    /// <summary>销售订单 id(= mdp_std_so.order_id)。定位 Authority = 当前租户 + 该 id。</summary>
+    public long SalesOrderId { get; set; }
+
+    /// <summary>销售订单号(展示用文本,非定位依据)。</summary>
+    public string? SalesOrderNo { get; set; }
+
+    /// <summary>数据范围,恒为 <c>ATTRIBUTED_ONLY</c>。消费方必须据此避免误认为拿到了采购全集。</summary>
+    public string DataScope { get; set; } = AdoS8Stage3Const.ScopeAttributedOnly;
+
+    public AdoS8Stage3ProcurementSummaryDto Summary { get; set; } = new();
+
+    public List<AdoS8Stage3ProcurementLineDto> Lines { get; set; } = new();
+}
+
+/// <summary>Stage-3 汇总。所有计数都只统计已归属到本订单的采购行。</summary>
+public class AdoS8Stage3ProcurementSummaryDto
+{
+    /// <summary>已唯一归属到本订单的采购行数(本汇总的分母)。</summary>
+    public int AttributedPoLineCount { get; set; }
+
+    public int ProcurementCompletedCount { get; set; }
+    public int ProcurementNotCompletedCount { get; set; }
+    public int ProcurementUnknownCount { get; set; }
+
+    /// <summary>WMS 侧根本没有这条采购行的记录 —— 与「已确认未完成」是两回事。</summary>
+    public int ProcurementNotObservedCount { get; set; }
+
+    public int QualityAcceptedCount { get; set; }
+    public int QualityRejectedCount { get; set; }
+    public int QualityPendingCount { get; set; }
+    public int QualityNotObservedCount { get; set; }
+
+    /// <summary>同一采购行命中多张可归属检验单,业务归并规则未冻结,本版不自动消解。</summary>
+    public int QualityMultipleUnresolvedCount { get; set; }
+
+    public int ObjectCompletedCount { get; set; }
+    public int ObjectNotCompletedCount { get; set; }
+    public int ObjectUnknownCount { get; set; }
+
+    /// <summary>存在质量多义未消解的行。</summary>
+    public bool HasUnresolvedQuality { get; set; }
+
+    /// <summary>存在采购态未知或未观测的行 —— 此时任何「完成率」都不完整。</summary>
+    public bool HasUnknownProcurement { get; set; }
+
+    /// <summary>
+    /// 已知对象完成率 = <see cref="ObjectCompletedCount"/> / <see cref="AttributedPoLineCount"/>。
+    /// <para><b>刻意带 Known 前缀</b>:分母只是已归属子集,不是订单的采购全集,
+    /// 绝不可当作业务完成率展示。</para>
+    /// </summary>
+    public decimal? KnownObjectCompletionRate { get; set; }
+}
+
+/// <summary>Stage-3 采购行。字段只来自数据中台标准层;标准层没有的一律为 null,不回源表补。</summary>
+public class AdoS8Stage3ProcurementLineDto
+{
+    public string? SalesOrderNo { get; set; }
+    public long? SalesOrderEntryId { get; set; }
+
+    public string PoNo { get; set; } = string.Empty;
+    public string PoLine { get; set; } = string.Empty;
+
+    public string? PurchaseRequestNo { get; set; }
+
+    /// <summary>工单号,取自采购申请标准层(已清洗字面量 'null')。仅作上下文,<b>不参与归属</b>。</summary>
+    public string? WorkOrder { get; set; }
+
+    public string? MaterialCode { get; set; }
+    public string? MaterialName { get; set; }
+
+    /// <summary>规格:采购标准层无此字段,恒为 null。</summary>
+    public string? Specification { get; set; }
+
+    public string? SupplierCode { get; set; }
+    public string? SupplierName { get; set; }
+
+    public decimal? OrderQty { get; set; }
+    public DateTime? DueDate { get; set; }
+
+    /// <summary>COMPLETED / NOT_COMPLETED / UNKNOWN / NOT_OBSERVED。</summary>
+    public string ProcurementStatus { get; set; } = AdoS8Stage3Const.ProcurementNotObserved;
+
+    /// <summary>UNIQUE / UNATTRIBUTED / AMBIGUOUS —— 检验单能否唯一落到本采购行。</summary>
+    public string IqcAttributionStatus { get; set; } = AdoS8Stage3Const.IqcUnattributed;
+
+    /// <summary>命中本行 (采购单号 + 物料) 的检验单数量。</summary>
+    public int IqcBillCount { get; set; }
+
+    /// <summary>ACCEPTED / REJECTED / PENDING / NOT_OBSERVED / MULTIPLE_UNRESOLVED。</summary>
+    public string QualityStatus { get; set; } = AdoS8Stage3Const.QualityNotObserved;
+
+    /// <summary>COMPLETED / NOT_COMPLETED / UNKNOWN。缺数据一律 UNKNOWN,绝不当成未完成。</summary>
+    public string ObjectStatus { get; set; } = AdoS8Stage3Const.ObjectUnknown;
+
+    /// <summary>WMS 侧是否存在该采购行的完成态事实行。</summary>
+    public bool CompletionFactObserved { get; set; }
+
+    /// <summary>是否存在可唯一归属到本行的检验单。</summary>
+    public bool QualityFactObserved { get; set; }
+}
+
+/// <summary>Stage-3 只读模型的取值域。集中在此,避免各处散落字面量。</summary>
+public static class AdoS8Stage3Const
+{
+    public const string ScopeAttributedOnly = "ATTRIBUTED_ONLY";
+
+    public const string ProcurementCompleted = "COMPLETED";
+    public const string ProcurementNotCompleted = "NOT_COMPLETED";
+    public const string ProcurementUnknown = "UNKNOWN";
+    public const string ProcurementNotObserved = "NOT_OBSERVED";
+
+    public const string IqcUnique = "UNIQUE";
+    public const string IqcUnattributed = "UNATTRIBUTED";
+    public const string IqcAmbiguous = "AMBIGUOUS";
+
+    public const string QualityAccepted = "ACCEPTED";
+    public const string QualityRejected = "REJECTED";
+    public const string QualityPending = "PENDING";
+    public const string QualityNotObserved = "NOT_OBSERVED";
+    public const string QualityMultipleUnresolved = "MULTIPLE_UNRESOLVED";
+
+    public const string ObjectCompleted = "COMPLETED";
+    public const string ObjectNotCompleted = "NOT_COMPLETED";
+    public const string ObjectUnknown = "UNKNOWN";
+}

+ 334 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/OrderFlow/S8Stage3ProcurementReadService.cs

@@ -0,0 +1,334 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
+
+/// <summary>
+/// S8 Stage-3(材料采购)只读模型 —— <b>REAL runtime,query-time,无持久化读模型表</b>。
+///
+/// <para>把三类已确权的中台事实组合起来:
+/// <list type="number">
+///   <item>采购身份与上下文 → <c>mdp_std_purchase_order</c></item>
+///   <item>采购完成态(WMS 侧)→ <c>mdp_std_purchase_order_completion</c>(1.0.544)</item>
+///   <item>来料检验质量 → <c>mdp_std_iqc_inspection</c>(1.0.541)</item>
+/// </list>
+/// 归属链另用 <c>mdp_std_purchase_request</c> / <c>mdp_std_so</c>,供应商名用 <c>mdp_std_supplier</c>。</para>
+///
+/// <para><b>只消费中台</b>:本服务不读 <c>mdp_stg_*</c>、不读 <c>PurOrdDetail</c> / <c>qms_qcp_*</c> 等源表、
+/// 不连 WMS 源库、也不读 SEED 的 <c>ado_s8_order_flow_procurement_pivot</c>。</para>
+///
+/// <para><b>ATTRIBUTED SUBSET,不是完成引擎</b>:只返回能唯一归属到该销售订单的采购行。
+/// 归属不到(UNATTRIBUTED)或多义(AMBIGUOUS)的采购对象绝不塞进任何订单,
+/// 因此本模型不宣称 Stage-3 完整完成,也不输出完成时间与周期。</para>
+/// </summary>
+public class S8Stage3ProcurementReadService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public S8Stage3ProcurementReadService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    /// <summary>
+    /// 取某张销售订单的 Stage-3 只读模型。
+    /// 定位 Authority = 当前租户 + <paramref name="salesOrderId"/>(= <c>mdp_std_so.order_id</c>),
+    /// 与 S8 既有 REAL 订单详情口径一致,不用订单号文本定位。
+    /// </summary>
+    public async Task<AdoS8Stage3ProcurementDto> GetStage3Async(
+        long salesOrderId, CancellationToken cancellationToken = default)
+    {
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+
+        var rows = await _db.Ado.SqlQueryAsync<Stage3Row>(LineSql, new List<SugarParameter>
+        {
+            new("@TenantId", tenantId),
+            new("@SalesOrderId", salesOrderId),
+        });
+
+        var dto = new AdoS8Stage3ProcurementDto
+        {
+            SalesOrderId = salesOrderId,
+            SalesOrderNo = rows.FirstOrDefault()?.SalesOrderNo,
+            DataScope = AdoS8Stage3Const.ScopeAttributedOnly,
+        };
+
+        foreach (var r in rows)
+            dto.Lines.Add(ToLine(r));
+
+        dto.Summary = Summarize(dto.Lines);
+        return dto;
+    }
+
+    // ── 状态派生:全部是纯函数,便于单测,且保证 SQL 侧不偷偷拍板业务规则 ──────────────
+
+    /// <summary>
+    /// 采购完成态。<b>没有事实行 ≠ 未完成</b>:WMS 侧压根没有这条采购行时是 NOT_OBSERVED,
+    /// 把它当成 NOT_COMPLETED 会让下游把「还没推过去」误读成「确认没做完」。
+    /// </summary>
+    public static string MapProcurementStatus(string? completionStatus)
+    {
+        if (completionStatus is null) return AdoS8Stage3Const.ProcurementNotObserved;
+        return completionStatus.Trim() switch
+        {
+            AdoS8Stage3Const.ProcurementCompleted => AdoS8Stage3Const.ProcurementCompleted,
+            AdoS8Stage3Const.ProcurementNotCompleted => AdoS8Stage3Const.ProcurementNotCompleted,
+            _ => AdoS8Stage3Const.ProcurementUnknown,
+        };
+    }
+
+    /// <summary>
+    /// 检验单能否唯一落到本采购行。判据只有「同租户内 (采购单号 + 物料) 命中几条采购行」,
+    /// 绝不用收货单号 / 供应商 / 批次 / 数量 / 日期增强匹配。
+    /// </summary>
+    public static string MapIqcAttribution(int candidatePoLineCount, int iqcBillCount)
+    {
+        if (iqcBillCount <= 0) return AdoS8Stage3Const.IqcUnattributed;
+        return candidatePoLineCount == 1
+            ? AdoS8Stage3Const.IqcUnique
+            : AdoS8Stage3Const.IqcAmbiguous;
+    }
+
+    /// <summary>
+    /// 质量结论。多张可归属检验单时一律 MULTIPLE_UNRESOLVED ——
+    /// 归并规则(取最新 / 任一通过 / 复检覆盖)尚未由业务冻结,读模型不得替业务拍板。
+    /// </summary>
+    public static string MapQualityStatus(
+        string iqcAttributionStatus, int iqcBillCount, int terminalPassCount, int terminalFailCount)
+    {
+        if (iqcAttributionStatus != AdoS8Stage3Const.IqcUnique)
+            return AdoS8Stage3Const.QualityNotObserved;
+        if (iqcBillCount > 1)
+            return AdoS8Stage3Const.QualityMultipleUnresolved;
+        if (iqcBillCount <= 0)
+            return AdoS8Stage3Const.QualityNotObserved;
+        if (terminalPassCount == 1) return AdoS8Stage3Const.QualityAccepted;
+        if (terminalFailCount == 1) return AdoS8Stage3Const.QualityRejected;
+        return AdoS8Stage3Const.QualityPending;
+    }
+
+    /// <summary>
+    /// 保守的对象完成态。只有两轴都给出确定结论且都通过才算完成;
+    /// 任何一轴缺数据(UNKNOWN / NOT_OBSERVED / MULTIPLE_UNRESOLVED)一律 UNKNOWN,
+    /// <b>绝不把「缺数据」当成「未完成」</b>。
+    /// </summary>
+    public static string MapObjectStatus(string procurementStatus, string qualityStatus)
+    {
+        var procurementDecided =
+            procurementStatus is AdoS8Stage3Const.ProcurementCompleted
+                              or AdoS8Stage3Const.ProcurementNotCompleted;
+        var qualityDecided =
+            qualityStatus is AdoS8Stage3Const.QualityAccepted
+                          or AdoS8Stage3Const.QualityRejected
+                          or AdoS8Stage3Const.QualityPending;
+
+        if (!procurementDecided || !qualityDecided)
+            return AdoS8Stage3Const.ObjectUnknown;
+
+        return procurementStatus == AdoS8Stage3Const.ProcurementCompleted
+               && qualityStatus == AdoS8Stage3Const.QualityAccepted
+            ? AdoS8Stage3Const.ObjectCompleted
+            : AdoS8Stage3Const.ObjectNotCompleted;
+    }
+
+    private static AdoS8Stage3ProcurementLineDto ToLine(Stage3Row r)
+    {
+        var procurement = MapProcurementStatus(r.CompletionStatus);
+        var attribution = MapIqcAttribution(r.IqcCandidatePoLineCount, r.IqcBillCount);
+        var quality = MapQualityStatus(attribution, r.IqcBillCount, r.IqcTerminalPassCount, r.IqcTerminalFailCount);
+
+        return new AdoS8Stage3ProcurementLineDto
+        {
+            SalesOrderNo = r.SalesOrderNo,
+            SalesOrderEntryId = r.SalesOrderEntryId,
+            PoNo = r.PoNo ?? string.Empty,
+            PoLine = r.PoLine ?? string.Empty,
+            PurchaseRequestNo = r.PurchaseRequestNo,
+            WorkOrder = r.WorkOrder,
+            MaterialCode = r.MaterialCode,
+            MaterialName = r.MaterialName,
+            Specification = null,   // 采购标准层无此字段,如实留空
+            SupplierCode = r.SupplierCode,
+            SupplierName = r.SupplierName,
+            OrderQty = r.OrderQty,
+            DueDate = r.DueDate,
+            ProcurementStatus = procurement,
+            IqcAttributionStatus = attribution,
+            IqcBillCount = r.IqcBillCount,
+            QualityStatus = quality,
+            ObjectStatus = MapObjectStatus(procurement, quality),
+            CompletionFactObserved = r.CompletionStatus != null,
+            QualityFactObserved = attribution == AdoS8Stage3Const.IqcUnique && r.IqcBillCount > 0,
+        };
+    }
+
+    private static AdoS8Stage3ProcurementSummaryDto Summarize(List<AdoS8Stage3ProcurementLineDto> lines)
+    {
+        var s = new AdoS8Stage3ProcurementSummaryDto
+        {
+            AttributedPoLineCount = lines.Count,
+
+            ProcurementCompletedCount = lines.Count(x => x.ProcurementStatus == AdoS8Stage3Const.ProcurementCompleted),
+            ProcurementNotCompletedCount = lines.Count(x => x.ProcurementStatus == AdoS8Stage3Const.ProcurementNotCompleted),
+            ProcurementUnknownCount = lines.Count(x => x.ProcurementStatus == AdoS8Stage3Const.ProcurementUnknown),
+            ProcurementNotObservedCount = lines.Count(x => x.ProcurementStatus == AdoS8Stage3Const.ProcurementNotObserved),
+
+            QualityAcceptedCount = lines.Count(x => x.QualityStatus == AdoS8Stage3Const.QualityAccepted),
+            QualityRejectedCount = lines.Count(x => x.QualityStatus == AdoS8Stage3Const.QualityRejected),
+            QualityPendingCount = lines.Count(x => x.QualityStatus == AdoS8Stage3Const.QualityPending),
+            QualityNotObservedCount = lines.Count(x => x.QualityStatus == AdoS8Stage3Const.QualityNotObserved),
+            QualityMultipleUnresolvedCount = lines.Count(x => x.QualityStatus == AdoS8Stage3Const.QualityMultipleUnresolved),
+
+            ObjectCompletedCount = lines.Count(x => x.ObjectStatus == AdoS8Stage3Const.ObjectCompleted),
+            ObjectNotCompletedCount = lines.Count(x => x.ObjectStatus == AdoS8Stage3Const.ObjectNotCompleted),
+            ObjectUnknownCount = lines.Count(x => x.ObjectStatus == AdoS8Stage3Const.ObjectUnknown),
+        };
+
+        s.HasUnresolvedQuality = s.QualityMultipleUnresolvedCount > 0;
+        s.HasUnknownProcurement = s.ProcurementUnknownCount > 0 || s.ProcurementNotObservedCount > 0;
+        s.KnownObjectCompletionRate = s.AttributedPoLineCount > 0
+            ? Math.Round((decimal)s.ObjectCompletedCount * 100 / s.AttributedPoLineCount, 2)
+            : null;
+
+        return s;
+    }
+
+    // ── 取数 ────────────────────────────────────────────────────────────────────────
+    //
+    // ⚠️ 列别名必须是 PascalCase:本库 DbSettings.EnableUnderLine=false,SqlSugar 不做
+    //    snake_case → PascalCase 转换,snake_case 别名会【静默】映射成 null 而行数正常。
+    //
+    // 一次查询取完,不做逐行再查 Completion / IQC 的 N+1。
+    private const string LineSql =
+        """
+        WITH Attr AS (
+            -- 归属链:采购行 → 采购申请 → 销售订单行。
+            -- 口径与 S3MdpSyncTransformService 的采购身份桥一致:走 purchase_request_no,
+            -- 【不走】work_order —— 采购标准层的 work_order 有大量字面量 'null',不可作关联键。
+            SELECT o.tenant_id                        AS TenantId,
+                   o.po_no                            AS PoNo,
+                   o.po_line                          AS PoLine,
+                   COUNT(DISTINCT s.order_id)         AS OrderCount,
+                   MIN(s.order_id)                    AS SalesOrderId,
+                   MIN(s.order_no)                    AS SalesOrderNo,
+                   MIN(p.sales_order_entry_id)        AS SalesOrderEntryId
+              FROM mdp_std_purchase_order o
+              JOIN mdp_std_purchase_request p
+                ON p.tenant_id = o.tenant_id
+               AND p.pr_no = o.purchase_request_no
+              JOIN mdp_std_so s
+                ON s.tenant_id = p.tenant_id
+               AND s.order_entry_id = p.sales_order_entry_id
+             WHERE o.tenant_id = @TenantId
+               AND IFNULL(o.purchase_request_no, '') <> ''
+             GROUP BY o.tenant_id, o.po_no, o.po_line
+        ),
+        IqcAgg AS (
+            -- 检验单按 (租户, 采购单号, 物料) 聚合。只认这三者,绝不用收货单号/供应商/批次/数量/日期。
+            SELECT q.tenant_id                        AS TenantId,
+                   TRIM(q.purchase_order_no)          AS PoNo,
+                   TRIM(q.material_code)              AS MaterialCode,
+                   COUNT(*)                           AS BillCount,
+                   SUM(CASE WHEN q.quality_result = 'PASS' AND q.flow_status = 2 THEN 1 ELSE 0 END) AS PassCount,
+                   SUM(CASE WHEN q.quality_result = 'FAIL' AND q.flow_status = 2 THEN 1 ELSE 0 END) AS FailCount
+              FROM mdp_std_iqc_inspection q
+             WHERE q.tenant_id = @TenantId
+               AND q.purchase_order_no IS NOT NULL
+               AND q.material_code IS NOT NULL
+             GROUP BY q.tenant_id, TRIM(q.purchase_order_no), TRIM(q.material_code)
+        ),
+        IqcCand AS (
+            -- 同租户内 (采购单号, 物料) 命中几条采购行。只有恰好 1 条时检验单才允许落到该行。
+            SELECT tenant_id                          AS TenantId,
+                   po_no                              AS PoNo,
+                   item_code                          AS MaterialCode,
+                   COUNT(*)                           AS PoLineCount
+              FROM mdp_std_purchase_order
+             WHERE tenant_id = @TenantId
+             GROUP BY tenant_id, po_no, item_code
+        ),
+        PrAgg AS (
+            -- 采购申请按单号聚合成一行:pr_no 不是唯一键(还有 pr_line),
+            -- 直接 JOIN 会让一条采购行按申请明细条数翻倍。
+            -- 工单号只在该申请单下唯一时才给值,多值一律留空 —— 它只是上下文,不值得去猜。
+            SELECT tenant_id                          AS TenantId,
+                   pr_no                              AS PrNo,
+                   CASE WHEN COUNT(DISTINCT work_order) = 1 THEN MIN(work_order) END AS WorkOrder
+              FROM mdp_std_purchase_request
+             WHERE tenant_id = @TenantId
+             GROUP BY tenant_id, pr_no
+        )
+        SELECT a.SalesOrderNo                         AS SalesOrderNo,
+               a.SalesOrderEntryId                    AS SalesOrderEntryId,
+               o.po_no                                AS PoNo,
+               o.po_line                              AS PoLine,
+               o.purchase_request_no                  AS PurchaseRequestNo,
+               pa.WorkOrder                           AS WorkOrder,
+               o.item_code                            AS MaterialCode,
+               o.item_name                            AS MaterialName,
+               o.supplier_code                        AS SupplierCode,
+               sup.supplier_name                      AS SupplierName,
+               o.order_qty                            AS OrderQty,
+               o.due_date                             AS DueDate,
+               c.completion_status                    AS CompletionStatus,
+               IFNULL(cd.PoLineCount, 0)              AS IqcCandidatePoLineCount,
+               IFNULL(ia.BillCount, 0)                AS IqcBillCount,
+               IFNULL(ia.PassCount, 0)                AS IqcTerminalPassCount,
+               IFNULL(ia.FailCount, 0)                AS IqcTerminalFailCount
+          FROM Attr a
+          JOIN mdp_std_purchase_order o
+            ON o.tenant_id = a.TenantId
+           AND o.po_no = a.PoNo
+           AND o.po_line = a.PoLine
+          LEFT JOIN PrAgg pa
+            ON pa.TenantId = o.tenant_id
+           AND pa.PrNo = o.purchase_request_no
+          LEFT JOIN mdp_std_supplier sup
+            ON sup.tenant_id = o.tenant_id
+           AND sup.supplier_code = o.supplier_code
+          LEFT JOIN mdp_std_purchase_order_completion c
+            ON c.tenant_id = o.tenant_id
+           AND c.po_no = o.po_no
+           AND c.po_line = o.po_line
+          LEFT JOIN IqcCand cd
+            ON cd.TenantId = o.tenant_id
+           AND cd.PoNo = o.po_no
+           AND cd.MaterialCode = o.item_code
+          LEFT JOIN IqcAgg ia
+            ON ia.TenantId = o.tenant_id
+           AND ia.PoNo = o.po_no
+           AND ia.MaterialCode = o.item_code
+         WHERE a.TenantId = @TenantId
+           AND a.SalesOrderId = @SalesOrderId
+           AND a.OrderCount = 1
+         ORDER BY o.po_no, CAST(o.po_line AS UNSIGNED), o.po_line
+        """;
+
+    /// <summary>SQL 投影行。属性名必须与 SELECT 别名逐字一致。</summary>
+    private sealed class Stage3Row
+    {
+        public string? SalesOrderNo { get; set; }
+        public long? SalesOrderEntryId { get; set; }
+        public string? PoNo { get; set; }
+        public string? PoLine { get; set; }
+        public string? PurchaseRequestNo { get; set; }
+        public string? WorkOrder { get; set; }
+        public string? MaterialCode { get; set; }
+        public string? MaterialName { get; set; }
+        public string? SupplierCode { get; set; }
+        public string? SupplierName { get; set; }
+        public decimal? OrderQty { get; set; }
+        public DateTime? DueDate { get; set; }
+
+        /// <summary>完成态事实行的原值;<c>null</c> 表示 WMS 侧没有这条行(NOT_OBSERVED)。</summary>
+        public string? CompletionStatus { get; set; }
+
+        public int IqcCandidatePoLineCount { get; set; }
+        public int IqcBillCount { get; set; }
+        public int IqcTerminalPassCount { get; set; }
+        public int IqcTerminalFailCount { get; set; }
+    }
+}