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

feat(s8): complete demo monitoring status and impact views

- R01 五阶段对照表新增「订单状态」行(按阶段 cumulativeVarianceDays 计算)
- R03 异常列表展示关闭时间 / 损失时间 / 是否超时关闭三列
- R03 CloseAsync 后追加 TryDispatchOverdueCloseNotificationAsync 关闭超时通知
- R04/R06 异常 30 天影响统计字段(repeatCount30d / cumulativeLossHours30d / impactScore /
  suggestedAttentionLevel / suggestedAttentionLabel / impactReason)+ 影响排序白名单
- R14 ProcurementDetailPanel 接入 getOrderFlowProcurementPivot 真实 API,默认不使用 fixture
- 版本号 Web 2.4.154 / server 1.0.118
YY968XX 4 дней назад
Родитель
Сommit
59bd413cfa

+ 1 - 1
Web/package.json

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

+ 10 - 0
Web/src/views/aidop/s8/api/s8ExceptionApi.ts

@@ -38,6 +38,9 @@ export interface S8ExceptionRow {
 	timeoutFlag: boolean;
 	createdAt: string;
 	closedAt?: string | null;
+	// S8-DEMO-CORE-FIELD-COMPLETE-1:损失时间(小时,1 位小数)与"是否超时关闭"冻结判定。
+	lossHours?: number | null;
+	isOverdueClosed?: boolean | null;
 	activeFlowInstanceId?: number | null;
 	activeFlowBizType?: string | null;
 	verifierId?: number | null;
@@ -60,6 +63,13 @@ export interface S8ExceptionRow {
 	orderFlowName?: string | null;
 	stageCode?: string | null;
 	ruleMechanism?: string | null;
+	// S8-DEMO-IMPACT-SORT-NOTICE-1:30 天影响统计(运行期计算,不落库)。
+	repeatCount30d?: number | null;
+	cumulativeLossHours30d?: number | null;
+	impactScore?: number | null;
+	suggestedAttentionLevel?: string | null;
+	suggestedAttentionLabel?: string | null;
+	impactReason?: string | null;
 }
 
 export interface S8DecisionRow {

+ 84 - 0
Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue

@@ -75,6 +75,14 @@
 			<el-form-item label="包含未分类">
 				<el-switch v-model="query.includeUnclassified" @change="loadList" />
 			</el-form-item>
+			<!-- S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序入口;默认仍 createdAt DESC,可切影响分 / 严重度。 -->
+			<el-form-item label="排序方式">
+				<el-select v-model="query.sortKey" style="width: 130px" @change="onSortChange">
+					<el-option label="创建时间" value="createdAt" />
+					<el-option label="影响分" value="impactScore" />
+					<el-option label="严重度" value="severity" />
+				</el-select>
+			</el-form-item>
 			<el-form-item>
 				<el-button type="primary" @click="loadList">查询</el-button>
 				<el-button @click="resetQuery">重置</el-button>
@@ -101,6 +109,22 @@
 			<el-table-column prop="statusLabel" label="状态" width="100" />
 			<el-table-column prop="severityLabel" label="严重度" width="90" />
 			<el-table-column prop="priorityLevel" label="优先级" width="90" />
+			<!-- S8-DEMO-IMPACT-SORT-NOTICE-1:建议关注 + 影响分(30 天窗口运行期计算)。 -->
+			<el-table-column label="建议关注" width="110" align="center">
+				<template #default="{ row }">
+					<el-tooltip
+						v-if="row.suggestedAttentionLevel"
+						:content="impactTooltip(row)"
+						placement="top"
+					>
+						<el-tag :type="attentionTagType(row.suggestedAttentionLevel)" size="small">{{ row.suggestedAttentionLabel || '—' }}</el-tag>
+					</el-tooltip>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="影响分" width="90" align="right">
+				<template #default="{ row }">{{ row.impactScore != null ? Number(row.impactScore).toFixed(1) : '—' }}</template>
+			</el-table-column>
 			<el-table-column label="发生部门" width="130" show-overflow-tooltip>
 				<template #default="{ row }">{{ deptDisplay(row.occurrenceDeptName, row.occurrenceDeptId) }}</template>
 			</el-table-column>
@@ -128,6 +152,20 @@
 					<el-tag :type="row.recoveredAt ? 'success' : 'warning'" size="small">{{ row.recoveredAt ? '已恢复' : '未恢复' }}</el-tag>
 				</template>
 			</el-table-column>
+			<!-- S8-DEMO-CORE-FIELD-COMPLETE-1:关闭时间 / 损失时间(小时) / 是否超时关闭(冻结)。与运行时"超时"列并列。 -->
+			<el-table-column label="关闭时间" width="170">
+				<template #default="{ row }">{{ row.closedAt || '—' }}</template>
+			</el-table-column>
+			<el-table-column label="损失时间(小时)" width="120" align="right">
+				<template #default="{ row }">{{ row.lossHours != null ? Number(row.lossHours).toFixed(1) : '—' }}</template>
+			</el-table-column>
+			<el-table-column label="是否超时关闭" width="120" align="center">
+				<template #default="{ row }">
+					<el-tag v-if="row.isOverdueClosed === true" type="danger" size="small">是</el-tag>
+					<el-tag v-else-if="row.isOverdueClosed === false" type="success" size="small">否</el-tag>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
 			<el-table-column prop="createdAt" label="创建时间" width="170" />
 		</el-table>
 
@@ -206,6 +244,10 @@ const query = reactive({
 	pageSize: 20,
 	tenantId: 1,
 	factoryId: 1,
+	// S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序入口(前端聚合 key + 后端透传 sortField/sortOrder)。
+	sortKey: 'createdAt' as 'createdAt' | 'impactScore' | 'severity',
+	sortField: '' as string,
+	sortOrder: '' as string,
 });
 
 // TASK-015-TREE-DEV-1:5 个真实父节点 + 未归类虚拟项。
@@ -297,6 +339,43 @@ function cleanRelatedObjectForDisplay(v: string | null | undefined): string {
 	return /^DEMO-/.test(v) ? '—' : v;
 }
 
+// S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序入口 → sortField/sortOrder;默认 createdAt DESC。
+function onSortChange() {
+	if (query.sortKey === 'impactScore') {
+		query.sortField = 'impactScore';
+		query.sortOrder = 'desc';
+	} else if (query.sortKey === 'severity') {
+		query.sortField = 'severity';
+		query.sortOrder = 'desc';
+	} else {
+		query.sortField = '';
+		query.sortOrder = '';
+	}
+	query.page = 1;
+	void loadList();
+}
+
+function attentionTagType(level: string | null | undefined) {
+	switch ((level ?? '').toUpperCase()) {
+		case 'HIGH':
+			return 'danger';
+		case 'MEDIUM':
+			return 'warning';
+		case 'LOW':
+			return 'success';
+		default:
+			return 'info';
+	}
+}
+
+function impactTooltip(row: S8ExceptionRow): string {
+	const parts: string[] = [];
+	if (row.repeatCount30d != null) parts.push(`30天重复 ${row.repeatCount30d} 次`);
+	if (row.cumulativeLossHours30d != null) parts.push(`累计损失 ${Number(row.cumulativeLossHours30d).toFixed(1)} 小时`);
+	if (row.impactReason) parts.push(row.impactReason);
+	return parts.length === 0 ? '—' : parts.join(';');
+}
+
 function ruleTypeTagType(t: string | null | undefined) {
 	switch (t) {
 		case 'OUT_OF_RANGE':
@@ -356,6 +435,8 @@ async function loadList() {
 			endTime: endStr || undefined,
 			relatedObjectCode: query.relatedObjectCode || undefined,
 			orderFlowCode: query.orderFlowCode || undefined,
+			sortField: query.sortField || undefined,
+			sortOrder: query.sortOrder || undefined,
 		});
 		rows.value = res.list;
 		total.value = res.total;
@@ -381,6 +462,9 @@ function resetQuery() {
 	query.relatedObjectCode = '';
 	query.orderFlowCode = '';
 	query.categoryFilter = '';
+	query.sortKey = 'createdAt';
+	query.sortField = '';
+	query.sortOrder = '';
 	dateStart.value = null;
 	dateEnd.value = null;
 	query.page = 1;

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

@@ -19,11 +19,14 @@ import ReviewBreakdownCard from './components/order-execution/ReviewBreakdownCar
 import DesignDetailPanel from './components/order-execution/DesignDetailPanel.vue';
 import ProcurementDetailPanel from './components/order-execution/ProcurementDetailPanel.vue';
 import ManufacturingDetailPanel from './components/order-execution/ManufacturingDetailPanel.vue';
+import FinalAssemblyCollabPanel from './components/order-execution/FinalAssemblyCollabPanel.vue';
 import {
 	DESIGN_DETAIL_FIXTURE,
-	PROCUREMENT_DETAIL_FIXTURE,
 	MANUFACTURING_DETAIL_FIXTURE,
+	adaptProcurementPivotFromApi,
+	type ProcurementDetailFromApi,
 } from '/@/views/aidop/s8/monitoring/data/order-execution/stage-detail';
+import { getOrderFlowProcurementPivot } from '/@/views/aidop/s8/api/s8OrderFlowDomainApi';
 // ORDER-FLOW-CHAIN-PAGE2-ORIGINAL-LOGIC-RESTORE-1:baseline (isUnfiltered) 态下 L2/L3 使用 PPT 常量。
 import {
 	PPT_REVIEW_SUBSTEPS,
@@ -240,6 +243,13 @@ const loadError = computed(() => store.loadError);
 // baseline 仅看订单样本数(isUnfiltered);selectedStageKeys 只控制列显示,不影响 baseline 切换。
 const showBaseline = computed(() => isUnfiltered.value && !!aggregateSnapshot.value);
 
+// ORDER-CHAIN-FINAL-ASSEMBLY-DEPT-COLLAB-MVP-1:点击总装发货时挂载末端协同面板。
+const showFinalAssemblyCollab = computed(() => activeStageKey.value === 'final_assembly_shipping');
+const finalStageSnapshot = computed(() =>
+	aggregateSnapshot.value?.stageSnapshots.find((s) => s.stageKey === 'final_assembly_shipping') ?? null,
+);
+const finalCollabSummary = computed(() => aggregateSnapshot.value?.finalAssemblyCollabSummary ?? null);
+
 function onRetry() {
 	// t3i:重试走 domain 流。
 	void (async () => {
@@ -249,6 +259,37 @@ function onRetry() {
 		if (focused) void store.loadChainFromDomain(focused);
 	})();
 }
+
+// S8-DEMO-R14-PROCUREMENT-MATRIX-LINK-1:采购矩阵真实 API 懒加载。
+// 仅在 material_procurement 节点激活且尚未加载时触发;失败仅显示错误态,不静默回退 fixture。
+const procurementApiDetail = ref<ProcurementDetailFromApi | null>(null);
+const procurementApiLoading = ref(false);
+const procurementApiError = ref<string | null>(null);
+const procurementApiLoaded = ref(false);
+
+async function loadProcurementPivot() {
+	if (procurementApiLoading.value || procurementApiLoaded.value) return;
+	procurementApiLoading.value = true;
+	procurementApiError.value = null;
+	try {
+		const pivot = await getOrderFlowProcurementPivot({ scope: 'BASELINE_PPT' });
+		procurementApiDetail.value = adaptProcurementPivotFromApi(pivot);
+		procurementApiLoaded.value = true;
+	} catch (e) {
+		procurementApiError.value = '采购矩阵数据加载失败,请稍后重试';
+		procurementApiDetail.value = null;
+	} finally {
+		procurementApiLoading.value = false;
+	}
+}
+
+watch(
+	() => showProcurementDetail.value,
+	(active) => {
+		if (active) void loadProcurementPivot();
+	},
+	{ immediate: true },
+);
 </script>
 
 <template>
@@ -327,8 +368,19 @@ function onRetry() {
 				title="意见评审 — 详细执行明细"
 			/>
 			<DesignDetailPanel v-if="showDesignDetail" :detail="DESIGN_DETAIL_FIXTURE" />
-			<ProcurementDetailPanel v-if="showProcurementDetail" :detail="PROCUREMENT_DETAIL_FIXTURE" />
+			<ProcurementDetailPanel
+				v-if="showProcurementDetail"
+				:api-detail="procurementApiDetail"
+				:loading="procurementApiLoading"
+				:error-message="procurementApiError"
+			/>
 			<ManufacturingDetailPanel v-if="showManufacturingDetail" :detail="MANUFACTURING_DETAIL_FIXTURE" />
+			<FinalAssemblyCollabPanel
+				v-if="showFinalAssemblyCollab"
+				:aggregate-final="finalStageSnapshot"
+				:collab-summary="finalCollabSummary"
+				:hit-count="filteredChainOrders.length"
+			/>
 		</template>
 		<template v-else-if="detailOrder">
 			<section v-if="isSingleOrderMode" class="oe-chain__summary">
@@ -375,8 +427,19 @@ function onRetry() {
 				title="意见评审 — 详细执行明细"
 			/>
 			<DesignDetailPanel v-if="showDesignDetail" :detail="DESIGN_DETAIL_FIXTURE" />
-			<ProcurementDetailPanel v-if="showProcurementDetail" :detail="PROCUREMENT_DETAIL_FIXTURE" />
+			<ProcurementDetailPanel
+				v-if="showProcurementDetail"
+				:api-detail="procurementApiDetail"
+				:loading="procurementApiLoading"
+				:error-message="procurementApiError"
+			/>
 			<ManufacturingDetailPanel v-if="showManufacturingDetail" :detail="MANUFACTURING_DETAIL_FIXTURE" />
+			<FinalAssemblyCollabPanel
+				v-if="showFinalAssemblyCollab"
+				:aggregate-final="finalStageSnapshot"
+				:collab-summary="finalCollabSummary"
+				:hit-count="filteredChainOrders.length"
+			/>
 		</template>
 
 		<section v-else class="oe-chain__empty">

+ 81 - 18
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderList.vue

@@ -12,6 +12,13 @@ import {
 	DEMO_TARGET_SO_NO,
 	DEMO_TOP_TITLE,
 } from '/@/views/aidop/s8/monitoring/data/order-execution/demo-step-config';
+import {
+	STAGE_STATUS_LABEL,
+	computeNodeStatus,
+	computeOrderStatus,
+	type OrderExecutionStatusTone,
+	type StatusVisual,
+} from './statusMapping';
 
 interface Props {
 	orders: SalesOrderExecution[];
@@ -124,21 +131,16 @@ const fmtVar = (v: number | null | undefined) => {
 const varTone = (v: number | null | undefined): 'over' | 'under' | 'neutral' =>
 	v == null ? 'neutral' : v > 0 ? 'over' : v < 0 ? 'under' : 'neutral';
 
-const STAGE_STATUS_LABEL: Record<StageSnapshot['status'], string> = {
-	green: '正常',
-	yellow: '关注',
-	red: '严重',
-	pending: '未开始',
-};
-
 interface StageColumn {
 	key: string;
 	name: string;
 	kpiText: string;
 	targetDate: string;
 	actualReachedAt: string;
-	status: StageSnapshot['status'];
+	status: OrderExecutionStatusTone;
 	statusLabel: string;
+	// S8-DEMO-CORE-FIELD-COMPLETE-1-FOLLOWUP-1:阶段级订单状态,基于该阶段自身 cumulativeVarianceDays。
+	orderStatus: StatusVisual;
 	nodeVarText: string;
 	nodeVarTone: 'over' | 'under' | 'neutral';
 	cumVarText: string;
@@ -151,14 +153,17 @@ function buildStageColumns(stages: StageSnapshot[]): StageColumn[] {
 			stage.actualDays != null && stage.nodeVarianceDays != null
 				? Number((stage.actualDays - stage.nodeVarianceDays).toFixed(1))
 				: null;
+		const node = computeNodeStatus(stage);
+		const order = computeOrderStatus(stage);
 		return {
 			key: stage.key,
 			name: stage.name,
 			kpiText: kpi != null ? kpi.toFixed(1) : '--',
 			targetDate: stage.targetDate || '--',
 			actualReachedAt: stage.actualReachedAt ?? '尚未达成',
-			status: stage.status,
-			statusLabel: STAGE_STATUS_LABEL[stage.status],
+			status: node.tone,
+			statusLabel: node.label,
+			orderStatus: order,
 			nodeVarText: fmtVar(stage.nodeVarianceDays),
 			nodeVarTone: varTone(stage.nodeVarianceDays),
 			cumVarText: fmtVar(stage.cumulativeVarianceDays),
@@ -167,6 +172,14 @@ function buildStageColumns(stages: StageSnapshot[]): StageColumn[] {
 	});
 }
 
+function nodeStatusOf(order: SalesOrderExecution) {
+	return computeNodeStatus(findStageByKey(order.lifecycle, order.currentNodeKey));
+}
+
+function orderStatusOf(order: SalesOrderExecution) {
+	return computeOrderStatus(order);
+}
+
 const rows = computed(() =>
 	props.orders.map((o) => ({
 		raw: o,
@@ -304,7 +317,7 @@ const rows = computed(() =>
 										<th>当前历时(天)</th>
 										<th>当前节点偏差(天)</th>
 										<th>累计偏差(天)</th>
-										<th>执行状态</th>
+										<th>状态</th>
 									</tr>
 								</thead>
 								<tbody>
@@ -316,13 +329,28 @@ const rows = computed(() =>
 										<td :class="`oe-var--${varTone(row.raw.nodeVarianceDays)}`">{{ fmtVar(row.raw.nodeVarianceDays) }}</td>
 										<td :class="`oe-var--${varTone(row.raw.cumulativeVarianceDays)}`">{{ fmtVar(row.raw.cumulativeVarianceDays) }}</td>
 										<td>
-											<span
-												class="oe-detail-status"
-												:class="`oe-detail-status--${row.raw.nodeStatus}`"
-											>
-												<span class="oe-detail-status__dot" />
-												{{ row.raw.exceptionStatus }}
-											</span>
+											<div class="oe-detail-status-stack">
+												<span class="oe-detail-status-stack__row">
+													<span class="oe-detail-status-stack__label">节点</span>
+													<span
+														class="oe-detail-status"
+														:class="`oe-detail-status--${nodeStatusOf(row.raw).tone}`"
+													>
+														<span class="oe-detail-status__dot" />
+														{{ nodeStatusOf(row.raw).label }}
+													</span>
+												</span>
+												<span class="oe-detail-status-stack__row">
+													<span class="oe-detail-status-stack__label">订单</span>
+													<span
+														class="oe-detail-status"
+														:class="`oe-detail-status--${orderStatusOf(row.raw).tone}`"
+													>
+														<span class="oe-detail-status__dot" />
+														{{ orderStatusOf(row.raw).label }}
+													</span>
+												</span>
+											</div>
 										</td>
 									</tr>
 								</tbody>
@@ -469,6 +497,21 @@ const rows = computed(() =>
 											<span v-else>--</span>
 										</td>
 									</tr>
+									<!-- S8-DEMO-CORE-FIELD-COMPLETE-1-FOLLOWUP-1:阶段级订单状态行,按该阶段 cumulativeVarianceDays 计算。 -->
+									<tr>
+										<td class="oe-detail-matrix-table__metric">订单状态</td>
+										<td v-for="col in buildStageColumns(row.raw.lifecycle)" :key="col.key">
+											<span
+												v-if="isStageRevealed(row.so, row.raw.lifecycle, col.key)"
+												class="oe-detail-matrix-table__status"
+												:class="`oe-detail-matrix-table__status--${col.orderStatus.tone}`"
+											>
+												<span class="oe-detail-matrix-table__status-dot" />
+												{{ col.orderStatus.label }}
+											</span>
+											<span v-else>--</span>
+										</td>
+									</tr>
 									<tr>
 										<td class="oe-detail-matrix-table__metric">当前节点偏差(天)</td>
 										<td
@@ -791,6 +834,26 @@ const rows = computed(() =>
 .oe-detail-status--green { color: #88fd54; }
 .oe-detail-status--yellow { color: #ffc107; }
 .oe-detail-status--red { color: #ff4d4f; }
+.oe-detail-status--pending { color: var(--order-text-muted, #909097); }
+
+.oe-detail-status-stack {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+	align-items: flex-start;
+}
+
+.oe-detail-status-stack__row {
+	display: inline-flex;
+	align-items: center;
+	gap: 6px;
+}
+
+.oe-detail-status-stack__label {
+	font-size: 10px;
+	color: var(--order-text-muted, #909097);
+	flex: 0 0 28px;
+}
 
 .oe-detail-matrix {
 	display: flex;

+ 289 - 78
Web/src/views/aidop/s8/monitoring/components/order-execution/ProcurementDetailPanel.vue

@@ -1,28 +1,98 @@
 <script setup lang="ts" name="ProcurementDetailPanel">
-// ORDER-FLOW-CHAIN-STAGE-DETAIL-MIGRATE-1:材料采购阶段结构化详情。fixture 来源 ecc-sandbox 硬编码;非真实数据。
-import { computed, ref } from 'vue';
+// ORDER-FLOW-CHAIN-STAGE-DETAIL-MIGRATE-1:材料采购阶段结构化详情。fixture 模式用于显式演示数据,非默认。
+// S8-DEMO-R14-PROCUREMENT-MATRIX-LINK-1:默认走真实 API(apiDetail),fixture 仅在 demoMode=true 时显示。
+import { computed, ref, watch } from 'vue';
 import {
 	PROCUREMENT_MATRIX_COLUMNS,
 	type ProcurementDetail,
+	type ProcurementDetailFromApi,
+	type ProcurementApiStatusTone,
 	type StageDetailStatus,
 } from '/@/views/aidop/s8/monitoring/data/order-execution/stage-detail';
 
-const props = defineProps<{ detail: ProcurementDetail }>();
+type PanelMode = 'api' | 'fixture';
 
-// ORDER-FLOW-CHAIN-PROCUREMENT-PIVOT-1:默认 YY(PPT 主案例 + 源项目默认)。
-const selectedMaterial = ref<'XX' | 'YY' | 'ZZ'>('YY');
+const props = defineProps<{
+	/** API 模式:真实数据;优先使用。 */
+	apiDetail?: ProcurementDetailFromApi | null;
+	/** 显式演示数据模式:仅当 demoMode=true 时使用 fixture,否则 fixture 不进入渲染。 */
+	detail?: ProcurementDetail;
+	/** 显式演示数据模式开关;默认 false。 */
+	demoMode?: boolean;
+	/** API 加载中。 */
+	loading?: boolean;
+	/** API 加载失败时的错误信息(任意非空字符串触发错误态)。 */
+	errorMessage?: string | null;
+}>();
 
-const MATRIX_COLUMNS = PROCUREMENT_MATRIX_COLUMNS;
+const mode = computed<PanelMode>(() => (props.demoMode && props.detail ? 'fixture' : 'api'));
 
-const supplierRows = computed(() => props.detail.supplierBreakdown.filter((r) => r.material === selectedMaterial.value));
-const specRows = computed(() => props.detail.specBreakdown.filter((r) => r.material === selectedMaterial.value));
-const currentMatrixRows = computed(() => props.detail.matrixByMaterial[selectedMaterial.value]);
+// ────────────────────────────────────────────────────────────
+// fixture 模式(演示数据)
+// ────────────────────────────────────────────────────────────
+const selectedFixtureMaterial = ref<'XX' | 'YY' | 'ZZ'>('YY');
+const FIXTURE_MATRIX_COLUMNS = PROCUREMENT_MATRIX_COLUMNS;
+const fixtureSupplierRows = computed(() => {
+	if (!props.detail) return [];
+	return props.detail.supplierBreakdown.filter((r) => r.material === selectedFixtureMaterial.value);
+});
+const fixtureSpecRows = computed(() => {
+	if (!props.detail) return [];
+	return props.detail.specBreakdown.filter((r) => r.material === selectedFixtureMaterial.value);
+});
+const fixtureMatrixRows = computed(() => {
+	if (!props.detail) return [];
+	return props.detail.matrixByMaterial[selectedFixtureMaterial.value];
+});
 
-function statusColor(s: StageDetailStatus): string {
+function fixtureStatusColor(s: StageDetailStatus): string {
 	if (s === 'red') return '#ff4d4f';
 	if (s === 'yellow') return '#ffc107';
 	return '#88fd54';
 }
+
+// ────────────────────────────────────────────────────────────
+// API 模式(真实数据)
+// ────────────────────────────────────────────────────────────
+const selectedApiMaterial = ref<string | null>(null);
+
+watch(
+	() => props.apiDetail,
+	(detail) => {
+		if (!detail) {
+			selectedApiMaterial.value = null;
+			return;
+		}
+		if (selectedApiMaterial.value && detail.sections.some((s) => s.materialCode === selectedApiMaterial.value)) {
+			return;
+		}
+		selectedApiMaterial.value = detail.defaultMaterialCode;
+	},
+	{ immediate: true },
+);
+
+const apiSection = computed(() => {
+	if (!props.apiDetail || !selectedApiMaterial.value) return null;
+	return props.apiDetail.sections.find((s) => s.materialCode === selectedApiMaterial.value) ?? null;
+});
+
+function apiToneColor(tone: ProcurementApiStatusTone): string {
+	if (tone === 'red') return '#ff4d4f';
+	if (tone === 'yellow') return '#ffc107';
+	if (tone === 'green') return '#88fd54';
+	return '#909097';
+}
+
+const isApiLoading = computed(() => mode.value === 'api' && !!props.loading);
+const apiErrorText = computed(() => (mode.value === 'api' ? (props.errorMessage ?? '').trim() : ''));
+const isApiError = computed(() => apiErrorText.value.length > 0);
+const isApiEmpty = computed(() => {
+	if (mode.value !== 'api') return false;
+	if (isApiLoading.value || isApiError.value) return false;
+	const d = props.apiDetail;
+	if (!d) return true;
+	return d.keyMaterials.length === 0 && d.sections.length === 0;
+});
 </script>
 
 <template>
@@ -30,89 +100,221 @@ function statusColor(s: StageDetailStatus): string {
 		<header class="proc-panel__head">
 			<span class="proc-panel__bar" />
 			<h2 class="proc-panel__title">材料采购 · 阶段详情</h2>
-			<span class="proc-panel__demo">演示数据</span>
+			<span v-if="mode === 'fixture'" class="proc-panel__demo">演示数据</span>
 		</header>
 
-		<div class="proc-panel__block">
-			<div class="proc-panel__caption">关键材料总览(点击切换下钻)</div>
-			<table class="proc-panel__table">
-				<thead>
-					<tr><th>关键材料</th><th>平均周期</th><th>周期状态</th><th>影响台次</th><th>准时齐套率</th><th>齐套状态</th></tr>
-				</thead>
-				<tbody>
-					<tr
-						v-for="row in detail.keyMaterials"
-						:key="row.material"
-						:class="{ 'proc-panel__row--active': row.material !== '合计' && row.material === selectedMaterial }"
-						@click="row.material !== '合计' && (selectedMaterial = row.material as 'XX' | 'YY' | 'ZZ')"
-					>
-						<td>{{ row.material }}</td>
-						<td :style="{ color: statusColor(row.cycleStatus), fontWeight: 600 }">{{ row.avgCycle }}</td>
-						<td><span class="proc-panel__dot" :style="{ background: statusColor(row.cycleStatus) }" /></td>
-						<td>{{ row.impactCount }}</td>
-						<td>{{ row.kitRate }}</td>
-						<td><span class="proc-panel__dot" :style="{ background: statusColor(row.resultStatus) }" /></td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
-
-		<div class="proc-panel__split">
-			<div class="proc-panel__block proc-panel__block--half">
-				<div class="proc-panel__caption">{{ selectedMaterial }} · 供应商表现</div>
+		<!-- fixture 模式(演示数据;非默认) -->
+		<template v-if="mode === 'fixture' && detail">
+			<div class="proc-panel__block">
+				<div class="proc-panel__caption">关键材料总览(点击切换下钻)</div>
 				<table class="proc-panel__table">
-					<thead><tr><th>供应商</th><th>平均周期</th><th>达标</th></tr></thead>
+					<thead>
+						<tr><th>关键材料</th><th>平均周期</th><th>周期状态</th><th>影响台次</th><th>准时齐套率</th><th>齐套状态</th></tr>
+					</thead>
 					<tbody>
-						<tr v-for="row in supplierRows" :key="`sup-${row.supplier}`">
-							<td>{{ row.supplier }}</td>
-							<td :style="{ color: statusColor(row.status), fontWeight: 600 }">{{ row.avgCycle }}</td>
-							<td><span class="proc-panel__dot" :style="{ background: statusColor(row.status) }" /></td>
+						<tr
+							v-for="row in detail.keyMaterials"
+							:key="row.material"
+							:class="{ 'proc-panel__row--active': row.material !== '合计' && row.material === selectedFixtureMaterial }"
+							@click="row.material !== '合计' && (selectedFixtureMaterial = row.material as 'XX' | 'YY' | 'ZZ')"
+						>
+							<td>{{ row.material }}</td>
+							<td :style="{ color: fixtureStatusColor(row.cycleStatus), fontWeight: 600 }">{{ row.avgCycle }}</td>
+							<td><span class="proc-panel__dot" :style="{ background: fixtureStatusColor(row.cycleStatus) }" /></td>
+							<td>{{ row.impactCount }}</td>
+							<td>{{ row.kitRate }}</td>
+							<td><span class="proc-panel__dot" :style="{ background: fixtureStatusColor(row.resultStatus) }" /></td>
 						</tr>
 					</tbody>
 				</table>
 			</div>
 
-			<div class="proc-panel__block proc-panel__block--half">
-				<div class="proc-panel__caption">{{ selectedMaterial }} · 规格表现</div>
+			<div class="proc-panel__split">
+				<div class="proc-panel__block proc-panel__block--half">
+					<div class="proc-panel__caption">{{ selectedFixtureMaterial }} · 供应商表现</div>
+					<table class="proc-panel__table">
+						<thead><tr><th>供应商</th><th>平均周期</th><th>达标</th></tr></thead>
+						<tbody>
+							<tr v-for="row in fixtureSupplierRows" :key="`sup-${row.supplier}`">
+								<td>{{ row.supplier }}</td>
+								<td :style="{ color: fixtureStatusColor(row.status), fontWeight: 600 }">{{ row.avgCycle }}</td>
+								<td><span class="proc-panel__dot" :style="{ background: fixtureStatusColor(row.status) }" /></td>
+							</tr>
+						</tbody>
+					</table>
+				</div>
+
+				<div class="proc-panel__block proc-panel__block--half">
+					<div class="proc-panel__caption">{{ selectedFixtureMaterial }} · 规格表现</div>
+					<table class="proc-panel__table">
+						<thead><tr><th>规格</th><th>平均周期</th><th>达标</th></tr></thead>
+						<tbody>
+							<tr v-for="row in fixtureSpecRows" :key="`spec-${row.spec}`">
+								<td>{{ row.spec }}</td>
+								<td :style="{ color: fixtureStatusColor(row.status), fontWeight: 600 }">{{ row.avgCycle }}</td>
+								<td><span class="proc-panel__dot" :style="{ background: fixtureStatusColor(row.status) }" /></td>
+							</tr>
+						</tbody>
+					</table>
+				</div>
+			</div>
+
+			<div class="proc-panel__block">
+				<div class="proc-panel__caption">{{ selectedFixtureMaterial }} 供应商 × 线材周期矩阵</div>
 				<table class="proc-panel__table">
-					<thead><tr><th>规格</th><th>平均周期</th><th>达标</th></tr></thead>
+					<thead>
+						<tr>
+							<th>供应商 / 线材</th>
+							<th v-for="col in FIXTURE_MATRIX_COLUMNS" :key="`h-${col}`">{{ col }}</th>
+						</tr>
+					</thead>
 					<tbody>
-						<tr v-for="row in specRows" :key="`spec-${row.spec}`">
-							<td>{{ row.spec }}</td>
-							<td :style="{ color: statusColor(row.status), fontWeight: 600 }">{{ row.avgCycle }}</td>
-							<td><span class="proc-panel__dot" :style="{ background: statusColor(row.status) }" /></td>
+						<tr v-for="row in fixtureMatrixRows" :key="row.supplier">
+							<td :style="{ fontWeight: row.supplier === '合计' ? 700 : 600 }">{{ row.supplier }}</td>
+							<td
+								v-for="col in FIXTURE_MATRIX_COLUMNS"
+								:key="`${row.supplier}-${col}`"
+								:style="{
+									color: fixtureStatusColor(row.values[col].status),
+									fontWeight: row.supplier === '合计' ? 700 : 500,
+								}"
+							>
+								{{ row.values[col].cycle }}
+							</td>
 						</tr>
 					</tbody>
 				</table>
 			</div>
-		</div>
-
-		<div class="proc-panel__block">
-			<div class="proc-panel__caption">{{ selectedMaterial }} 供应商 × 线材周期矩阵</div>
-			<table class="proc-panel__table">
-				<thead>
-					<tr>
-						<th>供应商 / 线材</th>
-						<th v-for="col in MATRIX_COLUMNS" :key="`h-${col}`">{{ col }}</th>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="row in currentMatrixRows" :key="row.supplier">
-						<td :style="{ fontWeight: row.supplier === '合计' ? 700 : 600 }">{{ row.supplier }}</td>
-						<td
-							v-for="col in MATRIX_COLUMNS"
-							:key="`${row.supplier}-${col}`"
-							:style="{
-								color: statusColor(row.values[col].status),
-								fontWeight: row.supplier === '合计' ? 700 : 500,
-							}"
-						>
-							{{ row.values[col].cycle }}
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+		</template>
+
+		<!-- API 模式(真实数据;默认) -->
+		<template v-else>
+			<div v-if="isApiLoading" class="proc-panel__placeholder">采购矩阵数据加载中…</div>
+			<div v-else-if="isApiError" class="proc-panel__placeholder proc-panel__placeholder--error">
+				{{ apiErrorText || '采购矩阵数据加载失败,请稍后重试' }}
+			</div>
+			<div v-else-if="isApiEmpty" class="proc-panel__placeholder">暂无采购矩阵数据</div>
+
+			<template v-else-if="apiDetail">
+				<div class="proc-panel__block">
+					<div class="proc-panel__caption">关键材料总览(点击切换下钻)</div>
+					<table class="proc-panel__table">
+						<thead>
+							<tr><th>关键材料</th><th>平均周期</th><th>周期状态</th><th>影响台次</th><th>准时齐套率</th><th>齐套状态</th></tr>
+						</thead>
+						<tbody>
+							<tr
+								v-for="row in apiDetail.keyMaterials"
+								:key="row.materialCode"
+								:class="{
+									'proc-panel__row--active':
+										row.materialCode !== 'TOTAL' && row.materialCode === selectedApiMaterial,
+								}"
+								@click="row.materialCode !== 'TOTAL' && (selectedApiMaterial = row.materialCode)"
+							>
+								<td>{{ row.displayMaterial }}</td>
+								<td :style="{ color: apiToneColor(row.cycleStatusTone), fontWeight: 600 }">
+									{{ row.avgCycle || '—' }}
+								</td>
+								<td>
+									<span class="proc-panel__dot" :style="{ background: apiToneColor(row.cycleStatusTone) }" />
+								</td>
+								<td>{{ row.impactCount || '—' }}</td>
+								<td>{{ row.kitRate || '—' }}</td>
+								<td>
+									<span class="proc-panel__dot" :style="{ background: apiToneColor(row.resultStatusTone) }" />
+								</td>
+							</tr>
+						</tbody>
+					</table>
+				</div>
+
+				<template v-if="apiSection">
+					<div class="proc-panel__split">
+						<div class="proc-panel__block proc-panel__block--half">
+							<div class="proc-panel__caption">{{ apiSection.displayMaterial }} · 供应商表现</div>
+							<table class="proc-panel__table">
+								<thead><tr><th>供应商</th><th>平均周期</th><th>达标</th></tr></thead>
+								<tbody>
+									<tr v-if="apiSection.supplierBreakdown.length === 0">
+										<td colspan="3" class="proc-panel__cell-empty">暂无数据</td>
+									</tr>
+									<tr v-for="(row, idx) in apiSection.supplierBreakdown" :key="`api-sup-${idx}`">
+										<td>{{ row.displayDimension }}</td>
+										<td :style="{ color: apiToneColor(row.statusTone), fontWeight: 600 }">
+											{{ row.avgCycle || '—' }}
+										</td>
+										<td>
+											<span class="proc-panel__dot" :style="{ background: apiToneColor(row.statusTone) }" />
+										</td>
+									</tr>
+								</tbody>
+							</table>
+						</div>
+
+						<div class="proc-panel__block proc-panel__block--half">
+							<div class="proc-panel__caption">{{ apiSection.displayMaterial }} · 规格表现</div>
+							<table class="proc-panel__table">
+								<thead><tr><th>规格</th><th>平均周期</th><th>达标</th></tr></thead>
+								<tbody>
+									<tr v-if="apiSection.specBreakdown.length === 0">
+										<td colspan="3" class="proc-panel__cell-empty">暂无数据</td>
+									</tr>
+									<tr v-for="(row, idx) in apiSection.specBreakdown" :key="`api-spec-${idx}`">
+										<td>{{ row.displayDimension }}</td>
+										<td :style="{ color: apiToneColor(row.statusTone), fontWeight: 600 }">
+											{{ row.avgCycle || '—' }}
+										</td>
+										<td>
+											<span class="proc-panel__dot" :style="{ background: apiToneColor(row.statusTone) }" />
+										</td>
+									</tr>
+								</tbody>
+							</table>
+						</div>
+					</div>
+
+					<div class="proc-panel__block">
+						<div class="proc-panel__caption">{{ apiSection.displayMaterial }} 供应商 × 线材周期矩阵</div>
+						<table class="proc-panel__table">
+							<thead>
+								<tr>
+									<th>供应商 / 线材</th>
+									<th
+										v-for="(col, idx) in apiSection.matrixColumns"
+										:key="`api-h-${col}`"
+									>
+										{{ apiSection.matrixDisplayColumns[idx] }}
+									</th>
+								</tr>
+							</thead>
+							<tbody>
+								<tr v-if="apiSection.matrixRows.length === 0">
+									<td :colspan="apiSection.matrixColumns.length + 1" class="proc-panel__cell-empty">
+										暂无数据
+									</td>
+								</tr>
+								<tr v-for="row in apiSection.matrixRows" :key="`api-row-${row.supplierCode}`">
+									<td :style="{ fontWeight: row.supplierCode === 'TOTAL' ? 700 : 600 }">
+										{{ row.displaySupplier }}
+									</td>
+									<td
+										v-for="col in apiSection.matrixColumns"
+										:key="`api-${row.supplierCode}-${col}`"
+										:style="{
+											color: apiToneColor(row.values[col]?.statusTone ?? 'pending'),
+											fontWeight: row.supplierCode === 'TOTAL' ? 700 : 500,
+										}"
+									>
+										{{ row.values[col]?.cycle || '—' }}
+									</td>
+								</tr>
+							</tbody>
+						</table>
+					</div>
+				</template>
+			</template>
+		</template>
 	</section>
 </template>
 
@@ -139,4 +341,13 @@ function statusColor(s: StageDetailStatus): string {
 .proc-panel__row--active { background: rgba(123, 208, 255, 0.12); cursor: pointer; }
 .proc-panel__table tbody tr { cursor: pointer; }
 .proc-panel__dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
+.proc-panel__cell-empty { color: var(--order-text-muted, #909097); font-style: italic; }
+.proc-panel__placeholder {
+	padding: 24px; text-align: center; font-size: 13px;
+	color: var(--order-text-muted, #909097);
+	background: rgba(25, 28, 34, 0.55);
+	border: 1px solid rgba(69, 70, 77, 0.28);
+	border-radius: 10px;
+}
+.proc-panel__placeholder--error { color: #ff4d4f; }
 </style>

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

@@ -0,0 +1,57 @@
+// S8-DEMO-CORE-FIELD-COMPLETE-1:订单执行档案“状态”列统一映射。
+// 节点状态来源 StageSnapshot.status(DB ado_s8_order_flow_stage.status),缺失时按 nodeVarianceDays fallback。
+// 订单状态严格基于 SalesOrderExecution.cumulativeVarianceDays,不复用 exceptionStatus 作为执行健康状态。
+import type {
+	SalesOrderExecution,
+	StageSnapshot,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+export type OrderExecutionStatusTone = 'green' | 'yellow' | 'red' | 'pending';
+
+export interface StatusVisual {
+	tone: OrderExecutionStatusTone;
+	label: string;
+}
+
+export const WATCH_VARIANCE_DAYS = 0;
+export const CRITICAL_VARIANCE_DAYS = 3;
+
+const NODE_LABEL: Record<OrderExecutionStatusTone, string> = {
+	green: '正常',
+	yellow: '关注',
+	red: '严重',
+	pending: '未到达',
+};
+
+const ORDER_LABEL: Record<OrderExecutionStatusTone, string> = {
+	green: '正常',
+	yellow: '关注',
+	red: '严重',
+	pending: '未形成累计偏差',
+};
+
+function toneByVariance(days: number | null | undefined): OrderExecutionStatusTone {
+	if (days == null) return 'pending';
+	if (days <= WATCH_VARIANCE_DAYS) return 'green';
+	if (days < CRITICAL_VARIANCE_DAYS) return 'yellow';
+	return 'red';
+}
+
+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') {
+		return { tone: s, label: NODE_LABEL[s] };
+	}
+	const tone = toneByVariance(stage.nodeVarianceDays);
+	return { tone, label: NODE_LABEL[tone] };
+}
+
+export function computeOrderStatus(
+	order: Pick<SalesOrderExecution, 'cumulativeVarianceDays'>,
+): StatusVisual {
+	const tone = toneByVariance(order.cumulativeVarianceDays);
+	return { tone, label: ORDER_LABEL[tone] };
+}
+
+export const STAGE_STATUS_LABEL: Record<OrderExecutionStatusTone, string> = NODE_LABEL;

+ 233 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/stage-detail.ts

@@ -194,3 +194,236 @@ export const MANUFACTURING_DETAIL_FIXTURE: ManufacturingDetail = {
 		{ name: '王师傅', avgHours: '8H', status: 'green' },
 	],
 };
+
+// ============================================================
+// S8-DEMO-R14-PROCUREMENT-MATRIX-LINK-1:真实 API 适配层。
+// 把后端 OrderFlowProcurementPivot(动态 string materialCode/specCode + decimal cycleDays + raw status)
+// 转换为采购矩阵面板可消费的视图结构。后端 status 原值保留,仅做受控映射;
+// "TOTAL" 占位统一展示为"合计";cycleDays:number → cycle:string(保留原精度)。
+// ============================================================
+
+import type {
+	OrderFlowProcurementPivot,
+	OrderFlowKeyMaterial,
+	OrderFlowSupplierBreakdown,
+	OrderFlowSpecBreakdown,
+	OrderFlowProcurementMatrixRow,
+	OrderFlowProcurementMatrixCell,
+} from '/@/views/aidop/s8/api/s8OrderFlowDomainApi';
+
+/** 后端 supplierCode / specCode 中的"合计"占位 */
+export const PROCUREMENT_PIVOT_TOTAL_CODE = 'TOTAL';
+/** 用户可见的"合计"文案 */
+export const PROCUREMENT_PIVOT_TOTAL_LABEL = '合计';
+/** 后端未识别 status 时显示原值;本枚举仅用于颜色映射。 */
+export type ProcurementApiStatusTone = StageDetailStatus | 'pending';
+
+export interface ProcurementApiKeyMaterialRow {
+	materialCode: string;
+	displayMaterial: string;
+	avgCycle: string;
+	cycleStatus: string;
+	cycleStatusTone: ProcurementApiStatusTone;
+	impactCount: string;
+	kitRate: string;
+	resultStatus: string;
+	resultStatusTone: ProcurementApiStatusTone;
+}
+
+export interface ProcurementApiBreakdownRow {
+	materialCode: string;
+	displayDimension: string;
+	avgCycle: string;
+	status: string;
+	statusTone: ProcurementApiStatusTone;
+}
+
+export interface ProcurementApiMatrixCell {
+	cycle: string;
+	status: string;
+	statusTone: ProcurementApiStatusTone;
+}
+
+export interface ProcurementApiMatrixRow {
+	supplierCode: string;
+	displaySupplier: string;
+	values: Record<string, ProcurementApiMatrixCell>;
+}
+
+export interface ProcurementApiMaterialSection {
+	materialCode: string;
+	displayMaterial: string;
+	matrixColumns: string[];
+	matrixDisplayColumns: string[];
+	supplierBreakdown: ProcurementApiBreakdownRow[];
+	specBreakdown: ProcurementApiBreakdownRow[];
+	matrixRows: ProcurementApiMatrixRow[];
+}
+
+export interface ProcurementDetailFromApi {
+	keyMaterials: ProcurementApiKeyMaterialRow[];
+	sections: ProcurementApiMaterialSection[];
+	defaultMaterialCode: string | null;
+}
+
+function displayCode(code: string | null | undefined): string {
+	if (code == null) return '';
+	return code === PROCUREMENT_PIVOT_TOTAL_CODE ? PROCUREMENT_PIVOT_TOTAL_LABEL : code;
+}
+
+function formatCycleDays(value: number | null | undefined): string {
+	if (value == null || Number.isNaN(value)) return '';
+	const num = typeof value === 'number' ? value : Number(value);
+	if (Number.isNaN(num)) return '';
+	return Number.isInteger(num) ? String(num) : String(Math.round(num * 1000) / 1000);
+}
+
+function formatPercent(value: number | null | undefined): string {
+	if (value == null || Number.isNaN(value)) return '';
+	const num = typeof value === 'number' ? value : Number(value);
+	if (Number.isNaN(num)) return '';
+	const pct = num <= 1 ? num * 100 : num;
+	return `${Math.round(pct)}%`;
+}
+
+function formatImpactCount(value: number | null | undefined): string {
+	if (value == null || Number.isNaN(value)) return '';
+	return String(value);
+}
+
+/**
+ * 把后端 status 字符串映射到颜色 tone。未识别值返回 'pending'(前端按未知态展示),
+ * 不在前端硬编码 "84/85 黄" 之类业务规则。
+ */
+export function mapProcurementStatusTone(raw: string | null | undefined): ProcurementApiStatusTone {
+	if (!raw) return 'pending';
+	const s = String(raw).trim().toLowerCase();
+	if (s === 'green' || s === 'ok' || s === 'normal' || s === 'completed') return 'green';
+	if (s === 'yellow' || s === 'warning' || s === 'risk' || s === 'delayed-warning') return 'yellow';
+	if (s === 'red' || s === 'danger' || s === 'delay' || s === 'overdue') return 'red';
+	return 'pending';
+}
+
+function adaptKeyMaterial(item: OrderFlowKeyMaterial): ProcurementApiKeyMaterialRow {
+	return {
+		materialCode: item.materialCode,
+		displayMaterial: displayCode(item.materialCode),
+		avgCycle: formatCycleDays(item.avgCycleDays),
+		cycleStatus: item.cycleStatus ?? '',
+		cycleStatusTone: mapProcurementStatusTone(item.cycleStatus),
+		impactCount: formatImpactCount(item.impactCount),
+		kitRate: formatPercent(item.kitRate),
+		resultStatus: item.resultStatus ?? '',
+		resultStatusTone: mapProcurementStatusTone(item.resultStatus),
+	};
+}
+
+function adaptSupplierRow(item: OrderFlowSupplierBreakdown): ProcurementApiBreakdownRow {
+	return {
+		materialCode: item.materialCode,
+		displayDimension: displayCode(item.supplierCode),
+		avgCycle: formatCycleDays(item.avgCycleDays),
+		status: item.status ?? '',
+		statusTone: mapProcurementStatusTone(item.status),
+	};
+}
+
+function adaptSpecRow(item: OrderFlowSpecBreakdown): ProcurementApiBreakdownRow {
+	return {
+		materialCode: item.materialCode,
+		displayDimension: displayCode(item.specCode),
+		avgCycle: formatCycleDays(item.avgCycleDays),
+		status: item.status ?? '',
+		statusTone: mapProcurementStatusTone(item.status),
+	};
+}
+
+function adaptMatrixCell(cell: OrderFlowProcurementMatrixCell): ProcurementApiMatrixCell {
+	return {
+		cycle: formatCycleDays(cell.cycleDays),
+		status: cell.status ?? '',
+		statusTone: mapProcurementStatusTone(cell.status),
+	};
+}
+
+function adaptMatrixRow(row: OrderFlowProcurementMatrixRow): ProcurementApiMatrixRow {
+	const values: Record<string, ProcurementApiMatrixCell> = {};
+	for (const [specCode, cell] of Object.entries(row.cells ?? {})) {
+		values[specCode] = adaptMatrixCell(cell);
+	}
+	return {
+		supplierCode: row.supplierCode,
+		displaySupplier: displayCode(row.supplierCode),
+		values,
+	};
+}
+
+/**
+ * 收集某 material 的全部 specCode 列,TOTAL 始终排在最后;其余按字符序。
+ */
+function collectMatrixColumns(rows: OrderFlowProcurementMatrixRow[]): string[] {
+	const set = new Set<string>();
+	for (const row of rows) {
+		for (const specCode of Object.keys(row.cells ?? {})) set.add(specCode);
+	}
+	const arr = Array.from(set);
+	arr.sort((a, b) => {
+		if (a === PROCUREMENT_PIVOT_TOTAL_CODE) return 1;
+		if (b === PROCUREMENT_PIVOT_TOTAL_CODE) return -1;
+		return a < b ? -1 : a > b ? 1 : 0;
+	});
+	return arr;
+}
+
+export function adaptProcurementPivotFromApi(
+	pivot: OrderFlowProcurementPivot | null | undefined,
+): ProcurementDetailFromApi | null {
+	if (!pivot) return null;
+	const keyMaterials = (pivot.keyMaterials ?? []).map(adaptKeyMaterial);
+
+	const allMaterialCodes = new Set<string>();
+	for (const k of keyMaterials) allMaterialCodes.add(k.materialCode);
+	for (const m of Object.keys(pivot.matrixByMaterial ?? {})) allMaterialCodes.add(m);
+	for (const r of pivot.supplierBreakdown ?? []) allMaterialCodes.add(r.materialCode);
+	for (const r of pivot.specBreakdown ?? []) allMaterialCodes.add(r.materialCode);
+
+	const sortedMaterialCodes = Array.from(allMaterialCodes).sort((a, b) => {
+		if (a === PROCUREMENT_PIVOT_TOTAL_CODE) return 1;
+		if (b === PROCUREMENT_PIVOT_TOTAL_CODE) return -1;
+		return a < b ? -1 : a > b ? 1 : 0;
+	});
+
+	const sections: ProcurementApiMaterialSection[] = sortedMaterialCodes
+		.filter((code) => code !== PROCUREMENT_PIVOT_TOTAL_CODE)
+		.map((materialCode) => {
+			const matrix = (pivot.matrixByMaterial ?? {})[materialCode] ?? [];
+			const columns = collectMatrixColumns(matrix);
+			return {
+				materialCode,
+				displayMaterial: displayCode(materialCode),
+				matrixColumns: columns,
+				matrixDisplayColumns: columns.map(displayCode),
+				supplierBreakdown: (pivot.supplierBreakdown ?? [])
+					.filter((r) => r.materialCode === materialCode)
+					.map(adaptSupplierRow),
+				specBreakdown: (pivot.specBreakdown ?? [])
+					.filter((r) => r.materialCode === materialCode)
+					.map(adaptSpecRow),
+				matrixRows: matrix.map(adaptMatrixRow),
+			};
+		});
+
+	const firstNonTotal = keyMaterials.find((k) => k.materialCode !== PROCUREMENT_PIVOT_TOTAL_CODE);
+	const defaultMaterialCode = firstNonTotal?.materialCode ?? sections[0]?.materialCode ?? null;
+
+	return {
+		keyMaterials,
+		sections,
+		defaultMaterialCode,
+	};
+}
+
+export function isProcurementDetailEmpty(detail: ProcurementDetailFromApi | null | undefined): boolean {
+	if (!detail) return true;
+	return detail.keyMaterials.length === 0 && detail.sections.length === 0;
+}

+ 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.117</AssemblyVersion>
-    <FileVersion>1.0.117</FileVersion>
-    <Version>1.0.117</Version>
+    <AssemblyVersion>1.0.118</AssemblyVersion>
+    <FileVersion>1.0.118</FileVersion>
+    <Version>1.0.118</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 35 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -34,6 +34,14 @@ public class AdoS8ExceptionQueryDto
     public string? RuleMechanism { get; set; }
     public int Page { get; set; } = 1;
     public int PageSize { get; set; } = 20;
+
+    /// <summary>S8-DEMO-IMPACT-SORT-NOTICE-1:排序字段白名单。
+    /// 允许值:createdAt / severity / priorityScore / repeatCount30d / cumulativeLossHours30d / impactScore。
+    /// 空或非白名单 → 默认 createdAt DESC。</summary>
+    public string? SortField { get; set; }
+
+    /// <summary>S8-DEMO-IMPACT-SORT-NOTICE-1:排序方向。asc / desc;非法值降级为 desc。</summary>
+    public string? SortOrder { get; set; }
 }
 
 public class AdoS8ExceptionListItemDto
@@ -63,6 +71,10 @@ public class AdoS8ExceptionListItemDto
     public bool TimeoutFlag { get; set; }
     public DateTime CreatedAt { get; set; }
     public DateTime? ClosedAt { get; set; }
+    /// <summary>S8-DEMO-CORE-FIELD-COMPLETE-1:损失时间(小时,保留 1 位小数)= ClosedAt - CreatedAt。未关闭为 null。</summary>
+    public decimal? LossHours { get; set; }
+    /// <summary>S8-DEMO-CORE-FIELD-COMPLETE-1:是否超时关闭(冻结判定)= ClosedAt &gt; SlaDeadline。未关闭或无 SLA 为 null。与运行时 TimeoutFlag 含义不同。</summary>
+    public bool? IsOverdueClosed { get; set; }
     public string? ExceptionTypeCode { get; set; }
     public DateTime? RecoveredAt { get; set; }
     public string? SourceRuleCode { get; set; }
@@ -79,6 +91,29 @@ public class AdoS8ExceptionListItemDto
     public string? StageCode { get; set; }
     /// <summary>ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t3j:规则机制。</summary>
     public string? RuleMechanism { get; set; }
+
+    // ============================================================
+    // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(运行期计算,不落库)。
+    // 同类异常归类键 = (tenant_id, factory_id, exception_type_code);窗口 = 滚动 30 天。
+    // ============================================================
+
+    /// <summary>过去 30 天同 exception_type_code 异常出现次数;exception_type_code 为 NULL 时回退 1。</summary>
+    public int RepeatCount30d { get; set; }
+
+    /// <summary>过去 30 天同 exception_type_code 已关闭异常累计损失小时数(closed_at - created_at),未关闭不计入;保留 1 位小数。</summary>
+    public decimal CumulativeLossHours30d { get; set; }
+
+    /// <summary>综合影响分(severityWeight + repeatWeight + lossWeight + timeoutWeight),保留 1 位小数。</summary>
+    public decimal ImpactScore { get; set; }
+
+    /// <summary>建议关注级别枚举:HIGH / MEDIUM / LOW。</summary>
+    public string? SuggestedAttentionLevel { get; set; }
+
+    /// <summary>建议关注级别中文标签:高 / 中 / 低。</summary>
+    public string? SuggestedAttentionLabel { get; set; }
+
+    /// <summary>影响判定原因简述(如 "重复 5 次/累计损失 30 小时")。</summary>
+    public string? ImpactReason { get; set; }
 }
 
 public class AdoS8ExceptionDetailDto : AdoS8ExceptionListItemDto

+ 202 - 41
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs

@@ -13,17 +13,24 @@ public class S8ExceptionService : ITransient
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
     private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
     private readonly SqlSugarRepository<SysUser> _sysUserRep;
+    private readonly S8ImpactMetricsService _impactMetricsService;
+
+    // S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序的候选集合上限。
+    // 超过此阈值时降级为 CreatedAt DESC 走 DB 分页,避免全量取 → 内存排序在生产规模下退化。
+    private const int ImpactSortCandidateCap = 2000;
 
     public S8ExceptionService(
         SqlSugarRepository<AdoS8Exception> rep,
         SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
         SqlSugarRepository<AdoS0EmployeeMaster> empRep,
-        SqlSugarRepository<SysUser> sysUserRep)
+        SqlSugarRepository<SysUser> sysUserRep,
+        S8ImpactMetricsService impactMetricsService)
     {
         _rep = rep;
         _deptRep = deptRep;
         _empRep = empRep;
         _sysUserRep = sysUserRep;
+        _impactMetricsService = impactMetricsService;
     }
 
     public async Task<(int total, List<AdoS8ExceptionListItemDto> list)> GetPagedAsync(AdoS8ExceptionQueryDto q)
@@ -73,55 +80,191 @@ public class S8ExceptionService : ITransient
                 (e, sc, wr) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!));
 
         var total = await query.CountAsync();
-        var list = await query
-            .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
-            .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
+
+        // S8-DEMO-IMPACT-SORT-NOTICE-1:候选集合 <= ImpactSortCandidateCap 时走"全量取 → 内存填影响 → 排序 → 分页",
+        // 支持 impactScore / repeatCount30d / cumulativeLossHours30d 等运行期字段排序;超过则降级 DB 分页 createdAt DESC。
+        var useImpactSort = total > 0 && total <= ImpactSortCandidateCap;
+
+        List<AdoS8ExceptionListItemDto> list;
+        if (useImpactSort)
+        {
+            // 先按 query 命中集合一次性取候选,order 仅 createdAt DESC 用于稳定性,后续内存覆写排序。
+            var candidates = await query
+                .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
+                .Take(ImpactSortCandidateCap)
+                .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
+                {
+                    Id = e.Id,
+                    FactoryId = e.FactoryId,
+                    ExceptionCode = e.ExceptionCode,
+                    Title = e.Title,
+                    Status = e.Status,
+                    Severity = e.Severity,
+                    PriorityScore = e.PriorityScore,
+                    PriorityLevel = e.PriorityLevel,
+                    SceneCode = e.SceneCode,
+                    SceneName = sc.SceneName,
+                    ModuleCode = e.ModuleCode,
+                    ResponsibleDeptId = e.ResponsibleDeptId,
+                    OccurrenceDeptId = e.OccurrenceDeptId,
+                    AssigneeId = e.AssigneeId,
+                    SlaDeadline = e.SlaDeadline,
+                    TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
+                    CreatedAt = e.CreatedAt,
+                    ClosedAt = e.ClosedAt,
+                    ExceptionTypeCode = e.ExceptionTypeCode,
+                    RecoveredAt = e.RecoveredAt,
+                    SourceRuleCode = e.SourceRuleCode,
+                    SourceObjectType = e.SourceObjectType,
+                    SourceObjectId = e.SourceObjectId,
+                    DedupKey = e.DedupKey,
+                    LastDetectedAt = e.LastDetectedAt,
+                    RuleType = wr.RuleType,
+                    RelatedObjectCode = e.RelatedObjectCode,
+                    OrderFlowCode = e.OrderFlowCode,
+                    StageCode = e.StageCode,
+                    RuleMechanism = e.RuleMechanism,
+                })
+                .ToListAsync();
+
+            // DTO 后处理:损失时间 / 是否超时关闭 / 标签。
+            foreach (var r in candidates)
             {
-                Id = e.Id,
-                FactoryId = e.FactoryId,
-                ExceptionCode = e.ExceptionCode,
-                Title = e.Title,
-                Status = e.Status,
-                Severity = e.Severity,
-                PriorityScore = e.PriorityScore,
-                PriorityLevel = e.PriorityLevel,
-                SceneCode = e.SceneCode,
-                SceneName = sc.SceneName,
-                ModuleCode = e.ModuleCode,
-                ResponsibleDeptId = e.ResponsibleDeptId,
-                OccurrenceDeptId = e.OccurrenceDeptId,
-                AssigneeId = e.AssigneeId,
-                SlaDeadline = e.SlaDeadline,
-                // S8-SLA-TIMEOUT-RUNTIME-1(P3):TimeoutFlag 展示字段 = 在线计算,与 dashboard / monitoring 同口径。
-                TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
-                CreatedAt = e.CreatedAt,
-                ClosedAt = e.ClosedAt,
-                ExceptionTypeCode = e.ExceptionTypeCode,
-                RecoveredAt = e.RecoveredAt,
-                SourceRuleCode = e.SourceRuleCode,
-                SourceObjectType = e.SourceObjectType,
-                SourceObjectId = e.SourceObjectId,
-                DedupKey = e.DedupKey,
-                LastDetectedAt = e.LastDetectedAt,
-                RuleType = wr.RuleType,
-                RelatedObjectCode = e.RelatedObjectCode,
-                OrderFlowCode = e.OrderFlowCode,
-                StageCode = e.StageCode,
-                RuleMechanism = e.RuleMechanism,
-            })
-            .ToPageListAsync(q.Page, q.PageSize);
+                r.StatusLabel = S8Labels.StatusLabel(r.Status);
+                r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
+                r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
+                var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline);
+                r.LossHours = lossHours;
+                r.IsOverdueClosed = isOverdueClosed;
+            }
 
-        foreach (var r in list)
+            // 影响统计批量填充(30 天窗口聚合一次性 GROUP BY,O(n) 回填,无 N+1)。
+            var impactRows = candidates
+                .Select(r => new ExceptionImpactRow
+                {
+                    Id = r.Id,
+                    ExceptionTypeCode = r.ExceptionTypeCode,
+                    Severity = r.Severity,
+                    CreatedAt = r.CreatedAt,
+                    ClosedAt = r.ClosedAt,
+                    TimeoutFlag = r.TimeoutFlag,
+                })
+                .ToList();
+            await _impactMetricsService.FillBatchAsync(q.TenantId, q.FactoryId, impactRows);
+            var impactById = impactRows.ToDictionary(x => x.Id);
+            foreach (var r in candidates)
+            {
+                if (!impactById.TryGetValue(r.Id, out var snap)) continue;
+                r.RepeatCount30d = snap.RepeatCount30d;
+                r.CumulativeLossHours30d = snap.CumulativeLossHours30d;
+                r.ImpactScore = snap.ImpactScore;
+                r.SuggestedAttentionLevel = snap.SuggestedAttentionLevel;
+                r.SuggestedAttentionLabel = snap.SuggestedAttentionLabel;
+                r.ImpactReason = snap.ImpactReason;
+            }
+
+            // 内存排序(白名单,非白名单回退 createdAt DESC)。
+            var sorted = ApplyImpactSort(candidates, q.SortField, q.SortOrder);
+
+            // 内存分页。
+            list = sorted
+                .Skip((q.Page - 1) * q.PageSize)
+                .Take(q.PageSize)
+                .ToList();
+        }
+        else
         {
-            r.StatusLabel = S8Labels.StatusLabel(r.Status);
-            r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
-            r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
+            // 降级路径:候选 > ImpactSortCandidateCap,DB 分页 createdAt DESC,不填充影响字段(前端展示 0/null)。
+            list = await query
+                .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
+                .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
+                {
+                    Id = e.Id,
+                    FactoryId = e.FactoryId,
+                    ExceptionCode = e.ExceptionCode,
+                    Title = e.Title,
+                    Status = e.Status,
+                    Severity = e.Severity,
+                    PriorityScore = e.PriorityScore,
+                    PriorityLevel = e.PriorityLevel,
+                    SceneCode = e.SceneCode,
+                    SceneName = sc.SceneName,
+                    ModuleCode = e.ModuleCode,
+                    ResponsibleDeptId = e.ResponsibleDeptId,
+                    OccurrenceDeptId = e.OccurrenceDeptId,
+                    AssigneeId = e.AssigneeId,
+                    SlaDeadline = e.SlaDeadline,
+                    TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
+                    CreatedAt = e.CreatedAt,
+                    ClosedAt = e.ClosedAt,
+                    ExceptionTypeCode = e.ExceptionTypeCode,
+                    RecoveredAt = e.RecoveredAt,
+                    SourceRuleCode = e.SourceRuleCode,
+                    SourceObjectType = e.SourceObjectType,
+                    SourceObjectId = e.SourceObjectId,
+                    DedupKey = e.DedupKey,
+                    LastDetectedAt = e.LastDetectedAt,
+                    RuleType = wr.RuleType,
+                    RelatedObjectCode = e.RelatedObjectCode,
+                    OrderFlowCode = e.OrderFlowCode,
+                    StageCode = e.StageCode,
+                    RuleMechanism = e.RuleMechanism,
+                })
+                .ToPageListAsync(q.Page, q.PageSize);
+
+            foreach (var r in list)
+            {
+                r.StatusLabel = S8Labels.StatusLabel(r.Status);
+                r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
+                r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
+                var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline);
+                r.LossHours = lossHours;
+                r.IsOverdueClosed = isOverdueClosed;
+            }
         }
 
         await FillDisplayNamesAsync(list, q.FactoryId);
         return (total, list);
     }
 
+    // S8-DEMO-IMPACT-SORT-NOTICE-1:白名单排序;severity 自定义键(SERIOUS=2 / FOLLOW=1 / 其他=0)。
+    // sortField 空或非白名单 → createdAt DESC;sortOrder 非 asc/desc → desc。
+    private static List<AdoS8ExceptionListItemDto> ApplyImpactSort(
+        List<AdoS8ExceptionListItemDto> rows, string? sortField, string? sortOrder)
+    {
+        var asc = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase);
+        var key = (sortField ?? string.Empty).Trim();
+        return key switch
+        {
+            "impactScore" => asc
+                ? rows.OrderBy(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList()
+                : rows.OrderByDescending(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList(),
+            "repeatCount30d" => asc
+                ? rows.OrderBy(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList()
+                : rows.OrderByDescending(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList(),
+            "cumulativeLossHours30d" => asc
+                ? rows.OrderBy(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList()
+                : rows.OrderByDescending(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList(),
+            "severity" => asc
+                ? rows.OrderBy(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList()
+                : rows.OrderByDescending(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList(),
+            "priorityScore" => asc
+                ? rows.OrderBy(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList()
+                : rows.OrderByDescending(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList(),
+            "createdAt" => asc
+                ? rows.OrderBy(r => r.CreatedAt).ToList()
+                : rows.OrderByDescending(r => r.CreatedAt).ToList(),
+            _ => rows.OrderByDescending(r => r.CreatedAt).ToList(),
+        };
+    }
+
+    private static int SeveritySortKey(string severity) => severity switch
+    {
+        "SERIOUS" => 2,
+        "FOLLOW" => 1,
+        _ => 0,
+    };
+
     public async Task<object> GetFilterOptionsAsync(long tenantId, long factoryId)
     {
         var scenes = await _rep.Context.Queryable<AdoS8SceneConfig>()
@@ -218,10 +361,28 @@ public class S8ExceptionService : ITransient
         d.StatusLabel = S8Labels.StatusLabel(d.Status);
         d.SeverityLabel = S8Labels.SeverityLabel(d.Severity);
         d.ModuleName = string.IsNullOrWhiteSpace(d.ModuleCode) ? null : S8ModuleCode.Label(d.ModuleCode!);
+        // S8-DEMO-CORE-FIELD-COMPLETE-1:详情与列表同口径,DTO 后处理计算损失时间 / 是否超时关闭。
+        var (lossHours, isOverdueClosed) = ComputeClosureMetrics(d.CreatedAt, d.ClosedAt, d.SlaDeadline);
+        d.LossHours = lossHours;
+        d.IsOverdueClosed = isOverdueClosed;
         await FillDisplayNamesAsync(new[] { d }, factoryId);
         return d;
     }
 
+    // S8-DEMO-CORE-FIELD-COMPLETE-1:损失时间(小时,1 位小数)+ 是否超时关闭(冻结判定)。
+    // 未关闭 → LossHours=null;未关闭或无 SLA → IsOverdueClosed=null。与运行时 TimeoutFlag 不复用。
+    private static (decimal? lossHours, bool? isOverdueClosed) ComputeClosureMetrics(
+        DateTime createdAt, DateTime? closedAt, DateTime? slaDeadline)
+    {
+        decimal? lossHours = closedAt == null
+            ? (decimal?)null
+            : Math.Round((decimal)(closedAt.Value - createdAt).TotalHours, 1);
+        bool? isOverdueClosed = (closedAt == null || slaDeadline == null)
+            ? (bool?)null
+            : closedAt.Value > slaDeadline.Value;
+        return (lossHours, isOverdueClosed);
+    }
+
     // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-2/A-3):list 行也水合 OccurrenceDeptName;
     // 部门查询加 factory_ref_id 二次约束,避免跨 factory 同名 / 同 RecID 错位。
     private async Task FillDisplayNamesAsync(IEnumerable<AdoS8ExceptionListItemDto> rows, long factoryId)

+ 294 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ImpactMetricsService.cs

@@ -0,0 +1,294 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8-DEMO-IMPACT-SORT-NOTICE-1:异常影响统计服务。
+/// 滚动 30 天窗口;归类键 = (tenant_id, factory_id, exception_type_code);
+/// 累计损失只统计已关闭异常(closed_at - created_at);未关闭不计入;
+/// 算法/阈值见 Compute 方法内常量;运行期计算,不落库。
+/// </summary>
+public class S8ImpactMetricsService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8Exception> _rep;
+
+    // 影响分权重上限(保护 score 不超过设计区间 0-140)
+    private const int RepeatCap = 5;
+    private const decimal LossHoursCap = 72m;
+    private const decimal LossWeightCap = 30m;
+    private const decimal SeverityWeightSerious = 40m;
+    private const decimal SeverityWeightFollow = 10m;
+    private const decimal TimeoutWeight = 20m;
+    private const decimal RepeatUnitWeight = 10m;
+
+    // 关注级别阈值
+    private const int HighRepeatThreshold = 3;
+    private const decimal HighLossThreshold = 24m;
+    private const int MediumRepeatThreshold = 2;
+    private const decimal MediumLossThreshold = 8m;
+
+    public S8ImpactMetricsService(SqlSugarRepository<AdoS8Exception> rep)
+    {
+        _rep = rep;
+    }
+
+    /// <summary>
+    /// 批量计算:传入候选异常子集(同一 tenant/factory),一次性按 exception_type_code 聚合 30 天窗口数据,
+    /// 内存填充各行 6 个影响字段。无 N+1。candidates 为空或 typeCode 全空时不查库。
+    /// </summary>
+    public async Task FillBatchAsync(
+        long tenantId,
+        long factoryId,
+        IReadOnlyList<ExceptionImpactRow> candidates)
+    {
+        if (candidates == null || candidates.Count == 0) return;
+
+        var since = DateTime.Now.AddDays(-30);
+
+        var typeCodes = candidates
+            .Select(c => c.ExceptionTypeCode)
+            .Where(c => !string.IsNullOrWhiteSpace(c))
+            .Distinct()
+            .ToList();
+
+        var repeatMap = new Dictionary<string, int>(StringComparer.Ordinal);
+        var lossMap = new Dictionary<string, decimal>(StringComparer.Ordinal);
+
+        if (typeCodes.Count > 0)
+        {
+            // repeatCount30d:按 exception_type_code GROUP BY 计数全部行(含未关闭)。
+            var repeatRows = await _rep.Context.Queryable<AdoS8Exception>()
+                .Where(x => x.TenantId == tenantId
+                            && x.FactoryId == factoryId
+                            && !x.IsDeleted
+                            && x.ExceptionTypeCode != null
+                            && typeCodes.Contains(x.ExceptionTypeCode)
+                            && x.CreatedAt >= since)
+                .GroupBy(x => x.ExceptionTypeCode!)
+                .Select(x => new
+                {
+                    ExceptionTypeCode = x.ExceptionTypeCode!,
+                    RepeatCount = SqlFunc.AggregateCount(x.Id),
+                })
+                .ToListAsync();
+
+            foreach (var r in repeatRows)
+            {
+                repeatMap[r.ExceptionTypeCode] = r.RepeatCount;
+            }
+
+            // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at) 分钟差累计。
+            var lossRows = await _rep.Context.Queryable<AdoS8Exception>()
+                .Where(x => x.TenantId == tenantId
+                            && x.FactoryId == factoryId
+                            && !x.IsDeleted
+                            && x.ExceptionTypeCode != null
+                            && typeCodes.Contains(x.ExceptionTypeCode)
+                            && x.CreatedAt >= since
+                            && x.ClosedAt != null)
+                .GroupBy(x => x.ExceptionTypeCode!)
+                .Select(x => new
+                {
+                    ExceptionTypeCode = x.ExceptionTypeCode!,
+                    LossMinutes = SqlFunc.AggregateSum(
+                        SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value)),
+                })
+                .ToListAsync();
+
+            foreach (var r in lossRows)
+            {
+                lossMap[r.ExceptionTypeCode] = Math.Round((decimal)r.LossMinutes / 60m, 1);
+            }
+        }
+
+        foreach (var c in candidates)
+        {
+            int repeatCount;
+            decimal lossHours;
+            if (string.IsNullOrWhiteSpace(c.ExceptionTypeCode))
+            {
+                // exception_type_code 为空 → 视为孤例:自身计 1 次;若自身已关闭则累计自身损失
+                repeatCount = 1;
+                lossHours = (c.ClosedAt != null)
+                    ? Math.Round((decimal)(c.ClosedAt.Value - c.CreatedAt).TotalHours, 1)
+                    : 0m;
+            }
+            else
+            {
+                repeatCount = repeatMap.TryGetValue(c.ExceptionTypeCode, out var rc) ? rc : 1;
+                lossHours = lossMap.TryGetValue(c.ExceptionTypeCode, out var lh) ? lh : 0m;
+            }
+
+            var (impactScore, level, label, reason) = Compute(c.Severity, repeatCount, lossHours, c.TimeoutFlag);
+
+            c.RepeatCount30d = repeatCount;
+            c.CumulativeLossHours30d = lossHours;
+            c.ImpactScore = impactScore;
+            c.SuggestedAttentionLevel = level;
+            c.SuggestedAttentionLabel = label;
+            c.ImpactReason = reason;
+        }
+    }
+
+    /// <summary>
+    /// 单异常计算:通知派发路径调用;exception_type_code / created_at / closed_at / severity / sla_deadline 从实体读取。
+    /// 同样的 30 天窗口聚合,但只查询当前 typeCode 一条。
+    /// </summary>
+    public async Task<ExceptionImpactSnapshot> ComputeOneAsync(AdoS8Exception entity)
+    {
+        if (entity == null) throw new ArgumentNullException(nameof(entity));
+
+        var since = DateTime.Now.AddDays(-30);
+        var now = DateTime.Now;
+        var timeoutFlag = entity.SlaDeadline != null
+            && entity.SlaDeadline < now
+            && entity.Status != "CLOSED"
+            && entity.Status != "RECOVERED";
+
+        int repeatCount;
+        decimal lossHours;
+
+        if (string.IsNullOrWhiteSpace(entity.ExceptionTypeCode))
+        {
+            repeatCount = 1;
+            lossHours = (entity.ClosedAt != null)
+                ? Math.Round((decimal)(entity.ClosedAt.Value - entity.CreatedAt).TotalHours, 1)
+                : 0m;
+        }
+        else
+        {
+            var typeCode = entity.ExceptionTypeCode;
+            // repeatCount30d:30 天窗口内同 typeCode 全量计数(含未关闭)。
+            repeatCount = await _rep.Context.Queryable<AdoS8Exception>()
+                .Where(x => x.TenantId == entity.TenantId
+                            && x.FactoryId == entity.FactoryId
+                            && !x.IsDeleted
+                            && x.ExceptionTypeCode == typeCode
+                            && x.CreatedAt >= since)
+                .CountAsync();
+            if (repeatCount == 0) repeatCount = 1; // 自身刚建尚未可见时回退 1。
+
+            // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at)。
+            var lossMinutesNullable = await _rep.Context.Queryable<AdoS8Exception>()
+                .Where(x => x.TenantId == entity.TenantId
+                            && x.FactoryId == entity.FactoryId
+                            && !x.IsDeleted
+                            && x.ExceptionTypeCode == typeCode
+                            && x.CreatedAt >= since
+                            && x.ClosedAt != null)
+                .SumAsync(x => (int?)SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value));
+            var lossMinutes = lossMinutesNullable ?? 0;
+            lossHours = Math.Round((decimal)lossMinutes / 60m, 1);
+        }
+
+        var (impactScore, level, label, reason) = Compute(entity.Severity, repeatCount, lossHours, timeoutFlag);
+
+        return new ExceptionImpactSnapshot
+        {
+            RepeatCount30d = repeatCount,
+            CumulativeLossHours30d = lossHours,
+            ImpactScore = impactScore,
+            SuggestedAttentionLevel = level,
+            SuggestedAttentionLabel = label,
+            ImpactReason = reason,
+        };
+    }
+
+    /// <summary>
+    /// 算法实现。Severity 已通过 S8SeverityCode.Normalize 统一为 FOLLOW / SERIOUS。
+    /// score = severityWeight + min(repeat,5)*10 + min(loss,72)/72*30 + (timeout?20:0),理论范围 0-140。
+    /// 关注级别按重复 / 损失 / 严重度 / 超时四因子判定。
+    /// </summary>
+    private static (decimal score, string level, string label, string reason) Compute(
+        string? severity, int repeatCount, decimal lossHours, bool timeoutFlag)
+    {
+        var normalizedSeverity = S8SeverityCode.Normalize(severity);
+        var severityWeight = normalizedSeverity switch
+        {
+            "SERIOUS" => SeverityWeightSerious,
+            "FOLLOW" => SeverityWeightFollow,
+            _ => 0m,
+        };
+
+        var cappedRepeat = Math.Min(repeatCount, RepeatCap);
+        var repeatWeight = cappedRepeat * RepeatUnitWeight;
+
+        var cappedLoss = Math.Min(lossHours, LossHoursCap);
+        var lossWeight = cappedLoss / LossHoursCap * LossWeightCap;
+
+        var timeoutWeight = timeoutFlag ? TimeoutWeight : 0m;
+
+        var impactScore = Math.Round(severityWeight + repeatWeight + lossWeight + timeoutWeight, 1);
+
+        string level;
+        if (normalizedSeverity == "SERIOUS"
+            || repeatCount >= HighRepeatThreshold
+            || lossHours >= HighLossThreshold)
+        {
+            level = "HIGH";
+        }
+        else if (repeatCount >= MediumRepeatThreshold
+            || lossHours >= MediumLossThreshold
+            || timeoutFlag)
+        {
+            level = "MEDIUM";
+        }
+        else
+        {
+            level = "LOW";
+        }
+
+        var label = level switch
+        {
+            "HIGH" => "高",
+            "MEDIUM" => "中",
+            "LOW" => "低",
+            _ => "—",
+        };
+
+        var reasonParts = new List<string>(4);
+        if (repeatCount > 1) reasonParts.Add($"重复 {repeatCount} 次");
+        if (lossHours > 0) reasonParts.Add($"累计损失 {lossHours.ToString("0.#")} 小时");
+        if (normalizedSeverity == "SERIOUS") reasonParts.Add("严重");
+        if (timeoutFlag) reasonParts.Add("当前超时");
+        var reason = reasonParts.Count == 0 ? "无显著影响" : string.Join("/", reasonParts);
+
+        return (impactScore, level, label, reason);
+    }
+
+}
+
+/// <summary>
+/// S8-DEMO-IMPACT-SORT-NOTICE-1:批量影响计算的最小行契约。
+/// S8ExceptionService 投影时构造,FillBatchAsync 在内存回填 6 个 30d 字段后,再赋给 DTO。
+/// </summary>
+public sealed class ExceptionImpactRow
+{
+    public long Id { get; set; }
+    public string? ExceptionTypeCode { get; set; }
+    public string? Severity { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public DateTime? ClosedAt { get; set; }
+    public bool TimeoutFlag { get; set; }
+
+    public int RepeatCount30d { get; set; }
+    public decimal CumulativeLossHours30d { get; set; }
+    public decimal ImpactScore { get; set; }
+    public string? SuggestedAttentionLevel { get; set; }
+    public string? SuggestedAttentionLabel { get; set; }
+    public string? ImpactReason { get; set; }
+}
+
+/// <summary>
+/// 单异常计算结果快照;通知派发路径使用。
+/// </summary>
+public sealed class ExceptionImpactSnapshot
+{
+    public int RepeatCount30d { get; set; }
+    public decimal CumulativeLossHours30d { get; set; }
+    public decimal ImpactScore { get; set; }
+    public string? SuggestedAttentionLevel { get; set; }
+    public string? SuggestedAttentionLabel { get; set; }
+    public string? ImpactReason { get; set; }
+}

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerResolver.cs

@@ -60,6 +60,25 @@ public class S8NotificationLayerResolver : ITransient
 		/// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。
 		/// </summary>
 		public bool Recovered { get; set; }
+
+		// ============================================================
+		// S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(可选;CREATED 路径透传,RECOVERED 路径置空)。
+		// 由 S8WatchSchedulerService.TryDispatchLayerNotificationAsync 调 S8ImpactMetricsService 计算后传入。
+		// ============================================================
+		public int? RepeatCount30d { get; set; }
+		public decimal? CumulativeLossHours30d { get; set; }
+		public string? SuggestedAttentionLevel { get; set; }
+		public string? SuggestedAttentionLabel { get; set; }
+		public string? ImpactReason { get; set; }
+
+		// ============================================================
+		// S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警字段(可选;仅 CloseAsync 命中 closedAt > slaDeadline 时传入)。
+		// 语义与 TimeoutFlag 运行时口径分离:TimeoutFlag 仅看未关闭超时;OverdueClosed 是已关闭后的闭环及时性提醒。
+		// ============================================================
+		public bool? OverdueClosed { get; set; }
+		public DateTime? ClosedAt { get; set; }
+		public DateTime? SlaDeadlineRef { get; set; }
+		public decimal? OverdueCloseHours { get; set; }
 	}
 
 	/// <summary>
@@ -177,6 +196,26 @@ public class S8NotificationLayerResolver : ITransient
 			["jumpUrl"] = input.JumpUrl,
 		};
 		if (input.Recovered) ctx["recovered"] = "true";
+		// S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段,仅 CREATED 路径携带,RECOVERED 路径不传入。
+		if (input.RepeatCount30d.HasValue)
+			ctx["repeatCount30d"] = input.RepeatCount30d.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+		if (input.CumulativeLossHours30d.HasValue)
+			ctx["cumulativeLossHours30d"] = input.CumulativeLossHours30d.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
+		if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLevel))
+			ctx["suggestedAttentionLevel"] = input.SuggestedAttentionLevel;
+		if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLabel))
+			ctx["suggestedAttentionLabel"] = input.SuggestedAttentionLabel;
+		if (!string.IsNullOrWhiteSpace(input.ImpactReason))
+			ctx["impactReason"] = input.ImpactReason;
+		// S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段,仅 CloseAsync 命中 closedAt > slaDeadline 时携带。
+		if (input.OverdueClosed == true)
+			ctx["overdueClosed"] = "true";
+		if (input.ClosedAt.HasValue)
+			ctx["closedAt"] = input.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
+		if (input.SlaDeadlineRef.HasValue)
+			ctx["slaDeadline"] = input.SlaDeadlineRef.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
+		if (input.OverdueCloseHours.HasValue)
+			ctx["overdueCloseHours"] = input.OverdueCloseHours.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
 		return new FlowNotification
 		{
 			Type = FlowNotificationTypeEnum.NewTask,

+ 11 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationPushAdapter.cs

@@ -139,6 +139,17 @@ public class S8NotificationPushAdapter : ITransient
 				sourceRuleCode = Get(ctx, "sourceRuleCode"),
 				jumpUrl = Get(ctx, "jumpUrl"),
 				recovered = "true".Equals(Get(ctx, "recovered"), StringComparison.OrdinalIgnoreCase),
+				// S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段;CREATED 路径有值,RECOVERED 路径全 null。
+				repeatCount30d = Get(ctx, "repeatCount30d"),
+				cumulativeLossHours30d = Get(ctx, "cumulativeLossHours30d"),
+				suggestedAttentionLevel = Get(ctx, "suggestedAttentionLevel"),
+				suggestedAttentionLabel = Get(ctx, "suggestedAttentionLabel"),
+				impactReason = Get(ctx, "impactReason"),
+				// S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段;仅 CloseAsync 命中 closedAt > slaDeadline 时有值。
+				overdueClosed = "true".Equals(Get(ctx, "overdueClosed"), StringComparison.OrdinalIgnoreCase),
+				closedAt = Get(ctx, "closedAt"),
+				slaDeadline = Get(ctx, "slaDeadline"),
+				overdueCloseHours = Get(ctx, "overdueCloseHours"),
 				targetCount,
 				success,
 				error = string.IsNullOrEmpty(error)

+ 57 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -17,6 +17,7 @@ public class S8TaskFlowService : ITransient
     private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
+    private readonly S8NotificationLayerResolver _notificationLayerResolver;
     private readonly ILogger<S8TaskFlowService> _logger;
 
     public S8TaskFlowService(
@@ -28,6 +29,7 @@ public class S8TaskFlowService : ITransient
         SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
         FlowEngineService flowEngine,
         UserManager userManager,
+        S8NotificationLayerResolver notificationLayerResolver,
         ILogger<S8TaskFlowService> logger)
     {
         _rep = rep;
@@ -38,6 +40,7 @@ public class S8TaskFlowService : ITransient
         _flowTaskRep = flowTaskRep;
         _flowEngine = flowEngine;
         _userManager = userManager;
+        _notificationLayerResolver = notificationLayerResolver;
         _logger = logger;
     }
 
@@ -382,9 +385,63 @@ public class S8TaskFlowService : ITransient
         // 双线合一:检验通过 = EXCEPTION_CLOSURE 复检流程审批通过。
         await TryApproveVerificationFlowAsync(e.Id, currentUserId);
 
+        // S8-R03-OVERDUE-CLOSE-NOTICE-1:闭环及时性提醒。
+        // 触发条件:closedAt > slaDeadline;与 TimeoutFlag 运行时口径分离,仅对已关闭异常做闭环回顾。
+        // 主流程已 commit(含状态机 + Timeline),异常隔离仅 LogWarning,不影响 CloseAsync。
+        await TryDispatchOverdueCloseNotificationAsync(e);
+
         return e;
     }
 
+    /// <summary>
+    /// S8-R03-OVERDUE-CLOSE-NOTICE-1:异常关闭后若 closedAt &gt; slaDeadline 触发一条独立"超时关闭"通知。
+    /// 复用 <see cref="S8NotificationLayerResolver.DispatchByLayerAsync"/> 分层链路,不写 ApprovalFlowNotifyLog。
+    /// 与 <see cref="S8TimeoutAutoEscalationService"/>(未关闭超时升级)语义分离:本钩子仅在已关闭后回顾闭环及时性。
+    /// 任何异常仅 LogWarning,不抛回 ApproveVerificationAsync 主流程。
+    /// </summary>
+    private async Task TryDispatchOverdueCloseNotificationAsync(AdoS8Exception e)
+    {
+        if (e == null || e.Id <= 0) return;
+        if (e.ClosedAt == null || e.SlaDeadline == null) return;
+        if (e.ClosedAt.Value <= e.SlaDeadline.Value) return;
+
+        try
+        {
+            var overdueCloseHours = Math.Round(
+                (decimal)(e.ClosedAt.Value - e.SlaDeadline.Value).TotalHours, 1);
+
+            var sceneCode = string.IsNullOrWhiteSpace(e.SceneCode) ? "S8_DEMO_DEFAULT" : e.SceneCode;
+            // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:派发前 Normalize 落 FOLLOW/SERIOUS。
+            var severity = S8SeverityCode.Normalize(e.Severity);
+
+            var content =
+                $"异常 {e.ExceptionCode} 已关闭,但关闭时间超过 SLA 截止时间,超时关闭 {overdueCloseHours.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)} 小时,请关注闭环及时性。";
+
+            await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
+            {
+                TenantId = e.TenantId,
+                FactoryId = e.FactoryId,
+                ExceptionId = e.Id,
+                ExceptionNo = e.ExceptionCode,
+                SceneCode = sceneCode,
+                Severity = severity,
+                Title = $"【超时关闭】{e.ExceptionCode}",
+                Content = content,
+                Status = e.Status,
+                SourceRuleCode = e.SourceRuleCode,
+                JumpUrl = $"/aidop/s8/exceptions/{e.Id}",
+                OverdueClosed = true,
+                ClosedAt = e.ClosedAt,
+                SlaDeadlineRef = e.SlaDeadline,
+                OverdueCloseHours = overdueCloseHours,
+            });
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "notify_overdue_close_dispatch_throw exceptionId={Id}", e.Id);
+        }
+    }
+
     private async Task TryApproveVerificationFlowAsync(long exceptionId, long currentUserId)
     {
         try

+ 28 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -23,6 +23,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
     private readonly S8NotificationService _notificationService;
     private readonly S8NotificationLayerResolver _notificationLayerResolver;
+    private readonly S8ImpactMetricsService _impactMetricsService;
     private readonly S8ManualReportService _manualReportService;
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
@@ -55,6 +56,7 @@ public class S8WatchSchedulerService : ITransient
         SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
         S8NotificationService notificationService,
         S8NotificationLayerResolver notificationLayerResolver,
+        S8ImpactMetricsService impactMetricsService,
         S8ManualReportService manualReportService,
         S8TimeoutRuleEvaluator timeoutEvaluator,
         S8ShortageRuleEvaluator shortageEvaluator,
@@ -70,6 +72,7 @@ public class S8WatchSchedulerService : ITransient
         _exceptionTypeRep = exceptionTypeRep;
         _notificationService = notificationService;
         _notificationLayerResolver = notificationLayerResolver;
+        _impactMetricsService = impactMetricsService;
         _manualReportService = manualReportService;
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
@@ -1620,9 +1623,27 @@ public class S8WatchSchedulerService : ITransient
             var sceneCode = string.IsNullOrWhiteSpace(entity.SceneCode) ? "S8_DEMO_DEFAULT" : entity.SceneCode;
             // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:写入前 Normalize,落 FOLLOW/SERIOUS。
             var severity = S8SeverityCode.Normalize(entity.Severity);
-            var content =
+
+            // S8-DEMO-IMPACT-SORT-NOTICE-1:附带 30 天影响统计(重复次数 / 累计损失 / 建议关注级别)。
+            // 计算失败不阻断通知主链路;snap==null 时 BuildNotification 仅写基础 Context。
+            ExceptionImpactSnapshot? snap = null;
+            try
+            {
+                snap = await _impactMetricsService.ComputeOneAsync(entity);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogWarning(ex, "notify_impact_compute_throw exceptionId={ExceptionId}", entity.Id);
+            }
+
+            var baseContent =
                 $"异常 {entity.ExceptionCode}:{entity.Title}(场景 {sceneCode},严重度 {severity}" +
                 (string.IsNullOrWhiteSpace(entity.SourceRuleCode) ? "" : $",规则 {entity.SourceRuleCode}") + ")";
+            var content = snap == null
+                ? baseContent
+                : baseContent
+                    + Environment.NewLine
+                    + $"过去30天同类异常发生 {snap.RepeatCount30d} 次,已关闭累计损失 {snap.CumulativeLossHours30d.ToString("0.#")} 小时,建议关注级别:{snap.SuggestedAttentionLabel}";
 
             await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
             {
@@ -1637,6 +1658,11 @@ public class S8WatchSchedulerService : ITransient
                 Status = entity.Status,
                 SourceRuleCode = entity.SourceRuleCode,
                 JumpUrl = $"/aidop/s8/exceptions/{entity.Id}",
+                RepeatCount30d = snap?.RepeatCount30d,
+                CumulativeLossHours30d = snap?.CumulativeLossHours30d,
+                SuggestedAttentionLevel = snap?.SuggestedAttentionLevel,
+                SuggestedAttentionLabel = snap?.SuggestedAttentionLabel,
+                ImpactReason = snap?.ImpactReason,
             });
         }
         catch (Exception ex)
@@ -1650,6 +1676,7 @@ public class S8WatchSchedulerService : ITransient
     /// 非破坏性挂入分层通知。全程异常隔离:任何异常仅 LogWarning,不抛回主流程,不影响 detection_log /
     /// 状态机;call site 已在事务边界外(recovered 路径无事务)。
     /// 重新读取 entity 拿场景/严重度/编号/状态/规则代码(恢复事件相对低频,1 次额外读可接受)。
+    /// S8-DEMO-IMPACT-SORT-NOTICE-1:恢复事件**不**追加影响统计字段,保持原有恢复语义不变。
     /// </summary>
     private async Task TryDispatchRecoveredLayerNotificationAsync(long exceptionId)
     {