Explorar el Código

Merge branch 'master' of http://123.60.180.165:4647/ZZYDOP/AiDOPWarehouse

Pengxy hace 2 semanas
padre
commit
5e84f73024
Se han modificado 34 ficheros con 3840 adiciones y 642 borrados
  1. 1 1
      Web/package.json
  2. 2 1
      Web/src/views/aidop/api/kanbanData.ts
  3. 53 0
      Web/src/views/aidop/api/kpiSqlConfigApi.ts
  4. 2 1
      Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
  5. 71 0
      Web/src/views/aidop/kanban/components/SqlEditor.vue
  6. 3 3
      Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
  7. 223 1
      Web/src/views/aidop/kanban/kpiMaster.vue
  8. 21 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  9. 52 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.271.sql
  10. 53 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.272.sql
  11. 35 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.273.sql
  12. 41 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.276.sql
  13. 23 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.277.sql
  14. 71 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.278.sql
  15. 115 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/KpiSqlSecurityValidatorTests.cs
  16. 105 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiCalcConfigController.cs
  17. 13 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
  18. 274 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/AidopT8KpiManualRefreshService.cs
  19. 60 6
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/T8BaseInboundMdpSyncService.cs
  20. 84 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/SmartOps/AdoSmartOpsKpiCalcConfigDtos.cs
  21. 77 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiCalcConfig.cs
  22. 87 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiCalcRunLog.cs
  23. 143 281
      server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspBillService.cs
  24. 615 0
      server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcMdpSyncService.cs
  25. 44 42
      server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/ProductionInboundService.cs
  26. 44 21
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/S5MdpRefreshJob.cs
  27. 127 49
      server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionMdpSyncService.cs
  28. 256 143
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/OutsourceIssueMdpSyncService.cs
  29. 286 61
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionReturnMdpSyncService.cs
  30. 48 29
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs
  31. 287 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiCalcConfigService.cs
  32. 144 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiCalcDispatcher.cs
  33. 241 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiSqlReadOnlyExecutor.cs
  34. 139 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiSqlSecurityValidator.cs

+ 1 - 1
Web/package.json

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

+ 2 - 1
Web/src/views/aidop/api/kanbanData.ts

@@ -836,7 +836,8 @@ export async function refreshModuleMdp(moduleCode: string): Promise<{
 }> {
 	const mc = String(moduleCode || '').toUpperCase();
 	try {
-		const res = await service.post(`/api/AidopKanban/mdp/refresh/${encodeURIComponent(mc)}`);
+		// 真实跑批(T8 入站 + KPI 转换,含 S5 legacy TVF)可能数十秒,覆盖 axios 默认 50s 超时避免被误判失败。
+		const res = await service.post(`/api/AidopKanban/mdp/refresh/${encodeURIComponent(mc)}`, undefined, { timeout: 180000 });
 		const d = res.data ?? {};
 		return {
 			ok: Boolean(d.ok),

+ 53 - 0
Web/src/views/aidop/api/kpiSqlConfigApi.ts

@@ -0,0 +1,53 @@
+import service from '/@/utils/request';
+
+const BASE = '/api/AdoSmartOpsKpiCalcConfig';
+
+/** 某 KPI 的全部配置版本 */
+export function getByMetric(metricCode: string, moduleCode: string) {
+	return service({ url: `${BASE}/by-metric/${encodeURIComponent(metricCode)}`, method: 'get', params: { moduleCode } });
+}
+
+/** 已登记数据源 */
+export function getDataSources() {
+	return service({ url: `${BASE}/data-sources`, method: 'get' });
+}
+
+/** 新增草稿 */
+export function createDraft(data: any) {
+	return service({ url: BASE, method: 'post', data });
+}
+
+/** 编辑草稿 */
+export function updateDraft(id: number, data: any) {
+	return service({ url: `${BASE}/${id}`, method: 'put', data });
+}
+
+/** 删除草稿 */
+export function deleteConfig(id: number) {
+	return service({ url: `${BASE}/${id}`, method: 'delete' });
+}
+
+/** SQL 安全校验 */
+export function validateSql(sqlScript: string) {
+	return service({ url: `${BASE}/validate`, method: 'post', data: { sqlScript } });
+}
+
+/** 试算(不写正式值);真实执行可能数十秒,放大超时 */
+export function previewSql(data: any) {
+	return service({ url: `${BASE}/preview`, method: 'post', data, timeout: 90000 });
+}
+
+/** 发布 */
+export function publishConfig(id: number) {
+	return service({ url: `${BASE}/${id}/publish`, method: 'post' });
+}
+
+/** 激活/回滚(激活某已发布历史版本) */
+export function activateConfig(id: number) {
+	return service({ url: `${BASE}/${id}/activate`, method: 'post' });
+}
+
+/** 停用 */
+export function retireConfig(id: number) {
+	return service({ url: `${BASE}/${id}/retire`, method: 'post' });
+}

+ 2 - 1
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -429,7 +429,8 @@ async function refreshDashboard() {
 			ElMessage.error(result.message || `${moduleCode.value} MDP 刷新失败`);
 			return;
 		}
-		ElMessage.success(`${moduleCode.value} MDP 刷新完成${result.batchId ? `,批次 ${result.batchId}` : ''}`);
+		// 后端返回聚合文案(成功N/无数据N/失败N/耗时)时优先展示;否则回退默认文案(兼容 S1-S4)。
+		ElMessage.success(result.message || `${moduleCode.value} MDP 刷新完成${result.batchId ? `,批次 ${result.batchId}` : ''}`);
 	} finally {
 		loading.value = false;
 	}

+ 71 - 0
Web/src/views/aidop/kanban/components/SqlEditor.vue

@@ -0,0 +1,71 @@
+<template>
+	<div ref="editorRef" class="sql-editor" :style="{ height: height }"></div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
+import * as monaco from 'monaco-editor';
+
+const props = withDefaults(
+	defineProps<{ modelValue: string; height?: string; readonly?: boolean }>(),
+	{ height: '320px', readonly: false }
+);
+const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>();
+
+const editorRef = ref<HTMLElement | null>(null);
+let editor: monaco.editor.IStandaloneCodeEditor | null = null;
+
+function init() {
+	if (editor || !editorRef.value) return;
+	editor = monaco.editor.create(editorRef.value, {
+		value: props.modelValue || '',
+		language: 'sql',
+		theme: 'vs-dark',
+		automaticLayout: true,
+		wordWrap: 'on',
+		minimap: { enabled: false },
+		readOnly: !!props.readonly,
+		scrollBeyondLastLine: false,
+		fontSize: 13,
+		tabSize: 2,
+	});
+	editor.onDidChangeModelContent(() => {
+		const v = editor!.getValue();
+		if (v !== props.modelValue) emit('update:modelValue', v);
+	});
+}
+
+onMounted(() => nextTick(() => setTimeout(init, 1)));
+onBeforeUnmount(() => {
+	editor?.dispose();
+	editor = null;
+});
+
+watch(
+	() => props.modelValue,
+	(v) => {
+		if (editor && editor.getValue() !== (v || '')) editor.setValue(v || '');
+	}
+);
+watch(
+	() => props.readonly,
+	(ro) => editor?.updateOptions({ readOnly: !!ro })
+);
+
+// tab 激活时(DOM 从隐藏变可见)父组件调用,确保初始化并重新布局
+defineExpose({
+	refresh: () => {
+		init();
+		nextTick(() => editor?.layout());
+	},
+});
+</script>
+
+<style scoped>
+.sql-editor {
+	width: 100%;
+	border: 1px solid #dcdfe6;
+	border-radius: 4px;
+	overflow: hidden;
+}
+</style>

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

@@ -63,9 +63,9 @@ export const moduleDashboardRegistry: Record<string, ModuleDashboardConfig> = {
 		chatSuggestions: commonSuggestions('S4 采购执行'),
 		refreshMdp: () => refreshModuleMdp('S4'),
 	},
-	S5: { moduleCode: 'S5', title: 'S5 物料仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S5 物料仓储') },
-	S6: { moduleCode: 'S6', title: 'S6 生产执行看板', enableChatBI: true, chatSuggestions: commonSuggestions('S6 生产执行') },
-	S7: { moduleCode: 'S7', title: 'S7 成品仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S7 成品仓储') },
+	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 运营指标') },
 };
 

+ 223 - 1
Web/src/views/aidop/kanban/kpiMaster.vue

@@ -277,12 +277,86 @@
 					</div>
 				</div>
 			</el-tab-pane>
+
+			<!-- ═══ Tab 3: 计算配置(SQL 配置化最小闭环) ═══ -->
+			<el-tab-pane label="计算配置" name="calc">
+				<div v-if="!currentDetail" class="calc-empty">请先在「指标管理」选择一个 KPI</div>
+				<div v-else class="calc-tab">
+					<div class="calc-tab__head">
+						<h3>{{ currentDetail.metricCode }} · {{ currentDetail.metricName }}</h3>
+						<span class="calc-engine-tag">当前生效引擎:{{ currentEngine }}</span>
+					</div>
+
+					<el-table :data="calcVersions" size="small" border style="margin-bottom: 12px">
+						<el-table-column prop="versionNo" label="版本" width="64" />
+						<el-table-column prop="calcEngineType" label="引擎" width="120" />
+						<el-table-column prop="publishStatus" label="状态" width="96" />
+						<el-table-column label="生效" width="64">
+							<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success" size="small">生效</el-tag></template>
+						</el-table-column>
+						<el-table-column prop="dataSourceCode" label="数据源" width="120" />
+						<el-table-column prop="publishedBy" label="发布人" width="110" />
+						<el-table-column label="操作" min-width="240">
+							<template #default="{ row }">
+								<el-button link size="small" @click="editDraft(row)">载入</el-button>
+								<el-button v-if="row.publishStatus === 'DRAFT'" link type="primary" size="small" @click="doPublish(row.id)">发布</el-button>
+								<el-button v-if="row.publishStatus === 'PUBLISHED' && !row.isCurrent" link type="warning" size="small" @click="doActivate(row.id)">激活</el-button>
+								<el-button v-if="row.publishStatus !== 'RETIRED'" link type="danger" size="small" @click="doRetire(row.id)">停用</el-button>
+							</template>
+						</el-table-column>
+					</el-table>
+
+					<el-form :inline="true" size="small" class="calc-form">
+						<el-form-item label="计算引擎">
+							<el-select v-model="calcDraft.calcEngineType" style="width: 150px">
+								<el-option label="CONFIG_SQL" value="CONFIG_SQL" />
+								<el-option label="LEGACY_CODE" value="LEGACY_CODE" />
+							</el-select>
+						</el-form-item>
+						<el-form-item label="数据源">
+							<el-select v-model="calcDraft.dataSourceCode" style="width: 220px">
+								<el-option v-for="ds in calcDataSources" :key="ds.code" :label="ds.name" :value="ds.code" />
+							</el-select>
+						</el-form-item>
+						<el-form-item label="超时(秒)">
+							<el-input-number v-model="calcDraft.timeoutSeconds" :min="1" :max="60" size="small" />
+						</el-form-item>
+						<el-button size="small" @click="newDraft">新建版本</el-button>
+					</el-form>
+
+					<div v-if="calcDraft.calcEngineType === 'CONFIG_SQL'">
+						<div class="calc-params">
+							可用参数:@tenant_id @factory_id @module_code @metric_code @biz_date @period_start @period_end @ztid
+							| 结果契约:metric_value(必需) numerator_value denominator_value result_status result_message | 只允许 SELECT/WITH,返回 0/1 行
+						</div>
+						<SqlEditor ref="sqlEditorRef" v-model="calcDraft.sqlScript" height="240px" />
+						<div class="calc-actions">
+							<el-button size="small" @click="doValidate">校验</el-button>
+							<el-button size="small" :loading="calcBusy" @click="doPreview">试算</el-button>
+							<el-button type="primary" size="small" :loading="calcBusy" @click="saveDraft">保存草稿</el-button>
+						</div>
+						<div v-if="calcValidation" class="calc-line" :class="calcValidation.ok ? 'ok' : 'err'">
+							校验:{{ calcValidation.validationStatus }} {{ calcValidation.errorMessage || '' }}
+							<span v-if="calcValidation.referencedTables && calcValidation.referencedTables.length">|表:{{ calcValidation.referencedTables.join(', ') }}</span>
+						</div>
+						<div v-if="calcPreview" class="calc-line" :class="calcPreview.ok ? 'ok' : 'err'">
+							试算:{{ calcPreview.resultStatus }} | 值={{ calcPreview.metricValue }} | 分子={{ calcPreview.numeratorValue }} | 行数={{ calcPreview.rowCount }} | 耗时={{ calcPreview.durationMs }}ms
+							<span v-if="calcPreview.errorMessage">|{{ calcPreview.errorCode }}: {{ calcPreview.errorMessage }}</span>
+							<div class="calc-preview-params">参数:{{ calcPreview.executedParameters }}</div>
+						</div>
+					</div>
+					<div v-else class="calc-legacy-note">
+						LEGACY_CODE:使用内置代码路径(BuildS5L1001)计算。保存草稿并发布/激活后,看板刷新走 legacy。
+						<div class="calc-actions"><el-button type="primary" size="small" @click="saveDraft">保存草稿</el-button></div>
+					</div>
+				</div>
+			</el-tab-pane>
 		</el-tabs>
 	</div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
+import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
 import { useRouter } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import type { NodeDropType } from 'element-plus/es/components/tree/src/tree.type';
@@ -290,6 +364,8 @@ import type { UploadFile, UploadInstance } from 'element-plus';
 import * as api from '/@/views/aidop/api/kpiMasterApi';
 import type { KpiTreeNode, KpiDetail } from '/@/views/aidop/api/kpiMasterApi';
 import FormulaEditor from './components/FormulaEditor.vue';
+import SqlEditor from './components/SqlEditor.vue';
+import * as cfgApi from '/@/views/aidop/api/kpiSqlConfigApi';
 
 const router = useRouter();
 
@@ -437,6 +513,152 @@ async function onNodeClick(data: KpiTreeNode) {
 	}
 }
 
+// ═══ 计算配置(SQL 配置化,最小闭环) ═══
+const sqlEditorRef = ref<any>(null);
+const calcVersions = ref<any[]>([]);
+const calcDataSources = ref<any[]>([]);
+const calcBusy = ref(false);
+const calcValidation = ref<any>(null);
+const calcPreview = ref<any>(null);
+const calcDraft = reactive({
+	id: 0,
+	calcEngineType: 'CONFIG_SQL',
+	dataSourceCode: 'MDP_LOCAL',
+	sqlScript: '',
+	timeoutSeconds: 30,
+	remark: '',
+});
+const currentEngine = computed(() => calcVersions.value.find((v) => v.isCurrent)?.calcEngineType ?? 'LEGACY_CODE(无配置,默认)');
+const PILOT_SQL_TEMPLATE = `SELECT AVG(cycle_days) AS metric_value,
+       COUNT(*) AS numerator_value
+FROM dwd_t8_material_online_cycle
+WHERE tenant_id = @tenant_id AND factory_id = @factory_id
+  AND biz_date = @biz_date AND source_ztid = @ztid
+  AND cycle_days IS NOT NULL`;
+
+function unwrapRes(res: any) {
+	return (res as any)?.data ?? res;
+}
+
+function newDraft() {
+	calcDraft.id = 0;
+	calcDraft.calcEngineType = 'CONFIG_SQL';
+	calcDraft.dataSourceCode = 'MDP_LOCAL';
+	calcDraft.sqlScript = PILOT_SQL_TEMPLATE;
+	calcDraft.timeoutSeconds = 30;
+	calcDraft.remark = '';
+	calcValidation.value = null;
+	calcPreview.value = null;
+}
+
+function editDraft(row: any) {
+	calcDraft.id = row.publishStatus === 'DRAFT' ? row.id : 0; // 非草稿载入为新草稿
+	calcDraft.calcEngineType = row.calcEngineType;
+	calcDraft.dataSourceCode = row.dataSourceCode || 'MDP_LOCAL';
+	calcDraft.sqlScript = row.sqlScript || '';
+	calcDraft.timeoutSeconds = row.timeoutSeconds || 30;
+	calcDraft.remark = row.remark || '';
+	calcValidation.value = null;
+	calcPreview.value = null;
+}
+
+async function loadCalc() {
+	if (!currentDetail.value) return;
+	try {
+		calcDataSources.value = unwrapRes(await cfgApi.getDataSources()) || [];
+		calcVersions.value = unwrapRes(await cfgApi.getByMetric(currentDetail.value.metricCode, currentDetail.value.moduleCode)) || [];
+		if (!calcDraft.sqlScript && calcDraft.id === 0) newDraft();
+	} catch (e: any) {
+		ElMessage.error('加载计算配置失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+
+async function doValidate() {
+	calcValidation.value = unwrapRes(await cfgApi.validateSql(calcDraft.sqlScript));
+}
+
+async function doPreview() {
+	if (!currentDetail.value) return;
+	calcBusy.value = true;
+	try {
+		calcPreview.value = unwrapRes(
+			await cfgApi.previewSql({
+				metricCode: currentDetail.value.metricCode,
+				moduleCode: currentDetail.value.moduleCode,
+				dataSourceCode: calcDraft.dataSourceCode,
+				sqlScript: calcDraft.sqlScript,
+				timeoutSeconds: calcDraft.timeoutSeconds,
+			})
+		);
+	} catch (e: any) {
+		ElMessage.error('试算失败: ' + (e.response?.data?.message || e.message || e));
+	} finally {
+		calcBusy.value = false;
+	}
+}
+
+async function saveDraft() {
+	if (!currentDetail.value) return;
+	calcBusy.value = true;
+	try {
+		const payload = {
+			metricCode: currentDetail.value.metricCode,
+			moduleCode: currentDetail.value.moduleCode,
+			calcEngineType: calcDraft.calcEngineType,
+			dataSourceCode: calcDraft.dataSourceCode,
+			sqlScript: calcDraft.sqlScript,
+			timeoutSeconds: calcDraft.timeoutSeconds,
+			remark: calcDraft.remark,
+		};
+		if (calcDraft.id > 0) await cfgApi.updateDraft(calcDraft.id, payload);
+		else {
+			const created = unwrapRes(await cfgApi.createDraft(payload));
+			calcDraft.id = created?.id || 0;
+		}
+		ElMessage.success('草稿已保存');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('保存失败: ' + (e.response?.data?.message || e.message || e));
+	} finally {
+		calcBusy.value = false;
+	}
+}
+
+async function doPublish(id: number) {
+	try {
+		await cfgApi.publishConfig(id);
+		ElMessage.success('已发布');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('发布失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+async function doActivate(id: number) {
+	try {
+		await cfgApi.activateConfig(id);
+		ElMessage.success('已激活该版本(下次刷新生效)');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('激活失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+async function doRetire(id: number) {
+	try {
+		await cfgApi.retireConfig(id);
+		ElMessage.success('已停用');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('停用失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+
+watch([activeTab, () => currentDetail.value?.id], ([t]) => {
+	if (t === 'calc') {
+		loadCalc();
+		nextTick(() => sqlEditorRef.value?.refresh());
+	}
+});
+
 async function onSave() {
 	if (!currentDetail.value) return;
 	saving.value = true;

+ 21 - 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.270</AssemblyVersion>
-    <FileVersion>1.0.270</FileVersion>
-    <Version>1.0.270</Version>
+    <AssemblyVersion>1.0.278</AssemblyVersion>
+    <FileVersion>1.0.278</FileVersion>
+    <Version>1.0.278</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -232,6 +232,24 @@
     <None Update="UpdateScripts\1.0.270.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.271.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.272.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.273.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.276.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.277.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.278.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

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

@@ -0,0 +1,52 @@
+-- 1.0.271:S5 生产退料 B-① 迁通用管线 + dopdemorq 双源入站实体登记(本库 status=1 / SQLSERVER status=0)
+--
+-- 背景:ProductionReturn 由 bespoke 直读 NbrMaster/NbrDetail 迁入通用管线
+--       (源→mdp_stg_production_return[头/明细共表,source_table 区分]→transform→mdp_std_production_return(_detail))。
+--       登记 4 个入站实体(本库 master+detail、dopdemorq SQL Server master+detail),均落 mdp_stg_production_return、FULL。
+-- 状态:正式脚本(1.0.271,已注册 csproj Copy,随 AutoVersionUpdate 执行)。
+--
+-- 硬约束:
+--   1) 本库实体 S5_PRODUCTION_RETURN_MASTER/_DETAIL status=1:RunFullAsync 迁 stg 管线后依赖它(否则 s5-production-return-mdp/refresh 端点断)。
+--   2) SQLSERVER 实体 status=0:就位不启用,合入后不改变现有运行行为;启用需带外注密码+受控切源。
+--   3) 四实体均 sync_mode='FULL'(本批 FULL-only,规避增量边界;不启自动增量)。
+--   4) source_table_name='NbrMaster'/'NbrDetail'(两库同名);transform 按 Type='WOD' AND IsActive(判真) AND IsReturn(判假) 过滤(退料)。
+--      tenant_id 由入站上下文赋值,本库源含该列(raw 保留),SQL Server 源无此列(回落上下文)。
+-- 幂等:WHERE NOT EXISTS 守卫,可重复执行。
+
+-- 本库入站实体(AIDOPDEV_MYSQL, status=1)
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'),
+       'S5_PRODUCTION_RETURN_MASTER', 'S5生产退料主入站(本库)', 'TABLE',
+       'NbrMaster', 'mdp_stg_production_return',
+       'FULL', 5000, 'Domain,Nbr', 1, 'S5生产退料 B-① 本库头入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_PRODUCTION_RETURN_MASTER');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'),
+       'S5_PRODUCTION_RETURN_DETAIL', 'S5生产退料明细入站(本库)', 'TABLE',
+       'NbrDetail', 'mdp_stg_production_return',
+       'FULL', 5000, 'Domain,Nbr,Line', 1, 'S5生产退料 B-① 本库明细入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_PRODUCTION_RETURN_DETAIL');
+
+-- dopdemorq SQL Server 入站实体(DOPDEMORQ_SQLSERVER, status=0 就位不启用)
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'),
+       'S5_PRODUCTION_RETURN_MASTER_SQLSERVER', 'S5生产退料主入站(SQLServer)', 'TABLE',
+       'NbrMaster', 'mdp_stg_production_return',
+       'FULL', 5000, 'Domain,Nbr', 0, 'S5生产退料 B-① 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_PRODUCTION_RETURN_MASTER_SQLSERVER');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'),
+       'S5_PRODUCTION_RETURN_DETAIL_SQLSERVER', 'S5生产退料明细入站(SQLServer)', 'TABLE',
+       'NbrDetail', 'mdp_stg_production_return',
+       'FULL', 5000, 'Domain,Nbr,Line', 0, 'S5生产退料 B-① 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_PRODUCTION_RETURN_DETAIL_SQLSERVER');

+ 53 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.272.sql

@@ -0,0 +1,53 @@
+-- 1.0.272:S5 委外发料 B-① 迁通用管线 + dopdemorq 双源入站实体登记(本库 status=1 / SQLSERVER status=0)
+--
+-- 背景:OutsourceIssue 由 bespoke(stg 死支路 + std 直读 NbrMaster/NbrDetail)迁入通用管线
+--       (源→mdp_stg_outsource_issue_pull[头/明细共表,source_table 区分]→transform→mdp_std_outsource_issue(_detail))。
+--       canonical 执行器 stg 用 *_pull(沿用 mdp_stg_ipqc_pull 惯例);legacy mdp_stg_outsource_issue(_detail) 不动、迁后死支路。
+--       登记 4 个入站实体(本库 master+detail、dopdemorq SQL Server master+detail),均落 mdp_stg_outsource_issue_pull、FULL。
+-- 状态:正式脚本(1.0.272,已注册 csproj Copy,随 AutoVersionUpdate 执行)。
+--
+-- 硬约束:
+--   1) 本库实体 S5_OUTSOURCE_ISSUE_MASTER/_DETAIL status=1:RunFullAsync 迁 stg 管线后依赖它(否则 s5-outsource-issue-mdp/refresh 端点断)。
+--   2) SQLSERVER 实体 status=0:就位不启用,合入后不改变现有运行行为;启用需带外注密码+受控切源。
+--   3) 四实体均 sync_mode='FULL'(本批 FULL-only,规避增量边界;不启自动增量)。
+--   4) source_table_name='NbrMaster'/'NbrDetail'(两库同名);transform 按 Type='CA' AND IsActive(判真) 过滤(委外)。
+--      tenant_id 由入站上下文赋值,本库源含该列(raw 保留),SQL Server 源无此列(回落上下文)。
+-- 幂等:WHERE NOT EXISTS 守卫,可重复执行。
+
+-- 本库入站实体(AIDOPDEV_MYSQL, status=1)
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'),
+       'S5_OUTSOURCE_ISSUE_MASTER', 'S5委外发料主入站(本库)', 'TABLE',
+       'NbrMaster', 'mdp_stg_outsource_issue_pull',
+       'FULL', 5000, 'Domain,Nbr', 1, 'S5委外发料 B-① 本库头入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_OUTSOURCE_ISSUE_MASTER');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'),
+       'S5_OUTSOURCE_ISSUE_DETAIL', 'S5委外发料明细入站(本库)', 'TABLE',
+       'NbrDetail', 'mdp_stg_outsource_issue_pull',
+       'FULL', 5000, 'Domain,Nbr,Line', 1, 'S5委外发料 B-① 本库明细入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_OUTSOURCE_ISSUE_DETAIL');
+
+-- dopdemorq SQL Server 入站实体(DOPDEMORQ_SQLSERVER, status=0 就位不启用)
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'),
+       'S5_OUTSOURCE_ISSUE_MASTER_SQLSERVER', 'S5委外发料主入站(SQLServer)', 'TABLE',
+       'NbrMaster', 'mdp_stg_outsource_issue_pull',
+       'FULL', 5000, 'Domain,Nbr', 0, 'S5委外发料 B-① 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_OUTSOURCE_ISSUE_MASTER_SQLSERVER');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'),
+       'S5_OUTSOURCE_ISSUE_DETAIL_SQLSERVER', 'S5委外发料明细入站(SQLServer)', 'TABLE',
+       'NbrDetail', 'mdp_stg_outsource_issue_pull',
+       'FULL', 5000, 'Domain,Nbr,Line', 0, 'S5委外发料 B-① 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S5_OUTSOURCE_ISSUE_DETAIL_SQLSERVER');

+ 35 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.273.sql

@@ -0,0 +1,35 @@
+-- 1.0.273:S6 IPQC 明细 bespoke→通用管线 + dopdemorq 双源入站实体登记(本库 status=1 / SQLSERVER status=0)
+--
+-- 背景:IPQC Head 已迁 pull stg(source_table='qms_gcjyd')双源就绪;本批把 Detail(qms_gcjydzb) 从 bespoke
+--       (读独立 mdp_stg_ipqc_inspection_detail + 裸 CAST)迁入通用管线:执行器灌 mdp_stg_ipqc_pull(source_table='qms_gcjydzb',与 Head 共表)
+--       → transform 读 pull stg(MdpJsonSql 修 ybl/jysl/bhgsl/NonNumerical*/InspectionTime 类型守卫)→ mdp_std_ipqc_inspection_detail。
+--       登记 2 个明细入站实体(本库 + dopdemorq SQL Server),均落 mdp_stg_ipqc_pull、FULL。Head 实体不动。
+-- 状态:正式脚本(1.0.273,已注册 csproj Copy,随 AutoVersionUpdate 执行)。
+--
+-- 硬约束:
+--   1) 本库实体 S6_IPQC_INSPECTION_DETAIL status=1:RunFullAsync 明细迁 pull 管线后依赖它(否则 s6 IPQC refresh 明细断)。
+--   2) SQLSERVER 实体 status=0:就位不启用,合入后不改变现有运行行为;启用需带外注密码+受控切源。
+--   3) 两实体均 sync_mode='FULL'(qms_gcjydzb 无 UpdateTime,FULL-only)。
+--   4) source_table_name='qms_gcjydzb'(两库同名);transform 按 source_table='qms_gcjydzb' 过滤,与 Head(qms_gcjyd) 共 pull stg。
+--      tenant_id 由入站上下文赋值,本库源含该列(raw 保留),SQL Server 源无此列(回落上下文默认 1300000000001)。
+-- 幂等:WHERE NOT EXISTS 守卫,可重复执行。
+
+-- 本库明细入站实体(AIDOPDEV_MYSQL, status=1)
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'),
+       'S6_IPQC_INSPECTION_DETAIL', 'S6过程检验明细入站(本库)', 'TABLE',
+       'qms_gcjydzb', 'mdp_stg_ipqc_pull',
+       'FULL', 5000, 'id', 1, 'S6 IPQC 明细 B-① 本库入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S6_IPQC_INSPECTION_DETAIL');
+
+-- dopdemorq SQL Server 明细入站实体(DOPDEMORQ_SQLSERVER, status=0 就位不启用)
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'),
+       'S6_IPQC_INSPECTION_DETAIL_SQLSERVER', 'S6过程检验明细入站(SQLServer)', 'TABLE',
+       'qms_gcjydzb', 'mdp_stg_ipqc_pull',
+       'FULL', 5000, 'id', 0, 'S6 IPQC 明细 B-① 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S6_IPQC_INSPECTION_DETAIL_SQLSERVER');

+ 41 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.276.sql

@@ -0,0 +1,41 @@
+-- 1.0.276:S7 FQC 直读真表→通用管线全迁 std + dopdemorq 双源入站实体登记(本库 status=1 / SQLSERVER status=0)
+--
+-- 背景:FQC(task/result/detail) 由直读 qms_fqcbj/qms_qcpp_inspbill/qms_qcpp_inspbillst 迁入通用管线(CTO 拍板:可接受批次快照延迟、全迁 std):
+--       源经执行器灌 mdp_stg_fqc_pull(三表共表,source_table 区分)→ FqcMdpSyncService transform(MdpJsonSql 类型兼容 + WorkOrdMaster 本库富化 SAP)
+--       → mdp_std_fqc_task / mdp_std_fqc_result / mdp_std_fqc_result_detail;Service 三读路径改读 std。
+--       登记 6 个入站实体(本库 task/result/detail + dopdemorq SQL Server 三镜像),均落 mdp_stg_fqc_pull、FULL。
+-- 状态:正式脚本(1.0.276,已注册 csproj Copy,随 AutoVersionUpdate 执行)。
+--
+-- 硬约束:
+--   1) 本库实体 status=1:RunFullAsync 迁 stg 管线后依赖它(否则 S7FqcInspBill/mdp/refresh 端点断)。
+--   2) SQLSERVER 实体 status=0:就位不启用,合入后不改变现有运行行为;启用需带外注密码+受控切源。
+--   3) 六实体均 sync_mode='FULL'(qms_* 无 UpdateTime,FULL-only)。
+--   4) source_table_name=qms_fqcbj/qms_qcpp_inspbill/qms_qcpp_inspbillst(两库同名);transform 按 source_table 过滤,三表共 mdp_stg_fqc_pull。
+--      tenant_id 由入站上下文赋值,本库源含该列(raw 保留),SQL Server 源无此列(回落上下文)。
+-- 幂等:WHERE NOT EXISTS 守卫,可重复执行。
+
+-- 本库入站实体(AIDOPDEV_MYSQL, status=1)
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'), 'S7_FQC_TASK', 'S7 FQC检验任务入站(本库)', 'TABLE', 'qms_fqcbj', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 1, 'S7 FQC 任务 本库入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_TASK');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'), 'S7_FQC_RESULT', 'S7 FQC检验单入站(本库)', 'TABLE', 'qms_qcpp_inspbill', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 1, 'S7 FQC 结果 本库入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'), 'S7_FQC_RESULT_DETAIL', 'S7 FQC检验单明细入站(本库)', 'TABLE', 'qms_qcpp_inspbillst', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 1, 'S7 FQC 明细 本库入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT_DETAIL');
+
+-- dopdemorq SQL Server 入站实体(DOPDEMORQ_SQLSERVER, status=0 就位不启用)
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'), 'S7_FQC_TASK_SQLSERVER', 'S7 FQC检验任务入站(SQLServer)', 'TABLE', 'qms_fqcbj', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 0, 'S7 FQC 任务 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_TASK_SQLSERVER');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'), 'S7_FQC_RESULT_SQLSERVER', 'S7 FQC检验单入站(SQLServer)', 'TABLE', 'qms_qcpp_inspbill', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 0, 'S7 FQC 结果 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT_SQLSERVER');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'), 'S7_FQC_RESULT_DETAIL_SQLSERVER', 'S7 FQC检验单明细入站(SQLServer)', 'TABLE', 'qms_qcpp_inspbillst', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 0, 'S7 FQC 明细 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT_DETAIL_SQLSERVER');

+ 23 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.277.sql

@@ -0,0 +1,23 @@
+-- 1.0.277:S5_L1_002 物料上线满足率 中台化 —— 启用 2 个报表/明细 VIEW 的 FULL 贴源。
+-- 背景:S5_L1_002 原直连 t8_v5(Cj_Bg_Head_Rep 按 noid 取 min(kgdate) 开工时间 / kc_dd_list_cllist 分母行数)。
+--   本批把这 2 张 VIEW 从"占位登记 sync_mode=NONE、target=NULL"启用为 FULL 贴源,指向 mdp_stg_t8_*
+--   (stg/std 建表 + mdp_std_t8_kc_tz_head 补 lynoid 列,由 T8BaseInboundMdpSyncService.EnsureTablesAsync 幂等处理)。
+-- biz_key:行级 Id(实测 count(*)==count(distinct Id):Cj_Bg_Head_Rep 5/5、kc_dd_list_cllist 29/29 唯一)。
+--   注意不沿用同源 S6_REPORT 的 biz_key_expr='noid,kgdate'(实测 noid、(noid,kgdate) 均会撞行:Id=4/5 同 noid 同 kgdate)。
+-- 幂等:可重复执行;按 entity_code 过滤,不触碰同源的 S6_REPORT / S6_REPORT_API。
+
+UPDATE mdp_entity e
+JOIN mdp_source s ON s.id = e.source_id AND s.source_code = 'T8_V5_SQLSERVER'
+SET
+  e.target_table_name = CASE e.entity_code
+    WHEN 'T8_CJ_BG_HEAD_REP' THEN 'mdp_stg_t8_cj_bg_head_rep'
+    WHEN 'T8_KC_DD_LIST_CLLIST' THEN 'mdp_stg_t8_kc_dd_list_cllist'
+  END,
+  e.sync_mode = 'FULL',
+  e.incr_column = NULL,
+  e.batch_size = 1000,
+  e.biz_key_expr = 'Id',
+  e.status = 1,
+  e.remark = CONCAT(IFNULL(e.remark,''), ' | S5_L1_002-MDP: FULL 贴源启用,biz_key=Id 行级'),
+  e.update_time = NOW()
+WHERE e.entity_code IN ('T8_CJ_BG_HEAD_REP','T8_KC_DD_LIST_CLLIST');

+ 71 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.278.sql

@@ -0,0 +1,71 @@
+-- 1.0.278:KPI SQL 配置化计算引擎(AIDOP-KPI-SQL-CONFIG-RUNTIME-1)。
+-- 表结构由 CodeFirst(EnableInitTable=true)从实体创建;本项目 EnableUnderLine=false → 列名 PascalCase。
+-- 下方 CREATE TABLE IF NOT EXISTS 为兜底(CodeFirst 已建则跳过),列名/类型对齐实体。
+-- 种子:S5_L1_001 在 AIDOP 租户 797403760988229 的 LEGACY_CODE v1(PUBLISHED/IsCurrent=1)——默认=legacy 向后兼容 + 支持"回滚到 LEGACY_CODE"演示。
+
+CREATE TABLE IF NOT EXISTS ado_smart_ops_kpi_calc_config (
+  `Id` BIGINT NOT NULL AUTO_INCREMENT,
+  `TenantId` BIGINT DEFAULT NULL,
+  `MetricCode` VARCHAR(50) NOT NULL,
+  `ModuleCode` VARCHAR(20) NOT NULL,
+  `VersionNo` INT NOT NULL,
+  `CalcEngineType` VARCHAR(20) NOT NULL DEFAULT 'CONFIG_SQL',
+  `DataSourceCode` VARCHAR(100) DEFAULT NULL,
+  `SqlScript` TEXT DEFAULT NULL,
+  `SqlParametersJson` TEXT DEFAULT NULL,
+  `TimeoutSeconds` INT NOT NULL DEFAULT 30,
+  `PublishStatus` VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+  `IsCurrent` TINYINT(1) NOT NULL DEFAULT 0,
+  `Remark` TEXT DEFAULT NULL,
+  `CreatedBy` VARCHAR(100) DEFAULT NULL,
+  `CreatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `UpdatedBy` VARCHAR(100) DEFAULT NULL,
+  `UpdatedAt` DATETIME DEFAULT NULL,
+  `PublishedBy` VARCHAR(100) DEFAULT NULL,
+  `PublishedAt` DATETIME DEFAULT NULL,
+  `RetiredBy` VARCHAR(100) DEFAULT NULL,
+  `RetiredAt` DATETIME DEFAULT NULL,
+  PRIMARY KEY (`Id`),
+  UNIQUE KEY `uk_kpi_calc_cfg_metric_ver` (`MetricCode`, `TenantId`, `VersionNo`),
+  KEY `idx_kpi_calc_cfg_current` (`MetricCode`, `TenantId`, `IsCurrent`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='KPI 计算配置(SQL 配置化)';
+
+CREATE TABLE IF NOT EXISTS ado_smart_ops_kpi_calc_run_log (
+  `Id` BIGINT NOT NULL AUTO_INCREMENT,
+  `TenantId` BIGINT DEFAULT NULL,
+  `BatchId` VARCHAR(100) NOT NULL,
+  `ModuleCode` VARCHAR(20) NOT NULL,
+  `MetricCode` VARCHAR(50) NOT NULL,
+  `ConfigId` BIGINT DEFAULT NULL,
+  `VersionNo` INT DEFAULT NULL,
+  `EngineType` VARCHAR(20) NOT NULL,
+  `DataSourceCode` VARCHAR(100) DEFAULT NULL,
+  `BizDate` DATE NOT NULL,
+  `StartedAt` DATETIME NOT NULL,
+  `FinishedAt` DATETIME DEFAULT NULL,
+  `DurationMs` BIGINT NOT NULL DEFAULT 0,
+  `Status` VARCHAR(20) NOT NULL,
+  `RowCount` INT NOT NULL DEFAULT 0,
+  `MetricValue` DECIMAL(18,4) DEFAULT NULL,
+  `NumeratorValue` DECIMAL(18,4) DEFAULT NULL,
+  `DenominatorValue` DECIMAL(18,4) DEFAULT NULL,
+  `ErrorCode` VARCHAR(50) DEFAULT NULL,
+  `ErrorMessage` VARCHAR(500) DEFAULT NULL,
+  `SqlHash` VARCHAR(64) DEFAULT NULL,
+  `ParameterSnapshot` TEXT DEFAULT NULL,
+  `TriggerType` VARCHAR(20) NOT NULL,
+  `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`Id`),
+  UNIQUE KEY `uk_kpi_calc_run_metric_batch` (`MetricCode`, `BatchId`),
+  KEY `idx_kpi_calc_run_metric_time` (`MetricCode`, `StartedAt`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='KPI 计算 SQL 运行日志';
+
+-- 种子 S5_L1_001 LEGACY_CODE v1(幂等:存在则跳过)。列名 PascalCase(EnableUnderLine=false)。
+INSERT INTO ado_smart_ops_kpi_calc_config
+  (`TenantId`, `MetricCode`, `ModuleCode`, `VersionNo`, `CalcEngineType`, `TimeoutSeconds`, `PublishStatus`, `IsCurrent`, `Remark`, `CreatedBy`, `CreatedAt`)
+SELECT 797403760988229, 'S5_L1_001', 'S5', 1, 'LEGACY_CODE', 30, 'PUBLISHED', 1, 'S5_L1_001 初始 legacy 版本(向后兼容+回滚基线)', 'system', NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM ado_smart_ops_kpi_calc_config
+  WHERE `MetricCode`='S5_L1_001' AND `TenantId`=797403760988229 AND `VersionNo`=1
+);

+ 115 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/KpiSqlSecurityValidatorTests.cs

@@ -0,0 +1,115 @@
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.SmartOps;
+
+/// <summary>KPI 配置 SQL 安全校验(多层非正则)单元测试。覆盖合法查询 + 各类写/危险/绕过。</summary>
+public class KpiSqlSecurityValidatorTests
+{
+    private const string LegalSelect =
+        "SELECT AVG(cycle_days) AS metric_value FROM dwd_t8_material_online_cycle WHERE tenant_id=@tenant_id AND cycle_days IS NOT NULL";
+
+    [Fact]
+    public void LegalSelect_Valid()
+    {
+        var r = KpiSqlSecurityValidator.Validate(LegalSelect);
+        Assert.True(r.Ok);
+        Assert.Contains("dwd_t8_material_online_cycle", r.ReferencedTables);
+    }
+
+    [Fact]
+    public void LegalWithSelect_Valid()
+    {
+        var sql = "WITH t AS (SELECT cycle_days FROM dwd_t8_material_online_cycle WHERE tenant_id=@tenant_id) " +
+                  "SELECT AVG(cycle_days) AS metric_value FROM mdp_std_t8_kc_tz_head";
+        var r = KpiSqlSecurityValidator.Validate(sql);
+        Assert.True(r.Ok);
+    }
+
+    [Theory]
+    [InlineData("INSERT INTO dwd_t8_x(a) VALUES(1)")]
+    [InlineData("UPDATE dwd_t8_x SET a=1")]
+    [InlineData("DELETE FROM dwd_t8_x")]
+    [InlineData("DROP TABLE dwd_t8_x")]
+    [InlineData("ALTER TABLE dwd_t8_x ADD c INT")]
+    [InlineData("CREATE TABLE t(a INT)")]
+    [InlineData("TRUNCATE TABLE dwd_t8_x")]
+    [InlineData("REPLACE INTO dwd_t8_x VALUES(1)")]
+    [InlineData("MERGE INTO dwd_t8_x")]
+    public void WriteStatements_Invalid(string sql)
+    {
+        var r = KpiSqlSecurityValidator.Validate(sql);
+        Assert.False(r.Ok);
+    }
+
+    [Theory]
+    [InlineData("EXEC sp_x")]
+    [InlineData("EXECUTE sp_x")]
+    [InlineData("CALL my_proc()")]
+    public void ProcCalls_Invalid(string sql)
+    {
+        Assert.False(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void MultiStatement_Invalid()
+    {
+        var r = KpiSqlSecurityValidator.Validate("SELECT 1 AS metric_value FROM dwd_t8_x; SELECT 2");
+        Assert.False(r.Ok);
+        Assert.Equal("MULTI_STATEMENT", r.ErrorCode);
+    }
+
+    [Fact]
+    public void CommentHiddenSecondStatement_StrippedThenSingleStatement_Valid()
+    {
+        // 注释里的第二语句被去掉后应为单条合法 SELECT
+        var sql = "SELECT AVG(cycle_days) AS metric_value FROM dwd_t8_material_online_cycle -- ; DROP TABLE x";
+        Assert.True(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void CommentHiddenKeyword_NotFalsePositive()
+    {
+        var sql = "SELECT AVG(cycle_days) AS metric_value /* delete update drop */ FROM dwd_t8_material_online_cycle";
+        Assert.True(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void KeywordInsideStringLiteral_NotFalsePositive()
+    {
+        var sql = "SELECT 'DROP TABLE users' AS result_message, 1 AS metric_value FROM dwd_t8_material_online_cycle";
+        Assert.True(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void IllegalTable_Invalid()
+    {
+        var r = KpiSqlSecurityValidator.Validate("SELECT metric_value FROM sys_user WHERE tenant_id=@tenant_id");
+        Assert.False(r.Ok);
+        Assert.Equal("TABLE_NOT_ALLOWED", r.ErrorCode);
+    }
+
+    [Theory]
+    [InlineData("SHOW TABLES")]
+    [InlineData("SET @a=1")]
+    [InlineData("USE mysql")]
+    public void NonSelectStart_Invalid(string sql)
+    {
+        Assert.False(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void IntoOutfile_Invalid()
+    {
+        var r = KpiSqlSecurityValidator.Validate(
+            "SELECT metric_value INTO OUTFILE '/tmp/x' FROM dwd_t8_material_online_cycle");
+        Assert.False(r.Ok);
+    }
+
+    [Fact]
+    public void Empty_Invalid()
+    {
+        Assert.False(KpiSqlSecurityValidator.Validate("   ").Ok);
+        Assert.False(KpiSqlSecurityValidator.Validate("-- only comment").Ok);
+    }
+}

+ 105 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiCalcConfigController.cs

@@ -0,0 +1,105 @@
+using System.Security.Claims;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+/// <summary>
+/// KPI 计算配置(SQL 配置化)接口。**要求登录**(区别于其它 AiDOP 匿名接口)——SQL 编辑/发布是高危面。
+/// tenant/权限不信任前端:租户由服务端按 moduleCode 解析;操作人取自 JWT。
+/// </summary>
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class AdoSmartOpsKpiCalcConfigController : ControllerBase
+{
+    private readonly AdoSmartOpsKpiCalcConfigService _service;
+
+    public AdoSmartOpsKpiCalcConfigController(AdoSmartOpsKpiCalcConfigService service)
+    {
+        _service = service;
+    }
+
+    private string Operator() =>
+        User?.FindFirstValue("RealName") ?? User?.FindFirstValue("Account") ?? User?.Identity?.Name ?? "unknown";
+
+    /// <summary>某 KPI 的全部配置版本。</summary>
+    [HttpGet("by-metric/{metricCode}")]
+    public async Task<IActionResult> GetByMetric(string metricCode, [FromQuery] string moduleCode)
+    {
+        if (string.IsNullOrWhiteSpace(moduleCode))
+            return BadRequest(new { message = "moduleCode 必填" });
+        var list = await _service.GetByMetricAsync(metricCode, moduleCode);
+        return Ok(list);
+    }
+
+    /// <summary>已登记数据源列表。</summary>
+    [HttpGet("data-sources")]
+    public IActionResult DataSources() => Ok(_service.ListDataSources());
+
+    /// <summary>新增草稿。</summary>
+    [HttpPost]
+    public async Task<IActionResult> Create([FromBody] KpiCalcConfigUpsertDto dto)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode) || string.IsNullOrWhiteSpace(dto.ModuleCode))
+            return BadRequest(new { message = "metricCode / moduleCode 必填" });
+        var created = await _service.CreateDraftAsync(dto, Operator());
+        return Ok(created);
+    }
+
+    /// <summary>编辑草稿。</summary>
+    [HttpPut("{id:long}")]
+    public async Task<IActionResult> Update(long id, [FromBody] KpiCalcConfigUpsertDto dto)
+    {
+        await _service.UpdateDraftAsync(id, dto, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>删除草稿。</summary>
+    [HttpDelete("{id:long}")]
+    public async Task<IActionResult> Delete(long id)
+    {
+        await _service.DeleteAsync(id);
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>SQL 安全校验。</summary>
+    [HttpPost("validate")]
+    public IActionResult Validate([FromBody] KpiSqlValidateDto dto) => Ok(_service.Validate(dto.SqlScript));
+
+    /// <summary>试算(不写正式 KPI 值;服务端重校验 + 只读执行)。</summary>
+    [HttpPost("preview")]
+    public async Task<IActionResult> Preview([FromBody] KpiSqlPreviewDto dto, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode) || string.IsNullOrWhiteSpace(dto.ModuleCode))
+            return BadRequest(new { message = "metricCode / moduleCode 必填" });
+        var res = await _service.PreviewAsync(dto, ct);
+        return Ok(res);
+    }
+
+    /// <summary>发布(事务:单生效版本)。</summary>
+    [HttpPost("{id:long}/publish")]
+    public async Task<IActionResult> Publish(long id)
+    {
+        await _service.PublishAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>激活/回滚(把某已发布历史版本设为当前生效)。</summary>
+    [HttpPost("{id:long}/activate")]
+    public async Task<IActionResult> Activate(long id)
+    {
+        await _service.ActivateAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>停用。</summary>
+    [HttpPost("{id:long}/retire")]
+    public async Task<IActionResult> Retire(long id)
+    {
+        await _service.RetireAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+}

+ 13 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -780,10 +780,23 @@ LIMIT 60
             "S2" => await RefreshS2Mdp(cancellationToken),
             "S3" => await RefreshS3Mdp(cancellationToken),
             "S4" => await RefreshS4Mdp(cancellationToken),
+            "S5" or "S6" or "S7" => await RefreshT8KpiModuleMdp(mc, cancellationToken),
             _ => Ok(new { ok = true, moduleCode = mc, message = "当前模块暂无 MDP 刷新任务,仅刷新动态看板数据。" })
         };
     }
 
+    /// <summary>
+    /// S5/S6/S7 T8 KPI 统一手动刷新:共享 6 表入站(硬门)→ 本模块 RunFull(MANUAL) → 逐指标状态。
+    /// 编排与并发锁在 AidopT8KpiManualRefreshService(与 Cron job_s5_t8_kpi_refresh 共享刷新锁)。
+    /// 经 App.GetRequiredService 解析,避免改动共享控制器构造函数。
+    /// </summary>
+    private async Task<IActionResult> RefreshT8KpiModuleMdp(string moduleCode, CancellationToken cancellationToken)
+    {
+        var orchestrator = App.GetRequiredService<Admin.NET.Plugin.AiDOP.DataPlatform.AidopT8KpiManualRefreshService>();
+        var result = await orchestrator.RunModuleRefreshAsync(moduleCode, cancellationToken);
+        return Ok(result);
+    }
+
     private async Task<S2SyncStatusDto?> GetS2SyncStatusAsync()
     {
         return await GetMdpSyncStatusAsync("S2_MDP_SYNC_TRANSFORM");

+ 274 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/AidopT8KpiManualRefreshService.cs

@@ -0,0 +1,274 @@
+using Admin.NET.Core.Service;
+using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+using Admin.NET.Plugin.AiDOP.Manufacturing;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform;
+
+/// <summary>
+/// S5/S6/S7 T8 KPI 手动刷新统一编排(看板"刷新"按钮真实跑批入口)。
+/// 单次刷新语义:抢全局刷新锁 → T8 基表入站(硬门,共享 6 表)→ 本模块 RunFull(MANUAL) → 汇总逐指标状态 → 释放锁。
+/// 与 Cron job_s5_t8_kpi_refresh 共享同一把锁(<see cref="RefreshLockKey"/>),保证手动刷新与定时跑批互斥、并发手动刷新排他。
+/// 入站为 S5/S6/S7 共用(T8BaseInboundMdpSyncService 6 张基表无法按模块拆),故刷任一模块均触发一次全量入站。
+/// S5_L1_002 / S5_L1_004 仍 legacy 直连 T8(本轮不改),随 S5 RunFull 一并执行,逐指标标 LEGACY_SUCCESS。
+///
+/// 锁设计(加固):用持有者无关的硬 TTL 键(SysCacheService.Set 带过期),而非 BeginCacheLock。
+/// 原因:BeginCacheLock 在持有者线程存活期间会续租,一旦某次运行意外挂死(如源库慢查询无超时)会永久占锁、
+/// 阻塞后续所有刷新。改为硬 TTL 后:正常路径 finally 主动 Remove;即使挂死,键到 <see cref="RefreshLockTtlSeconds"/> 也自动过期。
+/// 配合 QueryT8Async 的 T8 命令超时(60s)与主库命令超时(30s),单次运行时长恒 &lt; TTL,正常不会误过期。
+/// </summary>
+public sealed class AidopT8KpiManualRefreshService : ITransient
+{
+    /// <summary>手动刷新 + Cron 共享的全局互斥锁 key(覆盖 inbound + transform 整段)。</summary>
+    public const string RefreshLockKey = "aidop:t8kpi:refresh:lock";
+
+    /// <summary>刷新锁硬 TTL(秒,持有者无关):到点自动过期,即使某次运行意外挂死也不会永久阻塞后续刷新。
+    /// 取值须大于加固后最坏运行时长(T8 查询各 ≤60s、主库命令 ≤30s,全程数分钟内),10 分钟为安全上限。</summary>
+    public const int RefreshLockTtlSeconds = 600;
+
+    // legacy 直连 T8 的 KPI(仅剩 S5_L1_004 TVF;S5_L1_002 已中台化),逐指标状态标 LEGACY_SUCCESS 以示区分。
+    private static readonly HashSet<string> LegacyKpiCodes = new(StringComparer.OrdinalIgnoreCase) { "S5_L1_004" };
+
+    private readonly T8BaseInboundMdpSyncService _inbound;
+    private readonly S5MdpSyncTransformService _s5;
+    private readonly S6MdpSyncTransformService _s6;
+    private readonly S7MdpSyncTransformService _s7;
+    private readonly SysCacheService _cache;
+    private readonly ILogger<AidopT8KpiManualRefreshService> _logger;
+
+    public AidopT8KpiManualRefreshService(
+        T8BaseInboundMdpSyncService inbound,
+        S5MdpSyncTransformService s5,
+        S6MdpSyncTransformService s6,
+        S7MdpSyncTransformService s7,
+        SysCacheService cache,
+        ILogger<AidopT8KpiManualRefreshService> logger)
+    {
+        _inbound = inbound;
+        _s5 = s5;
+        _s6 = s6;
+        _s7 = s7;
+        _cache = cache;
+        _logger = logger;
+    }
+
+    /// <summary>执行一次模块级手动刷新(S5/S6/S7)。返回统一契约,绝不抛业务异常(取消除外)。</summary>
+    public async Task<AidopKpiRefreshResult> RunModuleRefreshAsync(string moduleCode, CancellationToken cancellationToken)
+    {
+        var mc = string.IsNullOrWhiteSpace(moduleCode) ? string.Empty : moduleCode.Trim().ToUpperInvariant();
+        var startedAt = DateTime.Now;
+        var result = new AidopKpiRefreshResult
+        {
+            ModuleCode = mc,
+            StartedAt = startedAt.ToString("yyyy-MM-dd HH:mm:ss")
+        };
+
+        if (mc is not ("S5" or "S6" or "S7"))
+        {
+            result.Ok = false;
+            result.OverallStatus = "SKIPPED";
+            result.Message = $"模块 {mc} 不支持 T8 KPI 手动刷新";
+            Finish(result, startedAt);
+            return result;
+        }
+
+        // 硬 TTL 锁:ExistKey 命中即判定已有刷新在跑(手动或定时);否则 Set(带 TTL) 占锁。
+        // 单实例 + 人工点击/每日 5 次定时下,ExistKey→Set 的微小竞态概率可忽略;即便未主动释放,TTL 到点自动过期。
+        if (_cache.ExistKey(RefreshLockKey))
+        {
+            result.Ok = false;
+            result.OverallStatus = "REFRESHING";
+            result.Message = "T8 KPI 刷新正在进行中(手动或定时任务占用),请稍后重试";
+            Finish(result, startedAt);
+            return result;
+        }
+        _cache.Set(RefreshLockKey, $"MANUAL:{mc}:{startedAt:yyyyMMddHHmmss}", TimeSpan.FromSeconds(RefreshLockTtlSeconds));
+
+        try
+        {
+            // ① 入站硬门:共享 6 张 T8 基表全量入站;失败即中断,不用旧 std 伪装成功。
+            try
+            {
+                var inbound = await _inbound.RunInboundAsync(0, true, null, cancellationToken);
+                result.Inbound.Ok = true;
+                result.Inbound.StageRows = inbound.Tables.Sum(t => t.StgRows);
+                result.Inbound.StandardRows = inbound.Tables.Sum(t => t.StdRows);
+            }
+            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+            {
+                throw;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "[手动刷新] {Module} T8 基表入站失败", mc);
+                result.Ok = false;
+                result.OverallStatus = "FAILED";
+                result.Inbound.Ok = false;
+                result.Inbound.Message = ex.Message;
+                result.Message = "源数据入站失败,未更新 KPI(保留原有看板数据)";
+                Finish(result, startedAt);
+                return result;
+            }
+
+            // ② 本模块 KPI 转换(RunFull 为全有全无:任一 KPI 异常即整体抛错)。
+            string batchId;
+            int dwdRows;
+            int kpiRows;
+            Dictionary<string, int> perKpiRows;
+            List<string> denomStatus;
+            try
+            {
+                switch (mc)
+                {
+                    case "S5":
+                        var r5 = await _s5.RunFullAsync(cancellationToken, "MANUAL");
+                        batchId = r5.BatchId;
+                        dwdRows = r5.DwdRows;
+                        kpiRows = r5.KpiRows;
+                        perKpiRows = r5.PerKpiKpiRows;
+                        denomStatus = r5.KpiDenominatorStatus;
+                        break;
+                    case "S6":
+                        var r6 = await _s6.RunFullAsync(cancellationToken, "MANUAL");
+                        batchId = r6.BatchId;
+                        dwdRows = r6.DwdRows;
+                        kpiRows = r6.KpiRows;
+                        perKpiRows = r6.PerKpiKpiRows;
+                        denomStatus = r6.KpiDenominatorStatus;
+                        break;
+                    default:
+                        var r7 = await _s7.RunFullAsync(cancellationToken, "MANUAL");
+                        batchId = r7.BatchId;
+                        dwdRows = r7.DwdRows;
+                        kpiRows = r7.KpiRows;
+                        perKpiRows = r7.PerKpiKpiRows;
+                        denomStatus = r7.KpiDenominatorStatus;
+                        break;
+                }
+            }
+            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+            {
+                throw;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "[手动刷新] {Module} KPI 转换失败", mc);
+                result.Ok = false;
+                result.OverallStatus = "FAILED";
+                result.Transform.Ok = false;
+                result.Transform.Message = ex.Message;
+                result.Message = "KPI 计算失败(源数据已入站,看板保留原有数据)";
+                Finish(result, startedAt);
+                return result;
+            }
+
+            result.BatchId = batchId;
+            result.Transform.Ok = true;
+            result.Transform.DwdRows = dwdRows;
+            result.Transform.KpiRows = kpiRows;
+
+            // ③ 逐指标状态:denom=OK → SUCCESS(legacy→LEGACY_SUCCESS);denom=NO_* → NO_DATA(真实无数据,非伪造 0)。
+            var denomMap = ParseDenominator(denomStatus);
+            foreach (var kv in perKpiRows.OrderBy(k => k.Key, StringComparer.Ordinal))
+            {
+                var code = kv.Key;
+                var denom = denomMap.TryGetValue(code, out var d) ? d : "OK";
+                var ok = string.Equals(denom, "OK", StringComparison.OrdinalIgnoreCase);
+                var status = ok
+                    ? (LegacyKpiCodes.Contains(code) ? "LEGACY_SUCCESS" : "SUCCESS")
+                    : "NO_DATA";
+                result.PerKpi.Add(new AidopKpiRefreshPerKpi
+                {
+                    MetricCode = code,
+                    Status = status,
+                    Rows = kv.Value,
+                    Message = ok ? null : denom
+                });
+            }
+
+            result.SuccessCount = result.PerKpi.Count(p => p.Status is "SUCCESS" or "LEGACY_SUCCESS");
+            result.NoDataCount = result.PerKpi.Count(p => p.Status == "NO_DATA");
+            result.FailedCount = result.PerKpi.Count(p => p.Status is "FAILED" or "LEGACY_FAILED");
+
+            result.Ok = true;
+            // RunFull 为全有全无,成功路径下 FailedCount 恒为 0;PARTIAL_SUCCESS 仅为契约保留态。
+            result.OverallStatus = result.FailedCount > 0 ? "PARTIAL_SUCCESS" : "SUCCESS";
+            Finish(result, startedAt);
+            result.Message = $"刷新完成:成功 {result.SuccessCount},无数据 {result.NoDataCount},失败 {result.FailedCount},耗时 {result.DurationMs / 1000.0:0.0} 秒";
+            return result;
+        }
+        finally
+        {
+            // 无论成功/失败/取消,主动释放锁(硬 TTL 是兜底,正常路径这里即时释放)。
+            _cache.Remove(RefreshLockKey);
+        }
+    }
+
+    /// <summary>把 "S5_L1_001:OK" 形式的分母状态列表解析为 metricCode → status 字典。</summary>
+    private static Dictionary<string, string> ParseDenominator(List<string> denom)
+    {
+        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+        foreach (var item in denom)
+        {
+            if (string.IsNullOrWhiteSpace(item)) continue;
+            var idx = item.IndexOf(':');
+            if (idx <= 0) continue;
+            map[item.Substring(0, idx).Trim()] = item.Substring(idx + 1).Trim();
+        }
+        return map;
+    }
+
+    private static void Finish(AidopKpiRefreshResult result, DateTime startedAt)
+    {
+        var finishedAt = DateTime.Now;
+        result.FinishedAt = finishedAt.ToString("yyyy-MM-dd HH:mm:ss");
+        result.DurationMs = (long)(finishedAt - startedAt).TotalMilliseconds;
+    }
+}
+
+/// <summary>手动刷新统一返回契约(PascalCase 属性经框架序列化为 camelCase:Ok→ok、BatchId→batchId)。</summary>
+public sealed class AidopKpiRefreshResult
+{
+    public bool Ok { get; set; }
+    public string ModuleCode { get; set; } = string.Empty;
+
+    /// <summary>整体状态:SUCCESS / PARTIAL_SUCCESS / FAILED / REFRESHING / SKIPPED。</summary>
+    public string OverallStatus { get; set; } = string.Empty;
+    public string? BatchId { get; set; }
+    public string StartedAt { get; set; } = string.Empty;
+    public string FinishedAt { get; set; } = string.Empty;
+    public long DurationMs { get; set; }
+    public AidopKpiRefreshInbound Inbound { get; set; } = new();
+    public AidopKpiRefreshTransform Transform { get; set; } = new();
+    public int SuccessCount { get; set; }
+    public int NoDataCount { get; set; }
+    public int FailedCount { get; set; }
+    public List<AidopKpiRefreshPerKpi> PerKpi { get; set; } = new();
+    public string Message { get; set; } = string.Empty;
+}
+
+public sealed class AidopKpiRefreshInbound
+{
+    public bool Ok { get; set; }
+    public int StageRows { get; set; }
+    public int StandardRows { get; set; }
+    public string? Message { get; set; }
+}
+
+public sealed class AidopKpiRefreshTransform
+{
+    public bool Ok { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+    public string? Message { get; set; }
+}
+
+/// <summary>单指标刷新状态:SUCCESS / LEGACY_SUCCESS / NO_DATA / FAILED / LEGACY_FAILED。</summary>
+public sealed class AidopKpiRefreshPerKpi
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string Status { get; set; } = string.Empty;
+    public int Rows { get; set; }
+    public string? Message { get; set; }
+}

+ 60 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/T8BaseInboundMdpSyncService.cs

@@ -11,8 +11,9 @@ namespace Admin.NET.Plugin.AiDOP.DataPlatform;
 ///       (种子把 target_table_name 指向对应 mdp_stg_t8_*、sync_mode=FULL、biz_key_expr=Id)。
 /// 只读源、只写 mdp;不写 T8;FULL 全量(源表极小、re-upsert 刷新可带回字段/作废标志更新)。
 /// 下游 S5/S6/S7 KPI 已改读 mdp_std_t8_*;故 KPI 跑批前须先跑一次本入站。
-/// 说明:S5_L1_002(Cj_Bg_Head_Rep)/ S5_L1_004(Rep_总账_存货_V3 TVF)仍走 legacy 直连 T8,
-///       其对应实体 T8_KC_DD_LIST_CLLIST / T8_CJ_BG_HEAD_REP / T8_REP_ZONGZHANG_CUNHUO_V3 本批不贴源。
+/// 说明:S5_L1_002 已中台化——其依赖的 Cj_Bg_Head_Rep(报工头,取min kgdate) 与 kc_dd_list_cllist(订单物料明细)
+///       随本服务贴源到 mdp_std_t8_cj_bg_head_rep / mdp_std_t8_kc_dd_list_cllist(biz_key=Id,实测唯一)。
+///       仅 S5_L1_004(Rep_总账_存货_V3 TVF,报表聚合结果无逐行主键)仍走 legacy 直连 T8,其实体 T8_REP_ZONGZHANG_CUNHUO_V3 不贴源。
 /// </summary>
 public sealed class T8BaseInboundMdpSyncService : ITransient
 {
@@ -28,6 +29,9 @@ public sealed class T8BaseInboundMdpSyncService : ITransient
         ("T8_KC_DD_LIST", "mdp_stg_t8_kc_dd_list", "mdp_std_t8_kc_dd_list"),
         ("T8_KC_ZJ_LIST", "mdp_stg_t8_kc_zj_list", "mdp_std_t8_kc_zj_list"),
         ("T8_SYS_PELIST", "mdp_stg_t8_sys_pelist", "mdp_std_t8_sys_pelist"),
+        // S5_L1_002 中台化新增:报工头(取 min kgdate 开工时间) 与 订单物料明细(分母行数)。
+        ("T8_CJ_BG_HEAD_REP", "mdp_stg_t8_cj_bg_head_rep", "mdp_std_t8_cj_bg_head_rep"),
+        ("T8_KC_DD_LIST_CLLIST", "mdp_stg_t8_kc_dd_list_cllist", "mdp_std_t8_kc_dd_list_cllist"),
     };
 
     public T8BaseInboundMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
@@ -89,6 +93,8 @@ public sealed class T8BaseInboundMdpSyncService : ITransient
             "T8_KC_DD_LIST" => BuildDdListTransform(),
             "T8_KC_ZJ_LIST" => BuildZjListTransform(),
             "T8_SYS_PELIST" => BuildPelistTransform(),
+            "T8_CJ_BG_HEAD_REP" => BuildCjBgHeadRepTransform(),
+            "T8_KC_DD_LIST_CLLIST" => BuildDdListCllistTransform(),
             _ => throw new InvalidOperationException($"未支持的 T8 入站实体:{entityCode}")
         };
 
@@ -117,15 +123,15 @@ WHERE s.process_status='PENDING' AND s.source_biz_key IS NOT NULL AND s.source_b
 
     private static string BuildTzHeadTransform() => $@"
 INSERT INTO mdp_std_t8_kc_tz_head
-  (tenant_id, source_system, src_id, ztid, lbs, hzyn, zfyn, shyn, shtime, date0,
+  (tenant_id, source_system, src_id, ztid, lbs, lynoid, hzyn, zfyn, shyn, shtime, date0,
    source_row_id, source_biz_key, sync_batch_id, sync_time)
 SELECT IFNULL(s.tenant_id,@tid), IFNULL(NULLIF(s.source_system,''),'T8'),
-  {SrcId}, {JStr("ztid")}, {JStr("lbs")}, {JInt("hzyn")}, {JInt("zfyn")}, {JInt("shyn")},
+  {SrcId}, {JStr("ztid")}, {JStr("lbs")}, {JStr("lynoid")}, {JInt("hzyn")}, {JInt("zfyn")}, {JInt("shyn")},
   {JDate("shtime")}, {JDate("date0")},
   IFNULL(NULLIF(s.source_row_id,''),s.source_biz_key), s.source_biz_key, @batch, @now
 {StgHead("mdp_stg_t8_kc_tz_head")}
 ON DUPLICATE KEY UPDATE
-  src_id=VALUES(src_id), ztid=VALUES(ztid), lbs=VALUES(lbs),
+  src_id=VALUES(src_id), ztid=VALUES(ztid), lbs=VALUES(lbs), lynoid=VALUES(lynoid),
   hzyn=VALUES(hzyn), zfyn=VALUES(zfyn), shyn=VALUES(shyn),
   shtime=VALUES(shtime), date0=VALUES(date0),
   source_row_id=VALUES(source_row_id), sync_batch_id=VALUES(sync_batch_id),
@@ -203,7 +209,35 @@ ON DUPLICATE KEY UPDATE
   source_row_id=VALUES(source_row_id), sync_batch_id=VALUES(sync_batch_id),
   sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP";
 
-    /// <summary>建 6 stg(通用信封契约)+ 6 std(KPI 实际引用的 typed 列)。幂等 IF NOT EXISTS。</summary>
+    // S5_L1_002 分子:报工头,按 noid 取 min(kgdate)=开工时间(下游 KPI 侧 GROUP BY noid 重算 MIN,保留逐行 kgdate)。
+    private static string BuildCjBgHeadRepTransform() => $@"
+INSERT INTO mdp_std_t8_cj_bg_head_rep
+  (tenant_id, source_system, src_id, noid, kgdate, ztid,
+   source_row_id, source_biz_key, sync_batch_id, sync_time)
+SELECT IFNULL(s.tenant_id,@tid), IFNULL(NULLIF(s.source_system,''),'T8'),
+  {SrcId}, {JStr("noid")}, {JDate("kgdate")}, {JStr("ztid")},
+  IFNULL(NULLIF(s.source_row_id,''),s.source_biz_key), s.source_biz_key, @batch, @now
+{StgHead("mdp_stg_t8_cj_bg_head_rep")}
+ON DUPLICATE KEY UPDATE
+  src_id=VALUES(src_id), noid=VALUES(noid), kgdate=VALUES(kgdate), ztid=VALUES(ztid),
+  source_row_id=VALUES(source_row_id), sync_batch_id=VALUES(sync_batch_id),
+  sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP";
+
+    // S5_L1_002 分母:订单物料明细,count 行数 per 工单(关联 kc_dd_head.src_id = cllist.idid)。
+    private static string BuildDdListCllistTransform() => $@"
+INSERT INTO mdp_std_t8_kc_dd_list_cllist
+  (tenant_id, source_system, src_id, idid,
+   source_row_id, source_biz_key, sync_batch_id, sync_time)
+SELECT IFNULL(s.tenant_id,@tid), IFNULL(NULLIF(s.source_system,''),'T8'),
+  {SrcId}, {JInt("idid")},
+  IFNULL(NULLIF(s.source_row_id,''),s.source_biz_key), s.source_biz_key, @batch, @now
+{StgHead("mdp_stg_t8_kc_dd_list_cllist")}
+ON DUPLICATE KEY UPDATE
+  src_id=VALUES(src_id), idid=VALUES(idid),
+  source_row_id=VALUES(source_row_id), sync_batch_id=VALUES(sync_batch_id),
+  sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP";
+
+    /// <summary>建 8 stg(通用信封契约)+ 8 std(KPI 实际引用的 typed 列)。幂等 IF NOT EXISTS。</summary>
     private async Task EnsureTablesAsync()
     {
         foreach (var (_, stgTable, _) in Entities)
@@ -211,6 +245,7 @@ ON DUPLICATE KEY UPDATE
 
         await _db.Ado.ExecuteCommandAsync(StdDdl("mdp_std_t8_kc_tz_head",
             "src_id bigint DEFAULT NULL, ztid varchar(100) DEFAULT NULL, lbs varchar(100) DEFAULT NULL, " +
+            "lynoid varchar(400) DEFAULT NULL, " +
             "hzyn int DEFAULT NULL, zfyn int DEFAULT NULL, shyn int DEFAULT NULL, " +
             "shtime datetime DEFAULT NULL, date0 date DEFAULT NULL"));
         await _db.Ado.ExecuteCommandAsync(StdDdl("mdp_std_t8_kc_tz_list",
@@ -231,6 +266,25 @@ ON DUPLICATE KEY UPDATE
         await _db.Ado.ExecuteCommandAsync(StdDdl("mdp_std_t8_sys_pelist",
             "src_id bigint DEFAULT NULL, ztid varchar(100) DEFAULT NULL, zzzt varchar(100) DEFAULT NULL, " +
             "gw varchar(400) DEFAULT NULL"));
+        // S5_L1_002 中台化新增 2 张 std(stg 由上方 Entities 循环建)。
+        await _db.Ado.ExecuteCommandAsync(StdDdl("mdp_std_t8_cj_bg_head_rep",
+            "src_id bigint DEFAULT NULL, noid varchar(400) DEFAULT NULL, kgdate date DEFAULT NULL, " +
+            "ztid varchar(100) DEFAULT NULL"));
+        await _db.Ado.ExecuteCommandAsync(StdDdl("mdp_std_t8_kc_dd_list_cllist",
+            "src_id bigint DEFAULT NULL, idid bigint DEFAULT NULL"));
+        // 既有 mdp_std_t8_kc_tz_head(CREATE IF NOT EXISTS 不会补列)幂等补 lynoid(S5_L1_002 分子按工单号分组用)。
+        await EnsureColumnAsync("mdp_std_t8_kc_tz_head", "lynoid", "varchar(400) DEFAULT NULL");
+    }
+
+    /// <summary>幂等补列:目标表缺该列时 ADD COLUMN;已有则跳过。用于向既有 std 表安全追加字段。</summary>
+    private async Task EnsureColumnAsync(string table, string column, string columnDef)
+    {
+        var exists = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(*) FROM information_schema.COLUMNS " +
+            "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t AND COLUMN_NAME=@c",
+            new SugarParameter("@t", table), new SugarParameter("@c", column));
+        if (exists == 0)
+            await _db.Ado.ExecuteCommandAsync($"ALTER TABLE {table} ADD COLUMN {column} {columnDef}");
     }
 
     private static string StgDdl(string table) => $@"

+ 84 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/SmartOps/AdoSmartOpsKpiCalcConfigDtos.cs

@@ -0,0 +1,84 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+
+/// <summary>新增/编辑草稿。</summary>
+public sealed class KpiCalcConfigUpsertDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string CalcEngineType { get; set; } = "CONFIG_SQL";
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public string? SqlParametersJson { get; set; }
+    public int TimeoutSeconds { get; set; } = 30;
+    public string? Remark { get; set; }
+}
+
+/// <summary>配置版本输出。</summary>
+public sealed class KpiCalcConfigDto
+{
+    public long Id { get; set; }
+    public long? TenantId { get; set; }
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public int VersionNo { get; set; }
+    public string CalcEngineType { get; set; } = string.Empty;
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public string? SqlParametersJson { get; set; }
+    public int TimeoutSeconds { get; set; }
+    public string PublishStatus { get; set; } = string.Empty;
+    public bool IsCurrent { get; set; }
+    public string? Remark { get; set; }
+    public string? CreatedBy { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public string? PublishedBy { get; set; }
+    public DateTime? PublishedAt { get; set; }
+}
+
+/// <summary>SQL 校验入参。</summary>
+public sealed class KpiSqlValidateDto
+{
+    public string? SqlScript { get; set; }
+}
+
+/// <summary>SQL 校验结果。</summary>
+public sealed class KpiSqlValidateResultDto
+{
+    public bool Ok { get; set; }
+    public string ValidationStatus { get; set; } = string.Empty; // VALID / INVALID
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+    public List<string> ReferencedTables { get; set; } = new();
+}
+
+/// <summary>试算入参(服务端重读/重校验,不信任前端 tenant/权限)。</summary>
+public sealed class KpiSqlPreviewDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public int TimeoutSeconds { get; set; } = 20;
+    public DateTime? BizDate { get; set; }
+}
+
+/// <summary>试算结果(不写正式 KPI 值)。</summary>
+public sealed class KpiSqlPreviewResultDto
+{
+    public bool Ok { get; set; }
+    public string ValidationStatus { get; set; } = string.Empty;
+    public string MetricCode { get; set; } = string.Empty;
+    public string EngineType { get; set; } = "CONFIG_SQL";
+    public string? DataSourceCode { get; set; }
+    public string BizDate { get; set; } = string.Empty;
+    public long DurationMs { get; set; }
+    public int RowCount { get; set; }
+    public decimal? MetricValue { get; set; }
+    public decimal? NumeratorValue { get; set; }
+    public decimal? DenominatorValue { get; set; }
+    public string ResultStatus { get; set; } = string.Empty; // SUCCESS / NO_DATA / FAILED
+    public string? ResultMessage { get; set; }
+    public string? ExecutedParameters { get; set; }
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+}

+ 77 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiCalcConfig.cs

@@ -0,0 +1,77 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 计算配置(SQL 配置化引擎)。一 KPI 多版本,含发布状态机与审计。
+/// 独立于 ado_smart_ops_kpi_master(看板热路径),SQL 敏感配置单独承载。
+/// 同一 (TenantId, MetricCode) 仅一个 IsCurrent=1 的生效版本,由服务层事务保证。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_calc_config", "KPI 计算配置(SQL 配置化)")]
+[SugarIndex("uk_kpi_calc_cfg_metric_ver", nameof(MetricCode), OrderByType.Asc, nameof(TenantId), OrderByType.Asc, nameof(VersionNo), OrderByType.Asc, true)]
+public class AdoSmartOpsKpiCalcConfig : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标编码 如 S5_L1_001", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块 S1~S9", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "版本号(同 KPI 内递增)")]
+    public int VersionNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "计算引擎 LEGACY_CODE / CONFIG_SQL / LEGACY_TVF", Length = 20)]
+    public string CalcEngineType { get; set; } = "CONFIG_SQL";
+
+    [SugarColumn(ColumnDescription = "数据源编码(已登记数据源,禁连接串)", Length = 100, IsNullable = true)]
+    public string? DataSourceCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "只读 SQL 脚本", ColumnDataType = "text", IsNullable = true)]
+    public string? SqlScript { get; set; }
+
+    [SugarColumn(ColumnDescription = "参数声明 JSON(元数据,运行绑定由引擎注入)", ColumnDataType = "text", IsNullable = true)]
+    public string? SqlParametersJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "查询超时(秒),受系统上限约束")]
+    public int TimeoutSeconds { get; set; } = 30;
+
+    [SugarColumn(ColumnDescription = "发布状态 DRAFT / PUBLISHED / RETIRED", Length = 20)]
+    public string PublishStatus { get; set; } = "DRAFT";
+
+    [SugarColumn(ColumnDescription = "是否当前生效版本(同 KPI 唯一)")]
+    public bool IsCurrent { get; set; }
+
+    [SugarColumn(ColumnDescription = "备注", ColumnDataType = "text", IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建人", Length = 100, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreatedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新人", Length = 100, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新时间", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "发布人", Length = 100, IsNullable = true)]
+    public string? PublishedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "发布时间", IsNullable = true)]
+    public DateTime? PublishedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "停用人", Length = 100, IsNullable = true)]
+    public string? RetiredBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "停用时间", IsNullable = true)]
+    public DateTime? RetiredAt { get; set; }
+}

+ 87 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiCalcRunLog.cs

@@ -0,0 +1,87 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 计算 SQL 执行运行日志(单 KPI 粒度,含配置版本 + SQL 指纹 + 分子分母)。
+/// mdp_transform_run_log 是批次级、表达不了 per-KPI + config version,故独立此表。
+/// 只记 SqlHash + ConfigId,不落 SQL 明文(SQL 原文在受权限控制的配置表)。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_calc_run_log", "KPI 计算 SQL 运行日志")]
+[SugarIndex("uk_kpi_calc_run_metric_batch", nameof(MetricCode), OrderByType.Asc, nameof(BatchId), OrderByType.Asc, true)]
+[SugarIndex("idx_kpi_calc_run_metric_time", nameof(MetricCode), OrderByType.Asc, nameof(StartedAt), OrderByType.Desc, false)]
+public class AdoSmartOpsKpiCalcRunLog : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "批次 ID(关联 mdp_transform_run_log.batch_id)", Length = 100)]
+    public string BatchId { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块 S1~S9", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "指标编码", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "计算配置 ID(LEGACY 无配置时可空)", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ConfigId { get; set; }
+
+    [SugarColumn(ColumnDescription = "配置版本号", IsNullable = true)]
+    public int? VersionNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "引擎 LEGACY_CODE / CONFIG_SQL / LEGACY_TVF", Length = 20)]
+    public string EngineType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "数据源编码", Length = 100, IsNullable = true)]
+    public string? DataSourceCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "业务日期", ColumnDataType = "date")]
+    public DateTime BizDate { get; set; }
+
+    [SugarColumn(ColumnDescription = "开始时间")]
+    public DateTime StartedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "结束时间", IsNullable = true)]
+    public DateTime? FinishedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "耗时毫秒")]
+    public long DurationMs { get; set; }
+
+    [SugarColumn(ColumnDescription = "状态 SUCCESS / NO_DATA / FAILED", Length = 20)]
+    public string Status { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "返回行数")]
+    public int RowCount { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标值", ColumnDataType = "decimal(18,4)", IsNullable = true)]
+    public decimal? MetricValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "分子", ColumnDataType = "decimal(18,4)", IsNullable = true)]
+    public decimal? NumeratorValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "分母", ColumnDataType = "decimal(18,4)", IsNullable = true)]
+    public decimal? DenominatorValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "错误码", Length = 50, IsNullable = true)]
+    public string? ErrorCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "错误摘要", Length = 500, IsNullable = true)]
+    public string? ErrorMessage { get; set; }
+
+    [SugarColumn(ColumnDescription = "SQL 指纹 SHA256(不落明文)", Length = 64, IsNullable = true)]
+    public string? SqlHash { get; set; }
+
+    [SugarColumn(ColumnDescription = "参数快照 JSON", ColumnDataType = "text", IsNullable = true)]
+    public string? ParameterSnapshot { get; set; }
+
+    [SugarColumn(ColumnDescription = "触发类型 AUTO / MANUAL / PREVIEW", Length = 20)]
+    public string TriggerType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreateTime { get; set; }
+}

+ 143 - 281
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspBillService.cs

@@ -1,26 +1,21 @@
+using System.Linq;
 using Admin.NET.Plugin.AiDOP.FinishedWarehouse.Dto;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 
 namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 
 /// <summary>
-/// S7 FQC 成品质量:检验任务列表 / 检验单(结果)列表 只读 list 服务。
+/// S7 FQC 成品质量:检验任务列表 / 检验单(结果)列表 / 检验单详情 只读 list 服务。
 ///
-/// 数据源(均 aidopdev,只读):
-///   - task-list  :qms_fqcbj(37 列,归口自旧系统 dopdemo,空表待上游抽数)。yxj/FINSPECTSTATUS 原样透出,
-///                  rf_dictionary 优先级字典未接(CTO 拍板舍弃)。默认排序 FAPPLYTIME DESC, id DESC。
-///   - result-list:qms_qcpp_inspbill 主表;SAP 工单号经 (SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo
-///                  WHERE wo.Batch = a.sczld) 标量子查询取得(Batch 非唯一仅因 NULL;非空 Batch 现 1:1,
-///                  MIN 保确定性、杜绝行倍增)。默认排序 FINSPEENDDATE DESC, id DESC。
+/// 数据源(2026-07-30 起):DOP 数据中台标准层(CTO 拍板:可接受批次快照延迟、全迁 std):
+///   - task-list  :mdp_std_fqc_task(源 qms_fqcbj)
+///   - result-list:mdp_std_fqc_result(源 qms_qcpp_inspbill + WorkOrdMaster 富化 SAP 工单号)
+///   - detail     :mdp_std_fqc_result 头 + mdp_std_fqc_result_detail 明细(glid=源头 id;j1..j50 样本 → Samples[])
+///   三 std 由 FqcMdpSyncService 从源同步标准化而来,且已双源(dopdemorq status=0 就位不启用)。
 ///
-/// 归口说明(方案 A,复刻已闭环 S6 IPQC IpqcInspectionMdpSyncService.EnsureTablesAsync):
-///   qms_fqcbj 原仅存于旧系统 dopdemo(0 行)、aidopdev 无此表,且 dopdemo 非运行时连接。
-///   EnsureFqcTaskTableAsync 在 aidopdev 建同构空表(CREATE TABLE IF NOT EXISTS,DDL 例外、仅一次、零 DML),
-///   后续由上游一次性抽数填充,届时无需改代码即随查询流通。id 不设 AUTO_INCREMENT(抽数将携旧系统主键)。
-///
-/// 只读边界:纯 SELECT + 一次性 DDL 建空表;绝不 INSERT/UPDATE/DELETE 任何业务表、不写库存/仓储/生产状态、
-///   不接 ApprovalFlow、不做详情/子表(qms_qcpp_inspbillst 留下一批)/录入/审核/处置。
-///   WP0-D4:按 tenant_id 过滤。
+/// 只读边界:纯 SELECT + POST mdp/refresh 触发同步(RunFullAsync,只读源/仅写 mdp_*);
+///   绝不 INSERT/UPDATE/DELETE 业务表、不接 ApprovalFlow、不做录入/审核/处置。WP0-D4:按 tenant_id 过滤(std 保留源 tenant)。
+///   id 语义:list 返回源主键(source_row_id),detail 按 source_row_id/glid 关联,前端契约不变。
 /// </summary>
 [ApiDescriptionSettings(Order = 330, Description = "FQC检验单列表")]
 [Route("api/S7FqcInspBill")]
@@ -30,53 +25,58 @@ public class FqcInspBillService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly FqcMdpSyncService _fqcMdpSyncService;
 
-    /// <summary>归口空表建表守卫:每进程仅尝试一次 CREATE TABLE IF NOT EXISTS。</summary>
-    private static int _taskTableEnsured;
-
-    /// <summary>检验单明细子表归口空表建表守卫:每进程仅尝试一次。</summary>
-    private static int _detailTableEnsured;
-
-    public FqcInspBillService(ISqlSugarClient db, UserManager userManager)
+    public FqcInspBillService(ISqlSugarClient db, UserManager userManager, FqcMdpSyncService fqcMdpSyncService)
     {
         _db = db;
         _userManager = userManager;
+        _fqcMdpSyncService = fqcMdpSyncService;
     }
 
-    /// <summary>检验任务列表 排序白名单(前端 prop → 物理列,杜绝 orderBy 注入)。</summary>
+    /// <summary>检验任务列表 排序白名单(前端 prop → std 物理列,杜绝 orderBy 注入)。</summary>
     private static readonly Dictionary<string, string> TaskOrderWhitelist = new(StringComparer.OrdinalIgnoreCase)
     {
-        ["applyTime"] = "t.FAPPLYTIME",
-        ["productionBatchNo"] = "t.scph",
-        ["productionOrderNo"] = "t.sczld",
-        ["materialCode"] = "t.wlbm",
-        ["priority"] = "t.yxj",
-        ["inspectStartTime"] = "t.jykssj",
-        ["inspectFinishTime"] = "t.jywcsj",
-        ["inspectProgress"] = "t.FINSPECTSTATUS",
+        ["applyTime"] = "t.apply_time",
+        ["productionBatchNo"] = "t.production_batch_no",
+        ["productionOrderNo"] = "t.production_order_no",
+        ["materialCode"] = "t.material_code",
+        ["priority"] = "t.priority",
+        ["inspectStartTime"] = "t.inspect_start_time",
+        ["inspectFinishTime"] = "t.inspect_finish_time",
+        ["inspectProgress"] = "t.inspect_progress",
     };
 
-    /// <summary>检验单列表 排序白名单(前端 prop → 物理列)。</summary>
+    /// <summary>检验单列表 排序白名单(前端 prop → std 物理列)。</summary>
     private static readonly Dictionary<string, string> ResultOrderWhitelist = new(StringComparer.OrdinalIgnoreCase)
     {
-        ["inspectTime"] = "a.FINSPEENDDATE",
-        ["billNo"] = "a.FBILLNO",
-        ["productionBatchNo"] = "a.scph",
-        ["materialCode"] = "a.FMATERIALCFG",
-        ["inspector"] = "a.jyr",
-        ["inspectQty"] = "a.jysj",
-        ["productionOrderNo"] = "a.sczld",
+        ["inspectTime"] = "a.inspect_time",
+        ["billNo"] = "a.bill_no",
+        ["productionBatchNo"] = "a.production_batch_no",
+        ["materialCode"] = "a.material_code",
+        ["inspector"] = "a.inspector",
+        ["inspectQty"] = "a.inspect_qty",
+        ["productionOrderNo"] = "a.production_order_no",
     };
 
     /// <summary>
-    /// FQC 检验任务列表(只读分页查询,源 qms_fqcbj)。
+    /// FQC 数据中台同步刷新(RunFullAsync:只读源 qms_fqcbj/qms_qcpp_inspbill/qms_qcpp_inspbillst,仅写 mdp_*)。
+    /// </summary>
+    [DisplayName("FQC数据中台刷新")]
+    [HttpPost("mdp/refresh")]
+    public async Task<object> RefreshMdp(CancellationToken cancellationToken)
+    {
+        var r = await _fqcMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
+        return new { ok = true, batchId = r.BatchId, stageRows = r.StageRows, taskRows = r.TaskRows, resultRows = r.ResultRows, detailRows = r.DetailRows };
+    }
+
+    /// <summary>
+    /// FQC 检验任务列表(只读分页查询,读标准层 mdp_std_fqc_task)。
     /// </summary>
     [DisplayName("FQC检验任务列表")]
     [HttpGet("task-list")]
     public async Task<object> GetTaskList([FromQuery] FqcTaskListInput input)
     {
-        await EnsureFqcTaskTableAsync();
-
         var page = input.Page <= 0 ? 1 : input.Page;
         var pageSize = input.PageSize <= 0 ? 10 : (input.PageSize > 200 ? 200 : input.PageSize);
         var offset = (page - 1) * pageSize;
@@ -92,55 +92,55 @@ public class FqcInspBillService : IDynamicApiController, ITransient
             pars.Add(new SugarParameter($"@{p}", $"%{v.Trim()}%"));
         }
 
-        AddLike(input.ProductionOrderNo, "t.sczld", "ProductionOrderNo");
-        AddLike(input.ProductionBatchNo, "t.scph", "ProductionBatchNo");
-        AddLike(input.MaterialCode, "t.wlbm", "MaterialCode");
-        AddLike(input.Priority, "t.yxj", "Priority");
-        AddLike(input.Inspector, "t.jyfzr", "Inspector");
+        AddLike(input.ProductionOrderNo, "t.production_order_no", "ProductionOrderNo");
+        AddLike(input.ProductionBatchNo, "t.production_batch_no", "ProductionBatchNo");
+        AddLike(input.MaterialCode, "t.material_code", "MaterialCode");
+        AddLike(input.Priority, "t.priority", "Priority");
+        AddLike(input.Inspector, "t.inspector", "Inspector");
 
         if (!string.IsNullOrWhiteSpace(input.InspectProgress))
         {
-            where.Add("t.FINSPECTSTATUS = @InspectProgress");
+            where.Add("t.inspect_progress = @InspectProgress");
             pars.Add(new SugarParameter("@InspectProgress", input.InspectProgress.Trim()));
         }
         if (!string.IsNullOrWhiteSpace(input.ApplyTimeStart))
         {
-            where.Add("t.FAPPLYTIME >= @ApplyTimeStart");
+            where.Add("t.apply_time >= @ApplyTimeStart");
             pars.Add(new SugarParameter("@ApplyTimeStart", $"{input.ApplyTimeStart.Trim()} 00:00:00"));
         }
         if (!string.IsNullOrWhiteSpace(input.ApplyTimeEnd))
         {
-            where.Add("t.FAPPLYTIME <= @ApplyTimeEnd");
+            where.Add("t.apply_time <= @ApplyTimeEnd");
             pars.Add(new SugarParameter("@ApplyTimeEnd", $"{input.ApplyTimeEnd.Trim()} 23:59:59"));
         }
 
         var whereSql = string.Join(" AND ", where);
-        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, TaskOrderWhitelist, "t.FAPPLYTIME DESC, t.id DESC", "t.id");
+        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, TaskOrderWhitelist, "t.apply_time DESC, t.id DESC", "t.id");
 
-        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM qms_fqcbj t WHERE {whereSql}", pars);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM mdp_std_fqc_task t WHERE {whereSql}", pars);
 
         var list = await _db.Ado.SqlQueryAsync<FqcTaskListRow>(
             $"""
             SELECT
-                t.id            AS Id,
-                t.FBILLNO       AS BillNo,
-                t.sczld         AS ProductionOrderNo,
-                t.FORGID        AS ApplyOrgId,
-                t.FAPPLYTIME    AS ApplyTime,
-                t.wlbm          AS MaterialCode,
-                t.cpmc          AS ProductName,
-                t.cpxh          AS ProductModel,
-                t.scph          AS ProductionBatchNo,
-                t.sczldsl       AS OrderQty,
-                t.sl            AS Qty,
-                t.FAPPLYUSER    AS Applicant,
-                t.FCOMMENT      AS Remark,
-                t.yxj           AS Priority,
-                t.jykssj        AS InspectStartTime,
-                t.jywcsj        AS InspectFinishTime,
-                t.jyfzr         AS Inspector,
-                t.FINSPECTSTATUS AS InspectProgress
-            FROM qms_fqcbj t
+                CAST(t.source_row_id AS SIGNED) AS Id,
+                t.bill_no             AS BillNo,
+                t.production_order_no AS ProductionOrderNo,
+                t.apply_org_id        AS ApplyOrgId,
+                t.apply_time          AS ApplyTime,
+                t.material_code       AS MaterialCode,
+                t.product_name        AS ProductName,
+                t.product_model       AS ProductModel,
+                t.production_batch_no AS ProductionBatchNo,
+                t.order_qty           AS OrderQty,
+                t.qty                 AS Qty,
+                t.applicant           AS Applicant,
+                t.remark              AS Remark,
+                t.priority            AS Priority,
+                t.inspect_start_time  AS InspectStartTime,
+                t.inspect_finish_time AS InspectFinishTime,
+                t.inspector           AS Inspector,
+                t.inspect_progress    AS InspectProgress
+            FROM mdp_std_fqc_task t
             WHERE {whereSql}
             ORDER BY {orderSql}
             LIMIT {pageSize} OFFSET {offset}
@@ -151,7 +151,7 @@ public class FqcInspBillService : IDynamicApiController, ITransient
     }
 
     /// <summary>
-    /// FQC 检验单(结果)列表(只读分页查询,源 qms_qcpp_inspbill + WorkOrdMaster 取 SAP 工单号)。
+    /// FQC 检验单(结果)列表(只读分页查询,读标准层 mdp_std_fqc_result)。
     /// </summary>
     [DisplayName("FQC检验单结果列表")]
     [HttpGet("result-list")]
@@ -172,55 +172,55 @@ public class FqcInspBillService : IDynamicApiController, ITransient
             pars.Add(new SugarParameter($"@{p}", $"%{v.Trim()}%"));
         }
 
-        AddLike(input.BillNo, "a.FBILLNO", "BillNo");
-        AddLike(input.ProductionBatchNo, "a.scph", "ProductionBatchNo");
-        AddLike(input.MaterialCode, "a.FMATERIALCFG", "MaterialCode");
-        AddLike(input.MaterialName, "a.wlmc", "MaterialName");
-        AddLike(input.Inspector, "a.jyr", "Inspector");
-        AddLike(input.SourceBillNo, "a.lydjbh", "SourceBillNo");
-        AddLike(input.ProductionOrderNo, "a.sczld", "ProductionOrderNo");
+        AddLike(input.BillNo, "a.bill_no", "BillNo");
+        AddLike(input.ProductionBatchNo, "a.production_batch_no", "ProductionBatchNo");
+        AddLike(input.MaterialCode, "a.material_code", "MaterialCode");
+        AddLike(input.MaterialName, "a.material_name", "MaterialName");
+        AddLike(input.Inspector, "a.inspector", "Inspector");
+        AddLike(input.SourceBillNo, "a.source_bill_no", "SourceBillNo");
+        AddLike(input.ProductionOrderNo, "a.production_order_no", "ProductionOrderNo");
 
         if (!string.IsNullOrWhiteSpace(input.Judgment))
         {
-            where.Add("a.pd = @Judgment");
+            where.Add("a.judgment = @Judgment");
             pars.Add(new SugarParameter("@Judgment", input.Judgment.Trim()));
         }
         if (!string.IsNullOrWhiteSpace(input.InspectDateStart))
         {
-            where.Add("a.FINSPEENDDATE >= @InspectDateStart");
+            where.Add("a.inspect_time >= @InspectDateStart");
             pars.Add(new SugarParameter("@InspectDateStart", $"{input.InspectDateStart.Trim()} 00:00:00"));
         }
         if (!string.IsNullOrWhiteSpace(input.InspectDateEnd))
         {
-            where.Add("a.FINSPEENDDATE <= @InspectDateEnd");
+            where.Add("a.inspect_time <= @InspectDateEnd");
             pars.Add(new SugarParameter("@InspectDateEnd", $"{input.InspectDateEnd.Trim()} 23:59:59"));
         }
 
         var whereSql = string.Join(" AND ", where);
-        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, ResultOrderWhitelist, "a.FINSPEENDDATE DESC, a.id DESC", "a.id");
+        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, ResultOrderWhitelist, "a.inspect_time DESC, a.id DESC", "a.id");
 
-        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM qms_qcpp_inspbill a WHERE {whereSql}", pars);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM mdp_std_fqc_result a WHERE {whereSql}", pars);
 
         var list = await _db.Ado.SqlQueryAsync<FqcResultListRow>(
             $"""
             SELECT
-                a.id            AS Id,
-                a.FBILLNO       AS BillNo,
-                a.FINSPEENDDATE AS InspectTime,
-                a.scph          AS ProductionBatchNo,
-                a.FMATERIALCFG  AS MaterialCode,
-                a.wlmc          AS MaterialName,
-                a.ggxh          AS Spec,
-                a.jyr           AS Inspector,
-                a.jysj          AS InspectQty,
-                a.lydjbh        AS SourceBillNo,
-                a.sczld         AS ProductionOrderNo,
-                a.sczldsl       AS OrderQty,
-                (SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo WHERE wo.Batch = a.sczld) AS SapWorkOrderNo,
-                a.pd            AS Judgment,
-                a.hgsl          AS QualifiedQty,
-                a.bhgsl         AS UnqualifiedQty
-            FROM qms_qcpp_inspbill a
+                CAST(a.source_row_id AS SIGNED) AS Id,
+                a.bill_no             AS BillNo,
+                a.inspect_time        AS InspectTime,
+                a.production_batch_no AS ProductionBatchNo,
+                a.material_code       AS MaterialCode,
+                a.material_name       AS MaterialName,
+                a.spec                AS Spec,
+                a.inspector           AS Inspector,
+                a.inspect_qty         AS InspectQty,
+                a.source_bill_no      AS SourceBillNo,
+                a.production_order_no AS ProductionOrderNo,
+                a.order_qty           AS OrderQty,
+                a.sap_work_order_no   AS SapWorkOrderNo,
+                a.judgment            AS Judgment,
+                a.qualified_qty       AS QualifiedQty,
+                a.unqualified_qty     AS UnqualifiedQty
+            FROM mdp_std_fqc_result a
             WHERE {whereSql}
             ORDER BY {orderSql}
             LIMIT {pageSize} OFFSET {offset}
@@ -231,75 +231,68 @@ public class FqcInspBillService : IDynamicApiController, ITransient
     }
 
     /// <summary>
-    /// FQC 检验单只读详情(源 qms_qcpp_inspbill 头 + qms_qcpp_inspbillst 明细,glid=id)。
-    ///
-    /// 契约对齐 S6 IPQC:id&lt;=0 或未命中返回 null(HTTP 200/204);扁平头 + Lines[]。
-    /// 明细首查前 EnsureFqcDetailTableAsync 幂等建归口空表;样本 j1..j50 收敛为 Samples[](末尾空值裁剪)。
-    /// 仅 SELECT:无任何写入。按 tenant_id 过滤。
+    /// FQC 检验单只读详情(读标准层 mdp_std_fqc_result 头 + mdp_std_fqc_result_detail 明细,glid=源头 id)。
+    /// 契约对齐 S6 IPQC:id&lt;=0 或未命中返回 null(HTTP 200/204);扁平头 + Lines[];样本 j1..j50 收敛 Samples[]。仅 SELECT。
     /// </summary>
     [DisplayName("FQC检验单详情")]
     [HttpGet("detail")]
     public async Task<FqcInspBillDetailDto?> GetDetail([FromQuery] long id, [FromQuery] long? tenantId)
     {
         if (id <= 0) return null;
-        await EnsureFqcDetailTableAsync();
 
         var tid = QmsTenantScope.Resolve(tenantId, _userManager.TenantId);
         var head = (await _db.Ado.SqlQueryAsync<FqcInspBillDetailDto>(
             """
             SELECT
-                a.id            AS Id,
-                a.FBILLNO       AS BillNo,
-                a.FINSPEENDDATE AS InspectTime,
-                a.lydjbh        AS SourceBillNo,
-                a.jyr           AS Inspector,
-                a.pd            AS Judgment,
-                a.FMATERIALCFG  AS MaterialCode,
-                a.wlmc          AS MaterialName,
-                a.ggxh          AS Spec,
-                a.scph          AS ProductionBatchNo,
-                a.sczld         AS ProductionOrderNo,
-                a.sczldsl       AS OrderQty,
-                (SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo WHERE wo.Batch = a.sczld) AS SapWorkOrderNo,
-                a.jysj          AS InspectQty,
-                a.hgsl          AS QualifiedQty,
-                a.bhgsl         AS UnqualifiedQty,
-                a.jfbh          AS FireNo,
-                a.jgbh          AS InspectSpecNo,
-                a.jgbb          AS InspectSpecVersion
-            FROM qms_qcpp_inspbill a
-            WHERE a.id = @id AND a.tenant_id = @TenantFilter
+                CAST(a.source_row_id AS SIGNED) AS Id,
+                a.bill_no             AS BillNo,
+                a.inspect_time        AS InspectTime,
+                a.source_bill_no      AS SourceBillNo,
+                a.inspector           AS Inspector,
+                a.judgment            AS Judgment,
+                a.material_code       AS MaterialCode,
+                a.material_name       AS MaterialName,
+                a.spec                AS Spec,
+                a.production_batch_no AS ProductionBatchNo,
+                a.production_order_no AS ProductionOrderNo,
+                a.order_qty           AS OrderQty,
+                a.sap_work_order_no   AS SapWorkOrderNo,
+                a.inspect_qty         AS InspectQty,
+                a.qualified_qty       AS QualifiedQty,
+                a.unqualified_qty     AS UnqualifiedQty,
+                a.fire_no             AS FireNo,
+                a.inspect_spec_no     AS InspectSpecNo,
+                a.inspect_spec_version AS InspectSpecVersion
+            FROM mdp_std_fqc_result a
+            WHERE a.source_row_id = @id AND a.tenant_id = @TenantFilter
             LIMIT 1
             """,
             new List<SugarParameter> { new("@id", id), new("@TenantFilter", tid) })).FirstOrDefault();
 
         if (head == null || head.Id <= 0) return null;
 
+        var jSelect = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"st.j{i}"));
         var lineRows = await _db.Ado.SqlQueryAsync<FqcInspBillDetailLineRow>(
-            """
+            $"""
             SELECT
-                st.id   AS Id,
-                st.xh   AS SeqNo,
-                st.jyxm AS InspectionItem,
-                st.jsbz AS AcceptStandard,
-                st.jsyq AS TechRequirement,
-                st.cyfa AS SamplingPlan,
-                st.ybl  AS SampleQty,
-                st.sx   AS UpperLimit,
-                st.xx   AS LowerLimit,
-                st.SizeSpecifications AS SizeSpecification,
-                st.pd   AS Judgment,
-                st.NonNumericalTypeOK AS NonNumericOk,
-                st.NonNumericalTypeNG AS NonNumericNg,
-                st.bz   AS Remark,
-                st.j1,st.j2,st.j3,st.j4,st.j5,st.j6,st.j7,st.j8,st.j9,st.j10,
-                st.j11,st.j12,st.j13,st.j14,st.j15,st.j16,st.j17,st.j18,st.j19,st.j20,
-                st.j21,st.j22,st.j23,st.j24,st.j25,st.j26,st.j27,st.j28,st.j29,st.j30,
-                st.j31,st.j32,st.j33,st.j34,st.j35,st.j36,st.j37,st.j38,st.j39,st.j40,
-                st.j41,st.j42,st.j43,st.j44,st.j45,st.j46,st.j47,st.j48,st.j49,st.j50
-            FROM qms_qcpp_inspbillst st
+                st.id                 AS Id,
+                st.seq_no             AS SeqNo,
+                st.inspection_item    AS InspectionItem,
+                st.accept_standard    AS AcceptStandard,
+                st.tech_requirement   AS TechRequirement,
+                st.sampling_plan      AS SamplingPlan,
+                st.sample_qty         AS SampleQty,
+                st.upper_limit        AS UpperLimit,
+                st.lower_limit        AS LowerLimit,
+                st.size_specification AS SizeSpecification,
+                st.judgment           AS Judgment,
+                st.non_numeric_ok     AS NonNumericOk,
+                st.non_numeric_ng     AS NonNumericNg,
+                st.remark             AS Remark,
+                {jSelect}
+            FROM mdp_std_fqc_result_detail st
             WHERE st.glid = @id AND st.tenant_id = @TenantFilter
-            ORDER BY st.xh, st.id
+            ORDER BY st.seq_no, st.id
             """,
             new List<SugarParameter> { new("@id", id), new("@TenantFilter", tid) });
 
@@ -319,135 +312,4 @@ public class FqcInspBillService : IDynamicApiController, ITransient
         }
         return fallback;
     }
-
-    /// <summary>
-    /// 归口空表建表(方案 A,DDL 例外、每进程一次、零 DML):
-    /// aidopdev 无 qms_fqcbj 时 CREATE TABLE IF NOT EXISTS 建同构 dopdemo 的 37 列空表,待上游抽数填充。
-    /// 仅建表,不写任何数据行。
-    /// </summary>
-    private async Task EnsureFqcTaskTableAsync()
-    {
-        if (Interlocked.Exchange(ref _taskTableEnsured, 1) == 1) return;
-        try
-        {
-            await _db.Ado.ExecuteCommandAsync(
-                """
-                CREATE TABLE IF NOT EXISTS qms_fqcbj (
-                    id BIGINT NOT NULL PRIMARY KEY COMMENT '主表id',
-                    tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
-                    FBILLNO VARCHAR(80) NULL COMMENT '单据编号',
-                    FBILLSTATUS VARCHAR(50) NULL COMMENT '单据状态',
-                    FCREATORID BIGINT NULL,
-                    FMODIFIERID BIGINT NULL,
-                    FAUDITORID BIGINT NULL,
-                    FAUDITDATE DATETIME NULL,
-                    FMODIFYTIME DATETIME NULL,
-                    FCREATETIME DATETIME NULL,
-                    FORGID BIGINT NULL COMMENT '申请组织',
-                    FQUALITYORG BIGINT NULL,
-                    FAPPLYUSER VARCHAR(50) NULL COMMENT '申请人',
-                    FAPPLYTIME DATETIME NULL COMMENT '申请时间',
-                    FBILLTYPE VARCHAR(50) NULL COMMENT '单据类型',
-                    FBIZTYPE BIGINT NULL COMMENT '业务类型',
-                    FINSPECORGID BIGINT NULL,
-                    FCOMMENT TEXT NULL COMMENT '备注',
-                    FAUTHORIZEOBJID BIGINT NULL,
-                    FINTERFACEID VARCHAR(50) NULL,
-                    cpmc VARCHAR(255) NULL COMMENT '产品名称',
-                    cpxh VARCHAR(255) NULL COMMENT '产品型号',
-                    scph VARCHAR(255) NULL COMMENT '生产批号',
-                    sl DECIMAL(10,0) NULL COMMENT '数量',
-                    jfbh TEXT NULL COMMENT '击发编号',
-                    wlbm VARCHAR(255) NULL COMMENT '物料编码',
-                    jyfzr VARCHAR(255) NULL COMMENT '检验负责人',
-                    yxj VARCHAR(255) NULL COMMENT '检验优先级',
-                    jykssj DATETIME NULL COMMENT '检验开始时间',
-                    jywcsj DATETIME NULL COMMENT '检验完成时间',
-                    mjpc VARCHAR(255) NULL,
-                    tsyq VARCHAR(255) NULL,
-                    FINSPECTSTATUS VARCHAR(255) NULL COMMENT '检验进度',
-                    sczld VARCHAR(255) NULL COMMENT '生产指令单',
-                    sczldsl DECIMAL(10,0) NULL COMMENT '指令单数量',
-                    lydjbh VARCHAR(100) NULL COMMENT '来源单据编号',
-                    ISscrap INT NULL,
-                    CirculationCard TEXT NULL
-                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验任务(报检) 归口自旧系统dopdemo,空表待上游抽数'
-                """);
-        }
-        catch
-        {
-            // 建表失败(如权限/并发)不吞成静默通过:复位守卫,让后续调用可再尝试,并向上抛出真实错误。
-            Interlocked.Exchange(ref _taskTableEnsured, 0);
-            throw;
-        }
-    }
-
-    /// <summary>
-    /// 检验单明细子表归口空表建表(方案 A,DDL 例外、每进程一次、零 DML):
-    /// aidopdev 无 qms_qcpp_inspbillst 时 CREATE TABLE IF NOT EXISTS 建同构 dopdemo 的 82 列空表(glid→主表 id),
-    /// 待上游抽数填充。样本列 j1..j50 一并建。仅建表,不写任何数据行。
-    /// 字符集忠实复刻 dopdemo:13 个列用 utf8mb3(3 字节),其余表默认 utf8mb4;否则 ~68 个 VARCHAR(255) 全 utf8mb4
-    /// 会超 MySQL 单行 65535 字节上限(Row size too large)。join 键 glid/id 为 bigint,字符集不影响。
-    /// </summary>
-    private async Task EnsureFqcDetailTableAsync()
-    {
-        if (Interlocked.Exchange(ref _detailTableEnsured, 1) == 1) return;
-        try
-        {
-            await _db.Ado.ExecuteCommandAsync(
-                """
-                CREATE TABLE IF NOT EXISTS qms_qcpp_inspbillst (
-                    id BIGINT NOT NULL PRIMARY KEY COMMENT '子表id',
-                    tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
-                    glid BIGINT NULL COMMENT '关联id(=qms_qcpp_inspbill.id)',
-                    jyxm VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '检验项目',
-                    sx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '上限',
-                    xx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '下限',
-                    bz VARCHAR(255) NULL COMMENT '备注',
-                    czf VARCHAR(255) NULL,
-                    j1 VARCHAR(255) NULL, j2 VARCHAR(255) NULL, j3 VARCHAR(255) NULL, j4 VARCHAR(255) NULL, j5 VARCHAR(255) NULL,
-                    j6 VARCHAR(255) NULL, j7 VARCHAR(255) NULL, j8 VARCHAR(255) NULL, j9 VARCHAR(255) NULL, j10 VARCHAR(255) NULL,
-                    j11 VARCHAR(255) NULL, j12 VARCHAR(255) NULL, j13 VARCHAR(255) NULL, j14 VARCHAR(255) NULL, j15 VARCHAR(255) NULL,
-                    j16 VARCHAR(255) NULL, j17 VARCHAR(255) NULL, j18 VARCHAR(255) NULL, j19 VARCHAR(255) NULL, j20 VARCHAR(255) NULL,
-                    j21 VARCHAR(255) NULL, j22 VARCHAR(255) NULL, j23 VARCHAR(255) NULL, j24 VARCHAR(255) NULL, j25 VARCHAR(255) NULL,
-                    j26 VARCHAR(255) NULL, j27 VARCHAR(255) NULL, j28 VARCHAR(255) NULL, j29 VARCHAR(255) NULL, j30 VARCHAR(255) NULL,
-                    j31 VARCHAR(255) NULL, j32 VARCHAR(255) NULL, j33 VARCHAR(255) NULL, j34 VARCHAR(255) NULL, j35 VARCHAR(255) NULL,
-                    j36 VARCHAR(255) NULL, j37 VARCHAR(255) NULL, j38 VARCHAR(255) NULL, j39 VARCHAR(255) NULL, j40 VARCHAR(255) NULL,
-                    j41 VARCHAR(255) NULL, j42 VARCHAR(255) NULL, j43 VARCHAR(255) NULL, j44 VARCHAR(255) NULL, j45 VARCHAR(255) NULL,
-                    j46 VARCHAR(255) NULL, j47 VARCHAR(255) NULL, j48 VARCHAR(255) NULL, j49 VARCHAR(255) NULL, j50 VARCHAR(255) NULL,
-                    pd VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '判定',
-                    ybl VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '样本量',
-                    txfl BIGINT NULL COMMENT '指标分类',
-                    bztk TEXT NULL,
-                    jsyq TEXT NULL COMMENT '技术要求',
-                    jyyqsbgj TEXT NULL COMMENT '检验仪器设备工具',
-                    jyff TEXT NULL,
-                    jytk TEXT NULL,
-                    jsbz TEXT NULL COMMENT '接收标准',
-                    cyfa TEXT NULL COMMENT '抽样方案',
-                    jl TEXT NULL,
-                    jlqjbh VARCHAR(255) NULL COMMENT '计量器具编号',
-                    jlbh VARCHAR(255) NULL COMMENT '计量器具编号',
-                    jf1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发1',
-                    jf2 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发2',
-                    jf3 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发3',
-                    jf4 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发4',
-                    jf5 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发5',
-                    NonNumericalTypeOK BIGINT NULL COMMENT '非数值OK',
-                    NonNumericalTypeNG BIGINT NULL COMMENT '非数值NG',
-                    ThresholdSwitch VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '自动判定按钮',
-                    ThresholdSwitchInt BIGINT NULL COMMENT '自动判定按钮',
-                    SizeSpecifications VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '规范标准',
-                    ybl1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
-                    xh INT NULL COMMENT '序号',
-                    KEY idx_qms_qcpp_inspbillst_glid (glid)
-                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验单明细(品检项目) 归口自旧系统dopdemo,空表待上游抽数'
-                """);
-        }
-        catch
-        {
-            Interlocked.Exchange(ref _detailTableEnsured, 0);
-            throw;
-        }
-    }
 }

+ 615 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcMdpSyncService.cs

@@ -0,0 +1,615 @@
+using System.Linq;
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7 FQC 成品质量 数据中台只读同步转换服务(DOP 内部,独立于 KPI 管线)。
+///
+/// B-①/C 迁通用管线(CTO 拍板:可接受批次快照延迟、全迁 std):
+///   源(qms_fqcbj 任务 / qms_qcpp_inspbill 结果头 / qms_qcpp_inspbillst 明细) 经执行器灌 mdp_stg_fqc_pull(三表共表,source_table 区分)
+///   → transform 读 pull stg(MdpJsonSql 跨源类型兼容)→ mdp_std_fqc_task / mdp_std_fqc_result / mdp_std_fqc_result_detail。
+///   result 富化 SAP 工单号:本库 WorkOrdMaster 相关子查询 MIN(WorkOrd) WHERE Batch=sczld(维表本库,不入 stg)。
+///   result_detail 回填 std_result_id:glid → mdp_std_fqc_result.source_row_id。样本 j1..j50 逐列落 std(utf8mb3 控行宽)。
+///
+/// 双源:本库实体 S7_FQC_TASK/RESULT/RESULT_DETAIL(AIDOPDEV_MYSQL, status=1);
+///   dopdemorq 第二源 *_SQLSERVER(DOPDEMORQ_SQLSERVER, status=0 就位不启用)。qms_* 无 UpdateTime → FULL。
+///
+/// 约束:只读源/贴源,仅写 mdp_stg_fqc_pull / mdp_std_fqc_*;绝不 INSERT/UPDATE/DELETE 业务源表;不接 ApprovalFlow、不做录入/审核/处置。
+///   tenant_id 从 raw_data 保留(源含该列 → std 租户不回归;SQL Server 源无此列 → 回落入站上下文)。
+///   源为 0 行时转换成功完成、处理数 0,不报错。切源 FULL Replace 三表各一次单事务、仅按 tenant、Pull 成功后才 destructive。
+/// </summary>
+public class FqcMdpSyncService : ITransient
+{
+    private const string JobCode = "S7_FQC_MDP_SYNC";
+    private const string InboundTaskEntityCode = "S7_FQC_TASK";
+    private const string InboundResultEntityCode = "S7_FQC_RESULT";
+    private const string InboundDetailEntityCode = "S7_FQC_RESULT_DETAIL";
+
+    private const string SqlServerSourceCode = "DOPDEMORQ_SQLSERVER";
+    private const string SqlServerTaskEntityCode = "S7_FQC_TASK_SQLSERVER";
+    private const string SqlServerResultEntityCode = "S7_FQC_RESULT_SQLSERVER";
+    private const string SqlServerDetailEntityCode = "S7_FQC_RESULT_DETAIL_SQLSERVER";
+
+    private const string StdTaskTable = "mdp_std_fqc_task";
+    private const string StdResultTable = "mdp_std_fqc_result";
+    private const string StdResultDetailTable = "mdp_std_fqc_result_detail";
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpSourcePullDispatcher _pullDispatcher;
+
+    public FqcMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    {
+        _db = db;
+        _pullDispatcher = pullDispatcher;
+    }
+
+    /// <summary>全量:本地 DB 执行器灌 pull stg(任务/结果/明细) → 标准层三表(读 stg)。</summary>
+    public async Task<FqcMdpSyncResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        var batchId = $"S7_FQC_FULL_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
+        var result = new FqcMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            var pullCtx = new MdpPullContext
+            {
+                TenantId = 0,
+                FullRefresh = true,
+                TaskCode = "S7_FQC_INBOUND",
+                BatchId = $"{batchId}_PULL"
+            };
+            var pull = await PopulateStgAsync(pullCtx, cancellationToken);
+            result.StageRows = pull.RowsWritten;
+            result.TaskRows = await TransformTaskStandardAsync(batchId, now);
+            result.ResultRows = await TransformResultStandardAsync(batchId, now);
+            result.DetailRows = await TransformResultDetailStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    /// <summary>双模式入站:执行器抽三表 → pull stg,再跑标准层三表(读 stg)。</summary>
+    public async Task<FqcInboundResult> RunInboundAsync(
+        long tenantId = 0,
+        bool fullRefresh = false,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = fullRefresh,
+            TaskCode = "S7_FQC_INBOUND",
+            BatchId = $"S7_FQC_IN_{now:yyyyMMddHHmmss}"
+        };
+        var pull = await PopulateStgAsync(pullCtx, cancellationToken);
+
+        var batchId = $"S7_FQC_STD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "INBOUND");
+        var result = new FqcMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.TaskRows = await TransformTaskStandardAsync(batchId, now);
+            result.ResultRows = await TransformResultStandardAsync(batchId, now);
+            result.DetailRows = await TransformResultDetailStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new FqcInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = pull.RowsPulled,
+            RowsWrittenStg = pull.RowsWritten,
+            NewCursor = pull.NewCursor,
+            TransformBatchId = batchId,
+            TaskRows = result.TaskRows,
+            ResultRows = result.ResultRows,
+            DetailRows = result.DetailRows
+        };
+    }
+
+    /// <summary>
+    /// 双源切换 FULL Replace(三表):从指定 DB 源(默认 DOPDEMORQ_SQLSERVER)PullAll 全量灌 pull stg,
+    /// 成功后 task/result/result_detail 各一次单事务 FULL 重建(按 tenant 精确隔离)。
+    /// 顺序:result 先(写 std 结果,供明细回填 std_result_id),再 result_detail;task 独立。
+    /// SQLSERVER 实体默认 status=0(就位不启用);dopdemorq 三表当前空,未实际执行 destructive 切换。
+    /// </summary>
+    public async Task<FqcInboundResult> RunSourceSwitchFullAsync(
+        string sourceCode = SqlServerSourceCode,
+        string taskEntityCode = SqlServerTaskEntityCode,
+        string resultEntityCode = SqlServerResultEntityCode,
+        string detailEntityCode = SqlServerDetailEntityCode,
+        long tenantId = 0,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = true,
+            TaskCode = "S7_FQC_INBOUND",
+            BatchId = $"S7_FQC_SW_{now:yyyyMMddHHmmss}"
+        };
+        var task = await _pullDispatcher.PullAllByEntityCodeAsync(taskEntityCode, pullCtx, cancellationToken);
+        var res = await _pullDispatcher.PullAllByEntityCodeAsync(resultEntityCode, pullCtx, cancellationToken);
+        var det = await _pullDispatcher.PullAllByEntityCodeAsync(detailEntityCode, pullCtx, cancellationToken);
+
+        var batchId = $"S7_FQC_SWSTD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "SOURCE_SWITCH");
+        var result = new FqcMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.TaskRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdTaskTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformTaskStandardAsync(batchId, now, sourceCode), cancellationToken);
+            result.ResultRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdResultTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformResultStandardAsync(batchId, now, sourceCode), cancellationToken);
+            result.DetailRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdResultDetailTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformResultDetailStandardAsync(batchId, now, sourceCode), cancellationToken);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new FqcInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = task.RowsPulled + res.RowsPulled + det.RowsPulled,
+            RowsWrittenStg = task.RowsWritten + res.RowsWritten + det.RowsWritten,
+            NewCursor = det.NewCursor ?? res.NewCursor ?? task.NewCursor,
+            TransformBatchId = batchId,
+            TaskRows = result.TaskRows,
+            ResultRows = result.ResultRows,
+            DetailRows = result.DetailRows
+        };
+    }
+
+    private async Task<(int RowsPulled, int RowsWritten, string? NewCursor)> PopulateStgAsync(
+        MdpPullContext pullCtx, CancellationToken cancellationToken)
+    {
+        var task = await _pullDispatcher.PullByEntityCodeAsync(InboundTaskEntityCode, pullCtx, cancellationToken);
+        var res = await _pullDispatcher.PullByEntityCodeAsync(InboundResultEntityCode, pullCtx, cancellationToken);
+        var det = await _pullDispatcher.PullByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
+        return (
+            task.RowsPulled + res.RowsPulled + det.RowsPulled,
+            task.RowsWritten + res.RowsWritten + det.RowsWritten,
+            det.NewCursor ?? res.NewCursor ?? task.NewCursor);
+    }
+
+    private async Task EnsurePullStgTableAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_stg_fqc_pull (
+                id BIGINT PRIMARY KEY AUTO_INCREMENT,
+                tenant_id BIGINT NOT NULL,
+                source_system VARCHAR(50) NULL,
+                source_table VARCHAR(200),
+                source_row_id VARCHAR(200),
+                source_biz_key VARCHAR(300) NULL,
+                raw_data JSON,
+                sync_batch_id VARCHAR(100),
+                sync_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+                process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+                process_message VARCHAR(500) NULL,
+                create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_source_key (source_system, source_table, source_biz_key),
+                KEY idx_batch (sync_batch_id),
+                KEY idx_src (source_table, source_row_id),
+                KEY idx_tenant (tenant_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S7 FQC 执行器贴源层(任务/结果/明细共表,source_table 区分)'
+            """);
+    }
+
+    /// <summary>
+    /// 防御式建表(幂等):归口源空表(qms_fqcbj/qms_qcpp_inspbillst,方案 A,供执行器贴源) + 任务 std + 结果 std + 明细 std(j1..j50 utf8mb3 控行宽)。
+    /// qms_qcpp_inspbill(结果头) 由既有 FQC 批次建,沿用原 Service 假设已存在。
+    /// </summary>
+    private async Task EnsureTablesAsync()
+    {
+        // 归口源空表(方案 A,DDL 例外、幂等、零 DML):同构自旧系统 dopdemo,待上游抽数;供执行器 pull 贴源。
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS qms_fqcbj (
+                id BIGINT NOT NULL PRIMARY KEY COMMENT '主表id',
+                tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
+                FBILLNO VARCHAR(80) NULL COMMENT '单据编号',
+                FBILLSTATUS VARCHAR(50) NULL COMMENT '单据状态',
+                FCREATORID BIGINT NULL, FMODIFIERID BIGINT NULL, FAUDITORID BIGINT NULL,
+                FAUDITDATE DATETIME NULL, FMODIFYTIME DATETIME NULL, FCREATETIME DATETIME NULL,
+                FORGID BIGINT NULL COMMENT '申请组织', FQUALITYORG BIGINT NULL,
+                FAPPLYUSER VARCHAR(50) NULL COMMENT '申请人', FAPPLYTIME DATETIME NULL COMMENT '申请时间',
+                FBILLTYPE VARCHAR(50) NULL COMMENT '单据类型', FBIZTYPE BIGINT NULL COMMENT '业务类型',
+                FINSPECORGID BIGINT NULL, FCOMMENT TEXT NULL COMMENT '备注', FAUTHORIZEOBJID BIGINT NULL, FINTERFACEID VARCHAR(50) NULL,
+                cpmc VARCHAR(255) NULL COMMENT '产品名称', cpxh VARCHAR(255) NULL COMMENT '产品型号',
+                scph VARCHAR(255) NULL COMMENT '生产批号', sl DECIMAL(10,0) NULL COMMENT '数量',
+                jfbh TEXT NULL COMMENT '击发编号', wlbm VARCHAR(255) NULL COMMENT '物料编码',
+                jyfzr VARCHAR(255) NULL COMMENT '检验负责人', yxj VARCHAR(255) NULL COMMENT '检验优先级',
+                jykssj DATETIME NULL COMMENT '检验开始时间', jywcsj DATETIME NULL COMMENT '检验完成时间',
+                mjpc VARCHAR(255) NULL, tsyq VARCHAR(255) NULL,
+                FINSPECTSTATUS VARCHAR(255) NULL COMMENT '检验进度', sczld VARCHAR(255) NULL COMMENT '生产指令单',
+                sczldsl DECIMAL(10,0) NULL COMMENT '指令单数量', lydjbh VARCHAR(100) NULL COMMENT '来源单据编号',
+                ISscrap INT NULL, CirculationCard TEXT NULL
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验任务(报检) 归口自旧系统dopdemo,空表待上游抽数'
+            """);
+        var jGkDdl = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"j{i} VARCHAR(255) NULL"));
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            CREATE TABLE IF NOT EXISTS qms_qcpp_inspbillst (
+                id BIGINT NOT NULL PRIMARY KEY COMMENT '子表id',
+                tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
+                glid BIGINT NULL COMMENT '关联id(=qms_qcpp_inspbill.id)',
+                jyxm VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '检验项目',
+                sx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '上限',
+                xx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '下限',
+                bz VARCHAR(255) NULL COMMENT '备注', czf VARCHAR(255) NULL,
+                {jGkDdl},
+                pd VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '判定',
+                ybl VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '样本量',
+                txfl BIGINT NULL COMMENT '指标分类', bztk TEXT NULL, jsyq TEXT NULL COMMENT '技术要求',
+                jyyqsbgj TEXT NULL COMMENT '检验仪器设备工具', jyff TEXT NULL, jytk TEXT NULL,
+                jsbz TEXT NULL COMMENT '接收标准', cyfa TEXT NULL COMMENT '抽样方案', jl TEXT NULL,
+                jlqjbh VARCHAR(255) NULL COMMENT '计量器具编号', jlbh VARCHAR(255) NULL COMMENT '计量器具编号',
+                jf1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发1',
+                jf2 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发2',
+                jf3 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发3',
+                jf4 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发4',
+                jf5 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发5',
+                NonNumericalTypeOK BIGINT NULL COMMENT '非数值OK', NonNumericalTypeNG BIGINT NULL COMMENT '非数值NG',
+                ThresholdSwitch VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '自动判定按钮',
+                ThresholdSwitchInt BIGINT NULL COMMENT '自动判定按钮',
+                SizeSpecifications VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '规范标准',
+                ybl1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                xh INT NULL COMMENT '序号',
+                KEY idx_qms_qcpp_inspbillst_glid (glid)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验单明细(品检项目) 归口自旧系统dopdemo,空表待上游抽数'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_std_fqc_task (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                bill_no VARCHAR(80) NULL,
+                production_order_no VARCHAR(255) NULL,
+                apply_org_id BIGINT NULL,
+                apply_time DATETIME NULL,
+                material_code VARCHAR(255) NULL,
+                product_name VARCHAR(255) NULL,
+                product_model VARCHAR(255) NULL,
+                production_batch_no VARCHAR(255) NULL,
+                order_qty DECIMAL(18,6) NULL,
+                qty DECIMAL(18,6) NULL,
+                applicant VARCHAR(50) NULL,
+                remark TEXT NULL,
+                priority VARCHAR(255) NULL,
+                inspect_start_time DATETIME NULL,
+                inspect_finish_time DATETIME NULL,
+                inspector VARCHAR(255) NULL,
+                inspect_progress VARCHAR(255) NULL,
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_fqc_task (tenant_id, source_row_id),
+                KEY idx_mdp_std_fqc_task_bill (tenant_id, bill_no),
+                KEY idx_mdp_std_fqc_task_apply (tenant_id, apply_time)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S7 FQC 检验任务标准层'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_std_fqc_result (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                bill_no VARCHAR(80) NULL,
+                inspect_time DATETIME NULL,
+                production_batch_no VARCHAR(255) NULL,
+                material_code VARCHAR(255) NULL,
+                material_name VARCHAR(255) NULL,
+                spec VARCHAR(255) NULL,
+                inspector VARCHAR(255) NULL,
+                inspect_qty DECIMAL(18,6) NULL,
+                source_bill_no VARCHAR(100) NULL,
+                production_order_no VARCHAR(255) NULL,
+                order_qty DECIMAL(18,6) NULL,
+                sap_work_order_no VARCHAR(64) NULL,
+                judgment VARCHAR(32) NULL,
+                qualified_qty DECIMAL(18,6) NULL,
+                unqualified_qty DECIMAL(18,6) NULL,
+                fire_no TEXT NULL,
+                inspect_spec_no VARCHAR(255) NULL,
+                inspect_spec_version VARCHAR(255) NULL,
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_fqc_result (tenant_id, source_row_id),
+                KEY idx_mdp_std_fqc_result_bill (tenant_id, bill_no),
+                KEY idx_mdp_std_fqc_result_inspect (tenant_id, inspect_time)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S7 FQC 检验单(结果)标准层'
+            """);
+        var jDdl = string.Join(",\n                ", Enumerable.Range(1, 50).Select(i =>
+            $"j{i} VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL"));
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            CREATE TABLE IF NOT EXISTS mdp_std_fqc_result_detail (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                std_result_id BIGINT NULL,
+                glid VARCHAR(100) NULL,
+                seq_no INT NULL,
+                inspection_item TEXT NULL,
+                accept_standard TEXT NULL,
+                tech_requirement TEXT NULL,
+                sampling_plan TEXT NULL,
+                sample_qty VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                upper_limit VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                lower_limit VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                size_specification VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                judgment VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                non_numeric_ok BIGINT NULL,
+                non_numeric_ng BIGINT NULL,
+                remark TEXT NULL,
+                {jDdl},
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_fqc_result_dtl (tenant_id, source_row_id),
+                KEY idx_mdp_std_fqc_result_dtl_head (tenant_id, glid),
+                KEY idx_mdp_std_fqc_result_dtl_stdres (std_result_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S7 FQC 检验单明细标准层(j1-j50样本)'
+            """);
+    }
+
+    /// <summary>标准化任务:pull stg(qms_fqcbj) → mdp_std_fqc_task。返回处理行数。</summary>
+    private async Task<int> TransformTaskStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    {
+        var srcClause = sourceSystem == null ? "" : " AND t.source_system=@Src";
+        var tTenant = $"IFNULL({MdpJsonSql.Int("t", "tenant_id")}, IFNULL(t.tenant_id, 0))";
+        var where = $"t.source_table='qms_fqcbj'{srcClause}";
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        var rows = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM mdp_stg_fqc_pull t WHERE {where}", countPars);
+
+        var insertSql =
+            $"""
+            INSERT INTO mdp_std_fqc_task
+            (tenant_id, source_system, bill_no, production_order_no, apply_org_id, apply_time, material_code,
+             product_name, product_model, production_batch_no, order_qty, qty, applicant, remark, priority,
+             inspect_start_time, inspect_finish_time, inspector, inspect_progress,
+             source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                {tTenant}, IFNULL(NULLIF(t.source_system,''), 'AIDOP'),
+                {MdpJsonSql.Str("t", "FBILLNO")}, {MdpJsonSql.Str("t", "sczld")}, {MdpJsonSql.Int("t", "FORGID")}, {MdpJsonSql.DateTimeSec("t", "FAPPLYTIME")}, {MdpJsonSql.Str("t", "wlbm")},
+                {MdpJsonSql.Str("t", "cpmc")}, {MdpJsonSql.Str("t", "cpxh")}, {MdpJsonSql.Str("t", "scph")}, {MdpJsonSql.Dec("t", "sczldsl", 18, 6)}, {MdpJsonSql.Dec("t", "sl", 18, 6)}, {MdpJsonSql.Str("t", "FAPPLYUSER")}, {MdpJsonSql.Str("t", "FCOMMENT")}, {MdpJsonSql.Str("t", "yxj")},
+                {MdpJsonSql.DateTimeSec("t", "jykssj")}, {MdpJsonSql.DateTimeSec("t", "jywcsj")}, {MdpJsonSql.Str("t", "jyfzr")}, {MdpJsonSql.Str("t", "FINSPECTSTATUS")},
+                IFNULL(t.source_row_id, {MdpJsonSql.Str("t", "id")}), IFNULL(NULLIF(t.source_biz_key,''), {MdpJsonSql.Str("t", "FBILLNO")}),
+                @BatchId, @Now
+            FROM mdp_stg_fqc_pull t
+            WHERE {where}
+            ON DUPLICATE KEY UPDATE
+                bill_no=VALUES(bill_no), production_order_no=VALUES(production_order_no), apply_org_id=VALUES(apply_org_id),
+                apply_time=VALUES(apply_time), material_code=VALUES(material_code), product_name=VALUES(product_name),
+                product_model=VALUES(product_model), production_batch_no=VALUES(production_batch_no), order_qty=VALUES(order_qty),
+                qty=VALUES(qty), applicant=VALUES(applicant), remark=VALUES(remark), priority=VALUES(priority),
+                inspect_start_time=VALUES(inspect_start_time), inspect_finish_time=VALUES(inspect_finish_time),
+                inspector=VALUES(inspector), inspect_progress=VALUES(inspect_progress),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
+        return rows;
+    }
+
+    /// <summary>标准化结果:pull stg(qms_qcpp_inspbill) + WorkOrdMaster(本库富化 SAP 工单号) → mdp_std_fqc_result。</summary>
+    private async Task<int> TransformResultStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    {
+        var srcClause = sourceSystem == null ? "" : " AND a.source_system=@Src";
+        var aTenant = $"IFNULL({MdpJsonSql.Int("a", "tenant_id")}, IFNULL(a.tenant_id, 0))";
+        var where = $"a.source_table='qms_qcpp_inspbill'{srcClause}";
+        var sapSub = $"(SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo WHERE wo.Batch = {MdpJsonSql.Str("a", "sczld")})";
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        var rows = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM mdp_stg_fqc_pull a WHERE {where}", countPars);
+
+        var insertSql =
+            $"""
+            INSERT INTO mdp_std_fqc_result
+            (tenant_id, source_system, bill_no, inspect_time, production_batch_no, material_code, material_name, spec,
+             inspector, inspect_qty, source_bill_no, production_order_no, order_qty, sap_work_order_no, judgment,
+             qualified_qty, unqualified_qty, fire_no, inspect_spec_no, inspect_spec_version,
+             source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                {aTenant}, IFNULL(NULLIF(a.source_system,''), 'AIDOP'),
+                {MdpJsonSql.Str("a", "FBILLNO")}, {MdpJsonSql.DateTimeSec("a", "FINSPEENDDATE")}, {MdpJsonSql.Str("a", "scph")}, {MdpJsonSql.Str("a", "FMATERIALCFG")}, {MdpJsonSql.Str("a", "wlmc")}, {MdpJsonSql.Str("a", "ggxh")},
+                {MdpJsonSql.Str("a", "jyr")}, {MdpJsonSql.Dec("a", "jysj", 18, 6)}, {MdpJsonSql.Str("a", "lydjbh")}, {MdpJsonSql.Str("a", "sczld")}, {MdpJsonSql.Dec("a", "sczldsl", 18, 6)}, {sapSub}, {MdpJsonSql.Str("a", "pd")},
+                {MdpJsonSql.Dec("a", "hgsl", 18, 6)}, {MdpJsonSql.Dec("a", "bhgsl", 18, 6)}, {MdpJsonSql.Str("a", "jfbh")}, {MdpJsonSql.Str("a", "jgbh")}, {MdpJsonSql.Str("a", "jgbb")},
+                IFNULL(a.source_row_id, {MdpJsonSql.Str("a", "id")}), IFNULL(NULLIF(a.source_biz_key,''), {MdpJsonSql.Str("a", "FBILLNO")}),
+                @BatchId, @Now
+            FROM mdp_stg_fqc_pull a
+            WHERE {where}
+            ON DUPLICATE KEY UPDATE
+                bill_no=VALUES(bill_no), inspect_time=VALUES(inspect_time), production_batch_no=VALUES(production_batch_no),
+                material_code=VALUES(material_code), material_name=VALUES(material_name), spec=VALUES(spec),
+                inspector=VALUES(inspector), inspect_qty=VALUES(inspect_qty), source_bill_no=VALUES(source_bill_no),
+                production_order_no=VALUES(production_order_no), order_qty=VALUES(order_qty), sap_work_order_no=VALUES(sap_work_order_no),
+                judgment=VALUES(judgment), qualified_qty=VALUES(qualified_qty), unqualified_qty=VALUES(unqualified_qty),
+                fire_no=VALUES(fire_no), inspect_spec_no=VALUES(inspect_spec_no), inspect_spec_version=VALUES(inspect_spec_version),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
+        return rows;
+    }
+
+    /// <summary>标准化明细:pull stg(qms_qcpp_inspbillst) → mdp_std_fqc_result_detail(回填 std_result_id,j1..j50 逐列)。</summary>
+    private async Task<int> TransformResultDetailStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    {
+        var srcClause = sourceSystem == null ? "" : " AND d.source_system=@Src";
+        var dTenant = $"IFNULL({MdpJsonSql.Int("d", "tenant_id")}, IFNULL(d.tenant_id, 0))";
+        var fromWhere =
+            $"""
+            FROM mdp_stg_fqc_pull d
+            LEFT JOIN mdp_std_fqc_result r
+              ON r.tenant_id = {dTenant} AND r.source_row_id = {MdpJsonSql.Str("d", "glid")}
+            WHERE d.source_table='qms_qcpp_inspbillst'{srcClause}
+            """;
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        var rows = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromWhere}", countPars);
+
+        var jCols = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"j{i}"));
+        var jSelect = string.Join(", ", Enumerable.Range(1, 50).Select(i => MdpJsonSql.Str("d", $"j{i}")));
+        var jUpdate = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"j{i}=VALUES(j{i})"));
+
+        var insertSql =
+            $"""
+            INSERT INTO mdp_std_fqc_result_detail
+            (tenant_id, std_result_id, glid, seq_no, inspection_item, accept_standard, tech_requirement, sampling_plan,
+             sample_qty, upper_limit, lower_limit, size_specification, judgment, non_numeric_ok, non_numeric_ng, remark,
+             {jCols},
+             source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                {dTenant}, r.id, {MdpJsonSql.Str("d", "glid")}, {MdpJsonSql.Int("d", "xh")},
+                {MdpJsonSql.Str("d", "jyxm")}, {MdpJsonSql.Str("d", "jsbz")}, {MdpJsonSql.Str("d", "jsyq")}, {MdpJsonSql.Str("d", "cyfa")},
+                {MdpJsonSql.Str("d", "ybl")}, {MdpJsonSql.Str("d", "sx")}, {MdpJsonSql.Str("d", "xx")}, {MdpJsonSql.Str("d", "SizeSpecifications")}, {MdpJsonSql.Str("d", "pd")}, {MdpJsonSql.Int("d", "NonNumericalTypeOK")}, {MdpJsonSql.Int("d", "NonNumericalTypeNG")}, {MdpJsonSql.Str("d", "bz")},
+                {jSelect},
+                IFNULL(d.source_row_id, {MdpJsonSql.Str("d", "id")}), IFNULL(NULLIF(d.source_biz_key,''), {MdpJsonSql.Str("d", "id")}),
+                @BatchId, @Now
+            {fromWhere}
+            ON DUPLICATE KEY UPDATE
+                std_result_id=VALUES(std_result_id), glid=VALUES(glid), seq_no=VALUES(seq_no),
+                inspection_item=VALUES(inspection_item), accept_standard=VALUES(accept_standard), tech_requirement=VALUES(tech_requirement),
+                sampling_plan=VALUES(sampling_plan), sample_qty=VALUES(sample_qty), upper_limit=VALUES(upper_limit),
+                lower_limit=VALUES(lower_limit), size_specification=VALUES(size_specification), judgment=VALUES(judgment),
+                non_numeric_ok=VALUES(non_numeric_ok), non_numeric_ng=VALUES(non_numeric_ng), remark=VALUES(remark),
+                {jUpdate},
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
+        return rows;
+    }
+
+    private async Task<long> InsertRunLogAsync(string batchId, DateTime startedAt, string triggerType)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_transform_run_log
+            (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time)
+            VALUES (0, @JobCode, 'S7 FQC MDP同步与标准化转换', @TriggerType, @BatchId, 'RUNNING', @StartTime)
+            """,
+            new SugarParameter("@JobCode", JobCode),
+            new SugarParameter("@TriggerType", NormalizeTriggerType(triggerType)),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StartTime", startedAt));
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    private async Task MarkRunSuccessAsync(long runLogId, DateTime startedAt, FqcMdpSyncResult result)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
+                stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=0, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@StageRows", result.StageRows),
+            new SugarParameter("@StandardRows", result.TaskRows + result.ResultRows + result.DetailRows),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
+                error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@ErrorMessage", message.Length > 2000 ? message[..2000] : message),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private static string NormalizeTriggerType(string? triggerType)
+        => string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
+}
+
+/// <summary>S7 FQC MDP 同步转换结果。</summary>
+public sealed class FqcMdpSyncResult
+{
+    public long RunLogId { get; set; }
+    public string BatchId { get; set; } = string.Empty;
+    public int StageRows { get; set; }
+    public int TaskRows { get; set; }
+    public int ResultRows { get; set; }
+    public int DetailRows { get; set; }
+}
+
+/// <summary>S7 FQC 双模式入站结果。</summary>
+public sealed class FqcInboundResult
+{
+    public string PullBatchId { get; set; } = string.Empty;
+    public int RowsPulled { get; set; }
+    public int RowsWrittenStg { get; set; }
+    public string? NewCursor { get; set; }
+    public string TransformBatchId { get; set; } = string.Empty;
+    public int TaskRows { get; set; }
+    public int ResultRows { get; set; }
+    public int DetailRows { get; set; }
+}

+ 44 - 42
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/ProductionInboundService.cs

@@ -1,17 +1,20 @@
 using Admin.NET.Plugin.AiDOP.FinishedWarehouse.Dto;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
 
 namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 
 /// <summary>
 /// S7 生产入库单列表 只读 list 服务。
 ///
-/// 数据源:aidopdev.NbrMaster ⋈ NbrDetail(Type='WOI' 生产入库、IsActive=1),忠实复刻证据 sql.txt:
-/// LEFT JOIN DepartmentMaster(部门)、ItemMaster(名称/规格/单位),明细粒度(一行一 NbrDetail)。
-/// 口径由 SQL 内定义:单据号=Nbr;入库日期=Date;状态=Status;入库类型=Remark;入库数量=QtyRec;
-/// ERP工单号=Address;相关单号=OrdNbr。排序 Nbr DESC, Line。
+/// 数据源(2026-07-30 起):DOP 数据中台标准层 mdp_std_production_receipt(扁平,一行=一条 NbrDetail 明细)。
+/// 该 std 由 ProductionReceiptMdpSyncService 从 aidopdev.NbrMaster/NbrDetail Type='WOI'(生产入库)同步标准化而来,且已双源(dopdemorq)。
+/// S7 成品入库与 S5 生产收货读同一批 WOI 数据(过滤逐字相同)→ CTO 拍板复用该 std、不另建管线/实体(S5-list 与 S7-list 展示同一 WOI 单据的两个视角)。
 ///
-/// 本服务仅 SELECT:无新增/编辑/删除/入库写入/库存事务/状态流转;WP0-D4 起按 n.tenant_id 过滤。
+/// 口径映射(std 列 → 列表列):入库单号=nbr;行号=line;入库日期=receipt_date;状态=status;入库类型=remark;
+/// 生产线=prod_line;工单号=work_ord;ERP工单号=erp_work_ord;物料编码=item_num;名称=item_name;规格=item_spec;
+/// 入库库位=location_to;批次=lot_serial;入库数量=qty_rec;单位=um;相关单号=ord_nbr。
+///
+/// 本服务仅 SELECT:无新增/编辑/删除/入库写入/库存事务/状态流转。租户口径对齐 ProductionReceiptService:
+/// 仅当 input.TenantId>0 才按 m.tenant_id 过滤(receipt std tenant 由同步入站上下文赋值,默认租户可能空态)。
 /// </summary>
 [ApiDescriptionSettings(Order = 310, Description = "生产入库单列表")]
 [Route("api/S7ProductionInbound")]
@@ -20,16 +23,14 @@ namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 public class ProductionInboundService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
-    private readonly UserManager _userManager;
 
-    public ProductionInboundService(ISqlSugarClient db, UserManager userManager)
+    public ProductionInboundService(ISqlSugarClient db)
     {
         _db = db;
-        _userManager = userManager;
     }
 
     /// <summary>
-    /// 生产入库单列表(只读分页查询)。
+    /// 生产入库单列表(只读分页查询,扁平:一行=一条明细;读数据中台标准层 mdp_std_production_receipt)。
     /// </summary>
     [DisplayName("生产入库单列表")]
     [HttpGet("list")]
@@ -39,72 +40,73 @@ public class ProductionInboundService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        var tid = QmsTenantScope.Resolve(input.TenantId, _userManager.TenantId);
-        var where = new List<string> { "n.Type = 'WOI'", "n.IsActive = 1", "n.tenant_id = @TenantFilter" };
-        var pars = new List<SugarParameter> { new("@TenantFilter", tid) };
+        var where = new List<string> { "1=1" };
+        var pars = new List<SugarParameter>();
 
+        if (input.TenantId is > 0)
+        {
+            where.Add("m.tenant_id = @TenantId");
+            pars.Add(new SugarParameter("@TenantId", input.TenantId));
+        }
         if (!string.IsNullOrWhiteSpace(input.InboundNo))
         {
-            where.Add("n.Nbr LIKE @InboundNo");
+            where.Add("m.nbr LIKE @InboundNo");
             pars.Add(new SugarParameter("@InboundNo", $"%{input.InboundNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.WorkOrderNo))
         {
-            where.Add("d.WorkOrd LIKE @WorkOrderNo");
+            where.Add("m.work_ord LIKE @WorkOrderNo");
             pars.Add(new SugarParameter("@WorkOrderNo", $"%{input.WorkOrderNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.ErpWorkOrderNo))
         {
-            where.Add("d.Address LIKE @ErpWorkOrderNo");
+            where.Add("m.erp_work_ord LIKE @ErpWorkOrderNo");
             pars.Add(new SugarParameter("@ErpWorkOrderNo", $"%{input.ErpWorkOrderNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.Batch))
         {
-            where.Add("d.LotSerial LIKE @Batch");
+            where.Add("m.lot_serial LIKE @Batch");
             pars.Add(new SugarParameter("@Batch", $"%{input.Batch.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.InboundDateStart))
         {
-            where.Add("n.Date >= @InboundDateStart");
+            where.Add("m.receipt_date >= @InboundDateStart");
             pars.Add(new SugarParameter("@InboundDateStart", $"{input.InboundDateStart.Trim()} 00:00:00"));
         }
         if (!string.IsNullOrWhiteSpace(input.InboundDateEnd))
         {
-            where.Add("n.Date <= @InboundDateEnd");
+            where.Add("m.receipt_date <= @InboundDateEnd");
             pars.Add(new SugarParameter("@InboundDateEnd", $"{input.InboundDateEnd.Trim()} 23:59:59"));
         }
 
         var whereSql = string.Join(" AND ", where);
 
         var total = await _db.Ado.GetIntAsync(
-            $"SELECT COUNT(1) FROM NbrMaster n LEFT JOIN NbrDetail d ON n.Domain = d.Domain AND n.Nbr = d.Nbr WHERE {whereSql}",
-            pars);
+            $"SELECT COUNT(1) FROM mdp_std_production_receipt m WHERE {whereSql}", pars);
 
         var list = await _db.Ado.SqlQueryAsync<ProductionInboundListRow>(
             $"""
             SELECT
-                n.Nbr          AS InboundNo,
-                d.Line         AS LineNo,
-                n.Date         AS InboundDate,
-                n.Status       AS Status,
-                n.Remark       AS InboundType,
-                d.ProdLine     AS ProductionLine,
-                d.WorkOrd      AS WorkOrderNo,
-                d.Address      AS ErpWorkOrderNo,
-                d.ItemNum      AS MaterialCode,
-                i.Descr        AS MaterialName,
-                i.Descr1       AS Spec,
-                d.LocationTo   AS InboundLocation,
-                d.LotSerial    AS Batch,
-                d.QtyRec       AS Qty,
-                i.UM           AS Unit,
-                d.OrdNbr       AS RelatedBillNo,
-                d.CreateTime   AS CreateTime
-            FROM NbrMaster n
-            LEFT JOIN NbrDetail d ON n.Domain = d.Domain AND n.Nbr = d.Nbr
-            LEFT JOIN ItemMaster i ON d.Domain = i.Domain AND d.ItemNum = i.ItemNum
+                m.nbr              AS InboundNo,
+                m.line             AS LineNo,
+                m.receipt_date     AS InboundDate,
+                m.status           AS Status,
+                m.remark           AS InboundType,
+                m.prod_line        AS ProductionLine,
+                m.work_ord         AS WorkOrderNo,
+                m.erp_work_ord     AS ErpWorkOrderNo,
+                m.item_num         AS MaterialCode,
+                m.item_name        AS MaterialName,
+                m.item_spec        AS Spec,
+                m.location_to      AS InboundLocation,
+                m.lot_serial       AS Batch,
+                m.qty_rec          AS Qty,
+                m.um               AS Unit,
+                m.ord_nbr          AS RelatedBillNo,
+                m.create_time      AS CreateTime
+            FROM mdp_std_production_receipt m
             WHERE {whereSql}
-            ORDER BY n.Nbr DESC, d.Line
+            ORDER BY m.nbr DESC, m.line
             LIMIT {pageSize} OFFSET {offset}
             """,
             pars);

+ 44 - 21
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S5MdpRefreshJob.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Core.Service;
 using Admin.NET.Plugin.AiDOP.DataPlatform;
 using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 using Admin.NET.Plugin.AiDOP.Manufacturing;
@@ -22,6 +23,10 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 ///   ② inbound 成功后,S5→S6→S7 采用步级失败隔离(某模块失败记错误、继续下一模块);
 ///   ③ OperationCanceledException 保持取消语义直接上抛,不吞。
 /// 手工补偿入口 /t8-base-mdp/inbound 仍独立保留。
+///
+/// 并发:与手动刷新(AidopKanbanController → AidopT8KpiManualRefreshService)共享同一把硬 TTL 刷新锁
+///   (RefreshLockKey)。占用时本轮定时跳过(5 次/天,下一 tick 自愈),避免共享入站/std 与手动刷新并发写;
+///   本轮结束在 finally 主动释放,即使异常/挂死也由 TTL 到点自动过期,不永久阻塞后续刷新。
 /// </summary>
 [JobDetail("job_s5_t8_kpi_refresh",
     Description = "T8 基础数据 + S5/S6/S7 KPI 统一刷新编排(inbound 硬门 → S5→S6→S7;5 次/天:02/07/12/17/22)",
@@ -43,33 +48,51 @@ public class S5MdpRefreshJob : IJob
 
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     {
-        // ① 硬门:T8 基表入站(源→stg→std)必须先成功;失败则本轮结束,不继续下游、不降级用旧 std。
-        try
+        // 与手动刷新共享同一把硬 TTL 刷新锁:占用时本轮跳过;本轮结束 finally 释放,异常/挂死由 TTL 自动过期兜底。
+        using var lockScope = _scopeFactory.CreateScope();
+        var cache = lockScope.ServiceProvider.GetRequiredService<SysCacheService>();
+        if (cache.ExistKey(AidopT8KpiManualRefreshService.RefreshLockKey))
         {
-            using var inboundScope = _scopeFactory.CreateScope();
-            var inbound = inboundScope.ServiceProvider.GetRequiredService<T8BaseInboundMdpSyncService>();
-            var inboundResult = await inbound.RunInboundAsync(
-                tenantId: 0, fullRefresh: true, entityCode: null, cancellationToken: stoppingToken);
-            _logger.LogInformation("S5MdpRefreshJob T8 inbound 完成 {Payload}", JsonSerializer.Serialize(inboundResult));
+            _logger.LogWarning("S5MdpRefreshJob 检测到刷新锁被占用(手动刷新或上轮未释放),本轮跳过");
+            return;
         }
-        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        cache.Set(AidopT8KpiManualRefreshService.RefreshLockKey, $"AUTO:{DateTime.Now:yyyyMMddHHmmss}",
+            TimeSpan.FromSeconds(AidopT8KpiManualRefreshService.RefreshLockTtlSeconds));
+
+        try
         {
-            _logger.LogInformation("S5MdpRefreshJob T8 inbound 收到停止信号,本轮结束");
-            throw;
+            // ① 硬门:T8 基表入站(源→stg→std)必须先成功;失败则本轮结束,不继续下游、不降级用旧 std。
+            try
+            {
+                using var inboundScope = _scopeFactory.CreateScope();
+                var inbound = inboundScope.ServiceProvider.GetRequiredService<T8BaseInboundMdpSyncService>();
+                var inboundResult = await inbound.RunInboundAsync(
+                    tenantId: 0, fullRefresh: true, entityCode: null, cancellationToken: stoppingToken);
+                _logger.LogInformation("S5MdpRefreshJob T8 inbound 完成 {Payload}", JsonSerializer.Serialize(inboundResult));
+            }
+            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+            {
+                _logger.LogInformation("S5MdpRefreshJob T8 inbound 收到停止信号,本轮结束");
+                throw;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "S5MdpRefreshJob T8 inbound 失败,本轮跳过 S5/S6/S7 KPI 刷新(不使用上一周期 std 继续计算)");
+                return;
+            }
+
+            // ② 下游:inbound 成功后,S5→S6→S7 顺序执行,步级失败隔离(各自独立 DI scope)。
+            await RunStepAsync("S5", stoppingToken, async sp =>
+                (object)await sp.GetRequiredService<S5MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
+            await RunStepAsync("S6", stoppingToken, async sp =>
+                (object)await sp.GetRequiredService<S6MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
+            await RunStepAsync("S7", stoppingToken, async sp =>
+                (object)await sp.GetRequiredService<S7MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
         }
-        catch (Exception ex)
+        finally
         {
-            _logger.LogError(ex, "S5MdpRefreshJob T8 inbound 失败,本轮跳过 S5/S6/S7 KPI 刷新(不使用上一周期 std 继续计算)");
-            return;
+            cache.Remove(AidopT8KpiManualRefreshService.RefreshLockKey);
         }
-
-        // ② 下游:inbound 成功后,S5→S6→S7 顺序执行,步级失败隔离(各自独立 DI scope)。
-        await RunStepAsync("S5", stoppingToken, async sp =>
-            (object)await sp.GetRequiredService<S5MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
-        await RunStepAsync("S6", stoppingToken, async sp =>
-            (object)await sp.GetRequiredService<S6MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
-        await RunStepAsync("S7", stoppingToken, async sp =>
-            (object)await sp.GetRequiredService<S7MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
     }
 
     /// <summary>

+ 127 - 49
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionMdpSyncService.cs

@@ -7,9 +7,9 @@ namespace Admin.NET.Plugin.AiDOP.Manufacturing;
 /// S6 过程检验单(IPQC)数据中台只读同步转换服务(DOP 内部,独立于 S6MdpSyncTransformService 的 KPI 管线)。
 ///
 /// 源:aidopdev.qms_gcjyd(过程检验单头,31 列)+ aidopdev.qms_gcjydzb(明细,100 列),归口自旧系统 dopdemo。
-/// 链路(Phase 1):执行器/贴源 → mdp_stg_ipqc_pull(头)+ mdp_stg_ipqc_inspection_detail(明细)→ std
-/// 双模式入站:mdp_entity=S6_IPQC_INSPECTION_HEAD → Writer → mdp_stg_ipqc_pull;头标准层读 pull stg;
-///     明细仍经 SyncDetailStagingAsync 入 inspection_detail 后读 stg 转 std
+/// 链路:执行器 → mdp_stg_ipqc_pull(头 source_table='qms_gcjyd' + 明细 source_table='qms_gcjydzb',共表)→ std(头/明细各读 pull stg)
+/// 双模式入站:mdp_entity=S6_IPQC_INSPECTION_HEAD / S6_IPQC_INSPECTION_DETAIL → Writer → mdp_stg_ipqc_pull;头/明细标准层均读 pull stg(MdpJsonSql 跨源类型兼容)。
+///     SyncHeadStagingAsync / SyncDetailStagingAsync 保留为 vestigial 可观测贴源写入(mdp_stg_ipqc_inspection(_detail)),不再被 transform 消费
 ///
 /// 归口说明(CTO 拍板方案 A):
 ///   qms_gcjyd/qms_gcjydzb 原仅存于旧系统 dopdemo(0 行)、aidopdev 无此表,且 dopdemo 非运行时连接。
@@ -29,10 +29,13 @@ public class IpqcInspectionMdpSyncService : ITransient
 {
     private const string JobCode = "S6_IPQC_INSPECTION_MDP_SYNC";
     private const string InboundEntityCode = "S6_IPQC_INSPECTION_HEAD";
+    // Phase 2:Detail(qms_gcjydzb) 迁通用管线,经执行器灌 mdp_stg_ipqc_pull(source_table='qms_gcjydzb',与 Head 共表),transform 读 pull stg。
+    private const string InboundDetailEntityCode = "S6_IPQC_INSPECTION_DETAIL";
 
-    // 双源(dopdemorq SQL Server)Phase 1:仅 Head(qms_gcjyd 无 UpdateTime → FULL)。实体默认 status=0(就位不启用)。Detail(qms_gcjydzb) 属 bespoke,留 Phase 2 不碰。
+    // 双源(dopdemorq SQL Server):Head(qms_gcjyd)+ Detail(qms_gcjydzb),均无 UpdateTime → FULL。实体默认 status=0(就位不启用)。
     private const string SqlServerSourceCode = "DOPDEMORQ_SQLSERVER";
     private const string SqlServerHeadEntityCode = "S6_IPQC_INSPECTION_HEAD_SQLSERVER";
+    private const string SqlServerDetailEntityCode = "S6_IPQC_INSPECTION_DETAIL_SQLSERVER";
 
     private readonly ISqlSugarClient _db;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
@@ -66,11 +69,13 @@ public class IpqcInspectionMdpSyncService : ITransient
                 TaskCode = "S6_IPQC_INSPECTION_INBOUND",
                 BatchId = $"{batchId}_PULL"
             };
-            var pull = await _pullDispatcher.PullByEntityCodeAsync(InboundEntityCode, pullCtx, cancellationToken);
-            result.HeadStageRows = pull.RowsWritten;
-            // 保留富贴源头表写入(可观测);头 std 以 pull stg 为准
+            var pullHead = await _pullDispatcher.PullByEntityCodeAsync(InboundEntityCode, pullCtx, cancellationToken);
+            var pullDetail = await _pullDispatcher.PullByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
+            result.HeadStageRows = pullHead.RowsWritten;
+            result.DetailStageRows = pullDetail.RowsWritten;
+            // 保留富贴源头/明细表写入(vestigial 可观测);头/明细 std 均以 pull stg 为准
             await SyncHeadStagingAsync(batchId, now);
-            result.DetailStageRows = await SyncDetailStagingAsync(batchId, now);
+            await SyncDetailStagingAsync(batchId, now);
             result.HeadStandardRows = await TransformHeadStandardAsync(batchId, now);
             result.DetailStandardRows = await TransformDetailStandardAsync(batchId, now);
             await MarkRunSuccessAsync(runLogId, now, result);
@@ -103,16 +108,18 @@ public class IpqcInspectionMdpSyncService : ITransient
             TaskCode = "S6_IPQC_INSPECTION_INBOUND",
             BatchId = $"S6_IPQC_IN_{now:yyyyMMddHHmmss}"
         };
-        var pull = await _pullDispatcher.PullByEntityCodeAsync(InboundEntityCode, pullCtx, cancellationToken);
+        var pullHead = await _pullDispatcher.PullByEntityCodeAsync(InboundEntityCode, pullCtx, cancellationToken);
+        var pullDetail = await _pullDispatcher.PullByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
 
         var batchId = $"S6_IPQC_STD_{now:yyyyMMddHHmmss}";
         var runLogId = await InsertRunLogAsync(batchId, now, "INBOUND");
         var result = new IpqcInspectionMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
         try
         {
-            result.HeadStageRows = pull.RowsWritten;
+            result.HeadStageRows = pullHead.RowsWritten;
+            result.DetailStageRows = pullDetail.RowsWritten;
             await SyncHeadStagingAsync(batchId, now);
-            result.DetailStageRows = await SyncDetailStagingAsync(batchId, now);
+            await SyncDetailStagingAsync(batchId, now);
             result.HeadStandardRows = await TransformHeadStandardAsync(batchId, now);
             result.DetailStandardRows = await TransformDetailStandardAsync(batchId, now);
             await MarkRunSuccessAsync(runLogId, now, result);
@@ -126,9 +133,9 @@ public class IpqcInspectionMdpSyncService : ITransient
         return new IpqcInspectionInboundResult
         {
             PullBatchId = pullCtx.BatchId,
-            RowsPulled = pull.RowsPulled,
-            RowsWrittenStg = pull.RowsWritten,
-            NewCursor = pull.NewCursor,
+            RowsPulled = pullHead.RowsPulled + pullDetail.RowsPulled,
+            RowsWrittenStg = pullHead.RowsWritten + pullDetail.RowsWritten,
+            NewCursor = pullDetail.NewCursor ?? pullHead.NewCursor,
             TransformBatchId = result.BatchId,
             HeadStageRows = result.HeadStageRows,
             HeadStandardRows = result.HeadStandardRows,
@@ -198,6 +205,66 @@ public class IpqcInspectionMdpSyncService : ITransient
         };
     }
 
+    /// <summary>
+    /// 双源切换 FULL Replace(Detail):从指定 DB 源(默认 DOPDEMORQ_SQLSERVER)PullAll 全量灌 pull-stg(source_table='qms_gcjydzb'),
+    /// 成功后在单事务内以「仅当前源」结果 FULL 重建 mdp_std_ipqc_inspection_detail(按 tenant 精确隔离),消除旧源独有业务键残留。
+    /// qms_gcjydzb 无 UpdateTime → FULL;用 PullAllByEntityCodeAsync + FullRefresh=true。std_head_id 回填依赖 Head std 已存在(LEFT JOIN,缺则 NULL)。
+    /// <b>只处理 Detail</b>:不碰 Head std(切 Head 用 RunHeadSourceSwitchFullAsync)。
+    /// SQLSERVER 实体默认 status=0(就位不启用);dopdemorq 该表当前空,Phase 2 不实际执行 destructive 切换。
+    /// </summary>
+    public async Task<IpqcInspectionInboundResult> RunDetailSourceSwitchFullAsync(
+        string sourceCode = SqlServerSourceCode,
+        string detailEntityCode = SqlServerDetailEntityCode,
+        long tenantId = 0,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        // 1) PullAll 抽尽 + fullRefresh=true。Pull 失败会抛异常,std 未动。
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = true,
+            TaskCode = "S6_IPQC_INSPECTION_INBOUND",
+            BatchId = $"S6_IPQC_DTL_SW_{now:yyyyMMddHHmmss}"
+        };
+        var pull = await _pullDispatcher.PullAllByEntityCodeAsync(detailEntityCode, pullCtx, cancellationToken);
+
+        // 2) FULL Replace(仅明细 std):事务内 DELETE 当前 tenant,再 INSERT 仅当前 source_system 的明细结果。
+        var batchId = $"S6_IPQC_DTL_SWSTD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "SOURCE_SWITCH");
+        var result = new IpqcInspectionMdpSyncResult { BatchId = batchId, RunLogId = runLogId, DetailStageRows = pull.RowsWritten };
+        try
+        {
+            result.DetailStandardRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, "mdp_std_ipqc_inspection_detail", tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformDetailStandardAsync(batchId, now, sourceCode),
+                cancellationToken);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new IpqcInspectionInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = pull.RowsPulled,
+            RowsWrittenStg = pull.RowsWritten,
+            NewCursor = pull.NewCursor,
+            TransformBatchId = batchId,
+            HeadStageRows = 0,
+            HeadStandardRows = 0,
+            DetailStageRows = result.DetailStageRows,
+            DetailStandardRows = result.DetailStandardRows
+        };
+    }
+
     private async Task EnsurePullStgTableAsync()
     {
         await _db.Ado.ExecuteCommandAsync(
@@ -544,48 +611,58 @@ public class IpqcInspectionMdpSyncService : ITransient
         return rows;
     }
 
-    /// <summary>标准化明细:mdp_stg_ipqc_inspection_detail → mdp_std_ipqc_inspection_detail。</summary>
-    private async Task<int> TransformDetailStandardAsync(string batchId, DateTime now)
+    /// <summary>
+    /// 标准化明细:mdp_stg_ipqc_pull(qms_gcjydzb) → mdp_std_ipqc_inspection_detail(回填 std_head_id)。
+    /// 全 typed 列经 MdpJsonSql 跨源类型兼容(ybl/jysl/bhgsl/NonNumerical* Dec/Int 加 NULL 守卫、InspectionTime 兼容 ISO-T)。
+    /// sourceSystem 非空时仅统计/转换当前 source(切源 FULL Replace 用);为空时保持既有全源行为。
+    /// </summary>
+    private async Task<int> TransformDetailStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
     {
+        var srcClause = sourceSystem == null ? "" : " AND d.source_system=@Src";
+        var dTenant = $"IFNULL({MdpJsonSql.Int("d", "tenant_id")}, IFNULL(d.tenant_id, 1300000000001))";
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
         var rows = await _db.Ado.GetIntAsync(
-            "SELECT COUNT(1) FROM mdp_stg_ipqc_inspection_detail WHERE source_table='qms_gcjydzb'");
-        await _db.Ado.ExecuteCommandAsync(
-            """
+            $"SELECT COUNT(1) FROM mdp_stg_ipqc_pull d WHERE d.source_table='qms_gcjydzb'{srcClause}", countPars);
+
+        var insertSql =
+            $"""
             INSERT INTO mdp_std_ipqc_inspection_detail
             (tenant_id, std_head_id, source_head_id, inspection_item, inspection_basis, technical_standard, inspection_method,
              upper_limit, lower_limit, process_code, process_name, measurement_instrument, inspector, inspection_time,
              sample_qty, inspection_qty, unqualified_qty, result_judgement, non_numeric_ok, non_numeric_ng, remark, attachment,
              source_row_id, source_biz_key, sync_batch_id, sync_time)
             SELECT
-                IFNULL(d.tenant_id, 1300000000001), h.id,
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.glid')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.jyxm')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.jyyj')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.jsbz')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.jyff')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.sx')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.xx')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ProcessCode')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ProcessName')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.MeasurementInstrument')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Inspector')),
-                STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.InspectionTime')),'null'),''), '%Y-%m-%d %H:%i:%s'),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ybl')) AS DECIMAL(18,6)),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.jysl')) AS DECIMAL(18,6)),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.bhgsl')) AS DECIMAL(18,6)),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.pd')),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.NonNumericalTypeOK')) AS SIGNED),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.NonNumericalTypeNG')) AS SIGNED),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.bz')),
-                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.fj')),
-                IFNULL(d.source_row_id, JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.id'))),
-                IFNULL(NULLIF(d.source_biz_key,''), JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.glid'))),
+                {dTenant}, h.id,
+                {MdpJsonSql.Str("d", "glid")},
+                {MdpJsonSql.Str("d", "jyxm")},
+                {MdpJsonSql.Str("d", "jyyj")},
+                {MdpJsonSql.Str("d", "jsbz")},
+                {MdpJsonSql.Str("d", "jyff")},
+                {MdpJsonSql.Str("d", "sx")},
+                {MdpJsonSql.Str("d", "xx")},
+                {MdpJsonSql.Str("d", "ProcessCode")},
+                {MdpJsonSql.Str("d", "ProcessName")},
+                {MdpJsonSql.Str("d", "MeasurementInstrument")},
+                {MdpJsonSql.Str("d", "Inspector")},
+                {MdpJsonSql.DateTimeSec("d", "InspectionTime")},
+                {MdpJsonSql.Int("d", "ybl")},
+                {MdpJsonSql.Dec("d", "jysl", 18, 6)},
+                {MdpJsonSql.Dec("d", "bhgsl", 18, 6)},
+                {MdpJsonSql.Str("d", "pd")},
+                {MdpJsonSql.Int("d", "NonNumericalTypeOK")},
+                {MdpJsonSql.Int("d", "NonNumericalTypeNG")},
+                {MdpJsonSql.Str("d", "bz")},
+                {MdpJsonSql.Str("d", "fj")},
+                IFNULL(d.source_row_id, {MdpJsonSql.Str("d", "id")}),
+                IFNULL(NULLIF(d.source_biz_key,''), {MdpJsonSql.Str("d", "glid")}),
                 @BatchId, @Now
-            FROM mdp_stg_ipqc_inspection_detail d
+            FROM mdp_stg_ipqc_pull d
             LEFT JOIN mdp_std_ipqc_inspection h
-              ON h.tenant_id = IFNULL(d.tenant_id, 1300000000001)
-             AND h.source_row_id = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.glid'))
-            WHERE d.source_table='qms_gcjydzb'
+              ON h.tenant_id = {dTenant}
+             AND h.source_row_id = {MdpJsonSql.Str("d", "glid")}
+            WHERE d.source_table='qms_gcjydzb'{srcClause}
             ON DUPLICATE KEY UPDATE
                 std_head_id=VALUES(std_head_id), source_head_id=VALUES(source_head_id), inspection_item=VALUES(inspection_item),
                 inspection_basis=VALUES(inspection_basis), technical_standard=VALUES(technical_standard), inspection_method=VALUES(inspection_method),
@@ -595,9 +672,10 @@ public class IpqcInspectionMdpSyncService : ITransient
                 result_judgement=VALUES(result_judgement), non_numeric_ok=VALUES(non_numeric_ok), non_numeric_ng=VALUES(non_numeric_ng),
                 remark=VALUES(remark), attachment=VALUES(attachment),
                 sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@Now", now));
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         return rows;
     }
 

+ 256 - 143
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/OutsourceIssueMdpSyncService.cs

@@ -1,38 +1,55 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+
 namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 
 /// <summary>
-/// S5 委外发料单 数据中台只读同步转换服务(DOP 内部,独立于 S5MdpSyncTransformService 的 KPI 管线)。
+/// S5 委外发料单 数据中台只读同步转换服务(DOP 内部,head-detail 双 std 专表,独立于 S5MdpSyncTransformService 的 KPI 管线)。
+///
+/// B-① 迁通用管线:源(NbrMaster/NbrDetail, Type='CA') 经执行器灌 mdp_stg_outsource_issue_pull(头/明细共表,source_table 区分)
+///   → transform 读 pull stg(MdpJsonSql 跨源类型兼容)→ mdp_std_outsource_issue(_detail)(typed,明细回填 std_head_id)。
+///   维表 DepartmentMaster(d) 仍本库 LEFT JOIN(富化 department_name,业务 code 由事实表保留)。
+///   注:canonical 执行器 stg 用 *_pull 命名(沿用 mdp_stg_ipqc_pull 惯例),与 legacy bespoke mdp_stg_outsource_issue(_detail) 区分;后者迁移后为死支路、不再消费、不动。
 ///
-/// 源:aidopdev NbrMaster / NbrDetail,业务类型 Type='CA';头明细关联 NbrDetail.NbrRecID -> NbrMaster.RecID。
-/// 链路:NbrMaster/NbrDetail(CA) 只读 → mdp_stg_outsource_issue(_detail)(raw_data JSON) → mdp_std_outsource_issue(_detail)(typed)。
+/// 双源:本库实体 S5_OUTSOURCE_ISSUE_MASTER/_DETAIL(AIDOPDEV_MYSQL, status=1);
+///   dopdemorq 第二源 S5_OUTSOURCE_ISSUE_MASTER_SQLSERVER/_DETAIL_SQLSERVER(DOPDEMORQ_SQLSERVER, status=0 就位不启用)
 ///
 /// 约束:
-///   - 只读源表,仅写 mdp_stg_*/mdp_std_*;绝不 INSERT/UPDATE/DELETE NbrMaster/NbrDetail。
+///   - 只读源/贴源,仅写 mdp_stg_outsource_issue_pull / mdp_std_outsource_issue(_detail);绝不 INSERT/UPDATE/DELETE NbrMaster/NbrDetail。
 ///   - 过滤 NbrMaster.Type='CA' AND IsActive=1;NbrDetail.Type='CA'。
-///   - status_desc 规则本批仅:Status='C' -> '关闭',其他 -> ''。
-///   - department_name 经 NbrMaster.Domain + NbrMaster.Department 关联 DepartmentMaster.Descr。
-///   - 明细只转换已确认 9 列(ItemNum/ItemName/UM/QtyOrd/LocationFrom/LocationTo/Line/Status/Remark);
-///     「发料数量/已发数/批次号」3 候选列本批后置,不转换、不落 typed 字段。
-///   - CA 为 0 行时转换成功完成、处理数为 0,不报错。
+///   - status_desc 规则本批仅:Status='C' -> '关闭',其他 -> ''。department_name 经 Domain+Department 关联 DepartmentMaster.Descr。
+///   - 明细只转换已确认 9 列(ItemNum/ItemName/UM/QtyOrd/LocationFrom/LocationTo/Line/Status/Remark);发料数量/已发数/批次号 3 候选列本批后置。
+///   - tenant_id 从 raw_data 保留(本库源含该列 → std 租户不回归;SQL Server 源无此列 → 回落入站上下文 tenant)。
+///   - CA 为 0 行时转换成功完成、处理数为 0,不报错。切源 FULL Replace 头/明细各一次单事务、仅按 tenant、Pull 成功后才 destructive。
 /// </summary>
 public class OutsourceIssueMdpSyncService : ITransient
 {
     private const string JobCode = "S5_OUTSOURCE_ISSUE_MDP_SYNC";
+    private const string InboundMasterEntityCode = "S5_OUTSOURCE_ISSUE_MASTER";
+    private const string InboundDetailEntityCode = "S5_OUTSOURCE_ISSUE_DETAIL";
+
+    private const string SqlServerSourceCode = "DOPDEMORQ_SQLSERVER";
+    private const string SqlServerMasterEntityCode = "S5_OUTSOURCE_ISSUE_MASTER_SQLSERVER";
+    private const string SqlServerDetailEntityCode = "S5_OUTSOURCE_ISSUE_DETAIL_SQLSERVER";
+
+    private const string StdHeadTable = "mdp_std_outsource_issue";
+    private const string StdDetailTable = "mdp_std_outsource_issue_detail";
 
     private readonly ISqlSugarClient _db;
+    private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public OutsourceIssueMdpSyncService(ISqlSugarClient db)
+    public OutsourceIssueMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
     {
         _db = db;
+        _pullDispatcher = pullDispatcher;
     }
 
-    /// <summary>
-    /// 全量同步转换:贴源(头/明细) → 标准化(头/明细)。
-    /// </summary>
+    /// <summary>全量:本地 DB 执行器灌 pull stg(头+明细) → 标准层头/明细(读 stg)。</summary>
     public async Task<OutsourceIssueMdpSyncResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
     {
         cancellationToken.ThrowIfCancellationRequested();
         await EnsureTablesAsync();
+        await EnsureStgTableAsync();
 
         var now = DateTime.Now;
         var batchId = $"S5_OUTSRC_ISSUE_FULL_{now:yyyyMMddHHmmss}";
@@ -41,8 +58,16 @@ public class OutsourceIssueMdpSyncService : ITransient
 
         try
         {
-            result.HeadStageRows = await SyncHeadStagingAsync(batchId, now);
-            result.DetailStageRows = await SyncDetailStagingAsync(batchId, now);
+            var pullCtx = new MdpPullContext
+            {
+                TenantId = 0,
+                FullRefresh = true,
+                TaskCode = "S5_OUTSOURCE_ISSUE_INBOUND",
+                BatchId = $"{batchId}_PULL"
+            };
+            var pull = await PopulateStgAsync(pullCtx, cancellationToken);
+            result.HeadStageRows = pull.RowsWritten;
+            result.DetailStageRows = 0;
             result.HeadStandardRows = await TransformHeadStandardAsync(batchId, now);
             result.DetailStandardRows = await TransformDetailStandardAsync(batchId, now);
             await MarkRunSuccessAsync(runLogId, now, result);
@@ -55,45 +80,154 @@ public class OutsourceIssueMdpSyncService : ITransient
         }
     }
 
-    /// <summary>防御式建表(与 UpdateScripts/1.0.206.sql 同构,幂等)。</summary>
-    private async Task EnsureTablesAsync()
+    /// <summary>双模式入站:执行器抽主/明细 → pull stg,再跑 CA 标准层头/明细(读 stg)。</summary>
+    public async Task<OutsourceIssueInboundResult> RunInboundAsync(
+        long tenantId = 0,
+        bool fullRefresh = false,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsureStgTableAsync();
+
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = fullRefresh,
+            TaskCode = "S5_OUTSOURCE_ISSUE_INBOUND",
+            BatchId = $"S5_OUTSRC_ISSUE_IN_{now:yyyyMMddHHmmss}"
+        };
+        var pull = await PopulateStgAsync(pullCtx, cancellationToken);
+
+        var batchId = $"S5_OUTSRC_ISSUE_STD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "INBOUND");
+        var result = new OutsourceIssueMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.HeadStandardRows = await TransformHeadStandardAsync(batchId, now);
+            result.DetailStandardRows = await TransformDetailStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new OutsourceIssueInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = pull.RowsPulled,
+            RowsWrittenStg = pull.RowsWritten,
+            NewCursor = pull.NewCursor,
+            TransformBatchId = batchId,
+            StdHeadRows = result.HeadStandardRows,
+            StdDetailRows = result.DetailStandardRows
+        };
+    }
+
+    /// <summary>
+    /// 双源切换 FULL Replace:从指定 DB 源(默认 DOPDEMORQ_SQLSERVER)PullAll 全量灌 pull stg,
+    /// 成功后头/明细各一次单事务 FULL 重建(按 tenant 精确隔离),消除旧源独有业务键残留。
+    /// 顺序:先 head(写 std 头,供明细回填 std_head_id),再 detail。两次独立事务,Pull 成功后才 destructive。
+    /// SQLSERVER 实体默认 status=0(就位不启用);dopdemorq 六表当前空,Phase 1 不实际执行 destructive 切换。
+    /// </summary>
+    public async Task<OutsourceIssueInboundResult> RunSourceSwitchFullAsync(
+        string sourceCode = SqlServerSourceCode,
+        string masterEntityCode = SqlServerMasterEntityCode,
+        string detailEntityCode = SqlServerDetailEntityCode,
+        long tenantId = 0,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsureStgTableAsync();
+
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = true,
+            TaskCode = "S5_OUTSOURCE_ISSUE_INBOUND",
+            BatchId = $"S5_OUTSRC_ISSUE_SW_{now:yyyyMMddHHmmss}"
+        };
+        var master = await _pullDispatcher.PullAllByEntityCodeAsync(masterEntityCode, pullCtx, cancellationToken);
+        var detail = await _pullDispatcher.PullAllByEntityCodeAsync(detailEntityCode, pullCtx, cancellationToken);
+
+        var batchId = $"S5_OUTSRC_ISSUE_SWSTD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "SOURCE_SWITCH");
+        var result = new OutsourceIssueMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.HeadStandardRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdHeadTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformHeadStandardAsync(batchId, now, sourceCode),
+                cancellationToken);
+            result.DetailStandardRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdDetailTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformDetailStandardAsync(batchId, now, sourceCode),
+                cancellationToken);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new OutsourceIssueInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = master.RowsPulled + detail.RowsPulled,
+            RowsWrittenStg = master.RowsWritten + detail.RowsWritten,
+            NewCursor = detail.NewCursor ?? master.NewCursor,
+            TransformBatchId = batchId,
+            StdHeadRows = result.HeadStandardRows,
+            StdDetailRows = result.DetailStandardRows
+        };
+    }
+
+    private async Task<(int RowsPulled, int RowsWritten, string? NewCursor)> PopulateStgAsync(
+        MdpPullContext pullCtx, CancellationToken cancellationToken)
+    {
+        var master = await _pullDispatcher.PullByEntityCodeAsync(InboundMasterEntityCode, pullCtx, cancellationToken);
+        var detail = await _pullDispatcher.PullByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
+        return (
+            master.RowsPulled + detail.RowsPulled,
+            master.RowsWritten + detail.RowsWritten,
+            detail.NewCursor ?? master.NewCursor);
+    }
+
+    private async Task EnsureStgTableAsync()
     {
         await _db.Ado.ExecuteCommandAsync(
             """
-            CREATE TABLE IF NOT EXISTS mdp_stg_outsource_issue (
-                id BIGINT AUTO_INCREMENT PRIMARY KEY,
-                tenant_id BIGINT NOT NULL DEFAULT 0,
-                factory_id BIGINT NULL DEFAULT 1,
-                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
-                source_table VARCHAR(100) NOT NULL,
-                source_row_id VARCHAR(100) NOT NULL,
-                source_biz_key VARCHAR(200) NULL,
-                sync_batch_id VARCHAR(100) NOT NULL,
-                sync_time DATETIME NOT NULL,
-                process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
-                raw_data JSON NOT NULL,
-                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-                UNIQUE KEY uk_mdp_stg_outsrc_issue (tenant_id, source_table, source_row_id)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单头贴源层'
-            """);
-        await _db.Ado.ExecuteCommandAsync(
-            """
-            CREATE TABLE IF NOT EXISTS mdp_stg_outsource_issue_detail (
-                id BIGINT AUTO_INCREMENT PRIMARY KEY,
-                tenant_id BIGINT NOT NULL DEFAULT 0,
-                factory_id BIGINT NULL DEFAULT 1,
-                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
-                source_table VARCHAR(100) NOT NULL,
-                source_row_id VARCHAR(100) NOT NULL,
-                source_biz_key VARCHAR(200) NULL,
-                sync_batch_id VARCHAR(100) NOT NULL,
-                sync_time DATETIME NOT NULL,
+            CREATE TABLE IF NOT EXISTS mdp_stg_outsource_issue_pull (
+                id BIGINT PRIMARY KEY AUTO_INCREMENT,
+                tenant_id BIGINT NOT NULL,
+                source_system VARCHAR(50) NULL,
+                source_table VARCHAR(200),
+                source_row_id VARCHAR(200),
+                source_biz_key VARCHAR(300) NULL,
+                raw_data JSON,
+                sync_batch_id VARCHAR(100),
+                sync_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
                 process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
-                raw_data JSON NOT NULL,
-                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-                UNIQUE KEY uk_mdp_stg_outsrc_issue_dtl (tenant_id, source_table, source_row_id)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单明细贴源层'
+                process_message VARCHAR(500) NULL,
+                create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_source_key (source_system, source_table, source_biz_key),
+                KEY idx_batch (sync_batch_id),
+                KEY idx_src (source_table, source_row_id),
+                KEY idx_tenant (tenant_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S5委外发料执行器贴源层(头/明细共表,source_table 区分)'
             """);
+    }
+
+    /// <summary>防御式建表(与 UpdateScripts/1.0.206.sql 同构,幂等)。std 头/明细两专表。</summary>
+    private async Task EnsureTablesAsync()
+    {
         await _db.Ado.ExecuteCommandAsync(
             """
             CREATE TABLE IF NOT EXISTS mdp_std_outsource_issue (
@@ -149,128 +283,95 @@ public class OutsourceIssueMdpSyncService : ITransient
             """);
     }
 
-    /// <summary>贴源头:NbrMaster(CA) -> mdp_stg_outsource_issue(raw_data JSON 快照)。返回源 CA 头行数。</summary>
-    private async Task<int> SyncHeadStagingAsync(string batchId, DateTime now)
+    /// <summary>标准化头:pull stg(NbrMaster, CA) + DepartmentMaster(本库) → mdp_std_outsource_issue。返回处理头行数。</summary>
+    private async Task<int> TransformHeadStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
     {
-        var rows = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM NbrMaster WHERE Type='CA' AND IsActive=1");
-        await _db.Ado.ExecuteCommandAsync(
-            """
-            INSERT INTO mdp_stg_outsource_issue
-            (tenant_id, factory_id, source_system, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time, process_status, raw_data)
-            SELECT
-                IFNULL(m.tenant_id, 0), 1, 'AIDOP', 'NbrMaster',
-                CAST(m.RecID AS CHAR), CAST(m.Nbr AS CHAR), @BatchId, @Now, 'PENDING',
-                JSON_OBJECT(
-                    'RecID', m.RecID, 'Nbr', m.Nbr, 'Date', m.Date, 'Address', m.Address,
-                    'WorkOrd', m.WorkOrd, 'Remark', m.Remark, 'CreateUser', m.CreateUser, 'CreateTime', m.CreateTime,
-                    'Status', m.Status, 'Domain', m.Domain, 'Department', m.Department,
-                    'User1', CAST(m.User1 AS CHAR), 'EffDate', m.EffDate, 'Ufld1', m.Ufld1
-                )
-            FROM NbrMaster m
-            WHERE m.Type='CA' AND m.IsActive=1
-            ON DUPLICATE KEY UPDATE
-                source_biz_key=VALUES(source_biz_key), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
-                process_status=VALUES(process_status), raw_data=VALUES(raw_data), update_time=CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@Now", now));
-        return rows;
-    }
+        var srcClause = sourceSystem == null ? "" : " AND m.source_system=@Src";
+        var mTenant = $"IFNULL({MdpJsonSql.Int("m", "tenant_id")}, IFNULL(m.tenant_id, 0))";
+        var statusExpr = MdpJsonSql.Str("m", "Status");
+        var headWhere =
+            $"""
+            m.source_table='NbrMaster'
+              AND {MdpJsonSql.Str("m", "Type")}='CA'
+              AND {MdpJsonSql.BoolTrue("m", "IsActive")}{srcClause}
+            """;
 
-    /// <summary>贴源明细:NbrDetail(CA) -> mdp_stg_outsource_issue_detail。返回源 CA 明细行数。</summary>
-    private async Task<int> SyncDetailStagingAsync(string batchId, DateTime now)
-    {
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
         var rows = await _db.Ado.GetIntAsync(
-            """
-            SELECT COUNT(1) FROM NbrDetail dt
-            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
-            WHERE dt.Type='CA'
-            """);
-        await _db.Ado.ExecuteCommandAsync(
-            """
-            INSERT INTO mdp_stg_outsource_issue_detail
-            (tenant_id, factory_id, source_system, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time, process_status, raw_data)
-            SELECT
-                IFNULL(dt.tenant_id, 0), 1, 'AIDOP', 'NbrDetail',
-                CAST(dt.RecID AS CHAR), CONCAT(CAST(dt.Nbr AS CHAR), '#', dt.Line), @BatchId, @Now, 'PENDING',
-                JSON_OBJECT(
-                    'RecID', dt.RecID, 'NbrRecID', dt.NbrRecID, 'Nbr', dt.Nbr, 'Line', dt.Line,
-                    'ItemNum', dt.ItemNum, 'ItemName', CAST(dt.ItemName AS CHAR), 'UM', dt.UM, 'QtyOrd', dt.QtyOrd,
-                    'LocationFrom', dt.LocationFrom, 'LocationTo', dt.LocationTo, 'Status', dt.Status, 'Remark', CAST(dt.Remark AS CHAR)
-                )
-            FROM NbrDetail dt
-            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
-            WHERE dt.Type='CA'
-            ON DUPLICATE KEY UPDATE
-                source_biz_key=VALUES(source_biz_key), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
-                process_status=VALUES(process_status), raw_data=VALUES(raw_data), update_time=CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@Now", now));
-        return rows;
-    }
+            $"SELECT COUNT(1) FROM mdp_stg_outsource_issue_pull m WHERE {headWhere}", countPars);
 
-    /// <summary>标准化头:NbrMaster(CA) + DepartmentMaster -> mdp_std_outsource_issue。返回处理头行数。</summary>
-    private async Task<int> TransformHeadStandardAsync(string batchId, DateTime now)
-    {
-        var rows = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM NbrMaster WHERE Type='CA' AND IsActive=1");
-        await _db.Ado.ExecuteCommandAsync(
-            """
+        var insertSql =
+            $"""
             INSERT INTO mdp_std_outsource_issue
             (tenant_id, factory_id, source_system, bill_no, issue_date, outsource_no, work_order,
              department_code, department_name, issuer, status, status_desc, remark, create_user,
              source_create_time, eff_date, source_biz_key, sync_batch_id, sync_time)
             SELECT
-                IFNULL(m.tenant_id, 0), 1, 'AIDOP', m.Nbr, m.Date, m.Address, m.WorkOrd,
-                m.Department, d.Descr, CAST(m.User1 AS CHAR), m.Status,
-                CASE WHEN m.Status='C' THEN '关闭' ELSE '' END,
-                m.Remark, m.CreateUser, m.CreateTime, m.EffDate,
-                CAST(m.Nbr AS CHAR), @BatchId, @Now
-            FROM NbrMaster m
-            LEFT JOIN DepartmentMaster d ON d.Domain = m.Domain AND d.Department = m.Department
-            WHERE m.Type='CA' AND m.IsActive=1
+                {mTenant}, 1, IFNULL(NULLIF(m.source_system,''), 'AIDOP'),
+                {MdpJsonSql.Str("m", "Nbr")}, {MdpJsonSql.DateTimeSec("m", "Date")}, {MdpJsonSql.Str("m", "Address")}, {MdpJsonSql.Str("m", "WorkOrd")},
+                {MdpJsonSql.Str("m", "Department")}, d.Descr, {MdpJsonSql.Str("m", "User1")}, {statusExpr},
+                CASE WHEN {statusExpr}='C' THEN '关闭' ELSE '' END,
+                {MdpJsonSql.Str("m", "Remark")}, {MdpJsonSql.Str("m", "CreateUser")}, {MdpJsonSql.DateTimeSec("m", "CreateTime")}, {MdpJsonSql.DateTimeSec("m", "EffDate")},
+                {MdpJsonSql.Str("m", "Nbr")}, @BatchId, @Now
+            FROM mdp_stg_outsource_issue_pull m
+            LEFT JOIN DepartmentMaster d
+              ON d.Domain = {MdpJsonSql.Str("m", "Domain")} AND d.Department = {MdpJsonSql.Str("m", "Department")}
+            WHERE {headWhere}
             ON DUPLICATE KEY UPDATE
                 issue_date=VALUES(issue_date), outsource_no=VALUES(outsource_no), work_order=VALUES(work_order),
                 department_code=VALUES(department_code), department_name=VALUES(department_name), issuer=VALUES(issuer),
                 status=VALUES(status), status_desc=VALUES(status_desc), remark=VALUES(remark), create_user=VALUES(create_user),
                 source_create_time=VALUES(source_create_time), eff_date=VALUES(eff_date),
                 sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@Now", now));
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         return rows;
     }
 
-    /// <summary>标准化明细:NbrDetail(CA) -> mdp_std_outsource_issue_detail(仅已确认 9 列;回填 std_head_id)。返回处理明细行数。</summary>
-    private async Task<int> TransformDetailStandardAsync(string batchId, DateTime now)
+    /// <summary>标准化明细:pull stg(NbrDetail, CA) self-join stg(NbrMaster) by RecID↔NbrRecID → mdp_std_outsource_issue_detail(仅 9 列;回填 std_head_id)。</summary>
+    private async Task<int> TransformDetailStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
     {
-        var rows = await _db.Ado.GetIntAsync(
-            """
-            SELECT COUNT(1) FROM NbrDetail dt
-            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
-            WHERE dt.Type='CA'
-            """);
-        await _db.Ado.ExecuteCommandAsync(
-            """
+        var srcClause = sourceSystem == null ? "" : " AND m.source_system=@Src AND d.source_system=@Src";
+        var dTenant = $"IFNULL({MdpJsonSql.Int("d", "tenant_id")}, IFNULL(d.tenant_id, 0))";
+        var fromJoinWhere =
+            $"""
+            FROM mdp_stg_outsource_issue_pull d
+            JOIN mdp_stg_outsource_issue_pull m
+              ON m.source_table='NbrMaster'
+             AND {MdpJsonSql.Int("m", "RecID")} = {MdpJsonSql.Int("d", "NbrRecID")}
+             AND {MdpJsonSql.Str("m", "Type")}='CA'
+             AND {MdpJsonSql.BoolTrue("m", "IsActive")}
+            LEFT JOIN mdp_std_outsource_issue h
+              ON h.tenant_id = {dTenant} AND h.bill_no = {MdpJsonSql.Str("d", "Nbr")}
+            WHERE d.source_table='NbrDetail' AND {MdpJsonSql.Str("d", "Type")}='CA'{srcClause}
+            """;
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        var rows = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromJoinWhere}", countPars);
+
+        var insertSql =
+            $"""
             INSERT INTO mdp_std_outsource_issue_detail
             (tenant_id, std_head_id, bill_no, line, item_num, item_name, um, qty_ord,
              location_from, location_to, status, remark, source_biz_key, sync_batch_id, sync_time)
             SELECT
-                IFNULL(dt.tenant_id, 0), h.id, dt.Nbr, dt.Line, dt.ItemNum, CAST(dt.ItemName AS CHAR), dt.UM, dt.QtyOrd,
-                dt.LocationFrom, dt.LocationTo, dt.Status, CAST(dt.Remark AS CHAR),
-                CONCAT(CAST(dt.Nbr AS CHAR), '#', dt.Line), @BatchId, @Now
-            FROM NbrDetail dt
-            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
-            LEFT JOIN mdp_std_outsource_issue h ON h.tenant_id = IFNULL(dt.tenant_id, 0) AND h.bill_no = dt.Nbr
-            WHERE dt.Type='CA'
+                {dTenant}, h.id, {MdpJsonSql.Str("d", "Nbr")}, {MdpJsonSql.Int("d", "Line")}, {MdpJsonSql.Str("d", "ItemNum")}, {MdpJsonSql.Str("d", "ItemName")}, {MdpJsonSql.Str("d", "UM")}, {MdpJsonSql.Dec("d", "QtyOrd", 18, 6)},
+                {MdpJsonSql.Str("d", "LocationFrom")}, {MdpJsonSql.Str("d", "LocationTo")}, {MdpJsonSql.Str("d", "Status")}, {MdpJsonSql.Str("d", "Remark")},
+                CONCAT(IFNULL({MdpJsonSql.Str("d", "Nbr")},''), '#', IFNULL({MdpJsonSql.Int("d", "Line")},0)), @BatchId, @Now
+            {fromJoinWhere}
             ON DUPLICATE KEY UPDATE
                 std_head_id=VALUES(std_head_id), line=VALUES(line), item_num=VALUES(item_num), item_name=VALUES(item_name),
                 um=VALUES(um), qty_ord=VALUES(qty_ord), location_from=VALUES(location_from), location_to=VALUES(location_to),
                 status=VALUES(status), remark=VALUES(remark), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
                 update_time=CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@Now", now));
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         return rows;
     }
 
@@ -338,3 +439,15 @@ public sealed class OutsourceIssueMdpSyncResult
     public int HeadStandardRows { get; set; }
     public int DetailStandardRows { get; set; }
 }
+
+/// <summary>S5 委外发料双模式入站结果。</summary>
+public sealed class OutsourceIssueInboundResult
+{
+    public string PullBatchId { get; set; } = string.Empty;
+    public int RowsPulled { get; set; }
+    public int RowsWrittenStg { get; set; }
+    public string? NewCursor { get; set; }
+    public string TransformBatchId { get; set; } = string.Empty;
+    public int StdHeadRows { get; set; }
+    public int StdDetailRows { get; set; }
+}

+ 286 - 61
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionReturnMdpSyncService.cs

@@ -1,37 +1,53 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+
 namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 
 /// <summary>
-/// S5 生产退料单 数据中台只读同步转换服务(DOP 内部,head-detail 双表,独立于 S5MdpSyncTransformService 的 KPI 管线)。
+/// S5 生产退料单 数据中台只读同步转换服务(DOP 内部,head-detail 双 std 专表,独立于 S5MdpSyncTransformService 的 KPI 管线)。
+///
+/// B-① 迁通用管线:源(NbrMaster/NbrDetail, Type='WOD') 经执行器灌 mdp_stg_production_return(头/明细共表,source_table 区分)
+///   → transform 读 stg(MdpJsonSql 跨源类型兼容)→ mdp_std_production_return(_detail)(typed,明细回填 std_head_id)。
+///   维表 DepartmentMaster(d) / ItemMaster(i) / LocationMaster(lf,lt) 仍本库 LEFT JOIN(富化 desc,业务 code 由事实表保留)。
 ///
-/// 源:aidopdev NbrMaster(m) + NbrDetail(dt),业务类型 Type='WOD';头明细关联 dt.NbrRecID -> m.RecID。
-///     维表 DepartmentMaster(d) / ItemMaster(i) / LocationMaster(lf,lt) 全 LEFT JOIN(LocationMaster 列名小写 location/descr)。
-/// 链路:NbrMaster/NbrDetail(WOD) 只读 → mdp_std_production_return(_detail)(typed)。
-///     不经 stg、不建 dwd(只读列表/详情);WOD=退料单业务类型有 S2 WorkOrderSchedulingService 代码实证。
+/// 双源:本库实体 S5_PRODUCTION_RETURN_MASTER/_DETAIL(AIDOPDEV_MYSQL, status=1);
+///   dopdemorq 第二源 S5_PRODUCTION_RETURN_MASTER_SQLSERVER/_DETAIL_SQLSERVER(DOPDEMORQ_SQLSERVER, status=0 就位不启用)。
 ///
 /// 约束:
-///   - 只读源表,仅写 mdp_std_production_return(_detail);绝不 INSERT/UPDATE/DELETE NbrMaster/NbrDetail;不碰 S2 写路径。
-///   - 头过滤 m.Type='WOD' AND m.IsActive=1 AND m.IsReturn=0;明细 dt.Type='WOD' 且经 NbrRecID join WOD 头。
-///   - status_desc 规则本批仅:Status='C' -> '关闭',其他 -> 原值(UPPER)。
-///   - 退料人 return_user 本批取 m.User1(列表 SQL 口径);user1/user2 原样落库,页面策略后置微调。
-///   - trans_type_text 恒空(旧系统口径);不做 danjia/jiage/库存事务/状态流转。
-///   - WOD 为 0 行时转换成功完成、处理数为 0,不报错。
+///   - 只读源/贴源,仅写 mdp_stg_production_return / mdp_std_production_return(_detail);绝不 INSERT/UPDATE/DELETE NbrMaster/NbrDetail;不碰 S2 写路径。
+///   - 头过滤 Type='WOD' AND IsActive(判真) AND IsReturn(判假);明细 Type='WOD' 且经 RecID↔NbrRecID join WOD 头。
+///   - status_desc 规则:Status='C' -> '关闭',其他 -> 原值(UPPER)。trans_type_text 恒空(旧系统口径);不做 danjia/库存事务/状态流转。
+///   - tenant_id 从 raw_data 保留(本库源含该列 → std 租户不回归;SQL Server 源无此列 → 回落入站上下文 tenant)。
+///   - WOD 为 0 行时转换成功完成、处理数为 0,不报错。切源 FULL Replace 头/明细各一次单事务、仅按 tenant、Pull 成功后才 destructive。
 /// </summary>
 public class ProductionReturnMdpSyncService : ITransient
 {
     private const string JobCode = "S5_PRODUCTION_RETURN_MDP_SYNC";
+    private const string InboundMasterEntityCode = "S5_PRODUCTION_RETURN_MASTER";
+    private const string InboundDetailEntityCode = "S5_PRODUCTION_RETURN_DETAIL";
+
+    private const string SqlServerSourceCode = "DOPDEMORQ_SQLSERVER";
+    private const string SqlServerMasterEntityCode = "S5_PRODUCTION_RETURN_MASTER_SQLSERVER";
+    private const string SqlServerDetailEntityCode = "S5_PRODUCTION_RETURN_DETAIL_SQLSERVER";
+
+    private const string StdHeadTable = "mdp_std_production_return";
+    private const string StdDetailTable = "mdp_std_production_return_detail";
 
     private readonly ISqlSugarClient _db;
+    private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public ProductionReturnMdpSyncService(ISqlSugarClient db)
+    public ProductionReturnMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
     {
         _db = db;
+        _pullDispatcher = pullDispatcher;
     }
 
-    /// <summary>全量同步转换:源(WOD 头/明细) → 标准层(头/明细)。</summary>
+    /// <summary>全量:本地 DB 执行器灌 stg(头+明细) → 标准层头/明细(读 stg)。</summary>
     public async Task<ProductionReturnMdpSyncResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
     {
         cancellationToken.ThrowIfCancellationRequested();
         await EnsureTablesAsync();
+        await EnsureStgTableAsync();
 
         var now = DateTime.Now;
         var batchId = $"S5_PROD_RTN_FULL_{now:yyyyMMddHHmmss}";
@@ -40,6 +56,14 @@ public class ProductionReturnMdpSyncService : ITransient
 
         try
         {
+            var pullCtx = new MdpPullContext
+            {
+                TenantId = 0,
+                FullRefresh = true,
+                TaskCode = "S5_PRODUCTION_RETURN_INBOUND",
+                BatchId = $"{batchId}_PULL"
+            };
+            await PopulateStgAsync(pullCtx, cancellationToken);
             result.HeadRows = await TransformHeadStandardAsync(batchId, now);
             result.DetailRows = await TransformDetailStandardAsync(batchId, now);
             await MarkRunSuccessAsync(runLogId, now, result);
@@ -52,6 +76,153 @@ public class ProductionReturnMdpSyncService : ITransient
         }
     }
 
+    /// <summary>双模式入站:执行器抽主/明细 → stg,再跑 WOD 标准层头/明细(读 stg)。</summary>
+    public async Task<ProductionReturnInboundResult> RunInboundAsync(
+        long tenantId = 0,
+        bool fullRefresh = false,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsureStgTableAsync();
+
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = fullRefresh,
+            TaskCode = "S5_PRODUCTION_RETURN_INBOUND",
+            BatchId = $"S5_PROD_RTN_IN_{now:yyyyMMddHHmmss}"
+        };
+        var pull = await PopulateStgAsync(pullCtx, cancellationToken);
+
+        var batchId = $"S5_PROD_RTN_STD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "INBOUND");
+        var result = new ProductionReturnMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.HeadRows = await TransformHeadStandardAsync(batchId, now);
+            result.DetailRows = await TransformDetailStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new ProductionReturnInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = pull.RowsPulled,
+            RowsWrittenStg = pull.RowsWritten,
+            NewCursor = pull.NewCursor,
+            TransformBatchId = batchId,
+            StdHeadRows = result.HeadRows,
+            StdDetailRows = result.DetailRows
+        };
+    }
+
+    /// <summary>
+    /// 双源切换 FULL Replace:从指定 DB 源(默认 DOPDEMORQ_SQLSERVER)PullAll 全量灌 stg,
+    /// 成功后头/明细各一次单事务 FULL 重建(按 tenant 精确隔离),消除旧源独有业务键残留。
+    /// 顺序:先 head(写 std 头,供明细回填 std_head_id),再 detail。两次独立事务,Pull 成功后才 destructive。
+    /// SQLSERVER 实体默认 status=0(就位不启用);dopdemorq 六表当前空,Phase 1 不实际执行 destructive 切换。
+    /// </summary>
+    public async Task<ProductionReturnInboundResult> RunSourceSwitchFullAsync(
+        string sourceCode = SqlServerSourceCode,
+        string masterEntityCode = SqlServerMasterEntityCode,
+        string detailEntityCode = SqlServerDetailEntityCode,
+        long tenantId = 0,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsureStgTableAsync();
+
+        var now = DateTime.Now;
+        // 1) PullAll 抽尽 + fullRefresh=true。任一 Pull 失败会抛异常,std 未动。
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = true,
+            TaskCode = "S5_PRODUCTION_RETURN_INBOUND",
+            BatchId = $"S5_PROD_RTN_SW_{now:yyyyMMddHHmmss}"
+        };
+        var master = await _pullDispatcher.PullAllByEntityCodeAsync(masterEntityCode, pullCtx, cancellationToken);
+        var detail = await _pullDispatcher.PullAllByEntityCodeAsync(detailEntityCode, pullCtx, cancellationToken);
+
+        // 2) FULL Replace:头先删建(供明细回填 std_head_id),明细后删建;各自单事务、仅按 tenant、仅当前 source_system。
+        var batchId = $"S5_PROD_RTN_SWSTD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "SOURCE_SWITCH");
+        var result = new ProductionReturnMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.HeadRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdHeadTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformHeadStandardAsync(batchId, now, sourceCode),
+                cancellationToken);
+            result.DetailRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdDetailTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformDetailStandardAsync(batchId, now, sourceCode),
+                cancellationToken);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new ProductionReturnInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = master.RowsPulled + detail.RowsPulled,
+            RowsWrittenStg = master.RowsWritten + detail.RowsWritten,
+            NewCursor = detail.NewCursor ?? master.NewCursor,
+            TransformBatchId = batchId,
+            StdHeadRows = result.HeadRows,
+            StdDetailRows = result.DetailRows
+        };
+    }
+
+    private async Task<(int RowsPulled, int RowsWritten, string? NewCursor)> PopulateStgAsync(
+        MdpPullContext pullCtx, CancellationToken cancellationToken)
+    {
+        var master = await _pullDispatcher.PullByEntityCodeAsync(InboundMasterEntityCode, pullCtx, cancellationToken);
+        var detail = await _pullDispatcher.PullByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
+        return (
+            master.RowsPulled + detail.RowsPulled,
+            master.RowsWritten + detail.RowsWritten,
+            detail.NewCursor ?? master.NewCursor);
+    }
+
+    private async Task EnsureStgTableAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_stg_production_return (
+                id BIGINT PRIMARY KEY AUTO_INCREMENT,
+                tenant_id BIGINT NOT NULL,
+                source_system VARCHAR(50) NULL,
+                source_table VARCHAR(200),
+                source_row_id VARCHAR(200),
+                source_biz_key VARCHAR(300) NULL,
+                raw_data JSON,
+                sync_batch_id VARCHAR(100),
+                sync_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+                process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+                process_message VARCHAR(500) NULL,
+                create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_source_key (source_system, source_table, source_biz_key),
+                KEY idx_batch (sync_batch_id),
+                KEY idx_src (source_table, source_row_id),
+                KEY idx_tenant (tenant_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S5生产退料执行器贴源层(头/明细共表,source_table 区分)'
+            """);
+    }
+
     /// <summary>防御式建表(与 UpdateScripts/1.0.213.sql 同构,幂等)。</summary>
     private async Task EnsureTablesAsync()
     {
@@ -138,33 +309,54 @@ public class ProductionReturnMdpSyncService : ITransient
     }
 
     /// <summary>
-    /// 标准化头:NbrMaster(WOD) + DepartmentMaster -> mdp_std_production_return。返回处理头行数。
-    /// SQL 由旧系统头 SQL 翻译(SQL Server -&gt; MySQL):IsNull-&gt;IFNULL、upper() 保留、
-    /// rtrim(a+' '+b)-&gt;TRIM(CONCAT(...))、with(nolock) 去除。
+    /// 标准化头:stg(NbrMaster, WOD) + DepartmentMaster(本库) → mdp_std_production_return。返回处理头行数。
+    /// 全字段经 MdpJsonSql 跨源类型兼容;tenant_id 从 raw_data 保留(本库源含列 → 不回归;SQL Server 源无列 → 回落 stg 上下文 tenant)。
     /// </summary>
-    private async Task<int> TransformHeadStandardAsync(string batchId, DateTime now)
+    private async Task<int> TransformHeadStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
     {
+        // WOD 头判定:Type='WOD' AND IsActive(判真) AND IsReturn(判假, 兼容 bit true/false 与 0/1)。
+        var srcClause = sourceSystem == null ? "" : " AND m.source_system=@Src";
+        var mTenant = $"IFNULL({MdpJsonSql.Int("m", "tenant_id")}, IFNULL(m.tenant_id, 0))";
+        var statusExpr = $"UPPER(IFNULL({MdpJsonSql.Str("m", "Status")}, ''))";
+        var headWhere =
+            $"""
+            m.source_table='NbrMaster'
+              AND {MdpJsonSql.Str("m", "Type")}='WOD'
+              AND {MdpJsonSql.BoolTrue("m", "IsActive")}
+              AND LOWER({MdpJsonSql.Raw("m", "IsReturn")}) IN ('0','false'){srcClause}
+            """;
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
         var rows = await _db.Ado.GetIntAsync(
-            "SELECT COUNT(1) FROM NbrMaster m WHERE m.Type='WOD' AND m.IsActive=1 AND m.IsReturn=0");
-        await _db.Ado.ExecuteCommandAsync(
-            """
+            $"SELECT COUNT(1) FROM mdp_stg_production_return m WHERE {headWhere}", countPars);
+
+        var insertSql =
+            $"""
             INSERT INTO mdp_std_production_return
             (tenant_id, factory_id, source_system, domain, rec_id, nbr, return_date, status, status_desc,
              work_ord, department, department_desc, qty_ord, prod_line, applicant_name, return_user,
              user1, user2, remark, create_user, source_create_time, trans_type, trans_type_text,
              eff_date, ufld1, source_biz_key, sync_batch_id, sync_time)
             SELECT
-                IFNULL(m.tenant_id, 0), 1, 'AIDOP', m.Domain, m.RecID, m.Nbr, m.Date,
-                UPPER(IFNULL(m.Status, '')),
-                CASE WHEN UPPER(IFNULL(m.Status,''))='C' THEN '关闭' ELSE UPPER(IFNULL(m.Status,'')) END,
-                m.WorkOrd, m.Department, TRIM(CONCAT(m.Department, ' ', IFNULL(d.Descr, ''))),
-                m.QtyOrd, m.ProdLine, m.Name, CAST(m.User1 AS CHAR),
-                CAST(m.User1 AS CHAR), CAST(m.User2 AS CHAR), m.Remark, m.CreateUser, m.CreateTime,
-                m.TransType, '', m.EffDate, m.Ufld1,
-                CONCAT(m.Domain, ':', m.RecID), @BatchId, @Now
-            FROM NbrMaster m
-            LEFT JOIN DepartmentMaster d ON d.Domain = m.Domain AND d.Department = m.Department
-            WHERE m.Type='WOD' AND m.IsActive=1 AND m.IsReturn=0
+                {mTenant}, 1, IFNULL(NULLIF(m.source_system,''), 'AIDOP'),
+                {MdpJsonSql.Str("m", "Domain")}, {MdpJsonSql.Int("m", "RecID")}, {MdpJsonSql.Str("m", "Nbr")},
+                {MdpJsonSql.DateTimeSec("m", "Date")},
+                {statusExpr},
+                CASE WHEN {statusExpr}='C' THEN '关闭' ELSE {statusExpr} END,
+                {MdpJsonSql.Str("m", "WorkOrd")}, {MdpJsonSql.Str("m", "Department")},
+                TRIM(CONCAT(IFNULL({MdpJsonSql.Str("m", "Department")},''), ' ', IFNULL(d.Descr, ''))),
+                {MdpJsonSql.Dec("m", "QtyOrd", 18, 5)}, {MdpJsonSql.Str("m", "ProdLine")}, {MdpJsonSql.Str("m", "Name")},
+                {MdpJsonSql.Str("m", "User1")},
+                {MdpJsonSql.Str("m", "User1")}, {MdpJsonSql.Str("m", "User2")}, {MdpJsonSql.Str("m", "Remark")},
+                {MdpJsonSql.Str("m", "CreateUser")}, {MdpJsonSql.DateTimeSec("m", "CreateTime")},
+                {MdpJsonSql.Str("m", "TransType")}, '', {MdpJsonSql.DateTimeSec("m", "EffDate")}, {MdpJsonSql.Str("m", "Ufld1")},
+                CONCAT(IFNULL({MdpJsonSql.Str("m", "Domain")},''), ':', IFNULL({MdpJsonSql.Int("m", "RecID")},0)),
+                @BatchId, @Now
+            FROM mdp_stg_production_return m
+            LEFT JOIN DepartmentMaster d
+              ON d.Domain = {MdpJsonSql.Str("m", "Domain")} AND d.Department = {MdpJsonSql.Str("m", "Department")}
+            WHERE {headWhere}
             ON DUPLICATE KEY UPDATE
                 factory_id=VALUES(factory_id), nbr=VALUES(nbr), return_date=VALUES(return_date),
                 status=VALUES(status), status_desc=VALUES(status_desc), work_ord=VALUES(work_ord),
@@ -174,45 +366,65 @@ public class ProductionReturnMdpSyncService : ITransient
                 source_create_time=VALUES(source_create_time), trans_type=VALUES(trans_type),
                 trans_type_text=VALUES(trans_type_text), eff_date=VALUES(eff_date), ufld1=VALUES(ufld1),
                 sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@Now", now));
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         return rows;
     }
 
     /// <summary>
-    /// 标准化明细:NbrDetail(WOD) + ItemMaster + LocationMaster -> mdp_std_production_return_detail(回填 std_head_id)。
-    /// 返回处理明细行数。LocationMaster 列名小写 location/descr,分别 join LocationFrom/LocationTo
+    /// 标准化明细:stg(NbrDetail, WOD) self-join stg(NbrMaster) by RecID↔NbrRecID + ItemMaster/LocationMaster(本库)
+    /// → mdp_std_production_return_detail(回填 std_head_id)。返回处理明细行数
     /// </summary>
-    private async Task<int> TransformDetailStandardAsync(string batchId, DateTime now)
+    private async Task<int> TransformDetailStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
     {
+        var srcClause = sourceSystem == null ? "" : " AND m.source_system=@Src AND d.source_system=@Src";
+        var dTenant = $"IFNULL({MdpJsonSql.Int("d", "tenant_id")}, IFNULL(d.tenant_id, 0))";
+        var dStatusExpr = $"UPPER(IFNULL({MdpJsonSql.Str("d", "Status")}, ''))";
+        // 明细 from/join/where 片段(count 与 insert 共用,避免发散)。
+        var fromJoinWhere =
+            $"""
+            FROM mdp_stg_production_return d
+            JOIN mdp_stg_production_return m
+              ON m.source_table='NbrMaster'
+             AND {MdpJsonSql.Int("m", "RecID")} = {MdpJsonSql.Int("d", "NbrRecID")}
+             AND {MdpJsonSql.Str("m", "Type")}='WOD'
+             AND {MdpJsonSql.BoolTrue("m", "IsActive")}
+             AND LOWER({MdpJsonSql.Raw("m", "IsReturn")}) IN ('0','false')
+            LEFT JOIN ItemMaster i
+              ON i.Domain = {MdpJsonSql.Str("d", "Domain")} AND i.ItemNum = {MdpJsonSql.Str("d", "ItemNum")}
+            LEFT JOIN LocationMaster lf
+              ON lf.tenant_id = {dTenant} AND lf.location = {MdpJsonSql.Str("d", "LocationFrom")}
+            LEFT JOIN LocationMaster lt
+              ON lt.tenant_id = {dTenant} AND lt.location = {MdpJsonSql.Str("d", "LocationTo")}
+            LEFT JOIN mdp_std_production_return h
+              ON h.tenant_id = {dTenant} AND h.domain = {MdpJsonSql.Str("m", "Domain")} AND h.rec_id = {MdpJsonSql.Int("d", "NbrRecID")}
+            WHERE d.source_table='NbrDetail' AND {MdpJsonSql.Str("d", "Type")}='WOD'{srcClause}
+            """;
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
         var rows = await _db.Ado.GetIntAsync(
-            """
-            SELECT COUNT(1) FROM NbrDetail dt
-            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='WOD' AND m.IsActive=1 AND m.IsReturn=0
-            WHERE dt.Type='WOD'
-            """);
-        await _db.Ado.ExecuteCommandAsync(
-            """
+            $"SELECT COUNT(1) {fromJoinWhere}", countPars);
+
+        var insertSql =
+            $"""
             INSERT INTO mdp_std_production_return_detail
             (tenant_id, std_head_id, domain, nbr_rec_id, rec_id, line, item_num, item_name, item_spec, um,
              qty_to, qty_rec, location_from, location_from_desc, location_to, location_to_desc,
              lot_serial, status, status_desc, remark, work_ord, source_biz_key, sync_batch_id, sync_time)
             SELECT
-                IFNULL(dt.tenant_id, 0), h.id, m.Domain, dt.NbrRecID, dt.RecID, dt.Line,
-                dt.ItemNum, i.Descr, i.Descr1, dt.UM,
-                dt.QtyTo, dt.QtyRec, dt.LocationFrom, lf.descr, dt.LocationTo, lt.descr,
-                CAST(dt.LotSerial AS CHAR), UPPER(IFNULL(dt.Status, '')),
-                CASE WHEN UPPER(IFNULL(dt.Status,''))='C' THEN '关闭' ELSE UPPER(IFNULL(dt.Status,'')) END,
-                CAST(dt.Remark AS CHAR), dt.WorkOrd,
-                CONCAT(m.Domain, ':', dt.NbrRecID, ':', dt.Line), @BatchId, @Now
-            FROM NbrDetail dt
-            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='WOD' AND m.IsActive=1 AND m.IsReturn=0
-            LEFT JOIN ItemMaster i ON i.Domain = dt.Domain AND i.ItemNum = dt.ItemNum
-            LEFT JOIN LocationMaster lf ON lf.tenant_id = IFNULL(dt.tenant_id, 0) AND lf.location = dt.LocationFrom
-            LEFT JOIN LocationMaster lt ON lt.tenant_id = IFNULL(dt.tenant_id, 0) AND lt.location = dt.LocationTo
-            LEFT JOIN mdp_std_production_return h ON h.tenant_id = IFNULL(dt.tenant_id, 0) AND h.domain = m.Domain AND h.rec_id = dt.NbrRecID
-            WHERE dt.Type='WOD'
+                {dTenant}, h.id, {MdpJsonSql.Str("m", "Domain")}, {MdpJsonSql.Int("d", "NbrRecID")}, {MdpJsonSql.Int("d", "RecID")}, {MdpJsonSql.Int("d", "Line")},
+                {MdpJsonSql.Str("d", "ItemNum")}, i.Descr, i.Descr1, {MdpJsonSql.Str("d", "UM")},
+                {MdpJsonSql.Dec("d", "QtyTo", 18, 5)}, {MdpJsonSql.Dec("d", "QtyRec", 18, 5)},
+                {MdpJsonSql.Str("d", "LocationFrom")}, lf.descr, {MdpJsonSql.Str("d", "LocationTo")}, lt.descr,
+                {MdpJsonSql.Str("d", "LotSerial")}, {dStatusExpr},
+                CASE WHEN {dStatusExpr}='C' THEN '关闭' ELSE {dStatusExpr} END,
+                {MdpJsonSql.Str("d", "Remark")}, {MdpJsonSql.Str("d", "WorkOrd")},
+                CONCAT(IFNULL({MdpJsonSql.Str("m", "Domain")},''), ':', IFNULL({MdpJsonSql.Int("d", "NbrRecID")},0), ':', IFNULL({MdpJsonSql.Int("d", "Line")},0)),
+                @BatchId, @Now
+            {fromJoinWhere}
             ON DUPLICATE KEY UPDATE
                 std_head_id=VALUES(std_head_id), rec_id=VALUES(rec_id), item_num=VALUES(item_num),
                 item_name=VALUES(item_name), item_spec=VALUES(item_spec), um=VALUES(um),
@@ -221,9 +433,10 @@ public class ProductionReturnMdpSyncService : ITransient
                 location_to_desc=VALUES(location_to_desc), lot_serial=VALUES(lot_serial),
                 status=VALUES(status), status_desc=VALUES(status_desc), remark=VALUES(remark), work_ord=VALUES(work_ord),
                 sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@Now", now));
+            """;
+        var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         return rows;
     }
 
@@ -288,3 +501,15 @@ public sealed class ProductionReturnMdpSyncResult
     public int HeadRows { get; set; }
     public int DetailRows { get; set; }
 }
+
+/// <summary>S5 生产退料双模式入站结果。</summary>
+public sealed class ProductionReturnInboundResult
+{
+    public string PullBatchId { get; set; } = string.Empty;
+    public int RowsPulled { get; set; }
+    public int RowsWrittenStg { get; set; }
+    public string? NewCursor { get; set; }
+    public string TransformBatchId { get; set; } = string.Empty;
+    public int StdHeadRows { get; set; }
+    public int StdDetailRows { get; set; }
+}

+ 48 - 29
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs

@@ -6,11 +6,11 @@ using System.Text.Json;
 namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 
 /// <summary>
-/// S5 物料仓储 — KPI 计算与刷新转换服务。边界:7 迁 2 留。
+/// S5 物料仓储 — KPI 计算与刷新转换服务。边界:8 迁 1 留。
 /// 已中台化(读本地标准层 mdp_std_t8_*,由 T8BaseInboundMdpSyncService 从 T8 贴源→标准):
-///   S5_L1_001 物料上线周期 / S5_L1_003 物料仓储人效。
+///   S5_L1_001 物料上线周期 / S5_L1_002 物料上线满足率(依赖 Cj_Bg_Head_Rep + kc_dd_list_cllist,已贴源)/ S5_L1_003 物料仓储人效。
 /// 仍 legacy 直连 T8(QueryT8Async,ConfigId=t8_v5):
-///   S5_L1_002 物料上线满足率(依赖 T8 报表 Cj_Bg_Head_Rep)/ S5_L1_004 品类物料库存周转(依赖 T8 TVF Rep_总账_存货_V3)。
+///   S5_L1_004 品类物料库存周转(依赖 T8 TVF Rep_总账_存货_V3,报表聚合结果无逐行主键,暂不中台化)。
 /// 结果统一落 dwd_t8_* 与 ado_s9_kpi_value_l1_day。计算口径沿用方老师 v5.4 KPI J 列。
 /// </summary>
 public class S5MdpSyncTransformService : ITransient
@@ -21,19 +21,27 @@ public class S5MdpSyncTransformService : ITransient
     private const string JobCode = "S5_MDP_SYNC_TRANSFORM";
     private const string JobName = "S5 物料仓储 MDP 同步与转换";
     private const string T8ConfigId = "t8_v5";
+    // 加固:legacy 直连 T8 查询的命令超时上限(秒)。防止 T8 TVF(Rep_总账_存货_V3) 慢或挂起时
+    // 查询无限期阻塞、进而长时间持有刷新锁(见 AidopT8KpiManualRefreshService)。
+    // 仅加超时护栏,不改 S5_L1_004 的 SQL / 参数 / 计算口径。(S5_L1_002 已中台化,不再走 QueryT8Async。)
+    private const int T8CommandTimeoutSeconds = 60;
     private const string ModuleCode = "S5";
     // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
     private const long NoticeReceiverUserId = 1300000000101L;
     private const string NoticeReceiverUserName = "超级管理员";
 
+    private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
+
     public S5MdpSyncTransformService(
         ISqlSugarClient db,
         SysNoticeService sysNoticeService,
-        ILogger<S5MdpSyncTransformService> logger)
+        ILogger<S5MdpSyncTransformService> logger,
+        SmartOps.KpiCalcDispatcher kpiCalcDispatcher)
     {
         _db = db;
         _sysNoticeService = sysNoticeService;
         _logger = logger;
+        _kpiCalcDispatcher = kpiCalcDispatcher;
     }
 
     public async Task<S5MdpSyncTransformResult> RunFullAsync(
@@ -72,7 +80,7 @@ public class S5MdpSyncTransformService : ITransient
             result.StageRows = 0;
             result.StandardRows = 0;
 
-            var sub16 = await BuildS5L1001MaterialOnlineCycleAsync(batchId, now, option, cancellationToken);
+            var sub16 = await BuildS5L1001MaterialOnlineCycleAsync(batchId, now, option, normalizedTrigger, cancellationToken);
             result.MergeSub("S5_L1_001", sub16);
 
             var sub17 = await BuildS5L1002MaterialOnlineFulfillmentAsync(batchId, now, option, cancellationToken);
@@ -98,9 +106,10 @@ public class S5MdpSyncTransformService : ITransient
     // KPI 实现(方老师 v5.4 KPI J 列 SQL 原逻辑直发 T8)
     // ─────────────────────────────────────────────────────────────────────────
 
-    /// <summary>S5_L1_001 物料上线周期 = 配送到产线日期(lbs=生产领料) - 收货日期(lbs=采购入库)。</summary>
+    /// <summary>S5_L1_001 物料上线周期 = 配送到产线日期(lbs=生产领料) - 收货日期(lbs=采购入库)。
+    /// 数据准备(写 dwd 明细)始终执行;最终 KPI 聚合由计算配置分发器接管(LEGACY_CODE/CONFIG_SQL)。</summary>
     private async Task<KpiBuildSubResult> BuildS5L1001MaterialOnlineCycleAsync(
-        string batchId, DateTime now, S5MdpRefreshOption option, CancellationToken ct)
+        string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
     {
         var sub = new KpiBuildSubResult();
 
@@ -166,12 +175,19 @@ ON DUPLICATE KEY UPDATE
         }
         sub.DwdRows = dwdAffected;
 
-        // KPI 值:所有物料 cycle_days 的算术平均;没有任一可计算物料时写 NULL,不伪装 0
-        decimal? metricValue = cycleDaysList.Count > 0
-            ? (decimal)cycleDaysList.Average()
-            : null;
-        sub.KpiRows = await UpsertKpiValueAsync("S5_L1_001", option.BizDate, metricValue, now, option);
-        sub.DenominatorStatus = cycleDaysList.Count > 0 ? "OK" : "NO_NUMERATOR";
+        // 数据准备(dwd 明细)已完成。最终 KPI 聚合交计算配置分发器:
+        //   无配置/LEGACY_CODE → 用下面 legacy 均值;CONFIG_SQL → 执行已发布只读 SQL;
+        //   CONFIG_SQL 失败不 fallback、不写值、保留上一成功值(ShouldUpsert=false)。
+        decimal? legacyValue = cycleDaysList.Count > 0 ? (decimal)cycleDaysList.Average() : null;
+        var legacyDenom = cycleDaysList.Count > 0 ? "OK" : "NO_NUMERATOR";
+        var dispatch = await _kpiCalcDispatcher.DispatchAsync(
+            "S5_L1_001", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
+            option.BizDate, option.DailyPeriodStart, option.DailyPeriodEnd, option.SourceZtid,
+            batchId, triggerType, legacyValue, legacyDenom, ct);
+        sub.KpiRows = dispatch.ShouldUpsert
+            ? await UpsertKpiValueAsync("S5_L1_001", option.BizDate, dispatch.MetricValue, now, option)
+            : 0;
+        sub.DenominatorStatus = dispatch.DenominatorStatus;
         return sub;
     }
 
@@ -181,31 +197,33 @@ ON DUPLICATE KEY UPDATE
     {
         var sub = new KpiBuildSubResult();
 
+        // 中台化:读本地标准层 mdp_std_t8_*(kc_tz_head.Id→src_id、Cj_Bg_Head_Rep/kc_dd_list_cllist 已贴源),
+        // 口径与原直发 T8 SQL 逐项等价(分子/分母/JOIN/去重/shtime<=kgdate/count 均不变),不再直连 t8_v5。
         const string sqlNumer = @"
 select lynoid as lynoid, count(*) as codenum
  from (
-   select a.lynoid as lynoid, b.code as code
-    from kc_tz_head a with(nolock)
-    inner join kc_tz_list b with(nolock) on a.Id=b.idid
+   select h.lynoid as lynoid, l.code as code
+    from mdp_std_t8_kc_tz_head h
+    inner join mdp_std_t8_kc_tz_list l on h.src_id=l.idid
     left join (
       select noid as noid, min(kgdate) as kgdate
-       from Cj_Bg_Head_Rep with(nolock)
+       from mdp_std_t8_cj_bg_head_rep
        where ztid=@ztid group by noid
-    ) c on a.lynoid=c.noid
-    where a.ztid=@ztid and a.lbs='生产领料' and a.hzyn=0 and a.zfyn=0 and a.shyn=1 and a.shtime<=c.kgdate
-    group by a.lynoid, b.code
+    ) c on h.lynoid=c.noid
+    where h.ztid=@ztid and h.lbs='生产领料' and h.hzyn=0 and h.zfyn=0 and h.shyn=1 and h.shtime<=c.kgdate
+    group by h.lynoid, l.code
  ) n
  group by lynoid";
         const string sqlDenom = @"
-select a.noid as noid, count(b.id) as listnum
- from kc_dd_head a with(nolock)
- left join kc_dd_list_cllist b with(nolock) on a.Id=b.idid
- where a.ztid=@ztid and a.lbs='生产任务' and a.zf=0 and a.shyn=1
- group by a.noid";
+select h.noid as noid, count(l.src_id) as listnum
+ from mdp_std_t8_kc_dd_head h
+ left join mdp_std_t8_kc_dd_list_cllist l on h.src_id=l.idid
+ where h.ztid=@ztid and h.lbs='生产任务' and h.zf=0 and h.shyn=1
+ group by h.noid";
 
         var p = new[] { new SugarParameter("@ztid", option.SourceZtid) };
-        var numerRows = await QueryT8Async<S5FulfillmentNumerRow>(sqlNumer, p);
-        var denomRows = await QueryT8Async<S5FulfillmentDenomRow>(sqlDenom, p);
+        var numerRows = await _db.Ado.SqlQueryAsync<S5FulfillmentNumerRow>(sqlNumer, p);
+        var denomRows = await _db.Ado.SqlQueryAsync<S5FulfillmentDenomRow>(sqlDenom, p);
         sub.T8Rows = numerRows.Count + denomRows.Count;
 
         var numerByOrder = numerRows.Where(r => !string.IsNullOrEmpty(r.lynoid))
@@ -422,11 +440,12 @@ ON DUPLICATE KEY UPDATE
     // 跨库 / 写入 / 日志 封装
     // ─────────────────────────────────────────────────────────────────────────
 
-    // legacy 直连 T8:仅 S5_L1_002(Cj_Bg_Head_Rep 报表)/ S5_L1_004(Rep_总账_存货_V3 TVF)仍用;
-    // 这两类源非干净基表,暂不纳入双模式贴源,待底表摸清后单独立批。已中台化的 001/003 不再走此方法。
+    // legacy 直连 T8:仅 S5_L1_004(Rep_总账_存货_V3 TVF,报表聚合结果无逐行主键)仍用;
+    // 已中台化的 S5_L1_001/002/003 均读 mdp_std_t8_*,不再走此方法。
     private async Task<List<T>> QueryT8Async<T>(string sql, SugarParameter[] parameters)
     {
         var t8 = _db.AsTenant().GetConnectionScope(T8ConfigId);
+        t8.Ado.CommandTimeOut = T8CommandTimeoutSeconds;
         return await t8.Ado.SqlQueryAsync<T>(sql, parameters);
     }
 

+ 287 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiCalcConfigService.cs

@@ -0,0 +1,287 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 计算配置服务:CRUD + 校验 + 试算 + 发布/停用/激活(发布状态机,单生效版本事务保证)。
+/// 租户显式控制(ClearFilter):配置落在"分发器运行时查找的租户"= KPI 数据租户(S5=AidopSourceTenantMap 解析),
+/// 而非 API 调用者 JWT 租户;否则运行时找不到配置。SqlScript 属高危配置,接口须登录鉴权(Controller 层)。
+/// </summary>
+public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
+{
+    private const string EngineLegacyCode = "LEGACY_CODE";
+    private const string EngineConfigSql = "CONFIG_SQL";
+    private const string EngineLegacyTvf = "LEGACY_TVF";
+    private const string StatusDraft = "DRAFT";
+    private const string StatusPublished = "PUBLISHED";
+    private const string StatusRetired = "RETIRED";
+    private static readonly string[] TenantScopedModules = { "S5", "S6", "S7" };
+
+    private readonly ISqlSugarClient _db;
+    private readonly KpiSqlReadOnlyExecutor _executor;
+
+    public AdoSmartOpsKpiCalcConfigService(ISqlSugarClient db, KpiSqlReadOnlyExecutor executor)
+    {
+        _db = db;
+        _executor = executor;
+    }
+
+    /// <summary>解析配置应归属的租户(= 运行时 KPI 落库租户)。S5/S6/S7 走 T8 账套映射(pbxfxp→AIDOP)。</summary>
+    public static long ResolveKpiTenantId(string moduleCode)
+    {
+        if (TenantScopedModules.Contains((moduleCode ?? "").Trim().ToUpperInvariant()))
+            return AidopSourceTenantMap.ResolveTenantId("pbxfxp");
+        return SqlSugarConst.DefaultTenantId;
+    }
+
+    private ISugarQueryable<AdoSmartOpsKpiCalcConfig> Query() =>
+        _db.Queryable<AdoSmartOpsKpiCalcConfig>().ClearFilter<ITenantIdFilter>();
+
+    /// <summary>某 KPI 的全部版本(按版本号倒序)。</summary>
+    public async Task<List<KpiCalcConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
+    {
+        var tenantId = ResolveKpiTenantId(moduleCode);
+        var list = await Query()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
+            .OrderBy(x => x.VersionNo, OrderByType.Desc)
+            .ToListAsync();
+        return list.Select(ToDto).ToList();
+    }
+
+    /// <summary>当前生效配置(PUBLISHED + IsCurrent),无则 null(= 走 legacy)。</summary>
+    public async Task<AdoSmartOpsKpiCalcConfig?> GetActiveAsync(long tenantId, string metricCode)
+    {
+        return await Query()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId
+                        && x.IsCurrent && x.PublishStatus == StatusPublished)
+            .FirstAsync();
+    }
+
+    /// <summary>新增草稿(VersionNo = 现有最大 + 1)。</summary>
+    public async Task<KpiCalcConfigDto> CreateDraftAsync(KpiCalcConfigUpsertDto dto, string operatorName)
+    {
+        ValidateEngineType(dto.CalcEngineType);
+        var tenantId = ResolveKpiTenantId(dto.ModuleCode);
+        var maxVer = await Query()
+            .Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId)
+            .MaxAsync(x => (int?)x.VersionNo) ?? 0;
+
+        var entity = new AdoSmartOpsKpiCalcConfig
+        {
+            TenantId = tenantId,
+            MetricCode = dto.MetricCode,
+            ModuleCode = dto.ModuleCode,
+            VersionNo = maxVer + 1,
+            CalcEngineType = dto.CalcEngineType,
+            DataSourceCode = dto.DataSourceCode,
+            SqlScript = dto.SqlScript,
+            SqlParametersJson = dto.SqlParametersJson,
+            TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds),
+            PublishStatus = StatusDraft,
+            IsCurrent = false,
+            Remark = dto.Remark,
+            CreatedBy = operatorName,
+            CreatedAt = DateTime.Now,
+        };
+        entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
+        return ToDto(entity);
+    }
+
+    /// <summary>编辑草稿(仅 DRAFT 可改;已发布/停用版本不可改,须新建版本)。</summary>
+    public async Task UpdateDraftAsync(long id, KpiCalcConfigUpsertDto dto, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus != StatusDraft)
+            throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
+        ValidateEngineType(dto.CalcEngineType);
+        entity.CalcEngineType = dto.CalcEngineType;
+        entity.DataSourceCode = dto.DataSourceCode;
+        entity.SqlScript = dto.SqlScript;
+        entity.SqlParametersJson = dto.SqlParametersJson;
+        entity.TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds);
+        entity.Remark = dto.Remark;
+        entity.UpdatedBy = operatorName;
+        entity.UpdatedAt = DateTime.Now;
+        await _db.Updateable(entity).ExecuteCommandAsync();
+    }
+
+    /// <summary>删除草稿(仅 DRAFT)。</summary>
+    public async Task DeleteAsync(long id)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus != StatusDraft)
+            throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
+        await _db.Deleteable<AdoSmartOpsKpiCalcConfig>().Where(x => x.Id == id).ExecuteCommandAsync();
+    }
+
+    /// <summary>SQL 安全校验(多层非正则)。</summary>
+    public KpiSqlValidateResultDto Validate(string? sql)
+    {
+        var r = KpiSqlSecurityValidator.Validate(sql);
+        return new KpiSqlValidateResultDto
+        {
+            Ok = r.Ok,
+            ValidationStatus = r.Ok ? "VALID" : "INVALID",
+            ErrorCode = r.ErrorCode,
+            ErrorMessage = r.ErrorMessage,
+            ReferencedTables = r.ReferencedTables,
+        };
+    }
+
+    /// <summary>试算(服务端重校验 + 只读执行;不写正式 KPI 值)。</summary>
+    public async Task<KpiSqlPreviewResultDto> PreviewAsync(KpiSqlPreviewDto dto, CancellationToken ct)
+    {
+        var bizDate = (dto.BizDate ?? DateTime.Today.AddDays(-1)).Date;
+        var res = new KpiSqlPreviewResultDto
+        {
+            MetricCode = dto.MetricCode,
+            EngineType = EngineConfigSql,
+            DataSourceCode = dto.DataSourceCode,
+            BizDate = bizDate.ToString("yyyy-MM-dd"),
+        };
+
+        var val = KpiSqlSecurityValidator.Validate(dto.SqlScript);
+        res.ValidationStatus = val.Ok ? "VALID" : "INVALID";
+        if (!val.Ok)
+        {
+            res.Ok = false;
+            res.ResultStatus = "FAILED";
+            res.ErrorCode = val.ErrorCode;
+            res.ErrorMessage = val.ErrorMessage;
+            return res;
+        }
+
+        var tenantId = ResolveKpiTenantId(dto.ModuleCode);
+        var pars = new KpiSqlRunParams
+        {
+            TenantId = tenantId,
+            FactoryId = 1,
+            ModuleCode = dto.ModuleCode,
+            MetricCode = dto.MetricCode,
+            BizDate = bizDate,
+            PeriodStart = bizDate,
+            PeriodEnd = bizDate.AddDays(1).AddSeconds(-1),
+            SourceZtid = "pbxfxp",
+        };
+        var exec = await _executor.ExecuteAsync(dto.DataSourceCode, dto.SqlScript!, dto.TimeoutSeconds, true, pars, ct);
+
+        res.Ok = exec.Status != "FAILED";
+        res.ResultStatus = exec.Status;
+        res.DurationMs = exec.DurationMs;
+        res.RowCount = exec.RowCount;
+        res.MetricValue = exec.MetricValue;
+        res.NumeratorValue = exec.NumeratorValue;
+        res.DenominatorValue = exec.DenominatorValue;
+        res.ResultMessage = exec.ResultMessage;
+        res.ErrorCode = exec.ErrorCode;
+        res.ErrorMessage = exec.ErrorMessage;
+        res.ExecutedParameters =
+            $"tenant_id={pars.TenantId}, factory_id={pars.FactoryId}, module_code={pars.ModuleCode}, " +
+            $"metric_code={pars.MetricCode}, biz_date={pars.BizDate:yyyy-MM-dd}, ztid={pars.SourceZtid}";
+        return res;
+    }
+
+    /// <summary>发布:事务内校验→旧 current 置 0→本版 PUBLISHED+IsCurrent=1(保证单生效版本)。</summary>
+    public async Task PublishAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus == StatusRetired)
+            throw Oops.Bah("已停用版本不可发布,请新建版本");
+
+        // CONFIG_SQL 发布前必须再过一次安全校验
+        if (entity.CalcEngineType == EngineConfigSql)
+        {
+            var val = KpiSqlSecurityValidator.Validate(entity.SqlScript);
+            if (!val.Ok) throw Oops.Bah($"SQL 安全校验未通过:{val.ErrorCode} {val.ErrorMessage}");
+            if (string.IsNullOrWhiteSpace(entity.DataSourceCode))
+                throw Oops.Bah("CONFIG_SQL 必须指定数据源");
+        }
+
+        var tenantId = entity.TenantId;
+        var tran = await _db.AsTenant().UseTranAsync(async () =>
+        {
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
+                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
+                .ExecuteCommandAsync();
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig
+                {
+                    PublishStatus = StatusPublished, IsCurrent = true,
+                    PublishedBy = operatorName, PublishedAt = DateTime.Now,
+                })
+                .Where(x => x.Id == id)
+                .ExecuteCommandAsync();
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException;
+    }
+
+    /// <summary>激活/回滚:把某历史版本设为当前生效(其它同 KPI 版本 IsCurrent 置 0),事务内。仅对已发布版本。</summary>
+    public async Task ActivateAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus != StatusPublished)
+            throw Oops.Bah("只能激活已发布(PUBLISHED)版本;回滚是激活历史已发布版本,不是复制重发");
+
+        var tenantId = entity.TenantId;
+        var tran = await _db.AsTenant().UseTranAsync(async () =>
+        {
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
+                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
+                .ExecuteCommandAsync();
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = true, UpdatedBy = operatorName, UpdatedAt = DateTime.Now })
+                .Where(x => x.Id == id)
+                .ExecuteCommandAsync();
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException;
+    }
+
+    /// <summary>停用(RETIRED,IsCurrent=0)。停用当前生效版本后无 current → 运行时回落 legacy。</summary>
+    public async Task RetireAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+            .SetColumns(x => new AdoSmartOpsKpiCalcConfig
+            {
+                PublishStatus = StatusRetired, IsCurrent = false,
+                RetiredBy = operatorName, RetiredAt = DateTime.Now,
+            })
+            .Where(x => x.Id == id)
+            .ExecuteCommandAsync();
+    }
+
+    /// <summary>已登记数据源列表(第一版仅本地中台库)。</summary>
+    public List<object> ListDataSources() => new()
+    {
+        new { code = KpiSqlReadOnlyExecutor.LocalDataSourceCode, name = "本地中台库(aidopdev / mdp_std_* / dwd_*)", readOnly = true },
+    };
+
+    private static void ValidateEngineType(string engine)
+    {
+        if (engine != EngineLegacyCode && engine != EngineConfigSql && engine != EngineLegacyTvf)
+            throw Oops.Bah($"非法引擎类型:{engine}(LEGACY_CODE/CONFIG_SQL/LEGACY_TVF)");
+    }
+
+    private static int NormalizeTimeout(int t) =>
+        t <= 0 ? 30 : Math.Min(t, KpiSqlReadOnlyExecutor.SystemMaxTimeoutSeconds);
+
+    private static KpiCalcConfigDto ToDto(AdoSmartOpsKpiCalcConfig e) => new()
+    {
+        Id = e.Id, TenantId = e.TenantId, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode,
+        VersionNo = e.VersionNo, CalcEngineType = e.CalcEngineType, DataSourceCode = e.DataSourceCode,
+        SqlScript = e.SqlScript, SqlParametersJson = e.SqlParametersJson, TimeoutSeconds = e.TimeoutSeconds,
+        PublishStatus = e.PublishStatus, IsCurrent = e.IsCurrent, Remark = e.Remark,
+        CreatedBy = e.CreatedBy, CreatedAt = e.CreatedAt, PublishedBy = e.PublishedBy, PublishedAt = e.PublishedAt,
+    };
+}

+ 144 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiCalcDispatcher.cs

@@ -0,0 +1,144 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>分发结果:引擎、是否应写 KPI 值、指标值、分母状态。</summary>
+public sealed class KpiCalcDispatchResult
+{
+    public string EngineType { get; set; } = "LEGACY_CODE";
+    /// <summary>是否写 KPI 日值。FAILED 时 false → 不写、保留上一成功值。</summary>
+    public bool ShouldUpsert { get; set; } = true;
+    public decimal? MetricValue { get; set; }
+    /// <summary>分母/结果状态:OK / NO_DATA / NO_NUMERATOR / FAILED。</summary>
+    public string DenominatorStatus { get; set; } = "OK";
+}
+
+/// <summary>
+/// KPI 计算运行时分发器:解析当前生效配置 → LEGACY_CODE(用旧代码值) / CONFIG_SQL(只读执行 SQL) / LEGACY_TVF(用旧代码值)。
+/// 无生效配置 → 回落 LEGACY_CODE(向后兼容)。CONFIG_SQL 失败**不 fallback**、不写值、返回 FAILED(保留上一成功值)。
+/// 每次执行落 ado_smart_ops_kpi_calc_run_log(含引擎/版本/SQL 指纹/分子分母/耗时/状态)。
+/// </summary>
+public sealed class KpiCalcDispatcher : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly AdoSmartOpsKpiCalcConfigService _configService;
+    private readonly KpiSqlReadOnlyExecutor _executor;
+
+    public KpiCalcDispatcher(ISqlSugarClient db, AdoSmartOpsKpiCalcConfigService configService, KpiSqlReadOnlyExecutor executor)
+    {
+        _db = db;
+        _configService = configService;
+        _executor = executor;
+    }
+
+    public async Task<KpiCalcDispatchResult> DispatchAsync(
+        string metricCode, string moduleCode, long tenantId, long factoryId,
+        DateTime bizDate, DateTime periodStart, DateTime periodEnd, string sourceZtid,
+        string batchId, string triggerType,
+        decimal? legacyValue, string legacyDenomStatus, CancellationToken ct)
+    {
+        var startedAt = DateTime.Now;
+        var config = await _configService.GetActiveAsync(tenantId, metricCode);
+        var engine = config?.CalcEngineType ?? "LEGACY_CODE";
+        var result = new KpiCalcDispatchResult { EngineType = engine };
+
+        // 无配置 / LEGACY_CODE / LEGACY_TVF → 用旧代码算出的值(旧 Build 已计算并传入)
+        if (config == null || engine == "LEGACY_CODE" || engine == "LEGACY_TVF")
+        {
+            result.MetricValue = legacyValue;
+            result.DenominatorStatus = legacyDenomStatus;
+            result.ShouldUpsert = true;
+            await WriteRunLogAsync(config, metricCode, moduleCode, tenantId, batchId, triggerType, engine, bizDate,
+                startedAt, legacyValue == null ? "NO_DATA" : "SUCCESS", legacyValue, null, null, 0, null, null, null);
+            return result;
+        }
+
+        // CONFIG_SQL:只读执行已发布 SQL
+        var pars = new KpiSqlRunParams
+        {
+            TenantId = tenantId, FactoryId = factoryId, ModuleCode = moduleCode, MetricCode = metricCode,
+            BizDate = bizDate, PeriodStart = periodStart, PeriodEnd = periodEnd, SourceZtid = sourceZtid,
+        };
+        var exec = await _executor.ExecuteAsync(config.DataSourceCode, config.SqlScript ?? "", config.TimeoutSeconds, false, pars, ct);
+
+        switch (exec.Status)
+        {
+            case "SUCCESS":
+                result.MetricValue = exec.MetricValue;
+                result.DenominatorStatus = "OK";
+                result.ShouldUpsert = true;
+                break;
+            case "NO_DATA":
+                result.MetricValue = null;
+                result.DenominatorStatus = "NO_DATA";
+                result.ShouldUpsert = true; // 遵循现有契约:NO_DATA 写 null,不伪造 0
+                break;
+            default: // FAILED
+                result.MetricValue = null;
+                result.DenominatorStatus = "FAILED";
+                result.ShouldUpsert = false; // 不 fallback、不写错误新值、保留上一成功值
+                break;
+        }
+
+        await WriteRunLogAsync(config, metricCode, moduleCode, tenantId, batchId, triggerType, engine, bizDate,
+            startedAt, exec.Status, exec.MetricValue, exec.NumeratorValue, exec.DenominatorValue,
+            exec.RowCount, exec.ErrorCode, exec.ErrorMessage, exec.SqlHash);
+        return result;
+    }
+
+    private async Task WriteRunLogAsync(
+        AdoSmartOpsKpiCalcConfig? config, string metricCode, string moduleCode, long tenantId,
+        string batchId, string triggerType, string engine, DateTime bizDate, DateTime startedAt,
+        string status, decimal? metricValue, decimal? numerator, decimal? denominator, int rowCount,
+        string? errorCode, string? errorMessage, string? sqlHash)
+    {
+        try
+        {
+            var finishedAt = DateTime.Now;
+            var log = new AdoSmartOpsKpiCalcRunLog
+            {
+                TenantId = tenantId,
+                BatchId = batchId,
+                ModuleCode = moduleCode,
+                MetricCode = metricCode,
+                ConfigId = config?.Id,
+                VersionNo = config?.VersionNo,
+                EngineType = engine,
+                DataSourceCode = config?.DataSourceCode,
+                BizDate = bizDate.Date,
+                StartedAt = startedAt,
+                FinishedAt = finishedAt,
+                DurationMs = (long)(finishedAt - startedAt).TotalMilliseconds,
+                Status = status,
+                RowCount = rowCount,
+                MetricValue = metricValue,
+                NumeratorValue = numerator,
+                DenominatorValue = denominator,
+                ErrorCode = errorCode,
+                ErrorMessage = errorMessage,
+                SqlHash = sqlHash,
+                ParameterSnapshot = $"{{\"tenant_id\":{tenantId},\"metric_code\":\"{metricCode}\",\"biz_date\":\"{bizDate:yyyy-MM-dd}\"}}",
+                TriggerType = triggerType,
+                CreateTime = finishedAt,
+            };
+            // 同批次同指标唯一:存在则更新、否则插入(避免撞唯一键 uk_kpi_calc_run_metric_batch)
+            var existing = await _db.Queryable<AdoSmartOpsKpiCalcRunLog>().ClearFilter<ITenantIdFilter>()
+                .Where(x => x.MetricCode == metricCode && x.BatchId == batchId).FirstAsync();
+            if (existing != null)
+            {
+                log.Id = existing.Id;
+                await _db.Updateable(log).ExecuteCommandAsync();
+            }
+            else
+            {
+                await _db.Insertable(log).ExecuteCommandAsync();
+            }
+        }
+        catch
+        {
+            // 运行日志失败不影响主计算链路
+        }
+    }
+}

+ 241 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiSqlReadOnlyExecutor.cs

@@ -0,0 +1,241 @@
+using System.Data;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>单条 KPI SQL 的运行参数(引擎服务端绑定,不信任前端)。</summary>
+public sealed class KpiSqlRunParams
+{
+    public long TenantId { get; set; }
+    public long FactoryId { get; set; } = 1;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string MetricCode { get; set; } = string.Empty;
+    public DateTime BizDate { get; set; }
+    public DateTime PeriodStart { get; set; }
+    public DateTime PeriodEnd { get; set; }
+    public string SourceZtid { get; set; } = "pbxfxp";
+}
+
+/// <summary>KPI SQL 执行结果契约。</summary>
+public sealed class KpiSqlExecutionResult
+{
+    /// <summary>SUCCESS / NO_DATA / FAILED。</summary>
+    public string Status { get; set; } = "FAILED";
+    public decimal? MetricValue { get; set; }
+    public decimal? NumeratorValue { get; set; }
+    public decimal? DenominatorValue { get; set; }
+    public string? ResultStatus { get; set; }
+    public string? ResultMessage { get; set; }
+    public int RowCount { get; set; }
+    public long DurationMs { get; set; }
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+    public string SqlHash { get; set; } = string.Empty;
+}
+
+/// <summary>
+/// KPI 配置 SQL 只读执行器。用**专用隔离连接** + `START TRANSACTION READ ONLY`(DB 层 1792 强制拒写)
+/// + 命令超时 + 参数化 + 单行结果契约解析。绝不在此写任何业务数据;KPI 日值由引擎另行 Upsert。
+/// </summary>
+public sealed class KpiSqlReadOnlyExecutor : ITransient
+{
+    /// <summary>正式执行的系统超时上限(秒)。</summary>
+    public const int SystemMaxTimeoutSeconds = 60;
+
+    /// <summary>试算超时上限(秒,建议小于正式)。</summary>
+    public const int PreviewMaxTimeoutSeconds = 20;
+
+    /// <summary>最大返回行数(契约要求 0/1 行;超过即 FAILED,避免大结果占用连接)。</summary>
+    public const int MaxResultRows = 100;
+
+    /// <summary>本地中台库数据源编码(解析为主库 aidopdev)。</summary>
+    public const string LocalDataSourceCode = "MDP_LOCAL";
+
+    private readonly ISqlSugarClient _db;
+
+    public KpiSqlReadOnlyExecutor(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public static string ComputeSqlHash(string sql)
+    {
+        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(sql ?? string.Empty));
+        return Convert.ToHexString(bytes);
+    }
+
+    /// <summary>
+    /// 只读执行一段 SQL 并解析单行结果契约。dataSourceCode 目前仅支持 MDP_LOCAL(本地中台库)。
+    /// </summary>
+    public async Task<KpiSqlExecutionResult> ExecuteAsync(
+        string? dataSourceCode, string sql, int timeoutSeconds, bool isPreview,
+        KpiSqlRunParams pars, CancellationToken ct)
+    {
+        var startedAt = DateTime.Now;
+        var res = new KpiSqlExecutionResult { SqlHash = ComputeSqlHash(sql) };
+
+        var cap = isPreview ? PreviewMaxTimeoutSeconds : SystemMaxTimeoutSeconds;
+        var timeout = timeoutSeconds <= 0 ? cap : Math.Min(timeoutSeconds, cap);
+
+        var code = string.IsNullOrWhiteSpace(dataSourceCode) ? LocalDataSourceCode : dataSourceCode.Trim();
+        if (!string.Equals(code, LocalDataSourceCode, StringComparison.OrdinalIgnoreCase))
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "DATASOURCE_NOT_SUPPORTED";
+            res.ErrorMessage = $"数据源 {code} 第一版暂不支持(仅 {LocalDataSourceCode})";
+            res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
+            return res;
+        }
+
+        // 专用隔离连接(不复用共享 _db,避免只读事务污染主连接 + 并发干扰)。
+        var main = _db.CurrentConnectionConfig;
+        var roConfig = new ConnectionConfig
+        {
+            ConnectionString = main.ConnectionString,
+            DbType = main.DbType,
+            IsAutoCloseConnection = false,
+        };
+
+        using var roDb = new SqlSugarClient(roConfig);
+        try
+        {
+            roDb.Ado.CommandTimeOut = timeout;
+            await roDb.Ado.ExecuteCommandAsync("START TRANSACTION READ ONLY");
+            try
+            {
+                ct.ThrowIfCancellationRequested();
+                var sugarPars = BuildParameters(pars);
+                var dt = await roDb.Ado.GetDataTableAsync(sql, sugarPars);
+                ParseContract(dt, res);
+            }
+            finally
+            {
+                try { await roDb.Ado.ExecuteCommandAsync("ROLLBACK"); } catch { /* 专用连接随 Dispose 关闭 */ }
+            }
+        }
+        catch (OperationCanceledException) when (ct.IsCancellationRequested)
+        {
+            throw;
+        }
+        catch (Exception ex)
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = ex.Message.IndexOf("timeout", StringComparison.OrdinalIgnoreCase) >= 0
+                            || ex.Message.IndexOf("Command Timeout", StringComparison.OrdinalIgnoreCase) >= 0
+                ? "TIMEOUT" : "SQL_ERROR";
+            res.ErrorMessage = Truncate(ex.Message, 480);
+        }
+
+        res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
+        return res;
+    }
+
+    private static SugarParameter[] BuildParameters(KpiSqlRunParams p) => new[]
+    {
+        new SugarParameter("@tenant_id", p.TenantId),
+        new SugarParameter("@factory_id", p.FactoryId),
+        new SugarParameter("@module_code", p.ModuleCode),
+        new SugarParameter("@metric_code", p.MetricCode),
+        new SugarParameter("@biz_date", p.BizDate.Date),
+        new SugarParameter("@period_start", p.PeriodStart),
+        new SugarParameter("@period_end", p.PeriodEnd),
+        new SugarParameter("@ztid", p.SourceZtid),
+    };
+
+    private static void ParseContract(DataTable dt, KpiSqlExecutionResult res)
+    {
+        res.RowCount = dt.Rows.Count;
+        if (dt.Rows.Count == 0)
+        {
+            res.Status = "NO_DATA";
+            return;
+        }
+        if (dt.Rows.Count > MaxResultRows || dt.Rows.Count > 1)
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "INVALID_RESULT_ROWS";
+            res.ErrorMessage = $"正式执行 SQL 只能返回 0 或 1 行,实际 {dt.Rows.Count} 行";
+            return;
+        }
+
+        var row = dt.Rows[0];
+        if (!HasColumn(dt, "metric_value"))
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "MISSING_METRIC_VALUE";
+            res.ErrorMessage = "结果必须含列 metric_value";
+            return;
+        }
+
+        res.NumeratorValue = TryDecimal(row, dt, "numerator_value");
+        res.DenominatorValue = TryDecimal(row, dt, "denominator_value");
+        res.ResultStatus = TryString(row, dt, "result_status");
+        res.ResultMessage = TryString(row, dt, "result_message");
+
+        var mvObj = row[GetColumn(dt, "metric_value")!];
+        if (mvObj == null || mvObj == DBNull.Value)
+        {
+            res.Status = "NO_DATA";
+            res.MetricValue = null;
+            return;
+        }
+        if (!TryToDecimal(mvObj, out var mv))
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "NON_NUMERIC";
+            res.ErrorMessage = $"metric_value 非数值:{Truncate(mvObj.ToString() ?? "", 60)}";
+            return;
+        }
+        res.MetricValue = mv;
+        res.Status = "SUCCESS";
+    }
+
+    private static bool HasColumn(DataTable dt, string name) => GetColumn(dt, name) != null;
+
+    private static string? GetColumn(DataTable dt, string name)
+    {
+        foreach (DataColumn c in dt.Columns)
+            if (string.Equals(c.ColumnName, name, StringComparison.OrdinalIgnoreCase)) return c.ColumnName;
+        return null;
+    }
+
+    private static decimal? TryDecimal(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        if (v == null || v == DBNull.Value) return null;
+        return TryToDecimal(v, out var d) ? d : (decimal?)null;
+    }
+
+    private static string? TryString(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        return v == null || v == DBNull.Value ? null : v.ToString();
+    }
+
+    private static bool TryToDecimal(object v, out decimal d)
+    {
+        switch (v)
+        {
+            case decimal dec: d = dec; return true;
+            case double db: d = (decimal)db; return true;
+            case float f: d = (decimal)f; return true;
+            case long l: d = l; return true;
+            case int i: d = i; return true;
+            case short s: d = s; return true;
+            case byte b: d = b; return true;
+            default:
+                return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture),
+                    NumberStyles.Any, CultureInfo.InvariantCulture, out d);
+        }
+    }
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
+}

+ 139 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiSqlSecurityValidator.cs

@@ -0,0 +1,139 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 配置 SQL 安全校验(多层、非仅正则)。真正的写保护由只读事务在 DB 层强制(KpiSqlReadOnlyExecutor);
+/// 本校验器是意图闸门 + 纵深防御:单语句、起始 token 白名单、token 级 denylist、FROM 表白名单。
+/// 顺序:去注释 → 单语句门 → 起始 token 白名单(SELECT/WITH) → denylist(token 边界) → FROM 表白名单。
+/// </summary>
+public static class KpiSqlSecurityValidator
+{
+    /// <summary>允许查询的表前缀(中台内部表;租户谓词软限降级——限制 FROM 目标而非静态分析谓词)。</summary>
+    public static readonly string[] AllowedTablePrefixes =
+    {
+        "mdp_std_", "mdp_stg_", "dwd_", "ado_s9_kpi_value_", "ado_smart_ops_kpi_atomic"
+    };
+
+    // token 边界匹配的危险关键字(去注释后,按单词边界;避免子串误伤列名如 create_time)。
+    private static readonly string[] DenyKeywords =
+    {
+        "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE", "DROP", "ALTER", "CREATE", "TRUNCATE",
+        "GRANT", "REVOKE", "RENAME", "LOAD", "CALL", "EXEC", "EXECUTE", "SET", "LOCK", "UNLOCK",
+        "HANDLER", "PREPARE", "DEALLOCATE", "INTO", "OUTFILE", "DUMPFILE", "USE", "SHOW", "DESCRIBE",
+        "ATTACH", "PRAGMA", "COPY"
+    };
+
+    private static readonly HashSet<string> StartTokenWhitelist = new(StringComparer.OrdinalIgnoreCase) { "SELECT", "WITH" };
+
+    public sealed class Result
+    {
+        public bool Ok { get; set; }
+        public string? ErrorCode { get; set; }
+        public string? ErrorMessage { get; set; }
+        public List<string> ReferencedTables { get; } = new();
+
+        public static Result Fail(string code, string msg) => new() { Ok = false, ErrorCode = code, ErrorMessage = msg };
+    }
+
+    public static Result Validate(string? rawSql)
+    {
+        if (string.IsNullOrWhiteSpace(rawSql))
+            return Result.Fail("EMPTY_SQL", "SQL 不能为空");
+
+        // 1) 去注释:-- 行注释、# 行注释、/* */ 块注释(防止注释绕过关键字/多语句检测)
+        var sql = StripComments(rawSql).Trim();
+        if (sql.Length == 0)
+            return Result.Fail("EMPTY_SQL", "SQL 去注释后为空");
+
+        // 2) 单语句门:去尾部分号后,若仍含分号 → 多语句,拒
+        var trimmed = sql.TrimEnd().TrimEnd(';').TrimEnd();
+        if (trimmed.Contains(';'))
+            return Result.Fail("MULTI_STATEMENT", "只允许单条语句,禁止分号多语句");
+        if (trimmed.Length == 0)
+            return Result.Fail("EMPTY_SQL", "SQL 为空语句");
+
+        // token 化(按非标识符字符切分,保留 . 以便识别 schema.table)
+        var tokens = Tokenize(trimmed);
+        if (tokens.Count == 0)
+            return Result.Fail("EMPTY_SQL", "SQL 无有效 token");
+
+        // 3) 起始 token 白名单:必须以 SELECT 或 WITH 开头
+        if (!StartTokenWhitelist.Contains(tokens[0]))
+            return Result.Fail("NOT_SELECT", "只允许 SELECT 或 WITH ... SELECT 查询");
+        // WITH 需其后出现 SELECT 主语句
+        if (string.Equals(tokens[0], "WITH", StringComparison.OrdinalIgnoreCase)
+            && !tokens.Any(t => string.Equals(t, "SELECT", StringComparison.OrdinalIgnoreCase)))
+            return Result.Fail("NOT_SELECT", "WITH 之后必须为 SELECT 查询");
+
+        // 4) token 级 denylist(单词边界匹配,非裸子串)
+        var upperTokens = new HashSet<string>(tokens.Select(t => t.ToUpperInvariant()));
+        foreach (var kw in DenyKeywords)
+        {
+            if (upperTokens.Contains(kw))
+                return Result.Fail("FORBIDDEN_KEYWORD", $"禁止的关键字/操作:{kw}");
+        }
+
+        // 5) FROM 表白名单:提取 FROM/JOIN 后的表名,须命中允许前缀
+        var result = new Result { Ok = true };
+        var tables = ExtractTables(tokens);
+        result.ReferencedTables.AddRange(tables);
+        foreach (var tbl in tables)
+        {
+            var bare = tbl.Contains('.') ? tbl.Substring(tbl.LastIndexOf('.') + 1) : tbl;
+            bare = bare.Trim('`', '"', '[', ']');
+            if (!AllowedTablePrefixes.Any(p => bare.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+                return Result.Fail("TABLE_NOT_ALLOWED",
+                    $"表 {tbl} 不在允许的中台数据集范围(仅 {string.Join("/", AllowedTablePrefixes)})");
+        }
+        return result;
+    }
+
+    private static string StripComments(string sql)
+    {
+        // 块注释 /* ... */(含跨行)
+        sql = Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline);
+        var sb = new StringBuilder();
+        foreach (var line in sql.Split('\n'))
+        {
+            var l = line;
+            var dash = l.IndexOf("--", StringComparison.Ordinal);
+            var hash = l.IndexOf('#');
+            var cut = -1;
+            if (dash >= 0) cut = dash;
+            if (hash >= 0 && (cut < 0 || hash < cut)) cut = hash;
+            if (cut >= 0) l = l.Substring(0, cut);
+            sb.Append(l).Append('\n');
+        }
+        return sb.ToString();
+    }
+
+    private static List<string> Tokenize(string sql)
+    {
+        // 保留标识符字符 + . + @(参数);其余作分隔。字符串字面量整体折叠为占位,避免其中关键字误判。
+        var noStrings = Regex.Replace(sql, @"'([^'\\]|\\.)*'", " '' ");
+        noStrings = Regex.Replace(noStrings, "\"([^\"\\\\]|\\\\.)*\"", " \"\" ");
+        var matches = Regex.Matches(noStrings, @"[A-Za-z0-9_@\.\$]+");
+        return matches.Select(m => m.Value).Where(v => v.Length > 0).ToList();
+    }
+
+    private static List<string> ExtractTables(List<string> tokens)
+    {
+        var tables = new List<string>();
+        for (var i = 0; i < tokens.Count - 1; i++)
+        {
+            var t = tokens[i].ToUpperInvariant();
+            if (t == "FROM" || t == "JOIN")
+            {
+                var next = tokens[i + 1];
+                // 跳过子查询开括号场景:Tokenize 已去括号,子查询里 FROM 会各自被扫描到;
+                // 若 next 是保留字(SELECT 等)说明是子查询,跳过表名收集。
+                if (StartTokenWhitelist.Contains(next) || string.Equals(next, "SELECT", StringComparison.OrdinalIgnoreCase))
+                    continue;
+                tables.Add(next);
+            }
+        }
+        return tables;
+    }
+}