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

feat(s8): show real stage3 procurement on order chain | web 2.4.402

订单链路全景的「材料采购」此前渲染的是 ado_s8_order_flow_procurement_pivot ——
100% SEED:17 个虚构单号、0 命中真实销售订单、租户号还比真实租户多 1。
这一批让它在真实订单上显示真数据。

新增 ProcurementStage3Panel,只消费一个接口:
GET /api/aidop/s8/order-flow/orders/by-id/{salesOrderId}/stage3
summary 与 lines 同一次响应消费,不拆两个请求,免得两半数据时点对不上。
面板内不做任何业务判断,只做 label 映射:不按数量推完成、不读
mdp_std_purchase_order.status、不把多张检验单化简成单一结论。

最容易出错的是「缺数据」和「未完成」的区别,UI 上三处都分开了:
NOT_OBSERVED 显示「未观测」而不是「未完成」;MULTIPLE_UNRESOLVED 显示
「多次检验待归并」而不是已接受/不合格;object UNKNOWN 显示「信息不足」。
Summary 除了 4 个正向指标,还把未知/未观测/待归并逐项列出来,
否则用户会把「没观测到」读成「没做完」。

范围诚实性:标题栏常驻「已归属数据」标签 + 范围说明 tooltip,
百分比字段叫「已归属对象完成率」而不是「完成率」——分母只是能可靠归属到
该订单的采购行,不是采购需求全集。页面任何情况下都不出现「阶段已完成 / 100%」。

真实面板只在单订单态且该订单有 salesOrderId 时出现;多订单态与 SEED fixture
仍走原采购矩阵,两路不混用。真实面板在场时不再拉 SEED 的 procurement-pivot ——
那份数据此时既不渲染也不消费,白发一次请求只会污染 Network 面板与后端日志。

踩到并修掉一个 TDZ:showStage3Real 原本声明在 immediate watch 之后,
<script setup> 里 const 有暂时性死区,首帧会抛 ReferenceError。

Chrome 实测(超管切到 UATTEST_CHL 租户,验完切回原账号原租户):
SO202608240001 → 8 行,PO202608260002 L1 采购已完成、质量未观测、对象信息不足;
SO202609030004 → 6 行;SO202608100001 → 7 行中 2 行未观测 / 5 行未完成,三态可分。
每张订单只发一次 stage3 请求且均 200,Console 零 error,
Stage1/2/4/5 与多订单态无回归。vue-tsc 584/基线 597 净增 0,build 通过。
YY968XX 4 дней назад
Родитель
Сommit
406e952f6b

+ 1 - 1
Web/package.json

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

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

@@ -456,6 +456,91 @@ export function getOrderFlowFinalAssemblyPivot(query: OrderFlowFinalAssemblyPivo
 		.then(unwrap);
 }
 
+// ────────────────────────────────────────────────────────────
+// Stage-3 材料采购 · 只读模型(REAL runtime)
+// 后端 S8Stage3ProcurementReadService / AdoS8Stage3ProcurementDtos.cs。
+// 数据中台标准层 query-time 组合:采购身份 × WMS 采购完成态 × 来料检验质量。
+// ────────────────────────────────────────────────────────────
+
+/** COMPLETED=已完成 / NOT_COMPLETED=未完成 / UNKNOWN=未知 / NOT_OBSERVED=未观测(WMS 侧无该行)。 */
+export type Stage3ProcurementStatus = 'COMPLETED' | 'NOT_COMPLETED' | 'UNKNOWN' | 'NOT_OBSERVED';
+
+/** 检验单能否唯一落到该采购行。 */
+export type Stage3IqcAttributionStatus = 'UNIQUE' | 'UNATTRIBUTED' | 'AMBIGUOUS';
+
+/** MULTIPLE_UNRESOLVED = 多张可归属检验单,归并规则未冻结,后端不自动消解。 */
+export type Stage3QualityStatus = 'ACCEPTED' | 'REJECTED' | 'PENDING' | 'NOT_OBSERVED' | 'MULTIPLE_UNRESOLVED';
+
+/** UNKNOWN = 两轴任一缺数据,绝不等同于未完成。 */
+export type Stage3ObjectStatus = 'COMPLETED' | 'NOT_COMPLETED' | 'UNKNOWN';
+
+export interface Stage3ProcurementLine {
+	salesOrderNo: string | null;
+	/** 后端 long → JSON 字符串(雪花 Id 超出 JS 安全整数范围),只做等值传递与展示。 */
+	salesOrderEntryId: number | string | null;
+	poNo: string;
+	poLine: string;
+	purchaseRequestNo: string | null;
+	/** 取自采购申请标准层,仅作上下文,不参与归属。 */
+	workOrder: string | null;
+	materialCode: string | null;
+	materialName: string | null;
+	/** 标准层暂无该字段,恒 null —— 禁止从别的来源补。 */
+	specification: string | null;
+	supplierCode: string | null;
+	supplierName: string | null;
+	orderQty: number | null;
+	dueDate: string | null;
+	procurementStatus: Stage3ProcurementStatus;
+	iqcAttributionStatus: Stage3IqcAttributionStatus;
+	iqcBillCount: number;
+	qualityStatus: Stage3QualityStatus;
+	objectStatus: Stage3ObjectStatus;
+	completionFactObserved: boolean;
+	qualityFactObserved: boolean;
+}
+
+export interface Stage3ProcurementSummary {
+	attributedPoLineCount: number;
+	procurementCompletedCount: number;
+	procurementNotCompletedCount: number;
+	procurementUnknownCount: number;
+	procurementNotObservedCount: number;
+	qualityAcceptedCount: number;
+	qualityRejectedCount: number;
+	qualityPendingCount: number;
+	qualityNotObservedCount: number;
+	qualityMultipleUnresolvedCount: number;
+	objectCompletedCount: number;
+	objectNotCompletedCount: number;
+	objectUnknownCount: number;
+	hasUnresolvedQuality: boolean;
+	hasUnknownProcurement: boolean;
+	/** 分母只是已归属子集,标签必须写「已归属对象完成率」,不可简写成完成率。 */
+	knownObjectCompletionRate: number | null;
+}
+
+export interface Stage3ProcurementDetail {
+	salesOrderId: number | string;
+	salesOrderNo: string | null;
+	/** 恒为 ATTRIBUTED_ONLY:只含能可靠归属到本订单的采购行,不是采购需求全集。 */
+	dataScope: string;
+	summary: Stage3ProcurementSummary;
+	lines: Stage3ProcurementLine[];
+}
+
+/**
+ * Stage-3 材料采购只读模型。定位 Authority = 当前租户(JWT) + salesOrderId,
+ * 与 getRealOrderFlowOrderById 同口径,故不附加 tenant 查询参数。
+ */
+export function getRealOrderStage3Procurement(salesOrderId: number | string) {
+	return service
+		.get<Stage3ProcurementDetail | null>(
+			`${BASE}/orders/by-id/${encodeURIComponent(String(salesOrderId))}/stage3`,
+		)
+		.then(unwrap);
+}
+
 export const s8OrderFlowDomainApi = {
 	getOrderFlowOrders,
 	getOrderFlowOrder,
@@ -465,4 +550,5 @@ export const s8OrderFlowDomainApi = {
 	getOrderFlowProductDesignDrawings,
 	getOrderFlowManufacturingPivot,
 	getOrderFlowFinalAssemblyPivot,
+	getRealOrderStage3Procurement,
 };

+ 78 - 3
Web/src/views/aidop/s8/monitoring/OrderChainOverviewPage.vue

@@ -18,6 +18,7 @@ import ReviewSubStepsCard from './components/order-execution/ReviewSubStepsCard.
 import ReviewBreakdownCard from './components/order-execution/ReviewBreakdownCard.vue';
 import DesignDetailPanel from './components/order-execution/DesignDetailPanel.vue';
 import ProcurementDetailPanel from './components/order-execution/ProcurementDetailPanel.vue';
+import ProcurementStage3Panel from './components/order-execution/ProcurementStage3Panel.vue';
 import ManufacturingDetailPanel from './components/order-execution/ManufacturingDetailPanel.vue';
 import FinalAssemblyCollabPanel from './components/order-execution/FinalAssemblyCollabPanel.vue';
 import {
@@ -39,6 +40,9 @@ import {
 	getOrderFlowFinalAssemblyPivot,
 	// S8-STEP5B-ORDER-CHAIN-CLOSURE-1(P-2):筛选态 collab summary 需按 orderCodes 重取。
 	getOrderFlowAggregate,
+	// Stage-3 材料采购真实只读模型(数据中台标准层 query-time 组合)。
+	getRealOrderStage3Procurement,
+	type Stage3ProcurementDetail,
 } from '/@/views/aidop/s8/api/s8OrderFlowDomainApi';
 import { mapFinalAssemblyCollabSummary } from '/@/views/aidop/s8/monitoring/data/order-execution/domainMapper';
 // ORDER-FLOW-CHAIN-PAGE2-ORIGINAL-LOGIC-RESTORE-1:baseline (isUnfiltered) 态下 L2/L3 使用 PPT 常量。
@@ -438,6 +442,13 @@ async function loadProcurementPivot() {
 	}
 }
 
+// 只在单订单态取真实 Stage-3:Read Model 是按单张销售订单归因的,
+// 多订单态下拿任意一单的采购行去顶替整个筛选范围会误导。
+const stage3SalesOrderId = computed(() =>
+	filteredChainOrders.value.length === 1 ? (detailOrder.value?.salesOrderId ?? null) : null,
+);
+const showStage3Real = computed(() => showProcurementDetail.value && !!stage3SalesOrderId.value);
+
 watch(
 	[
 		() => showProcurementDetail.value,
@@ -445,7 +456,57 @@ watch(
 		() => filteredChainOrders.value,
 	],
 	([active]) => {
-		if (active) void loadProcurementPivot();
+		// 真实 Stage-3 面板在场时不再拉 SEED 采购矩阵:那份数据此时既不渲染也不消费,
+		// 白发一次请求只会污染 Network 面板与后端日志。
+		if (active && !showStage3Real.value) void loadProcurementPivot();
+	},
+	{ immediate: true },
+);
+
+// ── Stage-3 材料采购 · 真实只读模型 ────────────────────────────────────────────
+// 唯一数据源是 Stage3 Read Model(后端按 salesOrderId + 当前租户定位),
+// summary 与 lines 同一次响应,不拆两个请求,避免两份数据时点不一致。
+// 只有真实订单(有 salesOrderId)才走这条;SEED fixture 仍走原采购矩阵,两路不混用。
+const stage3Detail = ref<Stage3ProcurementDetail | null>(null);
+const stage3Loading = ref(false);
+const stage3Error = ref<string | null>(null);
+let stage3RequestSeq = 0;
+
+async function loadStage3Procurement() {
+	const id = stage3SalesOrderId.value;
+	if (!id) {
+		stage3Detail.value = null;
+		return;
+	}
+	const mySeq = ++stage3RequestSeq;
+	stage3Loading.value = true;
+	stage3Error.value = null;
+	try {
+		const detail = await getRealOrderStage3Procurement(id);
+		if (mySeq !== stage3RequestSeq) return;
+		stage3Detail.value = detail;
+	} catch {
+		if (mySeq !== stage3RequestSeq) return;
+		stage3Error.value = '材料采购数据加载失败,请稍后重试';
+		stage3Detail.value = null;
+	} finally {
+		if (mySeq === stage3RequestSeq) stage3Loading.value = false;
+	}
+}
+
+// 订单一变就先清空,避免切单瞬间残留上一订单的采购行
+watch(
+	() => stage3SalesOrderId.value,
+	() => {
+		stage3Detail.value = null;
+		stage3Error.value = null;
+	},
+);
+
+watch(
+	[() => showStage3Real.value, () => stage3SalesOrderId.value],
+	([active]) => {
+		if (active) void loadStage3Procurement();
 	},
 	{ immediate: true },
 );
@@ -680,8 +741,15 @@ watch(
 					:loading="productDesignLoading"
 					:error-message="productDesignError"
 				/>
+			<ProcurementStage3Panel
+				v-if="showStage3Real"
+				:detail="stage3Detail"
+				:loading="stage3Loading"
+				:error-message="stage3Error"
+				@refresh="loadStage3Procurement"
+			/>
 			<ProcurementDetailPanel
-				v-if="showProcurementDetail"
+				v-if="showProcurementDetail && !showStage3Real"
 				:api-detail="procurementApiDetail"
 				:loading="procurementApiLoading"
 				:error-message="procurementApiError"
@@ -754,8 +822,15 @@ watch(
 					:loading="productDesignLoading"
 					:error-message="productDesignError"
 				/>
+			<ProcurementStage3Panel
+				v-if="showStage3Real"
+				:detail="stage3Detail"
+				:loading="stage3Loading"
+				:error-message="stage3Error"
+				@refresh="loadStage3Procurement"
+			/>
 			<ProcurementDetailPanel
-				v-if="showProcurementDetail"
+				v-if="showProcurementDetail && !showStage3Real"
 				:api-detail="procurementApiDetail"
 				:loading="procurementApiLoading"
 				:error-message="procurementApiError"

+ 431 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/ProcurementStage3Panel.vue

@@ -0,0 +1,431 @@
+<script setup lang="ts" name="ProcurementStage3Panel">
+// Stage-3 材料采购 · 真实只读模型面板。
+// 唯一数据来源:GET /api/aidop/s8/order-flow/orders/by-id/{salesOrderId}/stage3
+// (后端 query-time 组合数据中台标准层:采购身份 × WMS 采购完成态 × 来料检验质量)。
+//
+// 本面板只做 label / tag 映射,不重新判断任何业务结论:
+//   · 不按数量推采购完成,也不读 mdp_std_purchase_order.status;
+//   · 不把 MULTIPLE_UNRESOLVED 化简成已接受或不合格;
+//   · 不把 NOT_OBSERVED 显示成未完成 —— 「没观测到」与「确认没做完」是两回事。
+//
+// 范围恒为 ATTRIBUTED_ONLY:只含能可靠归属到本订单的采购行,不是采购需求全集,
+// 因此本面板任何情况下都不出现「阶段已完成 / 100%」。
+import { computed, ref, watch } from 'vue';
+import type {
+	Stage3ProcurementDetail,
+	Stage3ProcurementLine,
+	Stage3ObjectStatus,
+	Stage3ProcurementStatus,
+	Stage3QualityStatus,
+} from '/@/views/aidop/s8/api/s8OrderFlowDomainApi';
+
+interface Props {
+	detail: Stage3ProcurementDetail | null;
+	loading?: boolean;
+	errorMessage?: string | null;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	loading: false,
+	errorMessage: null,
+});
+
+const emit = defineEmits<{ (e: 'refresh'): void }>();
+
+// ── 状态文案:与后端 enum 一一对应,前端不新增语义 ─────────────────────────────
+const PROCUREMENT_LABEL: Record<Stage3ProcurementStatus, string> = {
+	COMPLETED: '已完成',
+	NOT_COMPLETED: '未完成',
+	UNKNOWN: '未知',
+	NOT_OBSERVED: '未观测',
+};
+const QUALITY_LABEL: Record<Stage3QualityStatus, string> = {
+	ACCEPTED: '已接受',
+	REJECTED: '不合格',
+	PENDING: '待判定',
+	NOT_OBSERVED: '未观测',
+	MULTIPLE_UNRESOLVED: '多次检验待归并',
+};
+// UNKNOWN 写成「信息不足」比「未知」更容易被业务读懂,但代码值仍是后端 enum。
+const OBJECT_LABEL: Record<Stage3ObjectStatus, string> = {
+	COMPLETED: '已完成',
+	NOT_COMPLETED: '未完成',
+	UNKNOWN: '信息不足',
+};
+
+type TagType = 'success' | 'warning' | 'danger' | 'info';
+const PROCUREMENT_TAG: Record<Stage3ProcurementStatus, TagType> = {
+	COMPLETED: 'success',
+	NOT_COMPLETED: 'warning',
+	UNKNOWN: 'info',
+	NOT_OBSERVED: 'info',
+};
+const QUALITY_TAG: Record<Stage3QualityStatus, TagType> = {
+	ACCEPTED: 'success',
+	REJECTED: 'danger',
+	PENDING: 'warning',
+	NOT_OBSERVED: 'info',
+	MULTIPLE_UNRESOLVED: 'warning',
+};
+const OBJECT_TAG: Record<Stage3ObjectStatus, TagType> = {
+	COMPLETED: 'success',
+	NOT_COMPLETED: 'warning',
+	UNKNOWN: 'info',
+};
+
+const IQC_ATTRIBUTION_LABEL: Record<string, string> = {
+	UNIQUE: '唯一归属',
+	UNATTRIBUTED: '无可归属检验单',
+	AMBIGUOUS: '归属多义(同单同料命中多行)',
+};
+
+// ── 本地筛选:返回行数很小(单订单已归属采购行),本地过滤即可,不动后端分页 ──────
+const keyword = ref('');
+const procurementFilter = ref<'' | Stage3ProcurementStatus>('');
+const qualityFilter = ref<'' | Stage3QualityStatus>('');
+const objectFilter = ref<'' | Stage3ObjectStatus>('');
+
+// 切订单时清掉筛选,避免上一单的筛选把新单过滤成空表
+watch(
+	() => props.detail?.salesOrderId,
+	() => {
+		keyword.value = '';
+		procurementFilter.value = '';
+		qualityFilter.value = '';
+		objectFilter.value = '';
+	},
+);
+
+const allLines = computed<Stage3ProcurementLine[]>(() => props.detail?.lines ?? []);
+
+const lines = computed<Stage3ProcurementLine[]>(() => {
+	const kw = keyword.value.trim().toLowerCase();
+	return allLines.value.filter((l) => {
+		if (procurementFilter.value && l.procurementStatus !== procurementFilter.value) return false;
+		if (qualityFilter.value && l.qualityStatus !== qualityFilter.value) return false;
+		if (objectFilter.value && l.objectStatus !== objectFilter.value) return false;
+		if (!kw) return true;
+		return [l.poNo, l.materialCode, l.materialName, l.supplierName]
+			.some((v) => (v ?? '').toLowerCase().includes(kw));
+	});
+});
+
+const summary = computed(() => props.detail?.summary ?? null);
+const isAttributedOnly = computed(() => props.detail?.dataScope === 'ATTRIBUTED_ONLY');
+const isEmpty = computed(
+	() => !props.loading && !props.errorMessage && (props.detail?.summary.attributedPoLineCount ?? 0) === 0,
+);
+const hasFilter = computed(
+	() => !!(keyword.value.trim() || procurementFilter.value || qualityFilter.value || objectFilter.value),
+);
+
+function dash(v: unknown): string {
+	return v === null || v === undefined || v === '' ? '-' : String(v);
+}
+function formatQty(v: number | null): string {
+	return v === null || v === undefined ? '-' : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 3 });
+}
+function formatDate(v: string | null): string {
+	return v ? String(v).slice(0, 10) : '-';
+}
+function formatRate(v: number | null | undefined): string {
+	return v === null || v === undefined ? '-' : `${v}%`;
+}
+function qualityTooltip(l: Stage3ProcurementLine): string {
+	const attr = IQC_ATTRIBUTION_LABEL[l.iqcAttributionStatus] ?? l.iqcAttributionStatus;
+	return `检验单归属:${attr};命中检验单 ${l.iqcBillCount} 张`;
+}
+</script>
+
+<template>
+	<section class="stage3-panel">
+		<header class="stage3-panel__head">
+			<span class="stage3-panel__bar" />
+			<h2 class="stage3-panel__title">材料采购 · 阶段详情</h2>
+			<el-tag v-if="isAttributedOnly" size="small" type="info" effect="plain" class="stage3-panel__scope">
+				已归属数据
+			</el-tag>
+			<el-tooltip
+				v-if="isAttributedOnly"
+				placement="top"
+				content="当前仅统计能够可靠归属到该订单的采购行,不代表完整采购需求集合。"
+			>
+				<span class="stage3-panel__scope-hint">范围说明</span>
+			</el-tooltip>
+			<span class="stage3-panel__spacer" />
+			<el-button size="small" text :disabled="loading" @click="emit('refresh')">刷新</el-button>
+		</header>
+
+		<div v-if="loading" class="stage3-panel__hint">材料采购数据加载中</div>
+		<div v-else-if="errorMessage" class="stage3-panel__hint stage3-panel__hint--error">
+			{{ errorMessage || '材料采购数据加载失败' }}
+		</div>
+		<div v-else-if="isEmpty" class="stage3-panel__hint">当前暂无可可靠归属到该订单的采购数据</div>
+
+		<template v-else-if="detail && summary">
+			<!-- Summary -->
+			<div class="stage3-panel__block">
+				<div class="stage3-panel__caption">
+					阶段概览(统计口径:已可靠归属到本订单的采购行)
+				</div>
+				<div class="stage3-panel__metrics">
+					<div class="stage3-metric">
+						<span class="stage3-metric__label">已归属采购行</span>
+						<span class="stage3-metric__value">{{ summary.attributedPoLineCount }}</span>
+					</div>
+					<div class="stage3-metric">
+						<span class="stage3-metric__label">采购已完成</span>
+						<span class="stage3-metric__value">{{ summary.procurementCompletedCount }}</span>
+					</div>
+					<div class="stage3-metric">
+						<span class="stage3-metric__label">质量已接受</span>
+						<span class="stage3-metric__value">{{ summary.qualityAcceptedCount }}</span>
+					</div>
+					<div class="stage3-metric">
+						<span class="stage3-metric__label">已完成对象</span>
+						<span class="stage3-metric__value">{{ summary.objectCompletedCount }}</span>
+					</div>
+					<div v-if="summary.knownObjectCompletionRate !== null" class="stage3-metric">
+						<span class="stage3-metric__label">已归属对象完成率</span>
+						<span class="stage3-metric__value">{{ formatRate(summary.knownObjectCompletionRate) }}</span>
+					</div>
+				</div>
+
+				<!-- 未知 / 未观测必须可见,否则用户会把「没数据」读成「没做完」 -->
+				<div class="stage3-panel__breakdown">
+					<span class="stage3-breakdown">
+						<b>采购</b>
+						完成 {{ summary.procurementCompletedCount }} ·
+						未完成 {{ summary.procurementNotCompletedCount }} ·
+						未知 {{ summary.procurementUnknownCount }} ·
+						未观测 {{ summary.procurementNotObservedCount }}
+					</span>
+					<span class="stage3-breakdown">
+						<b>质量</b>
+						接受 {{ summary.qualityAcceptedCount }} ·
+						不合格 {{ summary.qualityRejectedCount }} ·
+						待判定 {{ summary.qualityPendingCount }} ·
+						未观测 {{ summary.qualityNotObservedCount }} ·
+						待归并 {{ summary.qualityMultipleUnresolvedCount }}
+					</span>
+					<span class="stage3-breakdown">
+						<b>对象</b>
+						完成 {{ summary.objectCompletedCount }} ·
+						未完成 {{ summary.objectNotCompletedCount }} ·
+						信息不足 {{ summary.objectUnknownCount }}
+					</span>
+				</div>
+			</div>
+
+			<!-- Lines -->
+			<div class="stage3-panel__block">
+				<div class="stage3-panel__toolbar">
+					<span class="stage3-panel__caption">采购行明细</span>
+					<span class="stage3-panel__spacer" />
+					<el-input v-model="keyword" size="small" clearable placeholder="搜索采购单号 / 物料 / 供应商" class="stage3-panel__search" />
+					<el-select v-model="procurementFilter" size="small" clearable placeholder="采购状态" class="stage3-panel__select">
+						<el-option v-for="(label, key) in PROCUREMENT_LABEL" :key="key" :label="label" :value="key" />
+					</el-select>
+					<el-select v-model="qualityFilter" size="small" clearable placeholder="质量状态" class="stage3-panel__select">
+						<el-option v-for="(label, key) in QUALITY_LABEL" :key="key" :label="label" :value="key" />
+					</el-select>
+					<el-select v-model="objectFilter" size="small" clearable placeholder="对象状态" class="stage3-panel__select">
+						<el-option v-for="(label, key) in OBJECT_LABEL" :key="key" :label="label" :value="key" />
+					</el-select>
+				</div>
+
+				<div v-if="lines.length === 0 && hasFilter" class="stage3-panel__hint">
+					当前筛选条件下没有采购行
+				</div>
+				<table v-else class="stage3-panel__table">
+					<thead>
+						<tr>
+							<th>采购单号</th>
+							<th>行号</th>
+							<th>物料编码</th>
+							<th>物料名称</th>
+							<th>规格</th>
+							<th>供应商</th>
+							<th>采购数量</th>
+							<th>要求日期</th>
+							<th>采购状态</th>
+							<th>质量状态</th>
+							<th>对象状态</th>
+							<th>采购申请号</th>
+							<th>工单号</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr v-for="l in lines" :key="`${l.poNo}#${l.poLine}`">
+							<td>{{ dash(l.poNo) }}</td>
+							<td>{{ dash(l.poLine) }}</td>
+							<td>{{ dash(l.materialCode) }}</td>
+							<td class="stage3-panel__text">{{ dash(l.materialName) }}</td>
+							<td>{{ dash(l.specification) }}</td>
+							<td class="stage3-panel__text">{{ dash(l.supplierName ?? l.supplierCode) }}</td>
+							<td>{{ formatQty(l.orderQty) }}</td>
+							<td>{{ formatDate(l.dueDate) }}</td>
+							<td>
+								<el-tag size="small" :type="PROCUREMENT_TAG[l.procurementStatus]" effect="plain">
+									{{ PROCUREMENT_LABEL[l.procurementStatus] }}
+								</el-tag>
+							</td>
+							<td>
+								<el-tooltip placement="top" :content="qualityTooltip(l)">
+									<el-tag size="small" :type="QUALITY_TAG[l.qualityStatus]" effect="plain">
+										{{ QUALITY_LABEL[l.qualityStatus] }}
+									</el-tag>
+								</el-tooltip>
+							</td>
+							<td>
+								<el-tag size="small" :type="OBJECT_TAG[l.objectStatus]" effect="plain">
+									{{ OBJECT_LABEL[l.objectStatus] }}
+								</el-tag>
+							</td>
+							<td>{{ dash(l.purchaseRequestNo) }}</td>
+							<td>{{ dash(l.workOrder) }}</td>
+						</tr>
+					</tbody>
+				</table>
+			</div>
+		</template>
+	</section>
+</template>
+
+<style scoped>
+.stage3-panel {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+.stage3-panel__head {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+.stage3-panel__bar {
+	width: 5px;
+	height: 18px;
+	border-radius: 999px;
+	background: var(--order-accent, #7bd0ff);
+}
+.stage3-panel__title {
+	margin: 0;
+	font-size: 16px;
+	font-weight: 700;
+}
+.stage3-panel__scope-hint {
+	font-size: 12px;
+	color: var(--order-text-muted, #909097);
+	border-bottom: 1px dashed var(--order-border, rgba(144, 144, 151, 0.38));
+	cursor: help;
+}
+.stage3-panel__spacer {
+	flex: 1;
+}
+.stage3-panel__hint {
+	padding: 14px 16px;
+	border-radius: 12px;
+	background: var(--order-panel, rgba(25, 28, 34, 0.72));
+	border: 1px dashed var(--order-border, rgba(144, 144, 151, 0.18));
+	color: var(--order-text-secondary, #c6c6cd);
+	font-size: 13px;
+	text-align: center;
+}
+.stage3-panel__hint--error {
+	color: #ff8a8a;
+	border-style: solid;
+	border-color: rgba(255, 77, 79, 0.32);
+	background: rgba(255, 77, 79, 0.08);
+}
+.stage3-panel__block {
+	background: rgba(25, 28, 34, 0.55);
+	border: 1px solid rgba(69, 70, 77, 0.28);
+	border-radius: 10px;
+	padding: 12px;
+	overflow-x: auto;
+}
+.stage3-panel__caption {
+	font-size: 12px;
+	color: var(--order-text-muted, #909097);
+	margin-bottom: 8px;
+}
+.stage3-panel__toolbar {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 8px;
+	flex-wrap: wrap;
+}
+.stage3-panel__search {
+	width: 220px;
+}
+.stage3-panel__select {
+	width: 140px;
+}
+.stage3-panel__metrics {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+}
+.stage3-metric {
+	min-width: 132px;
+	flex: 1 1 132px;
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+	padding: 10px 12px;
+	border-radius: 8px;
+	background: rgba(15, 19, 26, 0.6);
+	border: 1px solid rgba(69, 70, 77, 0.22);
+}
+.stage3-metric__label {
+	font-size: 12px;
+	color: var(--order-text-muted, #909097);
+}
+.stage3-metric__value {
+	font-size: 20px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+.stage3-panel__breakdown {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 16px;
+	margin-top: 10px;
+	font-size: 12px;
+	color: var(--order-text-secondary, #c6c6cd);
+}
+.stage3-breakdown b {
+	color: var(--order-text-muted, #909097);
+	font-weight: 600;
+	margin-right: 4px;
+}
+.stage3-panel__table {
+	width: 100%;
+	border-collapse: collapse;
+	font-size: 12px;
+	color: var(--order-text-primary, #e1e2eb);
+}
+.stage3-panel__table th,
+.stage3-panel__table td {
+	padding: 8px 10px;
+	text-align: center;
+	border: 1px solid rgba(69, 70, 77, 0.18);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+	white-space: nowrap;
+}
+.stage3-panel__table th {
+	background: rgba(15, 19, 26, 0.7);
+	font-weight: 600;
+	font-family: 'PingFang SC', sans-serif;
+}
+.stage3-panel__text {
+	font-family: 'PingFang SC', sans-serif;
+	text-align: left;
+	max-width: 180px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+</style>