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

feat(aidop): harden tenant-isolated rebuild and UAT readiness

Add tenant-scoped module rebuilds, KPI target management, file imports, and UAT safeguards so test tenants can be initialized and validated without cross-tenant pollution. Bump Web to 2.4.298 and server to 1.0.356 with migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 2 месяцев назад
Родитель
Сommit
5e55e5e0e8
100 измененных файлов с 13602 добавлено и 323 удалено
  1. 1 1
      Web/package.json
  2. 100 2
      Web/src/layout/component/aside.vue
  3. 4 0
      Web/src/router/index.ts
  4. 2 0
      Web/src/stores/themeConfig.ts
  5. 1 0
      Web/src/types/pinia.d.ts
  6. 7 0
      Web/src/utils/request.ts
  7. 189 0
      Web/src/views/aidop/api/kanbanData.ts
  8. 86 0
      Web/src/views/aidop/api/kanbanModuleRebuild.spec.ts
  9. 37 0
      Web/src/views/aidop/api/kpiTargetApi.ts
  10. 41 0
      Web/src/views/aidop/api/moduleRebuildWatch.spec.ts
  11. 57 0
      Web/src/views/aidop/api/moduleRebuildWatch.ts
  12. 6 0
      Web/src/views/aidop/api/s1RebuildWatch.ts
  13. 38 0
      Web/src/views/aidop/data-platform/api/fileImports.ts
  14. 11 0
      Web/src/views/aidop/data-platform/components/MdpFileImportBatchList.vue
  15. 71 0
      Web/src/views/aidop/data-platform/components/MdpFileImportDrawer.vue
  16. 34 2
      Web/src/views/aidop/data-platform/sources.vue
  17. 185 234
      Web/src/views/aidop/diagnosis/index.vue
  18. 208 6
      Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
  19. 144 0
      Web/src/views/aidop/kanban/components/KpiTargetManager.vue
  20. 260 0
      Web/src/views/aidop/kanban/components/S1RebuildProgressCard.vue
  21. 38 0
      Web/src/views/aidop/kanban/config/moduleDashboardRegistry.spec.ts
  22. 56 31
      Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
  23. 3 0
      Web/src/views/aidop/kanban/kpiMaster.vue
  24. 64 0
      Web/tests/e2e/s2-s7-rebuild-uat.spec.ts
  25. 7 3
      doc/README.md
  26. 682 0
      doc/plan/Ai-DOP剩余待办统一执行任务书_20260814.md
  27. 16 14
      doc/plan/Ai-DOP项目待办清单.md
  28. 1132 0
      doc/plan/S1看板刷新机制修复执行任务书_20260815.md
  29. 1749 0
      doc/plan/S1看板重算进度体验优化执行任务书_20260815.md
  30. 1520 0
      doc/plan/S2-S7及S9看板刷新与重算统一架构执行任务书_20260815.md
  31. 2 0
      doc/plan/UAT多租户数据方案/README.md
  32. 159 0
      doc/plan/UAT测试数据准备/01-历史分析回顾与现状.md
  33. 108 0
      doc/plan/UAT测试数据准备/02-租户定位与决策定案.md
  34. 112 0
      doc/plan/UAT测试数据准备/03-A套缺口补齐清单.md
  35. 158 0
      doc/plan/UAT测试数据准备/04-B套全链路造数清单.md
  36. 192 0
      doc/plan/UAT测试数据准备/05-逐表清单-AB.md
  37. 85 0
      doc/plan/UAT测试数据准备/06-执行阶段与验收.md
  38. 91 0
      doc/plan/UAT测试数据准备/07-A套五组完整UAT场景包.md
  39. 75 0
      doc/plan/UAT测试数据准备/08-B套从零点通剧本.md
  40. 131 0
      doc/plan/UAT测试数据准备/09-Demo价值展示数据方案.md
  41. 99 0
      doc/plan/UAT测试数据准备/10-阻断问题修复与闭环机制.md
  42. 94 0
      doc/plan/UAT测试数据准备/11-总执行任务书.md
  43. 75 0
      doc/plan/UAT测试数据准备/README.md
  44. 204 0
      doc/plan/UAT测试数据准备/WP0-执行记录-20260816.md
  45. 102 0
      doc/plan/UAT测试数据准备/WP0-租户创建与隔离放行.md
  46. 180 0
      doc/plan/UAT测试数据准备/WP1-公共主数据闭包.md
  47. 80 0
      doc/plan/UAT测试数据准备/WP2-A套五组UAT数据.md
  48. 65 0
      doc/plan/UAT测试数据准备/WP3-B套从零点通.md
  49. 69 0
      doc/plan/UAT测试数据准备/WP4-Demo价值展示数据.md
  50. 67 0
      doc/plan/UAT测试数据准备/WP5-DWD-KPI诊断ChartBI对账.md
  51. 78 0
      doc/plan/UAT测试数据准备/WP6-全量验收与交付.md
  52. 61 0
      doc/plan/UAT测试数据准备/WPX-阻断缺陷修复模板.md
  53. 57 0
      doc/plan/UAT测试数据准备/数据瘦身执行记录-20260816.md
  54. 39 0
      doc/plan/UAT留证/2026-08-14-P022-P037执行记录.md
  55. 63 0
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/01-sql-verify.md
  56. 36 0
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/02-kpi-checksum-baseline.md
  57. 65 0
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/README.md
  58. 0 0
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/.gitkeep
  59. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S1-dashboard.png
  60. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-after-quick-refresh.png
  61. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-dashboard.png
  62. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-other-browser-silent.png
  63. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-other-browser-takeover.png
  64. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-rebuild-progress.png
  65. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-rebuild-restore.png
  66. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S3-dashboard.png
  67. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S4-dashboard.png
  68. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S5-dashboard.png
  69. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S6-dashboard.png
  70. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S7-dashboard.png
  71. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S9-after-quick-refresh.png
  72. BIN
      doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S9-dashboard.png
  73. 270 0
      doc/plan/关键定义说明/期量差说明.md
  74. 585 0
      doc/plan/关键定义说明/期量差阈值口径收口任务书.md
  75. 594 0
      doc/plan/智慧诊断配置树展示修复任务书_20260814.md
  76. 1274 0
      doc/plan/运营指标目标管理与Excel数据源接入任务书_20260814.md
  77. 653 0
      doc/plan/运营指标目标管理与Excel数据源接入方案_20260814.md
  78. BIN
      doc/plan/项目验收/01-客户项目交付验收方案_20260807修订.docx
  79. BIN
      doc/plan/项目验收/02-客户项目最低验收对照表.xlsx
  80. BIN
      doc/plan/项目验收/03-产品全量功能验收对照表.xlsx
  81. 70 0
      doc/plan/项目验收/04-双层验收范围与覆盖规则.md
  82. 84 0
      doc/plan/项目验收/05-UAT数据覆盖矩阵.md
  83. 63 0
      doc/plan/项目验收/README.md
  84. 2 0
      doc/项目交付验收方案.md
  85. 15 0
      server/Admin.NET.Application/Configuration/App.json
  86. 14 27
      server/Admin.NET.Core/Update/AutoVersionUpdate.cs
  87. 99 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  88. 20 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.341.sql
  89. 6 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.341.verify.sql
  90. 84 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.342.sql
  91. 11 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.342.verify.sql
  92. 216 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.343.sql
  93. 34 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.343.verify.sql
  94. 10 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.344.sql
  95. 7 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.344.verify.sql
  96. 54 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.345.sql
  97. 15 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.345.verify.sql
  98. 52 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.346.sql
  99. 18 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.346.verify.sql
  100. 56 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.347.sql

+ 1 - 1
Web/package.json

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

+ 100 - 2
Web/src/layout/component/aside.vue

@@ -1,21 +1,34 @@
 <template>
 	<div class="h100" v-show="!isTagsViewCurrenFull">
-		<el-aside class="layout-aside" :class="setCollapseStyle">
+		<el-aside
+			class="layout-aside"
+			:class="[setCollapseStyle, { 'is-resizing': isResizing }]"
+			:style="{ '--layout-aside-width': `${menuAsideWidth}px` }"
+		>
 			<Logo v-if="setShowLogo" />
 			<el-scrollbar class="flex-auto" ref="layoutAsideScrollbarRef" @mouseenter="onAsideEnterLeave(true)" @mouseleave="onAsideEnterLeave(false)">
 				<Vertical :menuList="state.menuList" />
 			</el-scrollbar>
+			<div
+				v-if="showResizeHandle"
+				class="layout-aside__resize-handle"
+				role="separator"
+				aria-label="拖动调整导航栏宽度"
+				aria-orientation="vertical"
+				@mousedown="startResize"
+			/>
 		</el-aside>
 	</div>
 </template>
 
 <script setup lang="ts" name="layoutAside">
-import { defineAsyncComponent, reactive, computed, watch, onBeforeMount, ref } from 'vue';
+import { defineAsyncComponent, reactive, computed, watch, onBeforeMount, onBeforeUnmount, ref } from 'vue';
 import { storeToRefs } from 'pinia';
 import { useRoutesList } from '/@/stores/routesList';
 import { useThemeConfig } from '/@/stores/themeConfig';
 import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
 import mittBus from '/@/utils/mitt';
+import { Local } from '/@/utils/storage';
 
 // 引入组件
 const Logo = defineAsyncComponent(() => import('/@/layout/logo/index.vue'));
@@ -33,6 +46,51 @@ const state = reactive<AsideState>({
 	menuList: [],
 	clientWidth: 0,
 });
+const isResizing = ref(false);
+const MIN_ASIDE_WIDTH = 180;
+const MAX_ASIDE_WIDTH = 360;
+let resizeStartX = 0;
+let resizeStartWidth = 220;
+let resizeFrame: number | null = null;
+
+const clampAsideWidth = (width: number) => Math.min(MAX_ASIDE_WIDTH, Math.max(MIN_ASIDE_WIDTH, Math.round(width)));
+const menuAsideWidth = computed(() => clampAsideWidth(themeConfig.value.menuAsideWidth || 220));
+const showResizeHandle = computed(() => state.clientWidth > 1000 && !themeConfig.value.isCollapse && themeConfig.value.layout !== 'transverse');
+
+const dispatchLayoutResize = () => {
+	if (resizeFrame != null) return;
+	resizeFrame = window.requestAnimationFrame(() => {
+		resizeFrame = null;
+		window.dispatchEvent(new Event('resize'));
+	});
+};
+
+const onResize = (event: MouseEvent) => {
+	themeConfig.value.menuAsideWidth = clampAsideWidth(resizeStartWidth + event.clientX - resizeStartX);
+	dispatchLayoutResize();
+};
+
+const stopResize = () => {
+	if (!isResizing.value) return;
+	isResizing.value = false;
+	document.removeEventListener('mousemove', onResize);
+	document.removeEventListener('mouseup', stopResize);
+	document.body.style.removeProperty('cursor');
+	document.body.style.removeProperty('user-select');
+	Local.set('themeConfig', themeConfig.value);
+	window.dispatchEvent(new Event('resize'));
+};
+
+const startResize = (event: MouseEvent) => {
+	event.preventDefault();
+	isResizing.value = true;
+	resizeStartX = event.clientX;
+	resizeStartWidth = menuAsideWidth.value;
+	document.body.style.cursor = 'col-resize';
+	document.body.style.userSelect = 'none';
+	document.addEventListener('mousemove', onResize);
+	document.addEventListener('mouseup', stopResize);
+};
 
 // 设置菜单展开/收起时的宽度
 const setCollapseStyle = computed(() => {
@@ -155,4 +213,44 @@ watch(
 		setFilterRoutes();
 	}
 );
+
+onBeforeUnmount(() => {
+	stopResize();
+	if (resizeFrame != null) window.cancelAnimationFrame(resizeFrame);
+});
 </script>
+
+<style scoped lang="scss">
+.layout-aside.layout-aside-pc-220 {
+	width: var(--layout-aside-width, 220px) !important;
+}
+
+.layout-aside.is-resizing {
+	transition: none;
+}
+
+.layout-aside__resize-handle {
+	position: absolute;
+	z-index: 10;
+	top: 0;
+	right: 0;
+	width: 6px;
+	height: 100%;
+	cursor: col-resize;
+
+	&::after {
+		position: absolute;
+		top: 0;
+		right: 0;
+		width: 2px;
+		height: 100%;
+		background: transparent;
+		content: '';
+		transition: background-color 0.15s ease;
+	}
+
+	&:hover::after {
+		background: var(--el-color-primary);
+	}
+}
+</style>

+ 4 - 0
Web/src/router/index.ts

@@ -143,5 +143,9 @@ router.afterEach(() => {
 	NProgress.done();
 });
 
+router.onError(() => {
+	NProgress.done();
+});
+
 // 导出路由
 export default router;

+ 2 - 0
Web/src/stores/themeConfig.ts

@@ -35,6 +35,8 @@ export const useThemeConfig = defineStore('themeConfig', {
 			menuBarActiveColor: 'var(--el-color-primary-light-7)',
 			// 是否开启菜单背景颜色渐变
 			isMenuBarColorGradual: false,
+			// 左侧主导航展开宽度(px)
+			menuAsideWidth: 220,
 
 			/**
 			 * 分栏设置

+ 1 - 0
Web/src/types/pinia.d.ts

@@ -71,6 +71,7 @@ declare interface ThemeConfigState {
 		menuBarColor: string; // 侧边栏菜单栏背景色
 		menuBarActiveColor: string; // 侧边栏激活项背景色
 		isMenuBarColorGradual: boolean; // 是否侧边栏菜单栏背景渐变
+		menuAsideWidth: number; // 左侧主导航展开宽度
 		columnsMenuBar: string; // 侧边栏菜单栏背景
 		columnsMenuBarColor: string; // 侧边栏菜单栏背景
 		isColumnsMenuBarColorGradual: boolean; // 是否侧边栏菜单栏背景渐变

+ 7 - 0
Web/src/utils/request.ts

@@ -143,6 +143,13 @@ service.interceptors.response.use(
 
 		// 处理未进行规范化处理的
 		if (status >= 400) {
+			const allowConflict = Boolean(
+				res.config?.headers?.['X-Allow-Conflict'] ||
+				res.config?.headers?.['x-allow-conflict']
+			);
+			if (status === 409 && allowConflict) {
+				return res;
+			}
 			throw new Error(res.statusText || 'Request Error.');
 		}
 

+ 189 - 0
Web/src/views/aidop/api/kanbanData.ts

@@ -154,6 +154,8 @@ export interface SmartDiagnosisPayload {
 	metricName?: string | null;
 	title?: string;
 	subtitle?: string;
+	selectionMode?: 'specified' | 'global_worst' | string;
+	hasChildren?: boolean;
 	root?: {
 		metricCode: string;
 		metricName: string;
@@ -162,6 +164,7 @@ export interface SmartDiagnosisPayload {
 		unit?: string;
 		status?: string;
 		gapLabel?: string | null;
+		department?: string | null;
 	};
 	pmSummary?: { node?: string; leadDelta?: string };
 	stages: SmartDiagnosisStage[];
@@ -476,6 +479,8 @@ export async function fetchSmartDiagnosis(
 			metricName: d.metricName ?? null,
 			title: d.title,
 			subtitle: d.subtitle,
+			selectionMode: d.selectionMode,
+			hasChildren: d.hasChildren,
 			root: d.root,
 			pmSummary: d.pmSummary,
 			stages: Array.isArray(d.stages) ? d.stages : [],
@@ -857,6 +862,190 @@ export async function refreshModuleMdp(moduleCode: string): Promise<{
 	}
 }
 
+export interface S1TargetSnapshotRefreshResult {
+	ok: boolean;
+	moduleCode?: string;
+	factoryId?: number;
+	updatedRows?: number;
+	missingRows?: number;
+	elapsedMs?: number;
+	message?: string;
+}
+
+export async function refreshS1TargetSnapshot(factoryId = 1): Promise<S1TargetSnapshotRefreshResult> {
+	try {
+		const res = await service.post('/api/AidopKanban/s1/target-snapshot/refresh', undefined, {
+			params: { factoryId },
+			timeout: 10000,
+		});
+		const d = res.data ?? {};
+		return {
+			ok: d.ok !== false,
+			moduleCode: d.moduleCode,
+			factoryId: d.factoryId,
+			updatedRows: d.updatedRows,
+			missingRows: d.missingRows,
+			elapsedMs: d.elapsedMs,
+			message: d.message,
+		};
+	} catch (e: any) {
+		const msg =
+			e?.response?.data?.message ??
+			(typeof e?.response?.data === 'string' ? e.response.data : undefined) ??
+			e?.message;
+		return { ok: false, message: typeof msg === 'string' ? msg : '刷新失败' };
+	}
+}
+
+export interface S1RebuildJob {
+	ok?: boolean;
+	jobId?: number;
+	status?: string;
+	currentStage?: string;
+	stageIndex?: number;
+	stageTotal?: number;
+	progressPercent?: number;
+	progressMessage?: string;
+	lastProgressAt?: string;
+	heartbeatAt?: string;
+	failedStage?: string;
+	submittedAt?: string;
+	startedAt?: string;
+	finishedAt?: string;
+	durationMs?: number | null;
+	batchId?: string;
+	stageRows?: number;
+	standardRows?: number;
+	dwdRows?: number;
+	kpiRows?: number;
+	atomicRows?: number;
+	errorMessage?: string;
+	message?: string;
+}
+
+export async function createS1RebuildJob(factoryId = 1): Promise<S1RebuildJob> {
+	try {
+		const res = await service.post('/api/AidopKanban/s1/rebuild-jobs', undefined, {
+			params: { factoryId },
+			timeout: 10000,
+			headers: { 'X-Allow-Conflict': '1' },
+			validateStatus: (s) => s === 202 || s === 409 || (s >= 200 && s < 300),
+		});
+		const d = res.data ?? {};
+		return {
+			ok: Boolean(d.ok),
+			jobId: d.jobId,
+			status: d.status,
+			message: d.message,
+		};
+	} catch (e: any) {
+		const d = e?.response?.data ?? {};
+		if (e?.response?.status === 409) {
+			return { ok: false, jobId: d.jobId, status: d.status, message: d.message };
+		}
+		const msg = d.message ?? e?.message;
+		return { ok: false, message: typeof msg === 'string' ? msg : '提交失败' };
+	}
+}
+
+export async function fetchS1RebuildJob(jobId: number, factoryId = 1): Promise<S1RebuildJob | null> {
+	try {
+		const res = await service.get(`/api/AidopKanban/s1/rebuild-jobs/${jobId}`, {
+			params: { factoryId },
+			timeout: 8000,
+			headers: { 'X-Silent-Error': '1' },
+			validateStatus: (s) => s === 200 || s === 404 || s === 403,
+		});
+		if (res.status === 404 || res.status === 403) return null;
+		return (res.data ?? null) as S1RebuildJob | null;
+	} catch {
+		return null;
+	}
+}
+
+export async function fetchLatestS1RebuildJob(factoryId = 1): Promise<S1RebuildJob | null> {
+	try {
+		const res = await service.get('/api/AidopKanban/s1/rebuild-jobs/latest', {
+			params: { factoryId },
+			timeout: 8000,
+			headers: { 'X-Silent-Error': '1' },
+		});
+		return (res.data ?? null) as S1RebuildJob | null;
+	} catch {
+		return null;
+	}
+}
+
+export async function refreshModuleTargetSnapshot(moduleCode: string, factoryId = 1): Promise<S1TargetSnapshotRefreshResult> {
+	const mc = String(moduleCode || '').toUpperCase();
+	try {
+		const res = await service.post(`/api/AidopKanban/${mc.toLowerCase()}/target-snapshot/refresh`, undefined, {
+			params: { factoryId },
+			timeout: 10000,
+		});
+		const d = res.data ?? {};
+		return {
+			ok: d.ok !== false,
+			moduleCode: d.moduleCode,
+			factoryId: d.factoryId,
+			updatedRows: d.updatedRows,
+			missingRows: d.missingRows,
+			elapsedMs: d.elapsedMs,
+			message: d.message,
+		};
+	} catch (e: any) {
+		const msg =
+			e?.response?.data?.message ??
+			(typeof e?.response?.data === 'string' ? e.response.data : undefined) ??
+			e?.message;
+		return { ok: false, message: typeof msg === 'string' ? msg : '刷新失败' };
+	}
+}
+
+export async function createModuleRebuildJob(moduleCode: string, factoryId = 1): Promise<S1RebuildJob> {
+	const mc = String(moduleCode || '').toUpperCase();
+	const path = mc === 'S1' ? '/api/AidopKanban/s1/rebuild-jobs' : `/api/AidopKanban/${mc}/rebuild-jobs`;
+	try {
+		const res = await service.post(path, undefined, {
+			params: { factoryId },
+			timeout: 10000,
+			headers: { 'X-Allow-Conflict': '1' },
+			validateStatus: (s) => s === 202 || s === 409 || (s >= 200 && s < 300),
+		});
+		const d = res.data ?? {};
+		return {
+			ok: Boolean(d.ok),
+			jobId: d.jobId,
+			status: d.status,
+			message: d.message,
+		};
+	} catch (e: any) {
+		const d = e?.response?.data ?? {};
+		if (e?.response?.status === 409) {
+			return { ok: false, jobId: d.jobId, status: d.status, message: d.message };
+		}
+		const msg = d.message ?? e?.message;
+		return { ok: false, message: typeof msg === 'string' ? msg : '提交失败' };
+	}
+}
+
+export async function fetchModuleRebuildJob(moduleCode: string, jobId: number, factoryId = 1): Promise<S1RebuildJob | null> {
+	const mc = String(moduleCode || '').toUpperCase();
+	const path = mc === 'S1' ? `/api/AidopKanban/s1/rebuild-jobs/${jobId}` : `/api/AidopKanban/${mc}/rebuild-jobs/${jobId}`;
+	try {
+		const res = await service.get(path, {
+			params: { factoryId },
+			timeout: 8000,
+			headers: { 'X-Silent-Error': '1' },
+			validateStatus: (s) => s === 200 || s === 404 || s === 403,
+		});
+		if (res.status === 404 || res.status === 403) return null;
+		return (res.data ?? null) as S1RebuildJob | null;
+	} catch {
+		return null;
+	}
+}
+
 // ───────────── 通用运营看板接口(S1~S9 除 S8,基于 KpiMaster + LayoutItem 动态) ─────────────
 
 /** 通用九宫格首页(L1)。返回与 S4HomeGridPayload 同构。 */

+ 86 - 0
Web/src/views/aidop/api/kanbanModuleRebuild.spec.ts

@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const postMock = vi.fn();
+const getMock = vi.fn();
+
+vi.mock('/@/utils/request', () => ({
+	default: {
+		get: (...args: unknown[]) => getMock(...args),
+		post: (...args: unknown[]) => postMock(...args),
+		put: vi.fn(),
+		delete: vi.fn(),
+		patch: vi.fn(),
+	},
+}));
+
+vi.mock('/@/stores/userInfo', () => ({
+	useUserInfo: () => ({
+		userInfos: { currentTenantId: 9, tenantId: 9 },
+	}),
+}));
+
+import { createModuleRebuildJob, fetchModuleRebuildJob, refreshModuleTargetSnapshot, refreshS1TargetSnapshot, createS1RebuildJob } from './kanbanData';
+
+describe('kanban rebuild/refresh APIs', () => {
+	beforeEach(() => {
+		postMock.mockReset();
+		getMock.mockReset();
+	});
+
+	it('S2-S7/S9 light refresh hits snapshot API not MDP refresh', async () => {
+		postMock.mockResolvedValue({ data: { ok: true, updatedRows: 3, elapsedMs: 12 } });
+		await refreshModuleTargetSnapshot('S2', 1);
+		await refreshModuleTargetSnapshot('S9', 1);
+		expect(postMock.mock.calls.map((c) => c[0])).toEqual([
+			'/api/AidopKanban/s2/target-snapshot/refresh',
+			'/api/AidopKanban/s9/target-snapshot/refresh',
+		]);
+		expect(postMock.mock.calls.some((c) => String(c[0]).includes('/mdp/refresh'))).toBe(false);
+	});
+
+	it('S1 light refresh stays on s1 snapshot path', async () => {
+		postMock.mockResolvedValue({ data: { ok: true } });
+		await refreshS1TargetSnapshot(1);
+		expect(postMock).toHaveBeenCalledWith('/api/AidopKanban/s1/target-snapshot/refresh', undefined, expect.objectContaining({ params: { factoryId: 1 } }));
+	});
+
+	it('S2 rebuild posts async jobs API with 202/409 accepted', async () => {
+		postMock.mockResolvedValue({ status: 202, data: { ok: true, jobId: 11, status: 'QUEUED' } });
+		const accepted = await createModuleRebuildJob('S2', 1);
+		expect(postMock).toHaveBeenCalledWith('/api/AidopKanban/S2/rebuild-jobs', undefined, expect.objectContaining({
+			params: { factoryId: 1 },
+			headers: { 'X-Allow-Conflict': '1' },
+		}));
+		expect(accepted.jobId).toBe(11);
+		expect(accepted.status).toBe('QUEUED');
+	});
+
+	it('409 conflict returns existing jobId', async () => {
+		postMock.mockRejectedValue({ response: { status: 409, data: { ok: false, jobId: 11, status: 'RUNNING', message: '正在执行' } } });
+		const accepted = await createModuleRebuildJob('S3', 1);
+		expect(accepted.ok).toBe(false);
+		expect(accepted.jobId).toBe(11);
+		expect(accepted.status).toBe('RUNNING');
+	});
+
+	it('409 success-path (interceptor pass-through) still returns jobId', async () => {
+		postMock.mockResolvedValue({ status: 409, data: { ok: false, jobId: 22, status: 'QUEUED', message: '正在执行' } });
+		const accepted = await createModuleRebuildJob('S2', 1);
+		expect(accepted.ok).toBe(false);
+		expect(accepted.jobId).toBe(22);
+		expect(accepted.status).toBe('QUEUED');
+	});
+
+	it('S1 rebuild keeps dedicated path', async () => {
+		postMock.mockResolvedValue({ status: 202, data: { ok: true, jobId: 5, status: 'QUEUED' } });
+		await createS1RebuildJob(1);
+		expect(postMock).toHaveBeenCalledWith('/api/AidopKanban/s1/rebuild-jobs', undefined, expect.objectContaining({ params: { factoryId: 1 } }));
+	});
+
+	it('fetch job is scoped by module and id', async () => {
+		getMock.mockResolvedValue({ status: 200, data: { jobId: 11, progressPercent: 40, status: 'RUNNING' } });
+		const row = await fetchModuleRebuildJob('S2', 11, 1);
+		expect(getMock).toHaveBeenCalledWith('/api/AidopKanban/S2/rebuild-jobs/11', expect.objectContaining({ params: { factoryId: 1 } }));
+		expect(row?.progressPercent).toBe(40);
+	});
+});

+ 37 - 0
Web/src/views/aidop/api/kpiTargetApi.ts

@@ -0,0 +1,37 @@
+import service from '/@/utils/request';
+
+const BASE = '/api/smart-ops/kpi-targets';
+
+export function listTargets(metricCode: string, factoryId?: number) {
+	return service({ url: BASE, method: 'get', params: { metricCode, factoryId } });
+}
+
+export function currentTarget(metricCode: string, factoryId: number, bizDate?: string) {
+	return service({ url: `${BASE}/current`, method: 'get', params: { metricCode, factoryId, bizDate } });
+}
+
+export function createTarget(data: any) {
+	return service({ url: BASE, method: 'post', data });
+}
+
+export function disableTarget(id: number) {
+	return service({ url: `${BASE}/${id}/disable`, method: 'post' });
+}
+
+export function downloadTargetTemplate() {
+	return service({ url: `${BASE}/template`, method: 'get', responseType: 'blob' });
+}
+
+export function validateTargetImport(file: File, factoryId = 0) {
+	const data = new FormData();
+	data.append('file', file);
+	return service({ url: `${BASE}/import/validate`, method: 'post', params: { factoryId }, data, headers: { 'Content-Type': 'multipart/form-data' } });
+}
+
+export function commitTargetImport(batchId: number) {
+	return service({ url: `${BASE}/import/${batchId}/commit`, method: 'post' });
+}
+
+export function downloadTargetImportErrors(batchId: number) {
+	return service({ url: `${BASE}/import/${batchId}/errors`, method: 'get', responseType: 'blob' });
+}

+ 41 - 0
Web/src/views/aidop/api/moduleRebuildWatch.spec.ts

@@ -0,0 +1,41 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('/@/stores/userInfo', () => ({
+	useUserInfo: () => ({
+		userInfos: { currentTenantId: 9, tenantId: 9 },
+	}),
+}));
+
+import {
+	clearWatchedRebuildJobId,
+	moduleRebuildWatchKey,
+	readWatchedRebuildJobId,
+	writeWatchedRebuildJobId,
+} from './moduleRebuildWatch';
+
+describe('moduleRebuildWatch', () => {
+	beforeEach(() => {
+		localStorage.clear();
+	});
+
+	it('keys isolate module tenant and factory', () => {
+		expect(moduleRebuildWatchKey('S2', 9, 1)).toBe('aidop.s2.rebuild.watch.9.1');
+		expect(moduleRebuildWatchKey('S2', 10, 1)).toBe('aidop.s2.rebuild.watch.10.1');
+		expect(moduleRebuildWatchKey('S3', 9, 1)).toBe('aidop.s3.rebuild.watch.9.1');
+		expect(moduleRebuildWatchKey('S2', 0, 1)).toBeNull();
+	});
+
+	it('does not leak watched job across modules', () => {
+		writeWatchedRebuildJobId('S2', 101);
+		expect(readWatchedRebuildJobId('S2')).toBe(101);
+		expect(readWatchedRebuildJobId('S3')).toBeNull();
+	});
+
+	it('clear only removes current module key', () => {
+		writeWatchedRebuildJobId('S2', 101);
+		writeWatchedRebuildJobId('S4', 202);
+		clearWatchedRebuildJobId('S2');
+		expect(readWatchedRebuildJobId('S2')).toBeNull();
+		expect(readWatchedRebuildJobId('S4')).toBe(202);
+	});
+});

+ 57 - 0
Web/src/views/aidop/api/moduleRebuildWatch.ts

@@ -0,0 +1,57 @@
+import { resolveAidopTenantId } from './aidopTenant';
+
+const FACTORY_ID = 1;
+
+export function moduleRebuildWatchKey(moduleCode: string, tenantId?: number, factoryId = FACTORY_ID): string | null {
+	const mc = String(moduleCode || '').trim().toLowerCase();
+	if (!mc || !tenantId || tenantId <= 0 || factoryId <= 0) return null;
+	return `aidop.${mc}.rebuild.watch.${tenantId}.${factoryId}`;
+}
+
+export function readWatchedRebuildJobId(moduleCode: string, factoryId = FACTORY_ID): number | null {
+	const key = moduleRebuildWatchKey(moduleCode, resolveAidopTenantId(), factoryId);
+	if (!key) return null;
+	try {
+		const raw = localStorage.getItem(key);
+		const n = raw ? Number(raw) : NaN;
+		return Number.isFinite(n) && n > 0 ? n : null;
+	} catch {
+		return null;
+	}
+}
+
+export function writeWatchedRebuildJobId(moduleCode: string, jobId: number, factoryId = FACTORY_ID) {
+	const key = moduleRebuildWatchKey(moduleCode, resolveAidopTenantId(), factoryId);
+	if (!key || !jobId) return;
+	try {
+		localStorage.setItem(key, String(jobId));
+	} catch {
+		/* ignore quota */
+	}
+}
+
+export function clearWatchedRebuildJobId(moduleCode: string, factoryId = FACTORY_ID) {
+	const key = moduleRebuildWatchKey(moduleCode, resolveAidopTenantId(), factoryId);
+	if (!key) return;
+	try {
+		localStorage.removeItem(key);
+	} catch {
+		/* ignore */
+	}
+}
+
+export function s1RebuildWatchKey(tenantId?: number, factoryId = FACTORY_ID): string | null {
+	return moduleRebuildWatchKey('S1', tenantId, factoryId);
+}
+
+export function readWatchedS1RebuildJobId(factoryId = FACTORY_ID): number | null {
+	return readWatchedRebuildJobId('S1', factoryId);
+}
+
+export function writeWatchedS1RebuildJobId(jobId: number, factoryId = FACTORY_ID) {
+	writeWatchedRebuildJobId('S1', jobId, factoryId);
+}
+
+export function clearWatchedS1RebuildJobId(factoryId = FACTORY_ID) {
+	clearWatchedRebuildJobId('S1', factoryId);
+}

+ 6 - 0
Web/src/views/aidop/api/s1RebuildWatch.ts

@@ -0,0 +1,6 @@
+export {
+	s1RebuildWatchKey,
+	readWatchedS1RebuildJobId,
+	writeWatchedS1RebuildJobId,
+	clearWatchedS1RebuildJobId,
+} from './moduleRebuildWatch';

+ 38 - 0
Web/src/views/aidop/data-platform/api/fileImports.ts

@@ -0,0 +1,38 @@
+import service from '/@/utils/request';
+
+export function fetchFileEntities() {
+	return service.get('/api/DataPlatform/file-imports/entities').then((r) => r.data?.list ?? r.data ?? []);
+}
+
+export function downloadFileTemplate(entityCode: string) {
+	return service.get(`/api/DataPlatform/file-imports/template/${encodeURIComponent(entityCode)}`, { responseType: 'blob' });
+}
+
+export function validateFileImport(file: File, entityCode: string, factoryId = 0) {
+	const data = new FormData();
+	data.append('file', file);
+	return service.post('/api/DataPlatform/file-imports/validate', data, {
+		params: { entityCode, factoryId },
+		headers: { 'Content-Type': 'multipart/form-data' },
+	});
+}
+
+export function commitFileImport(batchNo: string) {
+	return service.post(`/api/DataPlatform/file-imports/${encodeURIComponent(batchNo)}/commit`);
+}
+
+export function voidFileImport(batchNo: string) {
+	return service.post(`/api/DataPlatform/file-imports/${encodeURIComponent(batchNo)}/void`);
+}
+
+export function rollbackFileImport(batchNo: string) {
+	return service.post(`/api/DataPlatform/file-imports/${encodeURIComponent(batchNo)}/rollback`);
+}
+
+export function fetchFileBatch(batchNo: string) {
+	return service.get(`/api/DataPlatform/file-imports/${encodeURIComponent(batchNo)}`);
+}
+
+export function downloadFileErrors(batchNo: string) {
+	return service.get(`/api/DataPlatform/file-imports/${encodeURIComponent(batchNo)}/errors`, { responseType: 'blob' });
+}

+ 11 - 0
Web/src/views/aidop/data-platform/components/MdpFileImportBatchList.vue

@@ -0,0 +1,11 @@
+<template>
+	<el-table :data="rows" size="small" border>
+		<el-table-column prop="batchNo" label="批次" />
+		<el-table-column prop="status" label="状态" width="120" />
+		<el-table-column prop="validRows" label="有效" width="80" />
+		<el-table-column prop="errorRows" label="错误" width="80" />
+	</el-table>
+</template>
+<script setup lang="ts">
+defineProps<{ rows: any[] }>();
+</script>

+ 71 - 0
Web/src/views/aidop/data-platform/components/MdpFileImportDrawer.vue

@@ -0,0 +1,71 @@
+<template>
+	<el-drawer v-model="visible" title="Excel 文件导入" size="520px">
+		<el-form label-width="90px">
+			<el-form-item label="FILE 实体">
+				<el-select v-model="entityCode" filterable style="width: 100%">
+					<el-option v-for="e in entities" :key="e.entityCode || e.EntityCode" :label="(e.entityName || e.EntityName) + ' (' + (e.entityCode || e.EntityCode) + ')'" :value="e.entityCode || e.EntityCode" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="工厂">
+				<el-input-number v-model="factoryId" :min="0" :controls="false" />
+			</el-form-item>
+			<el-form-item>
+				<el-button @click="onTemplate">下载模板</el-button>
+			</el-form-item>
+			<el-form-item label="上传">
+				<el-upload :show-file-list="true" accept=".xlsx" :http-request="onValidate">
+					<el-button>预检</el-button>
+				</el-upload>
+			</el-form-item>
+		</el-form>
+		<p v-if="preview">{{ preview.status }} 有效 {{ preview.validRows }} / 错误 {{ preview.errorRows }}</p>
+		<el-button type="primary" :disabled="preview?.status !== 'READY'" @click="onCommit">确认导入</el-button>
+		<el-button :disabled="!preview?.batchNo" @click="onVoid">作废</el-button>
+	</el-drawer>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import * as api from '../api/fileImports';
+
+const visible = defineModel<boolean>({ default: false });
+const entities = ref<any[]>([]);
+const entityCode = ref('');
+const factoryId = ref(1);
+const preview = ref<any>(null);
+
+onMounted(async () => {
+	const data: any = await api.fetchFileEntities();
+	entities.value = Array.isArray(data) ? data : data?.list ?? [];
+});
+
+async function onTemplate() {
+	if (!entityCode.value) return;
+	const blob: any = await api.downloadFileTemplate(entityCode.value);
+	const url = URL.createObjectURL(blob?.data ?? blob);
+	const a = document.createElement('a');
+	a.href = url;
+	a.download = `${entityCode.value}-template.xlsx`;
+	a.click();
+	URL.revokeObjectURL(url);
+}
+
+async function onValidate(opt: any) {
+	const res: any = await api.validateFileImport(opt.file, entityCode.value, factoryId.value);
+	preview.value = res?.data ?? res;
+	if (preview.value?.status === 'READY') ElMessage.success('预检通过,未写入贴源');
+	else ElMessage.warning('预检失败,不能确认');
+}
+
+async function onCommit() {
+	await api.commitFileImport(preview.value.batchNo);
+	ElMessage.success('导入成功');
+	preview.value = { ...preview.value, status: 'SUCCESS' };
+}
+
+async function onVoid() {
+	await api.voidFileImport(preview.value.batchNo);
+	ElMessage.success('已作废');
+}
+</script>

+ 34 - 2
Web/src/views/aidop/data-platform/sources.vue

@@ -1,6 +1,7 @@
 <template>
 	<AidopDemoShell title="数据源管理" subtitle="mdp_source 真实登记:DB / API 源与健康状态">
 		<template #bar-right>
+			<el-button type="primary" @click="fileOpen = true">Excel 导入</el-button>
 			<el-button icon="ele-Refresh" :loading="loading" @click="loadList">刷新</el-button>
 		</template>
 
@@ -13,6 +14,7 @@
 					<el-select v-model="query.type" placeholder="全部" clearable style="width: 140px">
 						<el-option label="DB" value="DB" />
 						<el-option label="API" value="API" />
+						<el-option label="FILE_EXCEL" value="FILE_EXCEL" />
 					</el-select>
 				</el-form-item>
 				<el-form-item>
@@ -37,6 +39,7 @@
 			<el-table-column prop="lastCheck" label="最近检测" width="170" />
 			<el-table-column prop="healthMsg" label="探活信息" min-width="160" show-overflow-tooltip />
 		</el-table>
+		<MdpFileImportDrawer v-model="fileOpen" />
 	</AidopDemoShell>
 </template>
 
@@ -44,10 +47,13 @@
 import { onMounted, reactive, ref } from 'vue';
 import { ElMessage } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
+import MdpFileImportDrawer from './components/MdpFileImportDrawer.vue';
 import { fetchMdpSources } from './api/syncTasks';
+import { fetchFileEntities } from './api/fileImports';
 
 const loading = ref(false);
 const rows = ref<any[]>([]);
+const fileOpen = ref(false);
 const query = reactive({
 	keyword: '',
 	type: '',
@@ -56,10 +62,36 @@ const query = reactive({
 async function loadList() {
 	loading.value = true;
 	try {
-		rows.value = await fetchMdpSources({
+		const dbApi = await fetchMdpSources({
 			keyword: query.keyword || undefined,
-			type: query.type || undefined,
+			type: query.type === 'FILE_EXCEL' ? undefined : query.type || undefined,
 		});
+		let fileRows: any[] = [];
+		if (!query.type || query.type === 'FILE_EXCEL') {
+			try {
+				const ents: any = await fetchFileEntities();
+				const list = Array.isArray(ents) ? ents : ents?.list ?? [];
+				fileRows = list.map((e: any) => ({
+					sourceCode: e.sourceCode || e.SourceCode,
+					sourceName: e.entityName || e.EntityName,
+					sourceType: 'FILE_EXCEL',
+					direction: '文件入站',
+					dbType: '-',
+					host: '-',
+					health: '可用',
+					healthType: 'success',
+					lastCheck: '-',
+					healthMsg: e.entityCode || e.EntityCode,
+				}));
+			} catch {
+				fileRows = [];
+			}
+		}
+		const merged = query.type === 'FILE_EXCEL' ? fileRows : [...dbApi, ...fileRows];
+		const kw = (query.keyword || '').trim().toLowerCase();
+		rows.value = kw
+			? merged.filter((x) => `${x.sourceCode}${x.sourceName}`.toLowerCase().includes(kw))
+			: merged;
 	} catch (e: unknown) {
 		ElMessage.error((e as Error)?.message || '加载数据源失败');
 	} finally {

+ 185 - 234
Web/src/views/aidop/diagnosis/index.vue

@@ -25,7 +25,19 @@
       </div>
     </header>
 
-    <div class="smart-diagnosis__body">
+    <div v-if="diagnosisLoading" class="smart-diagnosis__loading">正在加载智慧诊断…</div>
+    <el-alert
+      v-else-if="diagnosisError"
+      type="error"
+      show-icon
+      :closable="false"
+      class="smart-diagnosis__error"
+    >
+      <template #title>{{ diagnosisError }}</template>
+      <el-button size="small" type="primary" @click="reloadDiagnosis">重试</el-button>
+    </el-alert>
+
+    <div v-show="!diagnosisLoading && !diagnosisError" class="smart-diagnosis__body">
       <!-- 诊断 + 流程 + 数据同表,列与表头强制对齐 -->
       <div class="smart-diagnosis__column-sync">
         <section class="smart-diagnosis__table-wrap" aria-label="智慧诊断与节点明细">
@@ -66,6 +78,7 @@
                     <span class="smart-diagnosis__callout-arrow" aria-hidden="true" />
                   </div>
                   <el-button
+                    v-if="hasTier1ForStage(s.key)"
                     type="primary"
                     link
                     size="small"
@@ -158,7 +171,7 @@
                 <td v-for="s in stages" :key="'kpi-' + s.key">{{ s.kpi }}</td>
               </tr>
               <tr>
-                <th scope="row">实际达成</th>
+                <th scope="row">实际 / 达成率</th>
                 <td v-for="s in stages" :key="'a-' + s.key" class="smart-diagnosis__td-actual">
                   <span class="smart-diagnosis__actual-line">
                     <strong>{{ s.actualDays }}</strong>
@@ -201,7 +214,7 @@
               class="sd-drill-panel"
               :style="{ gridColumn: `${drillAnchorCol} / span ${drillPanelSpan}` }"
             >
-              <p v-if="!drillTier1Rows.length" class="sd-drill-empty">暂无下一级关键环节</p>
+              <p v-if="!drillTier1Rows.length" class="sd-drill-empty">当前指标暂无下级诊断项。</p>
               <table
                 v-else
                 class="smart-diagnosis__table smart-diagnosis__table--drill smart-diagnosis__table--drill-h"
@@ -245,10 +258,10 @@
                     <th scope="row">操作</th>
                     <td v-for="c in drillTier1Rows" :key="'b1-' + c.key">
                       <el-button
+                        v-if="hasTier2ForRow(c.key)"
                         type="primary"
                         link
                         size="small"
-                        :disabled="!hasTier2ForRow(c.key)"
                         @click="openTier2Drill(c.key)"
                       >
                         下钻详情
@@ -292,7 +305,7 @@
                     class="sd-drill-tier2-panel"
                     :style="{ gridColumn: `${tier2PanelStartCol} / span ${tier2PanelSpan}` }"
                   >
-                    <p v-if="!drillTier2Rows.length" class="sd-drill-empty">暂无再下一级数据</p>
+                    <p v-if="!drillTier2Rows.length" class="sd-drill-empty">当前指标暂无下级诊断项。</p>
                     <table
                       v-else
                       class="smart-diagnosis__table smart-diagnosis__table--drill smart-diagnosis__table--drill-h"
@@ -418,7 +431,7 @@
             </div>
             <div class="sd-problem-matrix">
               <div class="sd-problem-matrix__main">
-                <table class="sd-problem-table">
+                <table v-if="stages.length" class="sd-problem-table">
                   <thead>
                     <tr>
                       <th>关键环节</th>
@@ -453,7 +466,7 @@
                       </td>
                     </tr>
                     <tr>
-                      <th>实际达成</th>
+                      <th>实际 / 达成率</th>
                       <td
                         v-for="stage in stages"
                         :key="`pm-stage-a-${stage.key}`"
@@ -477,7 +490,7 @@
                 </table>
 
                 <p v-if="!focusedTier1Rows.length" class="sd-problem-empty">
-                  当前主要问题指标尚未配置 L3/L4 下钻指标,请先在运营指标主数据中维护下层指标。
+                  当前指标暂无下级诊断项。
                 </p>
 
                 <div
@@ -537,7 +550,7 @@
                 </table>
 
                 <p v-if="focusedTier1Rows.length && !focusedTier2Rows.length" class="sd-problem-empty">
-                  当前 L3 指标尚未配置 L4 责任组织/明细指标。
+                  当前指标暂无下级诊断项。
                 </p>
 
                 <div
@@ -819,8 +832,6 @@ import {
   createImprovementPlanFromDiagnosis,
   fetchImprovementPlanDetail,
   fetchImprovementVerificationDashboard,
-  fetchHomeL1,
-  fetchS8Alerts,
   fetchSmartDiagnosis,
   submitImprovementPlanApproval,
   submitImprovementPlanVerification,
@@ -846,6 +857,9 @@ const diagnosisContext = reactive({
   gap: '-',
   unit: '',
   status: 'green',
+  department: '未配置',
+  selectionMode: '',
+  hasChildren: false,
 })
 
 const improvementSaving = ref(false)
@@ -867,175 +881,15 @@ const verificationForm = reactive({
   verifyRemark: '',
 })
 
-/** 与参考看板一致:五环节 + 部门 / KPI / 实际达成 / 达标灯号 */
-const stages = reactive([
-  {
-    key: 'review',
-    name: '评审/排产/测算',
-    flowCaption: '评审\n排产/测算',
-    dept: '市场部',
-    kpi: '5天',
-    actualDays: '6.2天',
-    actualRate: '58%',
-    status: 'red',
-    callout: '排产与测算节奏偏紧,需跟踪订单评审闭环',
-    calloutTone: 'red',
-    icon: Box,
-    iconTone: 'amber',
-    iconSize: 26
-  },
-  {
-    key: 'design',
-    name: '产品设计',
-    flowCaption: '产品设计',
-    dept: '研发中心',
-    kpi: '3天',
-    actualDays: '2.5天',
-    actualRate: '98%',
-    status: 'green',
-    callout: '设计周期与质量达标,可按计划释放下游',
-    calloutTone: 'green',
-    icon: Cpu,
-    iconTone: 'blue',
-    iconSize: 24,
-    hex: true
-  },
-  {
-    key: 'procure',
-    name: '材料采购',
-    flowCaption: '材料采购',
-    dept: '供应链部',
-    kpi: '14天',
-    actualDays: '15.6天',
-    actualRate: '86%',
-    status: 'yellow',
-    callout: '采购周期略超 KPI,关注缺料与替代方案',
-    calloutTone: 'yellow',
-    icon: Box,
-    iconTone: 'amber',
-    iconSize: 26
-  },
-  {
-    key: 'produce',
-    name: '本体生产',
-    flowCaption: '本体生产',
-    dept: '生产部',
-    kpi: '6天',
-    actualDays: '6.8天',
-    actualRate: '90%',
-    status: 'yellow',
-    callout: '本体产出略慢,在可控范围内加强节拍',
-    calloutTone: 'yellow',
-    icon: Cpu,
-    iconTone: 'amber',
-    iconSize: 24,
-    hex: true
-  },
-  {
-    key: 'ship',
-    name: '总装发货',
-    flowCaption: '总装发货',
-    dept: '生产部',
-    kpi: '3天',
-    actualDays: '2.9天',
-    actualRate: '97%',
-    status: 'green',
-    callout: '发运节拍良好,客户需求可按时满足',
-    calloutTone: 'green',
-    icon: Van,
-    iconTone: 'orange',
-    iconSize: 26
-  }
-])
+const diagnosisLoading = ref(true)
+const diagnosisError = ref('')
+const stages = reactive([])
 
-/** 一级下钻:按主表环节 key */
-const drillTier1ByStage = reactive({
-  review: [
-    { key: 'rev-1', name: '订单受理与建档', plan: '0.5天', actual: '0.6天', status: 'yellow' },
-    { key: 'rev-2', name: '合同与条款评审', plan: '2天', actual: '2.8天', status: 'red' },
-    { key: 'rev-3', name: '排产与 ATP 测算', plan: '2.5天', actual: '2.4天', status: 'green' }
-  ],
-  design: [
-    { key: 'des-1', name: '需求冻结与输入', plan: '0.5天', actual: '0.4天', status: 'green' },
-    { key: 'des-2', name: '方案与图纸设计', plan: '1.5天', actual: '1.6天', status: 'yellow' },
-    { key: 'des-3', name: '样机验证', plan: '1天', actual: '0.5天', status: 'green' }
-  ],
-  procure: [
-    { key: 'prc-1', name: '请购与选型', plan: '3天', actual: '3.2天', status: 'yellow' },
-    { key: 'prc-2', name: '下单与跟单', plan: '6天', actual: '6.5天', status: 'yellow' },
-    { key: 'prc-3', name: '到料检验入库', plan: '5天', actual: '4.9天', status: 'green' }
-  ],
-  produce: [
-    { key: 'prd-1', name: '工单下达与备料', plan: '1天', actual: '1.1天', status: 'yellow' },
-    { key: 'prd-2', name: '本体加工与装配', plan: '4天', actual: '4.2天', status: 'yellow' },
-    { key: 'prd-3', name: '过程检验', plan: '1天', actual: '0.9天', status: 'green' }
-  ],
-  ship: [
-    { key: 'shp-1', name: '总装齐套检查', plan: '0.5天', actual: '0.5天', status: 'green' },
-    { key: 'shp-2', name: '包装发运', plan: '2天', actual: '1.9天', status: 'green' },
-    { key: 'shp-3', name: '物流签收跟踪', plan: '0.5天', actual: '0.5天', status: 'green' }
-  ]
-})
+/** 一级下钻:按主表环节 key;接口返回前保持空,避免演示 L3 闪烁 */
+const drillTier1ByStage = reactive({})
 
-/** 二级下钻:key = `${stageKey}|${tier1RowKey}` */
-const drillTier2ByComposite = reactive({
-  'review|rev-1': [
-    { key: 'r11', name: '客户主数据校验', plan: '2h', actual: '3h', status: 'yellow' },
-    { key: 'r12', name: '信用与额度检查', plan: '2h', actual: '1.5h', status: 'green' }
-  ],
-  'review|rev-2': [
-    { key: 'r21', name: '法务条款审核', plan: '1天', actual: '1.4天', status: 'red' },
-    { key: 'r22', name: '商务价格确认', plan: '1天', actual: '1.4天', status: 'red' }
-  ],
-  'review|rev-3': [
-    { key: 'r31', name: '产能负荷演算', plan: '1天', actual: '0.9天', status: 'green' },
-    { key: 'r32', name: '物料 ATP', plan: '1.5天', actual: '1.5天', status: 'green' }
-  ],
-  'design|des-1': [
-    { key: 'd11', name: '需求评审会签', plan: '4h', actual: '3h', status: 'green' },
-    { key: 'd12', name: '输入基线发布', plan: '4h', actual: '5h', status: 'yellow' }
-  ],
-  'design|des-2': [
-    { key: 'd21', name: '三维建模', plan: '1天', actual: '1.1天', status: 'yellow' },
-    { key: 'd22', name: '工程图下发', plan: '0.5天', actual: '0.5天', status: 'green' }
-  ],
-  'design|des-3': [
-    { key: 'd31', name: '功能测试', plan: '0.5天', actual: '0.3天', status: 'green' }
-  ],
-  'procure|prc-1': [
-    { key: 'p11', name: '供应商比价', plan: '1.5天', actual: '1.6天', status: 'yellow' },
-    { key: 'p12', name: '技术承认', plan: '1.5天', actual: '1.6天', status: 'yellow' }
-  ],
-  'procure|prc-2': [
-    { key: 'p21', name: 'PO 下达', plan: '1天', actual: '1天', status: 'green' },
-    { key: 'p22', name: '交期滚动确认', plan: '5天', actual: '5.5天', status: 'red' }
-  ],
-  'procure|prc-3': [
-    { key: 'p31', name: 'IQC 检验', plan: '2天', actual: '1.8天', status: 'green' },
-    { key: 'p32', name: '入库上架', plan: '3天', actual: '3.1天', status: 'yellow' }
-  ],
-  'produce|prd-1': [
-    { key: 'm11', name: 'BOM 展开', plan: '4h', actual: '5h', status: 'yellow' },
-    { key: 'm12', name: '领料出库', plan: '4h', actual: '3h', status: 'green' }
-  ],
-  'produce|prd-2': [
-    { key: 'm21', name: '机加/钣金', plan: '2天', actual: '2.2天', status: 'yellow' },
-    { key: 'm22', name: '装配线节拍', plan: '2天', actual: '2天', status: 'green' }
-  ],
-  'produce|prd-3': [
-    { key: 'm31', name: '首件与巡检', plan: '0.5天', actual: '0.5天', status: 'green' }
-  ],
-  'ship|shp-1': [
-    { key: 's11', name: '齐套扫描', plan: '4h', actual: '4h', status: 'green' }
-  ],
-  'ship|shp-2': [
-    { key: 's21', name: '打托装车', plan: '1天', actual: '0.9天', status: 'green' },
-    { key: 's22', name: '承运商提货', plan: '1天', actual: '1天', status: 'green' }
-  ],
-  'ship|shp-3': [
-    { key: 's31', name: '在途跟踪', plan: '0.5天', actual: '0.5天', status: 'green' }
-  ]
-})
+/** 二级下钻:key = stageKey|tier1RowKey */
+const drillTier2ByComposite = reactive({})
 
 const drillRootStageKey = ref(null)
 const drillRootColIndex = ref(0)
@@ -1120,21 +974,39 @@ const mainProblem = computed(() => {
     name: '',
     actual: '',
     plan: '',
-    status: 'green'
+    status: 'green',
+    dept: '',
   }
   const accept = (candidate) => {
     const score = candidate.severity * 10 + candidate.level
     if (score > best.score) best = { ...candidate, score }
   }
+  if (!stages.length) {
+    const status = diagnosisContext.status || 'green'
+    const severity = statusSeverity(status)
+    return {
+      score: severity * 10 + 1,
+      severity,
+      level: 1,
+      stageKey: diagnosisContext.metricCode || '',
+      tier1Key: '',
+      tier2Key: '',
+      name: diagnosisContext.metricName || '',
+      actual: diagnosisContext.actual || '-',
+      plan: diagnosisContext.target || '-',
+      status,
+      dept: diagnosisContext.department || '未配置',
+    }
+  }
   for (const s of stages) {
     const severity = statusSeverity(s.status)
-    accept({ severity, level: 1, stageKey: s.key, tier1Key: '', tier2Key: '', name: s.name, actual: s.actualDays, plan: s.kpi, status: s.status })
+    accept({ severity, level: 1, stageKey: s.key, tier1Key: '', tier2Key: '', name: s.name, actual: s.actualDays, plan: s.kpi, status: s.status, dept: s.dept || '' })
     for (const node of drillTier1ByStage[s.key] || []) {
       const nodeSeverity = statusSeverity(node.status)
-      accept({ severity: nodeSeverity, level: 2, stageKey: s.key, tier1Key: node.key, tier2Key: '', name: node.name, actual: node.actual, plan: node.plan, status: node.status })
+      accept({ severity: nodeSeverity, level: 2, stageKey: s.key, tier1Key: node.key, tier2Key: '', name: node.name, actual: node.actual, plan: node.plan, status: node.status, dept: node.dept || s.dept || '' })
       for (const child of problemTier2Rows(s.key, node.key)) {
         const childSeverity = statusSeverity(child.status)
-        accept({ severity: childSeverity, level: 3, stageKey: s.key, tier1Key: node.key, tier2Key: child.key, name: child.name, actual: child.actual, plan: child.plan, status: child.status })
+        accept({ severity: childSeverity, level: 3, stageKey: s.key, tier1Key: node.key, tier2Key: child.key, name: child.name, actual: child.actual, plan: child.plan, status: child.status, dept: child.dept || node.dept || s.dept || '' })
       }
     }
   }
@@ -1143,9 +1015,19 @@ const mainProblem = computed(() => {
 
 const currentOverview = computed(() => {
   const fallback = stages.find((x) => x.key === mainProblem.value.stageKey) || stages[0]
+  const selectionMode = diagnosisContext.selectionMode
+  const metricLabel = diagnosisContext.metricName || fallback?.name || '关键指标'
+  const selectionText = selectionMode === 'global_worst'
+    ? `全局自动选择:${metricLabel}`
+    : selectionMode === 'specified'
+      ? `当前指标:${metricLabel}`
+      : ''
+  const scopeBase = diagnosisContext.moduleCode
+    ? `${diagnosisContext.moduleCode} / ${diagnosisContext.metricName || diagnosisContext.metricCode || '智慧诊断'}`
+    : '协同运营平台 / 智慧诊断'
   return {
-    scope: diagnosisContext.moduleCode ? `${diagnosisContext.moduleCode} / ${diagnosisContext.metricCode || '模块默认诊断'}` : '协同运营平台 / 智慧诊断',
-    name: diagnosisContext.metricName || fallback?.name || '关键指标',
+    scope: selectionText ? `${scopeBase} · ${selectionText}` : scopeBase,
+    name: metricLabel,
     target: diagnosisContext.target || fallback?.kpi || '-',
     actual: diagnosisContext.actual || fallback?.actualDays || '-',
     gap: withDiagnosisUnit(diagnosisContext.gap || pmSummary.leadDelta || '-', diagnosisContext.unit),
@@ -1168,11 +1050,28 @@ const filterSummaryText = computed(() => {
   return entries.length ? entries.join(',') : '全部'
 })
 
+const improvementPlanCard = computed(() => {
+  if (!improvementPlan.value?.id) return { statusText: '未生成', tone: 'gray' }
+  return {
+    statusText: improvementStatusLabel(improvementPlan.value.status),
+    tone: improvementLoopTone(improvementPlan.value.status),
+  }
+})
+
+const verificationPlanCard = computed(() => {
+  const plan = improvementPlan.value
+  if (!plan?.id) return { statusText: '未生成', tone: 'gray' }
+  if (plan.status === 'closed') return { statusText: '已关闭', tone: 'green' }
+  if (plan.verifyResult) return { statusText: '已验证', tone: 'green' }
+  if (plan.status === 'pending_verify') return { statusText: '待验证', tone: 'yellow' }
+  return { statusText: '待执行', tone: 'blue' }
+})
+
 const loopCards = computed(() => [
   { key: 'current', title: '目标&现状', subtitle: '当前差距', statusText: currentOverview.value.gap, tone: currentOverview.value.status },
   { key: 'problem', title: '主要问题', subtitle: '问题定位', statusText: mainProblem.value.name || '自动诊断', tone: mainProblem.value.status },
-  { key: 'plan', title: '改善计划', subtitle: '行动方案', statusText: '待填写', tone: 'blue' },
-  { key: 'verify', title: '有效性验证', subtitle: '结果确认', statusText: '待验证', tone: 'gray' },
+  { key: 'plan', title: '改善计划', subtitle: '行动方案', statusText: improvementPlanCard.value.statusText, tone: improvementPlanCard.value.tone },
+  { key: 'verify', title: '有效性验证', subtitle: '结果确认', statusText: verificationPlanCard.value.statusText, tone: verificationPlanCard.value.tone },
 ])
 
 const verificationTimeline = computed(() => verificationDashboard.value?.timeline || [])
@@ -1197,6 +1096,8 @@ const focusedTier2Rows = computed(() => {
 
 const currentProblemDept = computed(() => {
   const problem = mainProblem.value
+  if (!stages.length) return diagnosisContext.department || '未配置'
+  if (problem.dept) return problem.dept
   if (problem.level >= 3) {
     const node = focusedTier2Rows.value.find((x) => x.key === problem.tier2Key)
     return node?.dept || node?.name || focusedStage.value?.dept || '未配置'
@@ -1205,7 +1106,7 @@ const currentProblemDept = computed(() => {
     const node = focusedTier1Rows.value.find((x) => x.key === problem.tier1Key)
     return node?.dept || focusedStage.value?.dept || '未配置'
   }
-  return focusedStage.value?.dept || '未配置'
+  return focusedStage.value?.dept || diagnosisContext.department || '未配置'
 })
 
 const stageProblemLinkStyle = computed(() => ({
@@ -1289,6 +1190,19 @@ function improvementStatusLabel(status) {
   return map[status] || '未生成'
 }
 
+function improvementLoopTone(status) {
+  const map = {
+    draft: 'gray',
+    approving: 'yellow',
+    approved: 'green',
+    rejected: 'red',
+    executing: 'blue',
+    pending_verify: 'yellow',
+    closed: 'green',
+  }
+  return map[status] || 'gray'
+}
+
 function improvementStatusTag(status) {
   const map = {
     draft: 'info',
@@ -1539,6 +1453,10 @@ function toggleMainDrill(stageKey, colIdx) {
   drillTier2RowKey.value = null
 }
 
+function hasTier1ForStage(stageKey) {
+  return (drillTier1ByStage[stageKey] || []).length > 0
+}
+
 function hasTier2ForRow(tier1RowKey) {
   if (!drillRootStageKey.value) return false
   const k = `${drillRootStageKey.value}|${tier1RowKey}`
@@ -1563,26 +1481,45 @@ function resetObject(target, next) {
 }
 
 function applyDiagnosisPayload(payload) {
-  if (!payload?.stages?.length) return false
+  const rootCode = payload?.root?.metricCode || payload?.metricCode
+  if (!rootCode) return false
+
+  const nextMetric = String(rootCode)
+  if (improvementPlan.value && improvementPlan.value.metricCode && improvementPlan.value.metricCode !== nextMetric) {
+    improvementPlan.value = null
+    verificationDashboard.value = null
+  }
 
-  diagnosisTitle.value = '协同运营平台 · 智慧诊断'
-  diagnosisSubtitle.value = payload.subtitle || `${payload.moduleCode || ''} ${payload.metricName || ''} 指标链路诊断`
+  const metricName = payload.metricName || payload.root?.metricName || ''
+  const selectionMode = payload.selectionMode
+    || (typeof route.query.metricCode === 'string' && route.query.metricCode ? 'specified' : 'global_worst')
+  const selectionLabel = selectionMode === 'global_worst'
+    ? `全局自动选择:${metricName}`
+    : metricName
+      ? `当前指标:${metricName}`
+      : ''
+  diagnosisTitle.value = payload.title || `${payload.moduleCode || ''} 智慧诊断`.trim() || '协同运营平台 · 智慧诊断'
+  diagnosisSubtitle.value = payload.subtitle || selectionLabel || `${payload.moduleCode || ''} ${metricName} 指标链路诊断`
   diagnosisContext.moduleCode = payload.moduleCode || ''
-  diagnosisContext.metricCode = payload.metricCode || payload.root?.metricCode || ''
-  diagnosisContext.metricName = payload.metricName || payload.root?.metricName || ''
+  diagnosisContext.metricCode = nextMetric
+  diagnosisContext.metricName = metricName
   diagnosisContext.target = payload.root?.targetValue != null ? `${payload.root.targetValue}${payload.root.unit || ''}` : '-'
   diagnosisContext.actual = payload.root?.currentValue != null ? `${payload.root.currentValue}${payload.root.unit || ''}` : '-'
   diagnosisContext.gap = payload.root?.gapLabel || '-'
   diagnosisContext.unit = payload.root?.unit || ''
   diagnosisContext.status = payload.root?.status || 'green'
-  pmSummary.node = payload.pmSummary?.node || payload.metricName || '关键指标'
+  diagnosisContext.department = payload.root?.department || '未配置'
+  diagnosisContext.selectionMode = selectionMode
+  diagnosisContext.hasChildren = Boolean(payload.hasChildren || (Array.isArray(payload.stages) && payload.stages.length))
+  pmSummary.node = payload.pmSummary?.node || metricName || '关键指标'
   pmSummary.leadDelta = withDiagnosisUnit(payload.pmSummary?.leadDelta || payload.root?.gapLabel || '0', diagnosisContext.unit)
 
+  const stageList = Array.isArray(payload.stages) ? payload.stages : []
   const icons = [Box, Cpu, Box, Cpu, Van]
   stages.splice(
     0,
     stages.length,
-    ...payload.stages.map((s, index) => ({
+    ...stageList.map((s, index) => ({
       key: s.key,
       name: s.name,
       flowCaption: s.flowCaption || s.name,
@@ -1602,10 +1539,10 @@ function applyDiagnosisPayload(payload) {
 
   const tier1 = {}
   const tier2 = {}
-  for (const stage of payload.stages) {
+  for (const stage of stageList) {
     tier1[stage.key] = stage.tier1 || []
     for (const [key, rows] of Object.entries(stage.tier2 || {})) {
-      tier2[`${stage.key}|${key}`] = rows
+      tier2[`${stage.key}|${key}`] = rows || []
     }
   }
   resetObject(drillTier1ByStage, tier1)
@@ -1616,17 +1553,44 @@ function applyDiagnosisPayload(payload) {
 }
 
 async function loadContextDiagnosis() {
+  diagnosisLoading.value = true
+  diagnosisError.value = ''
   const moduleCode = String(route.query.module || '').toUpperCase()
-  if (!moduleCode) return false
-  const metricCode = typeof route.query.metricCode === 'string' ? route.query.metricCode : undefined
+  if (!moduleCode) {
+    diagnosisLoading.value = false
+    diagnosisError.value = '请从看板进入智慧诊断'
+    return false
+  }
+  const metricCode = typeof route.query.metricCode === 'string' && route.query.metricCode
+    ? route.query.metricCode
+    : undefined
   const extra = Object.fromEntries(
     Object.entries(route.query)
       .filter(([key]) => !['module', 'metricCode'].includes(key))
       .map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
       .filter(([, value]) => value != null && value !== '')
   )
-  const payload = await fetchSmartDiagnosis(moduleCode, metricCode, 1, extra)
-  return applyDiagnosisPayload(payload)
+  try {
+    const payload = await fetchSmartDiagnosis(moduleCode, metricCode, 1, extra)
+    if (!payload) {
+      diagnosisError.value = '智慧诊断加载失败,请重试'
+      return false
+    }
+    if (!applyDiagnosisPayload(payload)) {
+      diagnosisError.value = '未找到可诊断的指标'
+      return false
+    }
+    return true
+  } catch (e) {
+    diagnosisError.value = e?.message || '智慧诊断加载失败,请重试'
+    return false
+  } finally {
+    diagnosisLoading.value = false
+  }
+}
+
+async function reloadDiagnosis() {
+  await loadContextDiagnosis()
 }
 
 watch(
@@ -1640,41 +1604,16 @@ function resizeVerificationChart() {
   verificationChart?.resize()
 }
 
-onMounted(async () => {
-  window.addEventListener('resize', resizeVerificationChart)
-  if (await loadContextDiagnosis()) return
-
-  try {
-    const l1 = await fetchHomeL1(1, 1)
-    const map = Object.fromEntries(l1.map((x) => [x.moduleCode, x]))
-    const bind = [
-      ['review', 'S1'],
-      ['design', 'S2'],
-      ['procure', 'S3'],
-      ['produce', 'S4'],
-      ['ship', 'S7'],
-    ]
-    for (const [stageKey, moduleCode] of bind) {
-      const m = map[moduleCode]
-      const row = stages.find((x) => x.key === stageKey)
-      if (!m || !row) continue
-      row.actualRate = `${m.metricValue}%`
-      row.kpi = `${m.targetValue}${stageKey === 'procure' || stageKey === 'produce' || stageKey === 'ship' ? '天' : '%'}`
-      row.status = m.statusColor === 'green' ? 'green' : m.statusColor === 'yellow' ? 'yellow' : 'red'
-    }
-  } catch {
-    // Keep built-in fallback values when home KPI data is unavailable.
-  }
+watch(
+  () => [String(route.query.module || ''), String(route.query.metricCode || '')],
+  async () => {
+    await loadContextDiagnosis()
+  },
+  { immediate: true }
+)
 
-  try {
-    const alerts = await fetchS8Alerts(1, 1)
-    if (alerts.length > 0) {
-      pmSummary.node = alerts[0].module || pmSummary.node
-      pmSummary.leadDelta = `${alerts.length} 条预警`
-    }
-  } catch {
-    // Keep built-in fallback values when alert data is unavailable.
-  }
+onMounted(() => {
+  window.addEventListener('resize', resizeVerificationChart)
 })
 
 onBeforeUnmount(() => {
@@ -1706,6 +1645,18 @@ function complianceTitle(status) {
   box-sizing: border-box;
 }
 
+.smart-diagnosis__loading,
+.smart-diagnosis__error {
+  margin: 24px 8px 0;
+}
+
+.smart-diagnosis__loading {
+  padding: 48px 16px;
+  text-align: center;
+  color: #93c5fd;
+  font-size: 14px;
+}
+
 .smart-diagnosis__bg {
   position: absolute;
   inset: 0;

+ 208 - 6
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -11,7 +11,16 @@
 				<el-button size="small" type="primary" plain @click="goModeling">配置指标</el-button>
 				<el-button size="small" type="warning" plain @click="goDashboardConfig">详情看板配置</el-button>
 				<el-button size="small" type="success" plain @click="goSmartDiagnosis">智慧诊断</el-button>
+				<el-button size="small" type="warning" plain @click="goGlobalSmartDiagnosis">全局智慧诊断</el-button>
 				<el-button size="small" :loading="loading" @click="refreshDashboard">刷新</el-button>
+				<el-button
+					v-if="moduleConfig.supportsAsyncRebuild"
+					size="small"
+					type="primary"
+					plain
+					:loading="rebuildSubmitting"
+					@click="rebuildDashboard"
+				>重算数据</el-button>
 			</div>
 		</header>
 
@@ -20,13 +29,23 @@
 			<slot name="extra-fields" :query="extraQuery" />
 		</DetailQueryBar>
 
+		<S1RebuildProgressCard
+			v-if="moduleConfig.supportsAsyncRebuildProgress && rebuildJob"
+			:job="rebuildJob"
+			:polling="rebuildPolling"
+			:poll-error="rebuildPollError"
+			:module-code="moduleCode"
+			v-model:collapsed="rebuildProgressCollapsed"
+			@retry="rebuildDashboard"
+			@refresh="reload"
+		/>
 		<el-alert
-			v-if="scopeLabel"
-			:type="scopeAlertType"
+			v-else-if="rebuildStatusText"
+			:type="rebuildAlertType"
 			:closable="false"
 			show-icon
 			class="dynamic-dashboard__scope"
-			:title="scopeLabel"
+			:title="rebuildStatusText"
 		/>
 
 		<el-alert
@@ -303,9 +322,9 @@
 </template>
 
 <script setup lang="ts">
-import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
 import { useRouter } from 'vue-router';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
 import * as echarts from 'echarts';
 import DetailQueryBar from './DetailQueryBar.vue';
 import SmartOpsBaseQueryFields from './SmartOpsBaseQueryFields.vue';
@@ -316,9 +335,11 @@ import { getDimensionCapability, type KpiDimensionCapability } from '../../api/k
 import { FILTER_MODE_LABEL, FILTER_MODE, DIM_TO_CARD_PARAM, businessDimensions, unwrapDim } from './kpiDimensionConsts';
 import {
 	fetchDynamicDashboardPage,
+	fetchModuleRebuildJob,
 	type DynamicDashboardMetric,
 	type DynamicDashboardPagePayload,
 	type DynamicDashboardWidget,
+	type S1RebuildJob,
 } from '../../api/kanbanData';
 import {
 	baseQueryToApiParams,
@@ -328,6 +349,8 @@ import {
 } from '../utils/smartOpsBaseQuery';
 import { AIDOP_LAYOUT_SAVED } from '../utils/s4LayoutEvents';
 import { getModuleDashboardConfig } from '../config/moduleDashboardRegistry';
+import S1RebuildProgressCard from './S1RebuildProgressCard.vue';
+import { clearWatchedRebuildJobId, readWatchedRebuildJobId, writeWatchedRebuildJobId } from '../../api/moduleRebuildWatch';
 
 const props = defineProps<{
 	moduleCode: string;
@@ -338,6 +361,14 @@ const props = defineProps<{
 
 const router = useRouter();
 const loading = ref(false);
+const rebuildSubmitting = ref(false);
+const rebuildJob = ref<S1RebuildJob | null>(null);
+const rebuildPolling = ref(false);
+const rebuildPollError = ref(false);
+const rebuildProgressCollapsed = ref(false);
+let rebuildTimer: number | null = null;
+let rebuildSeenStatus: string | null = null;
+let rebuildReloadedForJobId: number | null = null;
 const page = ref<DynamicDashboardPagePayload | null>(null);
 const baseQuery = ref<SmartOpsBaseQuery>(emptySmartOpsBaseQuery());
 const extraQuery = reactive<Record<string, string>>({ ...(props.extraQueryInit || {}) });
@@ -538,6 +569,25 @@ async function reload() {
 }
 
 async function refreshDashboard() {
+	if (loading.value) return;
+	const quickRefresh = moduleConfig.value.quickRefresh;
+	if (quickRefresh) {
+		loading.value = true;
+		try {
+			const result = await quickRefresh();
+			if (!result.ok) {
+				ElMessage.error(result.message || `${moduleCode.value} 刷新失败`);
+				return;
+			}
+			const extra = result.updatedRows != null ? `,更新 ${result.updatedRows} 条目标快照` : '';
+			const cost = result.elapsedMs != null ? `,耗时 ${result.elapsedMs}ms` : '';
+			ElMessage.success(result.message || `${moduleCode.value} 已刷新${extra}${cost}`);
+		} finally {
+			loading.value = false;
+		}
+		await reload();
+		return;
+	}
 	const refreshMdp = moduleConfig.value.refreshMdp;
 	if (!refreshMdp) {
 		await reload();
@@ -550,7 +600,6 @@ async function refreshDashboard() {
 			ElMessage.error(result.message || `${moduleCode.value} MDP 刷新失败`);
 			return;
 		}
-		// 后端返回聚合文案(成功N/无数据N/失败N/耗时)时优先展示;否则回退默认文案(兼容 S1-S4)。
 		ElMessage.success(result.message || `${moduleCode.value} MDP 刷新完成${result.batchId ? `,批次 ${result.batchId}` : ''}`);
 	} finally {
 		loading.value = false;
@@ -558,6 +607,137 @@ async function refreshDashboard() {
 	await reload();
 }
 
+const rebuildStatusText = computed(() => {
+	const mc = moduleCode.value;
+	const job = rebuildJob.value;
+	if (!job?.status) return '';
+	if (job.status === 'QUEUED') return `${mc} 数据重算排队中`;
+	if (job.status === 'RUNNING') return `${mc} 数据重算进行中${job.durationMs ? `(已运行 ${Math.round(job.durationMs / 1000)} 秒)` : ',预计 10~20 分钟'}`;
+	if (job.status === 'SUCCESS') return `${mc} 数据重算已完成${job.batchId ? `,批次 ${job.batchId}` : ''}${job.durationMs != null ? `,耗时 ${Math.round(job.durationMs / 1000)} 秒` : ''}`;
+	if (job.status === 'FAILED') return `${mc} 数据重算失败:${job.errorMessage || job.message || '未知错误'}`;
+	if (job.status === 'CANCELLED') return `${mc} 数据重算已取消`;
+	return '';
+});
+const rebuildAlertType = computed(() => {
+	const st = rebuildJob.value?.status;
+	if (st === 'SUCCESS') return 'success';
+	if (st === 'FAILED' || st === 'CANCELLED') return 'error';
+	return 'info';
+});
+
+function stopRebuildPoll() {
+	rebuildPolling.value = false;
+	if (rebuildTimer != null) {
+		window.clearTimeout(rebuildTimer);
+		rebuildTimer = null;
+	}
+}
+
+function scheduleRebuildPoll() {
+	stopRebuildPoll();
+	const st = rebuildJob.value?.status;
+	if (st !== 'QUEUED' && st !== 'RUNNING') return;
+	rebuildPolling.value = true;
+	const delay = document.hidden ? 15000 : 5000;
+	rebuildTimer = window.setTimeout(() => pollRebuildJob(), delay);
+}
+
+async function pollRebuildJob() {
+	const id = rebuildJob.value?.jobId;
+	if (!id) return;
+	const next = await fetchModuleRebuildJob(moduleCode.value, id);
+	if (!next) {
+		rebuildPollError.value = true;
+		scheduleRebuildPoll();
+		return;
+	}
+	rebuildPollError.value = false;
+	const prev = rebuildSeenStatus;
+	rebuildJob.value = { ...rebuildJob.value, ...next, jobId: next.jobId ?? id };
+	rebuildSeenStatus = next.status || rebuildSeenStatus;
+	if (next.status === 'SUCCESS' && prev !== 'SUCCESS') {
+		ElMessage.success(`${moduleCode.value} 数据重算完成${next.batchId ? `,批次 ${next.batchId}` : ''}`);
+		if (rebuildReloadedForJobId !== id) {
+			rebuildReloadedForJobId = id;
+			await reload();
+		}
+	} else if (next.status === 'FAILED' && prev !== 'FAILED') {
+		ElMessage.error(next.errorMessage || `${moduleCode.value} 数据重算失败`);
+	}
+	scheduleRebuildPoll();
+}
+
+async function restoreRebuildJob() {
+	if (!moduleConfig.value.supportsAsyncRebuild) return;
+	const watched = readWatchedRebuildJobId(moduleCode.value, 1);
+	if (!watched) return;
+	const latest = await fetchModuleRebuildJob(moduleCode.value, watched, 1);
+	if (!latest?.jobId) {
+		clearWatchedRebuildJobId(moduleCode.value, 1);
+		return;
+	}
+	rebuildJob.value = latest;
+	rebuildSeenStatus = latest.status || null;
+	if (latest.status === 'SUCCESS' && rebuildReloadedForJobId !== latest.jobId) {
+		rebuildReloadedForJobId = latest.jobId;
+		await reload();
+	}
+	scheduleRebuildPoll();
+}
+
+function attachRebuildJob(job: S1RebuildJob) {
+	rebuildJob.value = job;
+	rebuildSeenStatus = job.status || null;
+	if (job.jobId) writeWatchedRebuildJobId(moduleCode.value, job.jobId, 1);
+	scheduleRebuildPoll();
+}
+
+async function rebuildDashboard() {
+	if (!moduleConfig.value.rebuild || rebuildSubmitting.value) return;
+	rebuildProgressCollapsed.value = false;
+	const mc = moduleCode.value;
+	const st = rebuildJob.value?.status;
+	if (st === 'QUEUED' || st === 'RUNNING') {
+		ElMessage.warning(rebuildJob.value?.message || `${mc} 数据重算正在执行,请勿重复提交`);
+		return;
+	}
+	const stages = moduleConfig.value.rebuildStageLabels?.length
+		? moduleConfig.value.rebuildStageLabels
+		: ['重新拉取源数据', '标准化数据', '生成 DWD 明细', '重算 KPI', '重建原子数据'];
+	try {
+		await ElMessageBox.confirm(
+			h('div', { style: 'line-height:1.7;white-space:pre-wrap;' }, [
+				h('p', '本操作将依次执行:'),
+				...stages.map((label, i) => h('p', `${i + 1}. ${label}`)),
+				h('p', { style: 'margin-top:8px;' }, `作用范围:仅重算当前租户、当前工厂的 ${mc} 数据。`),
+				h('p', '预计总耗时:10~20 分钟。'),
+				h('p', '任务将在后台执行,期间可以离开本页面。'),
+				h('p', `同一租户、同一工厂同时只能运行一个 ${mc} 全量重算任务,请勿重复提交。`),
+			]),
+			`确认重算 ${mc} 数据?`,
+			{ type: 'warning', confirmButtonText: '确认开始', cancelButtonText: '取消', customClass: 's1-rebuild-confirm' }
+		);
+	} catch {
+		return;
+	}
+	rebuildSubmitting.value = true;
+	try {
+		const accepted = await moduleConfig.value.rebuild();
+		if (accepted.jobId) {
+			attachRebuildJob({ jobId: accepted.jobId, status: accepted.status || 'QUEUED', message: accepted.message });
+			if (accepted.ok === false) {
+				ElMessage.warning(accepted.message || '已有重算任务在执行');
+			} else {
+				ElMessage.success(accepted.message || `${mc} 数据重算已排队`);
+			}
+		} else {
+			ElMessage.error(accepted.message || '提交失败');
+		}
+	} finally {
+		rebuildSubmitting.value = false;
+	}
+}
+
 async function onQuery() {
 	await reload();
 	const summary = summarizeSmartOpsBaseQuery(baseQuery.value);
@@ -585,6 +765,18 @@ function goDashboardConfig() {
 }
 
 function goSmartDiagnosis() {
+	const metricCode = activeTab.value || page.value?.tabs?.[0]?.metricCode || '';
+	router.push({
+		path: '/aidop/smart-diagnosis',
+		query: {
+			module: moduleCode.value,
+			...(metricCode ? { metricCode } : {}),
+			...buildExtraParams(),
+		},
+	});
+}
+
+function goGlobalSmartDiagnosis() {
 	router.push({
 		path: '/aidop/smart-diagnosis',
 		query: {
@@ -893,13 +1085,23 @@ function onLayoutSaved(event: Event) {
 
 onMounted(() => {
 	reload();
+	restoreRebuildJob();
 	window.addEventListener('resize', onResize);
 	window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved as EventListener);
 });
 
+watch(moduleCode, () => {
+	stopRebuildPoll();
+	rebuildJob.value = null;
+	rebuildSeenStatus = null;
+	rebuildReloadedForJobId = null;
+	restoreRebuildJob();
+});
+
 onUnmounted(() => {
 	window.removeEventListener('resize', onResize);
 	window.removeEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved as EventListener);
+	stopRebuildPoll();
 	disposeCharts();
 });
 

+ 144 - 0
Web/src/views/aidop/kanban/components/KpiTargetManager.vue

@@ -0,0 +1,144 @@
+<template>
+	<div class="kpi-target" v-if="metricCode">
+		<div class="kpi-target__head">
+			<h4>目标管理</h4>
+			<div class="kpi-target__actions">
+				<el-input-number v-model="factoryId" :min="0" :controls="false" style="width: 120px" @change="reload" />
+				<span class="kpi-target__hint">工厂(0=租户默认)</span>
+				<el-button size="small" @click="onDownloadTemplate">模板</el-button>
+				<el-upload :show-file-list="false" accept=".xlsx" :http-request="onValidate">
+					<el-button size="small">预检导入</el-button>
+				</el-upload>
+				<el-button size="small" type="primary" :disabled="preview?.status !== 'READY'" @click="onCommit">确认导入</el-button>
+			</div>
+		</div>
+		<el-alert
+			v-if="current"
+			:title="current.targetValue == null ? '未配置目标' : `当前目标 ${current.targetValue}(${current.targetSource})`"
+			:type="current.targetValue == null ? 'warning' : 'success'"
+			:closable="false"
+			class="mb8"
+		/>
+		<el-form :inline="true" class="kpi-target__form">
+			<el-form-item label="目标值">
+				<el-input-number v-model="form.targetValue" :min="0.000001" :controls="false" />
+			</el-form-item>
+			<el-form-item label="生效日">
+				<el-date-picker v-model="form.effectiveFrom" value-format="YYYY-MM-DD" />
+			</el-form-item>
+			<el-form-item label="失效日">
+				<el-date-picker v-model="form.effectiveTo" value-format="YYYY-MM-DD" />
+			</el-form-item>
+			<el-form-item>
+				<el-checkbox v-model="form.closePrevious">关闭上一开放区间</el-checkbox>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" :loading="saving" @click="onCreate">新增目标</el-button>
+			</el-form-item>
+		</el-form>
+		<p v-if="preview" class="kpi-target__preview">预检 {{ preview.status }}:有效 {{ preview.validRows }} / 错误 {{ preview.errorRows }}</p>
+		<el-table :data="history" size="small" border>
+			<el-table-column prop="effectiveFrom" label="生效" width="120" />
+			<el-table-column prop="effectiveTo" label="失效" width="120" />
+			<el-table-column prop="targetValue" label="目标" width="100" />
+			<el-table-column prop="sourceType" label="来源" width="140" />
+			<el-table-column prop="status" label="状态" width="80" />
+			<el-table-column label="操作" width="90">
+				<template #default="{ row }">
+					<el-button link type="danger" @click="onDisable(row.id)">停用</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+import * as api from '/@/views/aidop/api/kpiTargetApi';
+
+const props = defineProps<{ metricCode?: string; moduleCode?: string }>();
+const factoryId = ref(1);
+const current = ref<any>(null);
+const history = ref<any[]>([]);
+const saving = ref(false);
+const preview = ref<any>(null);
+const form = reactive({
+	targetValue: 1,
+	effectiveFrom: new Date().toISOString().slice(0, 10),
+	effectiveTo: '' as string | null,
+	closePrevious: true,
+});
+
+function unwrap(res: any) {
+	return res?.data ?? res;
+}
+
+async function reload() {
+	if (!props.metricCode) return;
+	current.value = unwrap(await api.currentTarget(props.metricCode, factoryId.value));
+	history.value = unwrap(await api.listTargets(props.metricCode, factoryId.value)) || [];
+}
+
+async function onCreate() {
+	if (!props.metricCode) return;
+	saving.value = true;
+	try {
+		await api.createTarget({
+			metricCode: props.metricCode,
+			factoryId: factoryId.value,
+			targetValue: form.targetValue,
+			effectiveFrom: form.effectiveFrom,
+			effectiveTo: form.effectiveTo || null,
+			closePrevious: form.closePrevious,
+		});
+		ElMessage.success('已新增目标');
+		await reload();
+	} catch (e: any) {
+		ElMessage.error(e?.message || e?.response?.data?.message || '保存失败');
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function onDisable(id: number) {
+	await api.disableTarget(id);
+	await reload();
+}
+
+async function onDownloadTemplate() {
+	const blob: any = await api.downloadTargetTemplate();
+	const url = URL.createObjectURL(blob?.data ?? blob);
+	const a = document.createElement('a');
+	a.href = url;
+	a.download = 'kpi-target-template.xlsx';
+	a.click();
+	URL.revokeObjectURL(url);
+}
+
+async function onValidate(opt: any) {
+	const file = opt.file as File;
+	preview.value = unwrap(await api.validateTargetImport(file, factoryId.value));
+	if (preview.value?.status === 'READY') ElMessage.success('预检通过');
+	else ElMessage.warning('预检有错误,不能确认');
+}
+
+async function onCommit() {
+	if (!preview.value?.batchId) return;
+	await api.commitTargetImport(preview.value.batchId);
+	ElMessage.success('导入成功');
+	preview.value = { ...preview.value, status: 'COMMITTED' };
+	await reload();
+}
+
+watch(() => props.metricCode, () => { preview.value = null; reload(); }, { immediate: true });
+</script>
+
+<style scoped>
+.kpi-target { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--el-border-color-lighter); }
+.kpi-target__head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 8px; }
+.kpi-target__actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.kpi-target__hint { color: var(--el-text-color-secondary); font-size: 12px; }
+.mb8 { margin-bottom: 8px; }
+.kpi-target__preview { font-size: 12px; color: var(--el-text-color-secondary); }
+</style>

+ 260 - 0
Web/src/views/aidop/kanban/components/S1RebuildProgressCard.vue

@@ -0,0 +1,260 @@
+<template>
+	<article v-if="job" class="s1-rebuild-card" :class="[`is-${statusClass}`, { 'is-collapsed': collapsed }]">
+		<header class="s1-rebuild-card__head" role="button" tabindex="0" @click="toggleCollapsed" @keydown.enter="toggleCollapsed">
+			<div>
+				<h3>{{ moduleCode }} 数据重算</h3>
+				<p v-if="!collapsed">{{ job.progressMessage || stageLabel }}</p>
+				<p v-else>{{ stageLabel }} · {{ displayPercent }}% · 已耗时 {{ elapsedText }}</p>
+			</div>
+			<div class="s1-rebuild-card__head-actions">
+				<el-tag :type="tagType" effect="dark">{{ statusLabel }}</el-tag>
+				<el-button text size="small" class="s1-rebuild-card__toggle" @click.stop="toggleCollapsed">
+					{{ collapsed ? '展开' : '收起' }}
+				</el-button>
+			</div>
+		</header>
+
+		<div v-show="!collapsed" class="s1-rebuild-card__body">
+			<div class="s1-rebuild-card__meta">
+				<span v-if="job.status === 'QUEUED' || job.status === 'RUNNING'">阶段 {{ job.stageIndex || 0 }} / {{ job.stageTotal || 5 }} · {{ stageLabel }}</span>
+				<span v-else>{{ stageLabel }}</span>
+				<span>jobId {{ job.jobId }}</span>
+			</div>
+
+			<el-progress
+				:percentage="displayPercent"
+				:status="progressStatus"
+				:striped="job.status === 'RUNNING'"
+				:striped-flow="job.status === 'RUNNING'"
+				:stroke-width="12"
+			/>
+			<div class="s1-rebuild-card__hint">预计进度(按处理阶段估算,不是精确完成比例)</div>
+
+			<div class="s1-rebuild-card__times">
+				<span>已耗时 {{ elapsedText }}</span>
+				<span>{{ remainingHint }}</span>
+			</div>
+
+			<p v-if="pollError" class="s1-rebuild-card__warn">暂时无法获取最新进度,后台任务可能仍在运行</p>
+			<p class="s1-rebuild-card__bg">任务在后台执行,可以离开本页面</p>
+
+			<div v-if="job.batchId" class="s1-rebuild-card__batch">批次 {{ job.batchId }}</div>
+			<div v-if="showRows" class="s1-rebuild-card__rows">
+				拉取 {{ job.stageRows ?? 0 }} · 标准层 {{ job.standardRows ?? 0 }} · DWD {{ job.dwdRows ?? 0 }} · KPI {{ job.kpiRows ?? 0 }} · 原子层 {{ job.atomicRows ?? 0 }}
+			</div>
+			<p v-if="job.status === 'SUCCESS'">看板已自动刷新 · 完成时间 {{ formatTime(job.finishedAt) }}</p>
+			<p v-if="job.status === 'FAILED'" class="s1-rebuild-card__error">
+				失败阶段:{{ failedStageLabel }}。{{ job.errorMessage || '未知错误' }}
+			</p>
+			<p v-if="job.status === 'CANCELLED'">{{ job.progressMessage || job.errorMessage || '任务已取消' }}</p>
+
+			<footer v-if="job.status === 'FAILED' || job.status === 'CANCELLED' || job.status === 'SUCCESS'" class="s1-rebuild-card__actions">
+				<el-button v-if="job.status !== 'SUCCESS'" size="small" type="primary" @click="$emit('retry')">重新重算</el-button>
+				<el-button size="small" @click="$emit('refresh')">刷新看板</el-button>
+			</footer>
+		</div>
+	</article>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, onUnmounted, ref } from 'vue';
+import type { S1RebuildJob } from '../../api/kanbanData';
+
+const props = defineProps<{
+	job: S1RebuildJob | null;
+	polling?: boolean;
+	pollError?: boolean;
+	collapsed?: boolean;
+	moduleCode?: string;
+}>();
+
+const emit = defineEmits<{ retry: []; refresh: []; 'update:collapsed': [value: boolean] }>();
+
+function toggleCollapsed() {
+	emit('update:collapsed', !props.collapsed);
+}
+
+const now = ref(Date.now());
+let tick: number | null = null;
+onMounted(() => {
+	tick = window.setInterval(() => {
+		now.value = Date.now();
+	}, 1000);
+});
+onUnmounted(() => {
+	if (tick != null) window.clearInterval(tick);
+});
+
+const moduleCode = computed(() => String(props.moduleCode || 'S1').toUpperCase());
+
+const STAGE_LABEL: Record<string, string> = {
+	QUEUED: '排队中',
+	ACQUIRING_LOCK: '等待资源',
+	PREPARING: '准备运行环境',
+	STAGING: '拉取源数据',
+	STANDARD: '标准化数据',
+	DWD: '生成 DWD 明细',
+	KPI: '重算 KPI',
+	ATOMIC: '重建原子数据',
+	T8_INBOUND: 'T8 基表入站',
+	KPI_PREPARING: '准备 KPI 计算',
+	KPI_CALCULATING: '计算 KPI',
+	FINALIZING: '收尾',
+	SUCCESS: '已完成',
+	FAILED: '失败',
+	CANCELLED: '已取消',
+};
+
+const stageLabel = computed(() => STAGE_LABEL[props.job?.currentStage || props.job?.status || ''] || '处理中');
+const failedStageLabel = computed(() => STAGE_LABEL[props.job?.failedStage || ''] || props.job?.failedStage || '未知阶段');
+
+const statusLabel = computed(() => {
+	const st = props.job?.status;
+	if (st === 'QUEUED') return '排队中';
+	if (st === 'RUNNING') return '运行中';
+	if (st === 'SUCCESS') return '已完成';
+	if (st === 'FAILED') return '失败';
+	if (st === 'CANCELLED') return '已取消';
+	return st || '';
+});
+
+const statusClass = computed(() => (props.job?.status || 'info').toLowerCase());
+const tagType = computed(() => {
+	const st = props.job?.status;
+	if (st === 'SUCCESS') return 'success';
+	if (st === 'FAILED') return 'danger';
+	if (st === 'CANCELLED') return 'warning';
+	return 'info';
+});
+
+const displayPercent = computed(() => {
+	const st = props.job?.status;
+	if (st === 'SUCCESS') return 100;
+	if (st === 'QUEUED') return 0;
+	const p = props.job?.progressPercent ?? 0;
+	return Math.max(0, Math.min(99, p));
+});
+
+const progressStatus = computed(() => {
+	const st = props.job?.status;
+	if (st === 'SUCCESS') return 'success';
+	if (st === 'FAILED') return 'exception';
+	if (st === 'CANCELLED') return 'warning';
+	return undefined;
+});
+
+function formatDuration(ms: number) {
+	const total = Math.max(0, Math.floor(ms / 1000));
+	const m = Math.floor(total / 60);
+	const s = total % 60;
+	return `${m}分${String(s).padStart(2, '0')}秒`;
+}
+
+const elapsedText = computed(() => {
+	const job = props.job;
+	if (!job) return '—';
+	if (job.status === 'RUNNING' && job.startedAt) {
+		const start = Date.parse(String(job.startedAt).replace(' ', 'T'));
+		if (!Number.isNaN(start)) return formatDuration(now.value - start);
+	}
+	if (job.durationMs != null) return formatDuration(job.durationMs);
+	return '—';
+});
+
+const remainingHint = computed(() => {
+	const st = props.job?.status;
+	const stage = props.job?.currentStage;
+	if (st === 'SUCCESS' || st === 'FAILED' || st === 'CANCELLED') return '';
+	if (props.job?.startedAt) {
+		const start = Date.parse(String(props.job.startedAt).replace(' ', 'T'));
+		if (!Number.isNaN(start) && now.value - start > 20 * 60 * 1000) return '已超过常规耗时,任务仍在后台执行';
+	}
+	if (st === 'QUEUED' || stage === 'ACQUIRING_LOCK') return '等待当前资源释放';
+	if (stage === 'STAGING') return '通常还需约 8~18 分钟';
+	if (stage === 'STANDARD') return '通常还需约 5~12 分钟';
+	if (stage === 'DWD') return '通常还需约 3~8 分钟';
+	if (stage === 'KPI') return '通常还需约 2~6 分钟';
+	if (stage === 'ATOMIC') return '通常还需约 1~5 分钟';
+	if (stage === 'T8_INBOUND') return '通常还需约 4~12 分钟';
+	if (stage === 'KPI_PREPARING') return '通常还需约 2~8 分钟';
+	if (stage === 'KPI_CALCULATING') return '通常还需约 2~8 分钟';
+	if (stage === 'FINALIZING') return '即将完成';
+	return '预计总耗时 10~20 分钟';
+});
+
+const showRows = computed(() => {
+	const j = props.job;
+	if (!j) return false;
+	return (j.stageRows || j.standardRows || j.dwdRows || j.kpiRows || j.atomicRows) != null && j.status !== 'QUEUED';
+});
+
+function formatTime(v?: string) {
+	if (!v) return '';
+	return String(v).replace('T', ' ').slice(0, 19);
+}
+</script>
+
+<style scoped>
+.s1-rebuild-card {
+	margin: 0 0 12px;
+	padding: 14px 16px;
+	border: 1px solid #1e293b;
+	border-radius: 12px;
+	background: rgba(15, 23, 42, 0.9);
+	color: #e2e8f0;
+}
+.s1-rebuild-card__head {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	gap: 12px;
+	cursor: pointer;
+}
+.s1-rebuild-card__head-actions {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	flex-shrink: 0;
+}
+.s1-rebuild-card__toggle {
+	color: #94a3b8;
+}
+.s1-rebuild-card.is-collapsed {
+	padding: 8px 16px;
+}
+.s1-rebuild-card h3 {
+	margin: 0;
+	font-size: 16px;
+}
+.s1-rebuild-card p {
+	margin: 4px 0 0;
+	color: #94a3b8;
+	font-size: 13px;
+}
+.s1-rebuild-card__meta,
+.s1-rebuild-card__times {
+	display: flex;
+	justify-content: space-between;
+	gap: 12px;
+	margin: 10px 0 8px;
+	font-size: 12px;
+	color: #94a3b8;
+}
+.s1-rebuild-card__hint,
+.s1-rebuild-card__bg,
+.s1-rebuild-card__batch,
+.s1-rebuild-card__rows {
+	font-size: 12px;
+	color: #64748b;
+	margin-top: 4px;
+}
+.s1-rebuild-card__warn,
+.s1-rebuild-card__error {
+	color: #fca5a5 !important;
+}
+.s1-rebuild-card__actions {
+	margin-top: 10px;
+	display: flex;
+	gap: 8px;
+}
+</style>

+ 38 - 0
Web/src/views/aidop/kanban/config/moduleDashboardRegistry.spec.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { getModuleDashboardConfig, moduleDashboardRegistry } from './moduleDashboardRegistry';
+
+describe('moduleDashboardRegistry rebuild/refresh split', () => {
+	it('S1-S7 expose quick refresh and async rebuild', () => {
+		for (const code of ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']) {
+			const cfg = moduleDashboardRegistry[code];
+			expect(cfg.quickRefresh, `${code} quickRefresh`).toBeTypeOf('function');
+			expect(cfg.rebuild, `${code} rebuild`).toBeTypeOf('function');
+			expect(cfg.supportsAsyncRebuild).toBe(true);
+			expect(cfg.supportsAsyncRebuildProgress).toBe(true);
+			expect(cfg.refreshMdp).toBeUndefined();
+			expect(cfg.rebuildStageLabels?.length).toBeGreaterThan(0);
+		}
+	});
+
+	it('S9 only has lightweight refresh', () => {
+		const cfg = moduleDashboardRegistry.S9;
+		expect(cfg.quickRefresh).toBeTypeOf('function');
+		expect(cfg.rebuild).toBeUndefined();
+		expect(cfg.supportsAsyncRebuild).toBeFalsy();
+		expect(cfg.supportsAsyncRebuildProgress).toBeFalsy();
+		expect(cfg.refreshMdp).toBeUndefined();
+	});
+
+	it('S8 is not a dynamic rebuild dashboard', () => {
+		const cfg = getModuleDashboardConfig('S8');
+		expect(cfg.rebuild).toBeUndefined();
+		expect(cfg.quickRefresh).toBeUndefined();
+		expect(cfg.supportsAsyncRebuild).toBeFalsy();
+	});
+
+	it('S4 has no atomic stage; S5-S7 use T8 stages', () => {
+		expect(moduleDashboardRegistry.S4.rebuildStageLabels).not.toContain('重建原子数据');
+		expect(moduleDashboardRegistry.S2.rebuildStageLabels).toContain('重建原子数据');
+		expect(moduleDashboardRegistry.S5.rebuildStageLabels?.[0]).toBe('T8 基表入站');
+	});
+});

+ 56 - 31
Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts

@@ -1,4 +1,4 @@
-import { refreshModuleMdp } from '../../api/kanbanData';
+import { refreshS1TargetSnapshot, createS1RebuildJob, refreshModuleTargetSnapshot, createModuleRebuildJob } from '../../api/kanbanData';
 
 export interface ModuleMdpRefreshResult {
 	ok: boolean;
@@ -10,6 +10,20 @@ export interface ModuleMdpRefreshResult {
 	message?: string;
 }
 
+export interface ModuleRefreshResult {
+	ok: boolean;
+	message?: string;
+	updatedRows?: number;
+	elapsedMs?: number;
+}
+
+export interface RebuildAcceptedResult {
+	ok: boolean;
+	jobId?: number;
+	status?: string;
+	message?: string;
+}
+
 export interface ModuleDashboardConfig {
 	moduleCode: string;
 	title: string;
@@ -18,6 +32,11 @@ export interface ModuleDashboardConfig {
 	chatTitle?: string;
 	chatSuggestions?: string[];
 	refreshMdp?: () => Promise<ModuleMdpRefreshResult>;
+	quickRefresh?: () => Promise<ModuleRefreshResult>;
+	rebuild?: () => Promise<RebuildAcceptedResult>;
+	supportsAsyncRebuild?: boolean;
+	supportsAsyncRebuildProgress?: boolean;
+	rebuildStageLabels?: string[];
 }
 
 const commonSuggestions = (moduleName: string) => [
@@ -26,6 +45,26 @@ const commonSuggestions = (moduleName: string) => [
 	`下一步应该创建什么改善计划?`,
 ];
 
+const etlStages = ['重新拉取源数据', '标准化数据', '生成 DWD 明细', '重算 KPI', '重建原子数据'];
+const etlStagesNoAtomic = ['重新拉取源数据', '标准化数据', '生成 DWD 明细', '重算 KPI'];
+const t8Stages = ['T8 基表入站', '准备 KPI 计算', '计算 KPI', '收尾'];
+
+function asyncModule(code: string, title: string, subtitle: string, stages: string[]): ModuleDashboardConfig {
+	return {
+		moduleCode: code,
+		title,
+		subtitle,
+		enableChatBI: true,
+		chatTitle: `${title}智能问答`,
+		chatSuggestions: commonSuggestions(title.replace('看板', '')),
+		quickRefresh: () => refreshModuleTargetSnapshot(code, 1),
+		rebuild: () => createModuleRebuildJob(code, 1),
+		supportsAsyncRebuild: true,
+		supportsAsyncRebuildProgress: true,
+		rebuildStageLabels: stages,
+	};
+}
+
 export const moduleDashboardRegistry: Record<string, ModuleDashboardConfig> = {
 	S1: {
 		moduleCode: 'S1',
@@ -34,39 +73,25 @@ export const moduleDashboardRegistry: Record<string, ModuleDashboardConfig> = {
 		enableChatBI: true,
 		chatTitle: 'S1 产销协同智能问答',
 		chatSuggestions: ['S1 当前最严重的指标是什么?', '订单评审周期为什么红了?', '下一步应该创建什么改善计划?'],
-		refreshMdp: () => refreshModuleMdp('S1'),
-	},
-	S2: {
-		moduleCode: 'S2',
-		title: 'S2 制造协同看板',
-		subtitle: '订单排程、工序排程与资源协同动态看板',
-		enableChatBI: true,
-		chatTitle: 'S2 制造协同智能问答',
-		chatSuggestions: commonSuggestions('S2 制造协同'),
-		refreshMdp: () => refreshModuleMdp('S2'),
-	},
-	S3: {
-		moduleCode: 'S3',
-		title: 'S3 供应协同看板',
-		subtitle: '物料需求、供应交付与齐套风险动态看板',
-		enableChatBI: true,
-		chatTitle: 'S3 供应协同智能问答',
-		chatSuggestions: commonSuggestions('S3 供应协同'),
-		refreshMdp: () => refreshModuleMdp('S3'),
+		quickRefresh: () => refreshS1TargetSnapshot(1),
+		rebuild: () => createS1RebuildJob(1),
+		supportsAsyncRebuild: true,
+		supportsAsyncRebuildProgress: true,
+		rebuildStageLabels: etlStages,
 	},
-	S4: {
-		moduleCode: 'S4',
-		title: 'S4 采购执行看板',
-		subtitle: '采购交付、供应商执行与异常风险动态看板',
+	S2: asyncModule('S2', 'S2 制造协同看板', '订单排程、工序排程与资源协同动态看板', etlStages),
+	S3: asyncModule('S3', 'S3 供应协同看板', '物料需求、供应交付与齐套风险动态看板', etlStages),
+	S4: asyncModule('S4', 'S4 采购执行看板', '采购交付、供应商执行与异常风险动态看板', etlStagesNoAtomic),
+	S5: asyncModule('S5', 'S5 物料仓储看板', '物料仓储动态看板', t8Stages),
+	S6: asyncModule('S6', 'S6 生产执行看板', '生产执行动态看板', t8Stages),
+	S7: asyncModule('S7', 'S7 成品仓储看板', '成品仓储动态看板', t8Stages),
+	S9: {
+		moduleCode: 'S9',
+		title: 'S9 运营指标看板',
 		enableChatBI: true,
-		chatTitle: 'S4 采购执行智能问答',
-		chatSuggestions: commonSuggestions('S4 采购执行'),
-		refreshMdp: () => refreshModuleMdp('S4'),
+		chatSuggestions: commonSuggestions('S9 运营指标'),
+		quickRefresh: () => refreshModuleTargetSnapshot('S9', 1),
 	},
-	S5: { moduleCode: 'S5', title: 'S5 物料仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S5 物料仓储'), refreshMdp: () => refreshModuleMdp('S5') },
-	S6: { moduleCode: 'S6', title: 'S6 生产执行看板', enableChatBI: true, chatSuggestions: commonSuggestions('S6 生产执行'), refreshMdp: () => refreshModuleMdp('S6') },
-	S7: { moduleCode: 'S7', title: 'S7 成品仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S7 成品仓储'), refreshMdp: () => refreshModuleMdp('S7') },
-	S9: { moduleCode: 'S9', title: 'S9 运营指标看板', enableChatBI: true, chatSuggestions: commonSuggestions('S9 运营指标') },
 };
 
 export function getModuleDashboardConfig(moduleCode: string): ModuleDashboardConfig {

+ 3 - 0
Web/src/views/aidop/kanban/kpiMaster.vue

@@ -195,6 +195,7 @@
 							<el-input v-model="editForm.remark" type="textarea" :rows="2" />
 						</el-form-item>
 					</el-form>
+					<KpiTargetManager :metric-code="currentDetail.metricCode" :module-code="currentDetail.moduleCode" />
 				</template>
 				<template v-else>
 					<div class="kpi-detail__empty">
@@ -216,6 +217,7 @@
 				<div class="import-tab">
 					<div class="import-tab__header">
 						<h3>Demo 数据导入</h3>
+						<p>本功能仅用于演示数据初始化,不用于正式目标维护。正式目标请在「指标管理 → 目标管理」维护或导入。</p>
 						<p>上传 Excel 模板,系统将根据填入的目标值 / 实际值生成 30 天日值数据并写入数据库。</p>
 					</div>
 
@@ -384,6 +386,7 @@ import SqlEditor from './components/SqlEditor.vue';
 import KpiBusinessInput from './components/KpiBusinessInput.vue';
 import KpiRunLog from './components/KpiRunLog.vue';
 import KpiDimensionConfigPanel from './components/KpiDimensionConfigPanel.vue';
+import KpiTargetManager from './components/KpiTargetManager.vue';
 import * as cfgApi from '/@/views/aidop/api/kpiSqlConfigApi';
 
 const router = useRouter();

+ 64 - 0
Web/tests/e2e/s2-s7-rebuild-uat.spec.ts

@@ -0,0 +1,64 @@
+import { test, expect } from './fixtures/auth';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SHOT = path.resolve(
+  __dirname,
+  '../../../doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots',
+);
+
+const modules = [
+  { code: 'S1', path: '/#/aidop/smart-ops/s1', rebuild: true },
+  { code: 'S2', path: '/#/aidop/smart-ops/s2', rebuild: true },
+  { code: 'S3', path: '/#/aidop/smart-ops/s3', rebuild: true },
+  { code: 'S4', path: '/#/aidop/smart-ops/s4', rebuild: true },
+  { code: 'S5', path: '/#/aidop/smart-ops/s5', rebuild: true },
+  { code: 'S6', path: '/#/aidop/smart-ops/s6', rebuild: true },
+  { code: 'S7', path: '/#/aidop/smart-ops/s7', rebuild: true },
+  { code: 'S9', path: '/#/aidop/smart-ops/s9', rebuild: false },
+];
+
+test('S1-S7 have rebuild; S9 does not; capture dashboards', async ({ authedPage }) => {
+  test.setTimeout(180_000);
+  for (const m of modules) {
+    await authedPage.goto(m.path, { waitUntil: 'domcontentloaded' });
+    await authedPage.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
+    await authedPage.waitForTimeout(1500);
+    const refresh = authedPage.getByRole('button', { name: '刷新' });
+    await expect(refresh).toBeVisible({ timeout: 20_000 });
+    const rebuild = authedPage.getByRole('button', { name: '重算数据' });
+    if (m.rebuild) await expect(rebuild).toBeVisible();
+    else await expect(rebuild).toHaveCount(0);
+    await authedPage.screenshot({
+      path: path.join(SHOT, `${m.code}-dashboard.png`),
+      fullPage: true,
+    });
+  }
+});
+
+test('S2 refresh stays lightweight and S9 refresh creates no rebuild card', async ({ authedPage }) => {
+  test.setTimeout(90_000);
+  await authedPage.goto('/#/aidop/smart-ops/s2', { waitUntil: 'domcontentloaded' });
+  await authedPage.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
+  const refreshReq = authedPage.waitForResponse(
+    (r) => r.url().includes('target-snapshot/refresh') && r.request().method() === 'POST',
+    { timeout: 20_000 },
+  );
+  await authedPage.getByRole('button', { name: '刷新' }).click();
+  const res = await refreshReq;
+  expect(res.status()).toBe(200);
+  await authedPage.screenshot({ path: path.join(SHOT, 'S2-after-quick-refresh.png'), fullPage: true });
+
+  await authedPage.goto('/#/aidop/smart-ops/s9', { waitUntil: 'domcontentloaded' });
+  await authedPage.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
+  const s9Req = authedPage.waitForResponse(
+    (r) => r.url().includes('s9/target-snapshot/refresh') && r.request().method() === 'POST',
+    { timeout: 20_000 },
+  );
+  await authedPage.getByRole('button', { name: '刷新' }).click();
+  expect((await s9Req).status()).toBe(200);
+  await expect(authedPage.getByRole('button', { name: '重算数据' })).toHaveCount(0);
+  await expect(authedPage.locator('.s1-rebuild-card')).toHaveCount(0);
+  await authedPage.screenshot({ path: path.join(SHOT, 'S9-after-quick-refresh.png'), fullPage: true });
+});

+ 7 - 3
doc/README.md

@@ -42,7 +42,8 @@
 
 | 文档 | 说明 |
 |------|------|
-| [项目交付验收方案.md](./项目交付验收方案.md) | Ai-DOP SaaS系统交付、技术规范逐条验收、付款联动及签署模板 |
+| [plan/项目验收/README.md](./plan/项目验收/README.md) | **现行项目验收入口**:L1 客户最低线(2026-08-07 修订 Word + 113 项技术规范/72 条合同 FUNC-AC)与 L2 产品全量线(167 条 FUNC-AC),以及三套 UAT 数据覆盖矩阵 |
+| [项目交付验收方案.md](./项目交付验收方案.md) | 历史 Markdown V0.6 / 兼容入口;现行 L1 以 `plan/项目验收/` 中 2026-08-07 修订 Word 为准 |
 | [审批流功能说明.md](./审批流功能说明.md) | 审批流菜单、审批中心、设计器、通知与权限 |
 | [审批流集成开发指南.md](./审批流集成开发指南.md) | `IFlowBizHandler`、`ApprovalPanel`、配置与 API |
 | [Windows后端WinSW守护重启方案.md](./Windows后端WinSW守护重启方案.md) | Windows 服务器上使用 WinSW 守护后端服务、自动重启与日志落盘 |
@@ -63,6 +64,7 @@
 | [plan/aidopdev_165库容量与增量写入分析_20260716.md](./plan/aidopdev_165库容量与增量写入分析_20260716.md) | 165/`aidopdev` 库容量快照、Top 表、调度增量写入与治理建议(2026-07-16) |
 | [plan/新系统数据库简明介绍与S1数据流.md](./plan/新系统数据库简明介绍与S1数据流.md) | 新系统数据库简明说明,并以 S1 为例说明数据中台流转 |
 | [plan/Ai-DOP项目待办清单.md](./plan/Ai-DOP项目待办清单.md) | **项目级待办统一入口**(专题子文档见 §2 注册表) |
+| [plan/Ai-DOP剩余待办统一执行任务书_20260814.md](./plan/Ai-DOP剩余待办统一执行任务书_20260814.md) | **可派发总任务书**:基于 server `1.0.340` / Web `2.4.293` 核对 P-001~P-037,将 29 个未完成项整理为 16 个代码包、8 个验收包、5 个决策/文档包;含依赖顺序、代码入口、步骤、验收、跨模块影响及新会话复制提示词 |
 | [plan/S1-S4业务功能测试结果汇总_20260712.md](./plan/S1-S4业务功能测试结果汇总_20260712.md) | S1–S4 API/E2E 测试结果(含样例单号、失败根因线索、原始 JSON 路径) |
 | [plan/数据中台方案复盘与未实现清单.md](./plan/数据中台方案复盘与未实现清单.md) | 数据中台总体方案、S1-S3 当前进度、未实现事项和后续优先级复盘 |
 | [plan/租户隔离彻底修正执行任务书.md](./plan/租户隔离彻底修正执行任务书.md) | **主执行任务书(进行中)**:租户隔离问题的完整修正方案,分 4 批(公共解析器 / 止血 / 贴源根因 / 兜底值 / 种子脚本),含白名单、回归矩阵、数据清理与自检检索式。批次 0/1/2/4 主体已随 `0da9ce11b`(server 1.0.327)落地;**剩余工作请执行下方收口任务书,勿重头再跑本书** |
@@ -88,8 +90,10 @@
 | [plan/数据库迁移/S3/S3数据中台与看板动态化验收.md](./plan/数据库迁移/S3/S3数据中台与看板动态化验收.md) | S3 供应协同数据中台链路、看板动态化与验收 SQL |
 | [plan/数据中台模块扩展开发指南-S3范式.md](./plan/数据中台模块扩展开发指南-S3范式.md) | 数据中台扩展(仿 S3):四库逻辑、作业与 Cursor 协作 |
 | [plan/UAT缺陷整治任务书/README.md](./plan/UAT缺陷整治任务书/README.md) | **可派发任务书集**:2026-08-09 起真人 UAT 暴露缺陷的整治,拆为 4 个可独立交付的工作包——WP-A 死信告警任务修复(`SqlSugar` 私有字段)+ D-W01 归档、WP-B 库存新鲜度打通(齐套算料改读 165 实时库存)、WP-C 齐套两入口口径统一与结论不互相覆盖、WP-D 计划实绩合并(MES 影子计划行实绩回读与聚合)。含各包决策项、事实复核 SQL、验收判据与"明确不做"边界;**D-W01 / D-M01 根因均在 165 存储过程内,受"不改 165"硬约束,只做我方侧适配与归档** |
-| [plan/UAT多租户数据方案/README.md](./plan/UAT多租户数据方案/README.md) | **多租户 UAT/Demo 数据方案**:三套租户定位(A 真机联调 `…229` / B 纯演示 `…230` / C 新客户样板 `…565`)、2026-08-11 实测基线、D1–D13 决策定案、迁移脚本与手工重置脚本分工、P0–P6 执行阶段(P6 含「DWD/KPI 必须全局刷新一次、不得按租户分别调用」的硬约束)、跨模块影响与 165 约束下无法演示的路径。**首要结论:租户隔离收口未完成前不得开始造数**(`dwd_material_shortage` 146 行仍 100% 落在 `tenant_id=0`)。含两处代码级核查更正:补 `HolidayMaster` 不影响 S2 排程(该表仅被 S1 `CapacityDateCalculator` 消费),并由此登记「S2 排程按周模式排产、不识别节假日」的算法缺陷 |
-| [plan/UAT多租户数据方案/逐表清单.md](./plan/UAT多租户数据方案/逐表清单.md) | 上述方案的逐表落地清单:S0–S9 每张表在三套租户下的现状行数、目标行数、数据来源(裁剪/构造/修复/运行生成)与落地通道 |
+| [plan/UAT测试数据准备/README.md](./plan/UAT测试数据准备/README.md) | **现行入口(2026-08-15)**:三套最终交付目标——`UATTEST_CHL` 首批 5 组客户全链路 UAT、`UATTEST_CHLB` 从新租户初始态逐步点通、`UATDEMO` 稳定展示九宫格/智慧诊断/S8/ChartBI。实施中所有阻断缺陷和交付范围内缺失功能必须修复并回归 |
+| [plan/UAT测试数据准备/10-阻断问题修复与闭环机制.md](./plan/UAT测试数据准备/10-阻断问题修复与闭环机制.md) | 三套准备过程的 P0–P3 分级、修复工作包门禁、禁止绕库、缺失功能判定与最终完成条件 |
+| [plan/UAT多租户数据方案/README.md](./plan/UAT多租户数据方案/README.md) | **2026-08-11 分析原稿**(已由上方现行入口接续):当时含 C 套;三套盘库、D1–D13、P0 阻断。勿在此目录继续追加执行内容 |
+| [plan/UAT多租户数据方案/逐表清单.md](./plan/UAT多租户数据方案/逐表清单.md) | 2026-08-11 三套逐表原稿(含 C 套对照) |
 | [db/mdp/README.md](./db/mdp/README.md) | S4/S8 相关 mdp、dwd 等建表脚本说明与执行顺序 |
 | [指标模型动态配置方案.md](./指标模型动态配置方案.md) | 指标模型动态配置总体方案 |
 | [meeting/20260408/vue-tsc-既存类型错误说明.md](./meeting/20260408/vue-tsc-既存类型错误说明.md) | 前端类型问题说明 |

+ 682 - 0
doc/plan/Ai-DOP剩余待办统一执行任务书_20260814.md

@@ -0,0 +1,682 @@
+# Ai-DOP 剩余待办统一执行任务书(2026-08-14)
+
+> **状态**:可派发
+> **用途**:供其它大模型 / 协作者在新会话中直接领取工作包执行。
+> **核对基线**:`master`,HEAD `27e926814`;server `1.0.340`;Web `2.4.293`。
+> **权威入口**:[`Ai-DOP项目待办清单.md`](./Ai-DOP项目待办清单.md)。
+> **统计**:P-001~P-037 中,已完成 8 项、进行中 1 项、部分完成 15 项、待办 13 项;本书只派发其余 29 项。
+> **重要**:本书是“派发与收口入口”,已有专项任务书仍是实现细节依据;不得绕开专项任务书重新设计已定方案。
+
+---
+
+## 0. 执行者必须遵守
+
+### 0.1 开工前
+
+1. 先读本书所领工作包的“必读文档”和“已完成基线”。
+2. 再对照当前代码确认文件和函数仍存在;行号会漂移,必须以类名、方法名、路由名定位。
+3. 执行代码修改前,按仓库协作规则向负责人列出:
+   - 目标;
+   - 文件清单;
+   - 可见行为变化;
+   - 跨模块影响;
+   - 本轮不做事项。
+4. 一次只领取一个工作包。工作包内部明确允许拆分时,按子阶段独立提交。
+5. 工作区可能有他人改动。禁止覆盖、回退或顺手提交不属于本工作包的文件。
+
+### 0.2 版本与提交
+
+- 修改前端运行代码/配置:同一提交递增 `Web/package.json` patch,并同步锁文件。
+- 修改后端运行代码/配置:同一提交递增 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的三处版本。
+- 同时修改前后端:两端分别 patch +1。
+- 仅修改文档、留证、验收记录:不升版本。
+- 用户说“提交”时,按仓库规则执行 commit + push;没有明确说“提交”则不得擅自提交。
+- 一个工作包原则上一个提交;数据库迁移、后端、前端必须同批上线时可同一提交,任务书另有拆分要求的除外。
+
+### 0.3 完成判据
+
+不能仅凭“代码存在”把状态改为完成:
+
+- **代码任务**:实现 + 构建/测试 + 运行验收 + 文档回写。
+- **验收任务**:必须记录环境、账号/租户、输入、输出、SQL/日志证据和结论。
+- **外部签收**:真实源地址、现场 APP、业务口径或负责人签字缺失时,只能标部分完成。
+- **数据清理**:执行前备份、执行后对账;脚本写好但未执行,只能标部分完成。
+
+### 0.4 禁止重做的已完成项
+
+P-010、P-012、P-013、P-014、P-017、P-026、P-031、P-033 已完成。除非回归证明失效,不得重新实现。
+
+---
+
+## 1. 真实状态与分组
+
+### 1.1 代码/数据仍需实施(16 项)
+
+`P-001、P-006、P-007、P-008、P-009、P-011、P-020、P-021、P-022、P-023、P-025、P-032、P-034、P-035、P-036、P-037`
+
+### 1.2 代码主体已落,主要缺验收/签收(8 项)
+
+`P-015、P-016、P-018、P-019、P-024、P-028、P-029、P-030`
+
+### 1.3 需先做产品/业务决策或文档收口(5 项)
+
+`P-002、P-003、P-004、P-005、P-027`
+
+---
+
+## 2. 建议执行顺序
+
+### 波次 A:安全与现场一致性(先做)
+
+1. **E-022 / P-022**:租户隔离收口。
+2. **E-023 / P-023**:匿名接口调用方鉴权。
+3. **E-035 / P-035**:废品数热回读。
+4. **E-032 / P-032**:`StdOp` 推送。
+5. **E-036 / P-036**:165 重复生效日计划自动清理。
+6. **V-029 / P-029**:日计划一致性三轮回归与死信收口。
+
+### 波次 B:排程与 MES/WMS 验收
+
+1. **V-028 / P-028**:幂等、冻结、单工单与 APP 验收。
+2. **V-030 / P-030**:优先级编辑细项及历史污染清理。
+3. **V-024 / P-024**:WP4 总体验收。
+4. **V-019 / P-019**:WP5/WP6 和未决项收口。
+5. **E-034 / P-034**:报工流水入站。
+
+### 波次 C:平台与交付
+
+1. **E-001 / P-001**:同步配置中心 TODO-SYNC-006。
+2. **E-009 / P-009**:KPI 原子层全量适配。
+3. **E-007 / P-007**:S4~S8 数据中台深度补齐。
+4. **V-015 / P-015**:窗口留证。
+5. **V-016 / P-016**:QMS 页面全量验收。
+6. **V-018 / P-018**:真实源与 §7 交付签收。
+7. **E-011 / P-011**:容量/日志保留治理。
+
+### 波次 D:产品增强与低优先级
+
+`P-002~P-005、P-006、P-008、P-020、P-021、P-025、P-027、P-037`
+
+---
+
+## 3. 可直接派发的代码工作包
+
+## E-001(P-001)同步配置中心运行绑定收口
+
+**当前基线**
+
+- TODO-SYNC-001~005 的主要代码已存在。
+- 参数/公式 API 与 UI 已在 server `1.0.264` / Web `2.4.258` 落地。
+- 拉取路径能读取 `mdp_sync_task_schedule` 的 FULL/INCR/ROLLING。
+- 未完成的是 TODO-SYNC-006:转换服务读取统一配置快照并把快照写入运行日志。
+
+**必读**
+
+- [`待执行任务列表.md`](./待执行任务列表.md)
+- [`数据中台同步配置中心实施任务书-执行版.md`](./数据中台同步配置中心实施任务书-执行版.md)
+
+**代码入口**
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSyncTaskConfigService.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/IMdpSourcePullExecutor.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSyncWindowResolver.cs`
+- 各模块 `*MdpSyncTransformService.cs`
+- `mdp_sync_log` / `mdp_transform_run_log` 的写入服务
+
+**执行步骤**
+
+1. 定义不可变配置快照 DTO:任务、来源、窗口、参数、公式、映射版本。
+2. 在一次运行开始时读取一次快照,后续阶段不得重复读出不同版本配置。
+3. 将快照摘要或版本写入运行日志 `detail_json`,敏感连接信息不得落日志。
+4. 选 S5 或 S4 作为首个转换服务接入样板,再抽公共入口,禁止先全模块复制。
+5. 补单测/集成测试:运行期间修改配置不影响当前批次,只影响下一批次。
+
+**验收**
+
+- UI 保存窗口、参数、公式后,下一次运行按新配置执行。
+- 同一 `run_id` 的日志能追溯完整配置版本。
+- 运行中修改配置不会造成同批次前后口径不一致。
+- TODO-SYNC-006 在任务清单中改为完成;P-001 才可标完成。
+
+**跨模块影响**:S1~S7 转换服务和 MDP 运行日志。先样板、后推广;不得一次性改完所有服务而无逐模块回归。
+
+---
+
+## E-006(P-006)旧 Business API 等价迁移收口
+
+**当前基线**:S1~S4 主 API、矩阵和 E2E 脚本已存在;矩阵多为“增强实现”,严格 CLOSE-S1~CLOSE-S4 未完成。
+
+**必读**
+
+- [`旧Business API全量等价迁移任务书.md`](./旧Business%20API全量等价迁移任务书.md)
+- [`旧Business API等价验收矩阵.md`](./旧Business%20API等价验收矩阵.md)
+- [`旧Business API迁移映射清单.md`](./旧Business%20API迁移映射清单.md)
+
+**执行步骤**
+
+1. 按 CLOSE-S1→S2→S3→S4 顺序逐模块跑现有 E2E 脚本。
+2. 每个旧接口核对输入、输出、写表、状态转换、错误语义、运行日志。
+3. 差异若已被产品接受,写“有意差异 + 决策”;否则补代码。
+4. S2 排程描述从“简化实现”更新为当前增强实现,引用 P-026/P-028。
+5. 每个模块独立提交和验收,不得四模块大包提交。
+
+**验收**:矩阵每行只能为“已打通 / 有意差异(有决策) / 外部阻塞(有负责人)”;不得留下无解释的“待对账”。
+
+**不做**:不重引 Mongo,不伪造 SAP/QAD 回执,不把外部系统未提供能力算代码完成。
+
+---
+
+## E-007(P-007)S4~S8 数据中台扩展收口
+
+**当前基线**
+
+- S4 已有较完整 `S4MdpSyncTransformService`。
+- S5~S7 有转换服务,但深度不及 S1~S3。
+- S8 无统一 `S8MdpSyncTransformService`;配置驱动管道与异常闭环未完成。
+
+**必读**
+
+- [`数据中台方案复盘与未实现清单.md`](./数据中台方案复盘与未实现清单.md)
+- [`数据中台模块扩展指南-S1至S3经验版.md`](./数据中台模块扩展指南-S1至S3经验版.md)
+
+**拆包**
+
+1. **E-007A S8 样板**:建立 source→stg→std→dwd→KPI 的最小闭环。
+2. **E-007B S5~S7 深度盘点**:只补缺失层,不重写现有服务。
+3. **E-007C 配置驱动**:在 E-001 快照机制完成后,将重复 SQL/步骤抽为配置。
+4. **E-007D 异常闭环**:异常→任务/改善→KPI 验证链路。
+
+**验收**
+
+- 每个模块有契约测试、刷新日志、租户隔离和看板读数证据。
+- S8 不得只建表不接作业/看板。
+- 配置驱动失败必须可回退到当前硬编码服务。
+
+**跨模块影响**:MDP 公共执行器、调度、S5~S8 看板。四个拆包禁止合并为一次提交。
+
+---
+
+## E-008(P-008)S0 CodeFirst 与 legacy BOM 治理
+
+**当前基线**:已有“遇单实体失败继续启动”的缓解配置;legacy BOM 根因、空值回填和新鲜启动验收未收口。
+
+**必读**:[`S0-CodeFirst启动异常与BOM历史数据治理方案.md`](./S0-CodeFirst启动异常与BOM历史数据治理方案.md)
+
+**代码/数据入口**
+
+- `server/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs`
+- `server/Admin.NET.Web.Entry/Configuration/Database.json`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoS0ProductStructure.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs`
+
+**执行步骤**
+
+1. 用当前库和空库各启动一次,保存 CodeFirst 错误基线。
+2. 明确 legacy 表由迁移脚本管理还是 CodeFirst 管理,禁止双重管理。
+3. 对 `ParentMaterialId` 等历史 NULL 做幂等回填与约束前检查。
+4. 移除仅用于掩盖该错误的临时绕过;若绕过仍服务其它实体,保留并解释。
+5. 空库、存量库各启动两次,第二次不得重复 DDL 或报错。
+
+**验收**:启动日志无目标 BOM DDL 错误;历史数据行数和父子关系对账一致;回滚脚本可用。
+
+---
+
+## E-009(P-009)KPI 原子层与全局筛选全量适配
+
+**当前基线**
+
+- 原子表、构建服务、查询服务和 warmup job 已存在。
+- 部分 S5/S6/S7 KPI 支持维度查询;其余仍是 `summary_only` 或部分回退。
+
+**必读**:[`KPI原子聚合层与全局筛选适配执行任务书.md`](./KPI原子聚合层与全局筛选适配执行任务书.md)
+
+**代码入口**
+
+- `SmartOpsKpiAtomicBuildService.cs`
+- `SmartOpsKpiAtomicQueryService.cs`
+- `AidopKanbanController.SmartOpsFilter.cs`
+- `Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue`
+
+**拆包**
+
+1. 建立 S1~S9“指标×筛选维度×支持模式”矩阵。
+2. 按模块补原子事实来源和维度值;一个模块一个提交。
+3. 不支持的指标明确返回 `summary_only`,前端必须提示,禁止静默忽略筛选。
+4. 诊断页与详情看板使用同一筛选合同。
+
+**验收**:支持项筛选前后值按 SQL 对账;不支持项有明确 UI 提示;租户、工厂和时间范围不串数据。
+
+---
+
+## E-011(P-011)库容量与日志保留治理
+
+**前置决策**:负责人先确认各日志保留天数,以及“直接删除 / 先归档”策略。未决策前只能做容量基线,不得上线清理。
+
+**必读**:[`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) §4
+
+**执行步骤**
+
+1. 重新采集当前 Top 表、日增量和索引体积,不能沿用 7 月数据直接定阈值。
+2. 建统一保留配置和清理作业,首批覆盖 `mdp_sync_log`、`mdp_transform_run_log`、`SysLogOp`。
+3. 清理按小批次执行,记录删除量和耗时,避免长事务。
+4. 评估 `dwd_process_outsource_delivery` 分区/滚动;先做影子验证再改生产表。
+5. 与 P-015 窗口留证联动,确认调度强度不会让清理追不上增长。
+
+**验收**:连续 7 天增长稳定;清理作业失败可告警;业务查询和审计窗口不受影响。
+
+---
+
+## E-020(P-020)工序执行进度 P2/P3
+
+**当前基线**:P1 已完成,唯一口径是 `WorkOrderExecutionProgressSql`;禁止消费侧另写进度 SQL。
+
+**必读**:[`旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md`](./旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md)
+
+**拆包**
+
+1. **P2-1** 工序路线图可视化。
+2. **P2-2** 相邻工序完成量差值的在制卡点解读。
+3. **P3-1** 看板快览抽屉。
+4. **P3-2** 报工流水/标签/领料单状态联动到执行追踪其它 Tab。
+
+**验收**:所有数量与 `WorkOrderExecutionProgressSql` 对账;P3-2 不新增看板列;深链与权限正常。
+
+**跨模块影响**:S2 页面、S3 订单追溯、P-034 报工流水。P-034 未完成前,P3-2 只能做空态和接口契约,不能宣称数据闭环。
+
+---
+
+## E-021(P-021)S2 执行进度口径沉入 DWD/KPI
+
+**触发条件**:只有负责人确认 FUNC-S2-009 必须与 FUNC-S2-008 同口径时才执行;否则保持待办。
+
+**代码入口**
+
+- `WorkOrderExecutionProgressSql.cs`
+- `S2MdpSyncTransformService.cs`
+- S2 KPI `S2_L1_004` 计算
+
+**执行步骤**
+
+1. 把唯一口径沉到 DWD,不允许 KPI SQL复制一套 `QtyComplete` 求和。
+2. 为当前/末道工序、末道完成量、在制量建立可复用 DWD 字段或视图。
+3. KPI 只消费 DWD。
+
+**验收**:同一工单在 FUNC-S2-008 与 FUNC-S2-009 数值一致;全量刷新和增量刷新均对账。
+
+---
+
+## E-022(P-022)租户隔离收口
+
+**当前基线**:批次 0/1/2/4 已落;不得重做。剩余工作全部以收口任务书为准。
+
+**必读**:[`租户隔离收口与造数前置任务书.md`](./租户隔离收口与造数前置任务书.md)
+
+**固定顺序**
+
+1. WP-1:修齐套刷新“先删后建、失败即空”及局部超时。
+2. WP-2:清除写入侧 `tenant_id` 零兜底。
+3. 稳定跑一轮完整同步,确认不再新增零值。
+4. WP-3:备份后清理存量脏数据。
+
+**禁止事项**
+
+- 不修改任务书 §0.2 列出的非缺陷。
+- 不把 `MdpPullContext` 中无害的 `TenantId=0` 当成写入缺陷。
+- 不在 WP-2 稳定前执行 WP-3。
+
+**验收**:各租户刷新后只见自身数据;失败不清空既有结果;零值行连续两轮不增长;清理前后总量可解释。
+
+---
+
+## E-023(P-023)匿名外部接口 AppKey 鉴权
+
+**范围**
+
+- `api/aidop/out`
+- `api/aidop/wms-workord/push`
+
+**当前问题**:两者允许匿名调用;调用方可指定租户。现有审计日志不等于鉴权。
+
+**执行步骤**
+
+1. 设计 AppKey + HMAC 签名,至少包含 key id、时间戳、nonce、method、path、body hash。
+2. 密钥只存服务端配置/密钥服务,不入库明文、不入日志。
+3. 校验时间窗和 nonce 防重放;鉴权通过后把调用方绑定到允许的租户集合。
+4. 保留 JWT 内部调用;匿名外部调用必须签名。
+5. 提供密钥轮换和禁用机制。
+
+**验收**
+
+- 无签名、错签名、过期、重放、越租户均拒绝。
+- 正确调用可用;日志记录 key id 与租户,不记录 secret。
+- 两个接口共用中间件/服务,不复制鉴权代码。
+
+**跨模块影响**:所有外部调用方必须同步升级。上线前先盘点调用方并提供灰度窗口,禁止直接切断未知生产调用。
+
+---
+
+## E-025(P-025)排程性能 L2/L3/L4
+
+**当前基线**:L1 索引已落并有 EXPLAIN 证据;页面耗时复测和后续层未做。
+
+**必读**
+
+- [`S2生产排程性能优化方案.md`](./S2生产排程性能优化方案.md)
+- [`S2排程性能-L1索引体系执行任务书.md`](./S2排程性能-L1索引体系执行任务书.md)
+
+**执行步骤**
+
+1. 先在当前 `1.0.340` 重测页面和服务耗时,保留 SQL profile。
+2. 只优化当前 Top 3 热点;优先集合化与去 N+1。
+3. L3 涉及主键/并发正确性时,必须与 P-028/P-029 回归一起做。
+4. L4 异步任务化不得重复 P-026 已有能力。
+
+**验收**:相同输入结果校验和一致;P50/P95 和 SQL 次数下降;无跨租户数据变化。
+
+---
+
+## E-032(P-032)工单三人组补推 `StdOp`
+
+**代码入口**:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/WorkOrdMesPushService.cs`
+
+**精确修改**
+
+1. `RoutingRow` 增加 `StdOp`。
+2. routing SELECT 补 `StdOp`。
+3. `BuildRoutingInsert` 与 `BuildRoutingUpdate` 同时补 `StdOp`,按 165 `nvarchar(20)` 截断。
+4. 增加 payload 契约测试,覆盖空值和非空值。
+
+**验收**
+
+- Ai-DOP 有 `StdOp` 的工艺路线推到 165 后值一致。
+- 仅选 165 `SAPRoutingOpDetail` 已存在的 `91HC0497` 系列做 APP 投产验收。
+- 对 165 不存在 SAP 主数据的物料,必须明确报外部主数据缺口,禁止向 165 伪造 `SAPRoutingOpDetail`。
+
+**不做**:不改 165 存储过程,不改 `CompanyCode`,不补造 SAP 主数据。
+
+---
+
+## E-034(P-034)`OpTransEmployee` 报工流水入站
+
+**影响模块**:S2 工单执行追踪、S3 订单交付追溯。
+
+**执行步骤**
+
+1. 先统计 165 表总量、日增量、主键/唯一键和 `CreateTime` 完整性。
+2. 明确落点:业务表直落或 `mdp_stg_*`→std;优先遵循当前 MDP 分层,禁止特殊直写。
+3. 增加 SQL Server INCR 实体配置、字段映射、调度与首次回补窗口。
+4. 评估加入 HotWatch;若分钟级同步已满足需求,不额外增加热关注。
+5. 补 S2/S3 查询回归和租户映射。
+
+**验收**:165 新增一条报工流水后,Ai-DOP 在约定 SLA 内可见;重复同步不重复;首次回补不锁表;S2/S3 数量一致。
+
+---
+
+## E-035(P-035)废品数 `QtyScrap` 热回读
+
+**代码入口**:`MdpHotWatchService.ApplyWorkOrderExecutionAsync`
+
+**执行步骤**
+
+1. routing UPDATE 增加 `QtyScrap = @QtyScrap`。
+2. 参数使用现有安全数值读取方法,处理 NULL。
+3. 同步修正文档注释。
+4. 核对 `WorkOrdMaster` 是否有同义汇总列;没有明确业务需求时不扩范围。
+
+**验收**:165 `WorkOrdRouting.QtyScrap` 变化后,本库同工单/工序/租户值一致;`QtyComplete`、`QtyReject`、`Status` 不回归;页面废品列显示。
+
+---
+
+## E-036(P-036)165 重复生效日计划自动收口
+
+**代码入口**:`ProductionScheduleGenerationService.ReconcileStale165PsdAsync`
+
+**当前缺口**
+
+- 当前重复统计粒度近似 `(WorkOrd, Op)`,不是完整业务键。
+- 同一业务键在 165 有 2 行、本库有 1 行时,不会自动作废多余行。
+
+**执行步骤**
+
+1. 以 165 数据为准,按 `(Domain, WorkOrds, Op, PlanDate, Line)` 分组。
+2. 仅处理“生效且未开始/未投产”的重复行;已处于 `R/S/W` 等现场状态的行只告警,不自动作废。
+3. 与本库最终态匹配,确定保留行;不要简单固定保留最小 RecID,需优先保留已被本库映射/引用的行。
+4. 对可安全清理的多余行入队 `S2_PSD_DEACTIVATE`,idem key 带 run id 和目标 RecID。
+5. 日志输出重复组数、自动处理数、人工处理数。
+
+**验收**:构造 1 组安全重复 + 1 组已投产重复;前者自动只剩 1 行,后者不动且告警;连续三轮幂等。
+
+---
+
+## E-037(P-037)“智慧诊断 / 全局智慧诊断”入口拆分
+
+**产品口径已定**
+
+- “智慧诊断”:跟随当前选中 L1。
+- “全局智慧诊断”:忽略当前选中,自动选择模块最差 L1。
+- 指标无 L3/L4 时不展开是正确行为,不补假数据。
+
+**文件**
+
+- `Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue`
+- `Web/src/views/aidop/diagnosis/index.vue`
+
+**执行步骤**
+
+1. 现有右上角“智慧诊断”传 `metricCode: activeTab`;无选中时使用看板默认 L1。
+2. 新增“全局智慧诊断”,只传 `module` 和筛选条件。
+3. 保留组件级“诊断”按钮的指标级行为。
+4. 诊断页首次请求期间显示 loading/skeleton;请求完成前不渲染内置演示 `stages`。
+5. 接口失败显示明确错误/重试,不回退到看似真实的演示数据。
+
+**验收**
+
+- 选“订单评审周期”→智慧诊断稳定显示其 L3/L4。
+- 全局智慧诊断可落到最差“满足率”;无 L3 时显示维护提示。
+- 页面不再出现“L3 闪一下又消失”。
+- 回归所有使用动态看板组件的模块,不只 S1。
+
+---
+
+## 4. 验收/签收工作包
+
+## V-015(P-015)抽数窗口逐模块留证
+
+**代码基线**:FULL/INCR/ROLLING 引擎和 UI 已有。
+**任务**:为 S1~S7 逐模块选择真实实体,分别跑 FULL、INCR、ROLLING;记录 SQL 范围、行数、耗时、游标和重复率。
+**注意**:清单中的“S5 ROLLING/30d 种子已落”缺少提交证据;验收前先确认数据库配置,不得把操作指南当已提交种子。
+**完成条件**:各模式有可复跑证据;日志保留能力由 P-011 提供。
+
+---
+
+## V-016(P-016)QMS 多租户全页面验收
+
+**代码基线**:29 张 `qms_*` 已有租户列,核心读写和 IQC 已落;IQC P4/P5 已有留证。
+**任务**:按 IQC、IPQC、FQC、待检、S0 质量页面逐页验证租户 A/B 数据隔离、增删改查和空态。
+**完成条件**:所有页面有租户双样本证据;任何无过滤接口转 P-022 收口,不在本包重复造框架。
+
+---
+
+## V-018(P-018)双模式交付门禁与人工签收
+
+**代码基线**:截至 v2.13 的代码门禁已完成。
+**任务**
+
+1. 更新交付级任务书进度看板至 `1.0.340 / 2.4.293`。
+2. 真实 DB/API 源逐模块执行契约测试。
+3. 补推送回执、失败重试、健康检查和告警证据。
+4. 执行 §7 人工签收;负责人、日期、环境、限制必须落文档。
+
+**完成条件**:真实源与 §7 签收齐全;不能用 Mock 契约替代生产交付签收。
+
+---
+
+## V-019(P-019)165 MES/WMS 主链路收口
+
+**当前基线**:WP5 的投产→报工→废品→结束计划已有部分真人 PASS;S4 WMS、S5 IQC、库存 LIVE_165 已继续推进,但 WP6 和派生缺陷未闭环。
+
+**必读**
+
+- [`旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md`](./旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md)
+- UAT 留证目录
+
+**执行步骤**
+
+1. 先完成 E-032/E-035/E-036 和 V-029,避免带已知缺陷做总验收。
+2. 更新执行进度文档,标明 WP5 已通过/未通过的具体场景。
+3. 完成 WP6 对账。
+4. 逐项拍板 A4/D3/D4/D6;未决项不得默认为“不需要”。
+5. S4 WMS、S5 IQC、LIVE_165 库存作为扩展验收附录,不混入旧 P-014。
+
+**完成条件**:WP1~WP10 状态清晰;未完成项均有独立 P-ID 或外部责任人;WP6 对账通过。
+
+---
+
+## V-024(P-024)WP4 日计划回写总体验收
+
+**代码基线**:UPSERT/DEACTIVATE 已落,APP ZJ01 有目视证据。
+**前置**:V-029、E-036 完成。
+**任务**:连续三轮全量重排,核对本库与 165 的生效行、作废行、重复组、死信、APP 可见工序。
+**完成条件**:WP4 能独立标完成;一致性问题不再重复写在 P-024,后续只在 P-029/P-036 追踪。
+
+---
+
+## V-028(P-028)排程算法剩余验收
+
+**代码基线**:H1~H6 已落。
+**任务**
+
+1. 相同输入连续两轮全量重排,计划日和占用表幂等。
+2. 构造冻结/已开工工序,验证旧产能被结转且后续工序不越序。
+3. 单工单重排三次,验证不与自身旧占用抢产能、不漂移。
+4. 验证交期不可达异常与 APP 可执行计划。
+
+**完成条件**:任务书 §6 所有未过项补齐证据;测试数据产能过低导致排到 2027 本身不算缺陷。
+
+---
+
+## V-029(P-029)日计划一致性与死信收口
+
+**代码基线**:I1~I5、P-033 B1 已落。
+**任务**
+
+1. 先分类现有死信,禁止 `allDead=true` 批量重投。
+2. 修复仍存在的 `SOURCE_ROUTE_UNRESOLVED` 配置/路由问题。
+3. 连续三轮全量重排,核对 `outboxDeadCount`、165 对账和 idem 顺序。
+4. 与 E-036 联合验收重复行。
+
+**完成条件**:V2/V5 通过;过期作废意图全部人工判断并留证;本库与 165 最终态一致。
+
+---
+
+## V-030(P-030)优先级编辑细项与历史清理
+
+**代码基线**:J1~J4 与主路径真人验收已完成。
+**任务**
+
+1. 连续改优先级三次,计划不漂、不作废。
+2. 有意修改交期,验证只改 `DueDate` 并触发预期重排。
+3. 正式记录 J5:`scheduling/save`、`scheduling/urgent` 无同类污染。
+4. 按 `DueDate=OrdDate` + action log 识别历史污染,先生成候选清单,负责人确认后再修复。
+
+**完成条件**:V2~V6 和历史清理闭环;不得仅凭静态代码把状态改完成。
+
+---
+
+## 5. 决策/文档工作包
+
+## D-002(P-002)Mongo P0 等价缺口决策与实现
+
+**先决策 M-01**:是否必须恢复“多子工单预备 + 资源检查/评审两阶段”。
+**若否**:记录产品接受现有合并 `review`,关闭 M-02/M-03。
+**若是**:先单独输出数据模型/状态机任务书,再实施 M-02/M-03。
+**M-04/M-05** 不依赖该决策:分别设计 BOM 标准层和全局库存占用仿真。
+**必读**:[`旧系统MongoDB替代-待办清单.md`](./旧系统MongoDB替代-待办清单.md)。
+
+---
+
+## D-003(P-003)Mongo P1 标准层
+
+按 M-06 替代料、M-07 工艺路由、M-08 细口径三个独立包执行。先盘点 S0 现有主数据,避免新建重复标准表;只有证明现有表不能承担 MDP 标准层职责时才建新表。
+
+---
+
+## D-004(P-004)Mongo P2 替代
+
+- M-09:先确认业务是否需要持久化 `dashboard_snapshot`;没有明确 SLA/回放需求则不建表。
+- M-10:盘点旧 `SystemJob` 的 8 类主数据是否已由现行 MDP 覆盖;已覆盖的直接文档关闭,未覆盖的才迁移。
+
+---
+
+## D-005(P-005)Mongo 文档同步
+
+纯文档包。对照当前 `bang_id`、占用清理、P-026/P-028 排程版本化,更新:
+
+- `旧系统MongoDB情况说明与新系统替代分析.md` §11;
+- `旧系统MongoDB替代-待办清单.md`;
+- 必要时 API 映射清单。
+
+验收:文档不得再把已落地主链路写成缺失,也不得把 M-01~M-10 写成已完成。
+
+---
+
+## D-027(P-027)产线班制业务复核
+
+由业务负责人确认:
+
+1. 是否所有产线周一~周五工作;
+2. 是否统一 10.5 小时;
+3. 是否存在两班/三班和周六常态生产;
+4. 是否需要按产线差异化;
+5. 法定节假日/调班由谁维护。
+
+输出签字版口径。若结论不同于当前假设,再生成日历配置化与存量重播种任务书;未确认前不直接改当前 616 行日历。
+
+---
+
+## 6. 状态回写规则
+
+每个工作包完成后必须同步:
+
+1. [`Ai-DOP项目待办清单.md`](./Ai-DOP项目待办清单.md) 对应 P-ID;
+2. 本书对应工作包的执行记录;
+3. 专项任务书头部状态;
+4. 有运行证据时更新 UAT 留证;
+5. 若新增可派发专项任务书,登记 `doc/README.md`。
+
+状态说明:
+
+- `⬜ 待办`:尚未开始实施。
+- `🔄 进行中`:当前有人执行且未形成可验收阶段结果。
+- `🔶 部分完成`:已有稳定代码/阶段验收,但定义范围未闭环。
+- `✅ 已完成`:定义范围全部完成,含必要运行验收/签收。
+
+---
+
+## 7. 给新会话的复制提示词
+
+```text
+请阅读:
+1. doc/plan/Ai-DOP项目待办清单.md
+2. doc/plan/Ai-DOP剩余待办统一执行任务书_20260814.md
+
+本次只领取工作包:【填写编号,例如 E-035(P-035)】。
+
+先核对当前代码与任务书基线是否一致,再按仓库协作规则列出:
+- 目标
+- 文件清单
+- 可见行为变化
+- 跨模块影响
+- 本轮不做事项
+
+等我确认后再改。不要执行其它 P-ID,不要重做任务书列出的已完成项。
+完成后按工作包验收标准验证,并回写项目待办清单、专项任务书与 UAT 留证。
+```
+
+---
+
+## 8. 建立记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-08-14 | 按 server `1.0.340` / Web `2.4.293` 逐项核对 P-001~P-037;将 29 个未完成项重组为 16 个代码包、8 个验收包、5 个决策/文档包 |

Разница между файлами не показана из-за своего большого размера
+ 16 - 14
doc/plan/Ai-DOP项目待办清单.md


+ 1132 - 0
doc/plan/S1看板刷新机制修复执行任务书_20260815.md

@@ -0,0 +1,1132 @@
+# S1 看板刷新机制修复执行任务书
+
+> 日期:2026-08-15  
+> 执行对象:供其他大模型或协作者直接照章实施。  
+> 优先级:P0(当前“刷新”通常超过前端超时,且目标配置无法通过正常刷新及时展示)。  
+> 范围:首轮只修 S1;共享组件采用配置开关,禁止改变 S2~S7 现有刷新行为。  
+> 前置任务:运营指标目标管理与 Excel 数据源接入已落地,后端基线至少为 `1.0.344`、前端基线至少为 `2.4.295`。执行时必须以仓库实际版本为准。
+
+## 1. 问题与实证
+
+### 1.1 用户可见问题
+
+1. 在“运营指标主数据 → 指标管理 → 目标管理”新增 S1 目标后,S1 看板目标值不立即变化。
+2. S1 看板右上角“刷新”长时间处于 loading。
+3. 前端请求超时或用户离开页面后,`mdp_transform_run_log` 可能长期保留 `RUNNING`。
+4. 连续点击刷新可以启动多个 S1 全量任务,互相争用数据库和源端资源。
+
+### 1.2 已核实的数据证据
+
+指标 `S1_L1_001`、租户 `797403760988229`、工厂 `1` 的现场状态:
+
+- 正式目标配置:`target_value=10`、`source_type=MANUAL`,创建时间为 2026-08-14 23:51 左右;
+- 看板日值快照:`target_value=20`、`target_source=LEGACY_DAILY_CURRENT`,生成时间为 2026-08-14 23:21 左右;
+- 配置时间晚于日值快照时间,因此配置页显示 10、看板仍显示 20;
+- 用户截图中输入框里的“1”只是待提交表单值;数据库没有 `target_value=1` 的正式配置。
+
+### 1.3 已核实的性能证据
+
+`mdp_transform_run_log` 中 S1 最近成功全量任务:
+
+- `S1_MDP_FULL_20260814231822`:约 708 秒;
+- `S1_MDP_FULL_20260814232114`:约 1136 秒;
+- 其它历史成功任务也约 11~19 分钟。
+
+前端 `refreshModuleMdp` 的超时仅 180 秒。因此当前同步 HTTP 设计不是“偶尔慢”,而是按现有数据量**稳定超过超时**。
+
+现场还存在两条手工任务:
+
+- `id=14360`,`trigger_type=MANUAL`,状态 `RUNNING`;
+- `id=14361`,`trigger_type=MANUAL`,状态 `RUNNING`。
+
+日志已出现请求取消后 `MarkTransformRunFailed` 也被同一取消令牌影响、失败状态未能写回的迹象。实施时不得机械依赖上述固定 ID,应按查询结果判断存量。
+
+### 1.4 两条控制台警告不属于本任务根因
+
+- `FormulaEditor.excludeKpiId` 收到字符串 `"337"`;
+- `MonacoEnvironment.getWorkerUrl/getWorker` 未配置。
+
+它们来自 KPI 主数据/SQL 编辑器缓存页,不在 S1 看板刷新调用链中。本任务不处理,禁止把修复刷新失败归因于这两条警告。
+
+---
+
+## 2. 根因
+
+### 2.1 一个按钮混合了两种完全不同的业务动作
+
+当前 S1 “刷新”执行:
+
+```text
+POST /api/AidopKanban/mdp/refresh/S1
+  → S1MdpSyncTransformService.RunFullAsync
+  → 15 个实体全量入站
+  → 标准层
+  → DWD
+  → KPI 日值
+  → 全历史订单日期 Atomic 重建
+  → GET /api/AidopKanban/dashboard-page/S1
+```
+
+用户理解的“刷新页面”却通常只是:
+
+```text
+重新加载看板,必要时让刚保存的正式目标进入最新日值快照
+```
+
+### 2.2 目标配置与看板快照是两条读取链
+
+- 目标管理的“当前目标”实时调用 `KpiTargetResolver`;
+- S1 看板只读 `ado_s9_kpi_value_l{1-4}_day.target_value`;
+- 目标 CRUD 不重算日值;
+- 全量 MDP 才会重新解析并写入目标快照。
+
+不得把看板改成每次查询实时解析目标。既有设计要求看板读取日值快照,以保证历史口径稳定。
+
+### 2.3 HTTP 请求取消直接取消后台工作
+
+控制器把请求 `CancellationToken` 直接传给 `RunFullAsync`。浏览器超时、导航或断网会取消全量任务;失败日志更新又可能使用已取消上下文,导致运行记录停留在 `RUNNING`。
+
+### 2.4 没有同模块并发闸门
+
+连续点击、定时任务与手工任务可以重叠执行 S1 全量刷新。当前 `mdp_transform_run_log` 只是日志,不是可靠的互斥锁。
+
+---
+
+## 3. 目标状态
+
+完成后必须同时满足:
+
+1. S1 右上角“刷新”在正常数据库负载下 5 秒内完成,不再触发全量 MDP。
+2. 新增或修改 S1 正式目标后,点击“刷新”可使**最新业务日**的目标快照更新,看板展示新目标。
+3. 只更新目标快照字段,不重新计算 `metric_value`,不回写历史日期。
+4. 完整 MDP 改为独立“重算数据”动作,提交后立即返回任务标识,不占用长 HTTP 请求。
+5. 同一环境任意时刻最多运行一个 S1 完整 MDP 任务;手工与 AUTO/BOOTSTRAP 也不能重叠。
+6. 页面可查询任务状态和最后一次结果;刷新、关闭页面不影响后台任务。
+7. 后台任务成功、失败、服务停止或异常退出后,不得永久停留在 `RUNNING`。
+8. S2~S7 的按钮和接口行为首轮保持不变。
+9. 看板仍从日值表读取目标快照,智慧诊断等既有口径不漂移。
+
+---
+
+## 4. 实施边界
+
+### 4.1 本任务必做
+
+- S1 最新业务日目标快照轻量刷新服务和 API;
+- S1 看板“刷新”改为轻量刷新后重载页面;
+- S1 完整 MDP 独立异步任务;
+- 单实例防重、状态查询、失败收尾和存量 `RUNNING` 治理;
+- 前后端测试、数据库验收和性能验收;
+- 按实际改动递增前后端 patch 版本。
+
+### 4.2 本任务明确不做
+
+- 不实时改写历史日值目标;
+- 不把看板查询改成实时调用 `KpiTargetResolver`;
+- 不优化 S1 全量 MDP 内部 15 实体入站和全历史 Atomic 算法;
+- 不改变 S2~S7 刷新语义;
+- 不处理 `excludeKpiId` 和 Monaco Worker 警告;
+- 不新增菜单,不调整 FUNC 编号;
+- 不修改 165 外部数据库对象;
+- 不提交或 push,除非用户明确说“提交”。
+
+---
+
+## 5. 跨模块影响
+
+### 5.1 前端共享组件
+
+`Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue` 被 S1~S7 共用。
+
+硬约束:
+
+- 不得通过 `if (moduleCode === 'S1')` 在组件内部散落特殊逻辑;
+- 在 `moduleDashboardRegistry.ts` 增加可选能力配置;
+- 只有 S1 注册轻量刷新和异步重算能力;
+- S2~S7 未配置时继续走现有 `refreshMdp`。
+
+### 5.2 后端共享日志表
+
+`mdp_transform_run_log` 被多个模块使用。
+
+硬约束:
+
+- 所有清理、查询、防重条件必须限定 `job_code='S1_MDP_SYNC_TRANSFORM'`;
+- 禁止批量修改其它模块的 `RUNNING`;
+- 若新增通用字段,必须证明不会破坏 S2~S7 现有写入;优先新增 S1 独立任务表,避免扩散。
+
+### 5.3 目标解析器
+
+`IKpiTargetResolver` 是 S1~S7、S9 共用能力。
+
+硬约束:
+
+- 只调用,不修改解析顺序;
+- 保持 `FORMAL_FACTORY → FORMAL_TENANT_DEFAULT → legacy`;
+- `MISSING` 不写库;
+- 不修改其它模块目标来源。
+
+---
+
+## 6. 推荐架构
+
+### 6.1 两个动作
+
+#### A. 刷新
+
+用途:快速更新 S1 最新业务日的目标快照并重新加载看板。
+
+```text
+POST /api/AidopKanban/s1/target-snapshot/refresh?factoryId=1
+  → 解析当前租户
+  → 对 L1~L4 分别找 S1 最新 biz_date
+  → 仅更新该日期行的 target_* 字段
+  → 返回更新统计
+GET /api/AidopKanban/dashboard-page/S1?factoryId=1
+```
+
+不得调用:
+
+- `S1MdpSyncTransformService.RunFullAsync`;
+- 源端 Pull;
+- 标准层/DWD 重建;
+- Atomic 全历史重建。
+
+#### B. 重算数据
+
+用途:用户明确需要重新拉源、计算实际值时触发完整 MDP。
+
+```text
+POST /api/AidopKanban/s1/rebuild-jobs
+  → 防重
+  → 创建持久任务
+  → 立即返回 202 + jobId
+
+后台 worker
+  → 领取任务
+  → RunFullAsync(与 HTTP CancellationToken 解耦)
+  → SUCCESS / FAILED / CANCELLED
+
+GET /api/AidopKanban/s1/rebuild-jobs/{jobId}
+  → 页面轮询状态
+```
+
+### 6.2 为什么不能只把 timeout 调大
+
+禁止把前端 timeout 从 180 秒改成 20 分钟作为修复,原因:
+
+- 代理、浏览器和网络仍可能先断;
+- 页面关闭会取消请求;
+- 无法可靠防重;
+- 无法在重启后恢复任务状态;
+- 长连接占用服务器资源;
+- 不能解决目标快照只需轻量更新的问题。
+
+### 6.3 为什么不能在看板 GET 中实时覆盖目标
+
+禁止在 `dashboard-page/S1` 查询时临时调用解析器覆盖返回值,原因:
+
+- 返回值与日值表快照不一致;
+- 智慧诊断、趋势、其它消费者仍读取旧值;
+- 历史页面会随当前配置漂移;
+- 破坏既定“日值保存目标快照”的口径。
+
+---
+
+## 7. API 契约
+
+### 7.1 轻量目标快照刷新
+
+```http
+POST /api/AidopKanban/s1/target-snapshot/refresh?factoryId=1
+Authorization: Bearer ...
+```
+
+租户必须使用:
+
+```text
+AidopTenantScope.ResolveOrThrow(...)
+```
+
+禁止信任客户端传入 `tenantId`。
+
+成功:
+
+```json
+{
+  "ok": true,
+  "moduleCode": "S1",
+  "factoryId": 1,
+  "updatedRows": 42,
+  "missingRows": 0,
+  "levels": [
+    { "level": 1, "bizDate": "2026-08-14", "updatedRows": 4, "missingRows": 0 },
+    { "level": 2, "bizDate": "2026-08-14", "updatedRows": 18, "missingRows": 0 },
+    { "level": 3, "bizDate": "2026-08-14", "updatedRows": 12, "missingRows": 0 },
+    { "level": 4, "bizDate": "2026-08-14", "updatedRows": 8, "missingRows": 0 }
+  ],
+  "elapsedMs": 320,
+  "message": "S1 看板目标快照已刷新"
+}
+```
+
+没有日值时返回 200,不伪造日值:
+
+```json
+{
+  "ok": true,
+  "updatedRows": 0,
+  "missingRows": 0,
+  "message": "S1 暂无可刷新的日值快照"
+}
+```
+
+### 7.2 创建完整重算任务
+
+```http
+POST /api/AidopKanban/s1/rebuild-jobs
+Authorization: Bearer ...
+```
+
+成功受理:
+
+```http
+HTTP/1.1 202 Accepted
+```
+
+```json
+{
+  "ok": true,
+  "jobId": 123,
+  "status": "QUEUED",
+  "message": "S1 数据重算已排队"
+}
+```
+
+已有活动任务时不得再创建:
+
+```http
+HTTP/1.1 409 Conflict
+```
+
+```json
+{
+  "ok": false,
+  "jobId": 122,
+  "status": "RUNNING",
+  "message": "S1 数据重算正在执行,请勿重复提交"
+}
+```
+
+### 7.3 查询重算任务
+
+```http
+GET /api/AidopKanban/s1/rebuild-jobs/{jobId}
+```
+
+```json
+{
+  "jobId": 123,
+  "status": "RUNNING",
+  "submittedAt": "2026-08-15T00:10:00+08:00",
+  "startedAt": "2026-08-15T00:10:02+08:00",
+  "finishedAt": null,
+  "durationMs": null,
+  "batchId": "S1_MDP_FULL_20260815001002",
+  "stageRows": 0,
+  "standardRows": 0,
+  "dwdRows": 0,
+  "kpiRows": 0,
+  "atomicRows": 0,
+  "errorMessage": null
+}
+```
+
+状态枚举固定为:
+
+```text
+QUEUED / RUNNING / SUCCESS / FAILED / CANCELLED
+```
+
+不得把异常堆栈原样返回浏览器;`errorMessage` 返回可读摘要,完整异常写后端日志。
+
+### 7.4 最近任务
+
+```http
+GET /api/AidopKanban/s1/rebuild-jobs/latest
+```
+
+用于页面重载后恢复进度,不得只把 jobId 保存在前端内存。
+
+---
+
+## 8. 数据设计
+
+### 8.1 推荐新增 S1 重算任务表
+
+新增实体建议:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoS1DashboardRebuildJob.cs
+```
+
+表:
+
+```text
+ado_s1_dashboard_rebuild_job
+```
+
+最低字段:
+
+```sql
+id BIGINT PRIMARY KEY AUTO_INCREMENT,
+tenant_id BIGINT NOT NULL,
+factory_id BIGINT NOT NULL DEFAULT 1,
+status VARCHAR(20) NOT NULL,
+trigger_type VARCHAR(20) NOT NULL,
+requested_by BIGINT NULL,
+batch_id VARCHAR(100) NULL,
+transform_run_log_id BIGINT NULL,
+stage_rows INT NOT NULL DEFAULT 0,
+standard_rows INT NOT NULL DEFAULT 0,
+dwd_rows INT NOT NULL DEFAULT 0,
+kpi_rows INT NOT NULL DEFAULT 0,
+atomic_rows INT NOT NULL DEFAULT 0,
+error_message VARCHAR(2000) NULL,
+submitted_at DATETIME(3) NOT NULL,
+started_at DATETIME(3) NULL,
+finished_at DATETIME(3) NULL,
+heartbeat_at DATETIME(3) NULL,
+duration_ms INT NULL,
+create_time DATETIME(3) NOT NULL,
+update_time DATETIME(3) NOT NULL,
+KEY ix_s1_rebuild_status (status, submitted_at),
+KEY ix_s1_rebuild_scope (tenant_id, factory_id, status, submitted_at)
+```
+
+说明:
+
+- 完整 S1 MDP 当前是跨租户构建,首轮互斥必须是**全局 S1 单实例**,不能因不同租户各放行一条;
+- `tenant_id/factory_id/requested_by` 用于审计发起者,不代表完整 MDP 只处理该租户;
+- `mdp_transform_run_log` 继续记录底层转换;新表记录用户任务和 worker 生命周期;
+- 不建立数据库外键,避免迁移和历史数据阻塞。
+
+### 8.2 持久防重
+
+仅用进程内 `SemaphoreSlim` 不够,因为:
+
+- 多实例部署无法互斥;
+- 服务重启后状态丢失;
+- AUTO 与手工入口不一定经过同一实例锁。
+
+实现时选择一种数据库级方案,并写测试:
+
+1. MySQL `GET_LOCK('AIDOP:S1:MDP:FULL', 0)` / `RELEASE_LOCK`;或
+2. 独立锁表 + 唯一键 + 租约/心跳。
+
+首选 MySQL advisory lock,要求:
+
+- 锁连接在整个任务期间保持;
+- `finally` 释放;
+- 获取失败则任务保持 `QUEUED` 或标记冲突,不启动第二个全量任务;
+- AUTO、BOOTSTRAP、MANUAL 三种入口最终都必须经过同一锁服务。
+
+不得只在控制器层防重,因为定时任务会绕过控制器。
+
+### 8.3 服务重启和陈旧任务
+
+worker 启动时:
+
+1. 查询本表 `RUNNING`;
+2. 若无对应锁持有者且 `heartbeat_at` 超过阈值(建议 5 分钟),标记 `FAILED`;
+3. 错误摘要写“服务中断,任务未正常结束”;
+4. `QUEUED` 可重新领取;
+5. 不自动重跑原 `RUNNING`,避免重复全量写入。
+
+对旧 `mdp_transform_run_log`:
+
+- 仅处理 `job_code='S1_MDP_SYNC_TRANSFORM'`;
+- 只处理部署前已超时且确认无活动执行的 `RUNNING`;
+- 标记 `FAILED`,写清“HTTP 请求已取消/服务中断后的存量收口”;
+- SQL 必须幂等并记录执行前后的 ID;
+- 禁止触碰其它 job_code。
+
+---
+
+## 9. 工作包与顺序
+
+```text
+WP0 基线与失败复现
+ → WP1 S1 最新日目标快照轻量刷新
+ → WP2 前端“刷新”切换为轻量路径
+ → WP3 持久异步重算任务与全局防重
+ → WP4 前端“重算数据”进度交互
+ → WP5 取消/异常/重启收尾与存量治理
+ → WP6 回归、性能与 UAT
+```
+
+每个 WP 完成后先测试,再进入下一包。WP1~WP2 是 P0 最小闭环;WP3~WP5 不得反过来阻塞 P0 上线。
+
+---
+
+## WP0. 基线与失败复现
+
+### WP0-1 强制检查
+
+执行模型第一步必须:
+
+1. 阅读 `AGENTS.md` 和 `.cursor/rules/`;
+2. 执行 `git status --short`、`git diff`、`git log -10 --oneline`;
+3. 识别当前工作区已有未提交修改,不覆盖、不回退;
+4. 确认后端、前端实际版本;
+5. 确认 5005 当前运行版本与数据库迁移日志一致;
+6. 未经用户要求不 commit、不 push。
+
+### WP0-2 保存数据库基线
+
+```sql
+SELECT id, tenant_id, factory_id, metric_code, target_value,
+       effective_from, effective_to, source_type, status,
+       create_time, update_time
+FROM ado_smart_ops_kpi_target_config
+WHERE metric_code='S1_L1_001'
+ORDER BY effective_from DESC;
+
+SELECT id, tenant_id, factory_id, biz_date, metric_code,
+       metric_value, target_value, target_config_id,
+       target_source, target_resolved_at, update_time
+FROM ado_s9_kpi_value_l1_day
+WHERE module_code='S1' AND metric_code='S1_L1_001'
+ORDER BY biz_date DESC, tenant_id, factory_id;
+
+SELECT id, trigger_type, batch_id, status, start_time, end_time,
+       duration_ms, stage_rows, standard_rows, dwd_rows, error_message
+FROM mdp_transform_run_log
+WHERE job_code='S1_MDP_SYNC_TRANSFORM'
+ORDER BY id DESC
+LIMIT 20;
+```
+
+### WP0-3 锁定现有接口
+
+记录:
+
+- `POST /api/AidopKanban/mdp/refresh/S1` 当前同步行为;
+- `GET /api/AidopKanban/dashboard-page/S1` 正常返回;
+- S2~S7 刷新现状;
+- S1 全量任务正常成功所需时间;
+- 浏览器取消后存量 `RUNNING` 的复现步骤。
+
+---
+
+## WP1. S1 最新日目标快照轻量刷新
+
+### WP1-1 新增服务
+
+建议新增:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/S1KpiTargetSnapshotRefreshService.cs
+```
+
+职责单一:
+
+1. 输入服务端租户、工厂;
+2. 遍历 L1~L4 日值表;
+3. 每张表查询该租户、工厂、`module_code='S1'` 的 `MAX(biz_date)`;
+4. 读取该日期的有效行;
+5. 对每个不同 `metric_code` 调用一次 `IKpiTargetResolver.ResolveAsync`;
+6. 更新:
+   - `target_value`
+   - `target_config_id`
+   - `target_source`
+   - `target_resolved_at`
+   - `update_time`
+7. 不修改:
+   - `metric_value`
+   - `status_color`
+   - `trend_flag`
+   - `calc_time`
+   - `biz_date`
+   - 其它日期行。
+
+### WP1-2 更新规则
+
+- 解析日期必须用日值行自己的 `biz_date`,不是无条件 `DateTime.Today`;
+- 正式目标命中后写 `FORMAL_FACTORY` 或 `FORMAL_TENANT_DEFAULT`;
+- legacy 命中按解析器返回来源写入;
+- `MISSING` 不写库,累计 `missingRows`;
+- 同一个 metric 在同一层同一日期有多行时可批量更新,但 WHERE 必须含:
+  `tenant_id + factory_id + module_code + biz_date + metric_code + is_deleted=0`;
+- 四层更新使用事务;任一层失败整体回滚;
+- 不新增空日值,不回写更早日期。
+
+### WP1-3 新增控制器端点
+
+优先放在 `AidopKanbanController` 的独立 partial 文件,例如:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.S1Refresh.cs
+```
+
+要求:
+
+- 不加 `[AllowAnonymous]`;
+- 租户用 `AidopTenantScope.ResolveOrThrow`;
+- `factoryId` 必须大于 0,默认 1;
+- 不接受客户端 tenantId;
+- 记录耗时和更新数;
+- 不调用全量 MDP。
+
+### WP1-4 测试
+
+建议新增:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/S1KpiTargetSnapshotRefreshServiceTests.cs
+```
+
+至少覆盖:
+
+1. 正式工厂目标覆盖旧 legacy 快照;
+2. 工厂目标缺失时命中租户默认;
+3. `MISSING` 不覆盖原行;
+4. 只更新 MAX(biz_date);
+5. 不修改 metric_value;
+6. 租户 A 不修改租户 B;
+7. 工厂 1 不修改其它工厂;
+8. L1~L4 任一失败时事务回滚;
+9. 无日值返回 0,不报错。
+
+---
+
+## WP2. S1 前端“刷新”切换为轻量路径
+
+### WP2-1 API
+
+修改:
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+```
+
+新增显式函数:
+
+```text
+refreshS1TargetSnapshot(factoryId)
+```
+
+请求超时建议 10 秒;不得复用 180 秒的全量刷新函数。
+
+### WP2-2 模块能力配置
+
+修改:
+
+```text
+Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
+```
+
+建议给 `ModuleDashboardConfig` 增加:
+
+```ts
+quickRefresh?: () => Promise<ModuleRefreshResult>;
+rebuild?: () => Promise<RebuildAcceptedResult>;
+supportsAsyncRebuild?: boolean;
+```
+
+S1:
+
+```text
+quickRefresh = 目标快照轻量刷新
+supportsAsyncRebuild = true
+```
+
+S2~S7 保留原 `refreshMdp`,本轮行为不变。
+
+### WP2-3 组件行为
+
+修改:
+
+```text
+Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
+```
+
+S1 点击“刷新”:
+
+1. 防止按钮重复点击;
+2. 调 `quickRefresh`;
+3. 成功后 `reload()`;
+4. toast 显示“更新 N 条目标快照,耗时 N ms”;
+5. 失败必须结束 loading,并显示后端消息;
+6. 不调用 `refreshModuleMdp('S1')`。
+
+不得影响“查询”按钮和筛选条件。
+
+### WP2-4 P0 验收
+
+在 WP3 前必须先验收:
+
+- 新增 S1 正式目标;
+- 点击“刷新”;
+- 5 秒内看板目标变化;
+- 日值 `target_config_id` 非空;
+- `target_source='FORMAL_FACTORY'`;
+- `metric_value` 前后相同;
+- 没有新增 `S1_MDP_FULL_*` 运行日志。
+
+---
+
+## WP3. S1 持久异步重算与全局防重
+
+### WP3-1 新增任务实体、迁移和验证 SQL
+
+- 使用实际下一个后端版本号;
+- 迁移脚本必须幂等;
+- 增加 verify SQL;
+- 在 `Admin.NET.Web.Entry.csproj` 登记 CopyToOutput;
+- 后端 `<Version>/<AssemblyVersion>/<FileVersion>` 同步 patch +1;
+- 不修改已经执行过的 1.0.343/1.0.344 内容。
+
+### WP3-2 后台队列
+
+参考现有:
+
+```text
+DataPlatform/Executors/MdpOutboxPushWorker.cs
+DataPlatform/HotWatch/MdpHotWatchWorker.cs
+```
+
+新增建议:
+
+```text
+DataPlatform/S1Refresh/S1DashboardRebuildWorker.cs
+DataPlatform/S1Refresh/S1DashboardRebuildQueue.cs
+DataPlatform/S1Refresh/S1DashboardRebuildService.cs
+```
+
+要求:
+
+- 队列以数据库任务为事实源;
+- 内存 signal 只用于唤醒,不作为唯一队列;
+- API 创建 `QUEUED` 后立即返回;
+- worker 使用独立 DI scope;
+- 不使用请求 CancellationToken;
+- 服务停止时使用 worker stoppingToken;
+- 状态写回使用独立、有限时长 token 或无取消的短数据库操作;
+- `finally` 必须释放数据库锁。
+
+### WP3-3 统一全量入口
+
+当前这些入口都可能启动 S1:
+
+- 手工 API;
+- `S1MdpSyncTransformJob`;
+- `SmartOpsKpiMdpBootstrapJob`;
+- 订单评审触发链;
+- 其它直接调用 `RunFullAsync` 的路径。
+
+执行模型必须全仓检索:
+
+```text
+S1MdpSyncTransformService.RunFullAsync
+```
+
+所有完整 S1 MDP 入口最终都经过统一锁。禁止只给新 API 加锁。
+
+### WP3-4 兼容旧接口
+
+旧接口:
+
+```text
+POST /api/AidopKanban/mdp/refresh/S1
+POST /api/AidopKanban/s1-mdp/refresh
+```
+
+不得继续同步等待 10~20 分钟。二选一:
+
+1. 返回 202,内部转调创建异步任务;推荐;
+2. 返回 410/明确迁移提示;仅在确认无其它调用方时允许。
+
+S2~S7 分支保持原样。
+
+---
+
+## WP4. S1 “重算数据”前端交互
+
+### WP4-1 操作入口
+
+在 S1 看板工具栏新增“重算数据”,与“刷新”明确区分。
+
+文案:
+
+- 刷新:`更新目标并重新加载看板`
+- 重算数据:`重新拉取源数据并计算 S1 指标,预计 10~20 分钟`
+
+重算前使用确认框,说明:
+
+- 后台执行;
+- 可离开页面;
+- 不要重复提交;
+- 完成后再刷新看板。
+
+### WP4-2 状态展示
+
+至少显示:
+
+- 排队中;
+- 运行中 + 已运行时长;
+- 成功 + 行数摘要;
+- 失败 + 可读错误;
+- 最近完成时间。
+
+轮询建议:
+
+- 运行中每 5 秒;
+- 页面失焦可降为 15 秒;
+- SUCCESS/FAILED/CANCELLED 后停止;
+- 组件卸载时清 timer;
+- 页面重新进入时调用 latest 恢复。
+
+### WP4-3 成功后的行为
+
+任务成功后:
+
+1. 自动调用 `reload()`;
+2. 不再调用重算 API;
+3. 展示 batchId 和耗时;
+4. 若 reload 失败,只提示页面加载失败,不把已成功任务改成失败。
+
+---
+
+## WP5. 异常收尾与存量治理
+
+### WP5-1 修复失败日志写回
+
+检查:
+
+```text
+S1MdpSyncTransformService.MarkTransformRunFailedAsync
+```
+
+要求:
+
+- 原业务取消令牌不得阻止状态写回;
+- 状态更新设置短超时,避免无限等待;
+- 失败写回自身失败时输出结构化日志;
+- 运行记录不能因浏览器离开页面而永久 RUNNING。
+
+### WP5-2 存量清理
+
+部署前先只读确认:
+
+```sql
+SELECT id, batch_id, trigger_type, status, start_time, end_time
+FROM mdp_transform_run_log
+WHERE job_code='S1_MDP_SYNC_TRANSFORM'
+  AND status='RUNNING'
+ORDER BY start_time;
+```
+
+确认后台无活动 S1 执行后,才允许把部署前陈旧记录标记 FAILED。更新必须限定:
+
+```text
+job_code='S1_MDP_SYNC_TRANSFORM'
+status='RUNNING'
+start_time < 明确截止时间
+```
+
+不得使用无时间条件的全表 UPDATE。
+
+### WP5-3 服务重启测试
+
+1. 创建异步任务;
+2. 等状态进入 RUNNING;
+3. 正常停止后端;
+4. 重启;
+5. 验证旧任务在阈值后变 FAILED,不永久 RUNNING;
+6. 验证新任务可以再次创建;
+7. 验证没有两个全量任务同时执行。
+
+---
+
+## WP6. 验收
+
+### 6.1 后端测试
+
+至少:
+
+- 目标快照刷新单测全部通过;
+- 异步任务状态机测试;
+- 全局锁并发测试:20 个并发提交最多 1 个活动任务;
+- 请求取消后任务不被取消;
+- worker 停止后状态可收口;
+- 旧接口 S1 返回 202;
+- S2~S7 控制器回归。
+
+### 6.2 前端测试
+
+至少:
+
+- S1 刷新调用轻量 API,不调用全量 API;
+- loading 在成功/失败都关闭;
+- 重算按钮重复点击被拦截;
+- 409 恢复已有 jobId;
+- 页面重进恢复任务状态;
+- S2~S7 仍调用原刷新函数;
+- TypeScript 构建通过。
+
+### 6.3 UAT 场景
+
+#### UAT-1 新目标快速生效
+
+1. 记录 `S1_L1_001` 当前 metric 和 target;
+2. 新增有效正式目标;
+3. 点击 S1“刷新”;
+4. 5 秒内目标变化;
+5. metric 不变;
+6. 只更新最新业务日;
+7. 来源为 `FORMAL_FACTORY`。
+
+#### UAT-2 不回写历史
+
+对比刷新前后所有较早 `biz_date` 的 target,必须完全一致。
+
+#### UAT-3 完整重算异步
+
+1. 点击“重算数据”;
+2. API 2 秒内返回 202;
+3. 页面显示 QUEUED/RUNNING;
+4. 刷新浏览器,状态恢复;
+5. 任务最终 SUCCESS;
+6. 看板自动重载。
+
+#### UAT-4 防重
+
+在两个浏览器同时点击重算:
+
+- 只生成一个活动任务;
+- 另一个收到 409 和当前 jobId;
+- 数据库只有一个 S1 全量执行。
+
+#### UAT-5 失败与重启
+
+模拟源端失败和服务停止,任务最终不得永久 RUNNING。
+
+### 6.4 性能门槛
+
+- 轻量刷新 API:P95 ≤ 2 秒,硬上限 5 秒;
+- 创建重算任务 API:P95 ≤ 1 秒,硬上限 2 秒;
+- 状态查询 API:P95 ≤ 500ms;
+- 全量任务耗时本轮不设优化目标,但必须完整记录;
+- 轻量刷新不得访问外部源数据库。
+
+---
+
+## 10. 验收 SQL
+
+### 10.1 目标快照是否更新
+
+```sql
+SELECT biz_date, metric_code, metric_value, target_value,
+       target_config_id, target_source, target_resolved_at, update_time
+FROM ado_s9_kpi_value_l1_day
+WHERE tenant_id=797403760988229
+  AND factory_id=1
+  AND module_code='S1'
+  AND metric_code='S1_L1_001'
+ORDER BY biz_date DESC;
+```
+
+### 10.2 刷新是否误触发全量任务
+
+```sql
+SELECT id, trigger_type, batch_id, status, start_time
+FROM mdp_transform_run_log
+WHERE job_code='S1_MDP_SYNC_TRANSFORM'
+ORDER BY id DESC
+LIMIT 10;
+```
+
+点击“刷新”前后不应增加记录;点击“重算数据”后应增加一条。
+
+### 10.3 是否存在多个活动任务
+
+```sql
+SELECT status, COUNT(*) AS c
+FROM ado_s1_dashboard_rebuild_job
+WHERE status IN ('QUEUED','RUNNING')
+GROUP BY status;
+```
+
+活动任务合计不得大于 1。
+
+### 10.4 是否仍有陈旧运行记录
+
+```sql
+SELECT id, batch_id, trigger_type, start_time,
+       TIMESTAMPDIFF(MINUTE, start_time, NOW()) AS running_minutes
+FROM mdp_transform_run_log
+WHERE job_code='S1_MDP_SYNC_TRANSFORM'
+  AND status='RUNNING'
+ORDER BY start_time;
+```
+
+无实际 worker 时不得存在超过阈值的 RUNNING。
+
+---
+
+## 11. 预计文件清单
+
+### 后端新增
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/S1KpiTargetSnapshotRefreshService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoS1DashboardRebuildJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildWorker.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildQueue.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.S1Refresh.cs
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/S1KpiTargetSnapshotRefreshServiceTests.cs
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/S1DashboardRebuildServiceTests.cs
+server/Admin.NET.Web.Entry/UpdateScripts/<next-version>.sql
+server/Admin.NET.Web.Entry/UpdateScripts/<next-version>.verify.sql
+```
+
+### 后端修改
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Job/S1MdpSyncTransformJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Job/SmartOpsKpiMdpBootstrapJob.cs
+server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
+```
+
+执行者必须通过全仓检索补齐所有 `RunFullAsync` 直接调用点,不得只按此静态清单机械修改。
+
+### 前端修改
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
+Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
+Web/package.json
+```
+
+若任务状态 UI 代码超过约 150 行,应拆:
+
+```text
+Web/src/views/aidop/kanban/components/S1RebuildStatus.vue
+```
+
+---
+
+## 12. 版本、构建与提交
+
+### 12.1 版本
+
+本任务同时修改前后端运行代码:
+
+- `Web/package.json` patch +1;
+- `Admin.NET.Web.Entry.csproj` 三个版本 patch +1;
+- 前端锁文件若随版本变化则同步更新;
+- 迁移文件使用后端实际新版本。
+
+### 12.2 构建
+
+至少执行:
+
+```text
+dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug -f net10.0
+pnpm --dir Web build
+```
+
+若全量测试有既存失败,必须单独列出;不得把新失败写成既存问题。
+
+### 12.3 重启验证
+
+后端构建成功后:
+
+1. 确认迁移脚本复制到输出目录;
+2. 停止旧 5005 进程;
+3. 启动新版本;
+4. 从启动日志确认迁移 Success;
+5. 再执行 UAT;
+6. 不得只看到 DLL 或端口存在就声称生效。
+
+### 12.4 Git
+
+- 用户未说“提交”时不得 commit;
+- 用户说“提交”时按仓库规则执行 commit + push;
+- 提交前检查全部工作区改动,避免夹带凭据、日志、构建产物和其他人的未完成修改。
+
+---
+
+## 13. 停止条件
+
+执行者遇到以下情况必须停止并报告,不得猜测:
+
+1. 发现完整 S1 MDP 实际必须按租户运行,与当前全局任务事实冲突;
+2. 数据库不允许 `GET_LOCK`,且没有可安全落地的租约锁;
+3. 发现 S2~S7 依赖 S1 旧同步接口的响应体或同步完成语义;
+4. 目标快照更新必须改变 metric/status 才能保证业务正确;
+5. 工作区已有相同文件的未提交并行改动;
+6. 迁移版本已被占用;
+7. 需要修改 165 数据库对象;
+8. 任何方案会导致历史日值随当前目标漂移。
+
+---
+
+## 14. 交付报告模板
+
+执行完成后按以下格式汇报:
+
+```text
+完成:
+- WP0 ...
+- WP1 ...
+
+关键结果:
+- S1 轻量刷新耗时:
+- 更新目标快照行数:
+- 完整重算 jobId / batchId / 耗时:
+- 防重验证:
+- 陈旧 RUNNING 收口:
+
+测试:
+- 后端:
+- 前端:
+- UAT:
+
+版本:
+- Web:
+- Server:
+- 数据库迁移:
+
+未完成/风险:
+- ...
+
+Git:
+- 未提交 / 已 commit + push(哈希)
+```
+
+---
+
+## 15. 可直接复制给执行模型的提示词
+
+```text
+请严格执行:
+doc/plan/S1看板刷新机制修复执行任务书_20260815.md
+
+要求:
+1. 先阅读 AGENTS.md、.cursor/rules 和任务书,检查 git status/diff/log。
+2. 动手前按协作规则列出拟改文件、跨模块影响和明确不做项,等我确认。
+3. 按 WP0→WP6 顺序,小步实现、小步测试。
+4. P0 先完成“S1 最新日目标快照轻量刷新 + 前端刷新切换”,不得用调大 HTTP timeout 冒充修复。
+5. 完整 MDP 必须异步持久化、全局防重,并与浏览器请求 CancellationToken 解耦。
+6. S2~S7 行为首轮保持不变。
+7. 不处理 excludeKpiId 和 Monaco 警告,不回写历史目标,不修改 165。
+8. 使用实际下一个版本号,完成构建、迁移、后端重启和 UAT。
+9. 未经我明确说“提交”,不得 commit/push。
+```

+ 1749 - 0
doc/plan/S1看板重算进度体验优化执行任务书_20260815.md

@@ -0,0 +1,1749 @@
+# S1 看板“重算数据”租户隔离与进度体验优化执行任务书
+
+> 日期:2026-08-15  
+> 执行对象:供其他大模型或协作者直接照章实施。  
+> 优先级:P0(当前手工重算实际会处理全部租户且使用环境级全局锁,违反客户工厂租户隔离;进度体验必须在租户隔离修复后交付)。  
+> 前置任务:`doc/plan/S1看板刷新机制修复执行任务书_20260815.md` 已实施。  
+> 当前基线:后端 `1.0.345`、前端 `2.4.296`。执行时必须以仓库实际版本和 Git 状态为准。
+
+---
+
+## 1. 任务目标
+
+在不改变 S1 指标计算公式的前提下,先把 S1 全量重算改为严格的租户/工厂级处理,再把“重算数据”从简单状态提示升级为可信、可恢复的后台任务体验:
+
+1. A 租户、工厂 1 发起重算时,只允许读取、转换和写回 A 租户、工厂 1 的数据;
+2. B 租户可以独立发起自己的重算,不被 A 租户的任务锁、任务状态或进度阻塞;
+3. 同一租户、同一工厂只能有一个活动重算任务;
+4. 点击“重算数据”后,先展示明确的确认弹窗;
+5. 弹窗说明将执行的动作、目标租户/工厂、预计总耗时和后台执行特性;
+6. 用户明确选择“确认开始”或“取消”;
+7. 确认后立即返回任务标识,后台继续执行;
+8. 页面展示任务卡片、阶段进度条、当前阶段、已耗时、预计剩余时间提示;
+9. 发起浏览器关闭或刷新后可恢复其关注的任务;同租户其他浏览器必须在用户主动点击“重算数据”后才接管查看;
+10. 成功后显示批次、总耗时和各阶段处理行数,并自动刷新看板;
+11. 失败后显示失败阶段和可读错误,可重新发起任务;
+12. 进度必须来自后端真实阶段切换,禁止用前端计时器伪造持续增长的百分比。
+
+---
+
+## 2. 当前实现基线
+
+### 2.1 已有能力
+
+当前已经具备:
+
+- S1 工具栏“重算数据”按钮;
+- 操作前 `ElMessageBox.confirm`;
+- `POST /api/AidopKanban/s1/rebuild-jobs` 创建任务;
+- `GET /api/AidopKanban/s1/rebuild-jobs/latest` 恢复最近任务;
+- `GET /api/AidopKanban/s1/rebuild-jobs/{jobId}` 查询状态;
+- `QUEUED / RUNNING / SUCCESS / FAILED / CANCELLED` 状态机;
+- 前台每 5 秒、页面失焦每 15 秒轮询;
+- 完成后自动重载看板;
+- 持久任务表 `ado_s1_dashboard_rebuild_job`;
+- S1 全量任务环境级全局锁和后台 worker。
+
+### 2.2 当前不足
+
+当前 UI 只显示一条状态 Alert:
+
+- 排队中;
+- 运行中,预计 10~20 分钟;
+- 成功或失败。
+
+当前任务表没有:
+
+- 当前业务阶段;
+- 阶段序号;
+- 预计进度百分比;
+- 阶段说明;
+- 最近一次进度更新时间;
+- 失败时所在阶段。
+
+当前 `stage_rows / standard_rows / dwd_rows / kpi_rows / atomic_rows` 主要在任务结束后一次性写入,运行期间无法反映已完成阶段。
+
+### 2.3 已核实的 P0 租户隔离缺陷
+
+当前代码存在以下事实:
+
+1. `S1MdpSyncTransformService.RunFullAsync` 不接收 `tenantId/factoryId`;
+2. `SyncStagingAsync` 固定调用 `tenantId: 0, fullRefresh: true`;
+3. 标准层和 DWD 的 `INSERT ... SELECT` 没有限定当前租户/工厂;
+4. KPI 计算按所有 `tenant_id` 分组,会重算全部租户;
+5. `BuildOrderDeliveryDomainForAllDatesAsync` 枚举全部日期;
+6. Atomic 构建按领域和日期全局 DELETE,再重建所有租户;
+7. `FindActiveAsync / ClaimNextQueuedAsync / GetLatestAsync / GetByIdAsync` 当前没有完整的租户/工厂作用域;
+8. 锁名固定为 `S1_MDP_FULL`,所有租户共享一把锁;
+9. 当前 worker 是单路消费,即使数据处理改为租户级,不改调度仍会让不同租户互相排队。
+
+因此现有“重算数据”不能视为租户隔离功能。执行模型必须先完成 WP0A,不得只增加进度字段和前端卡片。
+
+---
+
+## 3. 设计原则
+
+### 3.1 真实阶段,不伪造精确进度
+
+S1 全量任务不是按固定行数匀速执行,无法提供可信的逐行百分比。因此采用:
+
+```text
+真实阶段状态 + 阶段权重预计百分比 + 已耗时
+```
+
+进度条必须标注为“预计进度”或通过提示说明“按处理阶段估算”。
+
+禁止:
+
+- 前端每秒自动增加百分比;
+- 任务未切换阶段却把进度平滑推进到下一阶段;
+- 把“运行时间占预计 20 分钟的比例”当作真实完成比例;
+- 到 99% 后无限等待却仍显示“即将完成”。
+
+### 3.2 数据库任务是事实源
+
+- 数据库任务行是状态和进度的唯一事实源;
+- 内存队列只负责唤醒 worker;
+- 页面轮询只读取后端任务状态;
+- 页面关闭不得影响任务;
+- 后端重启后不得依赖浏览器恢复任务状态。
+
+### 3.3 租户/工厂隔离是硬边界
+
+- 每次完整重算必须有明确、可信的 `tenantId + factoryId`;
+- 手工 API 的租户只能来自认证 JWT,不能接受客户端 tenantId;
+- `factoryId` 必须验证属于当前租户;
+- 入站、标准层、DWD、KPI、Atomic、日志、任务和锁必须使用同一作用域;
+- 任一 SQL 缺少作用域条件都视为阻塞缺陷;
+- A 租户任务不得读取、删除、更新或重建 B 租户数据;
+- 不同租户/工厂使用不同锁键,允许逻辑并行;
+- 同租户、同工厂的不同账号和不同电脑共用同一活动任务;
+- 不允许用“环境全局串行”冒充租户隔离;
+- 如果共享源端或 staging 无法安全并行,必须先改造成按租户过滤和批次隔离,不能退回全局重算。
+
+### 3.4 S1 专属能力配置
+
+共享组件 `DynamicModuleDashboard.vue` 被 S1~S7 使用。本任务只能通过 `moduleDashboardRegistry.ts` 的可选能力启用 S1 任务卡片。
+
+不得在共享组件中散落:
+
+```ts
+if (moduleCode === 'S1')
+```
+
+S2~S7 的刷新按钮、接口、文案和行为必须保持不变。
+
+---
+
+## 4. 用户交互规格
+
+## 4.1 确认弹窗
+
+点击 S1“重算数据”后,显示标题:
+
+```text
+确认重算 S1 数据?
+```
+
+弹窗正文必须明确说明:
+
+```text
+本操作将依次执行:
+1. 重新拉取 S1 源数据
+2. 标准化数据
+3. 生成 DWD 明细
+4. 重算 KPI
+5. 重建原子数据
+
+作用范围:仅重算当前租户、当前工厂的 S1 数据。
+预计总耗时:10~20 分钟。
+任务将在后台执行,期间可以离开本页面。
+同一租户、同一工厂同时只能运行一个 S1 全量重算任务,请勿重复提交。
+```
+
+按钮:
+
+- 主按钮:`确认开始`
+- 次按钮:`取消`
+
+行为:
+
+- 点击取消或关闭弹窗:不得创建任务;
+- 点击确认:按钮进入提交 loading;
+- API 返回 202:关闭弹窗并显示任务卡片;
+- API 返回 409:只可能是当前租户/工厂已有活动任务;恢复后端返回的 `jobId`,显示现有任务卡片;
+- 网络错误:结束 loading,保留页面,显示可读错误;
+- 不允许连续点击产生多个请求。
+
+不同租户的活动任务不得导致当前租户收到 409。
+
+弹窗内容不得使用不受控 HTML 字符串。优先使用 Vue VNode、独立组件或安全的纯文本布局。
+
+## 4.2 后台任务卡片
+
+建议新增:
+
+```text
+Web/src/views/aidop/kanban/components/S1RebuildProgressCard.vue
+```
+
+任务卡片放在看板头部操作区下方、基础查询区上方或现有状态 Alert 所在位置。
+
+运行时至少展示:
+
+- 标题:`S1 数据重算`
+- 状态标签:排队中 / 运行中 / 已完成 / 失败 / 已取消
+- 当前阶段名称;
+- `阶段 N / 5`;
+- 标有“预计进度”的进度条;
+- 已耗时;
+- 预计总时长或阶段剩余时间提示;
+- `jobId`;
+- 有值时显示 `batchId`;
+- 提示:`任务在后台执行,可以离开本页面`
+- 最近更新时间。
+
+成功时展示:
+
+- `100%`
+- 总耗时;
+- batchId;
+- 拉取、标准层、DWD、KPI、原子层处理行数;
+- `看板已自动刷新`;
+- 完成时间。
+
+失败时展示:
+
+- 失败状态;
+- 失败阶段;
+- 错误摘要;
+- 已耗时;
+- “重新重算”按钮;
+- 不自动清除失败信息。
+
+取消时展示:
+
+- 已取消状态;
+- 取消原因;
+- 允许重新发起。
+
+## 4.3 进度条状态
+
+| 任务状态 | 进度条表现 |
+|---|---|
+| QUEUED | 0%,不确定动画或排队提示 |
+| RUNNING | 使用后端 `progressPercent` |
+| SUCCESS | 100%,success |
+| FAILED | 停留在最后可信进度,exception |
+| CANCELLED | 停留在最后可信进度,warning |
+
+当前阶段内部没有更细粒度数据时,百分比保持不变,并通过动态条纹/动画表示“该阶段仍在执行”,不得自行递增。
+
+## 4.4 时间展示
+
+已耗时:
+
+- RUNNING 时由 `startedAt` 与客户端当前时间计算,仅用于显示;
+- SUCCESS/FAILED/CANCELLED 优先使用后端 `durationMs`;
+- 显示格式:`2分35秒`、`12分08秒`,不显示纯大毫秒数。
+
+预计剩余时间:
+
+- 必须明确是估算;
+- 后端没有可靠估算时,显示 `预计总耗时 10~20 分钟`;
+- 可按当前阶段显示范围提示,如 `通常还需约 3~8 分钟`;
+- 不得显示精确到秒的虚假剩余时间;
+- 已超过 20 分钟时显示 `已超过常规耗时,任务仍在后台执行`,不要显示负数。
+
+建议阶段提示:
+
+| 阶段 | 剩余时间提示 |
+|---|---|
+| QUEUED / ACQUIRING_LOCK | 等待当前资源释放 |
+| STAGING | 通常还需约 8~18 分钟 |
+| STANDARD | 通常还需约 5~12 分钟 |
+| DWD | 通常还需约 3~8 分钟 |
+| KPI | 通常还需约 2~6 分钟 |
+| ATOMIC | 通常还需约 1~5 分钟 |
+| FINALIZING | 即将完成 |
+
+以上仅作 UI 提示,不写入业务事实字段;若实测明显不符,应按最近成功任务数据调整范围。
+
+## 4.5 进度卡片可见性与“主动接管”
+
+进度数据属于租户内可见数据,但页面不能因为同租户其他用户发起任务就自动弹出卡片。
+
+规则:
+
+1. 发起任务的浏览器在 202 后立即显示卡片,并保存“正在关注的 jobId”;
+2. 同一浏览器关闭或刷新页面后,可根据本地保存的 jobId 恢复;
+3. 同租户其他账号、其他电脑或其他浏览器仅打开 S1 页面时,不自动调用 latest 展示任务;
+4. 同租户其他用户主动点击“重算数据”后,如果当前租户/工厂已有活动任务,后端返回 409 和现有 jobId,此时前端才显示并接管该任务进度;
+5. 不同租户可以创建自己的任务,不返回 409,也绝不能获得对方 jobId;
+6. 本地保存的 jobId 必须按当前认证租户、工厂和模块分区;切换租户时不得沿用;
+7. 后端仍需对每次 jobId 查询做租户/工厂鉴权,不能信任 localStorage;
+8. 当前租户查不到、无权访问或任务已被清理时,前端删除本地关注记录。
+
+“同租户可见”不等于“进入页面自动展示”。用户主动点击是其他终端接管显示的触发点。
+
+---
+
+## 5. 后端进度模型
+
+## 5.0 P0 租户级重算架构
+
+### 5.0.1 统一运行作用域
+
+新增不可为空的运行作用域,例如:
+
+```csharp
+public sealed record S1MdpRunScope(long TenantId, long FactoryId);
+```
+
+要求:
+
+- `TenantId > 0`;
+- `FactoryId > 0`;
+- 手工请求由 `AidopTenantScope.ResolveOrThrow` 生成;
+- 不允许手工重算传 `tenantId=0`;
+- batchId、运行日志和结构化日志必须带作用域;
+- `RunFullAsync` 必须接收 scope,所有内部阶段继续传递同一个 scope。
+
+建议签名:
+
+```csharp
+Task<S1MdpSyncTransformResult> RunFullAsync(
+    S1MdpRunScope scope,
+    CancellationToken cancellationToken = default,
+    string triggerType = "AUTO",
+    long? rebuildJobId = null,
+    Func<S1MdpProgressUpdate, Task>? reportProgress = null);
+```
+
+不允许保留一个默认 `tenantId=0` 的完整重算入口。AUTO、BOOTSTRAP、订单评审和 LinkagePlan 等调用方必须解析明确作用域后调用。
+
+### 5.0.2 入站隔离
+
+`SyncStagingAsync` 必须改为:
+
+```text
+tenantId = scope.TenantId
+factoryId = scope.FactoryId(执行器上下文若尚无该字段则补齐)
+fullRefresh = 仅当前作用域全量
+```
+
+执行模型必须向下审计:
+
+```text
+MdpModuleStagingPuller
+MdpSourcePullDispatcher
+IMdpSourcePullExecutor / MdpPullContext
+DB/API 拉取执行器
+MdpStagingWriter
+```
+
+硬要求:
+
+- 源查询实际包含租户条件,不能只把 tenantId 写进上下文却仍读取全部源行;
+- fullRefresh 只能清理或覆盖当前租户/工厂数据;
+- staging 的 DELETE、UPSERT、游标和同步日志必须带作用域;
+- 源表没有可信 tenant_id/factory_id 时必须停止并报告,不能把源行强行归给当前租户;
+- join 必须包含 tenant_id,适用时同时包含 factory_id,禁止仅用业务单号跨租户关联;
+- 两租户使用相同订单号、物料号时不得串租。
+
+### 5.0.3 标准层与 DWD 隔离
+
+`BuildStandardCommands` 和 `BuildDwdCommands` 的每一条 SQL 都必须:
+
+- 参数包含 `@TenantId / @FactoryId`;
+- 源表 WHERE 限定当前作用域;
+- 多表 JOIN 包含租户条件;
+- 目标 UPSERT 的唯一键能区分租户;
+- 不删除、不覆盖其它租户批次;
+- 返回行数仅统计当前作用域。
+
+不得只在最外层方法过滤结果;过滤必须进入每条实际读写 SQL。
+
+### 5.0.4 KPI 隔离
+
+`CalculateS1KpiValuesAsync` 必须只聚合当前作用域:
+
+```text
+WHERE tenant_id=@TenantId
+  AND COALESCE(NULLIF(factory_id,0),1)=@FactoryId
+```
+
+所有 UNION 分支都必须有相同作用域条件。
+
+`UpsertS1KpiValueAsync` 必须:
+
+- 只解析当前租户/工厂目标;
+- 只更新当前租户/工厂日值;
+- 不遍历或写回其它租户;
+- 行数统计仅属于当前任务。
+
+### 5.0.5 Atomic 隔离
+
+禁止继续由手工租户任务调用全局:
+
+```text
+BuildOrderDeliveryDomainForAllDatesAsync(batchId)
+```
+
+新增明确作用域的方法,例如:
+
+```csharp
+BuildOrderDeliveryDomainForAllDatesAsync(
+    long tenantId,
+    long factoryId,
+    string? batchId,
+    CancellationToken cancellationToken = default);
+```
+
+要求:
+
+- 日期只从当前租户/工厂事实表查询;
+- DELETE 必须含 `tenant_id + factory_id + domain_code + stat_date`;
+- INSERT SELECT 必须限定当前租户/工厂;
+- 只重建当前作用域历史日期;
+- A 租户执行前后,B 租户 Atomic 行数、校验和、batchId、calc_time 均不变化。
+
+### 5.0.6 任务防重和锁
+
+任务活动判断必须从环境级改为:
+
+```text
+tenant_id + factory_id + status IN ('QUEUED','RUNNING')
+```
+
+Store 方法应显式接收作用域:
+
+```csharp
+FindActiveAsync(tenantId, factoryId)
+GetByIdAsync(jobId, tenantId, factoryId)
+GetLatestAsync(tenantId, factoryId)
+ClaimNextQueuedAsync(...)
+```
+
+锁键改为:
+
+```text
+S1_MDP_FULL:{tenantId}:{factoryId}
+```
+
+`ado_s1_mdp_run_lock` 允许每个作用域一行:
+
+- 同租户/工厂只能持有一把;
+- 不同租户使用不同 lock_name;
+- holder、jobId、心跳和释放都必须匹配完整 lock_name;
+- 迁移时保留或清理旧 `S1_MDP_FULL` 行必须有明确策略;
+- 不能因 A 的锁让 B 返回 409。
+
+### 5.0.7 多租户 worker
+
+当前单 reader 顺序执行会让不同租户互相排队,需改为受控并行调度:
+
+- 同一租户/工厂最大并行数 1;
+- 不同租户/工厂允许并行;
+- 使用独立 DI scope 和独立数据库连接;
+- 配置 `S1Rebuild:MaxParallelTenants`,默认至少 2;
+- 服务停止时等待或收口所有在途任务;
+- 数据库连接池、源端限流和 worker 并发必须有上限;
+- 只有在入站、STD、DWD、KPI、Atomic 全部证明作用域安全后才能开启并行;
+- A/B 并行测试未通过前不得上线。
+
+### 5.0.8 AUTO 与 BOOTSTRAP
+
+系统自动任务不能再调用一次“全租户 RunFull”。
+
+正确方式:
+
+1. 查询已启用的租户/工厂作用域;
+2. 为每个作用域独立创建或调度任务;
+3. 每个作用域使用独立任务记录和锁;
+4. 某租户失败不得阻止其它租户;
+5. 日志必须能按 tenantId/factoryId 查询;
+6. 不允许使用 `tenantId=0` 代表“遍历所有租户”进入业务 SQL。
+
+若自动任务是否应覆盖全部租户尚无产品规则,必须停止并请负责人确认;手工任务的租户隔离不因此降级。
+
+## 5.1 阶段定义
+
+新增统一阶段常量,禁止前后端各自随意拼字符串。
+
+建议阶段码:
+
+```text
+QUEUED
+ACQUIRING_LOCK
+PREPARING
+STAGING
+STANDARD
+DWD
+KPI
+ATOMIC
+FINALIZING
+SUCCESS
+FAILED
+CANCELLED
+```
+
+五个用户可见业务阶段:
+
+| stageIndex | currentStage | 中文名 |
+|---:|---|---|
+| 1 | STAGING | 拉取源数据 |
+| 2 | STANDARD | 标准化数据 |
+| 3 | DWD | 生成 DWD 明细 |
+| 4 | KPI | 重算 KPI |
+| 5 | ATOMIC | 重建原子数据 |
+
+`ACQUIRING_LOCK / PREPARING / FINALIZING` 是辅助阶段,不增加 `stageIndex` 的总数,`stageTotal` 固定为 5。
+
+## 5.2 阶段权重
+
+采用固定、可解释的阶段权重:
+
+| 事件 | progressPercent |
+|---|---:|
+| 已入队 | 0 |
+| worker 已领取、等待锁 | 2 |
+| 准备运行环境 | 5 |
+| 开始 STAGING | 10 |
+| 完成 STAGING | 35 |
+| 完成 STANDARD | 50 |
+| 完成 DWD | 65 |
+| 完成 KPI | 80 |
+| 开始 ATOMIC | 82 |
+| 完成 ATOMIC | 98 |
+| FINALIZING | 99 |
+| SUCCESS | 100 |
+
+这些值表示阶段权重,不表示精确行处理比例。
+
+约束:
+
+- 正常运行时百分比只能单调不减;
+- 范围必须为 0~100;
+- RUNNING 不得写 100;
+- SUCCESS 必须写 100;
+- FAILED/CANCELLED 保留最后可信百分比;
+- 重试或重新排队时应回到合理阶段,不得沿用错误的 99%。
+
+## 5.3 任务表字段
+
+在 `ado_s1_dashboard_rebuild_job` 增加:
+
+```sql
+current_stage            VARCHAR(32)  NOT NULL DEFAULT 'QUEUED'
+stage_index              TINYINT      NOT NULL DEFAULT 0
+stage_total              TINYINT      NOT NULL DEFAULT 5
+progress_percent         TINYINT      NOT NULL DEFAULT 0
+progress_message         VARCHAR(255) NULL
+last_progress_at         DATETIME(3)  NULL
+failed_stage             VARCHAR(32)  NULL
+```
+
+现有 `heartbeat_at` 继续使用,不重复新增。
+
+不要新增:
+
+- 逐秒进度历史表;
+- 每次轮询日志;
+- 前端专用 JSON 大字段;
+- 与 `mdp_transform_run_log` 重复的全量运行日志。
+
+## 5.4 实体与 DTO
+
+修改:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoS1DashboardRebuildJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildStore.cs
+```
+
+实体和 `S1RebuildJobDto` 增加:
+
+```text
+CurrentStage
+StageIndex
+StageTotal
+ProgressPercent
+ProgressMessage
+LastProgressAt
+FailedStage
+```
+
+API JSON 继续使用 camelCase:
+
+```json
+{
+  "jobId": 123,
+  "status": "RUNNING",
+  "currentStage": "KPI",
+  "stageIndex": 4,
+  "stageTotal": 5,
+  "progressPercent": 65,
+  "progressMessage": "正在重算 KPI",
+  "lastProgressAt": "2026-08-15 01:30:00",
+  "failedStage": null
+}
+```
+
+已有字段不得删除或改名,保持向后兼容。
+
+---
+
+## 6. 后端进度写入
+
+## 6.1 独立进度更新方法
+
+在任务存储增加列级更新方法,避免并发整行覆盖:
+
+```csharp
+Task UpdateProgressAsync(
+    long jobId,
+    string currentStage,
+    int stageIndex,
+    int progressPercent,
+    string message,
+    DateTime now,
+    CancellationToken ct = default);
+
+Task UpdateStageResultAsync(
+    long jobId,
+    string completedStage,
+    int rows,
+    int progressPercent,
+    string nextMessage,
+    DateTime now,
+    CancellationToken ct = default);
+
+Task TouchHeartbeatAsync(
+    long jobId,
+    DateTime now,
+    CancellationToken ct = default);
+```
+
+更新必须带:
+
+```text
+id = @jobId
+status = 'RUNNING'
+```
+
+要求:
+
+- 使用列级 UPDATE;
+- 不使用 `_db.Updateable(row)` 频繁整行覆盖;
+- 进度更新失败应记录结构化日志;
+- 单次短暂更新失败不应直接终止 MDP;
+- 连续多次更新失败可告警,但不能用失败的进度写回掩盖原业务异常。
+
+## 6.2 进度回调契约
+
+新增轻量进度事件模型,例如:
+
+```csharp
+public sealed record S1MdpProgressUpdate(
+    string Stage,
+    int StageIndex,
+    int ProgressPercent,
+    string Message,
+    int? Rows = null);
+```
+
+`S1MdpSyncTransformService.RunFullAsync` 增加可选进度回调或 reporter,必须保持现有调用点兼容。
+
+推荐形式之一:
+
+```csharp
+Task<S1MdpSyncTransformResult> RunFullAsync(
+    CancellationToken cancellationToken = default,
+    string triggerType = "AUTO",
+    long? rebuildJobId = null,
+    Func<S1MdpProgressUpdate, Task>? reportProgress = null);
+```
+
+约束:
+
+- 新参数放末尾并提供默认值,避免破坏现有调用;
+- AUTO、BOOTSTRAP、订单评审等非任务入口可以不传 reporter;
+- 只有异步重算 worker 传入 `rebuildJobId` 和 reporter;
+- 全仓检索所有 `RunFullAsync` 调用点并完成编译验证;
+- 全量锁的 `job_id` 应在异步重算时写入任务 ID,便于排障。
+
+## 6.3 阶段切换点
+
+必须在真实调用边界上报,禁止仅按时间上报:
+
+```text
+准备运行环境前          → PREPARING / 5
+SyncStagingAsync 前     → STAGING / 10
+SyncStagingAsync 后     → STAGING 完成 / 35,并写 stageRows
+TransformStandardAsync 后 → STANDARD 完成 / 50,并写 standardRows
+BuildDwdAsync 后        → DWD 完成 / 65,并写 dwdRows
+BuildS1KpiValuesAsync 后 → KPI 完成 / 80,并写 kpiRows
+AtomicBuild 前          → ATOMIC / 82
+AtomicBuild 后          → ATOMIC 完成 / 98,并写 atomicRows
+成功收尾前              → FINALIZING / 99
+任务成功                → SUCCESS / 100
+```
+
+如果某阶段抛异常:
+
+- `failed_stage` 写当前阶段;
+- `status='FAILED'`;
+- `progress_percent` 保持最后可信值;
+- `progress_message` 写“在【阶段中文名】失败”;
+- `error_message` 保留清洗和截断后的原始错误摘要。
+
+## 6.4 心跳
+
+阶段可能持续数分钟,不能只在阶段切换时更新心跳。
+
+异步 worker 领取任务后启动独立心跳循环:
+
+- 每 30 秒更新任务行 `heartbeat_at`;
+- 只更新 `id + status='RUNNING'` 的任务;
+- 使用 worker `stoppingToken` 控制循环;
+- 任务完成、失败或取消时在 `finally` 停止心跳;
+- 心跳写入使用独立 DI scope 或安全的短连接;
+- 心跳失败记录警告,不覆盖业务异常;
+- 不与全量锁心跳混为同一字段或同一 SQL。
+
+全量锁自身的心跳必须继续保留,避免长阶段超过租约后被第二任务抢锁。
+
+## 6.5 状态收尾
+
+成功:
+
+```text
+status = SUCCESS
+current_stage = SUCCESS
+stage_index = 5
+progress_percent = 100
+progress_message = S1 数据重算已完成
+failed_stage = NULL
+finished_at / heartbeat_at / last_progress_at = now
+```
+
+失败:
+
+```text
+status = FAILED
+current_stage = FAILED
+failed_stage = 最后执行阶段
+progress_percent = 最后可信值
+progress_message = 在【阶段名】失败
+```
+
+服务停止:
+
+```text
+status = CANCELLED
+current_stage = CANCELLED
+progress_message = 服务停止,任务已取消
+```
+
+锁冲突重新排队:
+
+```text
+status = QUEUED
+current_stage = ACQUIRING_LOCK
+stage_index = 0
+progress_percent = 2
+progress_message = 等待现有 S1 全量任务完成
+started_at = NULL
+```
+
+不得出现:
+
+- `SUCCESS + progressPercent < 100`
+- `RUNNING + progressPercent = 100`
+- `FAILED` 但 `failedStage` 丢失
+- 任务结束后心跳仍持续写入
+
+---
+
+## 7. API 要求
+
+接口路径保持不变:
+
+```text
+POST /api/AidopKanban/s1/rebuild-jobs
+GET  /api/AidopKanban/s1/rebuild-jobs/latest
+GET  /api/AidopKanban/s1/rebuild-jobs/{jobId}
+```
+
+### 7.1 创建任务
+
+202 示例:
+
+```json
+{
+  "ok": true,
+  "jobId": 123,
+  "status": "QUEUED",
+  "message": "S1 数据重算已排队"
+}
+```
+
+409 示例:
+
+```json
+{
+  "ok": false,
+  "jobId": 122,
+  "status": "RUNNING",
+  "message": "S1 数据重算正在执行,请勿重复提交"
+}
+```
+
+409 只检查当前认证租户和 factoryId。必须返回当前作用域的活动任务 ID,前端用它恢复进度卡片。
+
+若其它租户正在运行而当前租户没有活动任务,当前请求应正常创建并返回 202。
+
+### 7.2 查询任务
+
+运行中示例:
+
+```json
+{
+  "jobId": 123,
+  "status": "RUNNING",
+  "currentStage": "DWD",
+  "stageIndex": 3,
+  "stageTotal": 5,
+  "progressPercent": 50,
+  "progressMessage": "正在生成 DWD 明细",
+  "submittedAt": "2026-08-15 01:00:00",
+  "startedAt": "2026-08-15 01:00:05",
+  "finishedAt": null,
+  "durationMs": null,
+  "lastProgressAt": "2026-08-15 01:07:30",
+  "heartbeatAt": "2026-08-15 01:07:35",
+  "failedStage": null,
+  "batchId": "S1_MDP_FULL_20260815010005",
+  "stageRows": 1200,
+  "standardRows": 800,
+  "dwdRows": 0,
+  "kpiRows": 0,
+  "atomicRows": 0,
+  "errorMessage": null
+}
+```
+
+安全要求:
+
+- 继续使用 `[Authorize]`;
+- 租户来自认证上下文;
+- 创建、查询和 latest 都必须带当前认证租户及 factoryId;
+- 查询 jobId 时必须验证任务属于当前租户/工厂;
+- `latest` 必须只返回当前租户/工厂可见任务;
+- 不允许租户 A 通过 jobId 查看租户 B 的进度和错误。
+- 不允许客户端传 tenantId;
+- factoryId 必须校验归属;
+- A 租户存在活动任务时,B 租户创建任务不得收到 A 的 jobId 或 409。
+
+执行时必须检查当前 Store 是否已有租户过滤。若没有,应在本任务一并补齐,这是安全边界,不属于扩展范围。
+
+---
+
+## 8. 前端实施
+
+## 8.1 API 类型
+
+修改:
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+```
+
+扩展 `S1RebuildJob`:
+
+```ts
+currentStage?: string;
+stageIndex?: number;
+stageTotal?: number;
+progressPercent?: number;
+progressMessage?: string;
+lastProgressAt?: string;
+heartbeatAt?: string;
+failedStage?: string;
+```
+
+已有字段保持兼容。
+
+## 8.2 独立进度组件
+
+新增:
+
+```text
+Web/src/views/aidop/kanban/components/S1RebuildProgressCard.vue
+```
+
+建议 Props:
+
+```ts
+job: S1RebuildJob | null;
+polling: boolean;
+```
+
+建议 Emits:
+
+```ts
+retry
+refresh
+```
+
+组件职责:
+
+- 状态标签;
+- 阶段中文映射;
+- 阶段 N/5;
+- `el-progress`;
+- 已耗时格式化;
+- 预计剩余范围提示;
+- 行数摘要;
+- batchId / jobId;
+- 成功、失败、取消样式;
+- 重新重算入口。
+
+父组件职责:
+
+- 弹窗;
+- 创建任务;
+- 轮询;
+- 本浏览器关注任务恢复;
+- 用户主动点击后的 409 接管;
+- 成功后 reload;
+- timer 生命周期。
+
+不要把 API 请求封装进纯展示组件。
+
+## 8.3 模块能力配置
+
+扩展 `ModuleDashboardConfig` 可选能力,例如:
+
+```ts
+supportsAsyncRebuildProgress?: boolean;
+```
+
+只有 S1 开启。
+
+共享组件通过能力配置渲染 `S1RebuildProgressCard`,不得影响 S2~S7。
+
+## 8.4 轮询
+
+保留现有节奏:
+
+- 前台可见:5 秒;
+- 页面隐藏:15 秒;
+- 终态停止;
+- 组件卸载清理;
+- 页面重新进入只恢复本浏览器已保存且属于当前租户/工厂的关注 jobId;
+- 未保存关注 jobId 时不得自动调用 latest 展示同租户其他用户任务;
+- 轮询失败不立即把任务改为 FAILED;
+- 连续查询失败时显示“暂时无法获取最新进度,后台任务可能仍在运行”;
+- 网络恢复后继续查询。
+
+避免重复提示:
+
+- 只有状态从非 SUCCESS 变为 SUCCESS 时弹一次成功 toast;
+- 只有状态首次变为 FAILED 时弹一次失败 toast;
+- 页面恢复一个历史 SUCCESS 时不重复弹“刚刚完成”;
+- 每次 poll 不重复调用 reload。
+
+## 8.5 页面恢复
+
+进入 S1 看板:
+
+1. 读取按 `tenantId + factoryId + moduleCode` 分区的本地关注 jobId;
+2. 没有关注 jobId:不显示卡片,也不因同租户其他用户任务自动展示;
+3. 有关注 jobId:调用 jobId 查询接口,后端再次做租户/工厂鉴权;
+4. QUEUED/RUNNING:显示卡片并开始轮询;
+5. SUCCESS:显示完成结果,不轮询;
+6. FAILED/CANCELLED:显示失败或取消信息,允许重试;
+7. 若任务在页面离开期间完成,恢复后刷新一次看板;
+8. 404/403/作用域不符:清理本地关注记录。
+
+同租户其它浏览器的接管流程:
+
+1. 初次进入页面不显示卡片;
+2. 用户主动点击“重算数据”并确认;
+3. 后端按当前租户/工厂发现活动任务,返回 409 和 jobId;
+4. 前端保存该 jobId、显示进度卡片并开始轮询;
+5. 不创建第二个任务。
+
+`latest` 可保留为当前租户/工厂的受权接口,但不得在无用户触发、无本地关注记录时自动用于展示。
+
+需要使用本地关注标记或状态转换判断,避免每次进入历史 SUCCESS 都重复 reload。
+
+---
+
+## 9. 数据库迁移
+
+### 9.1 版本
+
+当前后端基线为 `1.0.345`。若执行时未被其它任务占用,使用:
+
+```text
+server/Admin.NET.Web.Entry/UpdateScripts/1.0.346.sql
+server/Admin.NET.Web.Entry/UpdateScripts/1.0.346.verify.sql
+```
+
+若实际版本已前进,必须使用新的下一个 patch,禁止覆盖或修改已执行迁移。
+
+迁移需:
+
+- 幂等检查字段是否存在;
+- 为现有任务回填合理默认值;
+- 增加或验证 `(tenant_id, factory_id, status, submitted_at)` 索引;
+- 将锁表从单一固定锁行改为允许 `S1_MDP_FULL:{tenantId}:{factoryId}` 多作用域锁行;
+- 旧 `S1_MDP_FULL` 锁行只有在确认无旧任务运行后才可释放或删除;
+- 在 `.csproj` 登记 CopyToOutput;
+- verify 检查字段存在、范围合法;
+- 不修改 165 外部数据库。
+
+### 9.2 存量回填
+
+建议:
+
+```text
+QUEUED    → current_stage=QUEUED, progress=0
+RUNNING   → current_stage=PREPARING, progress=5
+SUCCESS   → current_stage=SUCCESS, progress=100, stage_index=5
+FAILED    → current_stage=FAILED,failed_stage 无法判断时为 NULL
+CANCELLED → current_stage=CANCELLED
+```
+
+不要为历史失败任务猜测具体失败阶段。
+
+### 9.3 验证约束
+
+至少验证:
+
+```sql
+progress_percent BETWEEN 0 AND 100
+stage_index BETWEEN 0 AND stage_total
+stage_total = 5
+SUCCESS 行 progress_percent = 100
+```
+
+---
+
+## 10. 工作包
+
+## WP0:基线与风险检查
+
+1. 阅读本任务书和前置任务书;
+2. 检查 `git status / diff / log`;
+3. 确认当前前后端版本;
+4. 确认迁移版本未被占用;
+5. 记录当前任务表结构;
+6. 记录一次现有重算任务的状态变化;
+7. 全仓检索 `S1MdpSyncTransformService.RunFullAsync` 调用点;
+8. 逐层列出 STAGING、STD、DWD、KPI、Atomic 的租户/工厂过滤现状;
+9. 确认工作区是否已有同文件并行改动。
+
+交付:
+
+- 基线说明;
+- 调用点清单;
+- 预计改动文件;
+- 冲突和停止条件判断。
+
+## WP0A:P0 租户/工厂隔离修复
+
+必须先于进度功能完成:
+
+1. 引入强制 `S1MdpRunScope`;
+2. 手工入口只从 JWT 获取 tenantId;
+3. 校验 factoryId 归属;
+4. 入站实际按租户/工厂过滤;
+5. fullRefresh 只作用当前作用域;
+6. 所有 STD SQL 增加作用域条件和租户 JOIN;
+7. 所有 DWD SQL 增加作用域条件和租户 JOIN;
+8. 所有 KPI UNION 分支增加作用域条件;
+9. Atomic 日期查询、DELETE、INSERT 全部限定作用域;
+10. 任务查重改为 tenantId + factoryId;
+11. 锁改为作用域锁键;
+12. worker 支持不同作用域受控并行;
+13. AUTO/BOOTSTRAP 改为逐作用域调度;
+14. jobId/latest 查询按租户/工厂鉴权;
+15. 完成 A/B 租户隔离测试。
+
+完成标准:
+
+- A 重算只改变 A 数据;
+- B 数据行数、值、批次、更新时间和校验和不变;
+- A/B 可各有一个活动任务;
+- A 同租户第二次提交返回 409 和 A 的 jobId;
+- B 首次提交返回 202,不受 A 阻塞;
+- 相同业务单号在 A/B 间不串联;
+- 任一阶段不再接受 tenantId=0;
+- WP0A 未通过时停止,不实施或交付进度 UI。
+
+## WP1:任务进度数据模型与迁移
+
+1. 新增阶段常量和进度事件模型;
+2. 扩展任务实体;
+3. 增加迁移和 verify;
+4. 存量状态回填;
+5. 扩展 DTO;
+6. 补租户过滤检查;
+7. 单测实体映射和 DTO。
+
+完成标准:
+
+- 迁移可重复验证;
+- 原有任务查询兼容;
+- 不出现非法百分比;
+- 跨租户任务不可读取。
+
+## WP2:全量 MDP 真实阶段上报
+
+1. `RunFullAsync` 增加兼容的可选 reporter;
+2. 在五个真实阶段边界上报;
+3. 每阶段完成写入处理行数;
+4. 失败保留阶段;
+5. 异步重算时把 jobId 传入全量锁;
+6. 非异步调用不受影响;
+7. 全仓编译。
+
+完成标准:
+
+- 进度按真实阶段单调推进;
+- 阶段异常可定位;
+- 每个进度事件带正确 tenantId/factoryId/jobId;
+- AUTO/BOOTSTRAP/订单评审等调用仍可编译运行。
+
+## WP3:任务心跳与可靠收尾
+
+1. 新增 job 心跳循环;
+2. 列级更新心跳和进度;
+3. 终态停止心跳;
+4. 查询失败和进度写入失败日志;
+5. 服务停止收口;
+6. 同作用域锁冲突回到排队状态;
+7. stale 任务治理回归。
+
+完成标准:
+
+- 长阶段执行时 `heartbeat_at` 每 30 秒更新;
+- 任务结束后不再更新;
+- 服务停止后不永久 RUNNING;
+- 当前租户/工厂锁不因长阶段过期而被同作用域任务抢占;
+- 不同租户使用不同锁键并可独立运行。
+
+## WP4:确认弹窗与进度卡片
+
+1. 完善确认弹窗内容;
+2. 新增独立进度卡片组件;
+3. 扩展前端类型;
+4. 阶段中文映射;
+5. 预计进度条;
+6. 已耗时和预计剩余范围;
+7. 成功行数摘要;
+8. 失败阶段和重试入口;
+9. 配置化启用,仅 S1 生效。
+
+完成标准:
+
+- 取消不发请求;
+- 202 显示任务卡片;
+- 当前租户/工厂 409 恢复已有任务;
+- 其它租户任务不会触发当前租户 409;
+- 进度条不伪造;
+- S2~S7 页面无变化。
+
+## WP5:轮询、恢复和异常体验
+
+1. 保留 5 秒/15 秒轮询;
+2. 防重复 timer;
+3. 防重复 toast;
+4. 防重复 reload;
+5. 本浏览器关注任务页面重进恢复;
+6. 同租户其它浏览器仅在主动点击后通过 409 接管;
+7. 网络失败友好提示;
+8. 终态停止轮询;
+9. 卸载清理。
+
+完成标准:
+
+- 刷新浏览器后任务不丢;
+- 无本地关注记录时不自动展示其他用户任务;
+- 离开页面后后台继续;
+- 网络暂断不误报 FAILED;
+- 成功后只自动刷新一次。
+
+## WP6:测试、构建、迁移与 UAT
+
+1. 后端单测;
+2. 前端测试;
+3. 后端完整构建;
+4. 前端生产构建;
+5. 执行迁移并确认 verify Success;
+6. 重启后端;
+7. 执行本任务 UAT;
+8. 记录耗时、阶段变化和截图;
+9. 按实际提交范围递增版本。
+
+---
+
+## 11. 测试要求
+
+### 11.1 后端单测
+
+至少覆盖:
+
+1. 新任务初始 `QUEUED / 0%`;
+2. 阶段按顺序推进;
+3. 进度百分比单调不减;
+4. 百分比不会超过 100;
+5. 阶段完成立即写入对应 rows;
+6. STAGING 失败时 `failedStage=STAGING`;
+7. FAILED 保留最后可信进度;
+8. SUCCESS 固定为 100;
+9. worker 心跳启动和停止;
+10. 锁冲突重新排队;
+11. 同租户/工厂 20 个并发提交最多一个活动任务;
+12. 查询 jobId 有租户隔离;
+13. latest 有租户隔离;
+14. 服务停止标 CANCELLED;
+15. A/B 租户可以各有一个活动任务;
+16. A 重算不改变 B 的 STG/STD/DWD/KPI/Atomic;
+17. 相同业务键的两租户不会 JOIN 串租;
+18. Atomic DELETE 只删除当前租户/工厂;
+19. fullRefresh 只清理当前租户/工厂;
+20. AUTO/BOOTSTRAP 按作用域调度;
+21. 非异步入口必须传明确 scope,不允许默认 tenantId=0。
+
+建议扩展:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/S1DashboardRebuildServiceTests.cs
+```
+
+必要时新增:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/S1MdpProgressReporterTests.cs
+```
+
+### 11.2 前端测试
+
+至少覆盖:
+
+1. 弹窗展示五项动作和预计 10~20 分钟;
+2. 取消不调用创建 API;
+3. 确认只调用一次;
+4. 202 显示 QUEUED;
+5. 409 恢复已有 jobId;
+6. RUNNING 渲染后端百分比;
+7. 前端不自动增加百分比;
+8. SUCCESS 显示 100 和行数摘要;
+9. FAILED 显示失败阶段;
+10. 发起浏览器页面重进恢复;
+11. 同租户另一浏览器进入页面时不自动显示;
+12. 同租户另一浏览器主动点击后通过 409 接管;
+13. 切换租户后不恢复原租户本地 jobId;
+14. A 任务不阻止 B 创建任务;
+15. 终态停止轮询;
+16. S2~S7 不展示重算进度卡片;
+17. TypeScript 构建通过。
+
+---
+
+## 12. UAT 场景
+
+### UAT-1 取消确认
+
+1. 点击“重算数据”;
+2. 检查动作列表和预计耗时;
+3. 点击取消;
+4. 数据库不得新增任务;
+5. 不出现任务卡片。
+
+### UAT-2 确认入队
+
+1. 点击确认开始;
+2. 创建 API 2 秒内返回;
+3. 页面显示 `QUEUED`;
+4. 显示“后台执行,可以离开页面”;
+5. 按钮不得重复提交。
+
+### UAT-3 阶段进度
+
+观察一次完整重算:
+
+```text
+QUEUED
+→ PREPARING
+→ STAGING
+→ STANDARD
+→ DWD
+→ KPI
+→ ATOMIC
+→ FINALIZING
+→ SUCCESS
+```
+
+检查:
+
+- 进度不倒退;
+- 阶段序号不超过 5;
+- 每阶段名称与实际调用一致;
+- 阶段完成后出现对应行数;
+- SUCCESS 为 100%。
+
+### UAT-4 页面恢复
+
+1. 任务进入 RUNNING;
+2. 关闭或刷新页面;
+3. 重新进入 S1 看板;
+4. 发起该任务的浏览器根据本地关注记录恢复同一 jobId;
+5. 显示当前真实阶段;
+6. 不创建新任务。
+
+### UAT-5 后台执行
+
+1. 任务运行时离开 S1 页面;
+2. 等待一段时间;
+3. 查询数据库确认任务继续推进;
+4. 返回页面恢复最新状态。
+
+### UAT-6 同租户主动接管与防重
+
+租户 A 的两个浏览器:
+
+1. 浏览器 1 发起任务并显示进度;
+2. 浏览器 2 只打开页面,不自动显示进度卡片;
+3. 浏览器 2 主动点击“重算数据”并确认;
+4. 浏览器 2 收到 409;
+5. 两边显示同一个活动 jobId;
+6. 数据库中租户 A、同工厂只有一个 S1 全量运行。
+
+### UAT-7 跨租户独立任务
+
+准备租户 A、租户 B,并尽量准备相同订单号/物料号测试数据:
+
+1. A 发起重算并进入 RUNNING;
+2. B 此时发起自己的重算;
+3. B 必须返回 202,不得因 A 返回 409;
+4. A/B 获得不同 jobId、batchId 和作用域锁;
+5. A/B 可以同时处于 RUNNING;
+6. A 页面不能查询 B jobId;
+7. B 页面不能查询 A jobId;
+8. 两租户相同业务键不得串联或覆盖。
+
+### UAT-8 A 重算不改变 B
+
+执行前记录 B 租户以下数据的行数、关键值、batchId、update_time 和校验和:
+
+```text
+S1 涉及的 mdp_stg_*
+S1 涉及的 mdp_std_*
+S1 DWD 表
+ado_s9_kpi_value_l1_day ~ l4_day
+ado_smart_ops_kpi_atomic_day
+```
+
+只执行 A 租户重算,完成后:
+
+- A 数据按预期更新;
+- B 的上述数据完全不变;
+- Atomic 不发生 B 租户 DELETE/重建;
+- B 的目标解析、KPI、批次和时间戳不变化。
+
+### UAT-9 失败阶段
+
+在测试环境模拟某阶段失败:
+
+- 状态为 FAILED;
+- failedStage 正确;
+- 进度停留在最后可信值;
+- 错误摘要可读;
+- 页面允许重新发起;
+- 不永久 RUNNING。
+
+### UAT-10 长阶段心跳
+
+让单个阶段运行超过 5 分钟:
+
+- job `heartbeat_at` 持续更新;
+- 当前租户/工厂锁不会被同作用域第二任务抢占;
+- 同一租户/工厂不产生两个 S1 全量批次并发;
+- 其它租户使用独立锁并可正常运行;
+- 任务完成后心跳停止。
+
+---
+
+## 13. 验收 SQL
+
+### 13.1 查看任务进度
+
+```sql
+SELECT id, tenant_id, factory_id, status,
+       current_stage, stage_index, stage_total, progress_percent,
+       progress_message, failed_stage,
+       stage_rows, standard_rows, dwd_rows, kpi_rows, atomic_rows,
+       submitted_at, started_at, finished_at,
+       heartbeat_at, last_progress_at, duration_ms,
+       batch_id, error_message
+FROM ado_s1_dashboard_rebuild_job
+ORDER BY id DESC
+LIMIT 10;
+```
+
+### 13.2 非法进度
+
+```sql
+SELECT id, status, current_stage, stage_index, stage_total, progress_percent
+FROM ado_s1_dashboard_rebuild_job
+WHERE progress_percent < 0
+   OR progress_percent > 100
+   OR stage_index < 0
+   OR stage_index > stage_total
+   OR (status='SUCCESS' AND progress_percent<>100)
+   OR (status='RUNNING' AND progress_percent=100);
+```
+
+期望 0 行。
+
+### 13.3 租户/工厂活动任务防重
+
+```sql
+SELECT tenant_id, factory_id, COUNT(*) AS active_count
+FROM ado_s1_dashboard_rebuild_job
+WHERE status IN ('QUEUED','RUNNING')
+GROUP BY tenant_id, factory_id
+HAVING COUNT(*) > 1;
+```
+
+期望 0 行。不同 tenant_id/factory_id 各有一个活动任务是合法状态。
+
+### 13.4 心跳新鲜度
+
+```sql
+SELECT id, status, current_stage, heartbeat_at,
+       TIMESTAMPDIFF(SECOND, heartbeat_at, NOW(3)) AS heartbeat_age_seconds
+FROM ado_s1_dashboard_rebuild_job
+WHERE status='RUNNING';
+```
+
+正常运行时心跳年龄应通常小于 90 秒。
+
+### 13.5 锁与任务关联
+
+```sql
+SELECT lock_name, holder_id, job_id, status, acquired_at, heartbeat_at
+FROM ado_s1_mdp_run_lock
+WHERE lock_name LIKE 'S1_MDP_FULL:%'
+ORDER BY lock_name;
+```
+
+异步重算运行时:
+
+- `lock_name` 必须包含任务的 tenantId/factoryId;
+- `job_id` 应等于对应作用域活动任务 ID;
+- 不得继续持有旧环境级 `S1_MDP_FULL` 锁;
+- 不同租户应有不同 lock_name。
+
+### 13.6 跨租户写入审计
+
+执行 A 租户任务前后,对 B 租户各业务表执行行数、最大更新时间和校验和对比。示例:
+
+```sql
+SELECT tenant_id, factory_id,
+       COUNT(*) AS c,
+       MAX(update_time) AS max_update_time,
+       BIT_XOR(CAST(CRC32(CONCAT_WS('|',
+           tenant_id, factory_id, metric_code, biz_date,
+           IFNULL(metric_value,''), IFNULL(target_value,''),
+           IFNULL(calc_batch_id,''))) AS UNSIGNED)) AS checksum_value
+FROM ado_s9_kpi_value_l1_day
+WHERE tenant_id IN (@TenantA, @TenantB)
+GROUP BY tenant_id, factory_id;
+```
+
+执行者必须为 STG、STD、DWD、L1~L4 KPI 和 Atomic 选择稳定业务列生成同类对比,不能只检查任务表。
+
+---
+
+## 14. 性能与可靠性门槛
+
+- 创建任务 API:P95 ≤ 1 秒,硬上限 2 秒;
+- 查询任务 API:P95 ≤ 500ms;
+- 进度数据库写入:单次目标 ≤ 200ms;
+- 前台轮询:5 秒;
+- 后台页面轮询:15 秒;
+- job 心跳:30 秒;
+- 正常运行时心跳年龄:不超过 90 秒;
+- 进度更新不得显著增加全量任务总耗时;
+- 不允许逐秒写数据库;
+- 不允许因进度写入暂时失败而中断核心 MDP;
+- 不允许通过缩短全量阶段或跳过数据来满足进度体验。
+
+---
+
+## 15. 预计文件清单
+
+### 后端新增(按实际设计决定)
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1MdpProgressUpdate.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildProgressReporter.cs
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/S1MdpProgressReporterTests.cs
+server/Admin.NET.Web.Entry/UpdateScripts/<next-version>.sql
+server/Admin.NET.Web.Entry/UpdateScripts/<next-version>.verify.sql
+```
+
+### 后端修改
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoS1DashboardRebuildJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildStore.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1DashboardRebuildWorker.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S1Refresh/S1MdpFullRunLock.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpModuleStagingPuller.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/IMdpSourcePullExecutor.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpSourcePullDispatcher.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpStagingWriter.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicBuildService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Job/S1MdpSyncTransformJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Job/SmartOpsKpiMdpBootstrapJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/S1DashboardRebuildServiceTests.cs
+server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
+```
+
+执行模型必须通过全仓检索补齐实际涉及的 DB/API executor 和全部 S1 `RunFullAsync` 调用方;上述清单不是跳过调用点审计的理由。
+
+### 前端新增
+
+```text
+Web/src/views/aidop/kanban/components/S1RebuildProgressCard.vue
+```
+
+### 前端修改
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
+Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
+Web/package.json
+```
+
+若新增前端测试,放入项目现有测试目录,不要另建测试框架。
+
+---
+
+## 16. 跨模块影响
+
+### 16.1 S1 全量调用链
+
+修改 `S1MdpSyncTransformService.RunFullAsync` 会影响:
+
+- S1 手工异步重算;
+- S1 定时任务;
+- SmartOps 启动 BOOTSTRAP;
+- 订单评审触发;
+- LinkagePlan 等直接调用点。
+
+处理要求:
+
+- reporter 必须可选;
+- scope 必须强制,不允许可选;
+- 非异步任务不要求创建看板重算 job,但必须解析明确 tenantId/factoryId;
+- 所有调用点完成编译和行为回归;
+- 不改变各调用方原 triggerType;
+- AUTO/BOOTSTRAP 若覆盖多个租户,必须拆为逐作用域调度。
+
+### 16.2 共享看板组件
+
+`DynamicModuleDashboard.vue` 被 S1~S7 共用。
+
+处理要求:
+
+- 通过 registry 能力开关启用;
+- 只有 S1 渲染任务卡片;
+- S2~S7 原刷新接口和 loading 逻辑不变;
+- S9 不新增重算入口。
+
+### 16.3 MDP 共享执行器与 Atomic
+
+租户过滤可能影响共享:
+
+- `MdpModuleStagingPuller`;
+- DB/API source executor;
+- `MdpStagingWriter`;
+- 游标和同步日志;
+- `SmartOpsKpiAtomicBuildService`。
+
+处理要求:
+
+- 公共能力增加可选或强制 scope 时,必须回归其它模块;
+- 不能为了 S1 破坏 S2~S7 入站;
+- Atomic 新增租户级方法,保留其它模块确有需要的现有方法,但 S1 手工路径禁止调用全局方法;
+- 公共 executor 的 fullRefresh 语义必须有单测证明不会跨租户清理。
+
+### 16.4 数据库和部署
+
+- 修改 Ai-DOP 主库任务、锁及 S1 数据处理 SQL;
+- 不修改 165;
+- 不修改 S1 业务指标口径;
+- 不重算历史日值;
+- 不改变源端表;
+- 新迁移必须在部署前核对版本占用。
+
+---
+
+## 17. 明确不做
+
+本任务不做:
+
+- 不优化 S1 全量 MDP 的 10~20 分钟总耗时;
+- 不改变 15 个实体的业务映射口径,但必须补齐租户/工厂过滤;
+- 不修改 KPI 计算公式;
+- 不修改看板目标快照刷新机制;
+- 不回写历史目标;
+- 不改变 S2~S7 刷新语义;
+- 不新增菜单或 FUNC 编号;
+- 不增加 WebSocket/SSE;当前继续使用轮询;
+- 不实现用户主动取消正在执行的全量 MDP;
+- 不处理 Monaco Worker 或 `excludeKpiId` 警告;
+- 不用虚假平滑动画冒充真实进度。
+
+如需 WebSocket/SSE 或“取消任务”,必须另立任务评估线程安全和数据库事务影响。
+
+---
+
+## 18. 停止条件
+
+遇到以下情况必须停止并报告,不得猜测:
+
+1. 当前 `1.0.345` 改动尚未稳定或实际未部署成功;
+2. 下一个迁移版本已被占用;
+3. 同一文件存在未识别的并行改动;
+4. 任一 S1 源表无法可信识别 tenantId/factoryId;
+5. generic executor 的 fullRefresh 无法安全限定当前作用域;
+6. 标准层/DWD 唯一键不包含租户且无法安全迁移;
+7. Atomic 无法做到按租户/工厂 DELETE 和重建;
+8. A/B 租户相同业务键会发生跨租户 JOIN;
+9. S1 实际阶段边界与本任务书不一致;
+10. 进度回调需要改变业务计算顺序;
+11. 为获得进度必须修改 165;
+12. 查询任务接口无法安全实现租户隔离;
+13. 受控多租户并行导致连接池或源端容量不可接受;
+14. 心跳与作用域锁产生死锁或连接池耗尽;
+15. S2~S7 会被共享组件改动改变行为;
+16. 必须伪造百分比才能满足 UI。
+
+---
+
+## 19. 构建、版本与 Git
+
+### 19.1 版本
+
+本任务会修改前后端运行代码:
+
+- 前端 `Web/package.json` patch +1;
+- 后端 `.csproj` 的 `Version / AssemblyVersion / FileVersion` patch +1;
+- 锁文件若随版本变化则同步;
+- 迁移版本与后端版本一致。
+
+执行时以实际版本为准,不得机械使用文档中的预估版本。
+
+### 19.2 至少执行
+
+```text
+dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj -f net10.0
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug -f net10.0
+pnpm --dir Web build
+```
+
+构建后:
+
+1. 确认迁移脚本复制到输出目录;
+2. 停旧后端;
+3. 启动新版本;
+4. 确认迁移和 verify Success;
+5. 执行 UAT;
+6. 不得只看到 DLL 存在就声称生效。
+
+### 19.3 Git
+
+- 用户未说“提交”时不得 commit/push;
+- 用户说“提交”时按仓库规则执行 commit + push;
+- 提交前检查全部工作区,避免夹带其它任务、日志、token、构建产物;
+- 若工作区还有前置任务未提交改动,应先明确提交边界。
+
+---
+
+## 20. 交付报告模板
+
+```text
+完成:
+- WP0 ...
+- WP1 ...
+- WP2 ...
+
+关键结果:
+- A/B 租户隔离:
+- A 任务对 B 数据校验和影响:
+- 不同租户并行:
+- jobId:
+- batchId:
+- 阶段流转:
+- 总耗时:
+- 进度写入次数:
+- 最大心跳年龄:
+- 防重结果:
+- 页面恢复结果:
+
+测试:
+- 后端:
+- 前端:
+- UAT:
+
+版本:
+- Web:
+- Server:
+- 数据库迁移:
+
+未完成/风险:
+- ...
+
+Git:
+- 未提交 / 已 commit + push(哈希)
+```
+
+---
+
+## 21. 可直接复制给执行模型的提示词
+
+```text
+请严格执行:
+doc/plan/S1看板重算进度体验优化执行任务书_20260815.md
+
+要求:
+1. 先阅读 AGENTS.md、.cursor/rules、本任务书和前置任务书。
+2. 检查 git status/diff/log、实际版本、迁移占用和全部 RunFullAsync 调用点。
+3. 动手前列出拟改文件、跨模块影响和明确不做项,等我确认。
+4. 按 WP0→WP0A→WP1→WP2→WP3→WP4→WP5→WP6 小步实现、小步测试;WP0A 未通过时不得继续交付进度 UI。
+5. 每次 S1 完整重算必须有明确 tenantId/factoryId;禁止 tenantId=0 的全租户手工重算。
+6. STAGING、STD、DWD、KPI、Atomic 每层都必须限定当前租户/工厂;Atomic 禁止全局 DELETE。
+7. 同租户/工厂防重;不同租户使用不同锁和任务,允许受控并行,A 不能阻塞或改写 B。
+8. 同租户其它浏览器只有在用户主动点击并收到 409 后才接管显示,不得进入页面自动展示。
+9. 进度必须来自后端真实阶段边界,禁止前端计时伪造百分比。
+10. 数据库任务是事实源,内存 signal 不是任务状态来源。
+11. 任务心跳与作用域锁心跳都必须可靠,长阶段不能导致同作用域锁被抢占。
+12. 查询 jobId/latest 必须按认证租户和工厂隔离。
+13. 共享看板通过能力配置启用,仅 S1 展示,S2~S7 行为不变。
+14. 不优化全量算法、不修改 165、不回写历史、不处理无关警告。
+15. 使用实际下一个版本号,完成测试、构建、迁移、重启和 A/B 租户 UAT。
+16. 未经我明确说“提交”,不得 commit/push。
+```

+ 1520 - 0
doc/plan/S2-S7及S9看板刷新与重算统一架构执行任务书_20260815.md

@@ -0,0 +1,1520 @@
+# S2–S7 及 S9 看板刷新与重算统一架构执行任务书
+
+> 日期:2026-08-15  
+> 状态:WP0–WP9 已落地,API/SQL/浏览器 UAT 已留证  
+> 执行对象:供其他大模型或协作者逐工作包直接实施  
+> 优先级:P0(S2~S7 当前“刷新”仍同步执行长耗时全量任务,且多数完整管线尚未实现严格租户/工厂隔离)  
+> 目标策略:统一目标架构,分波迁移;禁止一次性“大爆炸”替换  
+> 行为基线:`doc/plan/S1看板刷新机制修复执行任务书_20260815.md`、`doc/plan/S1看板重算进度体验优化执行任务书_20260815.md`  
+> 当前参考版本:后端 `1.0.347`、前端 `2.4.298`;留证见 `doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/`
+
+---
+
+## 0. 执行须知
+
+### 0.1 开工前必须做
+
+1. 阅读 `AGENTS.md`、`.cursor/rules/`、本任务书及两份 S1 前置任务书;
+2. 检查 `git status --short`、`git diff`、`git log -10 --oneline`;
+3. 识别并保护工作区已有改动,禁止覆盖、回退或夹带其他任务;
+4. 记录前后端实际版本、当前运行进程版本及 `sys_db_migration_log`;
+5. 全仓检索 S1~S7 所有 `RunFullAsync`、手工刷新、AUTO、BOOTSTRAP 和业务触发调用点;
+6. 动手前列出本工作包的目标、文件、可见行为、跨模块影响、风险和明确不做项,等待负责人确认;
+7. 一次只实施一个 WP;每个 WP 构建、测试和留证通过后再进入下一 WP;
+8. 未经用户明确说“提交”,不得 commit/push;用户说“提交”时按仓库规则执行 commit + push。
+
+### 0.2 完成定义
+
+代码存在不等于完成。每个模块必须同时具备:
+
+- 真实租户/工厂隔离;
+- 轻量刷新与全量重算语义分离;
+- 持久异步任务、作用域锁、真实阶段进度和可靠收尾;
+- 后端单测、前端测试/构建、迁移验证;
+- A/B 租户运行 UAT 与 SQL/日志/截图证据;
+- AUTO、BOOTSTRAP、旧接口和业务触发入口收口。
+
+任一 SQL、锁、任务查询或完整重算入口仍可能跨租户时,该模块不得标记完成,不得仅交付进度 UI。
+
+---
+
+## 1. 任务目标
+
+在不改变 S1~S7 既有业务指标公式、不修改 165 外部数据库对象的前提下:
+
+1. 建立 S1~S7 共用的模块级异步重算框架;
+2. 统一作用域为 `moduleCode + tenantId + factoryId`;
+3. 同一模块、租户、工厂最多一个活动任务;
+4. 不同租户/工厂可受控并行,A 不得读取、改写或锁住 B;
+5. S2~S7 将现有同步全量“刷新”迁为独立的“重算数据”;
+6. S1~S7 的“刷新”只做秒级目标快照更新与看板重载;
+7. S1~S7 重算均显示可折叠真实阶段进度卡;
+8. 发起浏览器可恢复本地关注任务;
+9. 同租户其他浏览器进入页面时不自动显示任务,仅在主动点击重算并收到 409 后接管;
+10. AUTO、BOOTSTRAP 和业务触发统一进入持久任务框架;
+11. S1 保持现有用户体验,最后一波平滑迁入通用框架;
+12. S9 仅提供轻量刷新,不显示“重算数据”或进度卡;
+13. S8 完全不在本任务范围内。
+
+---
+
+## 2. 已核实的当前基线
+
+### 2.1 前端
+
+共享入口:
+
+```text
+Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
+Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
+Web/src/views/aidop/api/kanbanData.ts
+```
+
+现状:
+
+| 模块 | “刷新”当前行为 | “重算数据” | 进度卡 |
+|---|---|---|---|
+| S1 | 最新日目标快照轻量刷新 | 异步任务 | 已有 |
+| S2~S4 | 同步 `RunFullAsync`,前端超时 180 秒 | 无 | 无 |
+| S5~S7 | 同步 T8 入站 + 模块 KPI,前端超时 180 秒 | 无 | 无 |
+| S9 | 仅重新加载看板 | 无 | 无 |
+
+S1 已实现:
+
+- `POST /api/AidopKanban/s1/rebuild-jobs`;
+- 202/409;
+- 5 秒/15 秒轮询;
+- localStorage 按租户/工厂保存 watched jobId;
+- 无 watched jobId 时不自动查 latest;
+- 可折叠 `S1RebuildProgressCard.vue`;
+- 真实阶段权重、心跳、失败阶段和成功自动 reload。
+
+### 2.2 S2~S4 后端
+
+核心服务:
+
+```text
+Production/S2MdpSyncTransformService.cs
+Supply/S3MdpSyncTransformService.cs
+ProcurementExecution/S4MdpSyncTransformService.cs
+```
+
+当前 `RunFullAsync` 不接收强制 scope。S2、S3 的典型流程:
+
+```text
+STAGING → STANDARD → DWD → KPI → ATOMIC
+```
+
+S4:
+
+```text
+STAGING → STANDARD → DWD → KPI
+```
+
+已核实风险:
+
+- `RunFullAsync` 一次处理全部可见租户;
+- Pull 路径仍存在 `tenantId=0`;
+- Standard/DWD/KPI 通过分组写多租户,不等于当前任务租户隔离;
+- S2/S3 Atomic 仍调用无 scope 的 `Build*DomainForAllDatesAsync`;
+- 手工 HTTP 同步等待;
+- 定时任务和 BOOTSTRAP 直接执行完整 RunFull;
+- 无模块级持久任务、真实进度和作用域锁。
+
+S3 的 `RefreshMaterialReadinessAsync` 是独立业务动作。除非 WP0 审计证明它是完整 S3 重算不可分割的一步,否则首轮不得擅自并入重算阶段。
+
+### 2.3 S5~S7 后端
+
+核心服务:
+
+```text
+MaterialWarehouse/S5MdpSyncTransformService.cs
+Manufacturing/S6MdpSyncTransformService.cs
+FinishedWarehouse/S7MdpSyncTransformService.cs
+DataPlatform/T8BaseInboundMdpSyncService.cs
+DataPlatform/AidopT8KpiManualRefreshService.cs
+Job/S5MdpRefreshJob.cs
+```
+
+特点:
+
+- S5~S7 共用 T8 基础入站;
+- 使用 `AidopSourceTenantMap` 将源账套映射到目标租户;
+- 当前手工刷新由 `AidopT8KpiManualRefreshService` 编排;
+- 存在全局 Cache 锁 `aidop:t8kpi:refresh:lock`;
+- S5/S6/S7 的 `RefreshOption` 含 `SourceZtid / TargetTenantId / TargetFactoryId`;
+- 各模块按 KPI 子项执行,可取得 `PerKpiDwdRows / PerKpiKpiRows`;
+- S5 的 legacy TVF 查询有独立超时护栏;
+- S5~S7 不能假装成与 S2~S4 完全相同的五段 ETL。
+
+### 2.4 S9 后端
+
+S9 当前没有:
+
+- `S9MdpSyncTransformService`;
+- 独立源数据拉取;
+- 独立 DWD/KPI 重算管线;
+- 可定义真实阶段的 S9 full rebuild。
+
+S9 看板主要读取 S1~S7 写入的 `ado_s9_kpi_value_l1_day`~`l4_day` 及共享原子层。因此本任务固定:
+
+```text
+S9 刷新 = 更新可用的最新日目标快照(无可更新行时返回 0)+ reload
+S9 不提供重算数据
+S9 不展示进度卡
+S9 不编排 S1~S7 全量重算
+```
+
+---
+
+## 3. 产品交互规格
+
+### 3.1 S1~S7 两个动作
+
+#### 刷新
+
+- 语义:更新当前租户/工厂/模块最新业务日的目标快照,再重新加载看板;
+- 不拉取源数据;
+- 不重建 Standard、DWD、KPI 实际值或 Atomic;
+- 不增加 `mdp_transform_run_log` 完整重算记录;
+- 正常 P95 ≤ 2 秒,硬上限 5 秒;
+- 无日值时返回成功、更新 0 行,不伪造日值。
+
+#### 重算数据
+
+- 语义:执行该模块完整数据生产管线;
+- 点击后显示模块化确认弹窗;
+- 创建 API 应在 2 秒内返回 202 或 409;
+- 后台执行,不绑定浏览器请求 CancellationToken;
+- 展示真实阶段进度、已耗时、行数和失败阶段;
+- 成功后自动 reload 一次;
+- 禁止通过延长 HTTP timeout 代替异步任务。
+
+### 3.2 S9
+
+S9 工具栏只显示“刷新”。不得显示:
+
+- “重算数据”;
+- 重算确认弹窗;
+- 重算进度卡;
+- “去重算 S1~S7”的自动级联操作。
+
+### 3.3 任务卡可见性
+
+1. 发起浏览器收到 202 后保存并显示 jobId;
+2. 本浏览器刷新/关闭重开后按本地 watched jobId 恢复;
+3. 无 watched jobId 时不得自动调用 latest 展示同租户他人任务;
+4. 同租户其他浏览器主动点击并收到 409 后,才保存并接管该 jobId;
+5. A 不能查询 B 的 jobId;
+6. localStorage key:
+
+```text
+aidop.{moduleCodeLower}.rebuild.watch.{tenantId}.{factoryId}
+```
+
+7. 切换租户、工厂或模块不得沿用旧 key;
+8. 403/404/作用域不符时清理本地 key。
+
+### 3.4 可折叠进度卡
+
+泛化现有 S1 卡片为模块通用组件。折叠态至少显示:
+
+```text
+模块数据重算 · 当前阶段 · 预计进度% · 已耗时 · 状态
+```
+
+展开态显示:
+
+- 模块名称;
+- QUEUED/RUNNING/SUCCESS/FAILED/CANCELLED;
+- 阶段 N/M;
+- 标注“按处理阶段估算”的进度条;
+- 已耗时和非精确剩余提示;
+- jobId、batchId;
+- 通用阶段行数及模块扩展摘要;
+- 失败阶段和可读错误;
+- 成功后“看板已自动刷新”;
+- 重试/刷新看板操作。
+
+点击“重算数据”后必须自动展开。进度百分比只能来自后端,不得前端定时增长。
+
+---
+
+## 4. 统一目标架构
+
+### 4.1 架构图
+
+```mermaid
+flowchart LR
+    Ui["DynamicModuleDashboard"] --> Api["Module Rebuild API"]
+    Api --> Service["ModuleRebuildService"]
+    Service --> JobTable["ado_module_dashboard_rebuild_job"]
+    Service --> Signal["In-memory wake signal"]
+    Signal --> Worker["ModuleRebuildWorker"]
+    Worker --> Handler["IModuleRebuildHandler"]
+    Handler --> Lock["Scoped DB lease"]
+    Handler --> Pipeline["S1-S7 module pipeline"]
+    Pipeline --> Reporter["Progress reporter"]
+    Reporter --> JobTable
+    AutoJob["AUTO / BOOTSTRAP"] --> Service
+```
+
+数据库任务表是事实源;内存 signal 只负责唤醒 worker。
+
+### 4.2 通用运行作用域
+
+建议新增:
+
+```csharp
+public sealed record MdpRebuildScope(
+    string ModuleCode,
+    long TenantId,
+    long FactoryId);
+```
+
+硬要求:
+
+- `ModuleCode` 只允许 `S1`~`S7`;
+- `TenantId > 0`;
+- `FactoryId > 0`;
+- 手工 API 的 tenantId 只能来自 `AidopTenantScope.ResolveOrThrow`;
+- factoryId 必须验证属于当前租户;
+- 禁止 `tenantId=0` 作为业务 SQL 的“全部租户”;
+- AUTO/BOOTSTRAP 先枚举 scope,再逐 scope 入队;
+- batch、日志、锁和每个进度事件均带完整 scope。
+
+### 4.3 通用任务表
+
+新增统一表:
+
+```text
+ado_module_dashboard_rebuild_job
+```
+
+最低字段:
+
+```sql
+id                       BIGINT PRIMARY KEY,
+module_code              VARCHAR(8) NOT NULL,
+tenant_id                BIGINT NOT NULL,
+factory_id               BIGINT NOT NULL,
+status                    VARCHAR(20) NOT NULL,
+trigger_type              VARCHAR(20) NOT NULL,
+requested_by              BIGINT NULL,
+batch_id                  VARCHAR(100) NULL,
+transform_run_log_id      BIGINT NULL,
+current_stage             VARCHAR(32) NOT NULL DEFAULT 'QUEUED',
+stage_index               TINYINT NOT NULL DEFAULT 0,
+stage_total               TINYINT NOT NULL DEFAULT 0,
+progress_percent          TINYINT NOT NULL DEFAULT 0,
+progress_message          VARCHAR(255) NULL,
+failed_stage              VARCHAR(32) NULL,
+stage_rows                INT NOT NULL DEFAULT 0,
+standard_rows             INT NOT NULL DEFAULT 0,
+dwd_rows                  INT NOT NULL DEFAULT 0,
+kpi_rows                  INT NOT NULL DEFAULT 0,
+atomic_rows               INT NOT NULL DEFAULT 0,
+detail_json                TEXT NULL,
+error_message              VARCHAR(2000) NULL,
+submitted_at              DATETIME(3) NOT NULL,
+started_at                DATETIME(3) NULL,
+finished_at               DATETIME(3) NULL,
+heartbeat_at              DATETIME(3) NULL,
+last_progress_at          DATETIME(3) NULL,
+duration_ms               BIGINT NULL,
+create_time               DATETIME(3) NOT NULL,
+update_time               DATETIME(3) NOT NULL
+```
+
+必须有索引:
+
+```text
+(module_code, tenant_id, factory_id, status, submitted_at)
+(status, submitted_at)
+(heartbeat_at, status)
+```
+
+`detail_json` 只存模块特有摘要,例如 S5~S7 的 per-KPI 行数和分母状态;不得存异常堆栈或逐秒进度历史。
+
+### 4.4 通用锁
+
+建议统一 DB 租约锁表:
+
+```text
+ado_mdp_rebuild_lock
+```
+
+最低字段:
+
+```sql
+lock_name       VARCHAR(160) PRIMARY KEY,
+module_code     VARCHAR(8) NOT NULL,
+tenant_id       BIGINT NOT NULL,
+factory_id      BIGINT NOT NULL,
+holder_id       VARCHAR(100) NOT NULL,
+job_id          BIGINT NULL,
+status          VARCHAR(20) NOT NULL,
+acquired_at     DATETIME(3) NULL,
+heartbeat_at    DATETIME(3) NULL,
+create_time     DATETIME(3) NOT NULL,
+update_time     DATETIME(3) NOT NULL,
+KEY ix_mdp_rebuild_lock_scope (module_code, tenant_id, factory_id),
+KEY ix_mdp_rebuild_lock_heartbeat (status, heartbeat_at)
+```
+
+锁键:
+
+```text
+{moduleCode}_MDP_FULL:{tenantId}:{factoryId}
+```
+
+要求:
+
+- 同 scope 同模块最大并行数 1;
+- 不同模块是否可并行由依赖审计决定,不能仅凭锁键不同就放行;
+- 不同租户/工厂允许受控并行;
+- holder、jobId、acquiredAt、heartbeatAt、释放操作必须匹配锁键;
+- 长阶段持续锁心跳;
+- stale 判定不得误杀仍有新鲜心跳的任务;
+- S5~S7 的共享 T8 入站锁继续存在,并与模块作用域锁分层处理。
+
+### 4.5 Handler 与阶段协议
+
+建议:
+
+```csharp
+public interface IModuleRebuildHandler
+{
+    string ModuleCode { get; }
+    Task<ModuleRebuildResult> RunAsync(
+        MdpRebuildScope scope,
+        string triggerType,
+        long jobId,
+        Func<ModuleProgressUpdate, Task> report,
+        CancellationToken cancellationToken);
+}
+```
+
+通用阶段码:
+
+```text
+QUEUED
+ACQUIRING_LOCK
+PREPARING
+STAGING
+STANDARD
+DWD
+KPI
+ATOMIC
+FINALIZING
+SUCCESS
+FAILED
+CANCELLED
+```
+
+模块可省略不存在的业务阶段,但不得上报未实际执行的阶段。阶段定义:
+
+| 模块 | 用户可见阶段 |
+|---|---|
+| S1 | STAGING、STANDARD、DWD、KPI、ATOMIC |
+| S2 | STAGING、STANDARD、DWD、KPI、ATOMIC |
+| S3 | STAGING、STANDARD、DWD、KPI、ATOMIC |
+| S4 | STAGING、STANDARD、DWD、KPI |
+| S5 | T8_INBOUND、KPI_PREPARING、KPI_CALCULATING、FINALIZING |
+| S6 | T8_INBOUND、KPI_PREPARING、KPI_CALCULATING、FINALIZING |
+| S7 | T8_INBOUND、KPI_PREPARING、KPI_CALCULATING、FINALIZING |
+
+S5~S7 新增阶段码应放入统一注册表,例如:
+
+```text
+T8_INBOUND
+KPI_PREPARING
+KPI_CALCULATING
+```
+
+前后端共享同一阶段契约来源或由 API 返回中文 message;不得各模块散落硬编码分支。
+
+### 4.6 Worker
+
+- 一个通用 Worker 从 DB claim;
+- Handler 按 `module_code` 解析;
+- 每个任务使用独立 DI scope 和数据库连接;
+- 配置 `AiDOP:MdpRebuild:MaxParallelScopes`,默认 2,限制 1~8;
+- 只有租户隔离集成测试通过的模块才允许并行;
+- claim 必须避免同 scope 重复领取;
+- job 心跳建议 30 秒,正常年龄不超过 90 秒;
+- 服务停止时可靠标记 CANCELLED,或在重启时由 stale 收口;
+- 进度写失败仅告警,不掩盖业务异常;
+- 状态更新使用列级 UPDATE,避免并发整行覆盖。
+
+### 4.7 模块能力开关
+
+在 `server/Admin.NET.Application/Configuration/App.json` 增加通用配置,建议:
+
+```text
+AiDOP:MdpRebuild:MaxParallelScopes = 2
+AiDOP:MdpRebuild:Modules:S1:Enabled = false
+...
+AiDOP:MdpRebuild:Modules:S7:Enabled = false
+```
+
+要求:
+
+- 新框架上线时所有生产模块默认关闭;
+- 每个 WP 仅开启当波已验收模块;
+- S1 到 WP6 才开启;
+- S9 不得配置为可重算模块;
+- WP6 后将旧 `AiDOP:S1Rebuild:MaxParallelTenants` 迁到通用并发配置,兼容期不得有两个 Worker 同时领取 S1。
+
+---
+
+## 5. API 契约
+
+### 5.0 鉴权硬边界
+
+所有新 `target-snapshot/refresh`、`rebuild-jobs`、jobId 和 latest API 必须:
+
+- 使用独立 `[Authorize]` 控制器,参考现有 `AidopKanbanS1RefreshController`;
+- 禁止把新端点挂到带 `[AllowAnonymous]` 的 `AidopKanbanController` 或其 partial 类;
+- tenantId 仅从认证上下文解析;
+- factoryId 验证归属;
+- 不接受客户端 tenantId;
+- 未认证请求返回 401/403,不得进入 Job Store 或轻量刷新服务。
+
+### 5.1 轻量刷新
+
+```http
+POST /api/AidopKanban/{moduleCode}/target-snapshot/refresh?factoryId=1
+Authorization: Bearer ...
+```
+
+适用:S1~S7、S9。
+
+成功示例:
+
+```json
+{
+  "ok": true,
+  "moduleCode": "S2",
+  "factoryId": 1,
+  "updatedRows": 24,
+  "missingRows": 0,
+  "elapsedMs": 280,
+  "message": "S2 看板目标快照已刷新"
+}
+```
+
+实现要求:
+
+- 泛化 `S1KpiTargetSnapshotRefreshService`,参数化 moduleCode;
+- 仅更新当前租户、工厂、模块 L1~L4 各自最新业务日;
+- 使用日值行自己的 `biz_date` 解析目标;
+- 只更新 target 相关字段和 update_time;
+- 不修改 metric、状态色、趋势、calc_time 或历史日期;
+- `MISSING` 不覆盖原值;
+- S9 无日值时返回 200、updatedRows=0;
+- 不访问外部源数据库。
+
+### 5.2 创建重算任务
+
+```http
+POST /api/AidopKanban/{moduleCode}/rebuild-jobs?factoryId=1
+Authorization: Bearer ...
+```
+
+仅允许 S1~S7。S9 返回 404/405 或不注册路由,禁止创建空任务。
+
+202:
+
+```json
+{
+  "ok": true,
+  "moduleCode": "S2",
+  "jobId": 123,
+  "status": "QUEUED",
+  "message": "S2 数据重算已排队"
+}
+```
+
+409:
+
+```json
+{
+  "ok": false,
+  "moduleCode": "S2",
+  "jobId": 122,
+  "status": "RUNNING",
+  "message": "S2 数据重算正在执行,请勿重复提交"
+}
+```
+
+活动判定键:
+
+```text
+module_code + tenant_id + factory_id + status IN ('QUEUED','RUNNING')
+```
+
+### 5.3 查询
+
+```http
+GET /api/AidopKanban/{moduleCode}/rebuild-jobs/{jobId}?factoryId=1
+GET /api/AidopKanban/{moduleCode}/rebuild-jobs/latest?factoryId=1
+```
+
+每次查询必须同时过滤 moduleCode、认证 tenantId、factoryId。不得仅凭 jobId 返回。
+
+`latest` 保留给显式恢复/运维,不允许前端在没有本地 watched jobId 时自动调用并展示。
+
+### 5.4 旧接口
+
+现有:
+
+```text
+POST /api/AidopKanban/mdp/refresh/{moduleCode}
+POST /api/AidopKanban/s2-mdp/refresh ~ s7-mdp/refresh
+```
+
+迁移对应模块后:
+
+- 不再同步等待完整 RunFull;
+- 有调用方时内部转为 Enqueue,返回兼容的 202 数据;
+- 无调用方且负责人同意时可标记 Deprecated;
+- 不得悄悄保持同步长请求;
+- `[AllowAnonymous]` 入口必须收口到 `[Authorize]`;
+- inbound 专用接口不得接受客户端 tenantId 冒充认证租户。
+
+---
+
+## 6. 租户/工厂隔离硬要求
+
+对每个模块、每个阶段逐条审计:
+
+### 6.1 入站
+
+- 源查询真实限定当前租户/账套映射;
+- `fullRefresh` 只清理当前 scope;
+- staging DELETE/UPSERT、游标、同步日志带 scope;
+- 源表无可信 tenant/factory 时停止并报告;
+- 不得把所有源行强行写成当前 JWT tenant;
+- 同业务键跨租户不得 JOIN。
+
+### 6.2 Standard/DWD
+
+每条 SQL:
+
+- 参数含 `@TenantId/@FactoryId`;
+- 源 WHERE 限定 scope;
+- 多表 JOIN 包含 tenant,适用时包含 factory;
+- DELETE/UPDATE/UPSERT 不作用其它租户;
+- 唯一键能够区分租户;
+- 行数只统计当前 scope。
+
+### 6.3 KPI
+
+- 所有 UNION 分支都带 scope;
+- 目标解析只解析当前 tenant/factory;
+- Upsert WHERE 和唯一键包含 tenant/factory/module/metric/date;
+- 不遍历其它租户;
+- S5~S7 的 `TargetTenantId` 必须与认证 tenant 和 `SourceZtid` 映射一致。
+
+### 6.4 Atomic
+
+S1~S3 所有 `Build*DomainForAllDatesAsync` 必须提供 scope 版本:
+
+- 日期只从当前 scope 事实表枚举;
+- DELETE 含 tenant + factory + domain + date;
+- INSERT SELECT 限定当前 scope;
+- A 执行前后 B 的行数、checksum、batchId、calc_time 不变。
+
+### 6.5 S5~S7 特殊边界
+
+T8 入站可能是共享物理动作,不得虚构“各租户完全独立拉源”:
+
+1. WP0 必须证明 `SourceZtid → TargetTenantId` 是一对一可信映射;
+2. 共享入站使用独立全局/账套锁;
+3. 入站完成后,模块 KPI 写入使用 module + tenant + factory 作用域锁;
+4. 若同一入站批次可供 S5~S7 复用,应记录批次依赖,不重复并发拉取;
+5. A 的源账套映射不得写入 B;
+6. 无法证明映射安全时停止 S5~S7 迁移,不得以全局串行冒充隔离。
+
+---
+
+## 7. 分波迁移总览
+
+```text
+WP0 基线、依赖和安全审计
+ → WP1 通用任务/锁/Worker/进度框架(先不切 S1)
+ → WP2 S2 试点
+ → WP3 S3、S4
+ → WP4 S5~S7
+ → WP5 前端通用化并按模块开关启用
+ → WP6 S1 平滑迁入通用框架
+ → WP7 S9 轻量刷新
+ → WP8 AUTO/BOOTSTRAP/旧接口/业务触发收口
+ → WP9 迁移、测试、UAT 与交付
+```
+
+每波必须可独立回滚。不得在 WP2 未通过时同时切 S3~S7。
+
+---
+
+## WP0:基线、依赖与停止条件审计
+
+### 步骤
+
+1. 保存各模块最近 20 条 `mdp_transform_run_log`;
+2. 记录 S2~S7 当前同步刷新响应和耗时;
+3. 列出各模块实体清单、源表、stg/std/dwd/KPI/Atomic 表;
+4. 列出所有完整 RunFull 调用点;
+5. 审计所有 SQL 的 tenant/factory WHERE、JOIN、DELETE;
+6. 审计唯一键是否包含租户维度;
+7. 审计 S2/S3 Atomic 全局删除;
+8. 审计 S5~S7 SourceZtid 映射、共享入站锁及可复用批次;
+9. 明确 S2~S7 各模块真实阶段和当前耗时;
+10. 确认 S9 无独立重算管线;
+11. 检查数据库版本占用、多实例部署和连接池上限;
+12. 输出逐模块“可迁移/阻塞”结论。
+
+### 完成标准
+
+- 有完整调用点和 SQL 审计清单;
+- 任一不能可信分租户的数据源被标记为阻塞;
+- 明确 S3 material readiness 是否属于本轮 full rebuild;
+- 明确 S5~S7 T8 入站互斥粒度;
+- 未通过 WP0 不得开始通用框架代码。
+
+---
+
+## WP1:通用任务、锁、Worker 与进度框架
+
+### 建议新增
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/
+  MdpRebuildScope.cs
+  ModuleRebuildStages.cs
+  ModuleRebuildModels.cs
+  IModuleRebuildHandler.cs
+  ModuleRebuildJobStore.cs
+  ModuleRebuildService.cs
+  ModuleRebuildQueue.cs
+  ModuleRebuildWorker.cs
+  MdpRebuildLock.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoModuleDashboardRebuildJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/ModuleRebuildServiceTests.cs
+```
+
+### 步骤
+
+1. 建立 scope、DTO、状态机、阶段注册和 Handler 接口;
+2. 建统一 Job/Lock 实体和幂等迁移;
+3. 实现 scoped active/get/latest/claim;
+4. 实现 202/409 Enqueue;
+5. 实现 DB 事实源 + 内存唤醒;
+6. 实现通用 Worker、受控并行和 Handler 路由;
+7. 实现 30 秒 job heartbeat、锁 heartbeat 和 stale 收口;
+8. 实现列级进度更新及状态不变量;
+9. 新增独立 `[Authorize]` 通用或薄控制器,禁止继承/复用带 `[AllowAnonymous]` 的 `AidopKanbanController`;
+10. 注册 hosted service 和配置;
+11. 默认只注册测试 Handler,不切换生产模块。
+
+### 状态不变量
+
+- RUNNING progress < 100;
+- SUCCESS progress = 100;
+- FAILED 有 failedStage 或明确“未知阶段”;
+- stageIndex ≤ stageTotal;
+- 百分比单调不减;
+- 终态不再更新心跳;
+- 任务查询始终带 module + tenant + factory。
+
+### 完成标准
+
+- 20 个同 scope 并发提交只有一个活动任务;
+- 不同 scope 可各有一个任务;
+- A 不能查询 B;
+- stale、重启、锁冲突、取消均有单测;
+- 未改变现有 S1 生产路径。
+
+---
+
+## WP2:S2 试点迁移
+
+### 修改重点
+
+```text
+Production/S2MdpSyncTransformService.cs
+Job/S2MdpSyncTransformJob.cs
+SmartOps/SmartOpsKpiAtomicBuildService.cs
+Controllers/独立的 [Authorize] 模块刷新控制器(参考 AidopKanbanS1RefreshController)
+Job/SmartOpsKpiMdpBootstrapJob.cs
+```
+
+### 步骤
+
+1. `RunFullAsync` 强制接收 `MdpRebuildScope`;
+2. STAGING 实际按 tenant/factory 拉取和清理;
+3. Standard/DWD 每条 SQL 增加 scope 和 tenant JOIN;
+4. KPI 每个分支及 Upsert 限定 scope;
+5. 新增 scope 版 `BuildWorkScheduleDomainForAllDatesAsync`;
+6. 建 `S2ModuleRebuildHandler`;
+7. 在真实边界上报五阶段进度;
+8. S2 手工重算切换到通用 API;
+9. 旧 S2 refresh API 转 Enqueue,不再同步等待;
+10. S2 AUTO/BOOTSTRAP 暂仅在能力开关开启时 Enqueue;`SmartOpsKpiMdpBootstrapJob` 只切 S2 分支,S3/S4 继续旧路径并留到 WP3;
+11. 执行 A/B 租户隔离与受控并行 UAT。
+
+### 阶段权重
+
+沿用 S1 五段权重:0、2、5、10、35、50、65、80、82、98、99、100。
+
+### 完成标准
+
+- A 重算不改变 B 的 stg/std/dwd/KPI/Atomic;
+- A/B 可分别 RUNNING;
+- 同 scope 409;
+- API 2 秒内返回;
+- S2 完整阶段达到 SUCCESS 100%;
+- S2 通过后才能复制模式到 S3/S4。
+
+---
+
+## WP3:S3、S4 迁移
+
+### S3
+
+修改:
+
+```text
+Supply/S3MdpSyncTransformService.cs
+Job/S3MdpSyncTransformJob.cs
+SmartOps/SmartOpsKpiAtomicBuildService.cs
+```
+
+要求:
+
+- 复制经过 S2 验证的 scope 模式,不复制业务 SQL;
+- Supply/Purchase 与 Inventory Atomic 均限定 scope;
+- `RefreshMaterialReadinessAsync` 按 WP0 结论独立或作为真实阶段,禁止猜测;
+- `S3MdpSyncTransformJob` 和 `SmartOpsKpiMdpBootstrapJob` 的 S3 分支改为按启用 scope Enqueue,能力开关默认关闭并在 S3 验收后开启;
+- 保持现有 KPI 公式和日期口径。
+
+### S4
+
+修改:
+
+```text
+ProcurementExecution/S4MdpSyncTransformService.cs
+Job/S4MdpSyncTransformJob.cs
+Supply/ProcurementPipelineService.cs
+```
+
+要求:
+
+- 四阶段:STAGING、STANDARD、DWD、KPI;
+- `stageTotal=4`,权重按真实边界配置;
+- 采购管线业务触发改为 Enqueue;
+- `S4MdpSyncTransformJob` 和 `SmartOpsKpiMdpBootstrapJob` 的 S4 分支改为按启用 scope Enqueue,能力开关默认关闭并在 S4 验收后开启;
+- 不新增虚假 Atomic 阶段;
+- S4 对共享 S3 数据的依赖必须带 tenant/factory JOIN。
+
+### 完成标准
+
+- S3/S4 各自通过 A/B 隔离;
+- S4 pipeline 触发可查到持久 Job;
+- S3/S4 某一失败不阻塞另一模块或另一租户;
+- 旧同步 HTTP 不再长时间占用。
+
+---
+
+## WP4:S5~S7 迁移
+
+### 原则
+
+复用通用任务壳,不重写 T8 业务算法。T8 入站和模块 KPI 分层上报。
+
+### 步骤
+
+1. 将 `AidopT8KpiManualRefreshService` 拆出可由 Handler 调用的编排能力;
+2. 为 S5/S6/S7 建独立 Handler;
+3. scope 生成 `RefreshOption`,客户端不得直接指定 TargetTenantId;
+4. 验证 `SourceZtid` 映射与认证 tenant 一致;
+5. T8 入站持有共享账套锁,写入按 tenant 分区;
+6. KPI 阶段持有模块 scope 锁;
+7. 每个 KPI 子项完成时更新 `detail_json` 和预计进度;
+8. S5 legacy TVF 超时保留,不扩大;
+9. `S5MdpRefreshJob` 改为逐 scope/module Enqueue;
+10. 共享 inbound 失败时依赖模块明确 FAILED,不继续使用陈旧批次冒充成功;
+11. 验证 S5、S6、S7 之间的锁等待文案和状态。
+
+### 建议阶段权重
+
+| 事件 | 百分比 |
+|---|---:|
+| QUEUED | 0 |
+| ACQUIRING_LOCK | 2 |
+| PREPARING | 5 |
+| T8_INBOUND 开始 | 10 |
+| T8_INBOUND 完成 | 35 |
+| KPI_PREPARING 完成 | 45 |
+| KPI_CALCULATING 按子项真实完成数 | 45~95 |
+| FINALIZING | 99 |
+| SUCCESS | 100 |
+
+只有 KPI 子项总数固定且每项真实完成后,才允许按 `completed/total` 在 45~95 之间计算;不得按时间增长。
+
+### 完成标准
+
+- 手工请求不再等待 T8 长任务;
+- 同 scope/module 409;
+- 共享 inbound 锁冲突表现为等待,不创建重复拉源;
+- A 的映射数据不写入 B;
+- S5 TVF 失败能定位到 KPI 子项;
+- S5~S7 各自任务结果和 per-KPI 摘要可查询。
+
+---
+
+## WP5:前端通用化和逐模块启用
+
+### 建议改动
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+Web/src/views/aidop/api/moduleRebuildWatch.ts
+Web/src/views/aidop/kanban/components/ModuleRebuildProgressCard.vue
+Web/src/views/aidop/kanban/composables/useModuleAsyncRebuild.ts
+Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
+Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
+```
+
+### 步骤
+
+1. 参数化 create/get/latest/target snapshot API;
+2. 将 `S1RebuildJob` 泛化为 `ModuleRebuildJob`;
+3. 将 watch key 泛化并包含 module;
+4. 将 S1 卡片泛化,保留折叠功能;
+5. 将轮询、恢复、409 接管、toast 去重、reload 去重抽 composable;
+6. 确认弹窗动作列表、阶段名、预计耗时由 registry 配置;
+7. S2~S7 在各自后端验收后才开启:
+
+```text
+quickRefresh
+rebuild
+supportsAsyncRebuild
+supportsAsyncRebuildProgress
+```
+
+8. 移除 S2~S7 将全量 `refreshMdp` 绑定到“刷新”的配置;
+9. S9 暂时保持纯 reload;后端轻量 API 和 registry `quickRefresh` 统一在 WP7 接入;
+10. S8 不接入 registry;
+11. 点击重算自动展开卡片;
+12. 无 watched jobId 不自动展示。
+
+### 完成标准
+
+- S1 用户行为不回归;
+- S2~S7 两按钮语义一致;
+- S9 只有刷新;
+- 202、409、恢复、网络中断、终态停止轮询均有测试;
+- 共享组件没有散落 `if (moduleCode === 'Sx')`;模块差异通过配置/阶段元数据表达。
+
+---
+
+## WP6:S1 平滑迁入通用框架
+
+这是最后迁移,不得在通用框架未经 S2~S7 验证前执行。
+
+### 切换前
+
+1. 禁止新建旧 S1 任务;
+2. 等待旧 `ado_s1_dashboard_rebuild_job` 的 QUEUED/RUNNING 全部终态;
+3. 保存任务与锁基线;
+4. 确认旧 `S1_MDP_FULL:*` 无活动 holder;
+5. 备份或保留旧表只读。
+
+### 迁移策略
+
+- 不强制复制全部 S1 历史;
+- 在同一次受控切换中先停止旧 `S1DashboardRebuildWorker` 领取新任务,再将 S1 Enqueue 路由切到 `ModuleRebuildService`,最后开启 `AiDOP:MdpRebuild:Modules:S1:Enabled`;
+- `Startup.cs` 不得同时注册可领取 S1 的旧、新 Worker;若为回滚暂保留旧类型,必须有互斥配置且默认仅一套启用;
+- 新任务写统一表;
+- S1 API 路径保持不变;
+- 查询新 Job 使用统一表;
+- 对本地保存的旧 jobId,S1 查询接口提供只读 fallback;
+- 新 ID 必须避免与旧 S1 ID 冲突;
+- 旧表不得在切换后继续产生新任务;
+- 旧 `ado_s1_mdp_run_lock` 与新 `ado_mdp_rebuild_lock` 在兼容期并存;切换后 S1 只能获取新锁,确认无旧 holder 后旧锁表转只读,禁止两个锁体系同时放行 S1;
+- 前端兼容读取现有 `aidop.s1.rebuild.watch.{tenantId}.{factoryId}`;恢复成功后写入通用 key,兼容期结束前不得直接丢弃旧 watched job;
+- 兼容期结束需另行确认,不在本任务直接 DROP 旧表。
+
+切换验收:
+
+- 旧 Worker 不再 claim;
+- 新 S1 任务只写统一表;
+- 同一 S1 scope 不会被两套 Worker 同时执行;
+- 关闭通用 S1 能力开关并恢复旧注册可作为代码回滚路径,但回滚前必须先收口统一表中的活动 S1 任务。
+
+### 回归
+
+- 现有 `S1DashboardRebuildServiceTests` 全部迁移/兼容;
+- S1 五阶段、折叠卡、409、localStorage 恢复不变;
+- AUTO/BOOTSTRAP/订单评审/LinkagePlan 全调用点复核;
+- S1 A/B 隔离重新执行;
+- 不改变 S1 指标、目标快照或 Atomic 口径。
+
+---
+
+## WP7:S9 轻量刷新
+
+1. registry 给 S9 注册通用 target snapshot refresh;
+2. 后端允许 `moduleCode=S9` 的轻量刷新;
+3. 有 S9 日值时只更新最新业务日目标字段;
+4. 无 S9 日值时返回 updatedRows=0;
+5. 前端成功后 reload;
+6. 不显示重算按钮;
+7. 后端拒绝 S9 rebuild job;
+8. 验证点击刷新没有新增 S1~S7 任务或 transform log。
+
+完成标准:
+
+- 5 秒内完成;
+- 不触发任何完整 MDP;
+- 不级联 S1~S7;
+- S9 页面无进度卡。
+
+---
+
+## WP8:AUTO、BOOTSTRAP、旧入口与业务触发收口
+
+### 必查
+
+```text
+Job/S1MdpSyncTransformJob.cs
+Job/S2MdpSyncTransformJob.cs
+Job/S3MdpSyncTransformJob.cs
+Job/S4MdpSyncTransformJob.cs
+Job/S5MdpRefreshJob.cs
+Job/SmartOpsKpiMdpBootstrapJob.cs
+DataPlatform/AidopT8KpiManualRefreshService.cs
+Order/OrderReviewOrchestrationService.cs
+Order/LinkagePlanService.cs
+Supply/ProcurementPipelineService.cs
+Production/ProductionSchedulingActionService.cs
+Infrastructure/MdpRefreshDispatcher.cs
+Controllers/AidopKanbanController.cs
+```
+
+### 要求
+
+- AUTO/BOOTSTRAP 查询启用 scope 后逐一 Enqueue;
+- 不能用 tenantId=0 跑全租户 SQL;
+- 某 scope 失败不阻止其它 scope 入队;
+- 业务触发不得绕过统一锁和任务事实源;
+- triggerType 保留 MANUAL/AUTO/BOOTSTRAP/业务来源;
+- `MdpRefreshDispatcher` 不再用于完整 RunFull;`ProductionSchedulingActionService` 使用它调度 S2 排程动作而非完整 MDP,不得因此误删或误改该轻量调度;
+- `AidopT8KpiManualRefreshService` 不再从看板 HTTP 路径同步执行完整 S5~S7 RunFull;
+- legacy API 全部鉴权;
+- 多实例只能由 DB claim 保证唯一执行。
+
+---
+
+## WP9:迁移、测试、UAT 与交付
+
+### 9.1 数据库迁移
+
+- 使用执行时实际下一个后端 patch;
+- `.sql` + `.verify.sql`;
+- 在 `Admin.NET.Web.Entry.csproj` 登记 CopyToOutput;
+- 幂等创建统一 Job/Lock 表和索引;
+- 不修改已成功执行脚本;
+- 迁移只在确认旧 S1 在途任务策略后处理兼容标记;
+- `sys_db_migration_log` 必须为 Success 且 verify Success;
+- 不修改 165。
+
+### 9.2 后端测试
+
+通用框架至少覆盖:
+
+1. 初始 QUEUED/0;
+2. 202/409;
+3. 同 scope 20 并发只有一个活动任务;
+4. 不同 tenant/factory 可分别活动;
+5. module 维度隔离;
+6. jobId/latest 租户隔离;
+7. 阶段单调;
+8. SUCCESS=100;
+9. FAILED 保留阶段和进度;
+10. 心跳启动/停止;
+11. stale 收口;
+12. 锁冲突重新排队;
+13. 服务停止;
+14. 未注册/S9 Handler 被拒绝;
+15. AUTO/BOOTSTRAP 逐 scope 入队。
+
+每个 S2~S7 至少覆盖:
+
+- A 重算不改变 B;
+- fullRefresh 不清 B;
+- 相同业务键不跨租户 JOIN;
+- KPI Upsert 只写当前 scope;
+- Atomic 模块只删除当前 scope;
+- 真实阶段和行数;
+- 阶段异常定位;
+- 旧入口不再同步长跑。
+
+### 9.3 前端测试
+
+至少覆盖:
+
+1. S1~S7 刷新只调用轻量 API;
+2. S1~S7 重算调用异步 API;
+3. S9 无重算按钮;
+4. 取消确认不请求;
+5. 202 展示并自动展开;
+6. 409 接管;
+7. 后端百分比原样展示;
+8. 前端不自行增长;
+9. 折叠/展开;
+10. watched job 恢复;
+11. 无 watched job 不自动展示;
+12. tenant/factory/module 切换隔离;
+13. 终态停止轮询;
+14. 成功只 reload 一次;
+15. S8 无回归。
+
+### 9.4 构建
+
+```text
+dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj -f net10.0
+dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj -f net8.0
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug -f net10.0
+pnpm --dir Web build
+```
+
+---
+
+## 8. UAT 场景
+
+每个模块 S2~S7 都必须执行 UAT-1~UAT-8;S1 在 WP6 回归;S9 执行 UAT-9。
+
+### UAT-1 轻量刷新
+
+- 记录最新业务日 metric/target;
+- 修改有效目标;
+- 点击刷新;
+- 5 秒内目标变化;
+- metric 不变;
+- 历史日期不变;
+- 无新增完整 transform run。
+
+### UAT-2 确认与入队
+
+- 取消不创建任务;
+- 确认后 2 秒内返回 202;
+- 卡片自动展开;
+- 显示 QUEUED 和后台执行提示。
+
+### UAT-3 真实进度
+
+- 阶段顺序与实际调用一致;
+- 不出现不存在的阶段;
+- 百分比不倒退;
+- RUNNING 不为 100;
+- SUCCESS 为 100;
+- 行数只属于当前 scope。
+
+### UAT-4 页面恢复
+
+- 发起浏览器关闭页面后任务继续;
+- 重进恢复同 jobId;
+- 不创建新任务;
+- 成功只自动 reload 一次。
+
+### UAT-5 同租户主动接管
+
+- 浏览器 1 发起;
+- 浏览器 2 只进入页面时不显示卡片;
+- 浏览器 2 主动点击后收到 409;
+- 两端显示同一 jobId;
+- 数据库只有一个活动任务。
+
+### UAT-6 跨租户独立
+
+- A 运行时 B 发起;
+- B 返回 202,不返回 A 的 409/jobId;
+- A/B 可分别 RUNNING;
+- 双方不能查询对方 jobId;
+- 锁键、batchId 不同。
+
+### UAT-7 A 不改变 B
+
+执行前后比较 B 的:
+
+- stg/std/dwd;
+- L1~L4 KPI;
+- Atomic(适用模块);
+- 行数、最大 update_time、batchId、checksum。
+
+所有值必须不变。
+
+### UAT-8 失败、心跳与重启
+
+- 模拟阶段失败,failedStage 正确;
+- 长阶段 heartbeat_age 通常 <90 秒;
+- 停止后端后任务不永久 RUNNING;
+- 重启后 stale 收口;
+- 可重新创建任务。
+
+### UAT-9 S9
+
+- 点击刷新后 5 秒内完成;
+- 页面 reload;
+- 无 rebuild 请求;
+- 无新 Job;
+- 无 S1~S7 transform run;
+- 页面无进度卡。
+
+留证放入:
+
+```text
+doc/plan/UAT留证/<日期>-S2-S7看板刷新重算统一架构/
+```
+
+至少包含 README、API JSON、SQL 输出、日志和截图。
+
+---
+
+## 9. 验收 SQL
+
+### 9.1 活动任务唯一性
+
+```sql
+SELECT module_code, tenant_id, factory_id, COUNT(*) AS active_count
+FROM ado_module_dashboard_rebuild_job
+WHERE status IN ('QUEUED','RUNNING')
+GROUP BY module_code, tenant_id, factory_id
+HAVING COUNT(*) > 1;
+```
+
+期望 0 行。
+
+### 9.2 非法状态
+
+```sql
+SELECT id, module_code, status, current_stage,
+       stage_index, stage_total, progress_percent
+FROM ado_module_dashboard_rebuild_job
+WHERE progress_percent < 0
+   OR progress_percent > 100
+   OR stage_index < 0
+   OR stage_index > stage_total
+   OR (status='SUCCESS' AND progress_percent<>100)
+   OR (status='RUNNING' AND progress_percent=100);
+```
+
+期望 0 行。
+
+### 9.3 心跳
+
+```sql
+SELECT id, module_code, tenant_id, factory_id, current_stage,
+       TIMESTAMPDIFF(SECOND, heartbeat_at, NOW(3)) AS heartbeat_age_seconds
+FROM ado_module_dashboard_rebuild_job
+WHERE status='RUNNING';
+```
+
+正常任务通常小于 90 秒。
+
+### 9.4 锁与任务
+
+```sql
+SELECT lock_name, module_code, tenant_id, factory_id,
+       holder_id, job_id, status, acquired_at, heartbeat_at
+FROM ado_mdp_rebuild_lock
+ORDER BY module_code, tenant_id, factory_id;
+```
+
+锁键和 Job scope 必须一致。
+
+### 9.5 跨租户 KPI 校验
+
+```sql
+SELECT module_code, tenant_id, factory_id,
+       COUNT(*) AS row_count,
+       MAX(update_time) AS max_update_time,
+       BIT_XOR(CAST(CRC32(CONCAT_WS('|',
+           module_code, tenant_id, factory_id, metric_code, biz_date,
+           IFNULL(metric_value,''), IFNULL(target_value,''),
+           IFNULL(calc_batch_id,''))) AS UNSIGNED)) AS checksum_value
+FROM ado_s9_kpi_value_l1_day
+WHERE module_code=@ModuleCode
+  AND tenant_id IN (@TenantA,@TenantB)
+GROUP BY module_code, tenant_id, factory_id;
+```
+
+执行 A 重算后,B 的结果必须完全不变。执行者还需对本模块 stg/std/dwd/Atomic 选择稳定列做同类校验。
+
+### 9.6 S9 不触发重算
+
+点击 S9 刷新前后:
+
+```sql
+SELECT COUNT(*) AS c
+FROM ado_module_dashboard_rebuild_job
+WHERE submitted_at >= @BeforeRefresh;
+```
+
+不得因 S9 增加记录。
+
+---
+
+## 10. 性能与可靠性门槛
+
+- 轻量刷新 P95 ≤ 2 秒,硬上限 5 秒;
+- 创建任务 P95 ≤ 1 秒,硬上限 2 秒;
+- 查询任务 P95 ≤ 500ms;
+- 进度单次写入目标 ≤ 200ms;
+- 前台轮询 5 秒,隐藏页面 15 秒;
+- Job 心跳 30 秒,正常年龄不超过 90 秒;
+- 不逐秒写 DB;
+- 不因进度写失败中断核心 MDP;
+- 不通过跳过业务阶段满足耗时;
+- Worker 并发必须受连接池和源端容量约束;
+- S5~S7 T8 慢查询不得无限持锁。
+
+---
+
+## 11. 预计文件范围
+
+### 通用后端
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/*
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoModuleDashboardRebuildJob.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoMdpRebuildLock.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantScope.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController*.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/S1KpiTargetSnapshotRefreshService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicBuildService.cs
+server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/*
+server/Admin.NET.Web.Entry/UpdateScripts/<actual-next-version>.sql
+server/Admin.NET.Web.Entry/UpdateScripts/<actual-next-version>.verify.sql
+server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
+server/Admin.NET.Application/Configuration/App.json
+```
+
+### 模块后端
+
+```text
+Production/S2MdpSyncTransformService.cs
+Supply/S3MdpSyncTransformService.cs
+ProcurementExecution/S4MdpSyncTransformService.cs
+MaterialWarehouse/S5MdpSyncTransformService.cs
+Manufacturing/S6MdpSyncTransformService.cs
+FinishedWarehouse/S7MdpSyncTransformService.cs
+DataPlatform/T8BaseInboundMdpSyncService.cs
+DataPlatform/AidopT8KpiManualRefreshService.cs
+Job/S1MdpSyncTransformJob.cs
+Job/S2MdpSyncTransformJob.cs
+Job/S3MdpSyncTransformJob.cs
+Job/S4MdpSyncTransformJob.cs
+Job/S5MdpRefreshJob.cs
+Job/SmartOpsKpiMdpBootstrapJob.cs
+```
+
+### 前端
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+Web/src/views/aidop/api/s1RebuildWatch.ts
+Web/src/views/aidop/api/moduleRebuildWatch.ts
+Web/src/views/aidop/kanban/components/S1RebuildProgressCard.vue
+Web/src/views/aidop/kanban/components/ModuleRebuildProgressCard.vue
+Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
+Web/src/views/aidop/kanban/composables/useModuleAsyncRebuild.ts
+Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
+Web/package.json
+```
+
+执行模型必须以全仓检索补齐调用点;本清单不是跳过审计的理由。
+
+---
+
+## 12. 跨模块影响
+
+- S1:最终迁入通用框架,必须完整回归;
+- S2~S4:完整 MDP 从同步全局处理改为 scope 异步任务;
+- S5~S7:共享 T8 入站、账套映射和全局锁会受影响;
+- S9:仅轻量刷新;
+- S8:禁止修改;
+- 数据中台:Pull、Writer、游标、日志和 fullRefresh 语义需回归;
+- SmartOps:Atomic 和 KPI target snapshot 泛化;
+- 调度:AUTO/BOOTSTRAP 改为逐 scope 入队;
+- 业务链:订单评审、联动计划、采购管线触发需收口;
+- 部署:新增统一任务/锁表和 hosted worker;
+- 多实例:必须依赖 DB claim/lease,不依赖单进程内存锁。
+
+---
+
+## 13. 明确不做
+
+- 不新增或重排菜单/FUNC;
+- 不改 `sys_menu.Title`;
+- 不修改 S8;
+- 不给 S9 增加重算或级联重算;
+- 不优化各模块业务算法总耗时;
+- 不修改 KPI 公式和日期口径;
+- 不修改 165 数据库对象或存储过程;
+- 不回写历史目标;
+- 不新增 SSE/WebSocket;
+- 不实现用户主动取消运行中任务;
+- 不删除旧 S1 表;
+- 不强制改造 S5 outsource、IQC、FQC、生产领退料等独立卫星 refresh;本任务只覆盖看板工具栏对应的 S5~S7 主重算链路;
+- 不顺手重构无关页面、警告或模块;
+- 不用虚假时间进度;
+- 不提交 secret、日志或构建产物。
+
+---
+
+## 14. 停止条件
+
+遇到以下任一项必须停止并报告:
+
+1. 源表无法可信识别 tenant/factory/SourceZtid;
+2. fullRefresh 无法限定 scope;
+3. 唯一键无法隔离租户;
+4. 任一 DELETE/Atomic 只能全局执行;
+5. 相同业务键会跨租户 JOIN;
+6. S5~S7 账套映射不是可信一对一且无明确产品规则;
+7. S9 被要求编排 S1~S7 重算;
+8. 通用 Handler 迫使不同模块伪造阶段;
+9. 多实例 claim 无法保证唯一;
+10. Worker 并发会耗尽连接池或压垮源端;
+11. 当前迁移版本已占用;
+12. 旧 S1 仍有无法安全收口的活动任务;
+13. 需要修改 165;
+14. 工作区存在未识别的同文件并行改动;
+15. 必须改变指标公式才能实现;
+16. 只能靠前端计时伪造进度。
+
+---
+
+## 15. 回滚策略
+
+### 分波回滚
+
+- 每个模块由能力开关控制,失败时关闭该模块通用 rebuild;
+- 前端恢复该模块旧配置前,确认不会重新暴露同步长请求;必要时只保留轻量刷新;
+- 通用 Worker 可停止领取特定 module,不影响其它模块;
+- 已运行任务先收口,禁止直接删除活动 Job。
+
+### 数据库
+
+- 新表保留,不在紧急回滚中 DROP;
+- 已 Success 的迁移不修改、不伪造降版;
+- 代码回滚使用 Git revert;
+- 旧 S1 表在兼容期只读保留;
+- 数据批次回滚必须按 tenant + factory + module + batch 条件,执行前备份;
+- 已写入 165 的外部业务动作不适用自动回滚,本任务原则上不触及。
+
+---
+
+## 16. 版本、迁移和 Git
+
+执行代码工作包时:
+
+- 改前端运行代码:`Web/package.json` patch +1,并同步实际锁文件;
+- 改后端运行代码:`.csproj` 的 Version/AssemblyVersion/FileVersion 三处 patch +1;
+- 同一提交同时改前后端:两端分别 +1;
+- 迁移号与该次后端版本一致;
+- 每波若独立提交/部署,应独立取号,禁止所有 WP 共用一个预估版本;
+- 本任务书自身是纯文档,不递增版本;
+- 用户说“提交”才执行 commit + push。
+
+---
+
+## 17. 交付报告模板
+
+```text
+执行波次:
+- WP:WP0–WP9 一次落地
+- 模块:S1–S7 异步重算 + S9 轻量刷新;S8 未改
+
+完成:
+- 通用 Job/Lock/Worker/Handler
+- S2–S4 强制 scope,无 scope RunFull 抛错
+- S5–S7 经 T8 模块刷新入队
+- 前端 registry 双按钮 + 通用进度卡
+- S1 能力开关打开时走统一表
+- 旧 HTTP / AUTO / BOOTSTRAP 收口为 Enqueue
+- 1.0.347 表已在 aidopdev 创建
+
+租户隔离:
+- Tenant A / Factory:1300000000001 / 1(L1 checksum);运行账号 JWT 租户 797403760988229
+- Tenant B / Factory:1300000000888 / 1;隔离账号 797403760988230、824585161322565
+- A 对 B 的 L1 checksum:UAT-7 后 B 的 S2/S3/S4 仍为 1934982272 / 212701925 / 2010673860
+- 同业务键串租验证:MdpSqlScope.InjectTenantFactory 单测;无 scope 入口已禁用
+
+任务:
+- jobId:181/182 SUCCESS(跨租户 S2);183/184/201 FAILED STAGING(A 租户源数据)
+- triggerType:MANUAL/AUTO/BOOTSTRAP 均已入队
+- 阶段流转:SUCCESS=100;FAILED 保留 failedStage
+- 总耗时:181=2310ms,182=2209ms,183=30247ms,201=90248ms
+- 202/409:同 scope 第二请求 409 同一 jobId;跨租户互不阻塞
+- 浏览器接管:第二浏览器静默进入无卡,点击后 409 挂 job 201
+
+刷新:
+- S1 updatedRows=43 / 4785ms;S2=13 / 1396ms;S3=7 / 915ms;S4=9 / 1017ms;S5–S9 200
+- S9 rebuild HTTP 404,页面无重算按钮、无进度卡
+
+测试:
+- net8:506 passed / 2 skipped
+- net10:506 passed / 2 skipped
+- Web build:成功
+- 前端本次 vitest:14 passed
+- UAT:API + SQL + 浏览器截图已写入留证目录
+
+迁移:
+- 版本:1.0.347
+- sys_db_migration_log:Success / verify Success
+- verify:COUNT(DISTINCT INDEX_NAME)
+
+回滚/能力开关:
+- AiDOP:MdpRebuild.Modules.S1–S7.Enabled=true;MaxParallelScopes=1
+
+未完成/风险:
+- A 租户 797403760988229 的 S2 STAGING 失败来自源/165 数据,未改 165
+- 既有 S8 vitest 6 条失败与本任务无关
+
+Git:
+- 未提交(用户未说「提交」)
+```
+
+---
+
+## 18. 可直接复制给执行模型的总提示词
+
+```text
+请严格执行:
+doc/plan/S2-S7及S9看板刷新与重算统一架构执行任务书_20260815.md
+
+要求:
+1. 先阅读 AGENTS.md、.cursor/rules、两份 S1 前置任务书和本任务书。
+2. 先做 WP0,只读审计全部 RunFull、SQL、锁、AUTO/BOOTSTRAP 和业务触发调用点。
+3. 每个 WP 动手前列目标、文件、可见行为、跨模块影响、风险和明确不做项,等我确认。
+4. 采用“统一目标架构 + 分波迁移”,禁止一次性替换 S1~S7。
+5. 顺序必须是 WP0→WP1→WP2(S2)→WP3(S3/S4)→WP4(S5~S7)→WP5(前端)→WP6(S1)→WP7(S9)→WP8→WP9。
+6. WP2 S2 隔离和 UAT 未通过,不得复制到 S3~S7;S1 最后迁移。
+7. 所有完整重算必须有 moduleCode+tenantId+factoryId,禁止 tenantId=0。
+8. 每条 STG/STD/DWD/KPI/Atomic SQL、JOIN、DELETE、UPSERT 都要证明不会跨租户。
+9. S5~S7 必须正视共享 T8 入站和 SourceZtid 映射,不得用全局串行冒充隔离。
+10. 任务 DB 是事实源;同 scope 409,不同租户可受控并行;查询 jobId 必须鉴权隔离。
+11. 进度来自真实阶段;禁止前端计时增长。
+12. 无本地 watched jobId 不自动展示;其他浏览器仅主动点击收到 409 后接管。
+13. S1~S7:刷新=轻量目标快照,重算=异步 full rebuild。
+14. S9 仅轻量刷新,不提供重算或进度卡;S8 不修改。
+15. 不改 KPI 公式、不改 165、不加 SSE/取消、不新增菜单/FUNC。
+16. 每波完成单测、构建、迁移 Success、A/B UAT 和留证后再继续。
+17. 使用执行时实际版本号;未经我明确说“提交”,不得 commit/push。
+```

+ 2 - 0
doc/plan/UAT多租户数据方案/README.md

@@ -1,5 +1,7 @@
 # UAT 全功能测试与 Demo 演示 · 多租户数据方案
 
+> **现行工作入口已改到** [`../UAT测试数据准备/README.md`](../UAT测试数据准备/README.md)(2026-08-15 最终收口为 `UATTEST_CHL` 客户 UAT、`UATTEST_CHLB` 从零点通、`UATDEMO` 价值展示三套)。本文与 [逐表清单.md](./逐表清单.md) 保留为 2026-08-11 分析原稿,勿在此目录继续追加执行内容。
+>
 > **状态**:方案待确认(本轮只出方案与逐表清单,**未写任何 SQL、未改任何代码**)
 > **建立时间**:2026-08-11
 > **目标库**:MySQL `aidopdev`(`123.60.180.165:3306`)

+ 159 - 0
doc/plan/UAT测试数据准备/01-历史分析回顾与现状.md

@@ -0,0 +1,159 @@
+# 01 · 历史分析回顾与现状
+
+> 配套入口:[README.md](./README.md)  
+> 整理日期:2026-08-15
+
+---
+
+## 1. 两层历史
+
+为 UAT 准备全链路测试数据,仓库里实际做过两轮,不要混成一件事。
+
+### 1.1 第一层:S1–S4 全局主线(2026-06)
+
+入口:[S1-S4全局UAT场景测试数据生成方案.md](../S1-S4全局UAT场景测试数据生成方案.md)
+
+主线:
+
+```text
+S1 销售订单/交付需求
+  → S2 工单/工序排程/日计划
+  → S3 物料需求/采购申请/采购订单/齐套上线
+  → S4 供应商交货/发货/IQC/退货/欠料
+  → 回写 S3/S2/S1 看板、DWD、KPI
+```
+
+已落地:
+
+| 项 | 值 |
+|---|---|
+| 租户 | 仅 A 套 `797403760988229` |
+| P0 | 5 单,批次 `S1S4_UAT_20260604_RQ_V1` |
+| FULL | 50 单,批次 `S1S4_UAT_FULL_20260605_V1` |
+| 主样例 | `MPO482024102300001` → `PO-UAT-20260604-01` → `SH-UAT-20260604-01` |
+| 登录 | 租户 AIDOP,账号 `AIDOPDemo` |
+| 测试指南 | [S1-S4_UAT场景测试数据说明与测试指南.md](../S1-S4_UAT场景测试数据说明与测试指南.md) |
+
+原则至今有效:**S2/S3/S4 必须复用 S1 的订单、明细、物料,禁止各模块独立造数。**
+
+局限:只覆盖 S1–S4,只服务 A 套;S5–S9、B 套演示脱敏、多租户隔离都不在范围内。2026-08-11 方案明确写了「A 套 S1–S4 主线沿用该方案,不重写」。
+
+### 1.2 第二层:三套租户全链路盘库(2026-08-11)
+
+入口原稿:[UAT多租户数据方案/README.md](../UAT多租户数据方案/README.md)  
+触发原话:「准备几套数据,供完整 UAT 全功能测试和 Demo 演示,考虑分不同租户。」
+
+当时只出方案与逐表清单,**未写 SQL、未改代码**。2026-08-15 先确认过 A/B,随后最终收口为三套全新职责:`UATTEST_CHL`(5 组客户 UAT)、`UATTEST_CHLB`(从零点通)、`UATDEMO`(价值展示)。下表旧租户数据仅作素材与问题基线,不再等同于新 A/B/Demo。
+
+---
+
+## 2. 2026-08-11 盘库结论(仍是造数依据)
+
+### 2.1 全库租户总览
+
+| 租户 ID | 组织 | 菜单数 | 用户 | 角色 | 处置 |
+|---|---|---|---|---|---|
+| `1300000000001` | 系统默认 | 476 | 9 | 27 | 平台超管,不作业务演示 |
+| `1300000000777` | Ai-DOP 演示公司 | 473 | 1 | 1 | 空壳,不动 |
+| `1300000000888` | Demo默认组织 | 468 | 1 | 8 | 空壳,不动(勿与 B 套混淆) |
+| `788012541595717` | test | 274 | 1 | 8 | 测试残留,建议停用,本轮不删 |
+| `797403760988229` | AIDOP | 473 | 2 | 9 | **A 套** |
+| `797403760988230` | AIDOP-Demo | **273** | 1 | 7 | **B 套**(菜单缺约 200 条) |
+| `824585161322565` | AIDOPB | 475 | 2 | 9 | 历史素材租户,不直接作为新 B |
+
+### 2.2 三套业务数据对照(代表表)
+
+`—` 表示 0 行。完整逐表见 [05-逐表清单-AB.md](./05-逐表清单-AB.md)。
+
+| 层 | 代表表 | A `…229` | B `…230` | C `…565`(对照) |
+|---|---|---|---|---|
+| S0 物料 | `ItemMaster` | 16150 | — | 5958 |
+| S0 BOM | `ProductStructureMaster` | 57199 | — | 3824 |
+| S0 产线 | `LineMaster` | 109 | — | **—**(明细却有 4823) |
+| S1 销售订单 | `crm_seorder` | 19 | — | — |
+| S1/S2 工单 | `WorkOrdMaster` | 22 | — | — |
+| S2 日计划 | `PeriodSequenceDet` | 1296 | — | — |
+| S2 节假日 | `HolidayMaster` | **—** | — | 11 |
+| S3 物料需求 | `ic_demandschedule` | 1189 | — | — |
+| S3 采购订单 | `PurOrdMaster` | 46 | — | — |
+| S5 库存 | `InvMaster` | 16022 | — | 2346 |
+| S5 IQC | `qms_qcp_inspbill` | 5 | — | 1 |
+| S6 设备 | `EquipmentList` | **—** | — | **165** |
+| S6 模工具 | `MoldToolList` | **—** | — | **290** |
+| S7 FQC | `qms_fqcbj` | **—** | — | — |
+| S8 订单档案 | `ado_s8_order_flow_order` | **—** | **20** | — |
+| S9 KPI 值 | `ado_s9_kpi_value_l1_day` | 2319 | — | — |
+| S9 KPI 主数据 | `ado_smart_ops_kpi_master` | 169 | **—** | 169 |
+
+### 2.3 从基线读出的三个结论
+
+**第一,没有任何一个租户能演完整个系统。** A 有 S1–S5 但 S6/S7/S8 空;B 只有 S8 的 20 单;C 只有 S6 台账。演示时在租户之间切换不可接受。
+
+**第二,B 套几乎是白纸。** 除 20 单 S8 档案外业务表全 0,连 KPI 主数据(169 条)和约 200 条菜单都缺。工作量最大,但也最可控——不用兼容存量单据。
+
+**第三,A 套不能重造。** S1–S5 已有真人 UAT 与 50 单验收基线,重造会破坏已有留证。本轮只补 S6/S7/S8 及少量过薄表。
+
+---
+
+## 3. 前置阻断:租户隔离
+
+### 3.1 2026-08-11 实测污染(造数当时不可开工的原因)
+
+| 表 | `tenant_id=0` | 正确租户 `…229` | 影响 |
+|---|---|---|---|
+| `dwd_material_shortage` | **146(全部)** | 0 | S3 缺料看板恒空 |
+| `mdp_std_s6_report` | **2(全部)** | 0 | S6 报工标准层恒空 |
+| `mdp_std_production_issue` | **2(全部)** | 0 | S5 生产领料标准层恒空 |
+| `mdp_std_purchase_order` | 101 | 434 | S3/S4 采购口径偏低 |
+| `mdp_std_purchase_receipt` | 3 | 11 | S4 收货口径偏低 |
+
+对照 2026-08-10 审计,`dwd_material_shortage` 从 142 涨到 146 且仍 100% `tenant=0`,说明写入侧仍在持续污染,不是存量遗留。
+
+另:`ado_s8_exception` 42 行全部 `tenant_id=1`(既不是真实租户,也不是框架默认 `1300000000001`),S8 异常列表在所有租户下都空。归隔离批次 4,本方案不修。
+
+**当时结论**:经过 MDP 贴源/转换的新数据会重新掉进 `tenant_id=0`,造完看板仍空。造数必须排在隔离收口之后。
+
+### 3.2 2026-08-15 对照:P-022 部分完成,尚未放行
+
+依据 [Ai-DOP项目待办清单.md](../Ai-DOP项目待办清单.md) P-022 与 [租户隔离收口与造数前置任务书.md](../租户隔离收口与造数前置任务书.md):
+
+| 项 | 状态(2026-08-15) |
+|---|---|
+| 批次 0/1/2/4 主体 | 已随 `0da9ce11b` / server `1.0.327` 落地,勿重做 |
+| 收口 WP-1 齐套刷新「先删后建、失败即空」 | **代码已落**(server `1.0.342`),超时改为 300s |
+| 收口 WP-2 写入侧零兜底 | **代码已落**(`1.0.341.sql`) |
+| 收口 WP-3 存量零值清理 | 脚本 `1.0.342.sql` 已写入,**须后端启动后执行** |
+| `ado_s8_exception.tenant_id=1` | **未动** |
+| 启动后确认增量不再出零值 | **未验收** |
+| 齐套刷新失败不清空 | **未验收** |
+
+**本目录立场(2026-08-15)**:P0 比 8 月 11 日松了,但还没到「可以放心开造」的验收完成态。开写 SQL / 灌库之前,必须先做完 P-022 剩余验收,或由负责人书面放行「带风险并行」。
+
+### 3.3 使用约束(不是代码缺陷)
+
+S3/S4 的 DWD 构建是「一次重建全部租户」:按 `stat_date` 删当日所有租户行,再从上游重建。因此:
+
+> **DWD / KPI 必须全局跑一次,不得按租户分别调用 `RunInboundAsync(tenantId)`。**
+
+按租户 A 拉贴源时,随后的 DWD 仍会重建 B 的当日数据;B 上游若未就位,B 的当日 DWD 会被不完整上游覆盖。
+
+---
+
+## 4. 至今仍有效的原则
+
+1. 不能按模块各造各的,必须同一批订单 / 工单 / 物料 / 采购单串到底。
+2. A 套只补缺口,不重造已有 S1–S5 主线。
+3. B 套按「成品倒推 BOM 闭包」精简,点开任意演示物料都能查到 BOM、工艺、产线。
+4. 排程结果、齐套结论、KPI 一律不手工插,跑正式作业生成。
+5. 日期用 `CURDATE()` 偏移,覆盖「过去 30 天 + 未来 30 天」;随机须固定种子,保证可重复验收。
+6. MES/WMS APP 只能在 A 套演示;B 套不连 165,造数绕不过去。
+7. 主数据与租户基线走版本化 `UpdateScripts`;业务单据走 `doc/plan/sql/demo/` 可重置手工脚本。
+
+---
+
+## 5. 与旧目录的关系
+
+| 路径 | 角色 |
+|---|---|
+| `doc/plan/UAT多租户数据方案/` | 2026-08-11 分析原稿,保留不删 |
+| `doc/plan/UAT测试数据准备/` | **2026-08-15 起的现行工作入口**(三套:UATTEST_CHL / UATTEST_CHLB / UATDEMO) |

+ 108 - 0
doc/plan/UAT测试数据准备/02-租户定位与决策定案.md

@@ -0,0 +1,108 @@
+# 02 · 租户定位与决策定案
+
+> 配套入口:[README.md](./README.md)  
+> 定案日期:2026-08-15  
+> 负责人确认:A=`UATTEST_CHL`,B=`UATTEST_CHLB`,Demo=`UATDEMO`;A 首批 5 组完整链路
+
+---
+
+## 1. 三套职责
+
+| 套 | 租户编码 | 租户 ID | 核心职责 | 禁止混入 |
+|---|---|---|---|---|
+| **A** | `UATTEST_CHL` | 待数据库恢复后回填 | 客户 UAT;5 组完整业务链;允许反复操作和变更状态 | Demo 固定趋势、只为好看而造的孤立 KPI |
+| **B** | `UATTEST_CHLB` | 待回填 | 从零点通;验证新租户能从建模开始完成主链路 | 预置完整业务单据、依赖人工改库跳步骤 |
+| **Demo** | `UATDEMO` | 待回填 | 九宫格、智慧诊断、S8、ChartBI 的稳定价值展示 | 客户随意修改、UAT 压测、不可控实时 165 依赖 |
+
+若三个租户尚不存在,必须通过正式租户创建流程建立,并验证菜单、组织、角色、账号、KPI、规则与数据源基线是否完整;不得只手工插 `sys_tenant`。
+
+---
+
+## 2. 数据策略定案
+
+### 2.0 真实脱敏数据优先(2026-08-16 定案)
+
+现有数据来自其他客户实际业务脱敏,业务分布、数量关系、BOM/工艺复杂度和状态组合比纯人工数据真实。三套准备均采用:
+
+> **先盘点真实数据 → 选择完整候选链 → 整体迁移并理顺 → 补齐断点/状态 → 正式重算与验收。**
+
+复制不限制于主数据或功能配置,业务单据、库存、质量、S8、历史趋势均可复制;是否采用只看能否提高 UAT/Demo 的真实性、完整性和顺畅度。
+
+数据源优先级:
+
+1. `AIDOP (797403760988229)`:主数据、业务全链、功能配置。
+2. `AIDOPB (824585161322565)`:设备、模工具、质量补充。
+3. `AIDOP-Demo (797403760988230)`:账号、角色、权限和 S8 演示结构。
+4. 人工构造:只补真实数据不存在的节点、异常状态和相对日期故事。
+
+迁移后必须重映射租户、主外键和敏感字段;历史 KPI/DWD 只能作参考/快照,最终以目标租户正式重算结果为准。
+
+### 2.1 A 套:5 组完整 UAT 场景
+
+- 每组使用独立的订单、工单、物料、PO、批次、检验单和异常单号。
+- 五组分别覆盖正常闭环、部分完成、延期缺料、质量不合格、异常恢复。
+- 同一组数据必须能从 S1 追溯到 S8,并能生成对应 DWD/KPI。
+- 允许客户改变状态;每组提供重置方式,不能共用同一条不可恢复的单据。
+- 是否接 165 MES/WMS 必须按目标环境配置确认;不能沿用旧 AIDOP 租户映射假设。
+
+详见 [07-A套五组完整UAT场景包.md](./07-A套五组完整UAT场景包.md)。
+
+### 2.2 B 套:从零点通
+
+- 初始仅预置租户基线、必要字典和最小主数据种子,不预置完整业务单据。
+- 用户应能通过界面逐步完成:主数据 → 订单 → 评审 → 下达 → 排程 → 齐套 → 采购 → 收货/IQC → 生产/FQC → 入库 → 异常。
+- 每一步的产出必须成为下一步输入;发现按钮、接口、数据转换或流程缺失时必须修复。
+- 为避免现场等待,可提供“检查点快照/重置脚本”,但快照只用于恢复,不作为通过证据。
+
+详见 [08-B套从零点通剧本.md](./08-B套从零点通剧本.md)。
+
+### 2.3 Demo 套:展示价值
+
+- 预置至少 90 天趋势和未来计划窗口,日期按相对当天滚动。
+- 九宫格指标有层次,不全部满分或全红;能从总览进入模块详情。
+- 智慧诊断必须有“指标异常 → 业务事实 → 根因 → 改善建议/任务”的可解释链。
+- S8 同时有待处理、处理中、已恢复、已关闭事件,并关联订单/工单/采购/质量对象。
+- ChartBI 至少准备经营、交付、制造、供应、质量五类主题数据和可复用演示看板。
+- Demo 数据脚本可重复重置,客户操作账号默认只读或限制写入。
+
+详见 [09-Demo价值展示数据方案.md](./09-Demo价值展示数据方案.md)。
+
+---
+
+## 3. 统一数据原则
+
+1. **关联优先**:先定全局业务键,再落表;禁止为了页面有数独立插孤儿行。
+2. **正式路径优先**:能通过 API/页面/作业产生的数据,不直接插最终结果表。
+3. **脱敏**:客户、供应商、联系人、电话、地址全部使用虚构值;制造属性可保留合理结构但不复制敏感名称。
+4. **可识别**:单号分别使用 `UATA-`、`UATB-`、`DEMO-` 前缀;每组带批次标记。
+5. **可重置**:A 按场景组重置,B 按步骤检查点重置,Demo 整套一键重置。
+6. **相对日期**:使用固定偏移表而非随机 `RAND()`,确保重跑结果一致且看板始终落在有效窗口。
+7. **隔离**:所有查询、写入、作业和清理必须显式限定租户;不得出现 `tenant_id=0/1/1300000000001` 的业务数据。
+
+---
+
+## 4. 修复边界
+
+负责人已确认最终目标包含系统修复,因此以下不再列为“本轮不做”:
+
+- 阻断用户继续操作的前后端缺陷。
+- 正式流程缺失的接口、按钮、状态推进或数据转换。
+- 新租户初始化缺失(菜单、角色、KPI、规则、ChartBI 等)。
+- 跨租户泄露、错误默认租户、刷新清空、死信不恢复等数据安全与可靠性问题。
+- 九宫格、智慧诊断、S8、ChartBI 为完成 Demo 所缺的必要功能。
+
+但每个修复包仍须遵守仓库规则:先列目标、文件、可见行为、跨模块影响和明确不做,经确认后实施;修复对应端代码时提交必须升相应版本。
+
+---
+
+## 5. 跨模块影响
+
+本计划会影响 S0–S9、共享工单/采购/库存事实、MDP/STG/STD/DWD/KPI、S8 事件、智慧诊断和 ChartBI。尤其:
+
+- 工单、工序、日计划同时影响 S1、S2、S3、S9。
+- PO、收货、IQC 同时影响 S3、S4、S5、质量与 KPI。
+- DWD/KPI 构建为全局粒度,不能按租户孤立重建。
+- S8 和智慧诊断必须消费统一业务事实,不能各造一套口径。
+- ChartBI 应读稳定的数据集/视图,不能直接复制业务 SQL 形成第三套口径。
+
+上述跨模块影响随“三套最终可用”目标一并纳入,但具体代码改动仍按修复包逐次确认。

+ 112 - 0
doc/plan/UAT测试数据准备/03-A套缺口补齐清单.md

@@ -0,0 +1,112 @@
+# 03 · A 套缺口补齐清单
+
+> **历史参考,非现行执行依据**:本文记录的是旧租户 `797403760988229 / AIDOP` 的存量缺口。2026-08-15 已定案新 A 套为 `UATTEST_CHL`,应按 [07-A套五组完整UAT场景包.md](./07-A套五组完整UAT场景包.md) 从 5 组完整链路准备;本文仅用于复用已有表范围与数量经验。  
+>
+> 租户:`797403760988229`(AIDOP)  
+> 原则:**只补缺口,不重造**已有 S1–S5 主线  
+> 现状时点:2026-08-11 实测;目标为本方案建议值
+
+---
+
+## 1. 定位
+
+A 套是唯一映射 165 的真机联调租户。S1–S5 已有 2026-06 的 50 单 UAT 包,以及 2026-08-09 起的真人端到端留证。本轮**禁止**重导订单/工单/采购主线。
+
+要补的是:打开 S6/S7/S8 以及若干过薄页面时不再空白,并让 S8 能挂到真实业务单上。
+
+登录:租户 **AIDOP**,账号 `AIDOPDemo` / `AIDopAdmin`。
+
+---
+
+## 2. 保持不动(已有主线)
+
+| 对象 | 2026-08-11 行数 | 说明 |
+|---|---|---|
+| `ItemMaster` / BOM / 工艺 / 产线 | 1.6 万 / 5.7 万 / 3.2 万 / 109 | 主数据充足 |
+| `crm_seorder` / `crm_seorderentry` | 19 / 26 | 另有 2026-06 FULL 50 单批次,勿覆盖 |
+| `WorkOrdMaster` / `WorkOrdRouting` / `PeriodSequenceDet` | 22 / 269 / 1296 | 排程基线,不重排 |
+| `ic_demandschedule` / `PurOrdMaster` | 1189 / 46 | S3 主线保持 |
+| `InvMaster` | 16022 | 库存保持 |
+| `ado_s9_kpi_value_l1_day` | 2319 | 由后续全局刷新更新,不手工改 |
+
+S1–S4 识别规则仍用:`bill_from` 含 `UAT导入:S1S4_UAT_…`;PO 前缀 `PO-UAT-`;发货前缀 `SH-UAT-`。主样例 `MPO482024102300001`。
+
+---
+
+## 3. 必须补的缺口
+
+### 3.1 S0 / S1 过薄项
+
+| 表 | 现状 | 目标 | 来源 | 通道 |
+|---|---|---|---|---|
+| `HolidayMaster` | **0** | **11** | 按法定假日构造(可对照 C 套已有 11 行) | MIG |
+| `LocationShelfMaster` | **2** | **~20** | 构造,覆盖多库位多层货架 | MIG |
+| `ado_product_design` | 2 | **5** | 构造,挂到已有订单物料 | SQL |
+| `ASNBOLShipperMaster` | 1 | **5** | 构造,挂到已有销售订单 | SQL |
+| `ShippingPlan` | **0** | **5** | 构造;S1 发运计划页当前空 | SQL |
+
+`HolidayMaster` 直接补、不重排(决策 D11)。
+
+### 3.2 S4 过薄项
+
+| 表 | 现状 | 目标 | 说明 |
+|---|---|---|---|
+| `scm_shd` / `scm_shdzb` | 5 / 8 | **12 / ~35** | 相对 46 张 PO 过少 |
+| `PurOrdRctMaster` / `PurOrdRctDetail` | 4 / 4 | **10 / ~25** | 收货与 PO 不成比例,看板几乎无意义 |
+
+补的收货/发货必须能追溯到已有 `PurOrdMaster`,不要另起一批无主 PO。
+
+### 3.3 S5 仓储单据
+
+| 表 | 现状 | 目标 | 说明 |
+|---|---|---|---|
+| `NbrMaster` | **9** | **30** | 覆盖收/发/退/领四类 |
+| `qms_qcp_inspecapplyn` / `qms_qcp_inspbill` | 5 / 5 | **12 / 12** | 含至少 1 条不合格样例 |
+
+IQC 须能挂到已有收货批次;2026-08-11 后 A 套已有真人扫码收货→报检→判定链路留证,补数不要覆盖那些真实单号。
+
+### 3.4 S6 生产执行(当前全空)
+
+| 表 | 现状 | 目标 | 来源 |
+|---|---|---|---|
+| `EquipmentList` | **0** | **30** | 从 C 套 165 台裁剪,改写 `tenant_id` |
+| `MoldToolList` | **0** | **40** | 从 C 套 290 副裁剪,改写 `tenant_id` |
+| `qms_gcjyd` | **0** | **10** | 构造,挂到已有工单工序 |
+
+**前置**:`MoldToolList` 跨租户泄露未修时不要导入(见 [02](./02-租户定位与决策定案.md) §6)。
+
+### 3.5 S7 成品仓储(当前全空)
+
+| 表 | 现状 | 目标 | 说明 |
+|---|---|---|---|
+| `qms_fqcbj` / `qms_qcpp_inspbill` | **0 / 0** | **8 / 8** | 必须挂钩已有完工或可完工工单 |
+| `NbrMaster`(WOI 类生产入库) | 含在 S5 计数内 | **+10** | 成品入库找不到来源工单则无效 |
+
+### 3.6 S8 异常与档案(当前业务租户下为空)
+
+| 表 | 现状 | 目标 | 说明 |
+|---|---|---|---|
+| `ado_s8_order_flow_order` | **0** | **10** | 单号对齐 A 套已有销售订单/工单,验证真机联动 |
+| `ado_s8_exception` | **0**(42 行在 `tenant_id=1`) | **~30** | 本方案**不改**那 42 行野值;新造必须写 `797403760988229` |
+
+---
+
+## 4. 不手工造、靠运行生成
+
+| 对象 | 做法 |
+|---|---|
+| `dwd_material_shortage` 等 DWD | P0 清理后**全局**重跑 MDP,不按租户调 |
+| `ado_s9_kpi_value_l1_day` | 全局 KPI 刷新 |
+| `b_examine_result` | 需要时在 A 套触发一次齐套检查,不插结论行 |
+| `PeriodSequenceDet` | **不重排**,保持现有 1296 行 |
+
+---
+
+## 5. A 套专项验收(补齐后)
+
+- S6 设备/模工具/IPQC、S7 FQC/成品入库、S8 档案/异常,登录 AIDOP 后打开**不为空、不报错**。
+- 新补单据能下钻到已有 S1 订单或 S2 工单,不出现「无主」行。
+- `HolidayMaster`=11 且 `PeriodSequenceDet` 行数与补前一致(不重排)。
+- MES APP 仍能取到当天日计划并投产;WMS 发料路径不被新补数打断。
+- `mdp_outbox` 无因本轮补数新增的死信。
+- 用 B 套账号登录,**看不到**本轮新写入 A 套的任何一行。

+ 158 - 0
doc/plan/UAT测试数据准备/04-B套全链路造数清单.md

@@ -0,0 +1,158 @@
+# 04 · B 套全链路造数清单
+
+> **历史参考,非现行执行依据**:本文记录的是旧 `AIDOP-Demo` 演示造数思路。2026-08-15 已定案新 B 套为 `UATTEST_CHLB`,目标是从零点通,应执行 [08-B套从零点通剧本.md](./08-B套从零点通剧本.md);Demo 改由 `UATDEMO` 承担,见 [09-Demo价值展示数据方案.md](./09-Demo价值展示数据方案.md)。  
+>
+> 租户:`797403760988230`(AIDOP-Demo)  
+> 原则:围绕 S8 现有 20 单档案,按成品倒推闭包,**全链路新造**  
+> 现状时点:2026-08-11 实测(业务表几乎全 0)
+
+---
+
+## 1. 定位
+
+B 套面向对外 Demo / 投标 / 培训。不连 165,断开 165 后页面行为必须不变。
+
+要回答的问题:打开任意叶子菜单(除 MES/WMS APP 现场执行)都不报错、不空白,并能讲完一条制造运营故事。
+
+登录:租户 **AIDOP-Demo**,账号 `AIDOPDemoTenant`。建议再加 2 个演示账号(计划员、车间主管),用于不同岗位首页。
+
+---
+
+## 2. 先补租户基线(P1,否则侧栏残缺)
+
+| 表 | 现状 | 目标 | 说明 |
+|---|---|---|---|
+| `SysTenantMenu` | **273** | **473** | 缺的主要是 S0 建模、S5 仓储、S9 数据中台 |
+| `SysRole` | 7 | **9** | 对齐 A 套 |
+| `SysUser` | 1 | **3** | 原管理员 + 计划员 + 车间主管 |
+| `ado_smart_ops_kpi_master` | **0** | **169** | 从 A/C 套克隆 KPI 主数据 |
+| `ado_smart_ops_kpi_calc_config` | 21 | 保持 | 已有 |
+
+菜单补齐会改变该租户所有用户的侧栏,属预期。
+
+---
+
+## 3. 主数据闭包(P3)
+
+从 A 套按 20 个成品倒推,改写 `tenant_id`,客户/供应商名称脱敏。
+
+| 表 | 目标约数 | 来源 |
+|---|---|---|
+| `ItemMaster` | ~300 | 裁剪 |
+| `ProductStructureMaster` | ~40 | 裁剪 |
+| `ProductStructureOp` | ~120 | 裁剪 |
+| `RoutingOpDetail` | ~200 | 裁剪 |
+| `StdOpMaster` | ~40 | 裁剪 |
+| `WorkCtrMaster` | 4 | 裁剪 |
+| `LineMaster` | **8** | 裁剪;覆盖机加/装配/焊接等不同类型,甘特图才有层次 |
+| `ProdLineDetail` | ~200 | 裁剪 |
+| `CustMaster` | **10** | **构造**(D13,不用真实客户名) |
+| `SuppMaster` | 15 | 裁剪后改名 |
+| `DepartmentMaster` | 8 | 构造 |
+| `EmployeeMaster` | 20 | 构造 |
+| `LocationMaster` | 12 | 构造 |
+| `LocationShelfMaster` | 24 | 构造 |
+| `ShopCalendarWorkCtr` | ~1500 | 构造,8 产线全年 |
+| `HolidayMaster` | 11 | 与 A 套同口径 |
+| `QualityLineWorkDetail` | ~40 | 构造 |
+| `InvMaster` | ~300 | 裁剪,对齐物料闭包 |
+| `EquipmentList` | 20 | 从 C 套裁剪(导入时机同 A,须隔离已修) |
+| `MoldToolList` | 25 | 同上 |
+
+**库存三档**(决定齐套/缺料看板好不好看):
+
+1. 一部分物料充足 → 齐套通过  
+2. 一部分不足 → 齐套缺料  
+3. 一部分完全没有 → 采购在途  
+
+---
+
+## 4. 业务单据顺链路(P4)
+
+顺序必须是:
+
+```text
+S8 已有 20 单档案(保持)
+  → S1 销售订单/明细/评审/设计/发运
+  → S2 工单/工序(日计划由排程生成,不手插)
+  → S3 物料需求/PR/PO/交货计划
+  → S4 发货/收货
+  → S5 仓储单据/IQC
+  → S6 IPQC(台账在 P3)
+  → S7 FQC/成品入库
+  → S8 异常(新造 ~30 条)
+  → 全局 MDP / KPI 刷新
+```
+
+### 4.1 S1
+
+| 表 | 目标 | 约束 |
+|---|---|---|
+| `crm_seorder` | **20** | 与 `ado_s8_order_flow_order` 现有 20 单单号一一对应(D5) |
+| `crm_seorderentry` | ~50 | 每单不少于 2 行 |
+| `ado_product_design` | 6 | 挂上述成品 |
+| `ado_contract_review` | 8 | **含驳回样例** |
+| `ASNBOLShipperMaster` | 6 | |
+| `ShippingPlan` | 6 | |
+| `WorkOrdMaster` | **30** | 每条订单明细至少 1 个工单 |
+| `WorkOrdDetail` | ~400 | |
+| `WorkOrdRouting` | ~300 | 每工单不少于 2 道工序 |
+
+若现有 S8 单号格式不适合直接作为 `crm_seorder` 单号,须先确认映射再造,不要各写各的。
+
+`b_examine_result` **不手工造**,主数据和库存就位后在 B 套触发一次齐套检查。
+
+### 4.2 S2
+
+| 表 | 目标 | 约束 |
+|---|---|---|
+| `PeriodSequenceDet` | 由排程生成 | **禁止手插** |
+| `ScheduleResultOpMaster` | 由排程生成 | 同上 |
+| `ScheduleExceptionMaster` | 由排程生成 | 同上 |
+
+理由:排程与产能、日历、工序强耦合,手插一点开工序明细就会露馅。
+
+### 4.3 S3 / S4
+
+| 表 | 目标 | 约束 |
+|---|---|---|
+| `ic_demandschedule` | ~200 | 追溯到 B 套工单 |
+| `srm_pr_main` | 20 | 来自缺料 |
+| `PurOrdMaster` / `PurOrdDetail` | 15 / ~45 | 覆盖**按期 / 延期 / 部分到货**三态 |
+| `srm_polist_ds` | ~45 | |
+| `scm_shd` / `scm_shdzb` | 10 / ~30 | 追溯到上述 PO |
+| `PurOrdRctMaster` / `PurOrdRctDetail` | 8 / ~20 | |
+
+### 4.4 S5 / S6 / S7 / S8
+
+| 表 | 目标 | 约束 |
+|---|---|---|
+| `NbrMaster` / `NbrDetail` | 25 / ~80 | 收/发/退/领都有 |
+| `qms_qcp_inspecapplyn` / `qms_qcp_inspbill` | 10 / 10 | 含不合格样例 |
+| `qms_gcjyd` | 10 | 挂 B 套工序 |
+| `qms_fqcbj` / `qms_qcpp_inspbill` | 8 / 8 | 挂 B 套完工工单 |
+| `NbrMaster`(WOI) | 8 | 成品入库 |
+| `ado_s8_order_flow_order` | **保持 20** | 不重造、不对齐错单号 |
+| `ado_s8_exception` | ~30 | `tenant_id` 必须是 `…230` |
+
+---
+
+## 5. 日期与打标
+
+- 订单日:`DATE_SUB(CURDATE(), INTERVAL … DAY)`,落在过去 30 天。
+- 交期:未来 10–45 天。
+- 日计划:以本周一为基准向后铺(由排程生成,脚本只保证工单/日历满足排程输入)。
+- 随机必须固定种子或预计算偏移表,重复执行看板数字不变。
+- 所有手造单据带统一前缀(建议 `DEMO-` / `AIDOPDEMO`),便于与 A 套真实/UAT 批次区分和清理。
+
+---
+
+## 6. B 套专项验收
+
+- 该租户下 S0–S9 叶子菜单打开不报错;除 [02](./02-租户定位与决策定案.md) §5 列出的 165 路径外**不为空**。
+- S8 的 20 单每一单都能下钻到 S1 订单、S2 工单、S3 采购。
+- 任意列表随机抽 3 行:物料有 BOM,工单有订单,采购单有需求。
+- 看板日期窗口落在「最近 30 天」。
+- 断开 165 连接后所有页面行为不变。
+- 用 A 套账号登录,**查不到** B 套任何一行业务单据。
+- 现场能走通:下单 → 评审 → 下达 → 排程 → 齐套(不必真连 MES APP)。

+ 192 - 0
doc/plan/UAT测试数据准备/05-逐表清单-AB.md

@@ -0,0 +1,192 @@
+# 05 · 逐表清单(A / B)
+
+> **历史盘库参考,非新三套目标值**:本表中的 A/B 指 2026-08-11 的 `AIDOP` / `AIDOP-Demo`。新目标租户已改为 `UATTEST_CHL` / `UATTEST_CHLB` / `UATDEMO`;数据库恢复后须重新盘库并另出三套逐表基线,不得直接按本表行数执行。  
+>
+> 配套:[README.md](./README.md)  
+> 现状列为 **2026-08-11 实测**(MySQL `aidopdev`),目标列为本方案建议值。  
+> 本清单只描述「造什么、造多少、从哪来」,**不含 SQL**。  
+> C 套数字仅作对照,本轮不改。
+
+---
+
+## 图例
+
+| 记号 | 含义 |
+|---|---|
+| **A** | `797403760988229` AIDOP |
+| **B** | `797403760988230` AIDOP-Demo |
+| `—` | 当前 0 行 |
+| **保持** | 不改动现状 |
+| **裁剪** | 从 A(或注明的源)按闭包裁剪后改写 `tenant_id` |
+| **构造** | 按剧本新造 |
+| **修复** | 现状不一致,须订正(本轮仅登记 C 套项) |
+
+通道:`MIG` = `UpdateScripts`;`SQL` = `doc/plan/sql/demo/`;`运行` = 正式作业生成。
+
+---
+
+## 0. 租户框架
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `SysTenantMenu` | 租户菜单 | 473 | 保持 | **273** | **473** | MIG |
+| `SysRole` | 角色 | 9 | 保持 | 7 | **9** | MIG |
+| `SysOrg` | 组织 | 1 | 保持 | 5 | 保持 | — |
+| `SysUser` | 用户 | 2 | 保持 | 1 | **3** | MIG |
+| `ado_smart_ops_kpi_master` | KPI 主数据 | 169 | 保持 | **—** | **169** | MIG |
+| `ado_smart_ops_kpi_calc_config` | KPI 计算配置 | 21 | 保持 | 21 | 保持 | — |
+
+B 套菜单缺口是最优先项。缺的主要是 S0 / S5 / S9。
+
+---
+
+## 1. S0 运营建模
+
+### 1.1 制造域
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `ItemMaster` | 物料 | 16150 | 保持 | — | **~300**(裁剪) | MIG |
+| `ProductStructureMaster` | BOM 头 | 57199 | 保持 | — | **~40**(裁剪) | MIG |
+| `ProductStructureOp` | BOM 工序 | 808 | 保持 | — | **~120**(裁剪) | MIG |
+| `RoutingOpDetail` | 工艺明细 | 32309 | 保持 | — | **~200**(裁剪) | MIG |
+| `StdOpMaster` | 标准工序 | 598 | 保持 | — | **~40**(裁剪) | MIG |
+| `WorkCtrMaster` | 工作中心 | 4 | 保持 | — | **4**(裁剪) | MIG |
+| `LineMaster` | 产线 | 109 | 保持 | — | **8**(裁剪) | MIG |
+| `ProdLineDetail` | 产能明细 | 5817 | 保持 | — | **~200**(裁剪) | MIG |
+
+对照:C 套 `LineMaster`=0 而 `ProdLineDetail`=4823(13 个产线码、4684 行 `Rate>0`)。属 D12,本轮不做。
+
+### 1.2 销售与供应域
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `CustMaster` | 客户 | 1267 | 保持 | — | **10**(构造,脱敏) | MIG |
+| `SuppMaster` | 供应商 | 3185 | 保持 | — | **15**(裁剪后改名) | MIG |
+
+### 1.3 仓储与人员
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `DepartmentMaster` | 部门 | 7 | 保持 | — | **8**(构造) | MIG |
+| `EmployeeMaster` | 员工 | 464 | 保持 | — | **20**(构造) | MIG |
+| `LocationMaster` | 库位 | 80 | 保持 | — | **12**(构造) | MIG |
+| `LocationShelfMaster` | 货架 | **2** | **~20** | — | **24** | MIG |
+
+---
+
+## 2. S1 产销协同
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `crm_seorder` | 销售订单 | 19 | 保持 | — | **20**(对齐 S8) | SQL |
+| `crm_seorderentry` | 订单明细 | 26 | 保持 | — | **~50** | SQL |
+| `ado_product_design` | 产品设计 | 2 | **5** | — | **6** | SQL |
+| `ado_contract_review` | 合同评审 | 5 | 保持 | — | **8**(含驳回) | SQL |
+| `ASNBOLShipperMaster` | 发货通知 | **1** | **5** | — | **6** | SQL |
+| `ShippingPlan` | 发运计划 | **—** | **5** | — | **6** | SQL |
+| `WorkOrdMaster` | 工单 | 22 | 保持 | — | **30** | SQL |
+| `WorkOrdDetail` | 工单用料 | 658 | 保持 | — | **~400** | SQL |
+| `WorkOrdRouting` | 工单工序 | 269 | 保持 | — | **~300** | SQL |
+| `b_examine_result` | 齐套结论 | 409 | 保持 | — | **运行生成** | 运行 |
+
+B 套 20 单必须与 `ado_s8_order_flow_order` 现有单号一一对应(D5)。
+
+---
+
+## 3. S2 制造协同
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `ShopCalendarWorkCtr` | 工作日历 | 574 | 保持 | — | **~1500**(8 产线全年) | MIG |
+| `HolidayMaster` | 节假日 | **—** | **11** | — | **11** | MIG |
+| `QualityLineWorkDetail` | 休息时段 | 138 | 保持 | — | **~40** | MIG |
+| `PeriodSequenceDet` | 日计划 | 1296 | **保持(不重排)** | — | **排程生成** | 运行 |
+| `ScheduleResultOpMaster` | 排程结果 | 39 | 保持 | — | **排程生成** | 运行 |
+| `ScheduleExceptionMaster` | 排产异常 | 928 | 保持 | — | **排程生成** | 运行 |
+
+A 套补节假日无排程风险(D11)。S2 排程不识别节假日,只登记不修。
+
+---
+
+## 4. S3 供应协同
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `ic_demandschedule` | 物料需求 | 1189 | 保持 | — | **~200** | SQL |
+| `srm_pr_main` | 采购申请 | 224 | 保持 | — | **20** | SQL |
+| `PurOrdMaster` | PO 头 | 46 | 保持 | — | **15** | SQL |
+| `PurOrdDetail` | PO 行 | 98 | 保持 | — | **~45** | SQL |
+| `srm_polist_ds` | 交货计划 | 161 | 保持 | — | **~45** | SQL |
+| `dwd_material_shortage` | 缺料事实 | **0(146 行在 tenant=0)** | **MDP 刷新** | — | **MDP 刷新** | 运行 |
+
+B 套采购须覆盖:按期到货、延期未到、部分到货。
+
+---
+
+## 5. S4 采购执行
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `scm_shd` | 发货单头 | 5 | **12** | — | **10** | SQL |
+| `scm_shdzb` | 发货单行 | 8 | **~35** | — | **~30** | SQL |
+| `PurOrdRctMaster` | 收货头 | **4** | **10** | — | **8** | SQL |
+| `PurOrdRctDetail` | 收货行 | **4** | **~25** | — | **~20** | SQL |
+
+---
+
+## 6. S5 物料仓储
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `InvMaster` | 库存 | 16022 | 保持 | — | **~300**(对齐闭包) | MIG |
+| `NbrMaster` | 仓储单据头 | **9** | **30** | — | **25** | SQL |
+| `NbrDetail` | 仓储单据行 | 271 | 保持 | — | **~80** | SQL |
+| `qms_qcp_inspecapplyn` | IQC 报检 | 5 | **12** | — | **10** | SQL |
+| `qms_qcp_inspbill` | IQC 检验单 | 5 | **12** | — | **10**(含不合格) | SQL |
+
+---
+
+## 7. S6 生产执行
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `EquipmentList` | 设备台账 | **—** | **30**(裁剪自 C) | — | **20** | MIG |
+| `MoldToolList` | 模工具 | **—** | **40**(裁剪自 C) | — | **25** | MIG |
+| `qms_gcjyd` | IPQC | **—** | **10** | — | **10** | SQL |
+
+对照:C 套设备 165、模工具 290。`MoldToolList` 当前无租户过滤,导入必须在隔离修复之后。
+
+---
+
+## 8. S7 成品仓储
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `qms_fqcbj` | FQC 报检 | **—** | **8** | — | **8** | SQL |
+| `qms_qcpp_inspbill` | FQC 检验单 | **—** | **8** | — | **8** | SQL |
+| `NbrMaster`(WOI) | 生产入库 | 含在 S5 | **+10** | — | **8** | SQL |
+
+S7 在 A/B 下都是从零做。FQC 必须挂钩工单。
+
+---
+
+## 9. S8 异常监控
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `ado_s8_order_flow_order` | 订单档案 | **—** | **10** | **20** | **保持** | SQL |
+| `ado_s8_exception` | 异常 | **0(42 行在 tenant=1)** | **~30** | — | **~30** | SQL |
+
+42 行野值归隔离批次 4,本方案不改。B 套 20 单档案是现成演示资产,D5 围绕它组织其余数据。
+
+---
+
+## 10. S9 运营指标
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | 通道 |
+|---|---|---|---|---|---|---|
+| `ado_s9_kpi_value_l1_day` | KPI 日值 | 2319 | **刷新生成** | — | **刷新生成** | 运行 |
+| `mdp_std_*` / `dwd_*` | 标准层/事实层 | 部分 tenant=0 | **清理后全局重刷** | — | **全局刷新生成** | 运行 |
+
+S9 全部不手工造。KPI 为空或离谱,说明上游单据不自洽。

+ 85 - 0
doc/plan/UAT测试数据准备/06-执行阶段与验收.md

@@ -0,0 +1,85 @@
+# 06 · 执行阶段与验收
+
+> 配套入口:[README.md](./README.md)  
+> 范围:A=`UATTEST_CHL`、B=`UATTEST_CHLB`、Demo=`UATDEMO`
+
+---
+
+## 1. 总体阶段
+
+| 阶段 | 内容 | 放行条件 |
+|---|---|---|
+| **P0 环境与隔离** | 核实/创建三租户;初始化菜单、组织、角色、账号、KPI/规则/ChartBI;完成 P-022 验收 | 三租户 ID 已回填;无零租户新增;齐套失败不清空 |
+| **P1 真实数据盘点与迁移** | 盘点 AIDOP/AIDOPB/AIDOP-Demo,评分选择真实全链,完成主数据/业务数据迁移、脱敏与补链 | A 五链、B 检查点、Demo 样本已选定;关联闭包且跨租户不可见 |
+| **P2 A01 跑通** | 先完成 A01 正常闭环,沿途修复阻断 | A01 页面/API/SQL/KPI 全通过 |
+| **P3 A02–A05** | 复制已验证模式,补部分完成、延期缺料、质量、异常恢复 | 五组均可独立重置并通过 |
+| **P4 B 从零点通** | 按 B00–B14 真人操作;逐步修复新租户缺失能力 | 不改库可完整走通 |
+| **P5 Demo 数据与能力** | 建立 90 天故事数据、九宫格、诊断、S8、ChartBI | 四大展示能力完成 |
+| **P6 全局构建与对账** | 所有上游就位后全局刷新 DWD/KPI,逐租户对账 | 事实、DWD、KPI、诊断、ChartBI 口径一致 |
+| **P7 稳定性与交付** | 重置、账号权限、操作手册、演示彩排、客户试用 | 连续两轮全流程无 P0/P1 |
+
+发现阻断时插入修复包,不继续批量造数。规则见 [10](./10-阻断问题修复与闭环机制.md)。
+
+---
+
+## 2. 并行与顺序
+
+- P0 必须最先完成。
+- P1 先统一完成源数据盘点和候选链评分,再按三租户并行迁移;映射、脱敏和补链规则只维护一份。
+- A01 必须先于 A02–A05;不能同时铺开五组。
+- B 的目标是发现新租户初始化与流程缺口,建议在 A01 稳定后开始。
+- Demo 数据设计可与 A/B 流程验证并行,但最终 KPI/诊断/ChartBI 构建必须等共享口径稳定。
+- DWD/KPI 是全局构建粒度:所有租户上游就位后统一刷新,再逐租户验收。
+
+---
+
+## 3. 总验收
+
+### 3.1 A 套
+
+- A01–A05 全部 `PASSED`。
+- 每组能从 S1 追溯到 S8,DWD/KPI 与业务事实一致。
+- 客户可修改状态并独立重置,不影响其他组。
+- 若目标包含 165,MES/WMS APP 操作与回读也必须通过。
+
+### 3.2 B 套
+
+- 从新租户初始态只通过界面/正式作业完成 B00–B14。
+- 不需要开发人员人工改库或切换到旧租户。
+- 新租户初始化能力可复用于下一客户。
+
+### 3.3 Demo 套
+
+- 九宫格、智慧诊断、S8、ChartBI 均有完整故事和下钻。
+- 90 天趋势包含异常、处置和恢复,不是静态同值。
+- 一键重置后 15 分钟内恢复标准演示态。
+- Demo 主故事不依赖 165 在线。
+
+### 3.4 通用
+
+- 三租户互相不可见、互不清理、互不污染。
+- 业务行不得落 `tenant_id=0/1/1300000000001`。
+- 无未关闭 P0/P1;P2 必须有负责人明确接受才可交付。
+- 所有脚本幂等、带验证、限定租户和批次。
+- 操作指南、测试账号、场景单号和证据齐全。
+
+---
+
+## 4. 开工检查单
+
+- [ ] 数据库连接恢复,三个租户的存在状态和 ID 已确认
+- [ ] 若新建,正式租户初始化流程已跑并留证
+- [ ] P-022:存量清理已执行
+- [ ] 连续两轮同步,`tenant_id=0` 不增长
+- [ ] 齐套刷新失败不清空既有数据
+- [ ] 三套账号和权限矩阵已确认
+- [ ] 165 对接范围:A/B/Demo 各自是否启用已明确
+- [ ] A01 的成品闭包和全局关联键已选定
+- [ ] Demo 的五类 ChartBI 主题和四条诊断链已确认
+- [ ] 未占用的迁移版本号已按当前 `.csproj` 确认
+
+---
+
+## 5. 文档阶段边界
+
+当前只完成目标与方案重整,尚未写 SQL、改代码或写库。后续每个实施/修复包仍需先列改动清单并确认;不能把本方案视为对任意大范围代码修改的一次性授权。

+ 91 - 0
doc/plan/UAT测试数据准备/07-A套五组完整UAT场景包.md

@@ -0,0 +1,91 @@
+# 07 · A 套五组完整 UAT 场景包
+
+> 租户:`UATTEST_CHL / 838257186181189`  
+> 用途:客户 UAT,可反复操作、可逐组重置  
+> 原则:每组独立业务键,同组跨 S1–S8 串联,最终进入 DWD/KPI
+
+---
+
+## 1. 五组场景
+
+| 组 | 场景 | 起点状态 | 客户主要操作 | 预期终态 |
+|---|---|---|---|---|
+| **A01** | 正常按期闭环 | 新订单 | 评审→下达→排程→齐套→采购/收货→检验→生产→FQC→入库/交付 | 全链成功、无未关闭异常 |
+| **A02** | 部分完成与分批交付 | 多行订单、分批需求 | 部分排程、部分到货、部分报工、分批入库/发运 | 数量口径正确,剩余量可追踪 |
+| **A03** | 供应延期与缺料 | 库存不足、PO 在途 | 齐套检查→采购→延期→催交/调整计划 | S3/S4 风险出现并传导到 S1/S2 |
+| **A04** | IQC/FQC 不合格与退货 | 正常收货或完工 | 报检→不合格→退货/返工→复检 | 质量、库存、供应风险同步更新 |
+| **A05** | 生产异常与恢复闭环 | 已下达、已排程 | 投产→异常→S8 处置→恢复→继续报工 | S8 状态闭环,诊断/KPI 反映恢复 |
+
+---
+
+## 2. 每组必须具备的对象
+
+| 层 | 最低要求 |
+|---|---|
+| S0 | 客户、成品、BOM、工艺、工作中心/产线、供应商、库位、质量规则 |
+| S1 | 1 张销售订单,至少 2 行;评审/设计/发运对象按场景配置 |
+| S2 | 每行至少 1 个工单;每工单至少 2 道工序;可执行排程与日计划 |
+| S3 | 每工单至少 2 条物料需求;缺料项能生成 PR/PO/交货计划 |
+| S4 | 发货/收货事实;A03 有延期,A04 有质量退货 |
+| S5 | 库存、收发领退、IQC;数量与批次可追溯 |
+| S6 | 报工/IPQC/异常;A05 必须有现场执行反馈 |
+| S7 | FQC、成品入库;A04 有不合格或复检 |
+| S8 | 订单档案与事件;A03–A05 至少各 1 条事件 |
+| S9 | 对应 KPI 能由正式刷新生成,数值与业务事实对账 |
+
+---
+
+## 3. 关联键规范
+
+以组号为根前缀,示例:
+
+```text
+UATA-A01-SO-001
+UATA-A01-WO-001
+UATA-A01-PR-001
+UATA-A01-PO-001
+UATA-A01-SH-001
+UATA-A01-IQC-001
+UATA-A01-FQC-001
+UATA-A01-EX-001
+```
+
+每个对象还必须保留系统正式主键和业务外键;前缀只用于识别和清理,不替代真实关联字段。
+
+---
+
+## 4. 数据生成策略
+
+1. 从现有真实脱敏业务中评分选择 5 条完整度高、状态互补的候选链,连同主数据闭包整体迁移。
+2. 将候选链整理到各组测试起点;客户需要执行的状态推进必须通过正式接口/页面。
+3. 允许使用可审计脚本完成迁移、ID/租户重映射、日期平移和测试起点恢复,但不得用脚本冒充客户操作结果。
+4. 真实链有缺口时,优先复用其他真实节点,再通过正式流程补齐,最后才人工构造。
+5. 排程、齐套、DWD、KPI、诊断结论必须由目标租户正式服务生成;迁移历史结果只作对照。
+6. 每组完成后保存数据库校验、API 结果和页面证据。
+
+---
+
+## 5. 组级重置
+
+每组应有独立重置脚本:
+
+- 只清理该租户、该组前缀及其派生数据。
+- 按外键/业务依赖逆序清理。
+- 不清共享主数据;共享主数据用引用计数或固定种子维护。
+- 重置后恢复到该组“起点状态”,不是全部删除。
+- Demo 和 B 套数据不得被触碰。
+
+---
+
+## 6. 验收门槛
+
+每组必须同时通过:
+
+- 页面操作无阻断,状态推进符合业务语义。
+- 数据库关联完整,无孤儿、无跨租户数据。
+- 数量守恒:订单、需求、采购、收货、合格、不合格、报工、入库可解释。
+- S8 事件和业务对象可双向下钻。
+- DWD/KPI 与业务事实抽样对账。
+- 重置后可再次按同一路径通过。
+
+A01 通过后才能复制模式到 A02–A05;不能五组一起灌完再统一排错。

+ 75 - 0
doc/plan/UAT测试数据准备/08-B套从零点通剧本.md

@@ -0,0 +1,75 @@
+# 08 · B 套从零点通剧本
+
+> 租户:`UATTEST_CHLB / 838257212780613`  
+> 用途:验证新租户用户可以不改库、一步步点通全链路  
+> 判定原则:前一步的正式产出必须成为后一步输入
+
+---
+
+## 1. 初始态与可复用数据
+
+B 套允许从 AIDOP 移植真实脱敏数据,包括:
+
+- 租户菜单、组织、角色、账号与权限模板;
+- 字典、编码规则、审批模板、KPI/规则基线;
+- 客户、供应商、物料、BOM、工艺、产线、日历等主数据;
+- 用于培训、分段复测或对照的业务单据检查点。
+
+为证明“从零可操作”,须另设一个清晰的 `B-FRESH-*` 业务批次:该批次的订单、工单、采购、收货、检验、入库和 S8 事件由 B00–B14 正式流程产生。既有真实数据可留在同租户作为查询参考,但不能冒充该批次的操作证据。
+
+---
+
+## 2. 逐步剧本
+
+| 步 | 用户操作 | 正式产出 | 继续条件 |
+|---|---|---|---|
+| B00 | 登录并切换岗位 | 正确菜单、首页、待办 | 无越权、无缺菜单 |
+| B01 | 建客户、供应商、员工/组织 | 主数据 | 列表可查且租户正确 |
+| B02 | 建物料、BOM、工艺、产线、日历、库位、质量规则 | 可生产/采购主数据闭包 | BOM 可展开、工艺有产线和产能 |
+| B03 | 新建销售订单 | 订单及明细 | 可进入评审 |
+| B04 | 发起并完成订单评审/设计 | 评审结果、交期、设计状态 | 可下达 |
+| B05 | 下达并生成工单 | 工单、用料、工序 | 能追溯订单明细 |
+| B06 | 执行排程 | 排程结果、日计划、异常 | 结果由正式排程生成 |
+| B07 | 执行齐套检查 | 齐套结论、缺料 | 结论与库存/BOM 一致 |
+| B08 | 缺料转 PR/PO/交货计划 | 采购链单据 | 可发布/跟踪 |
+| B09 | 供应商发货、采购收货 | 发货/收货/批次 | 可发起 IQC |
+| B10 | IQC 判定 | 合格入库或不合格处置 | 库存与 PO 数量同步 |
+| B11 | 领料、投产、报工、IPQC | 执行实绩 | 是否依赖 165 由环境配置决定 |
+| B12 | FQC、生产入库、发运 | 成品库存与交付 | 数量守恒 |
+| B13 | 制造/供应/质量异常进入 S8 | 事件、处置、恢复 | 可双向下钻 |
+| B14 | 刷新 DWD/KPI 并验收 | 看板与指标 | 与业务事实一致 |
+
+---
+
+## 3. 检查点
+
+为便于培训和复测,可在 B02、B05、B08、B10、B12 后提供检查点:
+
+- 检查点优先从已理顺的真实业务链生成,也可恢复到已由正式流程产生过的状态。
+- 首次验收必须从 B00 开始完整走一遍,不能直接加载检查点判通过。
+- 加载检查点须显式限定 `UATTEST_CHLB`,不得覆盖 A/Demo。
+
+---
+
+## 4. 阻断判定
+
+以下任一出现即停止当前步骤并进入问题闭环:
+
+- 页面无入口、按钮缺失或权限基线未初始化。
+- API 404/500、状态无法推进、审批回调未接。
+- 前一步产出在下一步列表不可见。
+- 必须人工 UPDATE/INSERT 才能继续。
+- 数据落错租户、DWD/KPI 查不到、跨租户可见。
+- 关键算法产出为空或明显违反数量/日期/产能约束。
+
+处理方式见 [10-阻断问题修复与闭环机制.md](./10-阻断问题修复与闭环机制.md)。
+
+---
+
+## 5. 最终验收
+
+- 新建租户后无需开发人员改库即可完成 B00–B14。
+- 普通业务用户只通过页面和正式作业完成主链。
+- 每一步有操作说明、输入、预期、失败提示和证据。
+- 重跑时单号不冲突、数据不串租户、结果可解释。
+- B 套通过后,租户初始化能力应能复用于后续新客户,而不是只对该租户写死。

+ 131 - 0
doc/plan/UAT测试数据准备/09-Demo价值展示数据方案.md

@@ -0,0 +1,131 @@
+# 09 · Demo 价值展示数据方案
+
+> 租户:`UATDEMO / 838257237606469`  
+> 用途:稳定演示九宫格、智慧诊断、S8 监控和 ChartBI  
+> 目标:展示“发现问题—定位根因—协同处置—结果改善”的完整价值,而非只展示有数据的页面
+
+---
+
+## 1. 总体故事
+
+建议统一为一个离散制造企业的 90 天运营故事:
+
+1. 前期订单增长、交付压力上升。
+2. 某关键供应商延期导致缺料,影响两条产线。
+3. 一批来料 IQC 不合格,加重齐套风险。
+4. 某产线设备异常与节拍下降,生产进度落后。
+5. S8 汇聚异常并分派处置。
+6. 采取替代供应、计划调整、设备维修和质量改善后,近 14 天指标恢复。
+
+所有展示组件必须围绕这条故事使用同一批订单、工单、供应商、物料、产线和事件。
+
+---
+
+## 2. 九宫格
+
+九宫格每个模块至少准备:
+
+- 1 个核心健康指标。
+- 1 个趋势指标。
+- 1 个风险/异常指标。
+- 可下钻的业务事实。
+
+数值分布应有层次:
+
+| 状态 | 建议占比 | 用途 |
+|---|---|---|
+| 绿色健康 | 40%–50% | 证明系统不是“全红演示” |
+| 黄色预警 | 30%–40% | 引导进入诊断 |
+| 红色异常 | 10%–20% | 形成重点故事 |
+| 恢复中 | 至少 2 项 | 展示改善闭环 |
+
+禁止直接手插 L1 数值而没有对应事实;必须能从卡片下钻到模块看板或业务单据。
+
+---
+
+## 3. 智慧诊断
+
+至少准备 4 条可解释诊断链:
+
+| 诊断 | 异常指标 | 根因事实 | 改善动作 |
+|---|---|---|---|
+| 交付风险 | 准交率下降 | 关键订单受缺料与设备停机影响 | 调整计划、催交、替代供应 |
+| 供应风险 | 到货及时率下降 | 供应商延期/部分到货 | 供应商协同与交货重排 |
+| 质量风险 | IQC 合格率下降 | 某供应商某物料批次不合格 | 退货、复检、供应商整改 |
+| 制造风险 | 计划达成率下降 | 产线异常、节拍下降、工序积压 | 维修、换线、调整优先级 |
+
+每条诊断应展示:指标偏差 → 关联事实 → 根因说明 → 建议 → 改善任务/事件 → 改善后趋势。
+
+---
+
+## 4. S8 监控
+
+事件状态必须同时存在:
+
+- 待确认
+- 已分派
+- 处理中
+- 已恢复待关闭
+- 已关闭
+- 超时/升级
+
+事件类型至少覆盖订单、供应、质量、生产、设备。每条事件必须关联一个真实 Demo 业务对象,并可从 S8 下钻、从业务对象反查 S8。
+
+Demo 重置后事件时间按当天相对化,避免全部成为过期历史。
+
+---
+
+## 5. ChartBI
+
+至少准备五个主题数据集和演示看板:
+
+| 主题 | 主要维度 | 主要指标 | 演示价值 |
+|---|---|---|---|
+| 经营/订单 | 客户、产品、区域、月份 | 订单额、交付额、积压 | 经营结构与趋势 |
+| 交付 | 客户、订单、承诺周 | 准交率、逾期量、周期 | 交付风险定位 |
+| 制造 | 工厂、车间、产线、工序 | 达成率、产量、节拍、在制 | 产能与瓶颈 |
+| 供应 | 供应商、物料、采购员 | 及时率、缺料数、在途量 | 供应风险 |
+| 质量 | 供应商、物料、缺陷、产线 | IQC/FQC 合格率、退货量 | 质量根因 |
+
+ChartBI 数据集应优先基于稳定 DWD/语义视图,避免直接复制业务表 SQL。指标名称、单位和过滤口径要与九宫格/智慧诊断一致。
+
+---
+
+## 6. 数据规模与日期
+
+数据构成采用“真实脱敏数据主体 + 故事化补链”:
+
+- 从 AIDOP 选择订单、工单、采购、库存、质量的真实分布和重点链路。
+- 从 AIDOPB 补设备、模工具或质量素材,从 AIDOP-Demo 参考 S8 演示结构。
+- 保持真实数量级、关联复杂度和状态比例,仅对敏感字段、业务编码和日期做可追踪转换。
+- 真实数据缺少某个诊断根因、恢复阶段或事件状态时,再围绕原链补造,不另建互不关联的“图表专用假数据”。
+
+- 业务窗口:历史 90 天 + 未来 30 天计划。
+- 订单:约 60–100 张;其中 10–15 张作为可讲述重点。
+- 工单:约 100–200 个;覆盖多产线和多状态。
+- 采购订单:约 50–100 张;供应商 15–30 家。
+- 质量批次:不少于 30 个,含 5–8 个异常批次。
+- S8 事件:约 40–60 条,状态分布完整。
+- KPI:由正式构建生成;趋势必须包含“恶化—处置—恢复”。
+
+所有日期使用固定相对偏移表。重置同一天结果完全一致,跨天自动平移。
+
+---
+
+## 7. 演示保护
+
+- Demo 管理员可重置;普通演示账号默认只读或只允许少量沙盒操作。
+- 一键重置只作用于 `UATDEMO` 和 `DEMO-` 批次。
+- 每次正式演示前运行健康检查:菜单、核心 API、KPI 新鲜度、S8 数量、ChartBI 数据集和关键下钻。
+- Demo 不依赖 165 实时可用;若需演示现场执行,使用录屏或独立演示通道,不让外部故障拖垮主故事。
+
+---
+
+## 8. 验收
+
+- 九宫格所有卡片有数、状态有层次、可下钻。
+- 四条诊断链能完整讲通并与事实对账。
+- S8 状态分布完整,事件关联真实 Demo 对象。
+- 五类 ChartBI 看板可筛选、联动、钻取,口径与系统看板一致。
+- 重置后 15 分钟内恢复到标准演示态。
+- 断开 165 后核心演示不受影响。

+ 99 - 0
doc/plan/UAT测试数据准备/10-阻断问题修复与闭环机制.md

@@ -0,0 +1,99 @@
+# 10 · 阻断问题修复与闭环机制
+
+> 适用:A UAT、B 从零点通、Demo 展示准备全过程  
+> 目标:发现阻断后修复系统本身,再回到原步骤验证;不靠改库绕过
+
+---
+
+## 1. 问题分类
+
+| 级别 | 定义 | 示例 | 处置 |
+|---|---|---|---|
+| **P0** | 数据安全、跨租户、数据破坏或全链路无法继续 | 跨租户可见、刷新先清空后失败、写入 `tenant_id=0` | 立即停止所有造数,优先修复 |
+| **P1** | 当前场景无法继续或核心功能缺失 | 无入口、API 404/500、状态无法推进、下一环节查不到 | 停止该场景,修复后原步重跑 |
+| **P2** | 可继续但结果/口径错误 | 数量不守恒、KPI 与事实不一致、诊断根因错误 | 不得宣告通过,修复并回归相关模块 |
+| **P3** | 不阻断但影响顺畅或展示质量 | 文案、排序、图表布局、空状态提示 | 登记,可按批次修复 |
+
+---
+
+## 2. 缺陷单最小信息
+
+每个问题必须记录:
+
+- 套别与租户编码:A/B/Demo。
+- 场景与步骤:如 `A03-S3`、`B09`、`DEMO-DIAG-02`。
+- 操作账号、时间、输入业务键。
+- 页面/API 现象、HTTP 状态、错误体。
+- 数据库前后快照与租户过滤证据。
+- 期望行为、实际行为、是否有临时绕过。
+- 影响模块和回归范围。
+
+---
+
+## 3. 修复工作包门禁
+
+每个修复包动手前必须单独给出并获确认:
+
+1. **目标**:修复哪个阻断,恢复哪一步。
+2. **改动文件**:前端、后端、迁移、测试、文档。
+3. **可见行为变化**。
+4. **跨模块影响**:共享工单、采购、库存、DWD/KPI、S8、ChartBI 等。
+5. **数据迁移/清理**:是否需要备份、回填、重算。
+6. **明确不做**。
+
+小问题最小修复,不借机重构。若必须动公共组件/接口,先单独说明跨模块影响。
+
+---
+
+## 4. 修复完成条件
+
+修复不以“构建通过”为完成,必须同时满足:
+
+- 单元/契约测试覆盖根因。
+- 对应端构建通过,无新增 lint 错误。
+- 必要迁移成功且可验证。
+- 回到**原始失败步骤**,使用相同业务键或重置后的同组数据重跑通过。
+- 上下游至少各回归一环。
+- 涉及共享事实时回归所有受影响模块。
+- 涉及 DWD/KPI 时完成事实对账。
+- 涉及租户时用另一个租户做不可见性验证。
+- 留下页面/API/SQL 证据。
+
+---
+
+## 5. 禁止绕过
+
+- 不允许直接 UPDATE 状态让流程“看起来完成”。
+- 不允许手插排程、齐套、KPI、诊断结果替代正式作业。
+- 不允许切到旧租户或系统默认租户规避新租户初始化问题。
+- 不允许把 165 失败简单改成本地假成功而不说明模式。
+- 不允许只修当前单号的特例。
+- 不允许 P0/P1 未关闭就批量复制数据到其他场景。
+
+---
+
+## 6. 缺失功能判定
+
+若需求/菜单/操作链明确需要某能力,而当前仅有空页面、骨架接口或只读列表,则按缺失功能处理:
+
+1. 先核对报价/需求/FUNC/现有设计,确认属于交付范围。
+2. 定义最小可用业务闭环,不只补一个按钮。
+3. 补齐实体、API、权限、前端、审计、测试、数据中台与必要文档。
+4. 新增业务叶子菜单时遵守 FUNC 编号规则。
+5. 完成后回到 A/B/Demo 原场景验收。
+
+超出交付范围的能力不得静默扩 scope;单独列决策项。
+
+---
+
+## 7. 进度状态
+
+每个场景步骤只允许:
+
+- `BLOCKED`:被 P0/P1 阻断。
+- `FIXING`:修复工作包已确认并执行。
+- `RETEST`:修复完成,等待原步重跑。
+- `PASSED`:原步和必要回归均通过。
+- `DEFERRED`:负责人明确接受延期,不计入“最终可用”。
+
+最终交付条件:A01–A05、B00–B14、Demo 四大能力全部 `PASSED`;不得用 `DEFERRED` 冒充完成。

+ 94 - 0
doc/plan/UAT测试数据准备/11-总执行任务书.md

@@ -0,0 +1,94 @@
+# 11 · 三套 UAT / Demo 数据总执行任务书
+
+> **可派发状态**:可派发,但必须从 WP0 开始  
+> **目标**:让其他执行模型按固定顺序完成三租户、数据、功能修复、全量验收与交付  
+> **验收基准**:[项目验收](../项目验收/README.md)
+
+---
+
+## 0. 执行者开工前必读
+
+1. [README.md](./README.md)
+2. [项目验收/README.md](../项目验收/README.md)
+3. [项目验收/04-双层验收范围与覆盖规则.md](../项目验收/04-双层验收范围与覆盖规则.md)
+4. [10-阻断问题修复与闭环机制.md](./10-阻断问题修复与闭环机制.md)
+5. 仓库 `AGENTS.md` 与 `.cursor/rules/`
+
+历史租户不是新三套目标,但应优先作为真实脱敏数据源:
+
+1. `AIDOP (797403760988229)`:业务数据、主数据、功能配置和全链候选的第一来源。
+2. `AIDOPB (824585161322565)`:设备、模工具、质量等补充来源。
+3. `AIDOP-Demo (797403760988230)`:账号、角色、权限和已有 S8 演示结构参考。
+4. 人工构造:仅用于补齐真实数据缺失的链路节点、状态和演示时间窗口。
+
+---
+
+## 1. 工作包
+
+| 包 | 内容 | 前置 | 放行产物 |
+|---|---|---|---|
+| [WP0](./WP0-租户创建与隔离放行.md) | 核实/创建三租户、初始化基线、隔离收口 | 无 | 三租户 ID 与 P0 放行报告 |
+| [WP1](./WP1-公共主数据闭包.md) | 全库盘点、真实候选链评分、基线/业务数据迁移与补链 | WP0 | 候选链清单、迁移映射、三套基线 |
+| [WP2](./WP2-A套五组UAT数据.md) | A01–A05 五组完整 UAT | WP1 | 五组数据、重置脚本、证据 |
+| [WP3](./WP3-B套从零点通.md) | B00–B14 从零操作 | WP1、A01 | 从零操作记录与缺陷闭环 |
+| [WP4](./WP4-Demo价值展示数据.md) | 九宫格、诊断、S8、ChartBI | WP1、口径稳定 | Demo 重置包与演示脚本 |
+| [WP5](./WP5-DWD-KPI诊断ChartBI对账.md) | 全局构建、逐租户事实对账 | WP2–WP4 | 对账报告 |
+| [WP6](./WP6-全量验收与交付.md) | 逐 FUNC 覆盖、稳定性、手册、签收准备 | WP5 | L1/L2 验收结果 |
+| [WPX](./WPX-阻断缺陷修复模板.md) | 任意阻断的独立修复包模板 | 发现问题 | 修复与原步复测证据 |
+
+---
+
+## 2. 统一执行规则
+
+- 一个执行模型一次只负责一个 WP;不要跨包顺手修改。
+- 每个 WP 开始先记录 Git 状态、当前前后端版本和目标数据库。
+- 写库前生成只读基线;危险清理先备份目标批次。
+- SQL 必须显式租户条件和批次前缀,禁止全表更新/删除。
+- 真实脱敏数据优先;不限制复制主数据或业务数据,但必须为目标场景服务并完成关联闭包。
+- 复制时不能只改 `tenant_id`:必须处理主键、内部外键、租户字段、敏感字段、日期窗口和外部系统映射。
+- 历史 DWD/KPI 可作为迁移前参考或初始展示快照,最终验收必须由目标租户正式重算并对账。
+- 业务结果优先通过页面/API/正式作业生成。
+- 发现 P0/P1 立即停止,按 WPX 派发修复,修复后回到原步骤。
+- 每包完成后更新逐 FUNC 覆盖矩阵,不只更新文字进度。
+- 未经用户明确要求,不提交 Git;用户说“提交”时按仓库规则 commit + push,并按实际前后端改动升版本。
+
+---
+
+## 3. 留证目录
+
+统一放置:
+
+```text
+doc/plan/UAT留证/<日期>-三套UAT数据准备/
+  00-environment/
+  WP0/
+  WP1/
+  WP2-A01/ ... WP2-A05/
+  WP3/
+  WP4/
+  WP5/
+  WP6/
+  defects/
+```
+
+每一步至少保留:
+
+- 操作说明与时间;
+- 页面截图;
+- API 请求/响应;
+- SQL 前后快照;
+- 作业/日志 ID;
+- 对应 FUNC/AC/TC;
+- 结论与遗留项。
+
+---
+
+## 4. 总完成条件
+
+1. A01–A05 全部通过且可独立重置。
+2. B00–B14 无人工改库即可走通。
+3. Demo 四大能力稳定、可重置、可讲述。
+4. L1 客户最低线全覆盖。
+5. L2 产品全量 FUNC 无遗漏、无 `GAP-TC/GAP-FUNC/BLOCKED`。
+6. 三租户隔离、数量、DWD/KPI/诊断/ChartBI 对账通过。
+7. P0/P1 缺陷为 0。

+ 75 - 0
doc/plan/UAT测试数据准备/README.md

@@ -0,0 +1,75 @@
+# UAT 测试数据准备
+
+> **状态**:WP0 技术放行通过,可进入 WP1;后端修复仍须随正式提交、升版和部署进入共享环境  
+> **整理日期**:2026-08-15  
+> **目标库**:MySQL `aidopdev`(连接可用;A=`838257186181189`,B=`838257212780613`,Demo=`838257237606469`)  
+> **最终目标**:彻底准备好可供客户顺畅 UAT、从零实操和稳定 Demo 演示的环境;过程中发现的阻断缺陷与缺失功能必须修复并回归,不以静态造数掩盖。
+
+---
+
+## 0. 三套目标
+
+| 套 | 租户编码 | 用途 | 完成定义 |
+|---|---|---|---|
+| **A|客户 UAT** | `UATTEST_CHL` | 客户执行完整 UAT | 首批 **5 组**端到端数据,覆盖正常、部分完成、延期缺料、质量不合格、异常闭环 |
+| **B|从零点通** | `UATTEST_CHLB` | 用户从主数据开始逐步操作 | 能从 S0 建模一路点通订单、评审、排程、齐套、采购、质检、入库与异常 |
+| **Demo|价值展示** | `UATDEMO` | 九宫格、智慧诊断、S8、ChartBI 演示 | 数据稳定、趋势清晰、诊断有因果、异常可下钻、ChartBI 有可讲述的业务价值 |
+
+三套必须物理隔离、用途隔离、账号隔离。A 的 UAT 操作不得破坏 Demo 展示基线;Demo 不承担从零操作验证;B 不依赖预置业务单据才能完成主流程。
+
+---
+
+## 1. 本目录文档
+
+| 文档 | 状态与内容 |
+|---|---|
+| [01-历史分析回顾与现状.md](./01-历史分析回顾与现状.md) | 历史 UAT 包、多租户盘库、租户隔离前置 |
+| [02-租户定位与决策定案.md](./02-租户定位与决策定案.md) | **现行**三套定位、数据原则、修复边界 |
+| [03-A套缺口补齐清单.md](./03-A套缺口补齐清单.md) | 历史 AIDOP 存量缺口参考,非新 A 套执行依据 |
+| [04-B套全链路造数清单.md](./04-B套全链路造数清单.md) | 历史 AIDOP-Demo 造数参考,非新 B 套执行依据 |
+| [05-逐表清单-AB.md](./05-逐表清单-AB.md) | 2026-08-11 历史盘库基线 |
+| [06-执行阶段与验收.md](./06-执行阶段与验收.md) | **现行**三套实施顺序与总验收 |
+| [07-A套五组完整UAT场景包.md](./07-A套五组完整UAT场景包.md) | A 套 5 组场景、状态和关联键 |
+| [08-B套从零点通剧本.md](./08-B套从零点通剧本.md) | B 套逐步操作剧本与每步验收 |
+| [09-Demo价值展示数据方案.md](./09-Demo价值展示数据方案.md) | 九宫格、智慧诊断、S8、ChartBI 展示设计 |
+| [10-阻断问题修复与闭环机制.md](./10-阻断问题修复与闭环机制.md) | 问题发现、修复、回归、继续执行的门禁 |
+| [11-总执行任务书.md](./11-总执行任务书.md) | **其他大模型执行入口**:WP0–WP6、WPX 的顺序、放行条件与证据规范 |
+| [WP0-执行记录-20260816.md](./WP0-执行记录-20260816.md) | 三租户 ID、初始化修复、隔离证据和当前未放行项 |
+| [数据瘦身执行记录-20260816.md](./数据瘦身执行记录-20260816.md) | `aidopdev` 全链路优先抽样、历史污染清理、容量回收与周期保留记录 |
+
+阅读顺序:先读 [项目验收](../项目验收/README.md),再读 `01` → `02` → `07`/`08`/`09` → `10` → `11`。
+
+---
+
+## 2. 不可降级的原则
+
+1. **业务全链路,不是页面填数**:订单、工单、物料、采购、库存、质检、异常、KPI 必须使用同一组关联键。
+2. **正式功能生成正式结果**:排程结果、齐套结论、DWD/KPI、诊断结果不得用手工插表冒充成功。
+3. **阻断必须修复**:任何导致用户无法继续、数据口径错误、跨租户泄露或关键入口缺失的问题,进入 [10](./10-阻断问题修复与闭环机制.md) 闭环。
+4. **修复后回到原步骤重跑**:不能用绕库、改状态、跳步骤替代验收。
+5. **三套相互隔离**:脚本、单号、账号、数据批次和清理范围都必须按租户限定。
+6. **P0 先行**:先完成租户创建/基线和隔离收口验收,再写业务数据。
+7. **DWD/KPI 全局刷新一次**:所有租户上游就位后统一构建,再逐租户验收。
+8. **真实脱敏数据优先**:允许复制主数据和业务数据;先选择并迁移完整真实链,再补断点和场景状态,人工构造只补真实数据没有的部分。
+
+---
+
+## 3. 当前阻塞
+
+- 三租户已创建并回填 ID;管理员登录、KPI 主数据/布局安全初始化和 KPI 树隔离已通过。
+- P-022 的连续两轮同步、A/B/Demo 带前缀数据隔离和齐套刷新失败事务保护均已通过。
+- S3/S4 作用域、`S3_INVENTORY`、S8 硬编码租户等启动问题已修复;主要业务/DWD 历史污染已清理。
+- `aidopdev` 已按全链路优先规则由约 31.3 GB 瘦身至约 2.1 GB;三目标租户 S1–S4 两轮同步后代表表无效租户/工厂作用域均为 0。
+- S6 模工具台账匿名跨租户读取已修复;S6 同类服务审计未发现其它 WP0 阻断项。
+
+---
+
+## 4. 相关上游
+
+| 文档 | 关系 |
+|---|---|
+| [S1-S4全局UAT场景测试数据生成方案.md](../S1-S4全局UAT场景测试数据生成方案.md) | 已有跨模块关联原则和历史数据包 |
+| [S1-S4_UAT场景测试数据说明与测试指南.md](../S1-S4_UAT场景测试数据说明与测试指南.md) | 历史 UAT 操作指南与样例 |
+| [租户隔离收口与造数前置任务书.md](../租户隔离收口与造数前置任务书.md) | P0 放行条件 |
+| [UAT多租户数据方案/README.md](../UAT多租户数据方案/README.md) | 2026-08-11 盘库原稿 |
+| [项目验收/README.md](../项目验收/README.md) | L1 客户最低线 + L2 产品全量线,三套数据的验收基准 |

+ 204 - 0
doc/plan/UAT测试数据准备/WP0-执行记录-20260816.md

@@ -0,0 +1,204 @@
+# WP0 · 租户创建、初始化与隔离执行记录
+
+> 执行日期:2026-08-16  
+> 数据库:MySQL `aidopdev`  
+> 当前结论:**三租户已创建;安全初始化、基础 API 隔离及 S1–S4 连续两轮同步通过;WP0 尚余隔离写入与齐套失败保护等放行项**
+
+## 1. 目标租户
+
+| 套 | 编码 | TenantId | 管理员账号 | 状态 |
+|---|---|---:|---|---|
+| A | `UATTEST_CHL` | `838257186181189` | `UATAdminA` | 可登录 |
+| B | `UATTEST_CHLB` | `838257212780613` | `UATAdminB` | 可登录 |
+| Demo | `UATDEMO` | `838257237606469` | `UATDemoAdmin` | 可登录 |
+
+密码未写入仓库;管理员密码已通过正式重置接口设置并完成登录验证。
+
+## 2. 创建与修复过程
+
+1. 只读盘点确认三个目标租户原先不存在。
+2. 首次通过正式 `POST /api/sysTenant/add` 创建后,发现新租户只有:
+   - 482 条租户菜单;
+   - 默认角色 + 7 个 S8 业务角色;
+   - 21 条 KPI 计算配置。
+3. 新租户缺少 KPI 主数据和布局,判定为通用初始化缺陷。
+4. 修改通用初始化:
+   - `AidopKpiMasterSeed.EnsureSeed` 增加 `includeDemoValues` 安全开关;
+   - `AidopTenantInitService` 对新租户初始化 KPI 主数据/布局时关闭 Demo 指标值写入。
+5. 备份首次创建记录后,通过修复版正式 API 重建三租户。
+
+备份表:
+
+- `wp0_bak_SysTenant_20260816`
+- `wp0_bak_SysOrg_20260816`
+- `wp0_bak_SysUser_20260816`
+- `wp0_bak_SysRole_20260816`
+- `wp0_bak_SysPos_20260816`
+- `wp0_bak_SysTenantMenu_20260816`
+- `wp0_bak_SysRoleMenu_20260816`
+- `wp0_bak_kpi_calc_20260816`
+
+## 3. 初始化核验
+
+每个目标租户当前均有:
+
+| 对象 | 每租户数量 |
+|---|---:|
+| 租户菜单 | 482 |
+| 角色 | 8 |
+| KPI 主数据 | 169 |
+| KPI 布局 | 169 |
+| 首页模块 | 9 |
+| KPI 计算配置 | 21 |
+
+安全模式核验:三租户的 KPI L1/L2/L3/L4 结果表均为 0 行,没有由初始化过程写入 Demo 指标值。
+
+业务账号基线:
+
+- A:`UATPlanA`、`UATPurchaseA`、`UATQualityA`、`UATWarehouseA`、`UATExceptionA`
+- B:`UATPlanB`、`UATPurchaseB`、`UATQualityB`、`UATWarehouseB`、`UATExceptionB`
+- Demo:`UATDemoOperator`
+
+上述账号均启用;A/B 业务账号各绑定 2–3 个角色,Demo 演示账号绑定 8 个业务角色。
+
+自动化验证:
+
+- `AidopTenantInitContractTests`:1/1 通过。
+- 后端隔离发布构建:0 编译错误。
+
+## 4. API 隔离核验
+
+分别使用三个租户管理员调用 KPI 树接口:
+
+| 租户 | 登录 | KPI 节点 | 与另两租户 ID 交集 |
+|---|---|---:|---:|
+| A | 通过 | 169 | 0 |
+| B | 通过 | 169 | 0 |
+| Demo | 通过 | 169 | 0 |
+
+结论:当前样本下 JWT 租户解析和 KPI 查询隔离通过。
+
+## 5. 污染与启动问题处置
+
+### P0:历史错误租户数据
+
+2026-08-16 已完成主要业务表、DWD 和 KPI 原子表的
+`tenant_id=0/1/1300000000001` 清理,并按全链路样本优先规则完成数据瘦身。
+代表表复核均为 0 行,详见
+[数据瘦身执行记录-20260816.md](./数据瘦身执行记录-20260816.md)。
+
+历史存量处置已完成。2026-08-17 已完成三目标租户 S1–S4 连续两轮同步,
+同步后代表表未新增无效租户或 `factory_id=tenant_id` 数据。
+
+### 启动作业错误
+
+已完成:
+
+- S3/S4 从 STG 到 STD/DWD/KPI 的 `tenant_id + factory_id` 作用域修复;
+- S3/S4 STG 审计列和写入契约校验;
+- 废弃 `S3_INVENTORY`,不再访问不存在的 `InvMaster`;
+- S8 调度作用域与历史运行数据迁移;
+- 原始启动和相关单元/契约测试回归。
+
+2026-08-17 连续两轮回归未再次出现连接复用、乱序包、活动 Reader 冲突、
+`sync_batch_id` 超长、空工厂或跨租户约束错误。
+
+### 连续两轮同步证据
+
+目标租户:
+
+- A `838257186181189`
+- B `838257212780613`
+- Demo `838257237606469`
+
+第二轮对应重算任务为 `2874`–`2885`,三租户的 S1、S2、S3、S4
+共 12 个任务全部 `SUCCESS`,无 `error_message`,无遗留 `QUEUED/RUNNING`。
+
+同步后复核以下对象的无效作用域行数均为 0:
+
+- `mdp_stg_so`
+- `mdp_std_so`
+- `dwd_ship_trans`
+- `dwd_requirement_examine_detail`
+- `ado_smart_ops_kpi_atomic_day`
+- `ado_module_dashboard_rebuild_job`
+
+判定条件为 `tenant_id IS NULL/IN (0,1,1300000000001)` 或
+`factory_id=tenant_id`(按对象实际非空约束取适用项)。
+
+本轮目标租户尚未迁入业务数据,因此部分 STD/DWD/KPI 结果为 0 行;
+这不影响 WP0 隔离与调度放行判断,但不能替代 WP1–WP5 的业务数据验收。
+
+### 带前缀数据隔离证据
+
+2026-08-17 在 `mdp_stg_so` 临时写入 A/B/Demo 三条带不同前缀的探针数据。
+按各自 `tenant_id` 查询时均只返回本租户 1 行,外租户可见数均为 0;
+取证后 3 条探针数据已全部删除。该结果与前述三管理员 KPI API 隔离结果共同证明
+当前 JWT 查询层与 MDP 租户作用域均能隔离三个目标租户。
+
+### 齐套刷新失败保护
+
+`WorkOrderMaterialReadinessService.Refresh` 原有逻辑已经保证存储过程失败时不会预删
+`dwd_material_readiness`。本轮进一步将存储过程成功后的齐套旧数据清理、齐套 upsert、
+缺料旧数据清理和缺料 insert 纳入同一数据库事务;任一步失败都会回滚,避免刷新后半段
+失败造成旧结果被部分清空。相关项目编译通过,WP0 范围契约测试 34/34 通过。
+
+### S6 模工具台账隔离
+
+`ToolingLedgerService` 原为匿名、无租户过滤的全表只读接口,存在读取其它租户
+`MoldToolList` 数据的风险。本轮已:
+
+- 移除匿名访问;
+- 列表和类型下拉均通过 `AidopTenantScope.ResolveOrThrow` 取得可信租户;
+- SQL 强制增加 `tenant_id=@TenantId`,不再接受客户端租户覆盖。
+
+当前 290 条模工具数据全部属于源租户 `824585161322565`,因此 A/B/Demo 在 WP 数据迁移前
+看到空列表是正确隔离结果。
+
+S6 同类服务审计结论:
+
+- `IpqcInspectionService`、`IpqcInspectionFlowService`、
+  `IpqcInspectionDispositionService` 的读取均有可信租户约束;
+- `ProductionEquipmentLedgerService` 已强制使用可信租户,匿名标记与冗余 DTO
+  属于后续一致性加固项,不构成跨租户读取;
+- `IpqcInspectionFlowBizHandler` 使用原始 JWT 租户,缺少超管哨兵拒绝属于低风险加固项,
+  不构成当前列表接口泄露;
+- 未发现其它阻断 WP0 的 S6 Manufacturing 读取接口。
+
+### 隔离回归调度保护
+
+发现 `SmartOpsKpiMdpBootstrapJob` 会在隔离进程每次启动时再次为全部租户入队。
+已增加 `AIDOP_MDP_BOOTSTRAP_ENABLED=false` 环境变量早退开关;默认未设置时仍保持生产行为。
+误触发的任务 `2911`–`2938` 共 28 个已全部标记为 `FAILED`,未作为有效回归证据。
+开关契约及 WP0 范围测试 15/15 通过。
+
+## 6. 当前判定
+
+- [x] 三租户正式创建
+- [x] 管理员可登录
+- [x] 菜单、S8 角色、KPI 主数据/布局/计算配置初始化
+- [x] 基础 API 租户隔离
+- [x] 主要历史错误租户数据清理
+- [x] S3/S4/S8 启动作业阻断修复
+- [x] 连续两轮同步不新增无效租户/工厂作用域
+- [x] 连接复用、批次长度和空工厂错误不再复现
+- [x] A/B/Demo 带前缀测试数据正反向不可见
+- [x] 齐套刷新失败不清空旧结果(事务保护)
+- [x] 业务账号与权限模板
+- [x] `MoldToolList` 已知跨租户读取修复及 S6 同类服务审计
+
+## 7. 最终 Gate
+
+2026-08-17 判定:**WP0 技术放行通过,可以进入 WP1 数据迁移准备。**
+
+依据:
+
+- 三目标租户、管理员、业务账号、角色和基础配置完整;
+- API、MDP 探针和 S6 业务读取的已知跨租户泄露为 0;
+- 历史无效租户数据已清理,数据库约束与作用域守卫已生效;
+- S1–S4 连续两轮重算成功,未新增无效租户/工厂作用域;
+- 齐套刷新具备失败回滚保护;
+- 无未关闭的已知 P0。
+
+交付约束:本轮后端修复仍须随正式后端提交、版本递增和部署进入共享运行环境;
+在此之前,旧运行节点不代表本次技术 Gate 版本。

+ 102 - 0
doc/plan/UAT测试数据准备/WP0-租户创建与隔离放行.md

@@ -0,0 +1,102 @@
+# WP0 · 租户创建与隔离放行
+
+## 1. 目标
+
+核实或正式创建 `UATTEST_CHL`、`UATTEST_CHLB`、`UATDEMO`,验证新租户基线和租户隔离,产出后续所有 WP 使用的真实租户 ID。
+
+---
+
+## 2. 只读基线
+
+连接 `aidopdev` 后依次执行:
+
+1. 查询 `SysTenant` 并关联 `SysOrg`(`SysTenant.OrgId = SysOrg.Id`)核对三个编码/名称;MySQL 表名区分大小写。
+2. 查询每租户 `SysTenantMenu`、`SysRole`、`SysOrg`、`SysUser` 数量。
+3. 查询 KPI、规则、审批、数据源、ChartBI 相关基线表的租户分布。
+4. 检查业务/DWD/STD 表中 `tenant_id IN (0,1,1300000000001)` 的异常行。
+5. 核对 P-022 的迁移日志和连续两轮同步结果。
+
+不得预设表名大小写或租户 ID;先通过表结构和现有代码确认。
+
+---
+
+## 3. 创建与初始化
+
+若租户不存在:
+
+1. 使用 `SysTenantService` 正式创建流程,不手插 `SysTenant`。
+2. 分别创建管理员和测试角色账号。
+3. 验证 `AidopTenantInitService` 是否自动补齐:
+   - 组织、角色、菜单;
+   - KPI 主数据与计算配置;
+   - S8 类型/规则;
+   - 审批模板;
+   - 数据源/任务基线;
+   - ChartBI 必要基线。
+4. 缺项按 `WPX` 修复为通用新租户初始化能力,禁止只给三个 ID 写死补丁。
+
+### 3.1 基线移植来源
+
+租户正式创建并完成系统默认初始化后,再执行差异移植:
+
+| 目标内容 | 首选来源 | 说明 |
+|---|---|---|
+| 基础主数据、功能配置 | `AIDOP / 797403760988229` | 菜单对应配置、业务参数、KPI/规则、审批、数据中台配置等 |
+| 账号、角色、权限模板 | `AIDOP-Demo / 797403760988230` | 只参考结构和授权关系,用户重新创建,不复制密码/Token |
+| 设备、模工具、质量补充 | `AIDOPB / 824585161322565` | 后续 WP1 按候选链需要选用 |
+
+移植可以包含业务配置及业务数据,但 WP0 只处理“租户可用所必需的基线”;大规模业务数据迁移统一由 WP1 执行,避免初始化与造数混在一起无法回滚。
+
+### 3.2 配置移植步骤
+
+1. 列出 AIDOP 与目标租户的配置表差异,按“全局共享 / 每租户必备 / 业务实例”分类。
+2. 先复制每租户必备配置,再补账号角色模板。
+3. 新建主键并重映射父子引用、角色菜单引用和组织引用。
+4. 数据源、AppKey、连接串、165 映射等外部配置不原样复制,按目标套用途重新配置。
+5. 每张表记录源行数、目标行数、映射规则和校验结果。
+6. 使用三套账号逐页验证菜单、权限、默认参数和空状态。
+
+建议账号:
+
+| 租户 | 管理员 | 业务账号 |
+|---|---|---|
+| UATTEST_CHL | `UATAdminA` | 计划员、采购员、质检员、车间主管、异常负责人 |
+| UATTEST_CHLB | `UATAdminB` | 同上,用于从零点通 |
+| UATDEMO | `DemoAdmin` | 只读演示账号、有限操作账号 |
+
+密码不写入 Git 文档。
+
+---
+
+## 4. 隔离放行
+
+必须验证:
+
+- A 写入一条带前缀测试数据,B/Demo 查不到;反向同理。
+- 原生 SQL、SqlSugar、MDP Writer、DWD/KPI 查询均受租户约束。
+- 连续两轮同步不新增 `tenant_id=0`。
+- 齐套刷新失败不会清空旧结果。
+- `MoldToolList` 等已知无过滤服务已修复或明确阻断后续相关功能。
+- `ado_s8_exception` 不再把业务事件写到 `tenant_id=1`。
+
+---
+
+## 5. 产物
+
+- `tenant-baseline.md`:三个租户 ID、账号角色、基线数量。
+- `tenant-config-migration.xlsx`:配置表、源租户、目标租户、主键映射、行数与校验结果。
+- `tenant-isolation.sql/result.md`:正反向不可见性证据。
+- `init-gap.md`:初始化缺失项及 WPX 链接。
+- 更新所有 UAT 文档中的“ID 待回填”。
+
+---
+
+## 6. 放行条件
+
+- 三租户正式存在且可登录。
+- 菜单、角色、基础配置完整。
+- P-022 验收通过。
+- 已知跨租户泄露为 0。
+- 没有未关闭 P0。
+
+未满足不得进入 WP1。

+ 180 - 0
doc/plan/UAT测试数据准备/WP1-公共主数据闭包.md

@@ -0,0 +1,180 @@
+# WP1 · 真实数据候选链盘点、评分、迁移与补链
+
+## 1. 目标
+
+以现有真实脱敏数据为主体,盘点 AIDOP/AIDOPB/AIDOP-Demo 的主数据与业务链,选出适合 A 五组、B 检查点和 Demo 展示的候选数据;完成租户迁移、关系闭包、日期处理和缺口补齐。
+
+本包不追求“最少造数”,而追求**业务真实性、关联完整性和后续操作顺畅**。
+
+---
+
+## 2. 数据源
+
+| 优先级 | 源租户 | 用途 |
+|---:|---|---|
+| 1 | `AIDOP / 797403760988229` | 主数据、订单/工单/采购/库存/质量全链、功能配置 |
+| 2 | `AIDOPB / 824585161322565` | 设备、模工具、质量及缺少的制造资源 |
+| 3 | `AIDOP-Demo / 797403760988230` | 账号权限参考、S8 演示结构、可用展示素材 |
+| 4 | 人工补造 | 真实链不存在的节点、异常状态、恢复过程和相对日期 |
+
+复制范围不限制为主数据;业务单据、库存、质量、异常、历史趋势均可纳入。
+
+---
+
+## 3. WP1-A:全库只读盘点
+
+### 3.1 先确认结构
+
+执行模型必须:
+
+1. 用 `SHOW TABLES` / `DESCRIBE` 确认实际表名和字段大小写。
+2. 对照后端实体、Service SQL 和前端 API 确认关联字段。
+3. 建立 `source-table-inventory.xlsx`:
+   - 模块;
+   - 表;
+   - 租户字段;
+   - 行数;
+   - 日期范围;
+   - 业务键;
+   - 父子关联;
+   - 敏感字段;
+   - 是否可迁移。
+
+不得仅凭历史文档猜字段。
+
+### 3.2 盘点业务域
+
+至少覆盖:
+
+```text
+S0 客户/供应商/物料/BOM/工艺/产线/日历/质量规范
+S1 订单/明细/设计/评审/发运
+S2 工单/用料/工序/排程/日计划/实绩
+S3 需求/PR/PO/交货计划/齐套
+S4 发货/收货/退货
+S5 库存/收发领退/IQC/批次/条码
+S6 设备/模具/报工/IPQC/ANDON
+S7 FQC/成品入库/销售出库/追溯
+S8 订单档案/异常/事件/通知/处置
+S9 STD/DWD/KPI/诊断/ChartBI 数据集
+```
+
+---
+
+## 4. WP1-B:候选链发现与评分
+
+以销售订单/订单明细为起点,沿真实关联向下查找:
+
+```text
+订单 → 工单 → 工序/计划/实绩
+     → 物料需求 → PR → PO → 交货/发货/收货
+     → IQC/库存/领料 → 报工/IPQC → FQC/成品入库/发运
+     → S8 异常 → DWD/KPI
+```
+
+每条候选链按 100 分评分:
+
+| 维度 | 分值 | 判定 |
+|---|---:|---|
+| S1–S4 关联完整 | 25 | 订单、工单、需求、PO、交货可追溯 |
+| S5–S7 执行完整 | 25 | 库存、检验、领料、报工、入库至少覆盖主要节点 |
+| 状态丰富度 | 15 | 完成、部分、延期、质量、异常等 |
+| 主数据闭包 | 15 | BOM、工艺、产线、日历、供应商齐全 |
+| S8/S9 可用性 | 10 | 有异常/KPI 或易补齐 |
+| 数据质量 | 10 | 无明显孤儿、数量矛盾、错误租户 |
+
+输出 `candidate-chain-score.xlsx`,保留:
+
+- 源租户;
+- 全局业务键;
+- 各模块单号;
+- 得分与缺口;
+- 推荐用途(A01–A05 / B 检查点 / Demo);
+- 是否需日期平移;
+- 敏感字段清单。
+
+选择规则:
+
+- A01–A05 优先使用 5 条不同状态、得分最高的真实链。
+- B 选 1 条结构清晰的链作为操作输入和分段检查点。
+- Demo 选 10–20 条重点故事链,再补充更大统计样本。
+
+---
+
+## 5. WP1-C:迁移映射设计
+
+每张表制定:
+
+| 项 | 要求 |
+|---|---|
+| 主键 | 新生成或建立稳定映射;不得与目标冲突 |
+| 租户 | 写目标租户 ID |
+| 外键/业务键 | 同批映射,保持父子和跨模块追溯 |
+| 敏感字段 | 再检查脱敏;客户、供应商、联系人、电话、地址不得还原 |
+| 日期 | A 保留合理相对关系;Demo 平移到近 90 天;B 检查点按步骤设置 |
+| 状态 | 保留真实状态;仅按场景需要调整并记录 |
+| 外部映射 | 165/ERP/MES/WMS/账套按目标套重新配置 |
+| 派生数据 | 标记“复制快照/目标重算/不迁移” |
+
+禁止“整表 `INSERT SELECT` 后只改 `tenant_id`”。必须维护 `old_id → new_id` 映射表,并在事务或可恢复批次中执行。
+
+---
+
+## 6. WP1-D:补链
+
+对候选链缺口按优先级处理:
+
+1. 从同源租户其他真实单据中选择兼容节点并重新关联。
+2. 从 AIDOPB/AIDOP-Demo 补资源或演示结构。
+3. 通过正式页面/API生成缺失节点。
+4. 最后才用 SQL 构造,并记录业务依据。
+
+补链不得破坏真实数量关系。必须核对:
+
+```text
+订单数量 ≥ 已交付
+工单数量 = 合格 + 不合格 + 在制/剩余(按系统口径)
+PO 数量 ≥ 收货 - 退货
+库存期初 + 入 - 出 = 期末
+检验数量 = 合格 + 不合格/待判
+```
+
+---
+
+## 7. WP1-E:分套落地
+
+- A:迁移 5 条完整链及其闭包,保留客户可操作空间。
+- B:可迁移全部主数据和必要业务前置/检查点;首轮验收仍按正式步骤操作。
+- Demo:迁移更大真实样本,日期相对化并整理诊断故事。
+
+脚本放 `doc/plan/sql/uat-data/`,按 `source/target/domain` 拆分;每个脚本配备份、执行、verify 和按批次回滚。
+
+---
+
+## 8. 派生层处理
+
+- 历史 STD/DWD/KPI 可复制为初始快照或对照样本。
+- 正式验收前必须清晰标记并运行目标租户的正式构建。
+- 最终看板、诊断、ChartBI 以重算结果为准。
+- 若重算与迁移快照不一致,先查口径/链路,不能直接恢复旧 KPI 覆盖。
+
+---
+
+## 9. 验收与产物
+
+必须产出:
+
+- `source-table-inventory.xlsx`
+- `candidate-chain-score.xlsx`
+- `migration-map.xlsx`
+- 三套迁移/补链/验证/回滚脚本
+- `data-lineage-check.xlsx`
+- 页面/API/SQL 留证
+
+放行条件:
+
+- A 五条候选链已选定并闭环。
+- B 主数据与检查点可支持 B00–B14。
+- Demo 样本足以形成 90 天趋势。
+- 三套互不可见。
+- 无孤儿、数量矛盾、错误租户和未说明敏感数据。

+ 80 - 0
doc/plan/UAT测试数据准备/WP2-A套五组UAT数据.md

@@ -0,0 +1,80 @@
+# WP2 · A 套五组完整 UAT 数据
+
+## 1. 目标
+
+在 `UATTEST_CHL` 中按 [07-A套五组完整UAT场景包.md](./07-A套五组完整UAT场景包.md) 完成 A01–A05;优先覆盖 L1 全部数据型验收项,再覆盖 L2 产品能力。
+
+五组以 WP1 评分选出的真实脱敏候选链为主体,不要求从空白重新造单。先迁移并理顺真实链,再将每组恢复到客户可执行的测试起点。
+
+---
+
+## 2. 执行顺序
+
+### A01 正常闭环
+
+1. 载入 A01 真实候选链及主数据闭包,核对订单与至少 2 行明细。
+2. 将链路重置到约定起点,由用户完成设计/合同/订单评审(或复核已有历史结果)。
+3. 下达工单并执行正式排程;若候选链已有历史工单,保留为对照并创建/恢复可操作版本。
+4. 执行齐套;缺料转 PR/PO/交货计划。
+5. 供应发货、收货、IQC 合格入库。
+6. 领料、投产、报工、IPQC。
+7. FQC、成品入库、发运/交付。
+8. 刷新 DWD/KPI,核对订单到交付全链。
+
+A01 未通过前不得复制其他组。
+
+### A02–A05
+
+- A02:分批到货/报工/入库/交付,核对剩余量。
+- A03:供应延期与缺料,核对风险传导和 S8 供应事件。
+- A04:IQC/FQC 不合格、退货/返工/复检。
+- A05:生产或设备异常、S8 分级处置、恢复与改善。
+
+---
+
+## 3. 正式路径要求
+
+- 订单、评审、下达、排程、齐套、检验、异常必须走页面/API。
+- 允许脚本整体迁移真实业务链、准备测试前置库存/历史时间点和恢复起点;每处记录源租户、映射和理由。
+- 不手插排程、齐套、DWD、KPI、诊断结果。
+- 已迁移的历史排程/KPI只作对照或初始快照,客户验收结果必须来自目标租户正式重算。
+- 若环境要求 165 MES/WMS,先确认 A 租户映射,再进行 APP 操作;禁止借用旧租户映射。
+
+---
+
+## 4. FUNC/AC 覆盖
+
+每完成一步,读取 [项目验收/05-UAT数据覆盖矩阵.md](../项目验收/05-UAT数据覆盖矩阵.md) 并回填:
+
+- L1 FUNC/AC;
+- L2 FUNC/AC;
+- 场景组与步骤;
+- 业务键;
+- 证据路径;
+- 结果。
+
+无法覆盖的项分类为 `NON-DATA/GAP-TC/GAP-FUNC/BLOCKED`,不得空置。
+
+---
+
+## 5. 重置
+
+为 A01–A05 分别生成:
+
+- 起点快照;
+- 逆序清理脚本;
+- 恢复脚本;
+- verify SQL。
+
+重置必须限定租户 ID + `UATA-A0x-` 前缀,且不删除共享主数据。
+
+---
+
+## 6. 验收
+
+- 五组均能从起点重复走到终态。
+- 所有数量与状态守恒。
+- S8 与业务对象双向下钻。
+- KPI/诊断由正式构建生成并对账。
+- L1 数据型验收项 100% 有证据。
+- 无 P0/P1、无跨租户数据、无死信遗留。

+ 65 - 0
doc/plan/UAT测试数据准备/WP3-B套从零点通.md

@@ -0,0 +1,65 @@
+# WP3 · B 套从零点通
+
+## 1. 目标
+
+在 `UATTEST_CHLB` 从新租户初始态按 [08-B套从零点通剧本.md](./08-B套从零点通剧本.md) 完成 B00–B14,验证产品不是依赖历史造数才能运行。
+
+“从零点通”指业务步骤可由用户按正式流程完成,不要求数据库绝对空白。允许从 AIDOP 移植真实主数据、必要业务前置和分段检查点,使客户测试顺畅且数据真实。
+
+---
+
+## 2. 执行规则
+
+- 首轮每个被验收步骤只允许页面、公开 API 和正式后台作业;步骤起点可由 WP1 的真实数据迁移/检查点恢复。
+- 每一步记录操作账号、输入、产出业务键和下一步可见性。
+- 测试进行时数据库只读核验;不得用临时 UPDATE/INSERT 推进当前步骤。
+- 发现必须改库才能继续时立刻标记 P1,按 WPX 修复系统。
+
+---
+
+## 3. 分段执行
+
+| 段 | 步骤 | 重点 |
+|---|---|---|
+| B-I | B00–B02 | 新租户菜单/权限、主数据维护、BOM/工艺/产线/日历闭包 |
+| B-II | B03–B06 | 订单、评审、下达、工单、正式排程 |
+| B-III | B07–B10 | 齐套、PR/PO、发货/收货、IQC |
+| B-IV | B11–B12 | 领料、生产、IPQC、FQC、入库、发运 |
+| B-V | B13–B14 | S8 异常闭环、DWD/KPI 刷新 |
+
+每段通过后保存检查点。检查点可以来自已理顺的真实业务数据,但该段首次验收仍须由用户正式操作;检查点不能冒充通过证据。
+
+---
+
+## 4. 新租户通用性
+
+凡发现初始化缺失:
+
+- 修复 `AidopTenantInitService` 或正式基线机制;
+- 增加新租户自动初始化测试;
+- 再创建临时验证租户或清理后重建 B 套验证;
+- 禁止新增 `if tenantId == B_ID` 特例。
+
+---
+
+## 5. 产品全量覆盖
+
+B 套主要承担:
+
+- 所有新增、编辑、删除、导入、审批、发布、状态流转类 FUNC;
+- 隐藏表单/详情路由;
+- 角色差异与待办;
+- 新租户空状态和首次操作。
+
+每个操作 FUNC 映射 L2 Excel;缺 TC 现场补为可重复步骤。
+
+---
+
+## 6. 验收
+
+- B00–B14 全部 `PASSED`。
+- 测试步骤无人工改库推进;允许经审计的真实数据迁移和检查点恢复。
+- 无借用旧租户执行当前业务、无硬编码 ID。
+- 前一步产出能被下一步正式读取。
+- 新租户初始化缺口为 0。
+- B 套操作型 L2 FUNC 全覆盖。

+ 69 - 0
doc/plan/UAT测试数据准备/WP4-Demo价值展示数据.md

@@ -0,0 +1,69 @@
+# WP4 · Demo 价值展示数据
+
+## 1. 目标
+
+在 `UATDEMO` 落地 [09-Demo价值展示数据方案.md](./09-Demo价值展示数据方案.md),形成稳定可重置的九宫格、智慧诊断、S8、ChartBI 演示。
+
+---
+
+## 2. 数据落地
+
+1. 从 AIDOP 为主、AIDOPB/AIDOP-Demo 为辅,导入真实脱敏主数据和业务链。
+2. 优先保留真实数量分布、BOM/工艺复杂度、供应关系和状态结构。
+3. 将日期整体相对化为 90 天历史 + 30 天未来计划,保持节点先后和周期差。
+4. 整理约 60–100 张订单及其工单/采购/质量/库存/异常关联事实;不足部分围绕真实链补造。
+5. 通过正式作业构建 DWD/KPI。
+6. 配置四条诊断链和 S8 事件状态分布。
+7. 建立五类 ChartBI 主题数据集和看板。
+
+禁止只写 L1/KPI 结果表而无业务事实。
+历史 KPI/DWD 可用作初始展示快照,但标准演示态必须能由正式重算恢复。
+
+---
+
+## 3. 四项专项
+
+### 九宫格
+
+- 卡片全部有数;
+- 绿/黄/红/恢复中有层次;
+- 卡片可进入模块详情;
+- 筛选与数据口径一致。
+
+### 智慧诊断
+
+- 交付、供应、质量、制造至少各 1 条;
+- 指标 → 事实 → 根因 → 建议 → 改善任务 → 恢复趋势完整;
+- 诊断结果可解释,不使用无来源固定文案。
+
+### S8
+
+- 待确认、已分派、处理中、已恢复、已关闭、超时升级均有样例;
+- 关联订单/工单/PO/检验/设备;
+- 双向下钻和通知记录可见。
+
+### ChartBI
+
+- 经营、交付、制造、供应、质量五个主题;
+- 使用稳定 DWD/语义视图;
+- 支持筛选、联动、钻取;
+- 指标名称、单位、公式与系统看板一致。
+
+---
+
+## 4. 重置与保护
+
+- 提供 `DEMO-` 批次一键重置脚本和 verify SQL。
+- 普通演示账号只读或有限操作。
+- 重置不触碰 A/B。
+- 每次演示前运行健康检查。
+- 核心故事不依赖 165 在线。
+
+---
+
+## 5. 验收
+
+- 四项专项全部通过。
+- 演示脚本可在 30–45 分钟内完整讲述。
+- 重置后 15 分钟内恢复标准态。
+- Demo 对应 L1/L2 FUNC/AC 和证据已回填覆盖矩阵。

+ 67 - 0
doc/plan/UAT测试数据准备/WP5-DWD-KPI诊断ChartBI对账.md

@@ -0,0 +1,67 @@
+# WP5 · DWD / KPI / 诊断 / ChartBI 构建与对账
+
+## 1. 目标
+
+在三套上游数据全部就位后执行全局构建,并证明业务事实、DWD、KPI、诊断和 ChartBI 使用一致口径。
+
+---
+
+## 2. 执行前快照
+
+按租户记录:
+
+- STG/STD/DWD 行数、日期范围、租户分布;
+- KPI 最新日期与校验和;
+- 同步/转换任务最后状态;
+- S8 事件数;
+- ChartBI 数据集行数与刷新时间。
+
+发现 `tenant_id=0/1` 或其他租户数据立即停止。
+
+---
+
+## 3. 构建顺序
+
+1. 三套贴源/标准层全部准备完成。
+2. 标记 WP1 复制的历史 STD/DWD/KPI 快照,按正式构建机制安全清理或隔离,避免与目标重算重复。
+3. 执行一次全局 DWD 构建,不按租户分别重建。
+4. 执行 KPI L1–L4/原子层构建。
+5. 执行诊断规则/模型。
+6. 刷新 ChartBI 数据集。
+7. 将重算结果与源快照对比,只用于发现口径/迁移问题,不用源快照覆盖目标结果。
+8. 逐租户验收,不再次单独触发破坏性全局构建。
+
+记录每个 run/job ID、开始结束时间、状态和错误。
+
+---
+
+## 4. 对账样本
+
+每套至少抽:
+
+- 3 张订单;
+- 3 个工单;
+- 3 张 PO/收货;
+- 3 个质量批次;
+- 3 条 S8 事件;
+- 5 个核心 KPI。
+
+对每个样本保存:
+
+```text
+业务表 → STD → DWD/原子层 → KPI/诊断 → 看板/ChartBI
+```
+
+核对租户、业务键、数量、状态、日期、单位、聚合公式。
+
+---
+
+## 5. 通过标准
+
+- 作业成功,无新增死信。
+- 三租户互不改变对方业务事实。
+- KPI 与事实样本误差为 0;技术规范允许统计误差时须有口径说明。
+- 九宫格、诊断和 ChartBI 对同一指标显示一致。
+- 刷新后页面恢复、缓存更新和数据新鲜度符合要求。
+
+产出 `reconciliation.xlsx`、SQL/API 证据和差异清单。

+ 78 - 0
doc/plan/UAT测试数据准备/WP6-全量验收与交付.md

@@ -0,0 +1,78 @@
+# WP6 · 全量验收、重置与交付
+
+## 1. 目标
+
+完成 L1 客户最低线与 L2 产品全量线的逐项验收,交付三套可持续使用的数据、账号、操作手册和重置能力。
+
+---
+
+## 2. 生成逐 FUNC 覆盖明细
+
+读取:
+
+- `../项目验收/02-客户项目最低验收对照表.xlsx`
+- `../项目验收/03-产品全量功能验收对照表.xlsx`
+- `aidopFuncCodes.ts`
+- A/B/Demo 留证索引
+
+生成 `../项目验收/06-逐FUNC-UAT覆盖明细.xlsx`,列定义见 [项目验收/05-UAT数据覆盖矩阵.md](../项目验收/05-UAT数据覆盖矩阵.md)。
+
+校验:
+
+- L1 72 条 FUNC/AC 全覆盖;
+- L2 167 条 FUNC/AC 全覆盖;
+- L1 113 条技术规范验证项均有结论;
+- 缺 TC、缺功能、阻断均为 0。
+
+---
+
+## 3. 非数据专项
+
+单独执行:
+
+- 权限和三租户隔离;
+- ERP/MES/WMS/API 契约与联调;
+- 登录、查询、保存、看板响应;
+- 并发与可用性;
+- 备份、恢复、监控、告警;
+- 文档、培训、交付清单。
+
+非数据项不得因为 A/B/Demo 有数据就自动判通过。
+
+---
+
+## 4. 稳定性复跑
+
+连续两轮:
+
+1. 重置 A 五组并各抽一组重跑。
+2. B 从初始检查点完成主链。
+3. Demo 一键重置并彩排。
+4. 全局 DWD/KPI 构建与对账。
+
+两轮间不得人工修库;若出现 P0/P1,重新进入 WPX 并从受影响 WP 开始。
+
+---
+
+## 5. 交付物
+
+- 三租户地址、ID、账号角色说明(密码另行安全交付)。
+- A 五组场景手册和业务键。
+- B 从零操作手册与检查点。
+- Demo 演示脚本、健康检查和重置说明。
+- L1/L2 验收矩阵和执行证据。
+- 缺陷关闭清单、遗留问题清单。
+- 版本说明和部署/运维资料。
+
+---
+
+## 6. 最终判定
+
+只有同时满足以下条件才可标记完成:
+
+- L1 达到修订 Word 的客户验收标准;
+- L2 已实现功能全部通过;
+- A/B/Demo 三套稳定可复用;
+- P0/P1=0;
+- 无跨租户、无错误 KPI/诊断口径;
+- 负责人完成最终签收检查。

+ 61 - 0
doc/plan/UAT测试数据准备/WPX-阻断缺陷修复模板.md

@@ -0,0 +1,61 @@
+# WPX · 阻断缺陷修复任务模板
+
+> 每个缺陷复制一份,命名 `WPX-<场景步骤>-<简述>.md`
+
+## 1. 元信息
+
+| 项 | 内容 |
+|---|---|
+| 场景/步骤 |  |
+| 租户 |  |
+| FUNC/AC/TC |  |
+| 严重级别 | P0 / P1 / P2 / P3 |
+| 业务键 |  |
+| 发现时间 |  |
+
+## 2. 现象与证据
+
+- 操作:
+- 期望:
+- 实际:
+- 页面/API:
+- SQL 前后快照:
+- 日志/run/job ID:
+
+## 3. 根因
+
+写明代码/配置/数据/第三方根因;不得把“数据不对”当最终根因。
+
+## 4. 改动清单(必须先确认)
+
+- **目标**:
+- **文件**:
+- **可见行为变化**:
+- **跨模块影响**:
+- **迁移/数据修复**:
+- **明确不做**:
+
+用户确认前不应用补丁。
+
+## 5. 实施与验证
+
+- 单元/契约测试:
+- 构建/lint:
+- 迁移:
+- 原步骤复测:
+- 上游回归:
+- 下游回归:
+- 跨租户验证:
+- DWD/KPI/诊断/ChartBI 对账:
+
+## 6. 完成条件
+
+- 根因修复,不是单号特例。
+- 原步骤使用重置后的同组数据通过。
+- 必要回归通过。
+- 证据归档。
+- 覆盖矩阵状态更新为 `PASSED`。
+
+## 7. Git
+
+仅用户明确要求时提交。涉及前端/后端运行产物,按仓库规则同步递增相应版本;用户说“提交”默认 commit + push。

+ 57 - 0
doc/plan/UAT测试数据准备/数据瘦身执行记录-20260816.md

@@ -0,0 +1,57 @@
+# aidopdev 数据瘦身执行记录
+
+> 执行日期:2026-08-16  
+> 数据库:MySQL `aidopdev`  
+> 策略:时间窗口 + 数量上下限 + 关联完整性  
+> 备份决策:按确认不保留额外瘦身备份
+
+## 1. 执行口径
+
+- 每个功能优先保留约 20 条可用主记录;原本不足则全部保留,不伪造数据。
+- 优先保留可从 STG 串到 STD/DWD、具有业务关联键和不同状态的数据。
+- 普通业务主记录上限 2,000 条;高频交易与日志上限 10,000 条。
+- 明细随主记录完整保留,不独立随机截断。
+- 只保留启用业务租户的数据;`tenant_id=0/1/1300000000001` 不作为测试数据保留。
+
+## 2. 已执行结果
+
+| 对象 | 执行前 | 执行后 | 保留方式 |
+|---|---:|---:|---|
+| `dwd_process_outsource_delivery` | 约 94 万实际行、22.9 GB 已分配 | 268 行 | 每个外协业务键保留最新记录,保留 2 个有效租户 |
+| `SysLogOp` | 约 27.5 万行、5.1 GB | 约 2.94 万行 | 每租户最多 10,000 行;不足时保留最新 20 行 |
+| `mdp_std_inv_trans` | 432,566 行 | 10,000 行 | 优先保留带订单/工单关联的最新交易 |
+| `mdp_stg_inv_trans` | 432,566 行 | 10,000 行 | 只保留与上述 STD 样本 `source_row_id/src_rec_id` 对应的数据 |
+| `dwd_requirement_examine_detail` | 1,414,843 行、737 个评审根 | 20 个完整评审根、113,720 条明细 | 每个评审根的物料明细完整保留 |
+| `dwd_order_schedule_trans` | 771,705 行 | 299 行 | 按工单/销售单/物料/状态保留最新记录 |
+| `ado_s8_detection_log` | 162,451 行 | 229 行 | 每条规则、每种检测结果最多保留 20 行 |
+| 归档及迁移备份表 | 26 张、约 764 MB | 0 张 | 已按“不保留额外备份”决策删除 |
+
+数据库表分配空间由约 **31.3 GB** 降至约 **2.1 GB**。
+
+## 3. 租户污染清理
+
+已清理主要 DWD、`mdp_std_so` 和 KPI 原子表中的无效租户数据。复核以下代表表均为 0 行:
+
+- `dwd_ship_trans`
+- `dwd_supplier_delivery`
+- `dwd_supply_demand`
+- `dwd_process_outsource_delivery`
+- `mdp_std_so`
+- `ado_smart_ops_kpi_atomic_day`
+
+校验条件均为 `tenant_id IN (0, 1, 1300000000001)`。
+
+## 4. 防再次膨胀
+
+已在 `aidopdev` 启用两个每周事件:
+
+- `ev_aidopdev_weekly_log_retention`
+- `ev_aidopdev_weekly_s8_detection_retention`
+
+事件执行 30 天窗口、每租户最多 10,000 行及最低样本保留规则。业务 DWD 表不设置周期随机删数;其重复膨胀应由已修复的租户/工厂作用域和构建逻辑控制。
+
+## 5. 边界与后续
+
+- 本轮只瘦身已有数据,不为原本无业务数据的目标租户伪造 20 条记录。
+- A/B/Demo 三个目标租户的正式全链路样本仍由 WP1–WP4 迁移和生成。
+- WP0 仍需完成两轮同步后“无新增零租户”和链路回归,才能正式放行。

+ 39 - 0
doc/plan/UAT留证/2026-08-14-P022-P037执行记录.md

@@ -0,0 +1,39 @@
+# 2026-08-14 P-022 / P-028~P-030 / P-032 / P-035~P-037 执行记录
+
+> 版本:server `1.0.342` / Web `2.4.294`。未提交 Git。
+
+## 代码已落地
+
+| ID | 结果 |
+|---|---|
+| P-022 WP-1 | 齐套刷新去掉前置 `DELETE dwd_material_readiness`;存储过程单独超时 300s 后还原 |
+| P-022 WP-2 | `MdpJsonSql` 去零兜底 + `TenantGuard`;各转换 INSERT WHERE 跳过无法解析租户的行;IPQC 建表去 DEFAULT;`1.0.341.sql` |
+| P-022 WP-3 | `1.0.342.sql` 备份后删除派生层 `tenant_id IN (0, 1300000000001)`。**未动** `ado_s8_exception.tenant_id=1` |
+| P-032 | 工单工序推送补 `StdOp`;契约测试 3 项通过 |
+| P-035 | 热回读 `WorkOrdRouting.QtyScrap`;NULL 不覆盖已有值 |
+| P-036 | 165 重复生效日计划按完整业务键分组;`R/S/W` 只告警;其余按 RecID 作废 |
+| P-037 | 「智慧诊断」跟当前 L1;「全局智慧诊断」找最差;诊断页 loading,去掉演示 L3 闪烁 |
+
+## 代码核对(无需再改)
+
+| ID | 结论 |
+|---|---|
+| P-028 | H1~H6 仍在:占用表、`excludeFromCapacity` 含单工单、完工日推进、交期不可达异常 |
+| P-030 J1–J4 | `GetEditPreview` 已返 `DueDate`;弹窗交期可编辑;纯改优先级 `pendingReorder`、不作废 |
+| P-030 J5 | `scheduling/save` 只改数量/优先级/批次/加急,不写 `DueDate`;`scheduling/urgent` 只改 `Urgent`。无 OrdDate 污染 |
+
+## 启动后仍要做的运行验收
+
+1. 重启后端以执行 `1.0.341.sql` / `1.0.342.sql`。
+2. P-022:齐套刷新失败时 `dwd_material_readiness` 行数不变;再跑一轮同步,增量无 `tenant_id=0`。
+3. P-028:相同输入连续两轮全量重排幂等;冻结/单工单不漂;APP 可执行计划。
+4. P-029:禁止 `allDead=true`;逐条分类死信;三轮重排对账(与 P-036 一起)。
+5. P-032:只测 `91HC0497` 系列投产。
+6. P-035:MES 录废品后看本库与追踪页。
+7. P-037:S1 看板点「订单评审周期」进智慧诊断应稳定有 L3;全局入口可落到满足率。
+
+## 明确未做
+
+- 未全量重排、未操作 MES APP、未批量重投死信。
+- 未修改 `DueDate=OrdDate` 历史行(须负责人确认候选清单后再改)。
+- 未给 `ado_s8_exception` 的 `tenant_id=1` 指定归属。

+ 63 - 0
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/01-sql-verify.md

@@ -0,0 +1,63 @@
+# SQL 验收
+
+执行库:aidopdev  
+执行时间:2026-08-15
+
+## 表存在
+
+`ado_module_dashboard_rebuild_job` ok=1  
+`ado_mdp_rebuild_lock` ok=1
+
+## 索引(COUNT DISTINCT INDEX_NAME)
+
+job:`ix_mdp_rebuild_scope` / `ix_mdp_rebuild_status` / `ix_mdp_rebuild_heartbeat` → ok=1  
+lock:`ix_mdp_rebuild_lock_scope` / `ix_mdp_rebuild_lock_heartbeat` → ok=1
+
+说明:原 verify 用 `COUNT(*)` 会计入组合索引的每一列,启动校验会误报失败,已改为 `COUNT(DISTINCT INDEX_NAME)`。
+
+## 9.1 活动任务唯一性
+
+```sql
+SELECT module_code, tenant_id, factory_id, COUNT(*) AS active_count
+FROM ado_module_dashboard_rebuild_job
+WHERE status IN ('QUEUED','RUNNING')
+GROUP BY module_code, tenant_id, factory_id
+HAVING COUNT(*) > 1;
+```
+
+结果:0 行。
+
+## 9.2 非法状态
+
+```sql
+SELECT COUNT(*) AS illegal
+FROM ado_module_dashboard_rebuild_job
+WHERE (status='SUCCESS' AND progress_percent<>100)
+   OR (status IN ('QUEUED','RUNNING') AND IFNULL(failed_stage,'')<>'');
+```
+
+结果:`illegal=0`。
+
+## 9.3 / 9.4 心跳与锁
+
+无重复活动任务。Worker 已去掉独立 job heartbeat,进度写入即续租;锁心跳异常不打断任务。
+
+## 9.6 S9 不触发重算
+
+`POST /api/AidopKanban/S9/rebuild-jobs` → 404。S9 刷新不入队。
+
+## 关键任务样例
+
+| id | module | tenant | status | failed_stage | progress | trigger | duration_ms |
+|---:|---|---:|---|---|---:|---|---:|
+| 181 | S2 | 797403760988230 | SUCCESS | — | 100 | MANUAL | 2310 |
+| 182 | S2 | 824585161322565 | SUCCESS | — | 100 | MANUAL | 2209 |
+| 183 | S2 | 797403760988229 | FAILED | STAGING | 2 | MANUAL | 30247 |
+| 184 | S2 | 797403760988229 | FAILED | STAGING | 2 | MANUAL | 30280 |
+| 201 | S2 | 797403760988229 | FAILED | STAGING | 2 | AUTO | 90248 |
+
+181/182 证明跨租户全量重算可 SUCCESS=100。183/184/201 证明失败保留 `failedStage`。浏览器 UAT-5 对 job 201 做了 409 接管。
+
+## sys_db_migration_log
+
+`1.0.347` = Success / verify Success。

+ 36 - 0
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/02-kpi-checksum-baseline.md

@@ -0,0 +1,36 @@
+# A/B L1 KPI checksum 基线
+
+表:`ado_s9_kpi_value_l1_day`  
+过滤:`is_deleted=0`,tenant in (`1300000000001`, `1300000000888`)  
+checksum:`BIT_XOR(CRC32(module|tenant|factory|metric|biz_date|metric_value|target_value|target_source))`  
+说明:该表无 `calc_batch_id`,与任务书示例 SQL 列名不一致,按实际列适配。
+
+执行时间:2026-08-15(尚未跑 A 租户全量重算)
+
+| module | tenant | factory | row_count | max_update_time | checksum |
+|---|---:|---:|---:|---|---:|
+| S1 | 1300000000001 | 1 | 360 | 2026-08-14 19:41:27 | 358406167 |
+| S1 | 1300000000888 | 1 | 120 | null | 2074363645 |
+| S2 | 1300000000001 | 1 | 120 | null | 2444154613 |
+| S2 | 1300000000001 | 8010 | 146 | 2026-08-14 19:41:26 | 1891401584 |
+| S2 | 1300000000888 | 1 | 120 | null | 1934982272 |
+| S3 | 1300000000001 | 1 | 193 | 2026-08-13 12:48:43 | 3630207273 |
+| S3 | 1300000000888 | 1 | 120 | null | 212701925 |
+| S4 | 1300000000001 | 1 | 300 | 2026-08-06 10:35:45 | 3947753608 |
+| S4 | 1300000000888 | 1 | 120 | null | 2010673860 |
+| S5 | 1300000000001 | 1 | 172 | 2026-08-06 09:00:00 | 1232864159 |
+| S5 | 1300000000888 | 1 | 90 | null | 1450852225 |
+| S6 | 1300000000001 | 1 | 35 | 2026-08-06 09:00:00 | 1610451757 |
+| S6 | 1300000000888 | 1 | 30 | null | 4117907016 |
+| S7 | 1300000000001 | 1 | 177 | 2026-08-06 09:00:00 | 733658676 |
+| S7 | 1300000000888 | 1 | 90 | null | 3292647698 |
+| S9 | 1300000000001 | 1 | 150 | null | 364571927 |
+| S9 | 1300000000888 | 1 | 150 | null | 179859125 |
+
+UAT-7 复测(2026-08-15 浏览器/API UAT 之后):Tenant B `1300000000888` / factory 1
+
+| module | row_count | checksum | 相对基线 |
+|---|---:|---:|---|
+| S2 | 120 | 1934982272 | 未变 |
+| S3 | 120 | 212701925 | 未变 |
+| S4 | 120 | 2010673860 | 未变 |

+ 65 - 0
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/README.md

@@ -0,0 +1,65 @@
+# 2026-08-15 S2–S7 / S9 看板刷新与重算统一架构留证
+
+执行对象:`doc/plan/S2-S7及S9看板刷新与重算统一架构执行任务书_20260815.md`  
+版本:前端 `2.4.298`,后端 `1.0.347`  
+数据库:aidopdev(MCP `user-mysql-aidopdev`)  
+账号:`AIDOPDemo`(JWT 租户 `797403760988229`)  
+隔离对照:`AIDOPDemoTenant` / `797403760988230`,`AIDopAdminB` / `824585161322565`  
+L1 checksum 租户:A `1300000000001`,B `1300000000888`
+
+## 结论
+
+WP0–WP9 代码、迁移、单测、构建、API UAT、浏览器 UAT 均已完成。  
+`sys_db_migration_log` 中 `1.0.347` 为 **Success / verify Success**。
+
+UAT-1~UAT-9 已留证。A 租户(`797403760988229`)S2 全量重算在 STAGING 失败(源/165 数据,`failedStage=STAGING`),属 UAT-8 有效失败样本;B/C 租户 S2 全量重算 SUCCESS=100。未改 165 对象或 KPI 公式。
+
+浏览器 UAT 中发现:HTTP 409 会被 `Web/src/utils/request.ts` 成功拦截器丢掉响应体,导致同租户第二浏览器无法接管。已用请求头 `X-Allow-Conflict` 放行重算入队的 409,接管后进度卡显示同一 `jobId`。
+
+## 已完成
+
+| 项 | 结果 |
+|---|---|
+| 通用异步重算框架 | S1–S7 Job/Lock/Worker/Handler |
+| 轻量刷新 / 重算分离 | S1–S7 双按钮;S9 仅快照刷新、无进度卡 |
+| 旧同步入口 | `s2–s7-mdp/refresh`、`mdp/refresh/{module}` 改为鉴权后 Enqueue |
+| AUTO/BOOTSTRAP | 按 scope 入队 |
+| 新 API 鉴权 | `AidopKanbanModuleRefreshController` 独立 `[Authorize]` |
+| 迁移 | `1.0.347` Success / verify Success |
+| 能力开关 | `AiDOP:MdpRebuild`,S1–S7 `Enabled=true`,`MaxParallelScopes=1` |
+| 409 接管 | 前端放行冲突响应体,第二浏览器点击后挂同一 job |
+
+## 测试与构建
+
+| 项 | 结果 |
+|---|---|
+| `dotnet test` net8 / net10 | 各 506 passed / 2 skipped |
+| `pnpm --dir Web build` | 成功 |
+| 前端本次 vitest | registry / watch / kanban API 14 passed |
+| 全量 `vitest run` | 既有 S8 `s8MonitoringAnomalyApi.spec.ts` 6 条失败(无关,未改) |
+
+## 文件
+
+- `01-sql-verify.md`:表、索引、活动任务、非法状态、迁移日志
+- `02-kpi-checksum-baseline.md`:A/B L1 checksum;UAT-7 后 B 的 S2/S3/S4 未变
+- `03-api-snapshot-refresh.json`:S1–S7/S9 轻量刷新
+- `04-api-s9.json`:S9 刷新 200、rebuild 404
+- `05-api-rebuild-enqueue.json`:S2–S7 202 / 409
+- `06-api-s2-progress.json`:失败阶段 STAGING
+- `07-api-tenant-b.json`:跨租户 202 互不阻塞
+- `08-browser-uat.json`:浏览器 UAT-5 / UAT-9
+- `screenshots/`:S1–S7/S9 看板、S2 刷新/进度/恢复/静默进入/接管
+
+## UAT 对照
+
+| 项 | 证据 |
+|---|---|
+| UAT-1 轻量刷新 | API 200;`S2-after-quick-refresh.png` toast「目标快照已刷新」 |
+| UAT-2 确认入队 | 确认框 + 202/409;`S2-rebuild-progress.png` job 排队 |
+| UAT-3 真实进度 | `S2-rebuild-restore.png` job 184 阶段 1/5 拉取源数据 10% |
+| UAT-4 页面恢复 | 刷新后仍显示同一进度卡 |
+| UAT-5 同租户接管 | 第二浏览器静默进入无卡;点击后 409 job 201,出现进度卡 |
+| UAT-6 跨租户 | job 181 / 182 各租户独立 SUCCESS |
+| UAT-7 A 不改 B | B `1300000000888` S2/S3/S4 checksum 仍为 `1934982272` / `212701925` / `2010673860` |
+| UAT-8 失败阶段 | A 租户 job 183/184/201 `failedStage=STAGING`,SUCCESS 进度均为 100 |
+| UAT-9 S9 | 仅刷新;rebuild HTTP 404;截图无「重算数据」、无进度卡 |

+ 0 - 0
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/.gitkeep


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S1-dashboard.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-after-quick-refresh.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-dashboard.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-other-browser-silent.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-other-browser-takeover.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-rebuild-progress.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S2-rebuild-restore.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S3-dashboard.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S4-dashboard.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S5-dashboard.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S6-dashboard.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S7-dashboard.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S9-after-quick-refresh.png


BIN
doc/plan/UAT留证/2026-08-15-S2-S7看板刷新重算统一架构/screenshots/S9-dashboard.png


+ 270 - 0
doc/plan/关键定义说明/期量差说明.md

@@ -0,0 +1,270 @@
+# 期量差说明
+
+> 适用范围:Ai-DOP 运营指标、动态看板、智慧诊断、ChatBI 等使用目标值与实际值进行达成判断的场景。  
+> 本文是“期量差”的统一业务定义。指标是否异常不能只看差值正负,必须同时看指标方向。
+
+## 1. 定义
+
+期量差用于表示指标实际值相对目标值的偏离量,统一计算公式为:
+
+```text
+期量差 = 实际值(Current)− 目标值(Target)
+```
+
+因此:
+
+- 正数:实际值高于目标值;
+- 负数:实际值低于目标值;
+- 0:实际值等于目标值;
+- 空值:实际值或目标值缺失,无法计算期量差。
+
+期量差正负只描述“高于还是低于目标”,本身不等于好坏。
+
+## 2. 指标方向
+
+每个 KPI 通过 `Direction` 定义优劣方向。
+
+### 2.1 越高越好
+
+编码:
+
+```text
+higher_is_better
+```
+
+典型指标:
+
+- 满足率;
+- 达成率;
+- 人效;
+- 合格率;
+- 准时率。
+
+判断原则:
+
+- 期量差为正:实际高于目标,通常达标;
+- 期量差为 0:刚好达到目标;
+- 期量差为负:实际低于目标,存在缺口。
+
+例如:
+
+```text
+订单评审满足率:
+实际 90%,目标 95%
+期量差 = 90% − 95% = -5%
+```
+
+该指标越高越好,因此 `-5%` 表示比目标少 5 个百分点,是负向偏差。
+
+### 2.2 越低越好
+
+编码:
+
+```text
+lower_is_better
+```
+
+典型指标:
+
+- 评审周期;
+- 库存周转天数;
+- 等待时长;
+- 缺陷数;
+- 成本;
+- 延误天数。
+
+判断原则:
+
+- 期量差为负:实际低于目标,通常达标;
+- 期量差为 0:刚好达到目标;
+- 期量差为正:实际高于目标,存在超期或超量。
+
+例如:
+
+```text
+订单评审周期:
+实际 20.35 天,目标 20 天
+期量差 = 20.35 − 20 = +0.35 天
+```
+
+该指标越低越好,因此 `+0.35 天` 表示超出目标 0.35 天,是负向偏差。
+
+## 3. 截图中 S1 指标示例
+
+### 3.1 订单评审周期
+
+```text
+Direction:lower_is_better
+实际:20.35 天
+目标:20 天
+期量差:+0.35 天
+```
+
+周期越低越好,正差表示超期,因此有问题。
+
+### 3.2 订单评审满足率
+
+```text
+Direction:higher_is_better
+实际:0%
+目标:95%
+期量差:-95%
+```
+
+满足率越高越好,负差表示未达到目标,因此有问题。
+
+### 3.3 主计划人效
+
+```text
+Direction:higher_is_better
+实际:26 行/人
+目标:217.03 行/人
+期量差:-191.03 行/人
+```
+
+人效越高越好,负差表示低于目标,因此有问题。
+
+### 3.4 成品库存周转
+
+```text
+Direction:lower_is_better
+实际:101.94 天
+目标:15 天
+期量差:+86.94 天
+```
+
+周转天数越低越好,正差表示库存周转过慢,因此有问题。
+
+### 3.5 合同评审周期
+
+```text
+Direction:lower_is_better
+实际:4.7 天
+目标:12.61 天
+期量差:-7.91 天
+```
+
+周期越低越好,负差表示优于目标,因此达标。
+
+## 4. 绿、黄、红与严重告警
+
+状态判断应先计算达成比例:
+
+```text
+达成比例 = 实际值 ÷ 目标值 × 100%
+```
+
+默认阈值口径如下。
+
+### 4.1 越高越好
+
+- 绿色:达成比例大于等于 100%;
+- 黄色:达成比例大于等于黄色阈值且小于 100%,默认黄色阈值为 95%;
+- 红色:达成比例小于黄色阈值;
+- 严重红色/闪烁:达成比例小于红色阈值,默认红色阈值为 80%。
+
+### 4.2 越低越好
+
+- 绿色:达成比例小于等于 100%;
+- 黄色:达成比例大于 100% 且小于等于黄色阈值,默认黄色阈值为 110%;
+- 红色:达成比例大于黄色阈值;
+- 严重红色/闪烁:达成比例大于红色阈值,默认红色阈值为 120%。
+
+这里的两个阈值职责不同:
+
+- `YellowThreshold`:绿色/黄色之后,进入红色状态的边界;
+- `RedThreshold`:红色状态中是否达到严重告警、需要闪烁的边界。
+
+如果未来产品要增加橙色等第四种状态,应另行调整定义;在当前只有绿、黄、红三种状态的前提下,不应制造未定义区间。
+
+## 5. 判断顺序
+
+统一判断顺序:
+
+1. 检查实际值和目标值是否存在;
+2. 检查目标值是否为 0;
+3. 读取指标 `Direction`;
+4. 计算期量差;
+5. 计算达成比例;
+6. 根据方向和 `YellowThreshold` 得出绿、黄、红;
+7. 根据方向和 `RedThreshold` 判断红色是否属于严重告警/闪烁。
+
+禁止仅按以下方式判断:
+
+- “正数就是有问题”;
+- “负数就是有问题”;
+- “绝对值越大一定越差”;
+- 不读取 `Direction` 就直接判断状态。
+
+## 6. 空值和特殊值
+
+### 6.1 实际值或目标值缺失
+
+- 期量差显示 `—`;
+- 状态按当前兼容口径显示黄色;
+- 不闪烁;
+- 页面应说明数据尚不完整,不得显示伪造值。
+
+### 6.2 目标值为 0
+
+- 不能计算达成比例;
+- 状态按当前兼容口径显示黄色;
+- 不闪烁;
+- 不得进行除零运算。
+
+### 6.3 负目标值
+
+当前运营 KPI 一般不应使用负目标值。若业务确有负目标场景,不能直接沿用百分比阈值,必须为该指标单独定义比较规则。
+
+### 6.4 方向为空或非法
+
+现有兼容行为通常回落到 `higher_is_better`,但正式数据应在保存 KPI 主数据时校验,只允许:
+
+```text
+higher_is_better
+lower_is_better
+```
+
+## 7. 展示规范
+
+页面展示期量差时,应同时提供足以判断方向的信息:
+
+- 指标名称;
+- 实际值;
+- 目标值;
+- 单位;
+- 状态颜色;
+- 必要时通过提示展示“越高越好”或“越低越好”。
+
+建议文案:
+
+```text
+期量差:+0.35 天(周期越低越好,当前超出目标)
+```
+
+或:
+
+```text
+期量差:-5%(满足率越高越好,当前低于目标)
+```
+
+箭头只能表示数值上升或下降,不能脱离指标方向把“向上”固定解释为改善。
+
+## 8. 当前实现审计结论
+
+当前统一计算入口主要位于:
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopS4KpiMerge.cs`
+
+已确认:
+
+- `GapValue` 按“实际值减目标值”计算,符合本文定义;
+- `AchievementLevel` 使用指标方向判断状态;
+- 当前实现读取了 `RedThreshold`,但没有在该方法中使用;
+- 动态看板闪烁判断在 `AidopKanbanController.DashboardPage.cs` 中另写了一套固定 80%/120% 判断,没有读取每个指标配置的 `RedThreshold`。
+
+这会造成状态判断和严重告警判断分散,且配置化红色阈值没有完整生效。
+
+具体改善步骤见:
+
+[`期量差阈值口径收口任务书.md`](./期量差阈值口径收口任务书.md)

+ 585 - 0
doc/plan/关键定义说明/期量差阈值口径收口任务书.md

@@ -0,0 +1,585 @@
+# 期量差阈值口径收口任务书
+
+> 日期:2026-08-14  
+> 输入定义:[`期量差说明.md`](./期量差说明.md)  
+> 执行方式:本任务书供其他大模型直接照章实施。实施模型必须先读取定义文档、仓库规则、工作区状态和现有 diff,不得覆盖已有未提交改动。
+
+## 1. 目标
+
+统一 KPI 的期量差、绿黄红状态和严重红色/闪烁判断,使所有模块使用同一套方向与阈值规则。
+
+完成后必须满足:
+
+1. 期量差继续统一为“实际值减目标值”。
+2. 状态判断必须读取指标方向。
+3. `YellowThreshold` 负责绿、黄、红状态的分界。
+4. `RedThreshold` 负责红色状态中是否达到严重告警/闪烁的分界。
+5. 状态颜色和闪烁由同一个统一评估结果产生。
+6. 动态看板不再使用固定 80%/120% 的独立闪烁算法。
+7. KPI 自定义阈值在所有调用入口一致生效。
+8. 不改变指标实际值、目标值、公式和历史业务数据。
+
+## 2. 已确认问题
+
+### 2.1 `RedThreshold` 在统一状态方法中声明但未使用
+
+文件:
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopS4KpiMerge.cs`
+
+当前 `AchievementLevel`:
+
+- 接收 `yellowThreshold`;
+- 接收 `redThreshold`;
+- 创建局部变量 `rPct`;
+- 实际分支只使用 `yPct`;
+- `rPct` 没有参与返回结果。
+
+这使方法签名看似支持两级阈值,但只能返回颜色,不能给出是否越过严重红色阈值。
+
+### 2.2 闪烁判断另写固定阈值
+
+文件:
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs`
+
+当前 `IsBeyondRedThreshold` 固定使用:
+
+```text
+lower_is_better:ratio > 120%
+higher_is_better:ratio < 80%
+```
+
+它没有读取 `AdoSmartOpsKpiMaster.RedThreshold`。
+
+结果:
+
+- KPI 主数据配置了自定义 `RedThreshold`,状态接口读取不到其严重告警含义;
+- 页面闪烁仍按固定默认值判断;
+- 颜色与闪烁来自两套代码,后续容易继续漂移。
+
+### 2.3 多个入口依赖统一状态方法
+
+已知调用方包括:
+
+- `AidopKanbanController.Diagnosis.cs`
+- `SmartDiagnosisTree.cs`
+- `AidopKanbanController.cs`
+- `AidopKanbanController.S4.cs`
+- `AidopKanbanController.Generic.cs`
+- `AidopKanbanController.DashboardPage.cs`
+- `AdoSmartOpsKpiMasterController.cs`
+- `ChatBI/ChatBIService.cs`
+
+任何签名或语义调整都必须回归这些入口。
+
+## 3. 权威业务口径
+
+本任务以《期量差说明》为唯一业务定义。
+
+### 3.1 期量差
+
+```text
+Gap = Current − Target
+```
+
+不调整该公式。
+
+### 3.2 达成比例
+
+```text
+RatioPercent = Current ÷ Target × 100
+```
+
+目标值为 0 时不计算。
+
+### 3.3 越高越好
+
+默认:
+
+```text
+YellowThreshold = 95
+RedThreshold = 80
+```
+
+判定:
+
+- `ratio >= 100`:绿色;
+- `yellowThreshold <= ratio < 100`:黄色;
+- `ratio < yellowThreshold`:红色;
+- `ratio < redThreshold`:严重红色,允许闪烁。
+
+边界使用严格定义:
+
+- 恰好 95%:黄色;
+- 恰好 80%:红色但不闪烁;
+- 小于 80%:红色且闪烁。
+
+### 3.4 越低越好
+
+默认:
+
+```text
+YellowThreshold = 110
+RedThreshold = 120
+```
+
+判定:
+
+- `ratio <= 100`:绿色;
+- `100 < ratio <= yellowThreshold`:黄色;
+- `ratio > yellowThreshold`:红色;
+- `ratio > redThreshold`:严重红色,允许闪烁。
+
+边界使用严格定义:
+
+- 恰好 110%:黄色;
+- 恰好 120%:红色但不闪烁;
+- 大于 120%:红色且闪烁。
+
+### 3.5 空值
+
+实际值为空、目标值为空或目标值为 0:
+
+- 状态:黄色;
+- 达成比例:空;
+- 严重红色:否;
+- 闪烁:否;
+- 期量差按现有 `GapValue` 规则返回空。
+
+### 3.6 阈值合法性
+
+合法范围:
+
+#### higher_is_better
+
+```text
+0 < RedThreshold <= YellowThreshold <= 100
+```
+
+#### lower_is_better
+
+```text
+100 <= YellowThreshold <= RedThreshold
+```
+
+阈值允许为空;为空时使用方向对应默认值。
+
+如果历史库中存在非法组合,运行时必须安全回落到该方向的整组默认阈值,不能抛异常导致看板不可用,也不能交换两个配置值后悄悄改变业务含义。
+
+新增或编辑 KPI 时,应在保存入口拒绝非法组合并返回明确错误。
+
+## 4. 改动范围
+
+### 4.1 必改文件
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopS4KpiMerge.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs`
+
+### 4.2 按需修改
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Dto/SmartOps/AdoSmartOpsKpiMasterDtos.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiMasterController.cs`
+- KPI 主数据保存所经过的服务或验证器
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/`
+
+### 4.3 只核对、不默认重写
+
+- `AidopKanbanController.Diagnosis.cs`
+- `SmartDiagnosisTree.cs`
+- `AidopKanbanController.cs`
+- `AidopKanbanController.S4.cs`
+- `AidopKanbanController.Generic.cs`
+- `ChatBI/ChatBIService.cs`
+
+这些调用方原则上继续调用兼容方法即可;只有编译或契约需要时才做最小调整。
+
+### 4.4 明确不改
+
+- 不修改 KPI 实际值和目标值;
+- 不重算或清洗历史 KPI 日值;
+- 不调整任何指标 `Direction`;
+- 不批量改写现有黄色、红色阈值;
+- 不增加橙色等新状态;
+- 不改变诊断树父子关系;
+- 不修改菜单、FUNC 编号或路由;
+- 不顺手重构动态看板;
+- 不处理与阈值无关的指标算法。
+
+## 5. 后端实施步骤
+
+### B1. 新增统一评估结果
+
+在 `AidopS4KpiMerge` 中增加不可变结果类型,命名可按仓库风格调整,至少包含:
+
+```csharp
+string Status
+decimal? RatioPercent
+bool IsBeyondRedThreshold
+decimal EffectiveYellowThreshold
+decimal EffectiveRedThreshold
+```
+
+建议命名:
+
+```csharp
+KpiAchievementEvaluation
+```
+
+### B2. 新增统一评估方法
+
+建议接口:
+
+```csharp
+public static KpiAchievementEvaluation Evaluate(
+    decimal? current,
+    decimal? target,
+    string? direction,
+    decimal? yellowThreshold = null,
+    decimal? redThreshold = null)
+```
+
+职责:
+
+1. 识别方向;
+2. 解析默认阈值;
+3. 校验阈值组合;
+4. 计算达成比例;
+5. 计算绿黄红;
+6. 计算是否越过严重红色阈值;
+7. 对空值和目标 0 返回安全结果。
+
+不要在不同控制器重复该算法。
+
+### B3. 保留兼容方法
+
+保留现有:
+
+```csharp
+AchievementLevel(...)
+```
+
+避免一次性修改全部调用方。
+
+其实现改为:
+
+```csharp
+return Evaluate(...).Status;
+```
+
+删除未使用的局部变量,确保静态分析不再报告 `rPct` 未使用。
+
+如需要公开严重阈值判断,可提供:
+
+```csharp
+IsBeyondRedThreshold(...)
+```
+
+但该方法也必须委托给 `Evaluate`,不能复制算法。
+
+### B4. 动态看板只评估一次
+
+修改 `AidopKanbanController.DashboardPage.cs` 的 `BuildDashboardMetricPayload`:
+
+1. 调用一次 `AidopS4KpiMerge.Evaluate`;
+2. `statusColor = evaluation.Status`;
+3. `isBlink = evaluation.Status == "red" && evaluation.IsBeyondRedThreshold`;
+4. 删除控制器内私有的固定阈值 `IsBeyondRedThreshold`;
+5. 不改变现有接口字段名。
+
+禁止:
+
+- 先调用 `AchievementLevel`,再调用另一套严重阈值算法;
+- 在控制器保留固定 95/80/110/120;
+- 忽略 KPI 主数据中的自定义阈值。
+
+### B5. 保存时校验阈值
+
+找到 KPI 主数据新增和编辑的真实保存入口。
+
+保存前校验:
+
+- 方向为 `higher_is_better` 时,满足 `0 < red <= yellow <= 100`;
+- 方向为 `lower_is_better` 时,满足 `100 <= yellow <= red`;
+- 两个阈值均为空时允许保存;
+- 只配置一个阈值时,另一个按默认值参与组合校验。
+
+错误信息应带上方向和合法范围,例如:
+
+```text
+higher_is_better 阈值不合法,应满足 0 < RedThreshold <= YellowThreshold <= 100。
+```
+
+不得在保存时自动交换黄色、红色阈值。
+
+### B6. 复核全部调用方
+
+逐个检查第 2.3 节列出的调用方:
+
+- 编译通过;
+- 状态语义一致;
+- 没有自建第二套阈值算法;
+- 智慧诊断选最差指标时使用统一状态;
+- ChatBI 返回状态与动态看板一致。
+
+## 6. 前端实施步骤
+
+本任务原则上不需要改前端,因为现有接口字段保持不变。
+
+只做以下核对:
+
+- `statusColor` 继续驱动绿黄红;
+- `isBlink` 继续驱动严重红色闪烁;
+- 页面不自行按期量差正负重新推导状态;
+- 页面不固定写死 80%、95%、110%、120%。
+
+若发现前端存在重复判断,只删除重复逻辑并使用后端结果;不得在前端再实现一套阈值引擎。
+
+## 7. 自动化测试
+
+新增建议文件:
+
+`server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/KpiAchievementEvaluationTests.cs`
+
+### 7.1 higher_is_better 默认边界
+
+至少覆盖:
+
+```text
+actual/target = 100%    => green, blink=false
+actual/target = 99%     => yellow, blink=false
+actual/target = 95%     => yellow, blink=false
+actual/target = 94.99%  => red, blink=false
+actual/target = 80%     => red, blink=false
+actual/target = 79.99%  => red, blink=true
+```
+
+### 7.2 lower_is_better 默认边界
+
+至少覆盖:
+
+```text
+actual/target = 100%    => green, blink=false
+actual/target = 100.01% => yellow, blink=false
+actual/target = 110%    => yellow, blink=false
+actual/target = 110.01% => red, blink=false
+actual/target = 120%    => red, blink=false
+actual/target = 120.01% => red, blink=true
+```
+
+### 7.3 自定义阈值
+
+覆盖:
+
+- higher:黄色 90、严重红色 70;
+- lower:黄色 115、严重红色 130;
+- 颜色和闪烁均按自定义阈值变化;
+- 证明不再固定使用 80/120。
+
+### 7.4 空值和目标 0
+
+覆盖:
+
+- `current = null`;
+- `target = null`;
+- `target = 0`。
+
+期望:
+
+- 黄色;
+- 比例为空;
+- 不闪烁;
+- 不抛异常。
+
+### 7.5 非法阈值
+
+覆盖:
+
+- higher:红色阈值大于黄色阈值;
+- higher:黄色阈值大于 100;
+- lower:黄色阈值小于 100;
+- lower:红色阈值小于黄色阈值。
+
+运行时评估应回落整组默认阈值;保存验证应拒绝。
+
+### 7.6 兼容方法
+
+同一输入分别调用:
+
+- `Evaluate(...).Status`
+- `AchievementLevel(...)`
+
+两者状态必须完全一致。
+
+## 8. 构建与验证命令
+
+至少执行:
+
+```text
+dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj -f net10.0 --filter FullyQualifiedName~KpiAchievementEvaluationTests --nologo
+```
+
+然后执行:
+
+```text
+dotnet build server/Plugins/Admin.NET.Plugin.AiDOP/Admin.NET.Plugin.AiDOP.csproj -c Debug -f net10.0 --nologo
+```
+
+如果前端没有代码改动,不强制重新构建前端;若改动了前端,则必须执行:
+
+```text
+pnpm build
+```
+
+## 9. UAT
+
+### U1. S1 核心指标
+
+核对:
+
+- 订单评审周期:`lower_is_better`,正差为不利;
+- 订单评审满足率:`higher_is_better`,负差为不利;
+- 主计划人效:`higher_is_better`,负差为不利;
+- 成品库存周转:`lower_is_better`,正差为不利。
+
+状态必须与方向和阈值一致。
+
+### U2. 黄色边界
+
+分别准备或选择:
+
+- higher 达成比例恰好等于黄色阈值;
+- lower 达成比例恰好等于黄色阈值。
+
+两者均应为黄色且不闪烁。
+
+### U3. 严重红色边界
+
+分别验证:
+
+- 恰好等于红色阈值:红色但不闪烁;
+- 越过红色阈值:红色且闪烁。
+
+### U4. 自定义阈值
+
+选择一个非默认阈值 KPI,验证:
+
+- 动态看板颜色;
+- 动态看板闪烁;
+- 智慧诊断颜色;
+- ChatBI 状态。
+
+四处必须一致。
+
+### U5. 跨模块抽检
+
+至少从 S1~S7、S9 各选一个 KPI,覆盖:
+
+- 越高越好;
+- 越低越好;
+- 达标;
+- 黄色;
+- 红色;
+- 严重红色。
+
+## 10. 跨模块影响
+
+### 影响模块
+
+- S1 产销协同
+- S2 生产排程
+- S3 供应协同
+- S4 现场执行
+- S5 物料协同
+- S6 生产执行
+- S7 成品储运
+- S9 运营指标
+- 智慧诊断
+- ChatBI
+
+### 影响能力
+
+- KPI 状态颜色;
+- 严重指标闪烁;
+- 全局智慧诊断最差指标排序;
+- 首页、动态看板、通用详情;
+- ChatBI 指标状态解释。
+
+### 风险
+
+自定义 `RedThreshold` 过去未控制闪烁,修复后某些指标的闪烁行为会变化。这是预期行为,但必须通过跨模块 UAT 留证。
+
+## 11. 禁止事项
+
+执行模型不得:
+
+1. 修改期量差公式为绝对值。
+2. 仅按正负判断指标好坏。
+3. 忽略 `Direction`。
+4. 为修复闪烁而批量改数据库阈值。
+5. 新增橙色状态。
+6. 在前端复制阈值算法。
+7. 在不同控制器保留不同默认值。
+8. 修改历史 KPI 日值。
+9. 顺手调整指标目标值或方向。
+10. 未经用户要求执行 Git commit 或 push。
+
+## 12. 完成标准
+
+- [ ] 新增统一评估结果和评估方法。
+- [ ] `AchievementLevel` 委托统一评估方法。
+- [ ] `RedThreshold` 实际控制严重红色判断。
+- [ ] 动态看板删除固定 80/120 私有算法。
+- [ ] 状态和闪烁来自同一次评估。
+- [ ] 自定义阈值生效。
+- [ ] 非法阈值保存时被拒绝。
+- [ ] 历史非法阈值运行时安全回落。
+- [ ] 默认边界测试通过。
+- [ ] 自定义阈值测试通过。
+- [ ] 空值和非法阈值测试通过。
+- [ ] 插件构建通过。
+- [ ] S1~S7、S9 完成抽检。
+- [ ] 智慧诊断与动态看板状态一致。
+- [ ] 未修改业务数据和无关模块。
+
+## 13. 执行后的汇报格式
+
+```text
+实现结果
+- 统一评估器:
+- 动态看板:
+- 保存校验:
+- 兼容调用方:
+
+测试
+- 单元测试:
+- 后端构建:
+- 前端构建(如适用):
+
+UAT
+- S1:
+- S2:
+- S3:
+- S4:
+- S5:
+- S6:
+- S7:
+- S9:
+- ChatBI:
+
+未完成/阻塞
+- ...
+
+Git
+- 是否提交:
+- 是否推送:
+- 当前版本:
+```
+
+## 14. Git 与版本规则
+
+- 本任务书和定义文档本身属于纯文档,不要求递增版本。
+- 后续实施涉及后端代码;用户要求提交时,后端版本必须按仓库规则 patch +1。
+- 如果实际还修改了前端运行代码,前端版本也必须按仓库规则 patch +1,并同步锁文件。
+- 用户说“提交”时按仓库规则执行 commit + push。
+- 工作区已有其他未提交改动时,必须先区分本任务文件,禁止覆盖、回退或误提交无关内容。

+ 594 - 0
doc/plan/智慧诊断配置树展示修复任务书_20260814.md

@@ -0,0 +1,594 @@
+# 智慧诊断配置树展示修复任务书
+
+> 日期:2026-08-14  
+> 适用范围:Ai-DOP 动态看板「智慧诊断 / 全局智慧诊断」  
+> 关联待办:P-037  
+> 执行方式:本任务书供其他大模型直接照章实施。实施前必须先检查工作区现状,不得覆盖或回退已有未提交改动。
+
+## 1. 目标
+
+修复智慧诊断在所选 L1 没有下级指标时,跨树抓取其他 L2 指标补位的问题。
+
+最终行为必须满足:
+
+1. 「智慧诊断」严格使用看板当前选中的 L1。
+2. 「全局智慧诊断」严格选择当前模块真实最差的 L1。
+3. 页面只沿指标主数据中实际配置的父子关系展示:
+   - L1 有 L2:展示对应 L2。
+   - L2 有 L3:允许展开对应 L3。
+   - L3 有 L4:允许继续展开对应 L4。
+   - 任意层级没有下一级:在该层自然截止。
+4. 没有 L2/L3/L4 是合法配置形态:
+   - 不视为接口失败;
+   - 不提示“配置错误”或“请维护指标”;
+   - 不生成假数据;
+   - 不从其他指标树取数据补位。
+5. 指标值、状态、达成率和页面标题必须来自真实接口结果,不保留特定指标的演示硬编码。
+
+## 2. 已确认的问题与证据
+
+### 2.1 跨树补位
+
+文件:
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs`
+
+当前代码先取 `ParentId == root.Id` 的 L2;如果没有子项,则执行:
+
+```csharp
+children = kpis.Where(x => x.MetricLevel == 2).OrderBy(x => x.SortNo).Take(5).ToList();
+```
+
+这会把模块内其他 L1 的 L2 塡到当前根指标下。
+
+已观察到的错误实例:
+
+- 全局诊断真实选择:`S1_L1_004 成品库存周转(天)`
+- 目标:15 天
+- 实际:约 101.94 天
+- 期量差:约 `+86.94 天`
+- 该 L1 当前没有 L2
+- 页面却展示了合同评审、BOM 评审及技术售前组等订单评审链路
+
+顶部根指标与下层问题不属于同一棵指标树,属于严重的数据语义错误。
+
+### 2.2 BOM 评审达成率硬编码
+
+同一后端文件的 `BuildDiagnosisRate` 对 `S1_L2_004` 固定返回 `58%`。该值不是根据当前目标和实际计算得出,必须删除。
+
+### 2.3 空子树被前端当成加载失败
+
+文件:
+
+`Web/src/views/aidop/diagnosis/index.vue`
+
+当前 `applyDiagnosisPayload` 在 `payload.stages` 为空时直接返回 `false`,随后页面显示“当前指标暂无诊断数据”。
+
+这混淆了两种情况:
+
+- 没有根指标或接口数据不可用:异常/无数据;
+- 根指标有效,但没有配置下一级:正常终止节点。
+
+必须区分。
+
+### 2.4 页面覆盖接口标题
+
+接口已返回模块和根指标相关标题/副标题,前端却把主标题固定为“协同运营平台 · 智慧诊断”。全局诊断时用户无法直观看出系统自动选择了哪个根指标。
+
+### 2.5 闭环顶部状态固定
+
+`loopCards` 中:
+
+- 改善计划固定为“待填写”;
+- 有效性验证固定为“待验证”。
+
+卡片状态没有跟随 `improvementPlan.status` 和验证结果变化。
+
+## 3. 业务口径(实施时不得改写)
+
+### 3.1 层级不是强制四级
+
+L1、L2、L3、L4 是最大可用层级,不是每个指标必须具备的完整四级结构。
+
+以下结构全部合法:
+
+- 只有 L1;
+- L1 → L2;
+- L1 → L2 → L3;
+- L1 → L2 → L3 → L4;
+- 同一父节点下,一部分子指标有下一层,另一部分没有下一层。
+
+页面按实际配置树展示到最后一级即可。
+
+### 3.2 两个入口的根指标选择
+
+#### 智慧诊断
+
+- 请求带 `metricCode`;
+- 根指标必须是该模块中匹配的已启用 L1;
+- 不得因该 L1 没有子节点而切换到其他 L1。
+
+#### 全局智慧诊断
+
+- 请求不带 `metricCode`;
+- 在模块全部已启用 L1 中选择真实最差项;
+- 排序继续使用“状态严重度 → 偏差程度 → SortNo”;
+- 不得只在“有 L2 的 L1”中选择;
+- 不得为了展示更多内容跳到次差指标。
+
+### 3.3 无下级时的文案
+
+使用中性文案:
+
+> 当前指标暂无下级诊断项。
+
+禁止使用:
+
+- “配置错误”;
+- “请先维护 L3/L4”;
+- “指标配置不完整”;
+- 任何暗示必须补齐下一级的措辞。
+
+### 3.4 改善闭环
+
+当根指标没有 L2 时:
+
+- 根指标本身仍是有效诊断对象;
+- “目标&现状”正常展示根指标;
+- “主要问题”以根指标本身作为当前问题对象,不构造虚假责任链;
+- 可基于根指标创建改善计划;
+- 责任部门优先使用根指标主数据的 `Department`,无值时显示“未配置”。
+
+## 4. 改动范围
+
+### 4.1 后端
+
+主要文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs`
+
+按需要补充测试:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/`
+
+### 4.2 前端
+
+主要文件:
+
+- `Web/src/views/aidop/diagnosis/index.vue`
+- `Web/src/views/aidop/api/kanbanData.ts`
+
+仅在确有必要时检查,不默认修改:
+
+- `Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue`
+
+现有入口参数已经符合口径:
+
+- 普通智慧诊断传 `metricCode`;
+- 全局智慧诊断不传 `metricCode`。
+
+### 4.3 明确不改
+
+- 不新增或补造 L2/L3/L4 主数据;
+- 不修改现有指标父子关系;
+- 不修改 KPI 算法或库存周转口径;
+- 不清洗历史 KPI 日值;
+- 不修改菜单、FUNC 编号或路由;
+- 不把“订单评审满足率”硬编码为全局诊断结果;
+- 不清理当前 `display:none` 的旧诊断表格,除非其代码阻碍本任务实现;
+- 不做与本问题无关的页面重构。
+
+## 5. 后端执行步骤
+
+### B1. 删除跨树回退
+
+在 `GetSmartDiagnosis` 中:
+
+1. 保留 `ParentId == root.Id && MetricLevel == 2` 的直接子节点查询。
+2. 删除 `children.Count == 0` 时从模块全部 L2 `Take(5)` 的逻辑。
+3. `children` 为空时仍返回 HTTP 200 和完整根指标信息,`stages` 返回空数组。
+
+禁止用以下方式替代:
+
+- 找同模块第一个有 L2 的 L1;
+- 找次差且有 L2 的 L1;
+- 取固定 5 个 L2;
+- 使用种子演示数据。
+
+### B2. 完善根指标响应
+
+在现有响应 `root` 中补充必要的展示信息:
+
+- `metricCode`
+- `metricName`
+- `currentValue`
+- `targetValue`
+- `unit`
+- `status`
+- `gapLabel`
+- `department`
+
+建议增加:
+
+- `selectionMode`:`specified` 或 `global_worst`
+- `hasChildren`:根据真实 `children.Count > 0`
+
+字段命名如有仓库统一风格,以现有接口风格为准,但前后端必须一致。
+
+### B3. 保持严格父子关系
+
+构造各层级时必须保持:
+
+- L2:`ParentId == root.Id`
+- L3:`ParentId == 当前 L2.Id`
+- L4:`ParentId == 当前 L3.Id`
+
+某个 L2 没有 L3 时,其 `tier1` 返回空数组。
+
+某个 L3 没有 L4 时,对应 `tier2` 可返回空数组或不包含该键;前端必须兼容两种正常空形态。
+
+### B4. 删除固定 58%
+
+删除:
+
+```csharp
+if (string.Equals(metricCode, "S1_L2_004", StringComparison.OrdinalIgnoreCase)) return "58%";
+```
+
+所有指标统一按当前通用公式计算达成率:
+
+- `lower_is_better`:`target / current × 100%`
+- 其他方向:`current / target × 100%`
+
+空值、目标为 0 等情况继续返回 `-`,不得制造默认百分比。
+
+不要擅自将达成率封顶为 100%;如果产品需要封顶,应另立任务确认。
+
+### B5. 指定指标无效时不要静默切全局
+
+当前逻辑在传入 `metricCode` 但找不到对应 L1 时,会继续执行全局最差选择。应改为:
+
+- 传了 `metricCode` 且该模块没有匹配的已启用 L1:返回明确的 400;
+- 未传 `metricCode`:才执行全局最差选择。
+
+建议错误信息:
+
+> metricCode 不是当前模块已启用的 L1 指标。
+
+### B6. 不把根状态完全依赖历史颜色字段
+
+复核根指标排序和最终展示状态:
+
+- 最差根选择目前使用值表 `StatusColor`;
+- 返回状态重新通过当前值、目标、方向和阈值计算。
+
+二者应保持同一口径。若确认 `StatusColor` 可能陈旧,根选择也应调用统一状态计算函数;不要让“选择时是绿、展示时是红”。
+
+本步骤只做口径统一,不修改阈值规则。
+
+## 6. 前端执行步骤
+
+### F1. 扩展接口类型
+
+在 `SmartDiagnosisPayload` / `root` 类型中补充后端新增字段:
+
+- `root.department`
+- `selectionMode`
+- `hasChildren`
+
+保持字段可选以兼容部署期间前后端短暂版本差异。
+
+### F2. 正确接受空子树
+
+修改 `applyDiagnosisPayload`:
+
+1. 不能再用 `!payload?.stages?.length` 判定整个响应无效。
+2. 根指标存在即视为有效诊断响应。
+3. `stages` 不是数组时按空数组处理。
+4. 每次加载都清空上一次的:
+   - `stages`
+   - L3 映射
+   - L4 映射
+   - 当前展开节点
+   - 与旧根指标绑定的改善计划临时状态(如现有业务要求保留已生成计划,则按计划 ID 精确处理,不得串指标)。
+
+避免从“有下级指标”的诊断切到“无下级指标”的诊断时残留旧表格。
+
+### F3. 区分三种页面状态
+
+页面必须区分:
+
+1. 请求中:显示加载态。
+2. 请求失败或没有有效根指标:显示错误与重试。
+3. 根指标有效但无下级:
+   - 正常显示诊断页面;
+   - “目标&现状”显示根指标;
+   - “主要问题”显示根指标本身;
+   - 下钻区域中性显示“当前指标暂无下级诊断项”或直接不显示;
+   - 不出现错误 Alert。
+
+### F4. 按配置决定下钻按钮
+
+- L2 有 L3:显示对应“下钻详情”入口。
+- L2 无 L3:不显示可点击的下钻按钮。
+- L3 有 L4:允许继续展开。
+- L3 无 L4:不显示继续下钻入口。
+
+如果保留空态文字,只能使用中性文案,不得提示用户补配置。
+
+### F5. 根指标作为无子树时的主要问题
+
+调整 `mainProblem`:
+
+- 有下层时:继续在当前根树内按“严重度优先、同严重度取更深层”寻找问题点。
+- 无下层时:返回根指标本身,使用:
+  - 名称:`root.metricName`
+  - 目标:格式化后的 `root.targetValue + unit`
+  - 实际:格式化后的 `root.currentValue + unit`
+  - 状态:`root.status`
+  - 部门:`root.department`
+
+任何情况下不得访问其他根指标的节点。
+
+### F6. 使用接口标题并标识选择方式
+
+- 主标题优先使用 `payload.title`。
+- 副标题优先使用 `payload.subtitle`。
+- `selectionMode == global_worst` 时增加清晰标识,例如:
+
+  > 全局自动选择:成品库存周转(天)
+
+- `selectionMode == specified` 时可显示:
+
+  > 当前指标:订单评审周期(天)
+
+不要只显示内部编码而不显示业务名称。
+
+### F7. 统一“实际 / 达成率”语义
+
+当前表格把实际值与达成率显示在同一单元格。应至少将行标题或表头明确为:
+
+> 实际 / 达成率
+
+不得把达成率称为实际值,也不得保留固定 `58%`。
+
+本任务不要求重做表格布局。
+
+### F8. 闭环卡片使用真实状态
+
+`loopCards` 中:
+
+- 改善计划卡片根据 `improvementPlan?.status` 使用 `improvementStatusLabel`;
+- 未生成时显示“未生成”或“待创建”,不要固定“待填写”;
+- 有效性验证根据计划状态和验证结果显示:
+  - 未生成;
+  - 待执行;
+  - 待验证;
+  - 已验证;
+  - 已关闭;
+- 色彩应与实际状态匹配。
+
+本步骤只修顶部卡片摘要,不重构已有改善计划和审批流程。
+
+### F9. 保持入口契约
+
+复核但不要无故改写 `DynamicModuleDashboard.vue`:
+
+- `goSmartDiagnosis`:传当前 `metricCode`;
+- `goGlobalSmartDiagnosis`:不传 `metricCode`;
+- 查询筛选条件继续透传。
+
+如当前行为已符合,不修改该文件。
+
+## 7. 自动化测试
+
+### 7.1 后端最低测试集
+
+优先为纯选择/建树逻辑增加可独立测试的方法,避免测试依赖真实开发库。不得为测试大规模重构控制器。
+
+至少覆盖:
+
+1. **指定 L1 有 L2**
+   - 只返回 `ParentId == root.Id` 的 L2;
+   - 不混入同模块其他 L1 的 L2。
+
+2. **指定 L1 无 L2**
+   - 根指标正常;
+   - `stages.Count == 0`;
+   - HTTP 200。
+
+3. **全局最差 L1 无 L2**
+   - 仍选择该真实最差 L1;
+   - 不跳到次差且有 L2 的指标;
+   - `stages.Count == 0`。
+
+4. **非完整层级**
+   - 一个 L2 有 L3,另一个没有 L3;
+   - 结果分别为有内容和空数组;
+   - 无异常。
+
+5. **L3 无 L4**
+   - L3 正常返回;
+   - L4 为空;
+   - 无异常。
+
+6. **指定无效 metricCode**
+   - 不静默切换全局诊断;
+   - 返回明确 400。
+
+7. **达成率**
+   - `S1_L2_004` 使用通用公式;
+   - 结果不再固定为 `58%`;
+   - 空值与目标 0 返回 `-`。
+
+### 7.2 前端验证
+
+若现有项目没有适合该大型 SFC 的单元测试基础,不为本任务强行引入新测试框架;至少执行:
+
+```text
+pnpm build
+```
+
+并通过浏览器完成第 8 节 UAT。
+
+## 8. UAT 场景
+
+### U1. 普通智慧诊断:完整树
+
+1. 进入 S1 动态看板。
+2. 选择一个已有 L2/L3 的 L1。
+3. 点击“智慧诊断”。
+4. 验证根指标与当前看板选择一致。
+5. 验证 L2/L3/L4 只来自该根指标。
+
+### U2. 全局诊断:最差 L1 无 L2
+
+以当前 S1 数据为例:
+
+1. 点击“全局智慧诊断”。
+2. 如果成品库存周转仍为真实最差 L1,应继续选中它。
+3. 显示其目标、实际、差异和状态。
+4. 不显示合同评审、BOM 评审、技术售前组等其他树数据。
+5. 页面正常,不显示错误 Alert。
+6. 下级区域为空或显示“当前指标暂无下级诊断项”。
+
+### U3. L2 有、L3 无
+
+1. 选择存在 L2 但其中部分 L2 没有 L3 的根指标。
+2. 有 L3 的 L2 可以下钻。
+3. 无 L3 的 L2 不显示可点击下钻入口。
+4. 页面不提示配置错误。
+
+### U4. L3 有、L4 无
+
+1. 展开一个有 L3、无 L4 的分支。
+2. L3 正常展示。
+3. 不出现虚假 L4,不提示异常。
+
+### U5. 达成率
+
+1. 查看 BOM 评审周期。
+2. 按接口目标和实际手工复算。
+3. 页面结果与通用公式一致,不再固定为 58%。
+
+### U6. 闭环状态
+
+1. 未生成改善计划:顶部显示未生成/待创建。
+2. 生成草稿:显示草稿。
+3. 提交审批、执行、待验证、关闭后分别显示真实状态。
+
+### U7. 跨模块回归
+
+该接口支持 S1/S2/S3/S4/S5/S6/S7/S9,至少逐模块验证:
+
+- 普通智慧诊断能进入;
+- 全局智慧诊断能进入;
+- 无下级节点不会串入其他树;
+- 有下级节点可按实际层级展开;
+- 无控制台异常。
+
+不要求每个模块都具备 L3/L4。
+
+## 9. 禁止事项
+
+执行模型不得:
+
+1. 为让页面“看起来丰富”而补演示数据。
+2. 把无 L3/L4 认定为配置缺陷。
+3. 自动修改 KPI 主数据父子关系。
+4. 强制所有指标补齐四层。
+5. 将全局诊断限定为“有子节点的最差指标”。
+6. 硬编码全局诊断必须选择“订单评审满足率”。
+7. 保留任何特定指标固定达成率。
+8. 在本任务中清洗 KPI 历史数据。
+9. 顺手重构隐藏旧诊断区块或全局布局。
+10. 未经用户要求执行 Git commit 或 push。
+
+## 10. 跨模块影响
+
+### 影响模块
+
+- S1 产销协同
+- S2 生产排程
+- S3 供应协同
+- S4 现场执行
+- S5 物料协同
+- S6 生产执行
+- S7 成品储运
+- S9 运营指标
+
+### 影响能力
+
+- 动态看板普通智慧诊断入口;
+- 全局智慧诊断根指标选择;
+- L2/L3/L4 展示和下钻;
+- 改善计划问题对象;
+- 有效性验证对象与状态摘要。
+
+### 预期行为变化
+
+以前无 L2 的根指标可能错误显示模块其他 L2;修复后将只显示根指标和中性空下级状态。
+
+这是正确的可见行为变化,验收时不得把“内容变少”误判为回归。
+
+## 11. 完成标准
+
+同时满足以下条件才可宣告完成:
+
+- [ ] 后端不存在跨树 `Take(5)` 回退。
+- [ ] 全局诊断仍选择真实最差 L1。
+- [ ] 无 L2/L3/L4 均作为正常终止处理。
+- [ ] 前端空子树不进入错误态。
+- [ ] 根指标无 L2 时仍可作为改善对象。
+- [ ] 指标树严格遵循 `ParentId`。
+- [ ] `S1_L2_004` 不再固定返回 58%。
+- [ ] 标题明确显示模块、根指标和选择方式。
+- [ ] 闭环顶部状态与真实计划一致。
+- [ ] 后端测试通过。
+- [ ] 前端构建通过。
+- [ ] S1~S7、S9 完成最小跨模块回归。
+- [ ] 未修改指标主数据、历史 KPI 值或无关模块。
+
+## 12. 执行后的汇报格式
+
+执行模型完成后按以下格式汇报:
+
+```text
+实现结果
+- 后端:
+- 前端:
+- 接口契约:
+
+验证
+- 后端测试:
+- 前端构建:
+- UAT:
+
+跨模块回归
+- S1:
+- S2:
+- S3:
+- S4:
+- S5:
+- S6:
+- S7:
+- S9:
+
+未完成/阻塞
+- ...
+
+Git 状态
+- 是否提交:
+- 是否推送:
+- 当前版本:
+```
+
+## 13. Git 与版本规则
+
+- 本任务书本身是纯文档,不要求递增前后端版本。
+- 后续实际修改后端与前端代码时,如用户要求提交:
+  - 后端版本按仓库规则 patch +1;
+  - 前端版本按仓库规则 patch +1;
+  - 锁文件与 `package.json` 版本保持一致;
+  - 用户说“提交”时按仓库规则执行 commit + push。
+- 工作区当前可能已有其他未提交改动,执行模型必须先检查 `git status` 和 diff,禁止覆盖、回退或误纳入无关改动。

+ 1274 - 0
doc/plan/运营指标目标管理与Excel数据源接入任务书_20260814.md

@@ -0,0 +1,1274 @@
+# 运营指标目标管理与 Excel 数据源接入任务书
+
+> 日期:2026-08-14  
+> 输入方案:[`运营指标目标管理与Excel数据源接入方案_20260814.md`](./运营指标目标管理与Excel数据源接入方案_20260814.md)  
+> 业务定义:[`关键定义说明/期量差说明.md`](./关键定义说明/期量差说明.md)  
+> 执行对象:本任务书供其他大模型直接照章实施。  
+> 重要:这是跨模块建设,必须分工作包实施和验收;不得一次性大改后再统一排错。
+
+## 1. 最终目标
+
+完成后必须同时满足:
+
+1. “运营指标主数据 → 指标管理”可以维护 KPI 当前、历史和未来目标。
+2. 目标按租户、工厂、指标、生效日期管理,不存入 KPI 主表。
+3. 单项维护和目标 Excel 导入调用同一套保存校验。
+4. KPI 日值保存当天目标快照及来源,历史日值不随新配置漂移。
+5. S1~S7、S9 使用统一目标解析服务。
+6. S1~S4 硬编码目标有受控兼容期和退出路径。
+7. 数据中台增加正式 `FILE_EXCEL` 入站能力。
+8. 业务 Excel 经过 MDP 贴源/标准层和现有 KPI 计算,不直接写 KPI 日值。
+9. 目标 Excel、业务 Excel、Demo Excel 三条链路完全隔离。
+10. DB/API 现有入站和看板现有接口无回归。
+
+## 2. 执行前强制检查
+
+实施模型第一步必须:
+
+1. 阅读仓库根目录 `AGENTS.md` 和 `.cursor/rules/`。
+2. 执行并阅读:
+
+```text
+git status --short
+git diff
+git log -10 --oneline
+```
+
+3. 确认工作区已有未提交修改,尤其是:
+
+- `AidopS4KpiMerge.cs`;
+- `AidopKanbanController.DashboardPage.cs`;
+- `AdoSmartOpsKpiMasterController.cs`;
+- 智慧诊断相关文件;
+- 1.0.341 / 1.0.342 verify SQL;
+- 本方案和任务书。
+
+不得覆盖、回退、格式化或顺手提交这些已有修改。
+
+4. 检查执行时后端当前版本,迁移 SQL 使用实际下一个可用版本号,不要机械写死本任务书示例版本。
+5. 查询当前数据库真实结构与数据量:
+
+```sql
+SHOW CREATE TABLE ado_smart_ops_kpi_master;
+SHOW CREATE TABLE ado_s9_kpi_value_l1_day;
+SHOW CREATE TABLE mdp_source;
+SHOW CREATE TABLE mdp_entity;
+SHOW CREATE TABLE mdp_field_mapping;
+
+SELECT module_code, COUNT(*), MAX(biz_date)
+FROM ado_s9_kpi_value_l1_day
+GROUP BY module_code;
+
+SELECT source_type, COUNT(*)
+FROM mdp_source
+GROUP BY source_type;
+```
+
+6. 盘点当前租户/工厂、正式目标缺失和硬编码目标命中情况,保存为 UAT 前基线。
+7. 未经用户要求不得执行 Git commit 或 push。
+
+## 3. 实施边界
+
+### 3.1 本任务必做
+
+- 正式目标配置表、服务、接口和前端目标管理;
+- 目标 Excel 模板、预检、确认导入、错误报告;
+- 统一目标解析器;
+- 日值目标快照来源字段;
+- S1~S7、S9 目标解析接入;
+- Demo 导入隔离保护;
+- MDP `FILE_EXCEL` 基础设施;
+- 至少一个已有 MDP 实体完成正式 Excel 端到端试点;
+- 自动化测试、构建和跨模块 UAT。
+
+### 3.2 首期不做
+
+- 不一次性为 S1~S7 每张业务表手写专用导入控制器;
+- 不开放无保护的全量替换;
+- 不直接从 Excel 写 KPI 实际值;
+- 不重算全部历史 KPI 日值;
+- 不删除现有 DB/API 数据源;
+- 不把 Demo 导入改造成正式导入;
+- 不实现不可逆外部业务动作的通用自动回滚;
+- 不新增独立菜单,优先复用现有 `FUNC-S0-058` 和 `FUNC-S9-012`。
+
+## 4. 工作包总览与顺序
+
+必须按以下顺序执行:
+
+```text
+WP0 基线与契约测试
+ → WP1 数据库与实体
+ → WP2 目标服务/API
+ → WP3 目标管理前端与目标 Excel
+ → WP4 日值写入统一解析
+ → WP5 Demo 隔离与旧目标迁移
+ → WP6 MDP FILE_EXCEL 后端
+ → WP7 数据中台文件导入前端
+ → WP8 试点实体端到端
+ → WP9 回归、UAT、文档回写
+```
+
+每个 WP 完成后先跑对应测试,再进入下一工作包。
+
+---
+
+## WP0. 基线与契约测试
+
+### WP0-1 建立目标来源基线
+
+新增只读审计 SQL 或测试辅助脚本,输出:
+
+- 各模块目标为空数量;
+- 各模块最新目标值;
+- S1~S4 当日、历史、硬编码默认的命中情况;
+- S5~S7 新增日值目标为空情况;
+- S9 当前目标来源。
+
+不得在该步骤修改业务数据。
+
+### WP0-2 锁定现有行为
+
+新增测试,至少确认:
+
+- 看板从日值表读取 `target_value`;
+- 目标为空时状态安全,不除零;
+- DB/API MDP 执行器选择保持现状;
+- KPI Demo 导入仍是独立端点。
+
+建议测试目录:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/
+```
+
+### WP0 验收
+
+- 基线报告可重复执行;
+- 未改数据库;
+- 现有测试通过;
+- 已记录所有目标写入路径。
+
+---
+
+## WP1. 数据库迁移与实体
+
+### WP1-1 新增正式目标配置表
+
+新增实体:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiTargetConfig.cs
+```
+
+对应表 `ado_smart_ops_kpi_target_config`,字段必须符合方案第 4.2 节。
+
+最低 DDL 契约:
+
+```sql
+CREATE TABLE ado_smart_ops_kpi_target_config (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  factory_id BIGINT NOT NULL DEFAULT 0,
+  metric_code VARCHAR(50) NOT NULL,
+  target_value DECIMAL(18,6) NOT NULL,
+  effective_from DATE NOT NULL,
+  effective_to DATE NULL,
+  source_type VARCHAR(30) NOT NULL,
+  source_batch_id VARCHAR(100) NULL,
+  status TINYINT NOT NULL DEFAULT 1,
+  remark VARCHAR(500) NULL,
+  created_by BIGINT NULL,
+  updated_by BIGINT NULL,
+  create_time DATETIME(3) NOT NULL,
+  update_time DATETIME(3) NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_kpi_target_start
+    (tenant_id, factory_id, metric_code, effective_from),
+  KEY ix_kpi_target_resolve
+    (tenant_id, factory_id, metric_code, status, effective_from, effective_to),
+  KEY ix_kpi_target_batch (source_batch_id)
+);
+```
+
+不要给 `metric_code` 建跨租户外键;通过服务校验租户内 KPI 存在性。
+
+### WP1-2 新增目标 Excel 批次表
+
+新增:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiTargetImportBatch.cs
+```
+
+表:`ado_smart_ops_kpi_target_import_batch`
+
+至少包含:
+
+- `batch_no`;
+- `tenant_id`;
+- `factory_id`;
+- 文件名、SHA-256;
+- 状态 `VALIDATING/VALIDATION_FAILED/READY/COMMITTED/FAILED/VOIDED`;
+- 总数、有效数、错误数;
+- 待提交规范化内容 `payload_json`;
+- 错误内容 `error_json` 或错误报告对象键;
+- 操作人和时间。
+
+批次内容只保存目标导入,不与 MDP 业务文件批次混用。
+
+### WP1-3 日值增加目标血缘
+
+对以下四表幂等增加:
+
+```text
+target_config_id BIGINT NULL
+target_source VARCHAR(30) NULL
+target_resolved_at DATETIME(3) NULL
+```
+
+目标表:
+
+- `ado_s9_kpi_value_l1_day`
+- `ado_s9_kpi_value_l2_day`
+- `ado_s9_kpi_value_l3_day`
+- `ado_s9_kpi_value_l4_day`
+
+不回填历史 `target_config_id`,不得修改历史 `target_value`。
+
+### WP1-4 MDP 文件批次表
+
+新增实体:
+
+```text
+Entity/DataPlatform/MdpFileImportBatch.cs
+Entity/DataPlatform/MdpFileImportRow.cs
+Entity/DataPlatform/MdpFileImportError.cs
+```
+
+新增表:
+
+- `mdp_file_import_batch`
+- `mdp_file_import_row`
+- `mdp_file_import_error`
+
+字段按方案第 5.3 节实现。必须包含租户、工厂、source/entity、批次号、文件哈希、状态、行号、业务键、原始/映射 JSON 和错误。
+
+### WP1-5 扩展 MDP 配置
+
+修改:
+
+```text
+Entity/DataPlatform/MdpSource.cs
+Entity/DataPlatform/MdpEntity.cs
+```
+
+要求:
+
+- `MdpSource.SourceType` 注释和校验支持 `FILE_EXCEL`;
+- `MdpEntity` 增加可空 `file_config_json`;
+- `MdpEntity` 增加 `is_public_template`,默认 0;只有显式为 1 的实体模板才能跨租户读取;
+- DB/API 旧记录无须迁移,默认行为不变。
+
+### WP1-6 迁移脚本
+
+在:
+
+```text
+server/Admin.NET.Web.Entry/UpdateScripts/
+```
+
+创建执行时实际下一版本的:
+
+```text
+<version>.sql
+<version>.verify.sql
+```
+
+要求:
+
+- 取号前执行 `git fetch --all --prune`,检查本地和远端已有迁移号,禁止与他人脚本撞号;
+- 同步在 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 为两个脚本登记 `<CopyToOutputDirectory>Always</CopyToOutputDirectory>`;
+- 所有 DDL 幂等;
+- verify 只包含可执行查询;
+- verify 不在语句尾附加可能被迁移器误识别为独立语句的注释;
+- 验证表、列和索引存在;
+- 不写业务目标数据;
+- 构建后确认两个脚本已进入 `bin/Debug/net10.0/UpdateScripts/`;
+- 启动后查询实际迁移日志表(当前实现使用的 `sys_db_migration_log` 或执行时确认的等价表),确认版本成功,不得只以文件存在作为成功。
+
+### WP1 测试
+
+新增数据库结构契约测试,至少检查:
+
+- 目标唯一起始日索引;
+- 解析索引;
+- 四级日值目标血缘列;
+- MDP 三张批次表;
+- `FILE_EXCEL` 旧数据兼容。
+
+---
+
+## WP2. 目标服务与 API
+
+### WP2-1 DTO
+
+新增:
+
+```text
+Dto/SmartOps/AdoSmartOpsKpiTargetDtos.cs
+```
+
+至少包括:
+
+- `KpiTargetCreateDto`
+- `KpiTargetUpdateDto`
+- `KpiTargetQueryDto`
+- `KpiTargetResolveResult`
+- `KpiTargetImportPreviewDto`
+- `KpiTargetImportCommitDto`
+
+客户端 DTO 不允许传 `tenant_id`,服务端从上下文获取。
+
+### WP2-2 目标保存服务
+
+新增:
+
+```text
+SmartOps/KpiTargetService.cs
+```
+
+职责:
+
+1. 校验 KPI 属于当前租户;
+2. 校验工厂授权;
+3. 校验目标大于 0;
+4. 校验生效日期;
+5. 事务中锁定同指标/同工厂配置并检查重叠;
+6. 支持“新增未来目标”;
+7. 支持显式关闭上一开放目标;
+8. 禁止静默截断未来目标;
+9. 记录来源和操作人;
+10. 已生效配置原则上不原地改目标,只创建新版本。
+
+重叠判断:
+
+```text
+existing.from <= new.to(or max)
+AND new.from <= existing.to(or max)
+```
+
+### WP2-3 统一解析器
+
+新增:
+
+```text
+SmartOps/IKpiTargetResolver.cs
+SmartOps/KpiTargetResolver.cs
+```
+
+完整解析顺序必须固定,并且全部位于 `KpiTargetResolver` 内:
+
+1. 当前租户 + 当前工厂 + 指标 + 日期的正式配置;
+2. 当前租户 + `factory_id=0` + 指标 + 日期的正式配置;
+3. 兼容期:当天已有日值目标;
+4. 兼容期:最近历史日值目标;
+5. 兼容期:S1~S4 代码默认目标;
+6. 空。
+
+同一作用域命中多条视为数据错误:记录错误并拒绝选择“最新一条”掩盖问题。
+
+为兼容期新增:
+
+```text
+SmartOps/ILegacyKpiTargetProvider.cs
+```
+
+该提供器集中封装第 3~5 步:
+
+- S1~S4 可启用当天/历史/代码默认兼容;
+- S5~S7、S9 默认不读取旧日值或硬编码,只使用正式配置;
+- 每种命中必须返回明确来源并记录日志和计数;
+- 不得把历史查询或硬编码复制到各模块写入服务。
+
+目标来源枚举必须统一:
+
+| 层次 | 允许值 |
+|---|---|
+| 配置 `source_type` | `MANUAL` / `TARGET_EXCEL` / `LEGACY_MIGRATION` |
+| 日值 `target_source` | `FORMAL_FACTORY` / `FORMAL_TENANT_DEFAULT` / `LEGACY_DAILY_CURRENT` / `LEGACY_DAILY_PRIOR` / `LEGACY_CODE` / `DEMO_IMPORT` |
+| 仅统计、不得写库 | `MISSING` |
+
+正式日值通过 `target_config_id` 追溯配置来源,禁止把配置 `source_type` 直接当作日值作用域来源。
+
+### WP2-4 API
+
+新增:
+
+```text
+Controllers/AdoSmartOpsKpiTargetController.cs
+```
+
+控制器显式使用 `[Route("api/smart-ops/kpi-targets")]`,路由按方案第 7.1 节。要求:
+
+- 不加 `[AllowAnonymous]`;
+- 返回明确 400/404/409;
+- 重叠区间返回 409;
+- 所有查询带租户条件;
+- 禁止按客户端传入 TenantId 查询;
+- 禁止删除已经被日值引用的目标,改为停用或结束生效期。
+
+### WP2 自动化测试
+
+新增:
+
+```text
+Tests/SmartOps/KpiTargetServiceTests.cs
+Tests/SmartOps/KpiTargetResolverTests.cs
+```
+
+覆盖:
+
+- 当前工厂优先于租户默认;
+- 生效起止边界均包含;
+- 未来目标未提前生效;
+- 目标切换日前后;
+- 重叠拒绝;
+- 目标 <= 0 拒绝;
+- 跨租户拒绝;
+- 找不到目标返回空;
+- 兼容目标仅在无正式目标时生效;
+- 同作用域重复命中报错;
+- 历史日值不被修改。
+
+---
+
+## WP3. 目标管理前端与目标 Excel
+
+### WP3-1 API 封装
+
+新增:
+
+```text
+Web/src/views/aidop/api/kpiTargetApi.ts
+```
+
+提供列表、当前目标、新增、停用、模板下载、预检、确认和错误报告接口。
+
+### WP3-2 目标管理组件
+
+新增:
+
+```text
+Web/src/views/aidop/kanban/components/KpiTargetManager.vue
+```
+
+修改:
+
+```text
+Web/src/views/aidop/kanban/kpiMaster.vue
+```
+
+要求:
+
+- 选中 KPI 后加载目标;
+- 显示当前工厂、当前目标、来源和生效期;
+- 支持新增当前/未来目标;
+- 展示目标历史;
+- 生效目标不可直接覆盖;
+- 对重叠、未来冲突显示服务端错误;
+- 不把目标值加入现有 `KpiMasterUpsertDto`;
+- 不改变黄/红阈值保存接口。
+
+### WP3-3 目标 Excel
+
+后端建议新增:
+
+```text
+SmartOps/KpiTargetExcelService.cs
+```
+
+可复用 `MiniExcelUtil`,但不得复用 Demo 导入的删除/造数逻辑。
+
+模板列固定为:
+
+```text
+指标编码 ★
+工厂编码
+目标值 ★
+生效日期 ★
+失效日期
+备注
+```
+
+预检必须验证:
+
+- 指标存在且属于租户;
+- 工厂合法;
+- 数值和日期;
+- 同文件重复/重叠;
+- 与数据库现有配置重叠;
+- 文件 SHA-256 重复;
+- 最大行数和文件大小。
+
+确认导入:
+
+- 只能提交状态为 `READY` 的批次;
+- 单事务全成全败;
+- 并发下重新检查冲突;
+- 成功后状态改为 `COMMITTED`;
+- 不允许同一批次重复提交。
+
+### WP3-4 Demo 页面提示
+
+保留现有“数据导入”Tab,继续标记为 Demo。增加明确提示:
+
+```text
+本功能仅用于演示数据初始化,不用于正式目标维护。
+正式目标请在“指标管理 → 目标管理”维护或导入。
+```
+
+Demo 页面不得调用 `kpiTargetApi.ts`。
+
+### WP3 测试
+
+- 组件切换 KPI 不串目标;
+- 切换工厂重新加载;
+- 预检失败不能确认;
+- 导入成功刷新当前目标;
+- Demo Tab 与目标导入 API 完全分离。
+
+---
+
+## WP4. KPI 日值写入接入统一目标解析
+
+### WP4-1 新增统一快照模型
+
+建议新增:
+
+```text
+SmartOps/KpiTargetSnapshot.cs
+```
+
+包含:
+
+- `TargetValue`
+- `TargetConfigId`
+- `TargetSource`
+- `ResolvedAt`
+
+所有日值写入方使用该模型,禁止只取 decimal 后丢失来源。
+
+### WP4-2 S1~S4
+
+修改:
+
+```text
+Order/S1MdpSyncTransformService.cs
+Production/S2MdpSyncTransformService.cs
+Supply/S3MdpSyncTransformService.cs
+ProcurementExecution/S4MdpSyncTransformService.cs
+```
+
+把:
+
+```text
+当日已有 → 最近历史 → DefaultS*Target
+```
+
+替换为一次 `IKpiTargetResolver.ResolveAsync(...)` 调用。旧当日、历史和代码默认已经封装在解析器的兼容提供器内,模块层不得追加 fallback。
+
+要求:
+
+- 每次兜底写结构化日志;
+- 正式目标命中时写 `target_config_id/source/resolved_at`;
+- 兼容目标按实际来源写 `LEGACY_DAILY_CURRENT`、`LEGACY_DAILY_PRIOR` 或 `LEGACY_CODE`;
+- 不再在各服务复制目标选择顺序;
+- 状态计算继续调用统一 `AidopS4KpiMerge`。
+
+### WP4-3 S5~S7
+
+修改:
+
+```text
+MaterialWarehouse/S5MdpSyncTransformService.cs
+Manufacturing/S6MdpSyncTransformService.cs
+FinishedWarehouse/S7MdpSyncTransformService.cs
+```
+
+这些服务当前主要只写实际值。必须在 INSERT/UPDATE 同时解析并写目标快照。
+
+禁止:
+
+- 把目标写成实际值的 105%;
+- 从 Demo 表随机取目标;
+- 目标为空时写 0;
+- 继续沿用旧 `status_color` 而不按目标重新评估。
+
+### WP4-4 S9
+
+先定位 S9 日值真实写入入口。不得凭名称新造一套重复调度。
+
+要求:
+
+- S9 写日值时调用同一解析器;
+- 若当前仅由 Demo/脚本写入,新增正式计算入口时必须复用现有 KPI 计算调度;
+- 无正式目标则写 `target_value=NULL`,并在目标缺失报表中出现。
+
+### WP4-5 读取方
+
+原则上以下读取方继续读取日值快照,不改为查询当前目标配置:
+
+- 首页;
+- 动态看板;
+- 通用详情;
+- 智慧诊断;
+- ChatBI;
+- 改善闭环趋势。
+
+只按需增加 `targetSource` 展示/调试字段,不改变既有 `targetValue` 字段名。
+
+### WP4 测试
+
+至少覆盖:
+
+- 同一 KPI 目标切换日前后写入不同快照;
+- 目标配置后来变更不影响旧日值;
+- S5~S7 不再因新行而目标必为空;
+- 无目标仍为空,不写 0;
+- 正式目标优先于旧日值和硬编码;
+- 状态颜色使用快照目标。
+
+---
+
+## WP5. Demo 隔离与旧目标迁移
+
+### WP5-1 Demo 保护
+
+修改:
+
+```text
+Controllers/AdoSmartOpsKpiMasterController.cs
+Web/src/views/aidop/kanban/kpiMaster.vue
+```
+
+要求:
+
+- Demo 导入不写目标配置表;
+- Demo 导入不删除目标配置;
+- Demo 导入生成的日值标记 `target_source=DEMO_IMPORT`;
+- 增加 Demo 租户/环境保护,保护规则从配置读取,不在控制器硬编码唯一租户 ID;
+- 非允许环境调用返回 403;
+- 原有“删除租户全部日值”行为必须再次确认,并只作用于当前租户;
+- 不允许从 Demo 导入影响其他工厂,除非模板和接口明确选择工厂。
+
+### WP5-2 迁移预览
+
+新增一次性迁移服务或管理脚本,必须先 `preview`:
+
+```text
+SmartOps/KpiTargetMigrationService.cs
+```
+
+预览输出:
+
+- 将创建的租户/工厂/指标;
+- 最新目标和来源日期;
+- 已有正式配置跳过项;
+- 冲突项;
+- 无目标项;
+- 可能来自 Demo 的目标项。
+
+默认不自动提交。
+
+### WP5-3 迁移提交
+
+仅在用户确认预览后执行:
+
+- 最新非空、非 Demo 可疑目标写 `LEGACY_MIGRATION`;
+- 生效日为迁移日;
+- 不回写历史日值;
+- 不把随机 Demo 目标迁入正式配置;
+- 冲突全量报告,不静默覆盖。
+
+### WP5-4 兼容退出指标
+
+增加可查询统计:
+
+- `FORMAL_FACTORY`
+- `FORMAL_TENANT_DEFAULT`
+- `LEGACY_DAILY_CURRENT`
+- `LEGACY_DAILY_PRIOR`
+- `LEGACY_CODE`
+- `MISSING`
+
+只有 `LEGACY_DAILY_CURRENT`、`LEGACY_DAILY_PRIOR`、`LEGACY_CODE` 连续约定周期为 0,才能另开任务删除旧逻辑。本任务不直接删除硬编码。
+
+---
+
+## WP6. MDP `FILE_EXCEL` 后端
+
+### WP6-1 文件 DTO 与服务
+
+新增:
+
+```text
+Dto/DataPlatform/MdpFileImportDtos.cs
+DataPlatform/FileImport/MdpExcelTemplateService.cs
+DataPlatform/FileImport/MdpExcelValidationService.cs
+DataPlatform/FileImport/MdpExcelImportService.cs
+DataPlatform/FileImport/MdpFileImportSecurity.cs
+```
+
+不得增加新的 Excel 依赖,优先复用现有 `MiniExcelUtil` / MiniExcel 包。
+
+### WP6-2 接口
+
+新增经过鉴权的服务/控制器:
+
+```text
+DataPlatform/MdpFileImportService.cs
+```
+
+实现方案第 7.2 节接口。
+
+不要把写接口加到当前带 `[AllowAnonymous]` 的 `MdpSourceConfigService` 中。
+
+同时修改 `MdpSourcePullDispatcher`:
+
+- 读取到 `source_type=FILE_EXCEL` 时明确抛出“文件源不支持 Pull,请使用文件导入接口”;
+- 禁止 FILE 按默认分支进入 `MdpDbPullExecutor`;
+- 禁止 FILE 实体登记为同步任务 Pull/Extract 步骤,保存任务配置时也要校验;
+- FILE commit 只允许执行 Transform 后半段。
+
+### WP6-3 模板
+
+按 `mdp_entity + mdp_field_mapping` 动态生成模板:
+
+- 文件实体必须属于当前租户或公共模板;
+- 必须配置目标贴源表;
+- 必须配置业务键;
+- 列顺序按 `sort_order`;
+- 必填列标 `★`;
+- 类型和示例放在说明 Sheet;
+- 模板中不得出现可由服务端注入的 `tenant_id`;
+- `factory_id` 使用用户有权访问的工厂编码或由上传页面选定。
+
+### WP6-4 预检
+
+上传后:
+
+1. 校验扩展名、MIME、ZIP 签名、大小;
+2. 限制 Sheet、行、列和单元格长度;
+3. 拒绝宏文件、外部链接和不允许的公式;
+4. 解析列名;
+5. 使用 `mdp_field_mapping` 转换;
+6. 服务端覆盖租户和页面选择的工厂;
+7. 计算业务键和行哈希;
+8. 写批次、原始行和错误;
+9. 不写贴源表;
+10. 返回预检摘要。
+
+重要:`MdpStagingWriter` 当前允许源行合法 `tenant_id` 优先于上下文。Excel 服务必须删除/忽略上传内容中的租户字段并由服务端注入,不能把客户端租户字典原样传入。
+
+### WP6-5 确认导入
+
+确认时:
+
+- 只允许 `READY`;
+- 重新校验当前租户、实体状态和配置版本;
+- 防止同批重复执行;
+- 使用 `MdpStagingWriter.UpsertAsync` 写同一贴源契约;
+- `source_system` 使用 FILE 数据源编码;
+- `source_table` 使用文件实体编码或 Sheet;
+- `source_row_id` 使用批次号 + 行号;
+- `sync_batch_id` 使用文件批次号;
+- 不动态接受请求传入表名;
+- 记录写入数和失败原因。
+
+### WP6-6 Transform
+
+文件导入不能止于贴源层。
+
+执行模型必须先找出试点实体当前 DB/API 路径使用的 Transform/服务,不得新建不同口径。文件 commit 后调用同一 Transform 或同一同步任务后半段。
+
+如果当前任务编排无法从“贴源已完成”开始执行,则新增明确的服务注册:
+
+```text
+IMdpFileImportConsumer
+```
+
+按受控 `entity_code` 注册,不允许根据客户端字符串反射任意方法。
+
+### WP6-7 作废与回滚
+
+- 未 commit:状态改 `VOIDED`;
+- 已 commit 未 Transform:按批次删除贴源行;
+- 已 Transform:仅对声明 `rebuildable=true` 的实体执行作用域重建;
+- 已外部回写:返回 409,要求业务冲销;
+- 所有状态迁移必须并发安全。
+
+### WP6-8 数据源列表
+
+修改:
+
+```text
+DataPlatform/MdpSourceConfigService.cs
+```
+
+只读列表需正确展示:
+
+- `FILE_EXCEL`;
+- 方向为“文件入站”;
+- Host 显示“-”;
+- 健康状态改为“可用/配置异常”,不执行 DB 探活。
+
+租户和鉴权要求:
+
+- 当前 `MdpSourceConfigService` 的匿名、无租户过滤列表不能直接用于 FILE;
+- 优先移除该服务的 `[AllowAnonymous]`,并按当前登录租户过滤 `mdp_source`;
+- 若旧 DB/API 匿名列表因兼容暂时不能收口,则旧接口必须排除 `FILE_EXCEL`,并为 FILE 新增独立的鉴权、租户化列表;
+- 文件实体查询同时按 `mdp_entity.tenant_id` 和 source 归属过滤;
+- 公共模板必须有显式公共标记,禁止把其他租户配置当公共配置;
+- 不得破坏当前租户内 DB/API 展示。
+
+### WP6 自动化测试
+
+新增:
+
+```text
+Tests/DataPlatform/MdpExcelTemplateTests.cs
+Tests/DataPlatform/MdpExcelValidationTests.cs
+Tests/DataPlatform/MdpExcelImportTests.cs
+Tests/DataPlatform/MdpFileImportSecurityTests.cs
+```
+
+覆盖:
+
+- 模板映射;
+- 必填/类型/日期;
+- 同批重复;
+- 文件哈希重复;
+- 业务键幂等;
+- 跨租户字段注入被忽略;
+- 非白名单表名不可用;
+- 超大文件/行/单元格拒绝;
+- 预检不落贴源;
+- commit 只执行一次;
+- FILE 调用 Pull dispatcher 被明确拒绝;
+- DB/API dispatcher 行为不变;
+- 可撤销和不可撤销状态。
+
+---
+
+## WP7. 数据中台文件导入前端
+
+### WP7-1 数据源页面
+
+修改:
+
+```text
+Web/src/views/aidop/data-platform/sources.vue
+Web/src/views/aidop/data-platform/api/syncTasks.ts
+```
+
+增加 `FILE_EXCEL` 筛选和展示。
+
+### WP7-2 文件导入组件
+
+新增:
+
+```text
+Web/src/views/aidop/data-platform/components/MdpFileImportDrawer.vue
+Web/src/views/aidop/data-platform/components/MdpFileImportBatchList.vue
+Web/src/views/aidop/data-platform/api/fileImports.ts
+```
+
+功能:
+
+- 选择 FILE 实体;
+- 下载模板;
+- 选择工厂;
+- 上传并预检;
+- 展示有效、错误、重复数量;
+- 下载错误报告;
+- 确认导入;
+- 查看批次和状态;
+- 按后端能力显示“作废/撤销”;
+- 不在前端自行计算业务键或覆盖策略。
+
+### WP7-3 菜单
+
+默认作为数据源管理页内 Tab/抽屉,不新增菜单,不改 FUNC。
+
+若实际产品决定新增独立叶子路由,实施模型必须停下,先列出跨模块影响并等待用户确认,然后按 `aidop-func-code-menu.mdc` 登记 FUNC。
+
+### WP7 测试
+
+- DB/API 列表无回归;
+- FILE 类型显示正确;
+- 未预检不能确认;
+- 错误报告可下载;
+- 成功批次不可重复提交;
+- 不同租户/工厂切换不串数据。
+
+---
+
+## WP8. 试点实体端到端
+
+### WP8-1 选择规则
+
+不得凭空新造实体编码。先查询:
+
+```sql
+SELECT e.id, e.entity_code, e.entity_name, e.target_table_name,
+       e.biz_key_expr, s.source_code, s.source_type
+FROM mdp_entity e
+JOIN mdp_source s ON s.id=e.source_id
+WHERE e.status=1
+ORDER BY e.entity_code;
+```
+
+选择一个满足以下条件的已有实体:
+
+- 已有 DB/API 正式入站;
+- 已有完整字段映射;
+- 已有业务键;
+- 已有 Transform;
+- 不触发不可逆外部回写;
+- 能影响至少一个可观察 KPI。
+
+优先选择 S1 或 S5 的只入站实体。若没有满足条件的实体,报告阻塞,不得降低安全要求。
+
+### WP8-2 建立 FILE 变体
+
+为试点建立 FILE 数据源和实体配置,目标贴源表、字段映射、业务键和 Transform 与现有路径一致。
+
+建议通过配置复制,不复制业务代码。
+
+### WP8-3 端到端验收
+
+准备小型 Excel:
+
+- 2 条新增;
+- 1 条相同哈希重复;
+- 1 条同键更新;
+- 1 条必填错误;
+- 1 条非法日期;
+- 1 条伪造 tenant_id。
+
+验收:
+
+1. 预检只显示,不落贴源;
+2. 错误行不能 commit;
+3. 修复后 commit;
+4. 贴源批次可追溯;
+5. 标准层/DWD 正确;
+6. KPI 实际值更新;
+7. KPI 目标来自正式目标配置;
+8. 看板期量差和状态正确;
+9. 重传不重复;
+10. DB/API 再跑无回归。
+
+---
+
+## WP9. UAT、构建与回写
+
+### WP9-1 目标管理 UAT
+
+至少覆盖:
+
+1. 当前工厂手工目标;
+2. 租户默认目标;
+3. 未来目标;
+4. 生效切换边界;
+5. 目标历史;
+6. 目标 Excel 预检和提交;
+7. 重叠冲突;
+8. 跨租户/工厂隔离;
+9. Demo 导入不影响正式目标;
+10. 历史日值不漂移。
+
+### WP9-2 跨模块 UAT
+
+S1~S7、S9 各选至少一个 KPI:
+
+- 当前实际值;
+- 正式目标;
+- 目标来源;
+- 期量差;
+- 状态;
+- 严重红/闪烁;
+- 智慧诊断;
+- ChatBI。
+
+目标为空的模块必须显示“未配置目标”,不能伪造 0 或随机值。
+
+### WP9-3 Excel UAT
+
+- 模板;
+- 预检;
+- 错误报告;
+- 确认;
+- 幂等;
+- 血缘;
+- 作废;
+- 受控回滚;
+- DB/API 回归。
+
+### WP9-4 构建命令
+
+至少执行:
+
+```text
+dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj -f net10.0 --filter FullyQualifiedName~KpiTarget --nologo
+
+dotnet test server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Admin.NET.Plugin.AiDOP.Tests.csproj -f net10.0 --filter FullyQualifiedName~MdpExcel --nologo
+
+dotnet build server/Plugins/Admin.NET.Plugin.AiDOP/Admin.NET.Plugin.AiDOP.csproj -c Debug -f net10.0 --nologo
+```
+
+前端改动后执行:
+
+```text
+cd Web
+pnpm build
+```
+
+并使用 `ReadLints` 检查所有新改前端和后端文件。
+
+### WP9-5 数据库验收 SQL
+
+```sql
+-- 重叠配置(期望 0 行;MySQL 8)
+SELECT a.tenant_id, a.factory_id, a.metric_code, a.id, b.id
+FROM ado_smart_ops_kpi_target_config a
+JOIN ado_smart_ops_kpi_target_config b
+  ON a.id < b.id
+ AND a.tenant_id=b.tenant_id
+ AND a.factory_id=b.factory_id
+ AND a.metric_code=b.metric_code
+ AND a.status=1 AND b.status=1
+ AND a.effective_from <= COALESCE(b.effective_to, '9999-12-31')
+ AND b.effective_from <= COALESCE(a.effective_to, '9999-12-31');
+
+-- 正式目标来源分布
+SELECT source_type, COUNT(*)
+FROM ado_smart_ops_kpi_target_config
+WHERE status=1
+GROUP BY source_type;
+
+-- 最新日值目标缺失
+SELECT module_code, COUNT(*) missing_count
+FROM ado_s9_kpi_value_l1_day
+WHERE is_deleted=0
+  AND target_value IS NULL
+  AND biz_date=(SELECT MAX(biz_date) FROM ado_s9_kpi_value_l1_day)
+GROUP BY module_code;
+
+-- 文件批次状态
+SELECT status, COUNT(*)
+FROM mdp_file_import_batch
+GROUP BY status;
+```
+
+## 5. API 契约关键点
+
+### 5.1 目标新增示例
+
+```json
+{
+  "metricCode": "S1_L1_001",
+  "factoryId": 1,
+  "targetValue": 3,
+  "effectiveFrom": "2026-09-01",
+  "effectiveTo": null,
+  "closePrevious": true,
+  "remark": "九月目标"
+}
+```
+
+服务端不得接收或信任 `tenantId`。
+
+### 5.2 目标解析示例
+
+```json
+{
+  "metricCode": "S1_L1_001",
+  "factoryId": 1,
+  "bizDate": "2026-09-01",
+  "targetValue": 3,
+  "targetConfigId": 123,
+  "targetSource": "FORMAL_FACTORY",
+  "effectiveFrom": "2026-09-01",
+  "effectiveTo": null
+}
+```
+
+`targetSource` 表示本次解析命中的作用域;配置自身是页面手工还是目标 Excel 导入,应通过 `targetConfigId` 追溯配置表 `source_type`。
+
+### 5.3 文件预检响应示例
+
+```json
+{
+  "batchNo": "FILE_20260814_001",
+  "status": "READY",
+  "totalRows": 20,
+  "validRows": 18,
+  "errorRows": 2,
+  "duplicateRows": 1,
+  "canCommit": false,
+  "errorReportUrl": "/api/DataPlatform/file-imports/FILE_20260814_001/errors"
+}
+```
+
+只要存在阻断错误,`canCommit=false`。
+
+## 6. 安全要求
+
+1. 新增接口全部鉴权。
+2. 强制租户过滤。
+3. 工厂权限服务端校验。
+4. Excel 中的 tenant/company/factory 不得覆盖服务端作用域。
+5. 文件名不用于构造磁盘路径或 SQL。
+6. 表名和实体只能来自服务端配置白名单。
+7. 禁止宏、外部链接和超限压缩包。
+8. 错误 Excel 导出防公式注入。
+9. 日志不得记录完整敏感原始行。
+10. 原始文件保存策略必须复用项目现有文件存储;若未找到正式存储服务,只保存哈希和规范化行,不得自行写任意本地绝对路径。
+11. 批次提交和状态迁移使用事务/条件更新,防重复点击。
+12. 迁移、删除、回滚 SQL 必须同时带租户和批次条件。
+
+## 7. 跨模块影响
+
+### 影响模块
+
+- S1~S7、S9;
+- 数据中台;
+- 动态看板;
+- 智慧诊断;
+- ChatBI;
+- KPI Demo 初始化。
+
+### 影响能力
+
+- 目标值来源与日值写入;
+- KPI 状态和告警;
+- Excel 文件入站;
+- MDP 字段映射、贴源、Transform、血缘;
+- 历史目标解释。
+
+### 执行要求
+
+任何实施步骤如果需要:
+
+- 改公共 `MdpStagingWriter` 核心租户解析;
+- 改所有 DB/API 入站行为;
+- 新增独立菜单;
+- 改全量替换语义;
+- 改历史 KPI 日值;
+
+必须先单独列出跨模块影响并等待用户确认,不得顺手实施。
+
+## 8. 禁止事项
+
+执行模型不得:
+
+1. 把 `TargetValue` 加到 KPI 主表作为唯一目标。
+2. 每次看板查询都用当前配置重算历史目标。
+3. 把目标 Excel 交给 Demo 导入接口。
+4. 把业务 Excel 直接转成 KPI 实际值。
+5. 无目标时生成随机值、0 或实际值的固定比例。
+6. 静默覆盖重叠目标。
+7. 静默交换日期或数值。
+8. 信任 Excel 中的租户 ID。
+9. 接受客户端传入任意目标表名。
+10. 复制一套与 DB/API 不同的业务 Transform。
+11. 默认开启全量删除/替换。
+12. 对不可逆业务动作伪装“回滚成功”。
+13. 批量改写历史日值。
+14. 未经要求提交或推送 Git。
+
+## 9. 完成标准
+
+- [ ] 正式目标表和索引上线。
+- [ ] 目标 Excel 批次可审计。
+- [ ] 四级日值具备目标来源字段。
+- [ ] 目标服务拒绝重叠和越权。
+- [ ] 当前工厂/租户默认/未来目标解析正确。
+- [ ] 指标管理页可维护和查看目标。
+- [ ] 目标 Excel 支持预检、错误报告、确认导入。
+- [ ] Demo 导入不影响正式目标。
+- [ ] S1~S7、S9 接入统一目标解析。
+- [ ] 无目标不再伪造。
+- [ ] `FILE_EXCEL` 数据源可配置和展示。
+- [ ] 文件模板由字段映射生成。
+- [ ] 文件预检不写业务数据。
+- [ ] commit 幂等、批次可追溯。
+- [ ] 至少一个实体完成 Excel → MDP → KPI。
+- [ ] DB/API 入站无回归。
+- [ ] 后端测试和构建通过。
+- [ ] 前端构建通过。
+- [ ] 跨模块 UAT 留证。
+- [ ] 未修改无关业务数据。
+
+## 10. 执行后汇报格式
+
+```text
+实现结果
+- 目标配置表:
+- 目标解析器:
+- 单项目标管理:
+- 目标 Excel:
+- 日值快照:
+- S1~S7、S9 接入:
+- Demo 隔离:
+- FILE_EXCEL:
+- 试点实体:
+
+测试
+- KpiTarget:
+- MdpExcel:
+- 后端构建:
+- 前端构建:
+
+迁移
+- 历史日值是否改写:
+- 正式目标迁移预览:
+- 已迁移数量:
+- 冲突/缺失:
+
+UAT
+- 目标生效边界:
+- S1:
+- S2:
+- S3:
+- S4:
+- S5:
+- S6:
+- S7:
+- S9:
+- 智慧诊断:
+- ChatBI:
+- Excel 端到端:
+
+未完成/阻塞
+- ...
+
+Git
+- 是否提交:
+- 是否推送:
+- Web 版本:
+- Server 版本:
+```
+
+## 11. Git 与版本规则
+
+- 本方案和任务书本身是文档,不要求升版本。
+- 实施涉及前后端代码;用户要求提交时,前端和后端版本均按仓库规则 patch +1。
+- 数据库迁移版本必须与提交中的后端版本一致。
+- 前端版本变更同步锁文件。
+- 用户说“提交”时按仓库规则执行 commit + push。
+- 工作区存在其他未提交修改时,必须按实际提交范围处理,不得误提交无关文件。
+

+ 653 - 0
doc/plan/运营指标目标管理与Excel数据源接入方案_20260814.md

@@ -0,0 +1,653 @@
+# 运营指标目标管理与 Excel 数据源接入方案
+
+> 日期:2026-08-14  
+> 适用范围:Ai-DOP S1~S9 运营指标、数据中台、动态看板、智慧诊断、ChatBI  
+> 配套任务书:[`运营指标目标管理与Excel数据源接入任务书_20260814.md`](./运营指标目标管理与Excel数据源接入任务书_20260814.md)  
+> 相关定义:[`关键定义说明/期量差说明.md`](./关键定义说明/期量差说明.md)
+
+## 1. 背景与问题
+
+当前系统已经具备 KPI 主数据、日值、绿黄红、动态看板和数据中台 DB/API 接入能力,但“目标值”和“无第三方系统时的数据接入”尚未形成正式闭环。
+
+### 1.1 目标值现状
+
+1. KPI 主数据 `ado_smart_ops_kpi_master` 没有目标值字段。
+2. 看板实际读取 `ado_s9_kpi_value_l1_day`~`l4_day.target_value`。
+3. S1~S4 刷新时按“当日已有目标 → 最近历史目标 → C# 硬编码默认值”取目标。
+4. S5~S7 刷新主要写实际值,部分日值目标为空。
+5. S9 没有统一的正式目标维护链路。
+6. “运营指标主数据 → 数据导入”属于 Demo 造数工具,会清理并重建演示日值,不能作为用户日常目标维护入口。
+7. 当前没有目标生效日期、未来目标、历史版本、工厂范围和变更审计。
+
+因此,日值表中的 `target_value` 只是计算当天使用的目标快照,不应继续承担“目标配置源”的职责。
+
+### 1.2 Excel 接入现状
+
+1. 数据中台 `mdp_source.source_type` 当前只有 `DB`、`API`。
+2. MDP 入站执行器只有 `DB_SYNC`、`API_PULL`。
+3. S1~S7 没有通用的正式业务 Excel 入站链路。
+4. S0 四类检验规范虽有 Excel 导入,但直接写业务表,只能作为局部实现参考。
+5. KPI Demo Excel 直接生成合成日值,不能替代订单、采购、工单、库存等业务事实数据。
+6. `CONFIG_SQL` 只读取已落库的数据,不能直接读取 Excel 文件。
+
+当客户没有 ERP、MES、WMS、SRM 等第三方系统时,系统目前缺少“Excel → 数据中台 → 标准层 → KPI”的正式替代路径。
+
+## 2. 建设目标
+
+本方案同时建设两条相互独立但最终汇合到 KPI 日值的正式链路。
+
+### 2.1 目标管理链路
+
+```text
+单项维护 / 目标 Excel 批量导入
+        ↓
+正式目标配置(带工厂与生效期)
+        ↓
+统一目标解析器
+        ↓
+KPI 日值 target_value 快照
+        ↓
+看板 / 智慧诊断 / ChatBI
+```
+
+### 2.2 无第三方系统的业务 Excel 链路
+
+```text
+业务 Excel
+   ↓
+MDP 文件导入批次
+   ↓
+字段映射与逐行校验
+   ↓
+贴源层 / 标准层
+   ↓
+现有 Transform / KPI 计算
+   ↓
+KPI 实际值 metric_value
+```
+
+### 2.3 必须保持的隔离
+
+```text
+正式业务 Excel ≠ 目标值 Excel ≠ Demo 数据导入
+```
+
+- 正式业务 Excel 产生业务事实和实际值。
+- 目标值 Excel 产生目标配置。
+- Demo 导入只生成演示数据,不进入正式配置链路。
+
+## 3. 总体原则
+
+1. **目标配置与日值快照分离**:目标在配置表维护,日值只保存当天解析结果。
+2. **单项维护与批量导入同源**:两种入口最终调用同一目标服务、执行同一校验。
+3. **实际值不允许从目标入口导入**:目标模板不得包含 KPI 实际值。
+4. **业务 Excel 不直接写 KPI 日值**:必须经过中台贴源/标准层和现有计算口径。
+5. **Demo 永久隔离**:不得把 Demo 目标写成正式目标配置,不得让 Demo 清理正式目标。
+6. **历史不漂移**:目标变更不回写既有日值;历史看板保留当时目标快照。
+7. **租户和工厂强隔离**:服务端从登录上下文取租户,不接受客户端指定任意租户。
+8. **可审计、可重放、可解释**:目标和文件导入都必须记录来源、批次、操作人和结果。
+9. **配置优先于代码默认**:硬编码默认目标只作为迁移期兼容,最终退出正式运行链路。
+10. **无目标不造假**:正式链路找不到目标时返回空并记录告警,不随机生成、不静默写 0。
+
+## 4. 目标值管理设计
+
+### 4.1 页面位置
+
+在现有:
+
+```text
+运营指标主数据 → 指标管理 → 选中 KPI
+```
+
+增加“目标管理”区块,放在指标方向和阈值附近,但目标不存入 KPI 主表。
+
+建议展示:
+
+- 当前工厂;
+- 当前生效目标;
+- 生效日期;
+- 失效日期;
+- 来源(手工/目标 Excel/迁移);
+- “新增目标”;
+- “目标历史”;
+- “下载目标模板”;
+- “批量导入目标”。
+
+黄/红阈值继续属于指标定义;目标值属于随时间、工厂变化的运营配置。
+
+### 4.2 数据模型
+
+新增表:`ado_smart_ops_kpi_target_config`
+
+建议字段:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | bigint | 主键 |
+| `tenant_id` | bigint | 租户 |
+| `factory_id` | bigint | 工厂;`0` 表示租户默认 |
+| `metric_code` | varchar(50) | KPI 编码 |
+| `target_value` | decimal(18,6) | 目标值 |
+| `effective_from` | date | 生效日期,含 |
+| `effective_to` | date nullable | 失效日期,含;空表示长期 |
+| `source_type` | varchar(30) | `MANUAL` / `TARGET_EXCEL` / `LEGACY_MIGRATION` |
+| `source_batch_id` | varchar(100) nullable | Excel 导入批次 |
+| `status` | tinyint | 1 启用、0 停用 |
+| `remark` | varchar(500) nullable | 说明 |
+| `created_by` / `updated_by` | bigint nullable | 操作人 |
+| `create_time` / `update_time` | datetime(3) | 审计时间 |
+
+索引:
+
+```text
+UK(tenant_id, factory_id, metric_code, effective_from)
+IX(tenant_id, factory_id, metric_code, status, effective_from, effective_to)
+IX(source_batch_id)
+```
+
+应用层必须在事务中防止同一租户、工厂、指标存在重叠生效区间。数据库唯一索引只能防止同一起始日重复,不能替代区间校验。
+
+### 4.3 正式目标合法性
+
+- `metric_code` 必须属于当前租户且已存在。
+- `factory_id` 必须在当前租户授权范围内。
+- `target_value` 默认要求大于 0。
+- `effective_to` 不得早于 `effective_from`。
+- 同一租户、工厂、指标的启用区间不得重叠。
+- 不允许通过修改历史记录改变已经产生的历史日值。
+- 已生效目标的更改应创建新版本;旧目标结束于新目标生效日前一天。
+- 存在未来目标时,插入中间区间必须检查前后边界。
+
+负目标和目标为 0 暂不纳入通用达成率口径;确有业务需求时另建比较规则,不能直接绕过校验。
+
+### 4.4 目标解析规则
+
+统一服务建议命名:
+
+```csharp
+IKpiTargetResolver.ResolveAsync(
+    long tenantId,
+    long factoryId,
+    string metricCode,
+    DateOnly bizDate)
+```
+
+完整解析顺序必须全部封装在统一解析器内,模块写入方不得在解析器外追加自己的 fallback:
+
+1. 当前工厂在业务日期生效的正式目标;
+2. `factory_id = 0` 的租户默认目标;
+3. 兼容期:当天已有日值中的目标;
+4. 兼容期:该租户、工厂、指标最近历史日值中的目标;
+5. 兼容期:S1~S4 代码默认目标提供器;
+6. 找不到则返回 `null`,同时记录可观察告警。
+
+第 3~5 步仅用于平滑迁移:
+
+- S1~S4 兼容期启用完整 fallback;
+- S5~S7、S9 默认只使用第 1~2 步,除非迁移预览确认某项历史目标可以转为正式配置;
+- 兼容逻辑必须集中在 `ILegacyKpiTargetProvider`,不得保留在各模块写入服务;
+- 命中来源必须可统计;兼容命中归零后删除第 3~5 步。
+
+同一作用域内不依赖来源优先级,因为正式服务必须禁止重叠区间。`source_type` 只用于审计,不用于掩盖冲突。
+
+建议返回:
+
+```text
+TargetValue
+TargetConfigId
+TargetSource
+EffectiveFrom
+EffectiveTo
+```
+
+### 4.5 目标来源枚举
+
+配置来源和日值解析来源是两个不同概念,不得混用。
+
+| 层次 | 字段 | 允许值 | 含义 |
+|---|---|---|---|
+| 目标配置 | `source_type` | `MANUAL` | 页面单项维护 |
+| 目标配置 | `source_type` | `TARGET_EXCEL` | 正式目标 Excel |
+| 目标配置 | `source_type` | `LEGACY_MIGRATION` | 经确认的历史目标迁移 |
+| 日值快照 | `target_source` | `FORMAL_FACTORY` | 命中当前工厂正式配置 |
+| 日值快照 | `target_source` | `FORMAL_TENANT_DEFAULT` | 命中 `factory_id=0` 正式配置 |
+| 日值快照 | `target_source` | `LEGACY_DAILY_CURRENT` | 兼容期命中当天旧日值 |
+| 日值快照 | `target_source` | `LEGACY_DAILY_PRIOR` | 兼容期命中最近历史日值 |
+| 日值快照 | `target_source` | `LEGACY_CODE` | 兼容期命中 S1~S4 代码默认 |
+| 日值快照 | `target_source` | `DEMO_IMPORT` | Demo 工具生成 |
+| 解析统计 | 非库值 | `MISSING` | 完整解析后仍无目标 |
+
+正式命中通过 `target_config_id` 继续追溯配置自身的 `source_type`。统计报表直接使用上述日值来源,不再另造第三套枚举。
+
+### 4.6 日值快照
+
+KPI 计算和 MDP 刷新写日值时,先调用统一目标解析器,再写:
+
+- `target_value`;
+- `target_config_id`;
+- `target_source`;
+- `target_resolved_at`。
+
+建议向 L1~L4 日值表增加后三个可空字段。历史记录不强制回填配置 ID;已有 `target_value` 保持原样。
+
+看板仍读取日值表快照,不在每次查询时重新匹配当前目标,以防历史状态随配置变化。
+
+### 4.7 单项维护与目标 Excel
+
+单项维护和目标 Excel 使用同一服务,差别只是输入形式。
+
+目标 Excel 模板字段:
+
+```text
+指标编码 ★
+工厂编码
+目标值 ★
+生效日期 ★
+失效日期
+备注
+```
+
+导入采用“两阶段”:
+
+1. 上传并校验,返回有效行、错误行和冲突行;
+2. 用户确认后,在单事务中写入正式目标配置。
+
+不允许上传即落库,不允许部分成功后无批次记录。
+
+冲突处理:
+
+- 默认拒绝重叠,不静默覆盖;
+- 用户选择“顺延关闭上一目标”时,只允许关闭同一指标、同一工厂的上一条开放区间;
+- 不允许自动截断未来目标;
+- 错误报告必须包含 Excel 行号、指标编码和原因。
+
+### 4.8 历史迁移
+
+上线时不改写历史日值。迁移脚本只为“上线日起的正式运行”建立初始目标:
+
+1. 按租户、工厂、指标取最新非空日值目标;
+2. 创建 `LEGACY_MIGRATION` 目标配置,生效日为迁移日;
+3. 同一指标已有正式配置则跳过;
+4. 对 S1~S4 没有历史目标但存在代码默认值的指标,生成迁移清单,必须经负责人确认后再落配置;
+5. S5~S9 无目标的指标列为待配置,不自动随机补值。
+
+迁移完成后分两阶段退出旧逻辑:
+
+- 兼容期:正式配置优先,旧继承/硬编码作为告警兜底;
+- 收口期:删除历史继承和硬编码兜底,无正式目标即返回空。
+
+## 5. 数据中台 Excel 正式数据源设计
+
+### 5.1 数据源类型
+
+扩展 `mdp_source.source_type`:
+
+```text
+DB
+API
+FILE_EXCEL
+```
+
+Excel 属于用户上传的入站源,不是定时“拉取”。因此不应强行伪装成 DB/API 定时任务;应新增文件入站服务,并复用 `mdp_entity`、`mdp_field_mapping` 和后续 Transform。
+
+硬性隔离规则:
+
+- `FILE_EXCEL` 实体不得登记到 `mdp_sync_task` 的 Pull/Extract 步骤;
+- `MdpSourcePullDispatcher` 遇到 `FILE_EXCEL` 必须明确拒绝,不能按默认分支落入 DB executor;
+- 文件只允许通过“上传预检 → 确认导入”接口进入;
+- FILE commit 后可调用同一任务的 Transform 后半段,但不得调用 DB/API Pull。
+
+### 5.2 文件实体配置
+
+复用:
+
+- `mdp_source`:登记 Excel 数据源;
+- `mdp_entity`:登记业务对象与目标贴源表;
+- `mdp_field_mapping`:登记 Excel 列名到目标字段的映射。
+
+建议为 `mdp_entity` 增加可空 `file_config_json`,并增加 `is_public_template`(默认 0)显式标识是否为跨租户可见的公共模板。不得仅以 `tenant_id=0` 推断公共权限。文件配置至少表达:
+
+```json
+{
+  "sheetName": "数据",
+  "headerRow": 1,
+  "maxRows": 50000,
+  "allowedExtensions": [".xlsx"],
+  "importMode": "UPSERT",
+  "rebuildable": false,
+  "transformTaskCode": null
+}
+```
+
+首期只支持 `.xlsx` 和 `UPSERT`。`FULL_REPLACE` 只有在实体明确声明可重建、具备作用域保护和回滚策略后才能开放。
+
+### 5.3 导入批次模型
+
+新增:
+
+#### `mdp_file_import_batch`
+
+记录:
+
+- 租户、工厂;
+- 批次号;
+- source/entity;
+- 原始文件名、SHA-256、大小;
+- Sheet、导入模式;
+- 状态;
+- 总行数、有效行、错误行、写入行;
+- 文件对象键和错误报告对象键;
+- 操作人和时间;
+- Transform/消费状态。
+
+状态建议:
+
+```text
+UPLOADED
+VALIDATING
+VALIDATION_FAILED
+READY
+IMPORTING
+SUCCESS
+FAILED
+VOIDED
+ROLLBACK_PENDING
+ROLLED_BACK
+```
+
+#### `mdp_file_import_row`
+
+保存不可变原始行:
+
+- `batch_id`;
+- `row_no`;
+- `business_key`;
+- `row_hash`;
+- `raw_json`;
+- `mapped_json`;
+- `validation_status`;
+- `write_status`;
+- `error_count`。
+
+该表提供审计、重放和血缘,不把原始 Excel 二进制塞入业务表。
+
+#### `mdp_file_import_error`
+
+保存:
+
+- 批次;
+- 行号;
+- 字段;
+- 错误码;
+- 错误信息;
+- 原始值。
+
+### 5.4 文件入站流程
+
+```text
+下载模板
+  ↓
+上传文件
+  ↓
+文件安全检查
+  ↓
+按字段映射解析
+  ↓
+逐行业务校验
+  ↓
+生成预检结果/错误报告
+  ↓
+用户确认
+  ↓
+写不可变原始行
+  ↓
+写贴源表(带 batch_id / source_row_no)
+  ↓
+调用现有 Transform
+  ↓
+进入标准层、DWD、KPI
+```
+
+### 5.5 模板生成
+
+模板不能由控制器硬编码每一列。应基于 `mdp_field_mapping` 生成:
+
+- 列名;
+- 必填标记;
+- 字段类型;
+- 示例;
+- 字典说明;
+- 日期格式;
+- 业务键说明。
+
+每个文件实体必须有稳定 `entity_code` 和业务键 `biz_key_expr`。没有业务键的实体禁止正式导入。
+
+### 5.6 幂等与覆盖
+
+幂等键:
+
+```text
+tenant_id + entity_id + business_key
+```
+
+同时记录 `row_hash`:
+
+- 相同业务键、相同哈希:跳过;
+- 相同业务键、不同哈希:按实体的 `UPSERT` 规则更新;
+- 同一文件 SHA-256 重复上传:默认提示并阻止,管理员确认后才允许重放;
+- 租户、工厂字段由服务端注入,Excel 不得越权覆盖。
+
+### 5.7 校验与错误处理
+
+至少包含:
+
+- 文件扩展名、MIME、ZIP 签名和大小;
+- 最大 Sheet、行、列、单元格长度;
+- 禁止宏文件和外部链接;
+- 必填、类型、长度、日期格式;
+- 业务键完整性;
+- 字典和主数据引用;
+- 同批重复;
+- 库内重复;
+- 租户、工厂作用域;
+- 目标贴源表和字段必须来自服务端白名单配置。
+
+校验失败时不得写贴源表。错误报告应可下载,内容包含原始行和错误原因,并防止导出公式注入。
+
+### 5.8 批次撤销
+
+“回滚”必须按能力分级,不能承诺对所有业务表做通用物理回滚。
+
+1. 未提交批次:直接作废。
+2. 已写贴源、尚未 Transform:按 `batch_id` 删除本批贴源行。
+3. 已进入标准层但实体声明可重建:标记批次作废,按受影响业务日期/业务键重建标准层与 KPI。
+4. 已触发外部回写或不可逆业务动作:禁止自动回滚,只能执行经业务定义的冲销流程。
+
+页面必须显示当前批次是否可撤销以及原因。
+
+### 5.9 与现有 MDP 的关系
+
+- DB/API 路径保持不变。
+- Excel 入站最终写入与现有 DB/API 相同的贴源或标准入口。
+- 后续 Transform、KPI 计算、监控和血缘尽量复用现有链路。
+- `CONFIG_SQL` 不读取文件,只读取 Excel 已落库后的标准表/DWD。
+- 业务模块不得各自再建一套绕开 MDP 的通用 Excel 导入框架。
+
+## 6. 三类导入的产品边界
+
+| 类型 | 页面 | 写入对象 | 允许用途 |
+|---|---|---|---|
+| 业务 Excel | 数据中台 → 数据源管理/文件导入 | MDP 原始行、贴源层、标准层 | 无第三方系统的正式业务数据 |
+| 目标 Excel | 运营指标主数据 → 目标管理 | KPI 正式目标配置 | 批量维护目标 |
+| Demo Excel | 运营指标主数据 → 数据导入 | 演示 KPI 日值 | 仅 Demo 初始化 |
+
+Demo 导入必须:
+
+- 明示“会重建演示日值”;
+- 仅对允许的 Demo 租户/环境开放;
+- 不写 `ado_smart_ops_kpi_target_config`;
+- 不删除正式目标配置;
+- 不登记为 `TARGET_EXCEL`;
+- 不进入正式 MDP 文件批次。
+
+## 7. 接口建议
+
+### 7.1 目标管理
+
+```text
+GET    /api/smart-ops/kpi-targets?metricCode=&factoryId=
+GET    /api/smart-ops/kpi-targets/current?metricCode=&factoryId=&bizDate=
+POST   /api/smart-ops/kpi-targets
+PUT    /api/smart-ops/kpi-targets/{id}
+POST   /api/smart-ops/kpi-targets/{id}/disable
+GET    /api/smart-ops/kpi-targets/template
+POST   /api/smart-ops/kpi-targets/import/validate
+POST   /api/smart-ops/kpi-targets/import/{batchId}/commit
+GET    /api/smart-ops/kpi-targets/import/{batchId}/errors
+```
+
+### 7.2 MDP 文件入站
+
+```text
+GET    /api/DataPlatform/file-imports/entities
+GET    /api/DataPlatform/file-imports/template/{entityCode}
+POST   /api/DataPlatform/file-imports/validate
+GET    /api/DataPlatform/file-imports/{batchNo}
+GET    /api/DataPlatform/file-imports/{batchNo}/errors
+POST   /api/DataPlatform/file-imports/{batchNo}/commit
+POST   /api/DataPlatform/file-imports/{batchNo}/void
+POST   /api/DataPlatform/file-imports/{batchNo}/rollback
+```
+
+所有接口必须鉴权,不得新增 `[AllowAnonymous]`。
+
+现有 `MdpSourceConfigService` 是匿名接口,且列表缺少租户过滤。引入 FILE 配置时必须同步收口:
+
+- 数据源和实体列表按当前租户过滤;
+- 公共模板必须显式标记,不能靠 `tenant_id=0` 猜测;
+- FILE 写操作不得依赖匿名列表;
+- 新增写接口必须鉴权;
+- 如果因兼容暂时保留旧匿名 DB/API 只读接口,FILE 必须使用独立的鉴权、租户化列表,禁止跨租户暴露 FILE 配置。
+
+## 8. 前端方案
+
+### 8.1 KPI 目标管理
+
+在 `kpiMaster.vue` 的指标管理 Tab 内增加目标卡片或抽屉:
+
+- 当前目标;
+- 当前工厂;
+- 生效区间;
+- 来源;
+- 历史列表;
+- 新增未来目标;
+- 目标模板下载和导入。
+
+保留现有 Demo 数据导入 Tab,但重命名提示必须持续强调“Demo”。
+
+### 8.2 数据中台文件导入
+
+在现有“数据源管理”页面扩展 `FILE_EXCEL` 类型,并增加文件导入抽屉/Tab:
+
+- 选择文件实体;
+- 下载模板;
+- 上传预检;
+- 查看有效/错误数量;
+- 下载错误报告;
+- 确认导入;
+- 查看批次与血缘;
+- 在允许时撤销。
+
+原则上复用现有菜单:
+
+- 目标管理继续归属 `FUNC-S0-058`;
+- Excel 数据源继续归属数据源管理 `FUNC-S9-012`。
+
+如果实施中新增独立业务叶子菜单,必须按仓库规则登记新的 FUNC 编号;本方案不默认新增菜单。
+
+## 9. 兼容与迁移策略
+
+### 阶段 A:只读接入
+
+- 建表;
+- 上线目标维护和解析服务;
+- 看板仍读日值快照;
+- 不改历史日值。
+
+### 阶段 B:计算写快照
+
+- S1~S4 接入统一目标解析器;
+- S5~S7 补齐目标解析;
+- S9 接入正式目标;
+- 日值记录目标来源。
+
+### 阶段 C:退出旧目标
+
+- 统计旧继承和硬编码兜底命中率;
+- 将有效默认值迁移为显式配置;
+- 命中率归零后删除 `DefaultS*Target` 与历史目标继承。
+
+### 阶段 D:Excel 中台试点
+
+- 选择一个已有 MDP 实体做文件源试点;
+- 验证模板、预检、幂等、Transform 和 KPI;
+- 再按业务域配置扩展,不复制控制器。
+
+## 10. 跨模块影响
+
+### 10.1 影响模块
+
+- S1 产销协同;
+- S2 生产排程;
+- S3 供应协同;
+- S4 采购执行;
+- S5 物料协同;
+- S6 生产执行;
+- S7 成品储运;
+- S9 运营指标;
+- 数据中台;
+- 首页、动态看板、智慧诊断、ChatBI。
+
+### 10.2 影响能力
+
+- KPI 目标来源;
+- KPI 日值写入;
+- 期量差、颜色、严重告警;
+- 无第三方系统时的业务数据入口;
+- MDP 批次、字段映射、血缘和监控;
+- 历史数据解释与审计。
+
+### 10.3 主要风险
+
+1. 目标迁移错误会改变后续 KPI 状态。
+2. Excel 字段映射错误会污染多个消费模块。
+3. S1~S4 退出硬编码过早会造成目标为空。
+4. 全量替换或无作用域删除可能破坏其他租户/工厂数据。
+5. 文件重复导入可能产生重复业务事实。
+6. 实际值按筛选维度计算而目标仍为工厂级时,需在页面说明目标作用域。
+
+## 11. 明确不做
+
+- 不把目标值直接加到 `ado_smart_ops_kpi_master`。
+- 不把目标 Excel 合并到 Demo 导入。
+- 不让业务 Excel 直接写 KPI 实际值或日值。
+- 不把 Excel 二进制存入 KPI 表或业务表。
+- 不默认开放跨租户、跨工厂目标。
+- 不自动把历史日值全部重算成新目标。
+- 不在首期开放无保护的 `FULL_REPLACE`。
+- 不承诺对已经外部回写的业务动作做通用自动回滚。
+- 不借本任务重构与目标、文件入站无关的业务模块。
+
+## 12. 验收标准
+
+1. 用户可以在指标管理页维护当前和未来目标。
+2. 同一目标可通过单项维护或目标 Excel 导入,规则一致。
+3. 目标带租户、工厂和生效期,重叠配置被拒绝。
+4. 日值保存目标快照和来源,历史不随新目标变化。
+5. S1~S7、S9 都能解析正式目标;无目标时明确为空。
+6. Demo 导入不修改正式目标配置。
+7. 数据中台支持 `FILE_EXCEL`,业务 Excel 不绕过 MDP。
+8. 文件导入具备模板、预检、错误报告、幂等、批次和血缘。
+9. 至少一个真实 MDP 实体完成 Excel → 标准层 → KPI 的端到端试点。
+10. DB/API 现有同步链路无回归。
+

BIN
doc/plan/项目验收/01-客户项目交付验收方案_20260807修订.docx


BIN
doc/plan/项目验收/02-客户项目最低验收对照表.xlsx


BIN
doc/plan/项目验收/03-产品全量功能验收对照表.xlsx


+ 70 - 0
doc/plan/项目验收/04-双层验收范围与覆盖规则.md

@@ -0,0 +1,70 @@
+# 04 · 双层验收范围与覆盖规则
+
+## 1. 优先级
+
+1. **L1 客户最低线优先**:任何 L1 失败都阻断客户验收。
+2. **L2 产品全量线必须完成**:不阻断客户付款不等于可以不测。
+3. 同一 FUNC 同时属于 L1/L2 时只执行一套高质量测试证据,结果同时回填两层。
+4. L1 明确排除但 L2 已实现的功能,仅进入产品验收,不写进客户签署表。
+
+---
+
+## 2. 覆盖状态
+
+每条 FUNC/AC 只能使用以下状态:
+
+| 状态 | 含义 |
+|---|---|
+| `DATA-A` | 由 A 套完整 UAT 场景覆盖 |
+| `FLOW-B` | 由 B 套从零操作覆盖 |
+| `DEMO` | 由 Demo 数据与展示剧本覆盖 |
+| `NON-DATA` | 权限、安全、性能、集成、文档等非数据验收 |
+| `MULTI` | 需要两套以上共同覆盖 |
+| `GAP-TC` | 已有 FUNC/AC,缺可执行 TC |
+| `GAP-FUNC` | 验收要求存在,但功能缺失或仅为骨架 |
+| `BLOCKED` | 已有能力但受缺陷/环境阻断 |
+| `PASSED` | 已执行并留证通过 |
+
+不得用“页面能打开”代替业务验收,也不得用 Demo 静态数据替代可操作功能验证。
+
+---
+
+## 3. L1 覆盖规则
+
+- 113 条技术规范验证项逐条登记测试方式。
+- 72 条合同纳入 FUNC/AC 必须全部映射到 A/B/Demo 或 `NON-DATA`。
+- 客户核心场景:订单交付、采购质量、异常闭环、指标分析,必须使用 A 套完整链路验证。
+- 外部系统未提供条件时,可以条件验收,但必须有接口契约、Mock/测试源、日志和待联调清单。
+- 安全项必须使用至少两个租户和两个角色验证,不属于“造数后看页面”。
+
+---
+
+## 4. L2 覆盖规则
+
+- 以产品全量表 167 条 FUNC/AC 为当前基线。
+- 缺 TC 的约 71 条必须补齐 TC 或等价验收步骤后才能宣告全量测试完成。
+- 隐藏路由、详情页、表单页同样需要验收;不能只测侧栏可见页面。
+- 共享页面多入口可以复用证据,但须记录所有 FUNC 映射。
+- 空骨架、假按钮、固定返回、无后端接口按 `GAP-FUNC`,不是“正常空态”。
+
+---
+
+## 5. 数据与非数据分工
+
+| 验收类型 | 主承载 |
+|---|---|
+| 跨模块业务链、状态组合、数量口径 | A 套 |
+| 新租户初始化、主数据维护、流程从零操作 | B 套 |
+| 九宫格、诊断、S8、ChartBI、趋势展示 | Demo 套 |
+| 多租户隔离、角色权限 | 非数据专项 + 三租户 |
+| 性能、并发、可用性、恢复 | 专项测试环境 |
+| ERP/MES/WMS/API 集成 | 契约 + 测试源/真实源联调 |
+| 文档、培训、交付清单 | 资料验收 |
+
+---
+
+## 6. 缺口处理
+
+发现 `GAP-TC`:先补测试设计,不改业务功能。  
+发现 `GAP-FUNC` 或 `BLOCKED`:进入 `UAT测试数据准备/10-阻断问题修复与闭环机制.md`,修复后回到原 FUNC/AC 重测。  
+发现 L1 与 L2 名称或编号不一致:以 L1 合同范围决定客户验收,以 `aidopFuncCodes.ts` 决定产品功能编号,并在矩阵中保留映射说明,不擅自改号。

+ 84 - 0
doc/plan/项目验收/05-UAT数据覆盖矩阵.md

@@ -0,0 +1,84 @@
+# 05 · UAT 数据覆盖矩阵
+
+> **定位**:把 L1/L2 验收要求映射到 A/B/Demo 三套数据和非数据专项  
+> **当前状态**:覆盖规则已定;逐 FUNC 明细由执行任务书读取两份 Excel 后生成,不在本文手工复制 167 行
+
+---
+
+## 1. 模块级分工
+
+| 模块/能力 | A 完整 UAT | B 从零点通 | Demo 展示 | 非数据专项 |
+|---|---:|---:|---:|---:|
+| S0 主数据、BOM、工艺、日历、质量基础 | 抽查与业务引用 | **主覆盖** | 展示查询 | 导入/权限 |
+| S1 订单、评审、设计、交付 | **主覆盖** | 完整操作 | 经营趋势 | 审批权限 |
+| S2 工单、排程、执行进度 | **主覆盖** | 完整操作 | 产能/达成趋势 | 性能/并发 |
+| S3 MRP、齐套、采购需求、供应分析 | **主覆盖** | 完整操作 | 供应风险 | 算法/性能 |
+| S4 交货、发货、采购执行 | **主覆盖** | 完整操作 | 交付排行 | 门户权限 |
+| S5 库存、IQC、收发领退、追溯 | **主覆盖** | 完整操作 | 库存/质量趋势 | MES/WMS 集成 |
+| S6 生产指令、报工、IPQC、设备/OEE | **主覆盖** | 能力允许时操作 | OEE/异常趋势 | MES/设备源 |
+| S7 FQC、成品入库、销售出库、追溯 | **主覆盖** | 完整操作 | 交付/质量 | WMS 集成 |
+| S8 异常监控、提报、通知、闭环 | **主覆盖** | 完整操作 | **重点展示** | 通知通道 |
+| S9 KPI、九宫格、偏差分析 | 事实对账 | 刷新验证 | **重点展示** | KPI 性能 |
+| 智慧诊断/改善 | 异常场景验证 | 操作验证 | **重点展示** | 模型/规则 |
+| ChatBI/ChartBI | 典型问题验证 | 数据源验证 | **重点展示** | 权限/性能 |
+| 集成、安全、性能、文档 | 部分联调 | 新租户验证 | 健康检查 | **主覆盖** |
+
+---
+
+## 2. A 五组与验收能力
+
+| A 组 | 主要覆盖 |
+|---|---|
+| A01 正常闭环 | S0–S7 主链、订单交付、采购质量、库存、KPI 正常态 |
+| A02 部分完成 | 分批交付、部分到货、部分报工、剩余量与在制 |
+| A03 延期缺料 | S3/S4 风险、S2 计划影响、S1 交付预警、S8 供应异常 |
+| A04 质量不合格 | IQC/FQC、退货/返工/复检、库存与供应质量 KPI |
+| A05 异常恢复 | 生产/设备异常、S8 分级处置、诊断与改善后恢复 |
+
+五组用于覆盖业务状态组合;它们不能自动覆盖主数据维护、租户初始化、权限、性能等专项。
+
+---
+
+## 3. 逐 FUNC 矩阵生成要求
+
+执行模型读取:
+
+- `02-客户项目最低验收对照表.xlsx`
+- `03-产品全量功能验收对照表.xlsx`
+- `Web/src/constants/aidopFuncCodes.ts`
+
+输出 `06-逐FUNC-UAT覆盖明细.xlsx`,至少包含:
+
+| 列 | 含义 |
+|---|---|
+| 验收层级 | L1 / L2 / BOTH |
+| 模块 | S0–S9 / ChatBI / 集成等 |
+| FUNC | 系统功能编号 |
+| 功能名称 | 以产品映射为准 |
+| AC | 验收编号 |
+| REQ/TC | 现有追溯 |
+| 覆盖类型 | DATA-A / FLOW-B / DEMO / NON-DATA / MULTI |
+| 场景步骤 | A01…A05、B00…B14、Demo 剧本或专项编号 |
+| 数据对象 | 订单、工单、PO、批次、异常等 |
+| 当前状态 | GAP-TC / GAP-FUNC / BLOCKED / 待执行 / PASSED |
+| 证据路径 | 页面/API/SQL/日志 |
+
+硬校验:
+
+- L1 的 72 条 FUNC/AC 无遗漏。
+- L2 的 167 条 FUNC/AC 无遗漏。
+- 同一 FUNC/AC 只保留一条主记录,多场景用子行或列表表达。
+- 缺 TC 的功能不得默认为通过。
+
+---
+
+## 4. 完成判定
+
+只有当逐 FUNC 明细中:
+
+- L1 全部 `PASSED` 或经双方书面确认的条件验收;
+- L2 已实现功能全部 `PASSED`;
+- `GAP-FUNC`、`BLOCKED` 为 0;
+- `GAP-TC` 为 0;
+
+才可称为“UAT 数据与全量功能验收准备完成”。

+ 63 - 0
doc/plan/项目验收/README.md

@@ -0,0 +1,63 @@
+# 项目验收
+
+> **用途**:Ai-DOP 客户项目验收与产品全量验收的统一入口  
+> **定案日期**:2026-08-15  
+> **L1 基准**:负责人确认以 `项目交付验收方案_修订_20260807.docx` 为准
+
+---
+
+## 1. 两层验收体系
+
+| 层级 | 定位 | 基准文件 | 完成要求 |
+|---|---|---|---|
+| **L1 客户项目最低线** | 合同、技术规范及双方确认范围;决定客户项目能否验收 | [01-客户项目交付验收方案_20260807修订.docx](./01-客户项目交付验收方案_20260807修订.docx)、[02-客户项目最低验收对照表.xlsx](./02-客户项目最低验收对照表.xlsx) | 技术规范登记覆盖 100%;核心场景 100%;P0/P1=0;一般用例通过率 ≥95% |
+| **L2 产品全量线** | Ai-DOP 作为产品已实现的全部功能,包含超出客户合同的扩展能力 | [03-产品全量功能验收对照表.xlsx](./03-产品全量功能验收对照表.xlsx) | 已实现 FUNC 全部登记、全部有 AC、全部有可执行 TC/验收步骤并完成回归 |
+
+L1 是交付最低线,不是产品质量上限。L2 不作为客户付款前置,但不能因“不在合同内”而长期不测试。
+
+---
+
+## 2. 文件说明
+
+| 文件 | 内容 |
+|---|---|
+| `01-客户项目交付验收方案_20260807修订.docx` | 客户 L1 正式口径,原样复制归档,不在仓库内二次猜测范围 |
+| `02-客户项目最低验收对照表.xlsx` | 技术规范验证主表 113 项;合同纳入 FUNC/AC 72 项 |
+| `03-产品全量功能验收对照表.xlsx` | 2026-08-06 系统全量版:S0–S9 共 167 条 FUNC/AC;约 96 条已有 TC,其余待补 |
+| [04-双层验收范围与覆盖规则.md](./04-双层验收范围与覆盖规则.md) | L1/L2 优先级、覆盖与冲突处理 |
+| [05-UAT数据覆盖矩阵.md](./05-UAT数据覆盖矩阵.md) | A/B/Demo 三套数据如何支撑两层验收 |
+
+---
+
+## 3. 8 月 7 日修订稿的已知编辑不一致
+
+以下仅登记,不擅自改写原 Word:
+
+- 已删除源码托管交付项,但交付计划仍残留“源码托管记录 / 系统交付四项”。
+- 总体验收标准仍残留“源代码托管完成交付”。
+- 验收表删除移动应用后,序号从 32 跳到 34。
+- 移动应用在范围表写“待定”,验收表中未列。
+
+执行 L1 时以修订稿的**范围删减意图**为准;正式对外发版前应由负责人确认是否清理上述文字矛盾。本目录不改原件。
+
+---
+
+## 4. 与 UAT 数据准备的关系
+
+[UAT测试数据准备](../UAT测试数据准备/README.md) 负责提供验收所需数据和操作环境:
+
+- `UATTEST_CHL`:A 套,先保证 L1 100%,再覆盖 L2 中需要完整业务数据的功能。
+- `UATTEST_CHLB`:B 套,验证新租户从零点通和所有可维护/可流转功能。
+- `UATDEMO`:Demo 套,覆盖九宫格、智慧诊断、S8、ChartBI 及适合展示的产品扩展能力。
+
+数据无法验证的权限、安全、性能、集成、运维和文档项,必须另设非数据验收任务,不能标为“不适用”后遗漏。
+
+---
+
+## 5. 维护规则
+
+1. L1 Word 与 L1 Excel 只有负责人确认后才能换版。
+2. 新增/调整业务叶子菜单先维护 `aidopFuncCodes.ts` 和 FUNC,再同步 L2 Excel。
+3. 每个 FUNC 应有同号 AC;进入测试前必须有 TC 或等价可执行步骤。
+4. L2 的 FUNC 增减要同步 [05-UAT数据覆盖矩阵.md](./05-UAT数据覆盖矩阵.md)。
+5. 原始 OneDrive 文件发生变化时,不直接覆盖本目录;先比对版本和差异。

+ 2 - 0
doc/项目交付验收方案.md

@@ -1,5 +1,7 @@
 # Ai-DOP 项目交付验收方案
 
+> **现行 L1 客户验收基准已归档到** [`plan/项目验收/01-客户项目交付验收方案_20260807修订.docx`](./plan/项目验收/01-客户项目交付验收方案_20260807修订.docx)。负责人于 2026-08-15 确认以该 2026-08-07 手工修订 Word 为准。本文 V0.6 保留为历史 Markdown 和兼容引用,不再作为范围定案依据。双层验收入口见 [`plan/项目验收/README.md`](./plan/项目验收/README.md)。
+
 | 项目 | 内容 |
 |---|---|
 | 项目名称 | 2026年河南产互联制造业数据智能运营平台研发项目 |

+ 15 - 0
server/Admin.NET.Application/Configuration/App.json

@@ -74,6 +74,21 @@
       "MaxPageSize": 200,
       "LiveTimeoutSeconds": 5,
       "DefaultDomain": "8010"
+    },
+    "S1Rebuild": {
+      "MaxParallelTenants": 2
+    },
+    "MdpRebuild": {
+      "MaxParallelScopes": 1,
+      "Modules": {
+        "S1": { "Enabled": true },
+        "S2": { "Enabled": true },
+        "S3": { "Enabled": true },
+        "S4": { "Enabled": true },
+        "S5": { "Enabled": true },
+        "S6": { "Enabled": true },
+        "S7": { "Enabled": true }
+      }
     }
   },
   // 雪花Id

+ 14 - 27
server/Admin.NET.Core/Update/AutoVersionUpdate.cs

@@ -23,6 +23,7 @@ namespace Admin.NET.Core.Update;
 [SuppressSniffer]
 public static class AutoVersionUpdate
 {
+    private const int MigrationCommandTimeoutSeconds = 600;
     private const string MigrationLogTable = "sys_db_migration_log";
     private const string StatusRunning = "Running";
     private const string StatusSuccess = "Success";
@@ -170,6 +171,8 @@ public static class AutoVersionUpdate
         var startedAt = DateTime.Now;
         var sw = Stopwatch.StartNew();
         UpsertMigrationLog(db, script, databaseName, StatusRunning, 0, 0, VerifyNotConfigured, null, 0);
+        var originalCommandTimeout = db.Ado.CommandTimeOut;
+        db.Ado.CommandTimeOut = Math.Max(originalCommandTimeout, MigrationCommandTimeoutSeconds);
 
         try
         {
@@ -211,6 +214,10 @@ public static class AutoVersionUpdate
             LogHelper.Error($"AutoVersionUpdate 版本 {script.Version} 失败:{message}");
             throw new InvalidOperationException($"AutoVersionUpdate 版本 {script.Version} 执行失败:{message}", ex);
         }
+        finally
+        {
+            db.Ado.CommandTimeOut = originalCommandTimeout;
+        }
     }
 
     private static string RunVerifyScriptIfExists(ISqlSugarClient db, MigrationScript script)
@@ -502,32 +509,9 @@ public static class AutoVersionUpdate
             sql.Contains("DELIMITER", StringComparison.OrdinalIgnoreCase);
 
         public static ScriptExecuteResult Execute(ISqlSugarClient db, string sql)
-        {
-            if (RequiresSameSessionExecution(sql))
-                return ExecuteAsBatch(db, sql);
-
-            return ExecuteSplitWithSharedConnection(db, sql);
-        }
-
-        private static bool RequiresSameSessionExecution(string sql) =>
-            sql.Contains("PREPARE", StringComparison.OrdinalIgnoreCase) ||
-            Regex.IsMatch(sql, @"\bSET\s+@", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant) ||
-            Regex.IsMatch(sql, @"\bSELECT\b.+\bINTO\s+@", RegexOptions.IgnoreCase | RegexOptions.Singleline);
-
-        private static ScriptExecuteResult ExecuteAsBatch(ISqlSugarClient db, string sql)
-        {
-            var wasOpen = EnsureConnectionOpen(db);
-            try
-            {
-                var affected = db.Ado.ExecuteCommand(sql);
-                var statementCount = Math.Max(1, Split(sql).Count);
-                return new ScriptExecuteResult(statementCount, affected);
-            }
-            finally
-            {
-                RestoreConnection(db, wasOpen);
-            }
-        }
+            // SET @var / PREPARE / EXECUTE 依赖同一连接,但不需要作为一个超长批次提交。
+            // 分句执行既保留会话变量,又让超时和失败定位落到单条 DDL。
+            => ExecuteSplitWithSharedConnection(db, sql);
 
         private static ScriptExecuteResult ExecuteSplitWithSharedConnection(ISqlSugarClient db, string sql)
         {
@@ -542,7 +526,10 @@ public static class AutoVersionUpdate
                     var statement = statements[i];
                     try
                     {
-                        affectedRows += db.Ado.ExecuteCommand(statement);
+                        using var command = db.Ado.Connection.CreateCommand();
+                        command.CommandText = statement;
+                        command.CommandTimeout = db.Ado.CommandTimeOut;
+                        affectedRows += Math.Max(0, command.ExecuteNonQuery());
                     }
                     catch (Exception ex)
                     {

+ 99 - 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.340</AssemblyVersion>
-    <FileVersion>1.0.340</FileVersion>
-    <Version>1.0.340</Version>
+    <AssemblyVersion>1.0.356</AssemblyVersion>
+    <FileVersion>1.0.356</FileVersion>
+    <Version>1.0.356</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -343,6 +343,102 @@
     <None Update="UpdateScripts\1.0.340.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.341.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.341.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.342.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.342.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.343.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.343.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.344.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.344.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.345.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.345.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.346.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.346.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.347.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.347.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.348.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.348.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.349.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.349.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.350.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.350.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.351.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.351.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.352.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.352.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.353.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.353.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.354.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.354.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.355.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.355.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.356.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.356.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 20 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.341.sql

@@ -0,0 +1,20 @@
+-- 1.0.341 P-022 WP-2:去掉 IPQC 归口源表 tenant_id 的 DEFAULT 1300000000001(幂等)。
+SET NAMES utf8mb4;
+
+SET @has_def := (
+  SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'qms_gcjyd' AND COLUMN_NAME = 'tenant_id'
+    AND COLUMN_DEFAULT IS NOT NULL);
+SET @sql := IF(@has_def > 0,
+  'ALTER TABLE `qms_gcjyd` ALTER COLUMN `tenant_id` DROP DEFAULT',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @has_def := (
+  SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'qms_gcjydzb' AND COLUMN_NAME = 'tenant_id'
+    AND COLUMN_DEFAULT IS NOT NULL);
+SET @sql := IF(@has_def > 0,
+  'ALTER TABLE `qms_gcjydzb` ALTER COLUMN `tenant_id` DROP DEFAULT',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 6 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.341.verify.sql

@@ -0,0 +1,6 @@
+SELECT CASE WHEN COUNT(*) = 0 THEN 1 ELSE 0 END AS ok
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('qms_gcjyd', 'qms_gcjydzb')
+  AND COLUMN_NAME = 'tenant_id'
+  AND COLUMN_DEFAULT IS NOT NULL;

+ 84 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.342.sql

@@ -0,0 +1,84 @@
+-- 1.0.342 P-022 WP-3:备份并清理派生层 tenant_id IN (0, 1300000000001) 残渣。
+-- 只清 dwd_*/mdp_std_* 指定表;不动业务表;不动 ado_s8_exception tenant_id=1。
+SET NAMES utf8mb4;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_material_readiness_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_material_readiness') > 0,
+  'CREATE TABLE `dwd_material_readiness_bak_20260814` AS SELECT * FROM `dwd_material_readiness` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_material_shortage_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_material_shortage') > 0,
+  'CREATE TABLE `dwd_material_shortage_bak_20260814` AS SELECT * FROM `dwd_material_shortage` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_s4_purchase_execution_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_s4_purchase_execution') > 0,
+  'CREATE TABLE `dwd_s4_purchase_execution_bak_20260814` AS SELECT * FROM `dwd_s4_purchase_execution` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_supplier_risk_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='dwd_supplier_risk') > 0,
+  'CREATE TABLE `dwd_supplier_risk_bak_20260814` AS SELECT * FROM `dwd_supplier_risk` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_purchase_order_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_purchase_order') > 0,
+  'CREATE TABLE `mdp_std_purchase_order_bak_20260814` AS SELECT * FROM `mdp_std_purchase_order` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_purchase_receipt_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_purchase_receipt') > 0,
+  'CREATE TABLE `mdp_std_purchase_receipt_bak_20260814` AS SELECT * FROM `mdp_std_purchase_receipt` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_production_issue_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_production_issue') > 0,
+  'CREATE TABLE `mdp_std_production_issue_bak_20260814` AS SELECT * FROM `mdp_std_production_issue` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_s6_report_bak_20260814') = 0
+  AND (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_s6_report') > 0,
+  'CREATE TABLE `mdp_std_s6_report_bak_20260814` AS SELECT * FROM `mdp_std_s6_report` WHERE tenant_id IN (0, 1300000000001)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+DELETE FROM `dwd_material_readiness` WHERE tenant_id IN (0, 1300000000001);
+DELETE FROM `dwd_material_shortage` WHERE tenant_id IN (0, 1300000000001);
+DELETE FROM `dwd_s4_purchase_execution` WHERE tenant_id IN (0, 1300000000001);
+DELETE FROM `dwd_supplier_risk` WHERE tenant_id IN (0, 1300000000001);
+DELETE FROM `mdp_std_purchase_order` WHERE tenant_id IN (0, 1300000000001);
+DELETE FROM `mdp_std_purchase_receipt` WHERE tenant_id IN (0, 1300000000001);
+DELETE FROM `mdp_std_production_issue` WHERE tenant_id IN (0, 1300000000001);
+DELETE FROM `mdp_std_s6_report` WHERE tenant_id IN (0, 1300000000001);

+ 11 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.342.verify.sql

@@ -0,0 +1,11 @@
+SELECT CASE WHEN COALESCE(SUM(c), 0) = 0 THEN 1 ELSE 0 END AS ok
+FROM (
+  SELECT COUNT(*) c FROM dwd_material_readiness WHERE tenant_id IN (0, 1300000000001)
+  UNION ALL SELECT COUNT(*) FROM dwd_material_shortage WHERE tenant_id IN (0, 1300000000001)
+  UNION ALL SELECT COUNT(*) FROM dwd_s4_purchase_execution WHERE tenant_id IN (0, 1300000000001)
+  UNION ALL SELECT COUNT(*) FROM dwd_supplier_risk WHERE tenant_id IN (0, 1300000000001)
+  UNION ALL SELECT COUNT(*) FROM mdp_std_purchase_order WHERE tenant_id IN (0, 1300000000001)
+  UNION ALL SELECT COUNT(*) FROM mdp_std_purchase_receipt WHERE tenant_id IN (0, 1300000000001)
+  UNION ALL SELECT COUNT(*) FROM mdp_std_production_issue WHERE tenant_id IN (0, 1300000000001)
+  UNION ALL SELECT COUNT(*) FROM mdp_std_s6_report WHERE tenant_id IN (0, 1300000000001)
+) x;

+ 216 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.343.sql

@@ -0,0 +1,216 @@
+-- 1.0.343 运营指标正式目标配置 + 日值目标血缘 + MDP FILE_EXCEL 批次
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `ado_smart_ops_kpi_target_config` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `tenant_id` BIGINT NOT NULL,
+  `factory_id` BIGINT NOT NULL DEFAULT 0,
+  `metric_code` VARCHAR(50) NOT NULL,
+  `target_value` DECIMAL(18,6) NOT NULL,
+  `effective_from` DATE NOT NULL,
+  `effective_to` DATE NULL,
+  `source_type` VARCHAR(30) NOT NULL,
+  `source_batch_id` VARCHAR(100) NULL,
+  `status` TINYINT NOT NULL DEFAULT 1,
+  `remark` VARCHAR(500) NULL,
+  `created_by` BIGINT NULL,
+  `updated_by` BIGINT NULL,
+  `create_time` DATETIME(3) NOT NULL,
+  `update_time` DATETIME(3) NOT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_kpi_target_start` (`tenant_id`, `factory_id`, `metric_code`, `effective_from`),
+  KEY `ix_kpi_target_resolve` (`tenant_id`, `factory_id`, `metric_code`, `status`, `effective_from`, `effective_to`),
+  KEY `ix_kpi_target_batch` (`source_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运营指标正式目标配置';
+
+CREATE TABLE IF NOT EXISTS `ado_smart_ops_kpi_target_import_batch` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `batch_no` VARCHAR(64) NOT NULL,
+  `tenant_id` BIGINT NOT NULL,
+  `factory_id` BIGINT NOT NULL DEFAULT 0,
+  `file_name` VARCHAR(255) NOT NULL,
+  `file_sha256` VARCHAR(64) NOT NULL,
+  `status` VARCHAR(32) NOT NULL,
+  `total_rows` INT NOT NULL DEFAULT 0,
+  `valid_rows` INT NOT NULL DEFAULT 0,
+  `error_rows` INT NOT NULL DEFAULT 0,
+  `payload_json` MEDIUMTEXT NULL,
+  `error_json` MEDIUMTEXT NULL,
+  `created_by` VARCHAR(100) NULL,
+  `create_time` DATETIME(3) NOT NULL,
+  `commit_time` DATETIME(3) NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_kpi_target_batch_no` (`batch_no`),
+  KEY `ix_kpi_target_batch_tenant` (`tenant_id`, `status`),
+  KEY `ix_kpi_target_batch_hash` (`tenant_id`, `file_sha256`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI目标Excel导入批次';
+
+CREATE TABLE IF NOT EXISTS `mdp_file_import_batch` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `batch_no` VARCHAR(64) NOT NULL,
+  `tenant_id` BIGINT NOT NULL,
+  `factory_id` BIGINT NOT NULL DEFAULT 0,
+  `source_id` BIGINT NOT NULL,
+  `entity_id` BIGINT NOT NULL,
+  `entity_code` VARCHAR(100) NOT NULL,
+  `file_name` VARCHAR(255) NOT NULL,
+  `file_sha256` VARCHAR(64) NOT NULL,
+  `file_size` BIGINT NOT NULL DEFAULT 0,
+  `sheet_name` VARCHAR(100) NULL,
+  `import_mode` VARCHAR(40) NOT NULL DEFAULT 'UPSERT',
+  `status` VARCHAR(32) NOT NULL,
+  `total_rows` INT NOT NULL DEFAULT 0,
+  `valid_rows` INT NOT NULL DEFAULT 0,
+  `error_rows` INT NOT NULL DEFAULT 0,
+  `written_rows` INT NOT NULL DEFAULT 0,
+  `file_object_key` VARCHAR(500) NULL,
+  `error_report_key` VARCHAR(500) NULL,
+  `transform_status` VARCHAR(32) NULL,
+  `transform_time` DATETIME(3) NULL,
+  `created_by` VARCHAR(100) NULL,
+  `create_time` DATETIME(3) NOT NULL,
+  `commit_time` DATETIME(3) NULL,
+  `error_message` VARCHAR(1000) NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_mdp_file_batch_no` (`batch_no`),
+  KEY `ix_mdp_file_batch_tenant` (`tenant_id`, `status`),
+  KEY `ix_mdp_file_batch_hash` (`tenant_id`, `file_sha256`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MDP Excel文件导入批次';
+
+CREATE TABLE IF NOT EXISTS `mdp_file_import_row` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `batch_id` BIGINT NOT NULL,
+  `row_no` INT NOT NULL,
+  `business_key` VARCHAR(500) NULL,
+  `row_hash` VARCHAR(64) NULL,
+  `raw_json` MEDIUMTEXT NULL,
+  `mapped_json` MEDIUMTEXT NULL,
+  `validation_status` VARCHAR(32) NOT NULL DEFAULT 'OK',
+  `write_status` VARCHAR(32) NOT NULL DEFAULT 'PENDING',
+  `error_count` INT NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  KEY `ix_mdp_file_row_batch` (`batch_id`, `row_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MDP Excel导入原始行';
+
+CREATE TABLE IF NOT EXISTS `mdp_file_import_error` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `batch_id` BIGINT NOT NULL,
+  `row_no` INT NOT NULL,
+  `field_name` VARCHAR(200) NULL,
+  `error_code` VARCHAR(64) NOT NULL,
+  `error_message` VARCHAR(1000) NOT NULL,
+  `raw_value` VARCHAR(500) NULL,
+  PRIMARY KEY (`id`),
+  KEY `ix_mdp_file_err_batch` (`batch_id`, `row_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MDP Excel导入错误';
+
+-- 四级日值目标血缘列
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_s9_kpi_value_l1_day' AND COLUMN_NAME='target_config_id') = 0,
+  'ALTER TABLE `ado_s9_kpi_value_l1_day`
+     ADD COLUMN `target_config_id` BIGINT NULL,
+     ADD COLUMN `target_source` VARCHAR(30) NULL,
+     ADD COLUMN `target_resolved_at` DATETIME(3) NULL',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_s9_kpi_value_l2_day' AND COLUMN_NAME='target_config_id') = 0,
+  'ALTER TABLE `ado_s9_kpi_value_l2_day`
+     ADD COLUMN `target_config_id` BIGINT NULL,
+     ADD COLUMN `target_source` VARCHAR(30) NULL,
+     ADD COLUMN `target_resolved_at` DATETIME(3) NULL',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_s9_kpi_value_l3_day' AND COLUMN_NAME='target_config_id') = 0,
+  'ALTER TABLE `ado_s9_kpi_value_l3_day`
+     ADD COLUMN `target_config_id` BIGINT NULL,
+     ADD COLUMN `target_source` VARCHAR(30) NULL,
+     ADD COLUMN `target_resolved_at` DATETIME(3) NULL',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_s9_kpi_value_l4_day' AND COLUMN_NAME='target_config_id') = 0,
+  'ALTER TABLE `ado_s9_kpi_value_l4_day`
+     ADD COLUMN `target_config_id` BIGINT NULL,
+     ADD COLUMN `target_source` VARCHAR(30) NULL,
+     ADD COLUMN `target_resolved_at` DATETIME(3) NULL',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- mdp_entity 文件配置
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_entity' AND COLUMN_NAME='file_config_json') = 0,
+  'ALTER TABLE `mdp_entity` ADD COLUMN `file_config_json` TEXT NULL',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_entity' AND COLUMN_NAME='is_public_template') = 0,
+  'ALTER TABLE `mdp_entity` ADD COLUMN `is_public_template` TINYINT NOT NULL DEFAULT 0',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 试点:复制 S1_CUSTOMER_SQLSERVER 为 FILE 公共模板(不写业务目标数据)
+INSERT INTO `mdp_source` (
+  `tenant_id`, `source_code`, `source_name`, `source_type`, `status`,
+  `remark`, `create_time`, `update_time`)
+SELECT 0, 'S1_CUSTOMER_FILE', 'S1客户主数据 Excel 入站', 'FILE_EXCEL', 1,
+       '由 S1_CUSTOMER_SQLSERVER 复制的 FILE 试点源;is_public_template 见实体', NOW(), NOW()
+FROM DUAL
+WHERE EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code`='S1_CUSTOMER_SQLSERVER' AND `status`=1)
+  AND NOT EXISTS (SELECT 1 FROM `mdp_source` WHERE `source_code`='S1_CUSTOMER_FILE');
+
+INSERT INTO `mdp_entity` (
+  `tenant_id`, `source_id`, `entity_code`, `entity_name`, `entity_type`,
+  `target_table_name`, `sync_mode`, `batch_size`, `biz_key_expr`, `status`,
+  `file_config_json`, `is_public_template`, `remark`, `create_time`, `update_time`)
+SELECT
+  0,
+  (SELECT `id` FROM `mdp_source` WHERE `source_code`='S1_CUSTOMER_FILE' LIMIT 1),
+  'S1_CUSTOMER_FILE',
+  'S1客户主数据 Excel 入站',
+  'FILE',
+  src.`target_table_name`,
+  'UPSERT',
+  5000,
+  src.`biz_key_expr`,
+  1,
+  '{"sheetName":"数据","headerRow":1,"maxRows":50000,"allowedExtensions":[".xlsx"],"importMode":"UPSERT","rebuildable":false,"transformTaskCode":"S1_MDP_SYNC_TRANSFORM"}',
+  1,
+  'FILE 试点实体,贴源表/业务键与 S1_CUSTOMER_SQLSERVER 一致',
+  NOW(), NOW()
+FROM `mdp_entity` src
+WHERE src.`entity_code`='S1_CUSTOMER_SQLSERVER' AND src.`status`=1
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code`='S1_CUSTOMER_FILE')
+LIMIT 1;
+
+INSERT INTO `mdp_field_mapping` (
+  `entity_id`, `source_field`, `target_field`, `field_type`, `is_required`, `sort_order`, `create_time`)
+SELECT e.`id`, 'Domain', 'Domain', 'DIRECT', 1, 10, NOW()
+FROM `mdp_entity` e
+WHERE e.`entity_code`='S1_CUSTOMER_FILE'
+  AND NOT EXISTS (SELECT 1 FROM `mdp_field_mapping` m WHERE m.`entity_id`=e.`id` AND m.`source_field`='Domain');
+
+INSERT INTO `mdp_field_mapping` (
+  `entity_id`, `source_field`, `target_field`, `field_type`, `is_required`, `sort_order`, `create_time`)
+SELECT e.`id`, 'Customer', 'Customer', 'DIRECT', 1, 20, NOW()
+FROM `mdp_entity` e
+WHERE e.`entity_code`='S1_CUSTOMER_FILE'
+  AND NOT EXISTS (SELECT 1 FROM `mdp_field_mapping` m WHERE m.`entity_id`=e.`id` AND m.`source_field`='Customer');
+
+INSERT INTO `mdp_field_mapping` (
+  `entity_id`, `source_field`, `target_field`, `field_type`, `is_required`, `sort_order`, `create_time`)
+SELECT e.`id`, 'CustomerName', 'CustomerName', 'DIRECT', 0, 30, NOW()
+FROM `mdp_entity` e
+WHERE e.`entity_code`='S1_CUSTOMER_FILE'
+  AND NOT EXISTS (SELECT 1 FROM `mdp_field_mapping` m WHERE m.`entity_id`=e.`id` AND m.`source_field`='CustomerName');

+ 34 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.343.verify.sql

@@ -0,0 +1,34 @@
+SELECT CASE WHEN COUNT(*) = 1 THEN 1 ELSE 0 END AS ok
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'ado_smart_ops_kpi_target_config';
+
+SELECT CASE WHEN COUNT(*) = 1 THEN 1 ELSE 0 END AS ok
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'ado_smart_ops_kpi_target_config'
+  AND INDEX_NAME = 'uk_kpi_target_start'
+  AND NON_UNIQUE = 0;
+
+SELECT CASE WHEN COUNT(*) >= 1 THEN 1 ELSE 0 END AS ok
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'ado_smart_ops_kpi_target_config'
+  AND INDEX_NAME = 'ix_kpi_target_resolve';
+
+SELECT CASE WHEN COUNT(*) = 4 THEN 1 ELSE 0 END AS ok
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('ado_s9_kpi_value_l1_day','ado_s9_kpi_value_l2_day','ado_s9_kpi_value_l3_day','ado_s9_kpi_value_l4_day')
+  AND COLUMN_NAME = 'target_config_id';
+
+SELECT CASE WHEN COUNT(*) = 3 THEN 1 ELSE 0 END AS ok
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('mdp_file_import_batch','mdp_file_import_row','mdp_file_import_error');
+
+SELECT CASE WHEN COUNT(*) = 2 THEN 1 ELSE 0 END AS ok
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'mdp_entity'
+  AND COLUMN_NAME IN ('file_config_json','is_public_template');

+ 10 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.344.sql

@@ -0,0 +1,10 @@
+-- 1.0.344 补齐 mdp_entity.is_public_template 默认值。
+-- 1.0.343 的 ADD COLUMN 在 CodeFirst 已建列(无 DEFAULT)时被跳过,导致旧 INSERT 失败。
+SET NAMES utf8mb4;
+
+SET @sql := IF(
+  (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_entity' AND COLUMN_NAME='is_public_template') = 0,
+  'ALTER TABLE `mdp_entity` ADD COLUMN `is_public_template` TINYINT NOT NULL DEFAULT 0',
+  'ALTER TABLE `mdp_entity` MODIFY COLUMN `is_public_template` TINYINT NOT NULL DEFAULT 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 7 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.344.verify.sql

@@ -0,0 +1,7 @@
+SELECT CASE WHEN COUNT(*) = 1 THEN 1 ELSE 0 END AS ok
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'mdp_entity'
+  AND COLUMN_NAME = 'is_public_template'
+  AND IS_NULLABLE = 'NO'
+  AND COLUMN_DEFAULT = '0';

+ 54 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.345.sql

@@ -0,0 +1,54 @@
+-- 1.0.345 S1 看板异步重算任务表、全局锁,以及陈旧 S1 RUNNING 日志收口。
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `ado_s1_dashboard_rebuild_job` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `tenant_id` BIGINT NOT NULL,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `status` VARCHAR(20) NOT NULL,
+  `trigger_type` VARCHAR(20) NOT NULL,
+  `requested_by` BIGINT NULL,
+  `batch_id` VARCHAR(100) NULL,
+  `transform_run_log_id` BIGINT NULL,
+  `stage_rows` INT NOT NULL DEFAULT 0,
+  `standard_rows` INT NOT NULL DEFAULT 0,
+  `dwd_rows` INT NOT NULL DEFAULT 0,
+  `kpi_rows` INT NOT NULL DEFAULT 0,
+  `atomic_rows` INT NOT NULL DEFAULT 0,
+  `error_message` VARCHAR(2000) NULL,
+  `submitted_at` DATETIME(3) NOT NULL,
+  `started_at` DATETIME(3) NULL,
+  `finished_at` DATETIME(3) NULL,
+  `heartbeat_at` DATETIME(3) NULL,
+  `duration_ms` INT NULL,
+  `create_time` DATETIME(3) NOT NULL,
+  `update_time` DATETIME(3) NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ix_s1_rebuild_status` (`status`, `submitted_at`),
+  KEY `ix_s1_rebuild_scope` (`tenant_id`, `factory_id`, `status`, `submitted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `ado_s1_mdp_run_lock` (
+  `lock_name` VARCHAR(64) NOT NULL,
+  `holder_id` VARCHAR(100) NULL,
+  `job_id` BIGINT NULL,
+  `status` VARCHAR(20) NOT NULL DEFAULT 'FREE',
+  `acquired_at` DATETIME(3) NULL,
+  `heartbeat_at` DATETIME(3) NULL,
+  PRIMARY KEY (`lock_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO `ado_s1_mdp_run_lock` (`lock_name`, `status`)
+SELECT 'S1_MDP_FULL', 'FREE'
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `ado_s1_mdp_run_lock` WHERE `lock_name`='S1_MDP_FULL');
+
+UPDATE `mdp_transform_run_log`
+SET `status`='FAILED',
+    `end_time`=NOW(3),
+    `error_message`='HTTP 请求已取消/服务中断后的存量收口',
+    `update_time`=NOW(3),
+    `duration_ms`=LEAST(2147483647, GREATEST(0, TIMESTAMPDIFF(SECOND, `start_time`, NOW())) * 1000)
+WHERE `job_code`='S1_MDP_SYNC_TRANSFORM'
+  AND `status`='RUNNING'
+  AND `start_time` < DATE_SUB(NOW(), INTERVAL 30 MINUTE);

+ 15 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.345.verify.sql

@@ -0,0 +1,15 @@
+SELECT CASE WHEN COUNT(*) = 1 THEN 1 ELSE 0 END AS ok
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'ado_s1_dashboard_rebuild_job';
+
+SELECT CASE WHEN COUNT(*) = 1 THEN 1 ELSE 0 END AS ok
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'ado_s1_mdp_run_lock';
+
+SELECT CASE WHEN COUNT(*) = 0 THEN 1 ELSE 0 END AS ok
+FROM mdp_transform_run_log
+WHERE job_code = 'S1_MDP_SYNC_TRANSFORM'
+  AND status = 'RUNNING'
+  AND start_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE);

+ 52 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.346.sql

@@ -0,0 +1,52 @@
+-- 1.0.346 S1 看板重算进度字段与租户级锁。
+SET NAMES utf8mb4;
+
+SET @ddl := (
+  SELECT IF(
+    EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_s1_dashboard_rebuild_job' AND COLUMN_NAME='current_stage'),
+    'SELECT 1',
+    'ALTER TABLE `ado_s1_dashboard_rebuild_job`
+      ADD COLUMN `current_stage` VARCHAR(32) NOT NULL DEFAULT ''QUEUED'' AFTER `atomic_rows`,
+      ADD COLUMN `stage_index` TINYINT NOT NULL DEFAULT 0 AFTER `current_stage`,
+      ADD COLUMN `stage_total` TINYINT NOT NULL DEFAULT 5 AFTER `stage_index`,
+      ADD COLUMN `progress_percent` TINYINT NOT NULL DEFAULT 0 AFTER `stage_total`,
+      ADD COLUMN `progress_message` VARCHAR(255) NULL AFTER `progress_percent`,
+      ADD COLUMN `last_progress_at` DATETIME(3) NULL AFTER `progress_message`,
+      ADD COLUMN `failed_stage` VARCHAR(32) NULL AFTER `last_progress_at`'
+  )
+);
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+UPDATE `ado_s1_dashboard_rebuild_job`
+SET `current_stage`='QUEUED', `progress_percent`=0, `stage_index`=0, `stage_total`=5
+WHERE `status`='QUEUED' AND (`current_stage` IS NULL OR `current_stage`='QUEUED') AND `progress_percent`=0;
+
+UPDATE `ado_s1_dashboard_rebuild_job`
+SET `current_stage`='PREPARING', `progress_percent`=5, `stage_total`=5
+WHERE `status`='RUNNING' AND (`current_stage` IS NULL OR `current_stage` IN ('QUEUED',''));
+
+UPDATE `ado_s1_dashboard_rebuild_job`
+SET `current_stage`='SUCCESS', `progress_percent`=100, `stage_index`=5, `stage_total`=5
+WHERE `status`='SUCCESS';
+
+UPDATE `ado_s1_dashboard_rebuild_job`
+SET `current_stage`='FAILED', `stage_total`=5
+WHERE `status`='FAILED' AND (`current_stage` IS NULL OR `current_stage`='');
+
+UPDATE `ado_s1_dashboard_rebuild_job`
+SET `current_stage`='CANCELLED', `stage_total`=5
+WHERE `status`='CANCELLED' AND (`current_stage` IS NULL OR `current_stage`='');
+
+SET @ddl := (
+  SELECT IF(
+    EXISTS(SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_s1_dashboard_rebuild_job' AND INDEX_NAME='ix_s1_rebuild_scope'),
+    'SELECT 1',
+    'ALTER TABLE `ado_s1_dashboard_rebuild_job` ADD KEY `ix_s1_rebuild_scope` (`tenant_id`, `factory_id`, `status`, `submitted_at`)'
+  )
+);
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+UPDATE `ado_s1_mdp_run_lock`
+SET `status`='FREE', `holder_id`=NULL, `job_id`=NULL
+WHERE `lock_name`='S1_MDP_FULL'
+  AND (`status`='FREE' OR `heartbeat_at` IS NULL OR `heartbeat_at` < DATE_SUB(NOW(), INTERVAL 30 MINUTE));

+ 18 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.346.verify.sql

@@ -0,0 +1,18 @@
+SELECT CASE WHEN COUNT(*) = 7 THEN 1 ELSE 0 END AS ok
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'ado_s1_dashboard_rebuild_job'
+  AND COLUMN_NAME IN ('current_stage','stage_index','stage_total','progress_percent','progress_message','last_progress_at','failed_stage');
+
+SELECT CASE WHEN COUNT(*) = 0 THEN 1 ELSE 0 END AS ok
+FROM ado_s1_dashboard_rebuild_job
+WHERE progress_percent < 0 OR progress_percent > 100
+   OR stage_index < 0 OR stage_index > stage_total
+   OR stage_total <> 5
+   OR (status = 'SUCCESS' AND progress_percent <> 100);
+
+SELECT CASE WHEN COUNT(*) >= 1 THEN 1 ELSE 0 END AS ok
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'ado_s1_dashboard_rebuild_job'
+  AND INDEX_NAME = 'ix_s1_rebuild_scope';

+ 56 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.347.sql

@@ -0,0 +1,56 @@
+-- 1.0.347 模块看板统一异步重算任务表与作用域锁。
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `ado_module_dashboard_rebuild_job` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `module_code` VARCHAR(8) NOT NULL,
+  `tenant_id` BIGINT NOT NULL,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `status` VARCHAR(20) NOT NULL,
+  `trigger_type` VARCHAR(20) NOT NULL,
+  `requested_by` BIGINT NULL,
+  `batch_id` VARCHAR(100) NULL,
+  `transform_run_log_id` BIGINT NULL,
+  `current_stage` VARCHAR(32) NOT NULL DEFAULT 'QUEUED',
+  `stage_index` TINYINT NOT NULL DEFAULT 0,
+  `stage_total` TINYINT NOT NULL DEFAULT 0,
+  `progress_percent` TINYINT NOT NULL DEFAULT 0,
+  `progress_message` VARCHAR(255) NULL,
+  `failed_stage` VARCHAR(32) NULL,
+  `stage_rows` INT NOT NULL DEFAULT 0,
+  `standard_rows` INT NOT NULL DEFAULT 0,
+  `dwd_rows` INT NOT NULL DEFAULT 0,
+  `kpi_rows` INT NOT NULL DEFAULT 0,
+  `atomic_rows` INT NOT NULL DEFAULT 0,
+  `detail_json` TEXT NULL,
+  `error_message` VARCHAR(2000) NULL,
+  `submitted_at` DATETIME(3) NOT NULL,
+  `started_at` DATETIME(3) NULL,
+  `finished_at` DATETIME(3) NULL,
+  `heartbeat_at` DATETIME(3) NULL,
+  `last_progress_at` DATETIME(3) NULL,
+  `duration_ms` BIGINT NULL,
+  `create_time` DATETIME(3) NOT NULL,
+  `update_time` DATETIME(3) NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ix_mdp_rebuild_scope` (`module_code`, `tenant_id`, `factory_id`, `status`, `submitted_at`),
+  KEY `ix_mdp_rebuild_status` (`status`, `submitted_at`),
+  KEY `ix_mdp_rebuild_heartbeat` (`heartbeat_at`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `ado_mdp_rebuild_lock` (
+  `lock_name` VARCHAR(160) NOT NULL,
+  `module_code` VARCHAR(8) NOT NULL,
+  `tenant_id` BIGINT NOT NULL,
+  `factory_id` BIGINT NOT NULL,
+  `holder_id` VARCHAR(100) NULL,
+  `job_id` BIGINT NULL,
+  `status` VARCHAR(20) NOT NULL DEFAULT 'FREE',
+  `acquired_at` DATETIME(3) NULL,
+  `heartbeat_at` DATETIME(3) NULL,
+  `create_time` DATETIME(3) NOT NULL,
+  `update_time` DATETIME(3) NOT NULL,
+  PRIMARY KEY (`lock_name`),
+  KEY `ix_mdp_rebuild_lock_scope` (`module_code`, `tenant_id`, `factory_id`),
+  KEY `ix_mdp_rebuild_lock_heartbeat` (`status`, `heartbeat_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Некоторые файлы не были показаны из-за большого количества измененных файлов