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

feat(s8): add order execution fixture store

YY968XX 1 месяц назад
Родитель
Сommit
ca96511b36

+ 98 - 0
Web/src/stores/orderExecution.ts

@@ -0,0 +1,98 @@
+// ORDER-FLOW-FIXTURE-STORE-1:订单执行档案前端 fixture store(不调后端)。
+import { defineStore } from 'pinia';
+import { Session } from '/@/utils/storage';
+import {
+	EXECUTION_MOCK_CUSTOMERS,
+	EXECUTION_OBSERVED_AT,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/customers';
+import {
+	OBSERVED_AT_DATE,
+	buildSalesOrderExecution,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/builder';
+import {
+	computeOrderExecutionKpis,
+	createDefaultFilters,
+	filterOrders,
+	flattenCustomerOrders,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/aggregation';
+import type {
+	CustomerGroup,
+	OrderExecutionFilters,
+	OrderExecutionKpiAverages,
+	SalesOrderExecution,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+const NAV_STATE_KEY = 's8_order_chain_nav_state';
+
+interface OrderExecutionState {
+	customers: CustomerGroup[];
+	filters: OrderExecutionFilters;
+	selectedOrderNo: string | null;
+	initialized: boolean;
+}
+
+export const useOrderExecutionStore = defineStore('orderExecution', {
+	state: (): OrderExecutionState => ({
+		customers: [],
+		filters: createDefaultFilters(),
+		selectedOrderNo: null,
+		initialized: false,
+	}),
+	getters: {
+		allOrders(state): SalesOrderExecution[] {
+			return flattenCustomerOrders(state.customers);
+		},
+		filteredOrders(): SalesOrderExecution[] {
+			return filterOrders(this.allOrders, this.filters);
+		},
+		kpiAverages(): OrderExecutionKpiAverages {
+			return computeOrderExecutionKpis(this.filteredOrders);
+		},
+		customerGroups(state): CustomerGroup[] {
+			return state.customers;
+		},
+		currentObservationTime(): string {
+			return EXECUTION_OBSERVED_AT;
+		},
+		selectedOrder(state): SalesOrderExecution | null {
+			if (!state.selectedOrderNo) return null;
+			return this.allOrders.find((o) => o.soNo === state.selectedOrderNo) ?? null;
+		},
+	},
+	actions: {
+		loadFixture() {
+			if (this.initialized) return;
+			this.customers = EXECUTION_MOCK_CUSTOMERS.map((customer) => ({
+				id: customer.id,
+				name: customer.name,
+				type: customer.type,
+				orders: customer.orders.map((order) =>
+					buildSalesOrderExecution(order, customer.type, OBSERVED_AT_DATE),
+				),
+			}));
+			this.initialized = true;
+		},
+		setFilters(partial: Partial<OrderExecutionFilters>) {
+			this.filters = { ...this.filters, ...partial };
+		},
+		clearFilters() {
+			this.filters = createDefaultFilters();
+		},
+		selectOrderForChain(order: SalesOrderExecution) {
+			this.selectedOrderNo = order.soNo;
+			Session.set(NAV_STATE_KEY, {
+				selectedOrderNo: order.soNo,
+				orders: this.filteredOrders,
+			});
+		},
+		restoreChainSelection() {
+			const snap = Session.get(NAV_STATE_KEY) as
+				| { selectedOrderNo?: string; orders?: SalesOrderExecution[] }
+				| null;
+			if (snap?.selectedOrderNo) this.selectedOrderNo = snap.selectedOrderNo;
+		},
+		clearChainSelection() {
+			this.selectedOrderNo = null;
+		},
+	},
+});

+ 50 - 0
Web/src/views/aidop/s8/monitoring/SoOrderExecutionPreviewPage.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts" name="aidopS8SoOrderExecutionPreview">
+// ORDER-FLOW-FIXTURE-STORE-1:fixture/store 落地预览页。
+// 仅用于本切片自检 105 订单 / 5 客户 / KPI 三均值;本轮不注册菜单 / 不接正式路由 / 不做完整 UI。
+import { computed, onMounted } from 'vue';
+import { useOrderExecutionStore } from '/@/stores/orderExecution';
+
+const store = useOrderExecutionStore();
+
+onMounted(() => {
+	store.loadFixture();
+});
+
+const customerCount = computed(() => store.customerGroups.length);
+const orderCount = computed(() => store.allOrders.length);
+const kpi = computed(() => store.kpiAverages);
+const observedAt = computed(() => store.currentObservationTime);
+const previewOrders = computed(() => store.filteredOrders.slice(0, 5));
+const fmt = (v: number) => v.toFixed(1);
+</script>
+
+<template>
+	<div class="so-preview">
+		<h2>SO 订单执行档案数据预览</h2>
+		<p class="hint">本页仅用于 ORDER-FLOW-FIXTURE-STORE-1 自检;不在正式菜单中。</p>
+
+		<el-descriptions :column="2" border size="small" class="meta">
+			<el-descriptions-item label="客户数">{{ customerCount }}</el-descriptions-item>
+			<el-descriptions-item label="订单数">{{ orderCount }}</el-descriptions-item>
+			<el-descriptions-item label="平均响应时间">{{ fmt(kpi.avgResponse) }}</el-descriptions-item>
+			<el-descriptions-item label="平均处理时间">{{ fmt(kpi.avgProcessing) }}</el-descriptions-item>
+			<el-descriptions-item label="平均损失时间">{{ fmt(kpi.avgLoss) }}</el-descriptions-item>
+			<el-descriptions-item label="当前观测时间">{{ observedAt }}</el-descriptions-item>
+		</el-descriptions>
+
+		<el-table :data="previewOrders" border size="small" class="orders">
+			<el-table-column prop="soNo" label="SO 号" width="160" />
+			<el-table-column prop="customerName" label="客户" width="160" />
+			<el-table-column prop="productName" label="产品名称" width="180" show-overflow-tooltip />
+			<el-table-column prop="currentNode" label="当前节点" width="140" />
+			<el-table-column prop="exceptionCount" label="异常数" width="80" />
+		</el-table>
+	</div>
+</template>
+
+<style scoped>
+.so-preview { padding: 16px; }
+.hint { color: var(--el-text-color-secondary); font-size: 12px; }
+.meta { margin: 12px 0; }
+.orders { margin-top: 12px; }
+</style>

+ 77 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/aggregation.ts

@@ -0,0 +1,77 @@
+// ORDER-FLOW-FIXTURE-STORE-1:KPI 聚合 + 过滤 + 分组工具。
+// 公式严格复刻自 ecc CustomerOrderDashboard.tsx 内联实现 + utils/orderExecutionBuilder.ts getAverage。
+import type {
+	CustomerGroup,
+	OrderExecutionFilters,
+	OrderExecutionKpiAverages,
+	SalesOrderExecution,
+} from './types';
+
+export const getAverage = (values: number[]): number | null => {
+	if (values.length === 0) return null;
+	return values.reduce((sum, value) => sum + value, 0) / values.length;
+};
+
+export const flattenCustomerOrders = (customers: CustomerGroup[]): SalesOrderExecution[] =>
+	customers.flatMap((c) => c.orders);
+
+export const computeOrderExecutionKpis = (
+	orders: SalesOrderExecution[],
+): OrderExecutionKpiAverages => {
+	const responseValues = orders
+		.map((o) => o.responseDisplayMinutes)
+		.filter((v): v is number => v !== null);
+	const processingValues = orders
+		.map((o) => o.currentProcessingMinutes)
+		.filter((v): v is number => v !== null);
+	const lossValues = orders.map((o) => o.totalLossTime).filter((v): v is number => v !== null);
+	return {
+		avgResponse: getAverage(responseValues) ?? 0,
+		avgProcessing: getAverage(processingValues) ?? 0,
+		avgLoss: getAverage(lossValues) ?? 0,
+	};
+};
+
+export const filterOrders = (
+	orders: SalesOrderExecution[],
+	filters: OrderExecutionFilters,
+): SalesOrderExecution[] => {
+	const kw = filters.keyword.trim().toLowerCase();
+	return orders.filter((o) => {
+		if (filters.status && filters.status !== 'all' && o.workflowStatus !== filters.status) return false;
+		if (filters.node && o.currentNodeKey !== filters.node) return false;
+		if (filters.customer && o.customerName !== filters.customer && o.customerCode !== filters.customer) return false;
+		if (filters.productLine && o.productLine !== filters.productLine) return false;
+		if (filters.region && o.region !== filters.region) return false;
+		if (filters.severity && o.nodeStatus !== filters.severity) return false;
+		if (kw) {
+			const hay = `${o.soNo} ${o.orderId} ${o.productName} ${o.customerName}`.toLowerCase();
+			if (!hay.includes(kw)) return false;
+		}
+		// dateRange 字段保留,本轮不实现:源页面同样未在 KPI/筛选阶段使用
+		return true;
+	});
+};
+
+export const groupOrdersByCustomer = (
+	orders: SalesOrderExecution[],
+): Record<string, SalesOrderExecution[]> => {
+	const map: Record<string, SalesOrderExecution[]> = {};
+	for (const o of orders) {
+		const key = o.customerCode || o.customerName;
+		(map[key] ??= []).push(o);
+	}
+	return map;
+};
+
+export const createDefaultFilters = (): OrderExecutionFilters => ({
+	status: '',
+	node: '',
+	keyword: '',
+	customer: '',
+	productLine: '',
+	region: '',
+	severity: '',
+	startDate: null,
+	endDate: null,
+});

+ 282 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/builder.ts

@@ -0,0 +1,282 @@
+import type { ExecutionRawNodeDetail, ExecutionRawOrder } from './types';
+import type { ExceptionEvent, OrderNodeKey } from './types';
+import type { SalesOrderExecution, StageSnapshot, SubStepDetail } from './types';
+
+export const DISPLAY_NODE_NAMES: Record<OrderNodeKey, string> = {
+  order_review: '订单评审',
+  product_design: '产品设计',
+  material_procurement: '材料采购',
+  body_production: '本体生产',
+  final_assembly_shipping: '总装发货',
+};
+
+export const STAGE_KPI_DAYS = [5, 3, 14, 6, 3];
+
+export const OBSERVED_AT_DATE = new Date(2026, 2, 11, 20, 42, 0, 0);
+
+export const WORK_ORDER_TO_EXCEPTION_STATUS: Record<
+  '待响应' | '进行中' | '已完成' | '超时',
+  SalesOrderExecution['exceptionStatus']
+> = {
+  待响应: '待响应',
+  进行中: '处理中',
+  已完成: '已闭环',
+  超时: '处理中',
+};
+
+export const parseMonthDayTime = (value: string | null): Date | null => {
+  if (!value) return null;
+  const matched = value.match(/^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/);
+  if (!matched) return null;
+  const [, month, day, hour, minute] = matched;
+  return new Date(2026, Number(month) - 1, Number(day), Number(hour), Number(minute), 0, 0);
+};
+
+export const toFixedNumber = (value: number, digits = 1): number =>
+  Number(value.toFixed(digits));
+
+export const getElapsedDaysBetween = (startAt: Date, endAt: Date): number => {
+  const diffMs = endAt.getTime() - startAt.getTime();
+  return diffMs <= 0 ? 0 : diffMs / (1000 * 60 * 60 * 24);
+};
+
+export const getElapsedMinutesBetween = (startAt: Date, endAt: Date): number => {
+  const diffMs = endAt.getTime() - startAt.getTime();
+  return diffMs <= 0 ? 0 : Math.round(diffMs / (1000 * 60));
+};
+
+export const getAverage = (values: number[]): number | null => {
+  if (values.length === 0) return null;
+  return values.reduce((sum, value) => sum + value, 0) / values.length;
+};
+
+export const normalizeNodeName = (nodeKey: OrderNodeKey, fallbackName: string): string =>
+  DISPLAY_NODE_NAMES[nodeKey] ?? fallbackName;
+
+export const getStageStatus = (
+  actualDays: number,
+  kpiDays: number,
+): 'green' | 'yellow' | 'red' => {
+  if (actualDays <= kpiDays) return 'green';
+  return (actualDays - kpiDays) / kpiDays <= 0.2 ? 'yellow' : 'red';
+};
+
+export const buildLifecycle = (
+  nodes: ExecutionRawNodeDetail[],
+  observedAt: Date,
+): StageSnapshot[] => {
+  let cumulativeVariance = 0;
+
+  return nodes.map((node) => {
+    // Prefer raw actualDays (= KPI + offset) from mock generator;
+    // fall back to date computation only when raw value is missing.
+    let actualDays: number | null = node.actualDays ?? null;
+    if (actualDays === null) {
+      const actualStartAt = parseMonthDayTime(node.actualStartDate);
+      const actualEndAt = parseMonthDayTime(node.actualDate);
+      if (actualStartAt && actualEndAt) {
+        actualDays = toFixedNumber(getElapsedDaysBetween(actualStartAt, actualEndAt));
+      } else if (actualStartAt) {
+        actualDays = toFixedNumber(getElapsedDaysBetween(actualStartAt, observedAt));
+      }
+    }
+
+    const nodeVarianceDays =
+      actualDays === null ? null : toFixedNumber(actualDays - node.plannedDays);
+    const status: StageSnapshot['status'] =
+      actualDays === null ? 'pending' : getStageStatus(actualDays, node.plannedDays);
+
+    if (nodeVarianceDays !== null) {
+      cumulativeVariance = toFixedNumber(cumulativeVariance + nodeVarianceDays);
+    }
+
+    return {
+      key: node.key,
+      name: normalizeNodeName(node.key, node.name),
+      targetDate: node.expectedDate,
+      actualReachedAt: node.actualDate,
+      actualDays,
+      nodeVarianceDays,
+      cumulativeVarianceDays: nodeVarianceDays === null ? null : cumulativeVariance,
+      status,
+    };
+  });
+};
+
+const hashStr = (s: string): number =>
+  s.split('').reduce((acc, c) => ((acc * 31 + c.charCodeAt(0)) >>> 0), 7);
+
+const REVIEW_SUB_STEP_CONFIGS = [
+  { name: '意见评审', piHours: 8 },
+  { name: '意见反馈', piHours: 12 },
+  { name: '二次评审', piHours: 8 },
+  { name: '领导意见', piHours: 10 },
+  { name: '合同盖章', piHours: 2 },
+] as const;
+
+const OPINION_DEPT_CONFIGS = [
+  { name: '法律事务部', piHours: 2 },
+  { name: '技术售前组', piHours: 3 },
+  { name: '综合主计划', piHours: 1 },
+  { name: '试验站', piHours: 2 },
+] as const;
+
+const distributeHours = <T extends { name: string; piHours: number }>(
+  configs: readonly T[],
+  totalHours: number,
+  seed: number,
+  seedOffset: number,
+): SubStepDetail[] => {
+  const weights = configs.map((c, i) => {
+    const s = ((seed * 31 + (i + seedOffset) * 7) >>> 0);
+    return c.piHours * (0.7 + (s % 70) / 100);
+  });
+  const weightTotal = weights.reduce((sum, w) => sum + w, 0);
+  let remaining = totalHours;
+  return configs.map((c, i) => {
+    const isLast = i === configs.length - 1;
+    const raw = isLast ? remaining : Math.round(totalHours * weights[i] / weightTotal * 10) / 10;
+    const actualHours = Math.max(0.1, raw);
+    if (!isLast) remaining = Math.round((remaining - actualHours) * 10) / 10;
+    const status: SubStepDetail['status'] = actualHours <= c.piHours
+      ? 'green'
+      : (actualHours - c.piHours) / c.piHours <= 0.2 ? 'yellow' : 'red';
+    return { name: c.name, piHours: c.piHours, actualHours, status };
+  });
+};
+
+const buildReviewSubSteps = (soNo: string, actualDays: number | null): SubStepDetail[] => {
+  if (actualDays === null) {
+    return REVIEW_SUB_STEP_CONFIGS.map((c) => ({
+      name: c.name, piHours: c.piHours, actualHours: null, status: 'pending' as const,
+    }));
+  }
+
+  const actualTotal = actualDays * 8;
+  const seed = hashStr(soNo);
+  const subSteps = distributeHours(REVIEW_SUB_STEP_CONFIGS, actualTotal, seed, 0);
+
+  // 意见评审 sub-step gets department breakdown (index 0)
+  return subSteps.map((step, i) => {
+    if (i !== 0 || step.actualHours === null) return step;
+    return {
+      ...step,
+      breakdown: distributeHours(OPINION_DEPT_CONFIGS, step.actualHours, seed, 100),
+    };
+  });
+};
+
+const formatObservedAtStr = (d: Date): string => {
+  const mm = String(d.getMonth() + 1).padStart(2, '0');
+  const dd = String(d.getDate()).padStart(2, '0');
+  const hh = String(d.getHours()).padStart(2, '0');
+  const mi = String(d.getMinutes()).padStart(2, '0');
+  return `${mm}-${dd} ${hh}:${mi}`;
+};
+
+export const buildSalesOrderExecution = (
+  order: ExecutionRawOrder,
+  customerType: 'KA' | 'SMB' | 'MICRO',
+  observedAt: Date,
+): SalesOrderExecution => {
+  const primaryWorkOrder = order.workOrders[0];
+  const soNo = order.id.replace('ORD', 'SO');
+  const rawLifecycle = buildLifecycle(primaryWorkOrder.lifecycle, observedAt);
+  const lifecycle = rawLifecycle.map((stage) => {
+    if (stage.key !== 'order_review') return stage;
+    return { ...stage, subSteps: buildReviewSubSteps(soNo, stage.actualDays ?? null) };
+  });
+  const currentStage =
+    lifecycle.find((stage) => stage.key === order.currentNodeKey) ?? lifecycle[0];
+  const allExceptions: ExceptionEvent[] = primaryWorkOrder.lifecycle.flatMap(
+    (node) => node.exceptions,
+  );
+  // R-01: all completed nodes green → exceptions are absorbed, no KPI impact
+  const allCompletedNodesGreen = lifecycle
+    .filter((stage) => stage.status !== 'pending')
+    .every((stage) => stage.status === 'green');
+  const effectiveExceptions = allCompletedNodesGreen ? [] : allExceptions;
+  const releaseAt = parseMonthDayTime(order.releaseAt);
+  const lastCompletedAt = parseMonthDayTime(
+    primaryWorkOrder.lifecycle[primaryWorkOrder.lifecycle.length - 1]?.actualDate ?? null,
+  );
+  const currentNodeDetail = primaryWorkOrder.lifecycle.find(
+    (node) => node.key === order.currentNodeKey,
+  );
+  const currentNodeStartAt = parseMonthDayTime(currentNodeDetail?.actualStartDate ?? null);
+  const responseFromExceptions = getAverage(
+    effectiveExceptions.map((item) => item.responseMinutes),
+  );
+  const handleFromExceptions = getAverage(
+    effectiveExceptions.map((item) => item.handleMinutes),
+  );
+  // R-03: totalLossTime = responseTime + processingTime (no waitMinutes)
+  const totalLossTime =
+    effectiveExceptions.length > 0
+      ? effectiveExceptions.reduce(
+          (sum, item) => sum + item.responseMinutes + item.handleMinutes,
+          0,
+        )
+      : null;
+  const activeMinutes = currentNodeStartAt
+    ? getElapsedMinutesBetween(currentNodeStartAt, observedAt)
+    : null;
+  const responseDisplayMinutes =
+    primaryWorkOrder.responseTime ??
+    (responseFromExceptions === null
+      ? activeMinutes
+      : Math.round(responseFromExceptions));
+  const currentProcessingMinutes =
+    primaryWorkOrder.processingTime ??
+    (handleFromExceptions === null
+      ? activeMinutes === null || responseDisplayMinutes === null
+        ? null
+        : Math.max(activeMinutes - responseDisplayMinutes, 0)
+      : Math.round(handleFromExceptions));
+  const actualCycleDays =
+    releaseAt && lastCompletedAt && order.workflowStatus === 'completed'
+      ? toFixedNumber(getElapsedDaysBetween(releaseAt, lastCompletedAt))
+      : null;
+  const currentCycleDays = releaseAt
+    ? toFixedNumber(
+        getElapsedDaysBetween(
+          releaseAt,
+          order.workflowStatus === 'completed' && lastCompletedAt
+            ? lastCompletedAt
+            : observedAt,
+        ),
+      )
+    : null;
+
+  return {
+    soNo: order.id.replace('ORD', 'SO'),
+    orderId: order.id,
+    productName: order.productName,
+    productLine: order.productLine,
+    customerName: order.customer,
+    customerCode: order.customer.replace(/\s.*$/, '').toUpperCase(),
+    customerType,
+    region: order.region,
+    priority: order.priority,
+    workflowStatus: order.workflowStatus,
+    currentNode: normalizeNodeName(order.currentNodeKey, primaryWorkOrder.node),
+    currentNodeKey: order.currentNodeKey,
+    focusNodeKey: order.focusNodeKey,
+    nodeStatus: currentStage.status === 'pending' ? 'yellow' : currentStage.status,
+    observedAt: formatObservedAtStr(observedAt),
+    orderStartDate: order.releaseAt,
+    targetCycleDays: STAGE_KPI_DAYS.reduce((sum, days) => sum + days, 0),
+    actualCycleDays,
+    currentCycleDays,
+    nodeVarianceDays: currentStage.nodeVarianceDays,
+    cumulativeVarianceDays: currentStage.cumulativeVarianceDays,
+    exceptionCount: effectiveExceptions.length,
+    responseDisplayMinutes: effectiveExceptions.length > 0 ? responseDisplayMinutes : null,
+    responseTime: effectiveExceptions.length > 0 ? primaryWorkOrder.responseTime : null,
+    processingTime: effectiveExceptions.length > 0 ? primaryWorkOrder.processingTime : null,
+    currentProcessingMinutes,
+    totalLossTime,
+    exceptionStatus: WORK_ORDER_TO_EXCEPTION_STATUS[primaryWorkOrder.status],
+    lifecycle,
+  };
+};

+ 1477 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/customers.ts

@@ -0,0 +1,1477 @@
+/* eslint-disable */
+/**
+ * ORDER-FLOW-FIXTURE-STORE-1: 从 ecc-sandbox/frontend/src/mock/orderExecutionMock.ts 等价复刻;EXECUTION_MOCK_CUSTOMERS = 5 customers / 105 orders。
+ * Use src/services/orderChainService.ts instead.
+ */
+import type { DeliveryOrder, ExceptionEvent, OrderNodeExecution, OrderNodeKey } from './types';
+export type {
+  ExecutionSubStepDetail,
+  ExecutionDesignTaskDetail,
+  ExecutionProcurementFact,
+  ExecutionManufacturingFactor,
+  ExecutionManufacturingProcessDetail,
+  ExecutionManufacturingLossDetail,
+  ExecutionManufacturingFactorDetail,
+  ExecutionManufacturingOperatorDetail,
+  ExecutionManufacturingStepDetail,
+  ExecutionRawNodeDetail,
+  ExecutionRawWorkOrder,
+  ExecutionRawOrder,
+  ExecutionRawCustomer,
+} from './types';
+import type {
+  ExecutionSubStepDetail,
+  ExecutionDesignTaskDetail,
+  ExecutionProcurementFact,
+  ExecutionManufacturingFactor,
+  ExecutionManufacturingLossDetail,
+  ExecutionManufacturingStepDetail,
+  ExecutionRawNodeDetail,
+  ExecutionRawWorkOrder,
+  ExecutionRawOrder,
+  ExecutionRawCustomer,
+} from './types';
+
+type FamilyTemplate = {
+  id: string;
+  count: number;
+  workflowStatus: 'completed' | 'in_progress';
+  productType: '常规' | '非标';
+  priorityPool: Array<'P1' | 'P2' | 'P3'>;
+  supplierRisk: 'low' | 'medium' | 'high';
+  manufacturingLoad: 'low' | 'medium' | 'high';
+  focusNodeKey: OrderNodeKey;
+  currentNodeKey: OrderNodeKey;
+  offsets: Record<OrderNodeKey, [number, number]>;
+  exceptionTemplateIds: string[];
+  minExceptions: number;
+  maxExceptions: number;
+};
+
+type ExceptionTemplate = {
+  category: 'T' | 'R' | 'V';
+  title: string;
+  nodeKey: OrderNodeKey;
+  waitRange: [number, number];
+  responseRange: [number, number];
+  handleRange: [number, number];
+};
+
+type CustomerProfile = {
+  id: string;
+  name: string;
+  type: 'KA' | 'SMB' | 'MICRO';
+};
+
+const OBSERVED_AT = new Date(2026, 2, 11, 20, 42, 0, 0);
+const DAY_MINUTES = 24 * 60;
+
+const NODE_SEQUENCE: Array<{ key: OrderNodeKey; name: string; kpiDays: number }> = [
+  { key: 'order_review', name: '订单评审', kpiDays: 5 },
+  { key: 'product_design', name: '产品设计', kpiDays: 3 },
+  { key: 'material_procurement', name: '材料采购', kpiDays: 14 },
+  { key: 'body_production', name: '本体生产', kpiDays: 6 },
+  { key: 'final_assembly_shipping', name: '总装发货', kpiDays: 3 },
+];
+
+const KPI_DAYS: Record<OrderNodeKey, number> = Object.fromEntries(
+  NODE_SEQUENCE.map((node) => [node.key, node.kpiDays]),
+) as Record<OrderNodeKey, number>;
+
+const NODE_NAME_MAP: Record<OrderNodeKey, string> = Object.fromEntries(
+  NODE_SEQUENCE.map((node) => [node.key, node.name]),
+) as Record<OrderNodeKey, string>;
+
+const NODE_KEY_BY_NAME: Record<string, OrderNodeKey> = Object.fromEntries(
+  NODE_SEQUENCE.map((node) => [node.name, node.key]),
+) as Record<string, OrderNodeKey>;
+
+const CUSTOMER_PROFILES: CustomerProfile[] = [
+  { id: 'C001', name: '电气设备厂A', type: 'KA' },
+  { id: 'C002', name: '电气设备厂B', type: 'KA' },
+  { id: 'C003', name: '电气设备厂C', type: 'KA' },
+  { id: 'C004', name: '电气设备厂D', type: 'SMB' },
+  { id: 'C005', name: '电气设备厂E', type: 'MICRO' },
+];
+
+const CUSTOMER_PICKER = [
+  CUSTOMER_PROFILES[0],
+  CUSTOMER_PROFILES[1],
+  CUSTOMER_PROFILES[2],
+  CUSTOMER_PROFILES[3],
+  CUSTOMER_PROFILES[0],
+  CUSTOMER_PROFILES[4],
+];
+
+const PRODUCT_LINES = ['A产品线', 'B产品线', 'C产品线'] as const;
+const REGIONS = ['华东', '华南', '华北'] as const;
+
+const PRODUCT_NAME_LIBRARY: Record<(typeof PRODUCT_LINES)[number], string[]> = {
+  A产品线: ['高压接线盒', '驱动总成支架', '控制器外壳', '一体化支撑件'],
+  B产品线: ['转向机总成', '减速器壳体', '冷却模块框架', '动力舱隔板'],
+  C产品线: ['模组支架', '电池箱横梁', '试制急单件', '项目定制骨架'],
+};
+
+const DESIGN_OWNER_POOL = ['张工', '李工', '王工', '赵工', '刘工', '陈工', '周工', '吴工'];
+
+const MATERIAL_CODE_BY_PRODUCT_LINE: Record<(typeof PRODUCT_LINES)[number], 'XX' | 'YY' | 'ZZ'> = {
+  A产品线: 'XX',
+  B产品线: 'YY',
+  C产品线: 'ZZ',
+};
+
+const SUPPLIER_GROUPS: Array<'A' | 'B' | 'C'> = ['A', 'B', 'C'];
+const PROCUREMENT_SPEC_CODES = ['L4.5*11.2', 'MT2*6.3', 'MT1*5.4'] as const;
+const MANUFACTURING_FACTORS: ExecutionManufacturingFactor[] = ['材料影响', '设备影响', '质量影响', '作业效率损失'];
+const MANUFACTURING_STEP_TEMPLATES = [
+  { code: '10' as const, name: '备料转序', plannedRatio: 0.15, operatorPool: ['周强', '李森'] },
+  { code: '20' as const, name: '高压线预装', plannedRatio: 0.25, operatorPool: ['王涛', '陈凯', '韩旭'] },
+  { code: '30' as const, name: '压接装配', plannedRatio: 0.25, operatorPool: ['赵峰', '刘杰', '彭勇'] },
+  { code: '40' as const, name: '耐压检测', plannedRatio: 0.2, operatorPool: ['孙宁', '吴珊'] },
+  { code: '50' as const, name: '终检转序', plannedRatio: 0.15, operatorPool: ['郑辉', '何亮'] },
+] as const;
+const MANUFACTURING_PROCESS_TEMPLATES: Record<ExecutionManufacturingFactor, Array<{ processName: string; issueTitle: string }>> = {
+  材料影响: [
+    { processName: '高压线束预装', issueTitle: '关键线材待料补齐' },
+    { processName: '绝缘组件装配', issueTitle: '绝缘件到料窗口波动' },
+    { processName: '端子压接准备', issueTitle: '端子组件齐套延迟' },
+  ],
+  设备影响: [
+    { processName: '压接工位', issueTitle: '压接设备换模等待' },
+    { processName: '耐压测试工位', issueTitle: '测试台停机恢复偏慢' },
+    { processName: '工装校准工位', issueTitle: '关键工装校准时长增加' },
+  ],
+  质量影响: [
+    { processName: '绝缘复检工位', issueTitle: '绝缘项复检占用节拍' },
+    { processName: '端子复核工位', issueTitle: '压接质量复核返工' },
+    { processName: '耐压复测工位', issueTitle: '高压工序复测轮次增加' },
+  ],
+  作业效率损失: [
+    { processName: '班组切换工位', issueTitle: '班组切换衔接损失' },
+    { processName: '节拍平衡工位', issueTitle: '高压工序节拍平衡不足' },
+    { processName: '工装准备工位', issueTitle: '工装准备与转序等待' },
+  ],
+};
+
+const buildManufacturingPlannedStepDays = (
+  seed: string,
+  family: FamilyTemplate,
+  productLine: (typeof PRODUCT_LINES)[number],
+  productName: string,
+) => {
+  const plannedWeights = MANUFACTURING_STEP_TEMPLATES.map((step, index) => {
+    let weight = step.plannedRatio * 10;
+
+    if (productLine === 'A产品线') {
+      if (step.code === '20') {
+        weight += 0.6;
+      }
+      if (step.code === '30') {
+        weight += 0.5;
+      }
+    }
+
+    if (productLine === 'B产品线') {
+      if (step.code === '30') {
+        weight += 0.7;
+      }
+      if (step.code === '40') {
+        weight += 0.5;
+      }
+    }
+
+    if (productLine === 'C产品线') {
+      if (step.code === '10') {
+        weight += 0.4;
+      }
+      if (step.code === '40') {
+        weight += 0.3;
+      }
+      if (step.code === '50') {
+        weight += 0.4;
+      }
+    }
+
+    if (family.productType === '非标') {
+      if (step.code === '20' || step.code === '40') {
+        weight += 0.5;
+      }
+      if (step.code === '50') {
+        weight += 0.2;
+      }
+    }
+
+    if (family.manufacturingLoad === 'high' && step.code === '30') {
+      weight += 0.5;
+    }
+
+    if (family.supplierRisk === 'high' && step.code === '10') {
+      weight += 0.5;
+    }
+
+    if (productName.includes('高压') && step.code === '20') {
+      weight += 0.4;
+    }
+    if ((productName.includes('壳') || productName.includes('框架')) && step.code === '30') {
+      weight += 0.4;
+    }
+    if ((productName.includes('试制') || productName.includes('定制')) && step.code === '40') {
+      weight += 0.5;
+    }
+
+    weight += randomRange(seed, `manufacturing-planned-weight-${step.code}-${index}`, -0.12, 0.18);
+    return Number(Math.max(weight, 0.25).toFixed(2));
+  });
+
+  return allocateValueByWeights(KPI_DAYS.body_production, plannedWeights);
+};
+
+const buildParallelOperatorActualDays = (
+  seed: string,
+  stepCode: ExecutionManufacturingStepDetail['code'],
+  stepActualDays: number,
+  weights: number[],
+) => {
+  if (weights.length === 0 || stepActualDays <= 0) {
+    return weights.map(() => 0);
+  }
+
+  const maxWeight = Math.max(...weights);
+  const leadOperatorIndex = weights.findIndex((weight) => weight === maxWeight);
+
+  return weights.map((weight, operatorIndex) => {
+    if (operatorIndex === leadOperatorIndex) {
+      return Number(stepActualDays.toFixed(1));
+    }
+
+    const normalizedWeight = maxWeight > 0 ? weight / maxWeight : 0.5;
+    const ratio = Math.min(
+      0.96,
+      Math.max(
+        0.58,
+        0.58 + normalizedWeight * 0.22 + randomRange(seed, `${stepCode}-parallel-operator-${operatorIndex}`, -0.06, 0.1),
+      ),
+    );
+
+    return Number((stepActualDays * ratio).toFixed(1));
+  });
+};
+
+const REVIEW_SUBSTEP_BASELINES = [
+  { name: '意见评审', piHours: 8 },
+  { name: '意见反馈', piHours: 12 },
+  { name: '二次评审', piHours: 8 },
+  { name: '领导意见', piHours: 10 },
+  { name: '合同盖章', piHours: 2 },
+] as const;
+
+const OPINION_REVIEW_DEPARTMENT_BASELINES = [
+  { name: '法律事务部', piHours: 2 },
+  { name: '技术售前组', piHours: 3 },
+  { name: '综合主计划', piHours: 1 },
+  { name: '试验站', piHours: 2 },
+] as const;
+
+const OPINION_REVIEW_PI_HOURS = OPINION_REVIEW_DEPARTMENT_BASELINES.reduce((sum, item) => sum + item.piHours, 0);
+
+const EXCEPTION_TEMPLATES: Record<string, ExceptionTemplate> = {
+  E01: { category: 'T', title: '资料缺失复核', nodeKey: 'order_review', waitRange: [40, 180], responseRange: [20, 90], handleRange: [60, 360] },
+  E02: { category: 'T', title: '非标图纸返工', nodeKey: 'product_design', waitRange: [120, 480], responseRange: [30, 120], handleRange: [240, 1440] },
+  E03: { category: 'R', title: '设计资源排队', nodeKey: 'product_design', waitRange: [60, 240], responseRange: [30, 180], handleRange: [180, 960] },
+  E04: { category: 'T', title: '供应商交期延迟', nodeKey: 'material_procurement', waitRange: [180, 720], responseRange: [30, 240], handleRange: [480, 2880] },
+  E05: { category: 'V', title: '来料规格不符', nodeKey: 'material_procurement', waitRange: [120, 480], responseRange: [20, 120], handleRange: [240, 1440] },
+  E06: { category: 'R', title: '备选料切换', nodeKey: 'material_procurement', waitRange: [60, 300], responseRange: [15, 90], handleRange: [180, 720] },
+  E07: { category: 'R', title: '排产切换等待', nodeKey: 'body_production', waitRange: [120, 420], responseRange: [20, 120], handleRange: [240, 960] },
+  E08: { category: 'V', title: '设备停机故障', nodeKey: 'body_production', waitRange: [120, 480], responseRange: [10, 60], handleRange: [120, 1440] },
+  E09: { category: 'T', title: '质量返工', nodeKey: 'body_production', waitRange: [90, 420], responseRange: [20, 90], handleRange: [240, 1200] },
+  E10: { category: 'R', title: '发货窗口调整', nodeKey: 'final_assembly_shipping', waitRange: [30, 120], responseRange: [10, 60], handleRange: [60, 360] },
+};
+
+const FAMILY_TEMPLATES: FamilyTemplate[] = [
+  { id: 'C01', count: 20, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2', 'P3'], supplierRisk: 'low', manufacturingLoad: 'low', focusNodeKey: 'final_assembly_shipping', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-2.5, 1.3], product_design: [-0.9, -0.1], material_procurement: [-4, -1], body_production: [-1.5, -0.3], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E06'], minExceptions: 0, maxExceptions: 1 },
+  { id: 'C02', count: 10, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'low', focusNodeKey: 'material_procurement', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-2.0, -0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.8], body_production: [-1.3, -0.3], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E04'], minExceptions: 1, maxExceptions: 1 },
+  { id: 'C03', count: 8, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'body_production', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.5, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, 0.1], body_production: [-1, 0.15], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E07'], minExceptions: 1, maxExceptions: 1 },
+  { id: 'C04', count: 8, workflowStatus: 'completed', productType: '非标', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'product_design', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-0.3, 3.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.2, -0.1], final_assembly_shipping: [-0.7, -0.1] }, exceptionTemplateIds: ['E02', 'E03'], minExceptions: 1, maxExceptions: 2 },
+  { id: 'C05', count: 6, workflowStatus: 'completed', productType: '非标', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'low', focusNodeKey: 'product_design', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [3.0, 7.0], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.5, -0.2], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E02'], minExceptions: 1, maxExceptions: 1 },
+  { id: 'C06', count: 8, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'high', manufacturingLoad: 'medium', focusNodeKey: 'material_procurement', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.5, 0.3], product_design: [-0.9, -0.1], material_procurement: [15, 27], body_production: [-1.3, -0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E04', 'E05'], minExceptions: 1, maxExceptions: 2 },
+  { id: 'C07', count: 5, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2', 'P3'], supplierRisk: 'high', manufacturingLoad: 'medium', focusNodeKey: 'material_procurement', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-0.5, 1.8], product_design: [-0.6, 0.3], material_procurement: [25.3, 44.2], body_production: [9, 21], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E04', 'E06'], minExceptions: 2, maxExceptions: 2 },
+  { id: 'C08', count: 6, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'body_production', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.8, 0.2], product_design: [-0.9, -0.1], material_procurement: [-4, -0.5], body_production: [-0.9, 0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E07'], minExceptions: 1, maxExceptions: 1 },
+  { id: 'C09', count: 3, workflowStatus: 'completed', productType: '常规', priorityPool: ['P1'], supplierRisk: 'medium', manufacturingLoad: 'high', focusNodeKey: 'body_production', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.2, 0.8], product_design: [-0.8, 0.1], material_procurement: [-4, -0.3], body_production: [17, 29], final_assembly_shipping: [4, 12] }, exceptionTemplateIds: ['E08', 'E09'], minExceptions: 2, maxExceptions: 2 },
+  { id: 'C10', count: 6, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'low', manufacturingLoad: 'low', focusNodeKey: 'order_review', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [9, 16], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.3, -0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E01'], minExceptions: 1, maxExceptions: 1 },
+  { id: 'C11', count: 5, workflowStatus: 'completed', productType: '非标', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'product_design', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [8, 15], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.2, -0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E01', 'E02'], minExceptions: 2, maxExceptions: 2 },
+  { id: 'C12', count: 10, workflowStatus: 'completed', productType: '常规', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'final_assembly_shipping', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.5, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.5, -0.1], final_assembly_shipping: [-0.8, -0.1] }, exceptionTemplateIds: ['E04', 'E07'], minExceptions: 1, maxExceptions: 2 },
+  { id: 'I01', count: 2, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P2'], supplierRisk: 'low', manufacturingLoad: 'low', focusNodeKey: 'order_review', currentNodeKey: 'order_review', offsets: { order_review: [0, 2.5], product_design: [0, 0], material_procurement: [0, 0], body_production: [0, 0], final_assembly_shipping: [0, 0] }, exceptionTemplateIds: ['E01'], minExceptions: 1, maxExceptions: 1 },
+  { id: 'I02', count: 2, workflowStatus: 'in_progress', productType: '非标', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'product_design', currentNodeKey: 'product_design', offsets: { order_review: [-2.0, 0.5], product_design: [1.1, 5.8], material_procurement: [0, 0], body_production: [0, 0], final_assembly_shipping: [-0.8, 0] }, exceptionTemplateIds: ['E02', 'E03'], minExceptions: 1, maxExceptions: 2 },
+  { id: 'I03', count: 3, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P2'], supplierRisk: 'high', manufacturingLoad: 'medium', focusNodeKey: 'material_procurement', currentNodeKey: 'material_procurement', offsets: { order_review: [-2.0, 0.5], product_design: [-0.9, -0.1], material_procurement: [1.8, 7.2], body_production: [0, 0], final_assembly_shipping: [0, 0] }, exceptionTemplateIds: ['E04', 'E05'], minExceptions: 1, maxExceptions: 2 },
+  { id: 'I04', count: 2, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'high', focusNodeKey: 'body_production', currentNodeKey: 'body_production', offsets: { order_review: [-2.0, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.5], body_production: [1.2, 4.9], final_assembly_shipping: [-0.8, 0] }, exceptionTemplateIds: ['E07', 'E08'], minExceptions: 1, maxExceptions: 2 },
+  { id: 'I05', count: 1, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P1'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'final_assembly_shipping', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-2.0, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.5], body_production: [-1.3, 0.3], final_assembly_shipping: [0.0, 2.0] }, exceptionTemplateIds: ['E10'], minExceptions: 1, maxExceptions: 1 },
+];
+
+const hashSeed = (value: string): number => value.split('').reduce((seed, char) => ((seed * 31) + char.charCodeAt(0)) >>> 0, 19);
+
+const randomUnit = (seed: string, salt: string): number => (hashSeed(`${seed}:${salt}`) % 10000) / 10000;
+
+const randomInt = (seed: string, salt: string, min: number, max: number): number =>
+  Math.floor(min + randomUnit(seed, salt) * (max - min + 1));
+
+const randomRange = (seed: string, salt: string, min: number, max: number): number =>
+  min + randomUnit(seed, salt) * (max - min);
+
+const toMinutes = (days: number): number => Math.round(days * DAY_MINUTES);
+
+const addMinutes = (base: Date, minutes: number): Date => {
+  const next = new Date(base);
+  next.setMinutes(next.getMinutes() + minutes);
+  return next;
+};
+
+const formatMonthDay = (date: Date): string => {
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  return `${month}-${day}`;
+};
+
+const formatMonthDayTime = (date: Date): string => {
+  const hour = String(date.getHours()).padStart(2, '0');
+  const minute = String(date.getMinutes()).padStart(2, '0');
+  return `${formatMonthDay(date)} ${hour}:${minute}`;
+};
+
+const parseMonthDayTime = (value: string | null): Date | null => {
+  if (!value) {
+    return null;
+  }
+  const match = value.match(/^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/);
+  if (!match) {
+    return null;
+  }
+  const [, month, day, hour, minute] = match;
+  return new Date(2026, Number(month) - 1, Number(day), Number(hour), Number(minute), 0, 0);
+};
+
+const getStageStatus = (actualDays: number, plannedDays: number): 'green' | 'yellow' | 'red' => {
+  if (actualDays <= plannedDays) {
+    return 'green';
+  }
+  return (actualDays - plannedDays) / plannedDays <= 0.2 ? 'yellow' : 'red';
+};
+
+const buildExceptionEvent = (seed: string, orderId: string, index: number, templateId: string): ExceptionEvent => {
+  const template = EXCEPTION_TEMPLATES[templateId];
+  const waitMinutes = randomInt(seed, `${templateId}-${index}-wait`, template.waitRange[0], template.waitRange[1]);
+  const responseMinutes = randomInt(seed, `${templateId}-${index}-response`, template.responseRange[0], template.responseRange[1]);
+  const handleMinutes = randomInt(seed, `${templateId}-${index}-handle`, template.handleRange[0], template.handleRange[1]);
+  const totalMinutes = waitMinutes + responseMinutes + handleMinutes;
+
+  return {
+    id: `EX-${orderId.slice(-3)}-${String(index + 1).padStart(2, '0')}`,
+    category: template.category,
+    title: template.title,
+    waitMinutes,
+    responseMinutes,
+    handleMinutes,
+    lossDays: Number((totalMinutes / DAY_MINUTES).toFixed(1)),
+  };
+};
+
+const roundHourListToTotal = (values: number[], totalHours: number): number[] => {
+  if (values.length === 0) {
+    return [];
+  }
+  const rounded = values.map((value) => Number(Math.max(value, 0).toFixed(1)));
+  const currentTotal = rounded.reduce((sum, value) => sum + value, 0);
+  const diff = Number((totalHours - currentTotal).toFixed(1));
+  rounded[rounded.length - 1] = Number(Math.max(rounded[rounded.length - 1] + diff, 0).toFixed(1));
+  return rounded;
+};
+
+const allocateValueByWeights = (totalValue: number, weights: number[]): number[] => {
+  if (weights.length === 0 || totalValue <= 0) {
+    return weights.map(() => 0);
+  }
+
+  const normalizedWeights = weights.map((value) => Math.max(value, 0.1));
+  const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
+  return roundHourListToTotal(
+    normalizedWeights.map((value) => (totalValue * value) / weightTotal),
+    totalValue,
+  );
+};
+
+const allocateCountByWeights = (totalCount: number, weights: number[], minEach = 0): number[] => {
+  if (weights.length === 0 || totalCount <= 0) {
+    return weights.map(() => 0);
+  }
+
+  const guaranteed = Math.min(minEach * weights.length, totalCount);
+  const distributable = totalCount - guaranteed;
+  const normalizedWeights = weights.map((value) => Math.max(value, 0.1));
+  const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
+  const rawCounts = normalizedWeights.map((value) => (distributable * value) / weightTotal);
+  const baseCounts = rawCounts.map((value) => Math.floor(value));
+  let remainder = distributable - baseCounts.reduce((sum, value) => sum + value, 0);
+
+  const fractions = rawCounts
+    .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
+    .sort((left, right) => right.fraction - left.fraction);
+
+  for (let index = 0; index < fractions.length && remainder > 0; index += 1) {
+    baseCounts[fractions[index].index] += 1;
+    remainder -= 1;
+  }
+
+  return baseCounts.map((value) => value + (guaranteed >= weights.length * minEach ? minEach : 0));
+};
+
+const allocateHoursByWeights = (totalHours: number, weights: number[]): number[] => {
+  if (weights.length === 0 || totalHours <= 0) {
+    return weights.map(() => 0);
+  }
+
+  const normalizedWeights = weights.map((value) => Math.max(value, 0.1));
+  const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
+  return roundHourListToTotal(
+    normalizedWeights.map((value) => (totalHours * value) / weightTotal),
+    totalHours,
+  );
+};
+
+const getHourStatus = (actualHours: number, piHours: number): ExecutionSubStepDetail['status'] => {
+  if (actualHours <= piHours) {
+    return 'green';
+  }
+  return (actualHours - piHours) / piHours <= 0.2 ? 'yellow' : 'red';
+};
+
+const allocateHoursByPi = (totalHours: number, piHours: number[], extraWeights: number[]): number[] => {
+  if (totalHours <= 0) {
+    return piHours.map(() => 0);
+  }
+
+  const totalPi = piHours.reduce((sum, value) => sum + value, 0);
+  if (totalHours <= totalPi) {
+    const ratio = totalHours / totalPi;
+    return roundHourListToTotal(piHours.map((value) => value * ratio), totalHours);
+  }
+
+  const extraHours = totalHours - totalPi;
+  const normalizedWeights = extraWeights.map((value, index) => Math.max(value, piHours[index] * 0.4, 0.1));
+  const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
+  return roundHourListToTotal(
+    piHours.map((value, index) => value + (extraHours * normalizedWeights[index]) / weightTotal),
+    totalHours,
+  );
+};
+
+const consumeSequentialHours = (
+  templates: ReadonlyArray<{ name: string; piHours: number }>,
+  finalHours: number[],
+  consumedHours: number,
+): ExecutionSubStepDetail[] => {
+  let remaining = Math.max(consumedHours, 0);
+
+  return templates.map((template, index) => {
+    if (remaining <= 0.05) {
+      return {
+        name: template.name,
+        piHours: template.piHours,
+        actualHours: null,
+        status: 'pending',
+      };
+    }
+
+    const usedHours = Number(Math.min(finalHours[index], remaining).toFixed(1));
+    remaining = Number(Math.max(remaining - usedHours, 0).toFixed(1));
+
+    return {
+      name: template.name,
+      piHours: template.piHours,
+      actualHours: usedHours,
+      status: getHourStatus(usedHours, template.piHours),
+    };
+  });
+};
+
+const getElapsedBusinessHours = (startAt: Date | null, endAt: Date | null): number => {
+  if (!startAt || !endAt) {
+    return 0;
+  }
+  const elapsedDays = (endAt.getTime() - startAt.getTime()) / (DAY_MINUTES * 60 * 1000);
+  return Number(Math.max(elapsedDays * 8, 0).toFixed(1));
+};
+
+const getElapsedBusinessDays = (startAt: Date | null, endAt: Date | null): number => {
+  return Number((getElapsedBusinessHours(startAt, endAt) / 8).toFixed(1));
+};
+
+const getReviewStepDelayWeights = (
+  seed: string,
+  family: FamilyTemplate,
+  priority: 'P1' | 'P2' | 'P3',
+  reviewExceptionCount: number,
+): number[] => {
+  const weights = REVIEW_SUBSTEP_BASELINES.map((step, index) => {
+    let weight = step.piHours;
+
+    if (step.name === '意见评审') {
+      weight += reviewExceptionCount * 1.2;
+      if (family.productType === '非标') {
+        weight += 1.6;
+      }
+      if (family.focusNodeKey === 'order_review') {
+        weight += 1.1;
+      }
+    }
+
+    if (step.name === '意见反馈') {
+      weight += reviewExceptionCount * 0.9;
+      if (family.productType === '非标') {
+        weight += 0.8;
+      }
+      if (family.focusNodeKey === 'order_review') {
+        weight += 0.4;
+      }
+    }
+
+    if (step.name === '二次评审') {
+      if (family.productType === '非标') {
+        weight += 1.3;
+      }
+      if (reviewExceptionCount > 0) {
+        weight += 0.5;
+      }
+    }
+
+    if (step.name === '领导意见') {
+      if (priority === 'P1') {
+        weight += 1.6;
+      }
+      if (family.focusNodeKey === 'order_review') {
+        weight += 0.7;
+      }
+    }
+
+    if (step.name === '合同盖章') {
+      if (priority === 'P1') {
+        weight += 0.2;
+      }
+    }
+
+    weight += randomRange(seed, `review-step-weight-${index}`, -0.2, 0.25);
+    return Number(Math.max(weight, 0.3).toFixed(2));
+  });
+
+  return weights;
+};
+
+const getOpinionDepartmentDelayWeights = (
+  seed: string,
+  family: FamilyTemplate,
+  priority: 'P1' | 'P2' | 'P3',
+  reviewExceptionCount: number,
+): number[] => {
+  return OPINION_REVIEW_DEPARTMENT_BASELINES.map((department, index) => {
+    let weight = department.piHours;
+
+    if (department.name === '法律事务部') {
+      weight += reviewExceptionCount * 0.3;
+      if (priority === 'P1') {
+        weight += 0.2;
+      }
+    }
+
+    if (department.name === '技术售前组') {
+      if (family.productType === '非标') {
+        weight += 1.9;
+      }
+      if (family.focusNodeKey === 'order_review' || family.focusNodeKey === 'product_design') {
+        weight += 0.6;
+      }
+      weight += reviewExceptionCount * 0.5;
+    }
+
+    if (department.name === '综合主计划') {
+      if (priority === 'P1') {
+        weight += 1.1;
+      }
+      if (family.supplierRisk === 'high') {
+        weight += 0.3;
+      }
+    }
+
+    if (department.name === '试验站') {
+      if (family.productType === '非标') {
+        weight += 0.7;
+      }
+      if (family.manufacturingLoad === 'high') {
+        weight += 0.2;
+      }
+    }
+
+    weight += randomRange(seed, `opinion-department-weight-${index}`, -0.15, 0.2);
+    return Number(Math.max(weight, 0.2).toFixed(2));
+  });
+};
+
+const buildOrderReviewSubSteps = (
+  seed: string,
+  family: FamilyTemplate,
+  priority: 'P1' | 'P2' | 'P3',
+  finalReviewDays: number,
+  consumedReviewDays: number,
+  reviewExceptions: ExceptionEvent[],
+): ExecutionSubStepDetail[] => {
+  const finalReviewHours = Number(Math.max(finalReviewDays * 8, 0).toFixed(1));
+  const consumedReviewHours = Number(Math.min(Math.max(consumedReviewDays * 8, 0), finalReviewHours).toFixed(1));
+  const reviewWeights = getReviewStepDelayWeights(seed, family, priority, reviewExceptions.length);
+  const finalStepHours = allocateHoursByPi(
+    finalReviewHours,
+    REVIEW_SUBSTEP_BASELINES.map((item) => item.piHours),
+    reviewWeights,
+  );
+  const displayedSteps = consumeSequentialHours(REVIEW_SUBSTEP_BASELINES, finalStepHours, consumedReviewHours);
+  const opinionStepIndex = REVIEW_SUBSTEP_BASELINES.findIndex((item) => item.name === '意见评审');
+  const opinionFinalHours = finalStepHours[opinionStepIndex] ?? OPINION_REVIEW_PI_HOURS;
+  const opinionConsumedHours = displayedSteps[opinionStepIndex]?.actualHours ?? 0;
+  const opinionDepartmentWeights = getOpinionDepartmentDelayWeights(seed, family, priority, reviewExceptions.length);
+  const finalOpinionHours = allocateHoursByPi(
+    opinionFinalHours,
+    OPINION_REVIEW_DEPARTMENT_BASELINES.map((item) => item.piHours),
+    opinionDepartmentWeights,
+  );
+  const displayedOpinionDetails = consumeSequentialHours(
+    OPINION_REVIEW_DEPARTMENT_BASELINES,
+    finalOpinionHours,
+    opinionConsumedHours,
+  );
+
+  return displayedSteps.map((step) => (
+    step.name === '意见评审'
+      ? { ...step, children: displayedOpinionDetails }
+      : step
+  ));
+};
+
+const buildProductDesignTasks = (
+  seed: string,
+  orderId: string,
+  family: FamilyTemplate,
+  nodeActualDays: number,
+  nodeStatus: ExecutionRawNodeDetail['status'],
+  plannedStartAt: Date,
+  plannedEndAt: Date,
+  actualStartAt: Date | null,
+  actualEndAt: Date | null,
+  designExceptions: ExceptionEvent[],
+): ExecutionDesignTaskDetail[] => {
+  const category: ExecutionDesignTaskDetail['category'] = family.productType === '非标' ? '非标产品' : '常规产品';
+  const taskCountBase = family.productType === '非标'
+    ? randomInt(seed, 'design-task-count', 2, 4)
+    : randomInt(seed, 'design-task-count', 1, 2);
+  const taskCount = family.productType === '非标' && family.focusNodeKey === 'product_design'
+    ? Math.min(taskCountBase + 1, 4)
+    : taskCountBase;
+  const effectiveActualEndAt = actualEndAt ?? (actualStartAt ? OBSERVED_AT : null);
+  const taskKpiStartAt = actualStartAt ?? plannedStartAt;
+  const taskExpectedFinishAt = actualStartAt
+    ? addMinutes(actualStartAt, toMinutes(KPI_DAYS.product_design))
+    : plannedEndAt;
+  const criticalIndex = taskCount === 1
+    ? 0
+    : ((family.productType === '非标' || nodeStatus !== 'green' || designExceptions.length > 0)
+      ? taskCount - 1
+      : hashSeed(`${orderId}-design-critical`) % taskCount);
+  const secondaryRiskIndex = taskCount > 2 && family.productType === '非标'
+    ? Math.max(criticalIndex - 1, 0)
+    : -1;
+  const elapsedDays = actualStartAt && effectiveActualEndAt
+    ? Number(((effectiveActualEndAt.getTime() - actualStartAt.getTime()) / (DAY_MINUTES * 60 * 1000)).toFixed(1))
+    : 0;
+  const projectedDaysByTask = Array.from({ length: taskCount }, (_, index) => {
+    if (index === criticalIndex) {
+      return Number(Math.max(nodeActualDays, 0.4).toFixed(1));
+    }
+
+    const baseMax = family.productType === '非标' ? 3.2 : 2.7;
+    const upperBound = Math.max(Math.min(nodeActualDays - 0.2, baseMax), 0.6);
+    let projected = randomRange(seed, `design-task-${index}-projected`, 0.8, upperBound);
+
+    if (index === secondaryRiskIndex && nodeActualDays > KPI_DAYS.product_design + 0.8) {
+      projected = randomRange(
+        seed,
+        `design-task-${index}-secondary-risk`,
+        2.6,
+        Math.max(Math.min(nodeActualDays - 0.1, 4.6), 2.8),
+      );
+    }
+
+    return Number(Math.max(Math.min(projected, Math.max(nodeActualDays - 0.1, 0.8)), 0.4).toFixed(1));
+  });
+
+  const tasks = Array.from({ length: taskCount }, (_, index) => {
+    const prepareLeadHours = randomRange(
+      seed,
+      `design-task-${index}-prepare-lead`,
+      family.productType === '非标' ? 4 : 2,
+      family.productType === '非标' ? 22 : 10,
+    );
+    const prepareStartAt = addMinutes(taskKpiStartAt, -Math.round(prepareLeadHours * 60));
+    const projectedDays = projectedDaysByTask[index];
+    const projectedFinishAt = addMinutes(taskKpiStartAt, toMinutes(projectedDays));
+
+    let actualDays: number | null = null;
+    let actualFinishDate: string | null = null;
+    let status: ExecutionDesignTaskDetail['status'] = 'pending';
+
+    if (nodeStatus !== 'pending' && actualStartAt && effectiveActualEndAt) {
+      const projectedStatus = getStageStatus(projectedDays, KPI_DAYS.product_design);
+
+      if (projectedFinishAt.getTime() <= effectiveActualEndAt.getTime()) {
+        actualDays = projectedDays;
+        actualFinishDate = formatMonthDayTime(projectedFinishAt);
+        status = projectedStatus;
+      } else {
+        actualDays = Number(Math.max(elapsedDays, 0).toFixed(1));
+        actualFinishDate = null;
+        status = projectedStatus;
+      }
+    }
+
+    return {
+      id: `${orderId.replace('ORD-2026-', 'A')}-${String(index + 1).padStart(2, '0')}`,
+      owner: DESIGN_OWNER_POOL[hashSeed(`${orderId}-design-owner-${index}`) % DESIGN_OWNER_POOL.length],
+      category,
+      prepareStartDate: formatMonthDayTime(prepareStartAt),
+      kpiStartDate: formatMonthDayTime(taskKpiStartAt),
+      expectedFinishDate: formatMonthDayTime(taskExpectedFinishAt),
+      actualFinishDate,
+      actualDays,
+      projectedDays: status === 'pending' ? null : projectedDays,
+      status,
+    };
+  });
+
+  return tasks.sort((left, right) => hashSeed(left.id) - hashSeed(right.id));
+};
+
+const buildProcurementFacts = (
+  seed: string,
+  materialCode: 'XX' | 'YY' | 'ZZ',
+  supplierGroup: 'A' | 'B' | 'C',
+  nodeActualDays: number,
+  nodeStatus: ExecutionRawNodeDetail['status'],
+  actualStartAt: Date | null,
+  actualEndAt: Date | null,
+): ExecutionProcurementFact[] => {
+  const materials: Array<'XX' | 'YY' | 'ZZ'> = ['XX', 'YY', 'ZZ'];
+  const suppliers: Array<'A' | 'B' | 'C'> = ['A', 'B', 'C'];
+  const specs = [...PROCUREMENT_SPEC_CODES];
+  const anchoredMaterialBias: Record<'XX' | 'YY' | 'ZZ', number> = {
+    XX: materialCode === 'XX' ? 0.45 : -0.15,
+    YY: materialCode === 'YY' ? 0.5 : -0.1,
+    ZZ: materialCode === 'ZZ' ? 0.35 : -0.2,
+  };
+  const anchoredSupplierBias: Record<'A' | 'B' | 'C', number> = {
+    A: supplierGroup === 'A' ? 0.15 : -0.2,
+    B: supplierGroup === 'B' ? 0.35 : 0.05,
+    C: supplierGroup === 'C' ? 0.4 : 0.1,
+  };
+  const specBias: Record<typeof PROCUREMENT_SPEC_CODES[number], number> = {
+    'L4.5*11.2': -0.2,
+    'MT2*6.3': 0.55,
+    'MT1*5.4': 0.2,
+  };
+
+  const rawFacts = materials.flatMap((material) => (
+    suppliers.flatMap((supplier) => (
+      specs.map((spec) => {
+        let rawCycle = nodeActualDays
+          + anchoredMaterialBias[material]
+          + anchoredSupplierBias[supplier]
+          + specBias[spec]
+          + randomRange(seed, `procurement-${material}-${supplier}-${spec}-noise`, -0.35, 0.35);
+
+        if (supplier === 'B' && (spec === 'MT2*6.3' || spec === 'MT1*5.4')) {
+          rawCycle += 0.35;
+        }
+        if (supplier === 'C' && (spec === 'MT2*6.3' || spec === 'MT1*5.4')) {
+          rawCycle += 0.75;
+        }
+        if (material === 'YY') {
+          rawCycle += 0.2;
+        }
+        if (material === 'ZZ' && spec === 'L4.5*11.2') {
+          rawCycle -= 0.15;
+        }
+
+        return {
+          materialCode: material,
+          supplierGroup: supplier,
+          specCode: spec,
+          rawCycle,
+        };
+      })
+    ))
+  ));
+
+  const averageRawCycle = rawFacts.reduce((sum, fact) => sum + fact.rawCycle, 0) / rawFacts.length;
+  const normalizationDelta = nodeActualDays - averageRawCycle;
+  const kpiStartDate = actualStartAt ? formatMonthDayTime(actualStartAt) : null;
+  const expectedFinishDate = actualStartAt ? formatMonthDayTime(addMinutes(actualStartAt, toMinutes(KPI_DAYS.material_procurement))) : null;
+
+  return rawFacts.map((fact, index) => {
+    const cycleDays = Number(Math.max(fact.rawCycle + normalizationDelta, 8.5).toFixed(1));
+    const impactedUnits = randomInt(seed, `procurement-impact-${fact.materialCode}-${fact.supplierGroup}-${fact.specCode}-${index}`, 1, 3);
+    const actualFinishDate = actualEndAt
+      ? formatMonthDayTime(addMinutes(actualEndAt, Math.round((cycleDays - nodeActualDays) * DAY_MINUTES)))
+      : null;
+    const isOnTimeKit = actualEndAt ? cycleDays <= KPI_DAYS.material_procurement : null;
+    const status = nodeStatus === 'pending'
+      ? 'pending'
+      : getStageStatus(cycleDays, KPI_DAYS.material_procurement);
+
+    return {
+      materialCode: fact.materialCode,
+      specCode: fact.specCode,
+      supplierGroup: fact.supplierGroup,
+      kpiStartDate,
+      expectedFinishDate,
+      actualFinishDate,
+      cycleDays,
+      impactedUnits,
+      isOnTimeKit,
+      status,
+    };
+  });
+};
+
+const getManufacturingLossStatus = (lossHours: number): 'green' | 'yellow' | 'red' => {
+  if (lossHours <= 2) {
+    return 'green';
+  }
+  return lossHours <= 4 ? 'yellow' : 'red';
+};
+
+const buildManufacturingLosses = (
+  seed: string,
+  orderId: string,
+  family: FamilyTemplate,
+  nodeActualDays: number,
+  nodeStatus: ExecutionRawNodeDetail['status'],
+  bodyExceptions: ExceptionEvent[],
+  procurementExceptions: ExceptionEvent[],
+): ExecutionManufacturingLossDetail[] => {
+  if (nodeStatus === 'pending') {
+    return MANUFACTURING_FACTORS.map((factor) => ({
+      factor,
+      impactCount: 0,
+      lossHours: null,
+      status: 'pending',
+      details: [],
+    }));
+  }
+
+  const bodyExceptionTitles = new Set(bodyExceptions.map((item) => item.title));
+  const factorWeights = MANUFACTURING_FACTORS.map((factor, index) => {
+    let weight = 1;
+
+    if (factor === '材料影响') {
+      weight += family.supplierRisk === 'high' ? 1.5 : family.supplierRisk === 'medium' ? 0.8 : 0.2;
+      weight += procurementExceptions.length * 0.7;
+    }
+
+    if (factor === '设备影响') {
+      if (bodyExceptionTitles.has('设备停机故障')) {
+        weight += 2.2;
+      }
+      weight += family.manufacturingLoad === 'high' ? 1.4 : family.manufacturingLoad === 'medium' ? 0.6 : 0.2;
+    }
+
+    if (factor === '质量影响') {
+      if (bodyExceptionTitles.has('质量返工')) {
+        weight += 2;
+      }
+      if (family.productType === '非标') {
+        weight += 0.5;
+      }
+    }
+
+    if (factor === '作业效率损失') {
+      weight += family.manufacturingLoad === 'high' ? 1.3 : family.manufacturingLoad === 'medium' ? 0.8 : 0.4;
+      if (bodyExceptions.length === 0) {
+        weight += 0.4;
+      }
+    }
+
+    weight += randomRange(seed, `manufacturing-factor-weight-${factor}-${index}`, -0.2, 0.35);
+    return Number(Math.max(weight, 0.2).toFixed(2));
+  });
+
+  const delayHours = Math.max(nodeActualDays - KPI_DAYS.body_production, 0) * 8;
+  const exceptionHours = bodyExceptions.reduce((sum, item) => sum + (item.lossDays * 8), 0);
+  const baselineLossHours = nodeStatus === 'green'
+    ? randomRange(seed, 'manufacturing-loss-green', 2.2, 4.8)
+    : nodeStatus === 'yellow'
+      ? randomRange(seed, 'manufacturing-loss-yellow', 6.2, 9.6)
+      : randomRange(seed, 'manufacturing-loss-red', 10.8, 15.8);
+  const totalLossHours = Number(
+    Math.max(
+      baselineLossHours,
+      delayHours + (exceptionHours * 0.35) + randomRange(seed, 'manufacturing-loss-buffer', 0.8, 3.4),
+    ).toFixed(1),
+  );
+  const totalImpactCount = (() => {
+    const baseCount = nodeStatus === 'green'
+      ? randomInt(seed, 'manufacturing-impact-green', 14, 22)
+      : nodeStatus === 'yellow'
+        ? randomInt(seed, 'manufacturing-impact-yellow', 22, 34)
+        : randomInt(seed, 'manufacturing-impact-red', 30, 42);
+    return baseCount + bodyExceptions.length + procurementExceptions.length;
+  })();
+  const factorImpactCounts = allocateCountByWeights(totalImpactCount, factorWeights, 1);
+  const factorLossHours = allocateHoursByWeights(totalLossHours, factorWeights);
+
+  return MANUFACTURING_FACTORS.map((factor, index) => {
+    const impactCount = factorImpactCounts[index] ?? 0;
+    const lossHours = Number((factorLossHours[index] ?? 0).toFixed(1));
+    const templates = MANUFACTURING_PROCESS_TEMPLATES[factor];
+    const detailCount = impactCount >= 10 ? 3 : impactCount >= 4 ? 2 : 1;
+    const detailTemplates = Array.from({ length: detailCount }, (_, detailIndex) => {
+      return templates[(hashSeed(`${seed}-${factor}-detail-start`) + detailIndex) % templates.length];
+    });
+    const detailWeights = Array.from({ length: detailCount }, (_, detailIndex) => (
+      Number(Math.max(randomRange(seed, `${factor}-detail-weight-${detailIndex}`, 0.8, 1.4), 0.2).toFixed(2))
+    ));
+    const detailImpactCounts = allocateCountByWeights(impactCount, detailWeights, impactCount >= detailCount ? 1 : 0);
+    const detailLossHours = allocateHoursByWeights(lossHours, detailWeights);
+    const details = detailTemplates.map((template, detailIndex) => {
+      const detailLoss = Number((detailLossHours[detailIndex] ?? 0).toFixed(1));
+      return {
+        id: `${orderId}-${factor}-${detailIndex + 1}`,
+        processName: template.processName,
+        issueTitle: template.issueTitle,
+        impactCount: detailImpactCounts[detailIndex] ?? 0,
+        lossHours: detailLoss,
+        status: getManufacturingLossStatus(detailLoss),
+      };
+    });
+
+    return {
+      factor,
+      impactCount,
+      lossHours,
+      status: getManufacturingLossStatus(lossHours),
+      details,
+    };
+  });
+};
+
+const buildManufacturingSteps = (
+  seed: string,
+  orderId: string,
+  family: FamilyTemplate,
+  productLine: (typeof PRODUCT_LINES)[number],
+  productName: string,
+  nodeActualDays: number | null,
+  nodeStatus: ExecutionRawNodeDetail['status'],
+  bodyExceptions: ExceptionEvent[],
+  procurementExceptions: ExceptionEvent[],
+): ExecutionManufacturingStepDetail[] => {
+  const bodyExceptionTitles = new Set(bodyExceptions.map((item) => item.title));
+  const stepPlannedDays = buildManufacturingPlannedStepDays(seed, family, productLine, productName);
+
+  if (nodeActualDays === null || nodeStatus === 'pending') {
+    return MANUFACTURING_STEP_TEMPLATES.map((step, stepIndex) => ({
+      code: step.code,
+      name: step.name,
+      plannedDays: Number((stepPlannedDays[stepIndex] ?? 0).toFixed(1)),
+      actualDays: null,
+      status: 'pending',
+      operators: step.operatorPool.map((name, index) => ({
+        id: `${orderId}-${step.code}-${index + 1}`,
+        name,
+        plannedDays: Number((((stepPlannedDays[stepIndex] ?? 0) / step.operatorPool.length)).toFixed(1)),
+        actualDays: null,
+        status: 'pending' as const,
+        factors: MANUFACTURING_FACTORS.map((factor) => ({
+          factor,
+          plannedDays: Number(((((stepPlannedDays[stepIndex] ?? 0) / step.operatorPool.length) / MANUFACTURING_FACTORS.length)).toFixed(1)),
+          actualDays: null,
+          status: 'pending' as const,
+        })),
+      })),
+    }));
+  }
+
+  const stepWeights = MANUFACTURING_STEP_TEMPLATES.map((step, index) => {
+    let weight = (stepPlannedDays[index] ?? 0.1) * 1.7;
+
+    if (step.code === '10') {
+      weight += family.supplierRisk === 'high' ? 1.5 : family.supplierRisk === 'medium' ? 0.7 : 0.2;
+      weight += procurementExceptions.length * 0.5;
+    }
+    if (step.code === '20') {
+      weight += family.productType === '非标' ? 0.6 : 0.2;
+      weight += family.supplierRisk === 'high' ? 0.4 : 0;
+    }
+    if (step.code === '30') {
+      weight += family.manufacturingLoad === 'high' ? 1.8 : family.manufacturingLoad === 'medium' ? 0.8 : 0.3;
+      if (bodyExceptionTitles.has('设备停机故障')) {
+        weight += 1.1;
+      }
+    }
+    if (step.code === '40') {
+      if (bodyExceptionTitles.has('质量返工')) {
+        weight += 1.2;
+      }
+      weight += family.productType === '非标' ? 0.3 : 0.1;
+    }
+    if (step.code === '50') {
+      weight += family.manufacturingLoad === 'high' ? 0.6 : 0.2;
+    }
+
+    weight += randomRange(seed, `manufacturing-step-weight-${step.code}-${index}`, -0.15, 0.2);
+    return Number(Math.max(weight, 0.3).toFixed(2));
+  });
+
+  const stepActualDays = allocateValueByWeights(nodeActualDays, stepWeights);
+
+  return MANUFACTURING_STEP_TEMPLATES.map((step, stepIndex) => {
+    const plannedDays = Number((stepPlannedDays[stepIndex] ?? 0).toFixed(1));
+    const actualDays = Number((stepActualDays[stepIndex] ?? 0).toFixed(1));
+    const operatorWeights = step.operatorPool.map((_, operatorIndex) => {
+      let weight = 1;
+
+      if (step.code === '10') {
+        weight += operatorIndex === 0 ? 0.2 : 0;
+      }
+      if (step.code === '20') {
+        weight += operatorIndex === 1 ? 0.3 : 0.1;
+      }
+      if (step.code === '30') {
+        weight += operatorIndex === 0 ? 0.4 : 0.2;
+      }
+      if (step.code === '40') {
+        weight += operatorIndex === 1 ? 0.35 : 0.15;
+      }
+
+      weight += randomRange(seed, `${step.code}-operator-weight-${operatorIndex}`, -0.1, 0.15);
+      return Number(Math.max(weight, 0.2).toFixed(2));
+    });
+    const operatorPlannedDays = allocateValueByWeights(plannedDays, operatorWeights);
+    const operatorActualDays = buildParallelOperatorActualDays(seed, step.code, actualDays, operatorWeights);
+
+    const operators = step.operatorPool.map((name, operatorIndex) => {
+      const operatorPlanned = Number((operatorPlannedDays[operatorIndex] ?? 0).toFixed(1));
+      const operatorActual = Number((operatorActualDays[operatorIndex] ?? 0).toFixed(1));
+      const factorWeights = MANUFACTURING_FACTORS.map((factor, factorIndex) => {
+        let weight = 1;
+
+        if (factor === '材料影响') {
+          weight += step.code === '10' ? 1.2 : step.code === '20' ? 0.6 : 0.2;
+          weight += family.supplierRisk === 'high' ? 0.7 : 0.2;
+        }
+        if (factor === '设备影响') {
+          weight += step.code === '30' ? 1.1 : step.code === '40' ? 0.6 : 0.2;
+          if (bodyExceptionTitles.has('设备停机故障')) {
+            weight += 0.9;
+          }
+        }
+        if (factor === '质量影响') {
+          weight += step.code === '40' ? 1 : step.code === '30' ? 0.4 : 0.2;
+          if (bodyExceptionTitles.has('质量返工')) {
+            weight += 0.8;
+          }
+        }
+        if (factor === '作业效率损失') {
+          weight += step.code === '50' ? 0.9 : step.code === '30' ? 0.6 : 0.3;
+          weight += family.manufacturingLoad === 'high' ? 0.6 : 0.2;
+        }
+
+        weight += randomRange(seed, `${step.code}-${name}-${factor}-weight-${factorIndex}`, -0.08, 0.12);
+        return Number(Math.max(weight, 0.2).toFixed(2));
+      });
+
+      const factorPlannedDays = allocateValueByWeights(operatorPlanned, factorWeights);
+      const factorActualDays = allocateValueByWeights(operatorActual, factorWeights);
+      const factors = MANUFACTURING_FACTORS.map((factor, factorIndex) => ({
+        factor,
+        plannedDays: Number((factorPlannedDays[factorIndex] ?? 0).toFixed(1)),
+        actualDays: Number((factorActualDays[factorIndex] ?? 0).toFixed(1)),
+        status: getStageStatus(
+          Number((factorActualDays[factorIndex] ?? 0).toFixed(1)),
+          Math.max(Number((factorPlannedDays[factorIndex] ?? 0).toFixed(1)), 0.1),
+        ),
+      }));
+
+      return {
+        id: `${orderId}-${step.code}-${operatorIndex + 1}`,
+        name,
+        plannedDays: operatorPlanned,
+        actualDays: operatorActual,
+        status: getStageStatus(operatorActual, Math.max(operatorPlanned, 0.1)),
+        factors,
+      };
+    });
+
+    return {
+      code: step.code,
+      name: step.name,
+      plannedDays,
+      actualDays,
+      status: getStageStatus(actualDays, Math.max(plannedDays, 0.1)),
+      operators,
+    };
+  });
+};
+
+const pickPriority = (family: FamilyTemplate, seed: string): 'P1' | 'P2' | 'P3' =>
+  family.priorityPool[randomInt(seed, 'priority', 0, family.priorityPool.length - 1)];
+
+const createNodeOffsets = (family: FamilyTemplate, seed: string): Record<OrderNodeKey, number> => {
+  return NODE_SEQUENCE.reduce((result, node) => {
+    const [minOffset, maxOffset] = family.offsets[node.key];
+    const offset = randomRange(seed, `${node.key}-offset`, minOffset, maxOffset);
+    result[node.key] = Number(offset.toFixed(1));
+    return result;
+  }, {} as Record<OrderNodeKey, number>);
+};
+
+const createNodeExceptions = (
+  family: FamilyTemplate,
+  orderId: string,
+  seed: string,
+): Record<OrderNodeKey, ExceptionEvent[]> => {
+  const desiredCount = family.exceptionTemplateIds.length === 0
+    ? 0
+    : randomInt(seed, 'exception-count', family.minExceptions, family.maxExceptions);
+  const selectedTemplateIds = family.exceptionTemplateIds.slice(0, desiredCount === 0 ? 0 : desiredCount);
+
+  return selectedTemplateIds.reduce((result, templateId, index) => {
+    const event = buildExceptionEvent(seed, orderId, index, templateId);
+    const nodeKey = EXCEPTION_TEMPLATES[templateId].nodeKey;
+    result[nodeKey] = [...(result[nodeKey] ?? []), event];
+    return result;
+  }, {} as Record<OrderNodeKey, ExceptionEvent[]>);
+};
+
+const createPlannedAndActualDates = (
+  family: FamilyTemplate,
+  seed: string,
+  actualDurations: Record<OrderNodeKey, number>,
+): {
+  releaseAt: Date;
+  expectedStartAtByNode: Record<OrderNodeKey, Date>;
+  expectedEndAtByNode: Record<OrderNodeKey, Date>;
+  actualStartAtByNode: Record<OrderNodeKey, Date | null>;
+  actualEndAtByNode: Record<OrderNodeKey, Date | null>;
+} => {
+  const expectedStartAtByNode = {} as Record<OrderNodeKey, Date>;
+  const expectedEndAtByNode = {} as Record<OrderNodeKey, Date>;
+  const actualStartAtByNode = {} as Record<OrderNodeKey, Date | null>;
+  const actualEndAtByNode = {} as Record<OrderNodeKey, Date | null>;
+
+  if (family.workflowStatus === 'completed') {
+    const completionGapDays = randomRange(seed, 'completion-gap-days', 0.3, 6.5);
+    let stageEndAt = addMinutes(OBSERVED_AT, -toMinutes(completionGapDays));
+
+    for (let index = NODE_SEQUENCE.length - 1; index >= 0; index -= 1) {
+      const node = NODE_SEQUENCE[index];
+      const stageStartAt = addMinutes(stageEndAt, -toMinutes(actualDurations[node.key]));
+      actualStartAtByNode[node.key] = stageStartAt;
+      actualEndAtByNode[node.key] = stageEndAt;
+      stageEndAt = stageStartAt;
+    }
+  } else {
+    const currentNodeIndex = NODE_SEQUENCE.findIndex((node) => node.key === family.currentNodeKey);
+    const progressRatio = randomRange(seed, 'current-progress', 0.45, 0.92);
+    const currentElapsedMinutes = Math.round(toMinutes(actualDurations[family.currentNodeKey]) * progressRatio);
+    const currentStartAt = addMinutes(OBSERVED_AT, -currentElapsedMinutes);
+    actualStartAtByNode[family.currentNodeKey] = currentStartAt;
+    actualEndAtByNode[family.currentNodeKey] = null;
+
+    let previousEndAt = currentStartAt;
+    for (let index = currentNodeIndex - 1; index >= 0; index -= 1) {
+      const node = NODE_SEQUENCE[index];
+      const stageStartAt = addMinutes(previousEndAt, -toMinutes(actualDurations[node.key]));
+      actualStartAtByNode[node.key] = stageStartAt;
+      actualEndAtByNode[node.key] = previousEndAt;
+      previousEndAt = stageStartAt;
+    }
+
+    for (let index = currentNodeIndex + 1; index < NODE_SEQUENCE.length; index += 1) {
+      const node = NODE_SEQUENCE[index];
+      actualStartAtByNode[node.key] = null;
+      actualEndAtByNode[node.key] = null;
+    }
+  }
+
+  const releaseAt = actualStartAtByNode[NODE_SEQUENCE[0].key] ?? addMinutes(OBSERVED_AT, -toMinutes(31));
+  let plannedStartAt = new Date(releaseAt);
+
+  NODE_SEQUENCE.forEach((node) => {
+    expectedStartAtByNode[node.key] = new Date(plannedStartAt);
+    const plannedEndAt = addMinutes(plannedStartAt, toMinutes(node.kpiDays));
+    expectedEndAtByNode[node.key] = plannedEndAt;
+    plannedStartAt = plannedEndAt;
+  });
+
+  return {
+    releaseAt,
+    expectedStartAtByNode,
+    expectedEndAtByNode,
+    actualStartAtByNode,
+    actualEndAtByNode,
+  };
+};
+
+const createRawOrder = (family: FamilyTemplate, runningIndex: number): ExecutionRawOrder => {
+  const seed = `${family.id}-${runningIndex}`;
+  const customerProfile = CUSTOMER_PICKER[(runningIndex - 1) % CUSTOMER_PICKER.length];
+  const productLine = PRODUCT_LINES[(runningIndex - 1) % PRODUCT_LINES.length];
+  const materialCode = MATERIAL_CODE_BY_PRODUCT_LINE[productLine];
+  const region = REGIONS[(runningIndex + 1) % REGIONS.length];
+  const productOptions = PRODUCT_NAME_LIBRARY[productLine];
+  const productName = productOptions[randomInt(seed, 'product-name', 0, productOptions.length - 1)];
+  const priority = pickPriority(family, seed);
+  const supplierGroup = SUPPLIER_GROUPS[randomInt(seed, 'supplier-group', 0, SUPPLIER_GROUPS.length - 1)];
+  const orderId = `ORD-2026-${String(runningIndex).padStart(3, '0')}`;
+  const actualOffsets = createNodeOffsets(family, seed);
+  const actualDurations = Object.fromEntries(
+    NODE_SEQUENCE.map((node) => [
+      node.key,
+      Number(Math.max(KPI_DAYS[node.key] + actualOffsets[node.key], 0.4).toFixed(1)),
+    ]),
+  ) as Record<OrderNodeKey, number>;
+  const exceptionMap = createNodeExceptions(family, orderId, seed);
+  const timeline = createPlannedAndActualDates(family, seed, actualDurations);
+  const currentActualDurations = Object.fromEntries(
+    NODE_SEQUENCE.map((node) => {
+      const actualEndAt = timeline.actualEndAtByNode[node.key];
+      const actualStartAt = timeline.actualStartAtByNode[node.key];
+      const currentActualDays = actualEndAt
+        ? actualDurations[node.key]
+        : (actualStartAt ? getElapsedBusinessDays(actualStartAt, OBSERVED_AT) : null);
+      return [node.key, currentActualDays];
+    }),
+  ) as Record<OrderNodeKey, number | null>;
+  const reviewStartAt = timeline.actualStartAtByNode.order_review;
+  const reviewEndAt = timeline.actualEndAtByNode.order_review;
+  const reviewConsumedDays = reviewEndAt
+    ? actualDurations.order_review
+    : getElapsedBusinessDays(reviewStartAt, OBSERVED_AT);
+  const designNodeStatus: ExecutionRawNodeDetail['status'] = currentActualDurations.product_design !== null
+    ? getStageStatus(currentActualDurations.product_design, KPI_DAYS.product_design)
+    : 'pending';
+  const reviewSubSteps = buildOrderReviewSubSteps(
+    seed,
+    family,
+    priority,
+    actualDurations.order_review,
+    reviewConsumedDays,
+    exceptionMap.order_review ?? [],
+  );
+  const designSubTasks = buildProductDesignTasks(
+    seed,
+    orderId,
+    family,
+    actualDurations.product_design,
+    designNodeStatus,
+    timeline.expectedStartAtByNode.product_design,
+    timeline.expectedEndAtByNode.product_design,
+    timeline.actualStartAtByNode.product_design,
+    timeline.actualEndAtByNode.product_design,
+    exceptionMap.product_design ?? [],
+  );
+  const procurementFacts = buildProcurementFacts(
+    seed,
+    materialCode,
+    supplierGroup,
+    currentActualDurations.material_procurement ?? actualDurations.material_procurement,
+    currentActualDurations.material_procurement !== null
+      ? getStageStatus(currentActualDurations.material_procurement, KPI_DAYS.material_procurement)
+      : 'pending',
+    timeline.actualStartAtByNode.material_procurement,
+    timeline.actualEndAtByNode.material_procurement,
+  );
+  const manufacturingNodeStatus: ExecutionRawNodeDetail['status'] = currentActualDurations.body_production !== null
+    ? getStageStatus(currentActualDurations.body_production, KPI_DAYS.body_production)
+    : 'pending';
+  const manufacturingLosses = buildManufacturingLosses(
+    seed,
+    orderId,
+    family,
+    currentActualDurations.body_production ?? actualDurations.body_production,
+    manufacturingNodeStatus,
+    exceptionMap.body_production ?? [],
+    exceptionMap.material_procurement ?? [],
+  );
+  const manufacturingSteps = buildManufacturingSteps(
+    seed,
+    orderId,
+    family,
+    productLine,
+    productName,
+    currentActualDurations.body_production,
+    manufacturingNodeStatus,
+    exceptionMap.body_production ?? [],
+    exceptionMap.material_procurement ?? [],
+  );
+
+  const lifecycle = NODE_SEQUENCE.map((node) => {
+    const actualEndAt = timeline.actualEndAtByNode[node.key];
+    const actualStartAt = timeline.actualStartAtByNode[node.key];
+    const actualDays = currentActualDurations[node.key];
+    const status: ExecutionRawNodeDetail['status'] = actualDays !== null
+      ? getStageStatus(actualDays, node.kpiDays)
+      : 'pending';
+
+    return {
+      key: node.key,
+      name: node.name,
+      plannedDays: node.kpiDays,
+      actualDays,
+      expectedDate: formatMonthDay(timeline.expectedEndAtByNode[node.key]),
+      actualStartDate: actualStartAt ? formatMonthDayTime(actualStartAt) : null,
+      actualDate: actualEndAt ? formatMonthDayTime(actualEndAt) : null,
+      status,
+      exceptions: exceptionMap[node.key] ?? [],
+      subSteps: node.key === 'order_review'
+        ? reviewSubSteps
+        : undefined,
+      designTasks: node.key === 'product_design'
+        ? designSubTasks
+        : undefined,
+      manufacturingLosses: node.key === 'body_production'
+        ? manufacturingLosses
+        : undefined,
+      manufacturingSteps: node.key === 'body_production'
+        ? manufacturingSteps
+        : undefined,
+    };
+  });
+
+  const orderExceptions = lifecycle.flatMap((node) => node.exceptions);
+  // R-01/R-02: no exceptions → responseTime and processingTime must be null
+  const averageResponse = orderExceptions.length > 0
+    ? Math.round(orderExceptions.reduce((sum, item) => sum + item.responseMinutes, 0) / orderExceptions.length)
+    : null;
+  const averageHandle = orderExceptions.length > 0
+    ? Math.round(orderExceptions.reduce((sum, item) => sum + item.handleMinutes, 0) / orderExceptions.length)
+    : null;
+  const currentNodeName = NODE_NAME_MAP[family.currentNodeKey];
+  const currentNodeStatus = lifecycle.find((node) => node.key === family.currentNodeKey)?.status ?? 'green';
+  const responseTime = family.workflowStatus === 'in_progress' && randomUnit(seed, 'awaiting-response') < 0.25
+    ? null
+    : averageResponse;
+  const processingTime = family.workflowStatus === 'completed' ? averageHandle : null;
+  const workOrderStatus: ExecutionRawWorkOrder['status'] = family.workflowStatus === 'completed'
+    ? (responseTime !== null && processingTime !== null && (responseTime > 60 || processingTime > 120 || currentNodeStatus === 'red') ? '超时' : '已完成')
+    : (responseTime === null ? '待响应' : '进行中');
+
+  return {
+    id: orderId,
+    productName,
+    productLine,
+    materialCode,
+    customer: customerProfile.name,
+    region,
+    productType: family.productType,
+    priority,
+    supplierGroup,
+    supplierRisk: family.supplierRisk,
+    manufacturingLoad: family.manufacturingLoad,
+    focusNodeKey: family.focusNodeKey,
+    focusNode: NODE_NAME_MAP[family.focusNodeKey],
+    releaseAt: formatMonthDayTime(timeline.releaseAt),
+    workflowStatus: family.workflowStatus,
+    currentNodeKey: family.currentNodeKey,
+    procurementFacts,
+    workOrders: [
+      {
+        id: `WO-${orderId.slice(-3)}`,
+        node: currentNodeName,
+        nodeStatus: currentNodeStatus === 'pending' ? 'yellow' : currentNodeStatus,
+        responseTime,
+        processingTime,
+        status: workOrderStatus,
+        lifecycle,
+      },
+    ],
+  };
+};
+
+const buildExecutionOrders = (): ExecutionRawOrder[] => {
+  let orderIndex = 1;
+  return FAMILY_TEMPLATES.flatMap((family) => Array.from({ length: family.count }, () => {
+    const order = createRawOrder(family, orderIndex);
+    orderIndex += 1;
+    return order;
+  }));
+};
+
+const groupOrdersByCustomer = (orders: ExecutionRawOrder[]): ExecutionRawCustomer[] => {
+  return CUSTOMER_PROFILES.map((profile) => ({
+    ...profile,
+    orders: orders.filter((order) => order.customer === profile.name),
+  })).filter((profile) => profile.orders.length > 0);
+};
+
+const getElapsedDays = (startDate: string | null, endDate: string | null): number => {
+  const start = parseMonthDayTime(startDate);
+  const end = parseMonthDayTime(endDate);
+  if (!start || !end) {
+    return 0;
+  }
+  return Number(((end.getTime() - start.getTime()) / (DAY_MINUTES * 60 * 1000)).toFixed(1));
+};
+
+const buildDeliveryOrders = (orders: ExecutionRawOrder[]): DeliveryOrder[] => {
+  return orders.map((order) => {
+    const nodes: OrderNodeExecution[] = order.workOrders[0].lifecycle.map((node, index, lifecycle) => {
+      const isCurrent = node.key === order.currentNodeKey;
+      const previousEndAt = index > 0 ? lifecycle[index - 1].actualDate : order.releaseAt;
+      const elapsedDays = node.actualDate
+        ? getElapsedDays(node.actualStartDate ?? previousEndAt, node.actualDate)
+        : (isCurrent ? getElapsedDays(node.actualStartDate ?? previousEndAt, formatMonthDayTime(OBSERVED_AT)) : 0);
+
+      return {
+        key: node.key,
+        name: node.name,
+        plannedDays: node.plannedDays,
+        actualDays: node.actualDate
+          ? elapsedDays
+          : (isCurrent ? Number(Math.max(elapsedDays, 0).toFixed(1)) : 0),
+        status: node.actualDate ? 'completed' : (isCurrent ? 'processing' : 'pending'),
+        exceptions: node.exceptions,
+      };
+    });
+
+    const totalPlannedDays = nodes.reduce((sum, node) => sum + node.plannedDays, 0);
+    const totalActualDays = nodes.reduce((sum, node) => sum + node.actualDays, 0);
+    const isLate = nodes.some((node) => node.status !== 'pending' && node.actualDays > node.plannedDays);
+
+    return {
+      id: order.id,
+      productLine: order.productLine,
+      customer: order.customer,
+      region: order.region,
+      isLate,
+      totalPlannedDays,
+      totalActualDays,
+      nodes,
+    };
+  });
+};
+
+export const EXECUTION_MOCK_ORDERS = buildExecutionOrders();
+export const EXECUTION_MOCK_CUSTOMERS = groupOrdersByCustomer(EXECUTION_MOCK_ORDERS);
+export const EXECUTION_DELIVERY_ORDERS = buildDeliveryOrders(EXECUTION_MOCK_ORDERS);
+export const EXECUTION_OBSERVED_AT = formatMonthDayTime(OBSERVED_AT);
+export const EXECUTION_SUMMARY = {
+  total: EXECUTION_MOCK_ORDERS.length,
+  completed: EXECUTION_MOCK_ORDERS.filter((order) => order.workflowStatus === 'completed').length,
+  inProgress: EXECUTION_MOCK_ORDERS.filter((order) => order.workflowStatus === 'in_progress').length,
+};
+export const EXECUTION_NODE_NAME_BY_KEY = NODE_NAME_MAP;
+export const EXECUTION_NODE_KEY_BY_NAME = NODE_KEY_BY_NAME;

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

@@ -0,0 +1,270 @@
+// ORDER-FLOW-FIXTURE-STORE-1:从 ecc-sandbox 迁移的订单执行档案类型。
+// 合并自 ecc src/types/{delivery,dashboard,orderExecution}.ts,保持字段同名以降低后续页面迁移成本。
+
+export type OrderNodeKey =
+	| 'order_review'
+	| 'product_design'
+	| 'material_procurement'
+	| 'body_production'
+	| 'final_assembly_shipping';
+
+export interface ExceptionEvent {
+	id: string;
+	category: 'T' | 'R' | 'V';
+	title: string;
+	waitMinutes: number;
+	responseMinutes: number;
+	handleMinutes: number;
+	lossDays: number;
+}
+
+export interface OrderNodeExecution {
+	key: OrderNodeKey;
+	name: string;
+	plannedDays: number;
+	actualDays: number;
+	status: 'pending' | 'processing' | 'completed';
+	exceptions: ExceptionEvent[];
+}
+
+export interface DeliveryOrder {
+	id: string;
+	productLine: string;
+	customer: string;
+	region: string;
+	isLate: boolean;
+	totalPlannedDays: number;
+	totalActualDays: number;
+	nodes: OrderNodeExecution[];
+}
+
+// ── dashboard.ts ──
+
+export type WorkflowFilter = 'all' | 'in_progress' | 'completed';
+
+export interface SubStepDetail {
+	name: string;
+	piHours: number;
+	actualHours: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+	breakdown?: SubStepDetail[];
+}
+
+export interface StageSnapshot {
+	key: OrderNodeKey;
+	name: string;
+	targetDate: string;
+	actualReachedAt: string | null;
+	actualDays?: number | null;
+	nodeVarianceDays: number | null;
+	cumulativeVarianceDays: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+	subSteps?: SubStepDetail[];
+}
+
+export interface SalesOrderExecution {
+	soNo: string;
+	orderId: string;
+	productName: string;
+	productLine: string;
+	customerName: string;
+	customerCode: string;
+	customerType: 'KA' | 'SMB' | 'MICRO';
+	region: string;
+	priority: 'P1' | 'P2' | 'P3';
+	workflowStatus: 'completed' | 'in_progress';
+	currentNode: string;
+	currentNodeKey: OrderNodeKey;
+	focusNodeKey: OrderNodeKey;
+	nodeStatus: 'green' | 'yellow' | 'red';
+	observedAt: string;
+	orderStartDate: string;
+	targetCycleDays: number;
+	actualCycleDays: number | null;
+	currentCycleDays: number | null;
+	nodeVarianceDays: number | null;
+	cumulativeVarianceDays: number | null;
+	exceptionCount: number;
+	responseDisplayMinutes: number | null;
+	responseTime: number | null;
+	processingTime: number | null;
+	currentProcessingMinutes: number | null;
+	totalLossTime: number | null;
+	exceptionStatus: '待响应' | '处理中' | '已闭环';
+	lifecycle: StageSnapshot[];
+	sampleExceptionId?: string | null;
+	relatedExceptions?: Array<{
+		id: string;
+		title: string;
+		nodeKey: OrderNodeKey;
+		nodeLabel: string;
+		status: '待响应' | '处理中' | '已闭环';
+		impact: string;
+	}>;
+}
+
+export interface CustomerGroup {
+	id: string;
+	name: string;
+	type: 'KA' | 'SMB' | 'MICRO';
+	orders: SalesOrderExecution[];
+}
+
+export interface OrderChainNavState {
+	selectedOrderNo: string;
+	orders: SalesOrderExecution[];
+}
+
+// ── orderExecution.ts (raw mock 类型) ──
+
+export interface ExecutionSubStepDetail {
+	name: string;
+	piHours: number;
+	actualHours: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+	children?: ExecutionSubStepDetail[];
+}
+
+export interface ExecutionDesignTaskDetail {
+	id: string;
+	owner: string;
+	category: '常规产品' | '非标产品';
+	prepareStartDate: string | null;
+	kpiStartDate: string | null;
+	expectedFinishDate: string | null;
+	actualFinishDate: string | null;
+	actualDays: number | null;
+	projectedDays: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+}
+
+export interface ExecutionProcurementFact {
+	materialCode: 'XX' | 'YY' | 'ZZ';
+	specCode: 'L4.5*11.2' | 'MT2*6.3' | 'MT1*5.4';
+	supplierGroup: 'A' | 'B' | 'C';
+	kpiStartDate: string | null;
+	expectedFinishDate: string | null;
+	actualFinishDate: string | null;
+	cycleDays: number | null;
+	impactedUnits: number;
+	isOnTimeKit: boolean | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+}
+
+export type ExecutionManufacturingFactor = '材料影响' | '设备影响' | '质量影响' | '作业效率损失';
+
+export interface ExecutionManufacturingProcessDetail {
+	id: string;
+	processName: string;
+	issueTitle: string;
+	impactCount: number;
+	lossHours: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+}
+
+export interface ExecutionManufacturingLossDetail {
+	factor: ExecutionManufacturingFactor;
+	impactCount: number;
+	lossHours: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+	details: ExecutionManufacturingProcessDetail[];
+}
+
+export interface ExecutionManufacturingFactorDetail {
+	factor: ExecutionManufacturingFactor;
+	plannedDays: number;
+	actualDays: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+}
+
+export interface ExecutionManufacturingOperatorDetail {
+	id: string;
+	name: string;
+	plannedDays: number;
+	actualDays: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+	factors: ExecutionManufacturingFactorDetail[];
+}
+
+export interface ExecutionManufacturingStepDetail {
+	code: '10' | '20' | '30' | '40' | '50';
+	name: string;
+	plannedDays: number;
+	actualDays: number | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+	operators: ExecutionManufacturingOperatorDetail[];
+}
+
+export interface ExecutionRawNodeDetail {
+	key: OrderNodeKey;
+	name: string;
+	plannedDays: number;
+	actualDays: number | null;
+	expectedDate: string;
+	actualStartDate: string | null;
+	actualDate: string | null;
+	status: 'green' | 'yellow' | 'red' | 'pending';
+	exceptions: ExceptionEvent[];
+	subSteps?: ExecutionSubStepDetail[];
+	designTasks?: ExecutionDesignTaskDetail[];
+	manufacturingLosses?: ExecutionManufacturingLossDetail[];
+	manufacturingSteps?: ExecutionManufacturingStepDetail[];
+}
+
+export interface ExecutionRawWorkOrder {
+	id: string;
+	node: string;
+	nodeStatus: 'green' | 'yellow' | 'red';
+	responseTime: number | null;
+	processingTime: number | null;
+	status: '待响应' | '进行中' | '已完成' | '超时';
+	lifecycle: ExecutionRawNodeDetail[];
+}
+
+export interface ExecutionRawOrder {
+	id: string;
+	productName: string;
+	productLine: string;
+	materialCode: 'XX' | 'YY' | 'ZZ';
+	customer: string;
+	region: string;
+	productType: '常规' | '非标';
+	priority: 'P1' | 'P2' | 'P3';
+	supplierGroup: 'A' | 'B' | 'C';
+	supplierRisk: 'low' | 'medium' | 'high';
+	manufacturingLoad: 'low' | 'medium' | 'high';
+	focusNodeKey: OrderNodeKey;
+	focusNode: string;
+	releaseAt: string;
+	workflowStatus: 'completed' | 'in_progress';
+	currentNodeKey: OrderNodeKey;
+	procurementFacts: ExecutionProcurementFact[];
+	workOrders: ExecutionRawWorkOrder[];
+}
+
+export interface ExecutionRawCustomer {
+	id: string;
+	name: string;
+	type: 'KA' | 'SMB' | 'MICRO';
+	orders: ExecutionRawOrder[];
+}
+
+// ── 筛选 + KPI ──
+
+export interface OrderExecutionFilters {
+	status: '' | 'all' | 'in_progress' | 'completed';
+	node: '' | OrderNodeKey;
+	keyword: string;
+	customer: string;
+	productLine: string;
+	region: string;
+	severity: '' | 'green' | 'yellow' | 'red';
+	startDate: string | null;
+	endDate: string | null;
+}
+
+export interface OrderExecutionKpiAverages {
+	avgResponse: number;
+	avgProcessing: number;
+	avgLoss: number;
+}