Sfoglia il codice sorgente

feat(s8): align stage3 procurement state across order views | server 1.0.556 / web 2.4.404

YY968XX 3 giorni fa
parent
commit
77a4ce7180

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.403",
+	"version": "2.4.404",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 14 - 1
Web/src/stores/orderExecution.ts

@@ -249,7 +249,20 @@ export const useOrderExecutionStore = defineStore('orderExecution', {
 			try {
 				const chain = await getOrderFlowChain(orderCode);
 				if (!chain?.order) return;
-				const lifecycle = mapDomainOrderDetailLifecycleWithSubsteps(chain.order);
+				// chain 接口(SEED 路径)不下发阶段 DataState —— 直接覆盖会把 detail 接口已取到的
+				// 真实「已归属 / 已完成」计数抹成 null,矩阵又退回按时间推断的「未到达」。
+				// 因此只在 chain 自己没带该字段时,保留 detail 已写入的值。
+				const lifecycle = mapDomainOrderDetailLifecycleWithSubsteps(chain.order).map((stage) => {
+					const prev = existing.find((s) => s.key === stage.key);
+					if (!prev || stage.dataState != null) return stage;
+					return {
+						...stage,
+						dataState: prev.dataState ?? null,
+						dataScope: prev.dataScope ?? null,
+						knownItemCount: prev.knownItemCount ?? null,
+						completedItemCount: prev.completedItemCount ?? null,
+					};
+				});
 				const next = this.orders.slice();
 				next[index] = { ...next[index], lifecycle };
 				this.orders = next;

+ 13 - 0
Web/src/views/aidop/s8/api/s8OrderFlowDomainApi.ts

@@ -101,6 +101,19 @@ export interface OrderFlowStage {
 	nodeVarianceDays: number | null;
 	cumulativeVarianceDays: number | null;
 	substeps: OrderFlowSubstep[];
+	/**
+	 * 阶段数据态:NO_DATA(无可归属对象)/ IN_PROGRESS(有可归属对象)/ UNKNOWN(取数失败)。
+	 * **刻意没有 COMPLETED** —— 现有 Authority 只覆盖「能可靠归属到该订单的对象」,
+	 * 不是该阶段的完整需求集合,据此宣称阶段完成会是错的。
+	 * 当前仅 MATERIAL_PURCHASE 下发;其余阶段为 null,前端沿用原时间推断逻辑。
+	 */
+	dataState?: string | null;
+	/** 数据范围,如 ATTRIBUTED_ONLY。消费方据此避免把子集当全集。 */
+	dataScope?: string | null;
+	/** 该阶段已可靠归属到本订单的对象数(材料采购 = 已归属采购行数)。 */
+	knownItemCount?: number | null;
+	/** 上述对象中已完成的数量(材料采购 = 采购已完成行数)。 */
+	completedItemCount?: number | null;
 }
 
 export interface OrderFlowOrderDetail extends OrderFlowOrderListItem {

+ 18 - 4
Web/src/views/aidop/s8/monitoring/OrderChainOverviewPage.vue

@@ -100,10 +100,24 @@ onMounted(async () => {
 		if (!selectedOrderNos.value.includes(focused)) {
 			selectedOrderNos.value = [focused];
 		}
-		void store.loadChainFromDomain(focused);
+		void loadStagesForChain(focused);
 	}
 });
 
+/**
+ * Chain 页矩阵的五阶段数据来源。
+ *
+ * 此前只调 `loadChainFromDomain`(SEED chain 接口),REAL 订单该接口返回 204 →
+ * `lifecycle` 一直是空数组 → 矩阵每一格都回落成 '未到达',与 Archive 页同源不同结论。
+ * 正确做法是先取 `orders/by-id/{id}` 的真实五阶段 DTO(含材料采购 DataState),
+ * 再按需叠加 chain 的 substeps —— **不在前端另开一个 Stage-3 请求**,
+ * 保持「五阶段汇总只有一个后端 Authority」。
+ */
+async function loadStagesForChain(orderCode: string) {
+	await store.loadDetailFromDomain(orderCode);
+	await store.loadChainFromDomain(orderCode);
+}
+
 // ORDER-FLOW-CHAIN-ENTRY-AND-CLEANUP-1:路由复用场景下 onMounted 不会重跑,需 watch 同步入口订单。
 // t3i:watch 中追加 chain 详情按需加载,确保切换订单时 subSteps/units 也跟上。
 watch(
@@ -113,7 +127,7 @@ watch(
 			if (!selectedOrderNos.value.includes(newVal)) {
 				selectedOrderNos.value = [newVal];
 			}
-			void store.loadChainFromDomain(newVal);
+			void loadStagesForChain(newVal);
 		}
 	},
 );
@@ -296,7 +310,7 @@ async function ensureStageChainsLoaded() {
 	if (targets.length === 0) return;
 	for (const o of targets) loadingChainCodes.value.add(o.soNo);
 	try {
-		await Promise.allSettled(targets.map((o) => store.loadChainFromDomain(o.soNo)));
+		await Promise.allSettled(targets.map((o) => loadStagesForChain(o.soNo)));
 	} finally {
 		for (const o of targets) loadingChainCodes.value.delete(o.soNo);
 	}
@@ -400,7 +414,7 @@ function onRetry() {
 		await store.loadFromDomain();
 		void store.loadAggregateFromDomain('BASELINE_PPT');
 		const focused = store.selectedOrderNo;
-		if (focused) void store.loadChainFromDomain(focused);
+		if (focused) void loadStagesForChain(focused);
 	})();
 }
 

+ 42 - 2
Web/src/views/aidop/s8/monitoring/components/order-execution/ChainOverviewList.vue

@@ -10,7 +10,7 @@ import type {
 import { ORDER_CHAIN_STAGE_ORDER } from '/@/views/aidop/s8/monitoring/data/order-execution/stage-meta';
 // S8-STEP5B-ORDER-CHAIN-CLOSURE-1(E):单订单态达标文案改走 computeNodeStatus;
 // STAGE_STATUS_LABEL 仅保留给「该 stage 不存在」的兜底(此时确实是「未到达」,无起工时点可判)。
-import { STAGE_STATUS_LABEL, computeNodeStatus } from './statusMapping';
+import { STAGE_STATUS_LABEL, computeNodeStatus, computeStageDataHint } from './statusMapping';
 
 interface Props {
 	orders: SalesOrderExecution[];
@@ -34,6 +34,14 @@ interface ColumnAgg {
 	statusTone: 'green' | 'yellow' | 'red' | 'pending';
 	statusLabel: string;
 	isSingleOrder: boolean;
+	/**
+	 * 后端下发了 DataState 的阶段(当前只有材料采购)才为 true。
+	 * 该阶段没有时间 Authority,天数恒为 '--',光一个圆点无法表达「有 8 行在途采购」,
+	 * 因此额外渲染文字状态与计数;其余阶段保持原样,只有圆点。
+	 */
+	hasDataState: boolean;
+	/** 「已归属 N · 已完成 M」。口径是已归属子集,不是该阶段全集,不得据此算完成率。 */
+	dataHint: string | null;
 }
 
 function pickStage(order: SalesOrderExecution, key: OrderNodeKey): StageSnapshot | undefined {
@@ -79,6 +87,8 @@ function aggregateColumn(
 			statusTone: 'pending',
 			statusLabel: STATUS_LABEL.pending,
 			isSingleOrder: false,
+			hasDataState: false,
+			dataHint: null,
 		};
 	}
 
@@ -123,6 +133,9 @@ function aggregateColumn(
 		statusTone,
 		statusLabel: STATUS_LABEL[statusTone],
 		isSingleOrder: false,
+		// 多订单聚合态本批不合并 DataState —— 跨订单把「已归属」相加会造出一个没有 Authority 的总量。
+		hasDataState: false,
+		dataHint: null,
 	};
 }
 
@@ -155,6 +168,8 @@ function buildSingleOrderColumn(
 			statusTone: 'pending',
 			statusLabel: STAGE_STATUS_LABEL.pending,
 			isSingleOrder: true,
+			hasDataState: false,
+			dataHint: null,
 		};
 	}
 	const kpi =
@@ -179,6 +194,8 @@ function buildSingleOrderColumn(
 		// tone 仍取自 stage.status,配色不变。
 		statusLabel: computeNodeStatus(stage).label,
 		isSingleOrder: true,
+		hasDataState: stage.dataState != null,
+		dataHint: computeStageDataHint(stage),
 	};
 }
 
@@ -263,7 +280,12 @@ function onClick(key: OrderNodeKey) {
 						:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
 					>
 						<template v-if="col.isSingleOrder">
-							<span class="chain-matrix__achievement-days">{{ col.achievementDaysText }}</span>
+							<!-- 材料采购没有时间 Authority,天数恒为 '--';改用后端下发的已归属计数如实表达,
+								 绝不为了让矩阵好看而伪造天数。口径是已归属子集,不是该阶段全集。 -->
+							<span v-if="col.dataHint" class="chain-matrix__data-hint" :title="`数据范围:已归属对象(ATTRIBUTED_ONLY),非该阶段全集`">
+								{{ col.dataHint }}
+							</span>
+							<span v-else class="chain-matrix__achievement-days">{{ col.achievementDaysText }}</span>
 						</template>
 						<template v-else>
 							<div class="chain-matrix__achievement">
@@ -293,6 +315,9 @@ function onClick(key: OrderNodeKey) {
 							:title="col.statusLabel"
 							:aria-label="col.statusLabel"
 						/>
+						<!-- 该阶段没有时间 Authority,光一个圆点无法区分「有 8 行在途采购」与「什么都没有」,
+							 因此把后端 DataState 文案显式渲染出来。其余阶段保持原样,只有圆点。 -->
+						<span v-if="col.hasDataState" class="chain-matrix__data-state">{{ col.statusLabel }}</span>
 					</td>
 				</tr>
 			</tbody>
@@ -462,6 +487,21 @@ function onClick(key: OrderNodeKey) {
 	color: var(--order-text-muted, #909097);
 }
 
+/* DataState 相关文案沿用既有次级文字配色,不引入新配色、不改圆点样式。 */
+.chain-matrix__data-hint {
+	font-size: 12px;
+	color: var(--order-text-secondary, #b6b7c0);
+	white-space: nowrap;
+}
+
+.chain-matrix__data-state {
+	margin-left: 6px;
+	font-size: 12px;
+	color: var(--order-text-secondary, #b6b7c0);
+	vertical-align: middle;
+	white-space: nowrap;
+}
+
 .chain-matrix__achievement-rate {
 	font-size: 13px;
 	font-weight: 700;

+ 39 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/statusMapping.ts

@@ -54,12 +54,51 @@ const NODE_LABEL_COMPLETED = '已完成';
 // 都标成「进行中」会把历史数据缺口误判成业务尚未完成,所以必须单独成文案。
 const NODE_LABEL_END_UNKNOWN = '已完成(历史时间未知)';
 
+// ── 数据态(DataState)文案 ──
+// 背景:材料采购阶段**没有时间 Authority**(既无 actualStartAt 也无 actualEndAt),
+// 但有真实的「已归属 / 已完成」计数。若继续按「有没有时间」推断,已经有 8 行在途采购的订单
+// 会被标成「未到达」,与同页 Stage-3 明细直接矛盾。
+//
+// 三个文案刻意都不含「已完成」:后端 DataState 本身就没有 COMPLETED ——
+// 现有 Authority 只覆盖「能可靠归属到该订单的对象」(DataScope=ATTRIBUTED_ONLY),
+// 不是该阶段的完整需求集合,据此宣称阶段完成会是错的。
+const NODE_LABEL_NO_ATTRIBUTED_DATA = '暂无已归属数据';
+const NODE_LABEL_DATA_UNKNOWN = '数据未知';
+
+/** 后端下发了 DataState 的阶段,其标签**只由 DataState 决定**,不再回落时间推断。 */
+function labelByDataState(dataState: string): string | null {
+	if (dataState === 'IN_PROGRESS') return NODE_LABEL_IN_PROGRESS;
+	if (dataState === 'NO_DATA') return NODE_LABEL_NO_ATTRIBUTED_DATA;
+	// UNKNOWN = 取数失败。技术故障不得当业务结论,既不能说「未到达」也不能说「进行中」。
+	if (dataState === 'UNKNOWN') return NODE_LABEL_DATA_UNKNOWN;
+	return null;
+}
+
+/**
+ * 阶段次级信息:「已归属 N · 已完成 M」。
+ * 仅在后端下发了计数(当前只有材料采购)且确有已归属对象时返回,其余一律 null。
+ * 口径是**已归属子集**,不是该阶段全集 —— 调用方不得据此算完成率。
+ */
+export function computeStageDataHint(stage: StageSnapshot | null | undefined): string | null {
+	if (!stage || stage.dataState !== 'IN_PROGRESS') return null;
+	const known = stage.knownItemCount;
+	if (known == null || known <= 0) return null;
+	const done = stage.completedItemCount ?? 0;
+	return `已归属 ${known} · 已完成 ${done}`;
+}
+
 export function computeNodeStatus(stage: StageSnapshot | null | undefined): StatusVisual {
 	if (!stage) return { tone: 'pending', label: NODE_LABEL.pending };
 	const s = stage.status;
 	if (s === 'green' || s === 'yellow' || s === 'red' || s === 'pending') {
 		let label = NODE_LABEL[s];
 		if (s === 'pending') {
+			// DataState 是后端唯一 Authority,优先级高于时间形态推断与 calculationStatus。
+			// 只对**后端确实下发了 DataState 的阶段**生效;其余阶段 dataState 为 null,
+			// 走下面完全未改动的原逻辑。
+			const byData = stage.dataState ? labelByDataState(stage.dataState) : null;
+			if (byData) return { tone: s, label: byData };
+
 			// calculationStatus 是阶段 KPI 的权威判定,优先于"有没有时间"的形态推断。
 			// 尤其 END_UNKNOWN:它没有 end 时间,但绝不是"进行中"。
 			if (stage.calculationStatus === 'END_UNKNOWN') label = NODE_LABEL_END_UNKNOWN;

+ 6 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/domainMapper.ts

@@ -119,6 +119,12 @@ export function mapDomainStage(stage: OrderFlowStage): StageSnapshot {
 		nodeVarianceDays: stage.nodeVarianceDays,
 		cumulativeVarianceDays: stage.cumulativeVarianceDays,
 		status: asStageStatus(stage.status),
+		// 数据态由后端唯一 Authority 下发,前端只透传、不自行推断、不补默认值。
+		// 后端没下发(其余阶段)时保持 null,矩阵沿用原时间推断逻辑。
+		dataState: stage.dataState ?? null,
+		dataScope: stage.dataScope ?? null,
+		knownItemCount: stage.knownItemCount ?? null,
+		completedItemCount: stage.completedItemCount ?? null,
 	};
 }
 

+ 11 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/types.ts

@@ -67,6 +67,17 @@ export interface StageSnapshot {
 	cumulativeVarianceDays: number | null;
 	status: 'green' | 'yellow' | 'red' | 'pending';
 	subSteps?: SubStepDetail[];
+	/**
+	 * 阶段数据态:NO_DATA / IN_PROGRESS / UNKNOWN。由后端五阶段 DTO 下发,**无 COMPLETED**。
+	 * 当前仅材料采购阶段有值;其余阶段为 undefined,矩阵沿用原时间推断。
+	 */
+	dataState?: string | null;
+	/** 数据范围,如 ATTRIBUTED_ONLY —— 已归属子集,不是该阶段全集。 */
+	dataScope?: string | null;
+	/** 已可靠归属到本订单的对象数。 */
+	knownItemCount?: number | null;
+	/** 上述对象中已完成的数量。 */
+	completedItemCount?: number | null;
 }
 
 export interface SalesOrderExecution {

+ 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.555</AssemblyVersion>
-    <FileVersion>1.0.555</FileVersion>
-    <Version>1.0.555</Version>
+    <AssemblyVersion>1.0.556</AssemblyVersion>
+    <FileVersion>1.0.556</FileVersion>
+    <Version>1.0.556</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 203 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/OrderFlow/S8FiveStageSummaryAuthorityTests.cs

@@ -0,0 +1,203 @@
+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.OrderFlow;
+
+using A = S8RealOrderArchiveService;
+using D = AdoS8StageDataState;
+
+/// <summary>
+/// 五阶段汇总的 Stage-3 数据态契约。
+///
+/// <para>背景:五阶段矩阵此前把材料采购一律画成「未到达」—— 因为该阶段没有时间 Authority,
+/// 而矩阵只会按「有没有时间」推断。同一张订单的 Stage-3 明细却实实在在列着 8 行在途采购,
+/// 两个页面因此给出互相矛盾的结论。</para>
+///
+/// <para>修法是让后端在<b>同一个五阶段 DTO</b> 里如实下发数据态,而不是让前端另开一个请求
+/// 自己改矩阵 —— 那样会出现第二个汇总 Authority,两边迟早再次分叉。本文件守住三件事:</para>
+///
+/// <list type="number">
+///   <item>永远不产生 COMPLETED —— 口径是 ATTRIBUTED_ONLY 子集,推不出阶段完成;</item>
+///   <item>取数失败是 UNKNOWN,不是 NO_DATA,更不是「未到达」;</item>
+///   <item>只有材料采购被点亮,其余四个阶段一个字段都不能多填。</item>
+/// </list>
+/// </summary>
+public class S8FiveStageSummaryAuthorityTests
+{
+    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 Archive() => Source("Service", "S8", "OrderFlow", "S8RealOrderArchiveService.cs");
+
+    /// <summary>去掉 XML 文档注释后的代码体 —— 文档需要点名被禁的东西来说明边界,那是说明不是违规。</summary>
+    private static string ArchiveBody()
+        => string.Join('\n', Archive().Split('\n')
+            .Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
+
+    private static AdoS8Stage3ProcurementSummaryDto Summary(int attributed, int completed)
+        => new() { AttributedPoLineCount = attributed, ProcurementCompletedCount = completed };
+
+    private static A.RealOrderRow Row() => new() { SalesOrderId = 1, OrderNo = "SO-TEST-0001" };
+
+    private static AdoS8OrderFlowStageDto Stage(
+        AdoS8Stage3ProcurementSummaryDto? summary, string code)
+        => A.BuildFiveStageSkeleton(Row(), summary).Single(s => s.OrderFlowCode == code);
+
+    private static AdoS8OrderFlowStageDto Purchase(AdoS8Stage3ProcurementSummaryDto? summary)
+        => Stage(summary, OrderFlowConstants.MATERIAL_PURCHASE);
+
+    // ── ① 数据态映射(§二十九 1-4)────────────────────────────────────────────────
+
+    [Fact]
+    public void 有已归属采购行是IN_PROGRESS()
+        => Assert.Equal(D.InProgress, A.ResolveStage3DataState(Summary(8, 1)));
+
+    [Fact]
+    public void 已归属为零是NO_DATA()
+        => Assert.Equal(D.NoData, A.ResolveStage3DataState(Summary(0, 0)));
+
+    [Fact]
+    public void 取数失败是UNKNOWN而不是NO_DATA()
+    {
+        // summary == null 表示 Stage-3 读模型抛异常,不是「查到了零行」。
+        // 把技术故障折叠进 NO_DATA,会让一次数据库超时被读成「这张订单没有采购」。
+        Assert.Equal(D.Unknown, A.ResolveStage3DataState(null));
+        Assert.NotEqual(D.NoData, A.ResolveStage3DataState(null));
+    }
+
+    [Fact]
+    public void 已归属行全部完成也不得产生COMPLETED()
+    {
+        // 8 行已归属、8 行已完成 —— 看上去「采购做完了」,但口径是 ATTRIBUTED_ONLY:
+        // 这 8 行只是能可靠归属到该订单的采购行,不是该订单的完整采购需求集合。
+        var state = A.ResolveStage3DataState(Summary(8, 8));
+        Assert.Equal(D.InProgress, state);
+        Assert.NotEqual("COMPLETED", state);
+    }
+
+    [Fact]
+    public void 数据态取值集合刻意不含COMPLETED()
+    {
+        var all = new[] { D.InProgress, D.NoData, D.Unknown };
+        Assert.DoesNotContain("COMPLETED", all);
+        // 常量类里也不许偷偷加一个 —— 加了就说明有人绕过了上面那条业务约束。
+        Assert.DoesNotContain("COMPLETED", typeof(AdoS8StageDataState)
+            .GetFields().Select(f => f.GetValue(null) as string ?? string.Empty));
+    }
+
+    // ── ② 五阶段装配(§二十九 5-8)────────────────────────────────────────────────
+
+    [Fact]
+    public void 材料采购阶段带上已归属与已完成计数()
+    {
+        var stage = Purchase(Summary(8, 1));
+        Assert.Equal(D.InProgress, stage.DataState);
+        Assert.Equal(AdoS8Stage3Const.ScopeAttributedOnly, stage.DataScope);
+        Assert.Equal(8, stage.KnownItemCount);
+        Assert.Equal(1, stage.CompletedItemCount);
+    }
+
+    [Fact]
+    public void 其余四个阶段的数据态字段一律为空()
+    {
+        // 只有材料采购建立了 Authority。顺手把设计/生产/发货也点亮,等于凭空发明结论。
+        var stages = A.BuildFiveStageSkeleton(Row(), Summary(8, 1))
+            .Where(s => s.OrderFlowCode != OrderFlowConstants.MATERIAL_PURCHASE);
+        foreach (var s in stages)
+        {
+            Assert.Null(s.DataState);
+            Assert.Null(s.DataScope);
+            Assert.Null(s.KnownItemCount);
+            Assert.Null(s.CompletedItemCount);
+        }
+    }
+
+    [Fact]
+    public void 取数失败时不得下发计数()
+    {
+        // UNKNOWN 意味着「不知道」。此时给出 0 会被前端当成「查到了零行」照常渲染。
+        var stage = Purchase(null);
+        Assert.Equal(D.Unknown, stage.DataState);
+        Assert.Null(stage.KnownItemCount);
+        Assert.Null(stage.CompletedItemCount);
+        Assert.Null(stage.DataScope);
+    }
+
+    [Fact]
+    public void 数据态不得伪造时间与评级()
+    {
+        // 本批的全部价值就在于「不伪造时间也能如实表达」。一旦有人用 observed_at 之类
+        // 回填 ActualStartAt 让矩阵变绿,Stage-3 就会带着一个查无实据的时间进入 KPI。
+        var stage = Purchase(Summary(8, 1));
+        Assert.Null(stage.ActualStartAt);
+        Assert.Null(stage.ActualEndAt);
+        Assert.Null(stage.ActualHours);
+        Assert.Equal("pending", stage.Status);
+    }
+
+    [Fact]
+    public void 五阶段始终齐备且顺序不因数据态改变()
+    {
+        // 五阶段是业务设计,不由「有没有数据」决定 —— NO_DATA 不能让这一列消失。
+        foreach (var summary in new AdoS8Stage3ProcurementSummaryDto?[] { null, Summary(0, 0), Summary(8, 1) })
+        {
+            var stages = A.BuildFiveStageSkeleton(Row(), summary);
+            Assert.Equal(OrderFlowConstants.All, stages.Select(s => s.OrderFlowCode).ToList());
+            Assert.Equal(Enumerable.Range(1, OrderFlowConstants.All.Count), stages.Select(s => s.SortNo));
+        }
+    }
+
+    // ── ③ 单一 Authority(§三十)──────────────────────────────────────────────────
+
+    [Fact]
+    public void Stage3取数失败不得让整张订单详情失败()
+    {
+        // 采购是详情的附加信息,不是订单存在的前提。让它把 200 变成 500,
+        // 等于用一条辅助链路的抖动阻断主链路。
+        var body = ArchiveBody();
+        var i = body.IndexOf("LoadStage3SummaryAsync", StringComparison.Ordinal);
+        Assert.True(i >= 0, "未找到 LoadStage3SummaryAsync");
+        var tail = body[i..];
+        Assert.Contains("catch", tail, StringComparison.Ordinal);
+        Assert.Contains("return null", tail, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 五阶段汇总只消费Stage3读模型不另起查询()
+    {
+        // 汇总必须复用 Stage-3 读模型,否则同一页的「矩阵」与「明细」会由两套 SQL 产出,
+        // 迟早给出不一致的数字 —— 那正是本批要消灭的 SUMMARY_DETAIL_SPLIT。
+        var body = ArchiveBody();
+        Assert.Contains("_stage3.GetStage3Async", body, StringComparison.Ordinal);
+        foreach (var forbidden in new[]
+                 {
+                     "mdp_std_purchase_order_completion",
+                     "mdp_std_purchase_order",
+                     "mdp_std_iqc",
+                 })
+            Assert.DoesNotContain(forbidden, body, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 订单列表不得触发采购归因查询()
+    {
+        // 列表一次返回 N 张订单;在那里调 Stage-3 会变成 N 次归因查询。
+        var body = ArchiveBody();
+        var i = body.IndexOf("GetRealOrdersAsync", StringComparison.Ordinal);
+        Assert.True(i >= 0, "未找到 GetRealOrdersAsync");
+        var j = body.IndexOf("GetRealOrderAsync", StringComparison.Ordinal);
+        Assert.True(j >= 0, "未找到 GetRealOrderAsync");
+        // 列表方法体(到下一个方法之前)不得出现 Stage-3 调用
+        var listBody = body[i..Math.Max(i, j > i ? j : body.Length)];
+        Assert.DoesNotContain("LoadStage3SummaryAsync", listBody, StringComparison.Ordinal);
+    }
+}

+ 44 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/OrderFlow/AdoS8OrderFlowDtos.cs

@@ -130,6 +130,50 @@ public class AdoS8OrderFlowStageDto
     public decimal? NodeVarianceDays { get; set; }
     public decimal? CumulativeVarianceDays { get; set; }
     public List<AdoS8OrderFlowSubstepDto> Substeps { get; set; } = new();
+
+    // ── 阶段数据态(通用,不是某个阶段专属)─────────────────────────────────────
+    //
+    // 起因:REAL runtime 下 Status 恒为 "pending",前端只能按「有没有时间」细分标签。
+    // 对没有时间 Authority 的阶段(如材料采购),这会把「已经有真实在办数据」
+    // 说成「未到达」—— 与同页详情直接矛盾。下面四个字段让阶段能在不伪造时间的前提下
+    // 如实表达自己有多少可信数据。仅有 REAL Authority 的阶段填,其余保持 null。
+
+    /// <summary>
+    /// 阶段数据态:<c>NO_DATA</c>(无可归属对象)/ <c>IN_PROGRESS</c>(有可归属对象)/
+    /// <c>UNKNOWN</c>(取数失败,技术故障不得当业务结论)。
+    /// <para><b>刻意没有 COMPLETED</b>:现有 Authority 只覆盖「能可靠归属到该订单的对象」,
+    /// 不是该阶段的完整需求集合,据此宣称阶段完成会是错的。</para>
+    /// </summary>
+    public string? DataState { get; set; }
+
+    /// <summary>数据范围,如 <c>ATTRIBUTED_ONLY</c>。消费方据此避免把子集当全集。</summary>
+    public string? DataScope { get; set; }
+
+    /// <summary>该阶段已可靠归属到本订单的对象数(材料采购 = 已归属采购行数)。</summary>
+    public int? KnownItemCount { get; set; }
+
+    /// <summary>上述对象中已完成的数量(材料采购 = 采购已完成行数)。</summary>
+    public int? CompletedItemCount { get; set; }
+}
+
+/// <summary>
+/// 阶段数据态取值。
+///
+/// <para><b>这里没有、也不会有 <c>COMPLETED</c>。</b>现有 Authority 只覆盖「能可靠归属到该订单的
+/// 对象」(<see cref="AdoS8OrderFlowStageDto.DataScope"/> = <c>ATTRIBUTED_ONLY</c>),
+/// 不是该阶段的完整需求集合。即便已归属对象全部完成,也推不出「该阶段已完成」——
+/// 那要等完整需求集合的 Authority 建立之后才谈得上。谁想加 COMPLETED,先拿出那个 Authority。</para>
+/// </summary>
+public static class AdoS8StageDataState
+{
+    /// <summary>有可归属对象(在办)。</summary>
+    public const string InProgress = "IN_PROGRESS";
+
+    /// <summary>无可归属对象。注意是「没查到」,不是「不需要」。</summary>
+    public const string NoData = "NO_DATA";
+
+    /// <summary>取数失败。技术故障,不得渲染成任何业务结论。</summary>
+    public const string Unknown = "UNKNOWN";
 }
 
 public class AdoS8OrderFlowSubstepDto

+ 63 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/OrderFlow/S8RealOrderArchiveService.cs

@@ -32,11 +32,16 @@ public class S8RealOrderArchiveService : ITransient
 
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly S8Stage3ProcurementReadService _stage3;
 
-    public S8RealOrderArchiveService(ISqlSugarClient db, UserManager userManager)
+    public S8RealOrderArchiveService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        S8Stage3ProcurementReadService stage3)
     {
         _db = db;
         _userManager = userManager;
+        _stage3 = stage3;
     }
 
     /// <summary>
@@ -187,11 +192,55 @@ public class S8RealOrderArchiveService : ITransient
             CurrentOrderFlowName = item.CurrentOrderFlowName,
             CurrentStatus = item.CurrentStatus,
             DataSource = item.DataSource,
-            Lifecycle = BuildFiveStageSkeleton(row),
+            Lifecycle = BuildFiveStageSkeleton(row, await LoadStage3SummaryAsync(salesOrderId)),
         };
         return detail;
     }
 
+    /// <summary>
+    /// 取材料采购阶段的摘要,供五阶段矩阵使用。
+    ///
+    /// <para><b>复用同一个 Authority</b>:直接调 <see cref="S8Stage3ProcurementReadService"/>,
+    /// 不另写第二份业务 SQL,也不从 SEED / 贴源层 / 源库重算。矩阵与详情面板因此不可能给出
+    /// 互相矛盾的结论 —— 它们本来就是同一次计算的两种投影。</para>
+    ///
+    /// <para><b>只在单订单详情里调用</b>。订单列表(<c>GetRealOrdersAsync</c>)刻意不调,
+    /// 否则 N 张订单会变成 N 次采购归因查询。</para>
+    ///
+    /// <para>取数失败时返回 <c>null</c>,由调用方标成 <c>UNKNOWN</c> ——
+    /// 技术故障绝不能被渲染成「未到达」这种业务结论。</para>
+    /// </summary>
+    private async Task<AdoS8Stage3ProcurementSummaryDto?> LoadStage3SummaryAsync(long salesOrderId)
+    {
+        try
+        {
+            var stage3 = await _stage3.GetStage3Async(salesOrderId);
+            return stage3?.Summary;
+        }
+        catch (Exception)
+        {
+            return null;
+        }
+    }
+
+
+    /// <summary>
+    /// 材料采购阶段的数据态。
+    ///
+    /// <para><b>永远不会返回「已完成」</b>:采购摘要的口径是 ATTRIBUTED_ONLY —— 只统计能可靠
+    /// 归属到该订单的采购行,不是该订单的完整采购需求集合。即便已归属的行全部完成,
+    /// 也不能推出「材料采购阶段已完成」,那要等完整需求集合的 Authority 建立之后才谈得上。</para>
+    ///
+    /// <para>摘要为 null 表示取数失败(不是没有数据),故落 <c>UNKNOWN</c> 而非 <c>NO_DATA</c>。</para>
+    /// </summary>
+    public static string ResolveStage3DataState(AdoS8Stage3ProcurementSummaryDto? stage3)
+    {
+        if (stage3 == null) return AdoS8StageDataState.Unknown;
+        return stage3.AttributedPoLineCount > 0
+            ? AdoS8StageDataState.InProgress
+            : AdoS8StageDataState.NoData;
+    }
+
     // ══════════════════════════════════════════════════════════════════════
     // 映射
     // ══════════════════════════════════════════════════════════════════════
@@ -253,7 +302,9 @@ public class S8RealOrderArchiveService : ITransient
     /// <para><b>五阶段是 S8 的业务设计,不由「有没有数据」决定</b> —— 因此 Stage 2~5
     /// 必须存在但字段为空;本批只有 ORDER_REVIEW 有真实事实。</para>
     /// </summary>
-    private static List<AdoS8OrderFlowStageDto> BuildFiveStageSkeleton(RealOrderRow r)
+    public static List<AdoS8OrderFlowStageDto> BuildFiveStageSkeleton(
+        RealOrderRow r,
+        AdoS8Stage3ProcurementSummaryDto? stage3)
     {
         var list = new List<AdoS8OrderFlowStageDto>();
         for (var i = 0; i < OrderFlowConstants.All.Count; i++)
@@ -261,6 +312,7 @@ public class S8RealOrderArchiveService : ITransient
             var code = OrderFlowConstants.All[i];
             var isReview = code == OrderFlowConstants.ORDER_REVIEW_PLAN_CALC;
             var isDesign = code == OrderFlowConstants.PRODUCT_DESIGN;
+            var isPurchase = code == OrderFlowConstants.MATERIAL_PURCHASE;
 
             list.Add(new AdoS8OrderFlowStageDto
             {
@@ -287,13 +339,20 @@ public class S8RealOrderArchiveService : ITransient
                 // 状态色本批不评级(红黄绿属另一批次);统一 pending,由前端按时间细分「进行中 / 未到达」
                 Status = "pending",
                 Substeps = [],
+
+                // 材料采购没有时间 Authority,但有真实的「已归属 / 已完成」计数。
+                // 用数据态如实表达,绝不为了让矩阵好看而伪造 ActualStartAt / ActualEndAt。
+                DataState = isPurchase ? ResolveStage3DataState(stage3) : null,
+                DataScope = isPurchase && stage3 != null ? AdoS8Stage3Const.ScopeAttributedOnly : null,
+                KnownItemCount = isPurchase ? stage3?.AttributedPoLineCount : null,
+                CompletedItemCount = isPurchase ? stage3?.ProcurementCompletedCount : null,
             });
         }
         return list;
     }
 
     /// <summary>SQL 投影行,字段名与 SELECT 别名一一对应。</summary>
-    private sealed class RealOrderRow
+    public sealed class RealOrderRow
     {
         public long SalesOrderId { get; set; }
         public string? OrderNo { get; set; }