Parcourir la source

fix(s8): stop baseline data from masquerading as order facts | web 2.4.405

订单执行档案/订单链路全景上有三类「缺数被兜底成事实」,本次逐条收口。

1) 每个真实订单都被涂成绿色「正常」
   REAL 路径后端把 CurrentStatus 下发为空串(S8RealOrderArchiveService.cs:272)——
   S8 从未对真实订单做过红黄绿评级。而 asNodeStatus 把「空 / 不认识」一律兜底成
   'green',于是列表里每一单都显示「正常」。这是凭空造出来的评级结论。
   改为无评级即 null:只显示节点名、不着色;严重度筛选项同步过滤掉 null
   (与相邻的 productLines / regions 同一 .filter(Boolean) 写法)。
   同文件的 asCustomerType / asPriority 早已按这条规矩修过,本次把 asNodeStatus 补齐。

2) 三张时间 KPI 卡在毫无 Authority 时打印「0.0 分钟」
   getAverage 本就对空集合返回 null,是 computeOrderExecutionKpis 的 `?? 0`
   把它吃掉了。0.0 分钟读起来是「响应极快」,属于把缺数说成业绩。
   改为保持 null,由卡片沿用既有 "--" 无数据语义呈现(不新造文案)。

3) chain(SEED) 整体覆盖 detail(REAL) 的阶段事实
   detail(orders/by-id/{id},读 mdp_std_so + dwd_*)才是五阶段事实 Authority;
   chain(orders/{code}/chain,读 ado_s8_order_flow_*)只应补 L2/L3 子步骤。
   旧实现反了:拿 chain 阶段整体覆盖,再把 4 个 DataState 字段救回来,其余
   targetDate / actualStartedAt / actualReachedAt / actualDays / actualHours /
   calculationStatus / 偏差 / status 全被 SEED 值顶替,唯一防线只是「订单号恰好不撞」。
   改为真实阶段整体保留、只叠加 subSteps;detail 未取到阶段时不再由 chain 单独拼
   lifecycle。这同时让两个页面对同一订单的阶段事实来自同一 authority。

4) 零订单租户被渲染成 105 单基线矩阵
   isUnfiltered 原为纯长度相等,allOrders 为 0 时 `0 === 0` 恒真,于是一张真实订单
   都没有(或列表加载失败)时反而进入基线态,标题还印着「全部 0 单」。
   补 allOrders 非空条件;标题改为「参考基线(非本租户实算) · 基线样本 N 单」,
   N 取快照自身样本量,不再把真实订单数印在基线矩阵上方。

Stage-3 的 DataState/DataScope/KnownItemCount/CompletedItemCount 契约未改,
并新增回归守卫钉住「DataState 优先于任何时间形态推断、三态均不含已完成」。

验证:新增 15 例(provenance 11 + store 4),对修复前实现分别 3/11 与 2/4 失败(RED 验证);
vitest 55 passed / 6 failed,6 例为既有基线(s8MonitoringAnomalyApi.spec.ts),净新增失败 0;
vue-tsc 584 → 584,逐行 diff 无新增;vite build exit=0。
YY968XX il y a 1 jour
Parent
commit
9c1ca274c6

+ 1 - 1
Web/package.json

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

+ 155 - 0
Web/src/stores/orderExecution.provenance.spec.ts

@@ -0,0 +1,155 @@
+// S8-REAL-DATA-PRESENTATION-1:真实阶段事实 vs SEED chain 叠加 的边界守卫。
+//
+// detail(orders/by-id/{id} → mdp_std_so + dwd_*)是五阶段事实的唯一 Authority。
+// chain(orders/{code}/chain → ado_s8_order_flow_*)只负责 L2/L3 子步骤明细。
+//
+// 旧实现把 chain 的阶段整体覆盖 detail,只把 4 个 DataState 字段救回来;
+// 其余阶段事实(目标时间/实际开始/实际完成/实际天数/实际工时/计算状态/偏差/状态)
+// 全被 SEED 值顶替,唯一防线只是「订单号恰好不撞」。本测试钉住修复后的方向。
+
+import { createPinia, setActivePinia } from 'pinia';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getOrderFlowChainMock = vi.fn();
+
+vi.mock('/@/views/aidop/s8/api/s8OrderFlowDomainApi', () => ({
+	getOrderFlowAggregate: vi.fn(),
+	getOrderFlowChain: (...a: unknown[]) => getOrderFlowChainMock(...a),
+	getOrderFlowOrder: vi.fn(),
+	getRealOrderFlowOrderById: vi.fn(),
+	getOrderFlowOrders: vi.fn(),
+	getOrderFlowProductDesignDrawings: vi.fn(),
+}));
+
+vi.mock('/@/utils/storage', () => ({
+	Session: { get: vi.fn(), set: vi.fn(), remove: vi.fn() },
+}));
+
+import { useOrderExecutionStore } from './orderExecution';
+
+/** detail 已经取到的真实阶段(材料采购带 Stage-3 数据态)。 */
+const REAL_STAGE = {
+	key: 'material_procurement',
+	orderFlowCode: 'MATERIAL_PURCHASE',
+	orderFlowName: '材料采购',
+	sortNo: 3,
+	status: 'pending',
+	targetDate: null,
+	actualStartedAt: '2026-09-01 08:00',
+	actualReachedAt: null,
+	actualDays: null,
+	actualHours: 12.5,
+	nodeVarianceDays: null,
+	cumulativeVarianceDays: null,
+	calculationStatus: null,
+	dataState: 'IN_PROGRESS',
+	dataScope: 'ATTRIBUTED_ONLY',
+	knownItemCount: 8,
+	completedItemCount: 3,
+	subSteps: [],
+};
+
+/** chain(SEED)对同一阶段给出的一套完全不同的“事实”。 */
+const SEED_CHAIN_ORDER = {
+	orderCode: 'SO-REAL-0001',
+	lifecycle: [
+		{
+			orderFlowCode: 'MATERIAL_PURCHASE',
+			orderFlowName: '材料采购',
+			ownerDept: '采购部',
+			sortNo: 3,
+			status: 'green',
+			targetAt: '2026-07-01 00:00',
+			actualStartAt: '2026-06-01 00:00',
+			actualEndAt: '2026-06-20 00:00',
+			actualDays: 19,
+			actualHours: 999,
+			nodeVarianceDays: -2,
+			cumulativeVarianceDays: -5,
+			calculationStatus: null,
+			substeps: [
+				{
+					substepCode: 'SS1',
+					substepName: '询价',
+					piHours: 4,
+					actualHours: 3,
+					status: 'green',
+					sortNo: 1,
+					units: [],
+				},
+			],
+		},
+	],
+};
+
+function seedStore() {
+	const store = useOrderExecutionStore();
+	store.orders = [
+		{
+			soNo: 'SO-REAL-0001',
+			salesOrderId: 90001,
+			lifecycle: [{ ...REAL_STAGE }],
+		},
+	] as never;
+	return store;
+}
+
+describe('orderExecution store — chain(SEED) 不得覆盖 detail(REAL) 的阶段事实', () => {
+	beforeEach(() => {
+		setActivePinia(createPinia());
+		getOrderFlowChainMock.mockReset();
+	});
+
+	it('RealStageFact_WinsOverBaseline:真实阶段字段在 chain 叠加后原样保留', async () => {
+		getOrderFlowChainMock.mockResolvedValue({ order: SEED_CHAIN_ORDER });
+		const store = seedStore();
+
+		await store.loadChainFromDomain('SO-REAL-0001');
+
+		const s = store.orders[0].lifecycle[0] as unknown as typeof REAL_STAGE;
+		expect(s.actualHours).toBe(12.5);
+		expect(s.actualStartedAt).toBe('2026-09-01 08:00');
+		// SEED 宣称 2026-06-20 完成 —— 真实事实是「尚未完成」,必须保持 null
+		expect(s.actualReachedAt).toBeNull();
+		expect(s.actualDays).toBeNull();
+		expect(s.targetDate).toBeNull();
+		expect(s.nodeVarianceDays).toBeNull();
+		expect(s.cumulativeVarianceDays).toBeNull();
+		expect(s.status).toBe('pending');
+	});
+
+	it('Stage3_DataState_RemainsBackendAuthority:四个数据态字段不被 chain 改写', async () => {
+		getOrderFlowChainMock.mockResolvedValue({ order: SEED_CHAIN_ORDER });
+		const store = seedStore();
+
+		await store.loadChainFromDomain('SO-REAL-0001');
+
+		const s = store.orders[0].lifecycle[0] as unknown as typeof REAL_STAGE;
+		expect(s.dataState).toBe('IN_PROGRESS');
+		expect(s.dataScope).toBe('ATTRIBUTED_ONLY');
+		expect(s.knownItemCount).toBe(8);
+		expect(s.completedItemCount).toBe(3);
+	});
+
+	it('chain 的合法贡献(子步骤明细)仍然被叠加进来', async () => {
+		getOrderFlowChainMock.mockResolvedValue({ order: SEED_CHAIN_ORDER });
+		const store = seedStore();
+
+		await store.loadChainFromDomain('SO-REAL-0001');
+
+		const s = store.orders[0].lifecycle[0] as unknown as typeof REAL_STAGE;
+		expect(Array.isArray(s.subSteps)).toBe(true);
+		expect(s.subSteps.length).toBe(1);
+	});
+
+	it('detail 尚未取到阶段时,不得由 chain 单独拼出一套 lifecycle', async () => {
+		getOrderFlowChainMock.mockResolvedValue({ order: SEED_CHAIN_ORDER });
+		const store = useOrderExecutionStore();
+		store.orders = [{ soNo: 'SO-REAL-0001', salesOrderId: 90001, lifecycle: [] }] as never;
+
+		await store.loadChainFromDomain('SO-REAL-0001');
+
+		expect(store.orders[0].lifecycle).toEqual([]);
+		expect(getOrderFlowChainMock).not.toHaveBeenCalled();
+	});
+});

+ 21 - 15
Web/src/stores/orderExecution.ts

@@ -142,7 +142,11 @@ export const useOrderExecutionStore = defineStore('orderExecution', {
 			const customerNames = dedupe(orders.map((o) => o.customerName));
 			const productLines = dedupe(orders.map((o) => o.productLine)).filter(Boolean);
 			const regions = dedupe(orders.map((o) => o.region)).filter(Boolean);
-			const severities = dedupe(orders.map((o) => o.nodeStatus));
+			// 与 productLines / regions 同:null(无评级 Authority)不进筛选项,
+			// 否则会出现一个没有业务含义、且把「无评级」误显示成某个等级的选项。
+			const severities = dedupe(orders.map((o) => o.nodeStatus)).filter(
+				(s): s is 'green' | 'yellow' | 'red' => !!s,
+			);
 			const statuses = dedupe(orders.map((o) => o.workflowStatus));
 
 			return {
@@ -247,22 +251,24 @@ export const useOrderExecutionStore = defineStore('orderExecution', {
 			const hasSubSteps = existing.some((s) => Array.isArray(s.subSteps) && s.subSteps.length > 0);
 			if (hasSubSteps) return;
 			try {
+				// detail(orders/by-id/{id},读 mdp_std_so + dwd_*)是五阶段事实的**唯一** Authority;
+				// chain(orders/{code}/chain,读 ado_s8_order_flow_*)只补 L2/L3 子步骤明细。
+				//
+				// 旧实现反了:它拿 chain 的阶段整体覆盖 detail,再把 4 个 DataState 字段救回来。
+				// 其余阶段事实——targetDate / actualStartedAt / actualReachedAt / actualDays /
+				// actualHours / calculationStatus / nodeVarianceDays / cumulativeVarianceDays /
+				// status——全部被 SEED 值顶替,且唯一的防线只是「订单号恰好不撞」。
+				// 现在改为:真实阶段整体保留,只叠加 subSteps。
+				if (existing.length === 0) return;
 				const chain = await getOrderFlowChain(orderCode);
 				if (!chain?.order) return;
-				// 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 substepsByKey = new Map(
+					mapDomainOrderDetailLifecycleWithSubsteps(chain.order).map((s) => [s.key, s.subSteps ?? []]),
+				);
+				const lifecycle = existing.map((stage) => ({
+					...stage,
+					subSteps: substepsByKey.get(stage.key) ?? stage.subSteps ?? [],
+				}));
 				const next = this.orders.slice();
 				next[index] = { ...next[index], lifecycle };
 				this.orders = next;

+ 12 - 2
Web/src/views/aidop/s8/monitoring/OrderChainOverviewPage.vue

@@ -199,8 +199,13 @@ const detailOrder = computed<SalesOrderExecution | null>(() => {
 });
 
 // ORDER-FLOW-CHAIN-PAGE2-ORIGINAL-LOGIC-RESTORE-1:同页三态判定(不纳入 selectedStageKeys)。
+//
+// S8-REAL-DATA-PRESENTATION-1:补 allOrders 非空这一条。
+// 原判据是纯长度相等,于是 allOrders 为 0 时 `0 === 0` 恒真 —— 一个**一张真实订单都没有**
+// 的租户(或列表加载失败时)会被判为「未筛选」,进而渲染 105 单 PPT 基线矩阵,
+// 标题还印着「全部 0 单」。没有数据就该是空态,不该用参考基线把版面填满。
 const isUnfiltered = computed(
-	() => filteredChainOrders.value.length === allOrders.value.length,
+	() => allOrders.value.length > 0 && filteredChainOrders.value.length === allOrders.value.length,
 );
 // ORDER-FLOW-CHAIN-ENTRY-AND-CLEANUP-1:单订单态守门,避免关闭 SO tag 后顶部 summary 残留旧订单。
 const isSingleOrderMode = computed(() => filteredChainOrders.value.length === 1);
@@ -690,7 +695,12 @@ watch(
 						<span class="oe-chain__sep">·</span>
 						<span>当前环节 {{ activeStageName }}</span>
 					</template>
-					<template v-else-if="isUnfiltered">基线聚合 · 全部 {{ allOrders.length }} 单</template>
+					<!-- S8-REAL-DATA-PRESENTATION-1:此处展示的是基线快照,不是本租户订单的实算结果。
+					     原文案「基线聚合 · 全部 {allOrders.length} 单」把**真实订单数**印在基线矩阵上方,
+					     读起来像是"对这 N 单算出来的"。改为标明参考基线并使用快照自身的样本量。 -->
+					<template v-else-if="isUnfiltered">
+						参考基线(非本租户实算) · 基线样本 {{ aggregateSnapshot?.totalOrders ?? 0 }} 单
+					</template>
 					<template v-else>多订单聚合 · 命中 {{ filteredChainOrders.length }} 单</template>
 				</p>
 			</div>

+ 3 - 1
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionKpiCards.vue

@@ -9,7 +9,9 @@ interface Props {
 
 const props = defineProps<Props>();
 
-const fmt = (v: number) => (props.ready ? v.toFixed(1) : '--');
+// v == null 表示该指标没有任何真实 Authority(REAL 订单当前即如此),
+// 与「尚未加载」共用同一个 "--" 无数据语义,不新造文案,更不落回 0。
+const fmt = (v: number | null) => (props.ready && v != null ? v.toFixed(1) : '--');
 
 const cards = computed(() => [
 	{ key: 'response', label: '平均响应时间', value: fmt(props.kpi.avgResponse), unit: '分钟', tone: 'accent' as const },

+ 2 - 1
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderList.vue

@@ -256,9 +256,10 @@ const rows = computed(() =>
 						class="oe-order-row__chip oe-order-row__chip--workflow"
 						:class="`oe-order-row__chip--workflow-${row.workflowTone}`"
 					>{{ row.workflow }}</span>
+					<!-- nodeStatus 为 null = 无评级 Authority:只显示节点名,不着色,绝不默认绿 -->
 					<span
 						class="oe-order-row__chip oe-order-row__chip--node"
-						:class="`oe-order-row__chip--${row.nodeStatus}`"
+						:class="row.nodeStatus ? `oe-order-row__chip--${row.nodeStatus}` : ''"
 					>{{ row.currentNode }}</span>
 					<span class="oe-order-row__metric">
 						<span class="oe-order-row__metric-lbl">异常</span>

+ 5 - 3
Web/src/views/aidop/s8/monitoring/data/order-execution/aggregation.ts

@@ -25,10 +25,12 @@ export const computeOrderExecutionKpis = (
 		.map((o) => o.currentProcessingMinutes)
 		.filter((v): v is number => v !== null);
 	const lossValues = orders.map((o) => o.totalLossTime).filter((v): v is number => v !== null);
+	// 无样本时保持 null。旧实现 `?? 0` 会把「没有任何 Authority」渲染成「0.0 分钟」,
+	// 读起来是「响应极快」——那是把缺数说成了业绩。由卡片按现有 "--" 语义呈现。
 	return {
-		avgResponse: getAverage(responseValues) ?? 0,
-		avgProcessing: getAverage(processingValues) ?? 0,
-		avgLoss: getAverage(lossValues) ?? 0,
+		avgResponse: getAverage(responseValues),
+		avgProcessing: getAverage(processingValues),
+		avgLoss: getAverage(lossValues),
 	};
 };
 

+ 5 - 1
Web/src/views/aidop/s8/monitoring/data/order-execution/domainMapper.ts

@@ -77,8 +77,12 @@ function asWorkflowStatus(v: string): SalesOrderExecution['workflowStatus'] {
 	return v === 'in_progress' ? 'in_progress' : 'completed';
 }
 
+// 同 asCustomerType / asPriority:REAL runtime 没有订单级红黄绿评级 Authority,
+// 后端把 CurrentStatus 下发为空串(S8RealOrderArchiveService.cs:272)。
+// 旧实现把「空 / 不认识」一律兜底成 'green',于是**每个真实订单都显示为「正常」**——
+// 那是凭空造出来的评级结论,不是事实。无评级一律 null(渲染 "--")。
 function asNodeStatus(v: string): SalesOrderExecution['nodeStatus'] {
-	return v === 'yellow' || v === 'red' ? v : 'green';
+	return v === 'green' || v === 'yellow' || v === 'red' ? v : null;
 }
 
 function asExceptionStatus(v: string): SalesOrderExecution['exceptionStatus'] {

+ 162 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/provenance.spec.ts

@@ -0,0 +1,162 @@
+// S8-REAL-DATA-PRESENTATION-1:真实事实 vs 参考基线 的来源守卫。
+//
+// 本文件钉住一条边界:**真实事实缺失时,不得由兜底值 / 基线 / seed 顶替**。
+// 背景(2026-09-21 只读取证):
+//   · asNodeStatus('') 曾返回 'green' —— 后端 REAL 路径把 CurrentStatus 下发为空串
+//     (S8RealOrderArchiveService.cs:272),于是**每一个真实订单**都被涂成绿色「正常」,
+//     而 S8 从未对真实订单做过任何红黄绿评级。
+//   · computeOrderExecutionKpis 曾 `?? 0` —— 三个 KPI 卡在毫无 Authority 的情况下
+//     打印「0.0 分钟」,读起来就是「响应很快」。
+//   · chain(SEED)接口的响应曾整体覆盖 detail(REAL)已取到的阶段事实,
+//     只有 4 个 DataState 字段被显式救回。
+//
+// 同文件内早有正确范式:asCustomerType / asPriority 对空串一律给 null(渲染 "--"),
+// 注释写着「不得凭空造值」。本测试把同一条规矩扩到其余字段。
+
+import { describe, expect, it } from 'vitest';
+
+import { computeOrderExecutionKpis, getAverage } from './aggregation';
+import { mapDomainOrderListItem } from './domainMapper';
+import { computeNodeStatus, computeStageDataHint } from '../../components/order-execution/statusMapping';
+import type { SalesOrderExecution, StageSnapshot } from './types';
+
+/** 后端 REAL 列表项的最小形状:只带本测试关心的字段,其余给空。 */
+function realListDto(over: Record<string, unknown> = {}) {
+	return {
+		soNo: 'SO-REAL-0001',
+		salesOrderId: 90001,
+		customerName: '真实客户',
+		customerCode: '',
+		customerType: '',
+		productName: '',
+		productLine: '',
+		region: '',
+		priority: '',
+		workflowStatus: 'in_progress',
+		// REAL 路径后端固定下发空串——没有任何评级 Authority
+		currentStatus: '',
+		currentOrderFlowCode: 'MATERIAL_PURCHASE',
+		exceptionStatus: '',
+		releaseAt: null,
+		responseDisplayMinutes: null,
+		currentProcessingMinutes: null,
+		totalLossTime: null,
+		...over,
+	};
+}
+
+function stage(over: Partial<StageSnapshot> = {}): StageSnapshot {
+	return {
+		key: 'material_procurement',
+		orderFlowCode: 'MATERIAL_PURCHASE',
+		orderFlowName: '材料采购',
+		sortNo: 3,
+		status: 'pending',
+		targetDate: null,
+		actualStartedAt: null,
+		actualReachedAt: null,
+		actualDays: null,
+		actualHours: null,
+		nodeVarianceDays: null,
+		cumulativeVarianceDays: null,
+		calculationStatus: null,
+		dataState: null,
+		dataScope: null,
+		knownItemCount: null,
+		completedItemCount: null,
+		subSteps: [],
+		...over,
+	} as StageSnapshot;
+}
+
+describe('S8 order-execution provenance — 缺失事实不得被兜底值顶替', () => {
+	// ── ② MissingRealFact_DoesNotBecomeBaselineFact ──
+
+	it('空的 currentStatus 不得兜底成 green(否则每个真实订单都显示“正常”)', () => {
+		const row = mapDomainOrderListItem(realListDto() as never) as SalesOrderExecution;
+		expect(row.nodeStatus).toBeNull();
+	});
+
+	it('后端真的下发评级时仍按原值透传', () => {
+		for (const v of ['green', 'yellow', 'red'] as const) {
+			const row = mapDomainOrderListItem(realListDto({ currentStatus: v }) as never) as SalesOrderExecution;
+			expect(row.nodeStatus).toBe(v);
+		}
+	});
+
+	it('无法识别的评级值判为未知,而不是悄悄变成 green', () => {
+		const row = mapDomainOrderListItem(realListDto({ currentStatus: 'BOGUS' }) as never) as SalesOrderExecution;
+		expect(row.nodeStatus).toBeNull();
+	});
+
+	it('三项时间 KPI 全无 Authority 时必须是 null,不得是 0', () => {
+		const orders = [
+			{ responseDisplayMinutes: null, currentProcessingMinutes: null, totalLossTime: null },
+			{ responseDisplayMinutes: null, currentProcessingMinutes: null, totalLossTime: null },
+		] as unknown as SalesOrderExecution[];
+
+		const kpi = computeOrderExecutionKpis(orders);
+		expect(kpi.avgResponse).toBeNull();
+		expect(kpi.avgProcessing).toBeNull();
+		expect(kpi.avgLoss).toBeNull();
+	});
+
+	it('有真实数值时仍照常求平均(不因本次修复而失真)', () => {
+		const orders = [
+			{ responseDisplayMinutes: 10, currentProcessingMinutes: 4, totalLossTime: 100 },
+			{ responseDisplayMinutes: 20, currentProcessingMinutes: 6, totalLossTime: 200 },
+		] as unknown as SalesOrderExecution[];
+
+		const kpi = computeOrderExecutionKpis(orders);
+		expect(kpi.avgResponse).toBe(15);
+		expect(kpi.avgProcessing).toBe(5);
+		expect(kpi.avgLoss).toBe(150);
+	});
+
+	it('getAverage 对空集合本就返回 null —— 该语义不得再被 ?? 0 吃掉', () => {
+		expect(getAverage([])).toBeNull();
+		expect(getAverage([2, 4])).toBe(3);
+	});
+
+	// ── ③ BaselineDuration_IsNotRenderedAsActualDuration ──
+	// ── ④ BaselineTimestamp_IsNotRenderedAsActualTimestamp ──
+
+	it('没有真实完成时间/耗时的阶段不得被标成已完成', () => {
+		const node = computeNodeStatus(stage({ targetDate: '2026-08-01 12:00', actualDays: 5 }));
+		expect(node.label).not.toContain('已完成');
+	});
+
+	it('仅有目标时间(计划值)不产生任何实际达成结论', () => {
+		const planOnly = computeNodeStatus(stage({ targetDate: '2026-08-01 12:00' }));
+		const nothing = computeNodeStatus(stage());
+		expect(planOnly.label).toBe(nothing.label);
+	});
+
+	// ── ⑥ Stage3_DataState_RemainsBackendAuthority ──
+
+	it('DataState 优先于任何时间形态推断', () => {
+		// 即便带着「看起来已完成」的时间,DataState 仍必须赢
+		const withTimes = stage({
+			dataState: 'NO_DATA',
+			actualStartedAt: '2026-08-01 09:00',
+			actualReachedAt: '2026-08-09 18:00',
+		});
+		expect(computeNodeStatus(withTimes).label).toBe('暂无已归属数据');
+	});
+
+	it('DataState 三态均不得出现「已完成」语义', () => {
+		for (const s of ['IN_PROGRESS', 'NO_DATA', 'UNKNOWN'] as const) {
+			expect(computeNodeStatus(stage({ dataState: s })).label).not.toContain('已完成');
+		}
+	});
+
+	it('已归属计数只在 IN_PROGRESS 且确有计数时出现', () => {
+		expect(computeStageDataHint(stage({ dataState: 'NO_DATA', knownItemCount: 0 }))).toBeNull();
+		expect(computeStageDataHint(stage({ dataState: 'UNKNOWN' }))).toBeNull();
+		const hint = computeStageDataHint(
+			stage({ dataState: 'IN_PROGRESS', knownItemCount: 8, completedItemCount: 3 }),
+		);
+		expect(hint).toContain('8');
+		expect(hint).toContain('3');
+	});
+});

+ 6 - 5
Web/src/views/aidop/s8/monitoring/data/order-execution/types.ts

@@ -105,7 +105,7 @@ export interface SalesOrderExecution {
 	currentNode: string;
 	currentNodeKey: OrderNodeKey;
 	focusNodeKey: OrderNodeKey;
-	nodeStatus: 'green' | 'yellow' | 'red';
+	nodeStatus: 'green' | 'yellow' | 'red' | null;
 	observedAt: string;
 	orderStartDate: string;
 	/** SEED 的目标周期;REAL runtime 无 Authority,恒为 null。 */
@@ -244,7 +244,7 @@ export interface ExecutionRawNodeDetail {
 export interface ExecutionRawWorkOrder {
 	id: string;
 	node: string;
-	nodeStatus: 'green' | 'yellow' | 'red';
+	nodeStatus: 'green' | 'yellow' | 'red' | null;
 	responseTime: number | null;
 	processingTime: number | null;
 	status: '待响应' | '进行中' | '已完成' | '超时';
@@ -294,9 +294,10 @@ export interface OrderExecutionFilters {
 }
 
 export interface OrderExecutionKpiAverages {
-	avgResponse: number;
-	avgProcessing: number;
-	avgLoss: number;
+	// null = 无样本 / 无 Authority。禁止用 0 顶替——0 会被读成「响应为 0 分钟」。
+	avgResponse: number | null;
+	avgProcessing: number | null;
+	avgLoss: number | null;
 }
 
 // ── 产品设计图号 (S8-ORDER-CHAIN-PRODUCT-DESIGN-DRAWING-PERSIST-1) ──