浏览代码

feat(aidop): add MDP monitor and migration assets

Add MDP monitoring pages and services, S1 sync/monitor backend support, migration update scripts, and reorganized migration/report documents. Bump Web to 2.4.164 and server to 1.0.128.

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 2 天之前
父节点
当前提交
12105e6164
共有 40 个文件被更改,包括 3893 次插入179 次删除
  1. 1 1
      Web/package.json
  2. 128 0
      Web/src/views/aidop/data-platform/api/mdpMonitor.ts
  3. 484 0
      Web/src/views/aidop/data-platform/mdpMonitor.vue
  4. 1 0
      Web/src/views/aidop/data-platform/overview.vue
  5. 3 1
      Web/src/views/system/role/component/editRole.vue
  6. 2 0
      doc/README.md
  7. 346 0
      doc/plan/数据库迁移/S1/S1-任务交接记忆.md
  8. 102 0
      doc/plan/数据库迁移/S1/S1数据中台迁移实施计划.md
  9. 0 0
      doc/plan/数据库迁移/S3/S3调用旧系统Business API解析.md
  10. 0 0
      doc/plan/数据库迁移/S3/数据中台模块扩展开发指南-S3范式.md
  11. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3供应协同试点实施方案.md
  12. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3存储过程替代任务包.md
  13. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批DDL任务包.md
  14. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批MDP同步配置草案.md
  15. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批前端接口联调验收清单.md
  16. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批后端改造任务清单.md
  17. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批实施任务拆解.md
  18. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批建表SQL草案.md
  19. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批标准化转换规则草案.md
  20. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批落库评审清单.md
  21. 0 0
      doc/plan/数据库迁移/S3/数据库迁移-S3首批评审汇总与决策清单.md
  22. 二进制
      doc/ppt/Ai-DOP项目周报_20260511-0515.pptx
  23. 二进制
      doc/ppt/Ai-DOP项目周报_20260511-0517.pptx
  24. 二进制
      doc/ppt/Ai-DOP项目周报_20260518-0522.pptx
  25. 465 0
      doc/ppt/generate_weekly_report.py
  26. 1 1
      server/Admin.NET.Application/Configuration/Database.json
  27. 56 20
      server/Admin.NET.Core/Service/Role/SysRoleMenuService.cs
  28. 9 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  29. 464 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.130.sql
  30. 59 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.131.sql
  31. 44 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/S1MdpSyncTransformJob.cs
  32. 14 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/LinkagePlanService.cs
  33. 424 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/MdpMonitorService.cs
  34. 47 78
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderDeliveryService.cs
  35. 127 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpMonitorService.cs
  36. 858 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs
  37. 33 40
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/SeOrderService.cs
  38. 187 9
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/ShippingPlanService.cs
  39. 1 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs
  40. 37 21
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

+ 1 - 1
Web/package.json

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

+ 128 - 0
Web/src/views/aidop/data-platform/api/mdpMonitor.ts

@@ -0,0 +1,128 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface MdpMonitorModuleOption {
+	moduleCode: string;
+	jobCode: string;
+}
+
+export interface MdpRunLogRow {
+	id: number;
+	tenantId?: number;
+	jobCode?: string | null;
+	jobName?: string | null;
+	triggerType?: string | null;
+	batchId?: string | null;
+	status?: string | null;
+	startTime?: string | null;
+	endTime?: string | null;
+	durationMs?: number | null;
+	stageRows?: number | null;
+	standardRows?: number | null;
+	dwdRows?: number | null;
+	errorMessage?: string | null;
+	summaryJson?: string | null;
+	createTime?: string | null;
+	updateTime?: string | null;
+}
+
+export interface MdpLineageStage {
+	stageCode?: string | null;
+	stageName?: string | null;
+	layer?: string | null;
+	description?: string | null;
+	inputObjects?: string | null;
+	outputObjects?: string | null;
+	execution?: string | null;
+}
+
+export interface MdpLineageFieldMapping {
+	entityId: number;
+	sourceField?: string | null;
+	targetField?: string | null;
+	fieldType?: string | null;
+	transformScript?: string | null;
+	constValue?: string | null;
+	lookupTable?: string | null;
+	isRequired?: boolean;
+	defaultValue?: string | null;
+	sortOrder?: number;
+}
+
+export interface MdpLineageSyncLog {
+	entityId: number;
+	entityName?: string | null;
+	status?: string | null;
+	rowsRead?: number | null;
+	rowsInsert?: number | null;
+	rowsUpdate?: number | null;
+	rowsSkip?: number | null;
+	rowsError?: number | null;
+	syncStart?: string | null;
+	syncEnd?: string | null;
+	durationMs?: number | null;
+	errorMsg?: string | null;
+}
+
+export interface MdpLineageEntity {
+	id: number;
+	entityCode?: string | null;
+	entityName?: string | null;
+	entityType?: string | null;
+	sourceCode?: string | null;
+	sourceName?: string | null;
+	sourceType?: string | null;
+	sourceDbType?: string | null;
+	sourceDbHost?: string | null;
+	sourceDbPort?: number | null;
+	sourceDbName?: string | null;
+	sourceTableName?: string | null;
+	sourceApiPath?: string | null;
+	sourceFullName?: string | null;
+	targetDbType?: string | null;
+	targetDbHost?: string | null;
+	targetDbPort?: number | null;
+	targetDbName?: string | null;
+	targetTableName?: string | null;
+	targetFullName?: string | null;
+	syncMode?: string | null;
+	incrColumn?: string | null;
+	status?: number | null;
+	fieldMappingCount?: number;
+	fieldMappings?: MdpLineageFieldMapping[];
+	syncLog?: MdpLineageSyncLog | null;
+}
+
+export interface MdpLineageDetail {
+	moduleCode?: string | null;
+	jobCode?: string | null;
+	batchId?: string | null;
+	stages: MdpLineageStage[];
+	entities: MdpLineageEntity[];
+}
+
+export function fetchMdpMonitorModules() {
+	return service.get<MdpMonitorModuleOption[]>('/api/DataPlatform/mdp-monitor/modules').then((r) => r.data);
+}
+
+export function fetchMdpMonitorLatest(params: Record<string, unknown>) {
+	return service.get<MdpRunLogRow>('/api/DataPlatform/mdp-monitor/latest', { params }).then((r) => r.data);
+}
+
+export function fetchMdpMonitorRunLogList(params: Record<string, unknown>) {
+	return service.get<Paged<MdpRunLogRow>>('/api/DataPlatform/mdp-monitor/list', { params }).then((r) => r.data);
+}
+
+export function fetchMdpMonitorRunLogDetail(id: number, params?: Record<string, unknown>) {
+	return service.get<MdpRunLogRow>(`/api/DataPlatform/mdp-monitor/detail/${id}`, { params }).then((r) => r.data);
+}
+
+export function fetchMdpMonitorLineage(params: Record<string, unknown>) {
+	return service.get<MdpLineageDetail>('/api/DataPlatform/mdp-monitor/lineage', { params }).then((r) => r.data);
+}

+ 484 - 0
Web/src/views/aidop/data-platform/mdpMonitor.vue

@@ -0,0 +1,484 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="统一查看 S1/S3 等模块的 MDP 同步、转换与指标写入运行状态">
+		<template #bar-right>
+			<el-button :loading="lineageLoading" icon="ele-Connection" @click="openLineage()">链路详情</el-button>
+			<el-button :loading="loading" icon="ele-Refresh" @click="loadAll">刷新</el-button>
+			<el-button type="primary" icon="ele-AlarmClock" @click="goJob">去任务调度</el-button>
+		</template>
+
+		<el-row :gutter="12" class="summary-row">
+			<el-col :xs="24" :sm="12" :md="6">
+				<el-card shadow="never">
+					<div class="summary-label">最近状态</div>
+					<el-tag :type="statusTag(latest.status)" size="large">{{ statusText(latest.status) }}</el-tag>
+				</el-card>
+			</el-col>
+			<el-col :xs="24" :sm="12" :md="6">
+				<el-card shadow="never">
+					<div class="summary-label">最近批次</div>
+					<div class="summary-value" :title="latest.batchId || ''">{{ latest.batchId || '--' }}</div>
+				</el-card>
+			</el-col>
+			<el-col :xs="24" :sm="12" :md="6">
+				<el-card shadow="never">
+					<div class="summary-label">任务</div>
+					<div class="summary-value" :title="latest.jobCode || ''">{{ latest.jobCode || '--' }}</div>
+				</el-card>
+			</el-col>
+			<el-col :xs="24" :sm="12" :md="6">
+				<el-card shadow="never">
+					<div class="summary-label">行数 Stage / Std / DWD</div>
+					<div class="summary-value">{{ latest.stageRows ?? 0 }} / {{ latest.standardRows ?? 0 }} / {{ latest.dwdRows ?? 0 }}</div>
+				</el-card>
+			</el-col>
+		</el-row>
+
+		<el-card shadow="never" class="mb12">
+			<el-form :inline="true" :model="query" @submit.prevent>
+				<el-form-item label="模块">
+					<el-select v-model="query.moduleCode" clearable placeholder="全部 MDP" style="width: 150px" @change="onModuleChange">
+						<el-option v-for="item in moduleOptions" :key="item.moduleCode" :label="item.moduleCode" :value="item.moduleCode" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="JobCode">
+					<el-input v-model="query.jobCode" clearable placeholder="可直接输入任务编码" style="width: 240px" @change="onJobCodeChange" />
+				</el-form-item>
+				<el-form-item label="批次号">
+					<el-input v-model="query.batchId" clearable placeholder="模糊查询" style="width: 220px" />
+				</el-form-item>
+				<el-form-item label="状态">
+					<el-select v-model="query.status" clearable style="width: 130px">
+						<el-option label="运行中" value="RUNNING" />
+						<el-option label="成功" value="SUCCESS" />
+						<el-option label="失败" value="FAILED" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="开始时间">
+					<el-date-picker
+						v-model="query.timeRange"
+						type="datetimerange"
+						range-separator="至"
+						start-placeholder="开始时间"
+						end-placeholder="结束时间"
+						value-format="YYYY-MM-DD HH:mm:ss"
+						style="width: 360px"
+					/>
+				</el-form-item>
+				<el-form-item>
+					<el-button type="primary" icon="ele-Search" @click="doSearch">查询</el-button>
+					<el-button icon="ele-RefreshLeft" @click="resetQuery">重置</el-button>
+				</el-form-item>
+			</el-form>
+		</el-card>
+
+		<el-table :data="rows" v-loading="loading" border stripe style="width: 100%">
+			<el-table-column prop="status" label="状态" width="100" fixed="left" align="center">
+				<template #default="{ row }">
+					<el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="jobCode" label="任务编码" min-width="190" show-overflow-tooltip />
+			<el-table-column prop="batchId" label="批次号" min-width="210" show-overflow-tooltip />
+			<el-table-column prop="triggerType" label="触发" width="90" />
+			<el-table-column prop="startTime" label="开始时间" width="170">
+				<template #default="{ row }">{{ fmtDateTime(row.startTime) }}</template>
+			</el-table-column>
+			<el-table-column prop="endTime" label="结束时间" width="170">
+				<template #default="{ row }">{{ fmtDateTime(row.endTime) }}</template>
+			</el-table-column>
+			<el-table-column prop="durationMs" label="耗时" width="110" align="right">
+				<template #default="{ row }">{{ formatDuration(row.durationMs) }}</template>
+			</el-table-column>
+			<el-table-column prop="stageRows" label="Stage行数" width="110" align="right" />
+			<el-table-column prop="standardRows" label="Std行数" width="100" align="right" />
+			<el-table-column prop="dwdRows" label="DWD行数" width="100" align="right" />
+			<el-table-column prop="errorMessage" label="错误摘要" min-width="180" show-overflow-tooltip />
+			<el-table-column label="操作" width="135" fixed="right" align="center">
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openDetail(row)">详情</el-button>
+					<el-button link type="primary" @click="openLineage(row)">链路</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[10, 20, 50]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+
+		<el-drawer v-model="detailVisible" title="运行详情" size="520px">
+			<el-descriptions :column="1" border size="small">
+				<el-descriptions-item label="状态">
+					<el-tag :type="statusTag(detail.status)" size="small">{{ statusText(detail.status) }}</el-tag>
+				</el-descriptions-item>
+				<el-descriptions-item label="任务">{{ detail.jobName || detail.jobCode || '--' }}</el-descriptions-item>
+				<el-descriptions-item label="批次号">{{ detail.batchId || '--' }}</el-descriptions-item>
+				<el-descriptions-item label="触发方式">{{ detail.triggerType || '--' }}</el-descriptions-item>
+				<el-descriptions-item label="开始时间">{{ fmtDateTime(detail.startTime) }}</el-descriptions-item>
+				<el-descriptions-item label="结束时间">{{ fmtDateTime(detail.endTime) }}</el-descriptions-item>
+				<el-descriptions-item label="耗时">{{ formatDuration(detail.durationMs) }}</el-descriptions-item>
+				<el-descriptions-item label="Stage / Std / DWD">
+					{{ detail.stageRows ?? 0 }} / {{ detail.standardRows ?? 0 }} / {{ detail.dwdRows ?? 0 }}
+				</el-descriptions-item>
+				<el-descriptions-item label="错误信息">
+					<pre class="detail-text">{{ detail.errorMessage || '--' }}</pre>
+				</el-descriptions-item>
+				<el-descriptions-item label="摘要 JSON">
+					<pre class="detail-text">{{ detail.summaryJson || '--' }}</pre>
+				</el-descriptions-item>
+			</el-descriptions>
+		</el-drawer>
+
+		<el-drawer v-model="lineageVisible" title="同步链路详情" size="80%">
+			<div v-loading="lineageLoading">
+				<el-alert
+					class="mb12"
+					type="info"
+					show-icon
+					:closable="false"
+					title="当前阶段展示配置化元数据与后端执行说明;标准层、DWD、KPI 仍由模块 Service 代码执行,尚不是纯配置驱动。"
+				/>
+
+				<el-descriptions :column="3" border size="small" class="mb12">
+					<el-descriptions-item label="模块">{{ lineage.moduleCode || query.moduleCode || '--' }}</el-descriptions-item>
+					<el-descriptions-item label="任务">{{ lineage.jobCode || query.jobCode || '--' }}</el-descriptions-item>
+					<el-descriptions-item label="批次">{{ lineage.batchId || '未选择具体批次' }}</el-descriptions-item>
+					<el-descriptions-item label="实体数">{{ lineage.entities?.length || 0 }}</el-descriptions-item>
+					<el-descriptions-item label="字段映射数">{{ lineageFieldCount }}</el-descriptions-item>
+				</el-descriptions>
+
+				<el-divider content-position="left">阶段说明</el-divider>
+				<el-timeline>
+					<el-timeline-item v-for="stage in lineage.stages" :key="stage.stageCode || stage.stageName" :timestamp="stage.layer || ''">
+						<div class="lineage-stage-title">{{ stage.stageName || stage.stageCode }}</div>
+						<div class="lineage-stage-desc">{{ stage.description || '--' }}</div>
+						<div class="lineage-stage-meta">输入:{{ stage.inputObjects || '--' }}</div>
+						<div class="lineage-stage-meta">输出:{{ stage.outputObjects || '--' }}</div>
+						<div class="lineage-stage-meta">执行:{{ stage.execution || '--' }}</div>
+					</el-timeline-item>
+				</el-timeline>
+
+				<el-divider content-position="left">实体与字段映射</el-divider>
+				<el-table :data="lineage.entities" border stripe row-key="id" style="width: 100%">
+					<el-table-column type="expand" width="48">
+						<template #default="{ row }">
+							<el-table :data="row.fieldMappings || []" border size="small" class="mapping-table">
+								<el-table-column prop="sourceField" label="源字段" min-width="160" show-overflow-tooltip />
+								<el-table-column prop="targetField" label="目标字段" min-width="160" show-overflow-tooltip />
+								<el-table-column prop="fieldType" label="映射类型" width="110" />
+								<el-table-column prop="defaultValue" label="默认值" min-width="120" show-overflow-tooltip />
+								<el-table-column prop="isRequired" label="必填" width="80" align="center">
+									<template #default="{ row: mapping }">{{ mapping.isRequired ? '是' : '否' }}</template>
+								</el-table-column>
+								<el-table-column prop="transformScript" label="转换说明" min-width="180" show-overflow-tooltip />
+							</el-table>
+						</template>
+					</el-table-column>
+					<el-table-column prop="entityCode" label="实体编码" min-width="170" show-overflow-tooltip />
+					<el-table-column prop="entityName" label="实体名称" min-width="160" show-overflow-tooltip />
+					<el-table-column label="源数据源" min-width="155" show-overflow-tooltip>
+						<template #default="{ row }">
+							<span :title="sourceTooltip(row)">{{ row.sourceCode || row.sourceName || '--' }}</span>
+						</template>
+					</el-table-column>
+					<el-table-column label="源服务器" min-width="210" show-overflow-tooltip>
+						<template #default="{ row }">
+							<span :title="sourceTooltip(row)">{{ sourceServer(row) }}</span>
+						</template>
+					</el-table-column>
+					<el-table-column label="源库表" min-width="220" show-overflow-tooltip>
+						<template #default="{ row }">
+							<span :title="sourceTooltip(row)">{{ sourceDbObject(row) }}</span>
+						</template>
+					</el-table-column>
+					<el-table-column label="目标服务器" min-width="210" show-overflow-tooltip>
+						<template #default="{ row }">
+							<span :title="targetTooltip(row)">{{ targetServer(row) }}</span>
+						</template>
+					</el-table-column>
+					<el-table-column label="贴源目标库表" min-width="220" show-overflow-tooltip>
+						<template #default="{ row }">
+							<span :title="targetTooltip(row)">{{ targetDbObject(row) }}</span>
+						</template>
+					</el-table-column>
+					<el-table-column prop="syncMode" label="模式" width="90" />
+					<el-table-column prop="fieldMappingCount" label="字段数" width="90" align="right" />
+					<el-table-column label="本批次状态" width="100" align="center">
+						<template #default="{ row }">
+							<el-tag v-if="row.syncLog" :type="statusTag(row.syncLog.status)" size="small">{{ statusText(row.syncLog.status) }}</el-tag>
+							<span v-else>--</span>
+						</template>
+					</el-table-column>
+					<el-table-column label="读/插/更/错" width="150" align="right">
+						<template #default="{ row }">
+							<span v-if="row.syncLog">
+								{{ row.syncLog.rowsRead ?? 0 }} / {{ row.syncLog.rowsInsert ?? 0 }} / {{ row.syncLog.rowsUpdate ?? 0 }} /
+								{{ row.syncLog.rowsError ?? 0 }}
+							</span>
+							<span v-else>--</span>
+						</template>
+					</el-table-column>
+					<el-table-column label="耗时" width="100" align="right">
+						<template #default="{ row }">{{ row.syncLog ? formatDuration(row.syncLog.durationMs) : '--' }}</template>
+					</el-table-column>
+					<el-table-column label="错误" min-width="180" show-overflow-tooltip>
+						<template #default="{ row }">{{ row.syncLog?.errorMsg || '--' }}</template>
+					</el-table-column>
+				</el-table>
+			</div>
+		</el-drawer>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopDataPlatformMdpMonitor">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import AidopDemoShell from '../components/AidopDemoShell.vue';
+import {
+	fetchMdpMonitorLineage,
+	fetchMdpMonitorLatest,
+	fetchMdpMonitorModules,
+	fetchMdpMonitorRunLogDetail,
+	fetchMdpMonitorRunLogList,
+	type MdpLineageDetail,
+	type MdpLineageEntity,
+	type MdpMonitorModuleOption,
+	type MdpRunLogRow,
+} from './api/mdpMonitor';
+
+const route = useRoute();
+const router = useRouter();
+const pageTitle = computed(() => (route.meta?.title as string) || 'MDP运行监控');
+
+const query = reactive({
+	moduleCode: String(route.query.moduleCode || route.query.module || 'S1'),
+	jobCode: String(route.query.jobCode || ''),
+	batchId: '',
+	status: '',
+	timeRange: [] as string[],
+	page: 1,
+	pageSize: 10,
+});
+const loading = ref(false);
+const moduleOptions = ref<MdpMonitorModuleOption[]>([]);
+const rows = ref<MdpRunLogRow[]>([]);
+const total = ref(0);
+const latest = ref<MdpRunLogRow>({ id: 0 });
+const detail = ref<MdpRunLogRow>({ id: 0 });
+const detailVisible = ref(false);
+const lineageVisible = ref(false);
+const lineageLoading = ref(false);
+const lineage = ref<MdpLineageDetail>({ stages: [], entities: [] });
+const lineageFieldCount = computed(() => lineage.value.entities?.reduce((sum, item) => sum + (item.fieldMappingCount || 0), 0) || 0);
+
+function buildParams() {
+	return {
+		moduleCode: query.moduleCode || undefined,
+		jobCode: query.jobCode || undefined,
+		batchId: query.batchId || undefined,
+		status: query.status || undefined,
+		startTime: query.timeRange?.[0] || undefined,
+		endTime: query.timeRange?.[1] || undefined,
+	};
+}
+
+function statusText(status?: string | null) {
+	return status === 'SUCCESS' ? '成功' : status === 'FAILED' ? '失败' : status === 'RUNNING' ? '运行中' : '--';
+}
+
+function statusTag(status?: string | null) {
+	return status === 'SUCCESS' ? 'success' : status === 'FAILED' ? 'danger' : status === 'RUNNING' ? 'warning' : 'info';
+}
+
+function fmtDateTime(v?: string | null) {
+	return v ? String(v).replace('T', ' ').slice(0, 19) : '--';
+}
+
+function formatDuration(ms?: number | null) {
+	if (ms == null) return '--';
+	if (ms < 1000) return `${ms}ms`;
+	return `${(ms / 1000).toFixed(1)}s`;
+}
+
+function formatServer(dbType?: string | null, host?: string | null, port?: number | null) {
+	const hostText = host ? (port ? `${host}:${port}` : host) : '';
+	return [dbType, hostText].filter(Boolean).join(' / ') || '--';
+}
+
+function formatDbObject(dbName?: string | null, objectName?: string | null) {
+	if (!objectName) return '--';
+	return dbName ? `${dbName}.${objectName}` : objectName;
+}
+
+function sourceServer(row: MdpLineageEntity) {
+	return formatServer(row.sourceDbType, row.sourceDbHost, row.sourceDbPort);
+}
+
+function targetServer(row: MdpLineageEntity) {
+	return formatServer(row.targetDbType, row.targetDbHost, row.targetDbPort);
+}
+
+function sourceDbObject(row: MdpLineageEntity) {
+	return formatDbObject(row.sourceDbName, row.sourceTableName || row.sourceApiPath);
+}
+
+function targetDbObject(row: MdpLineageEntity) {
+	return formatDbObject(row.targetDbName, row.targetTableName);
+}
+
+function sourceTooltip(row: MdpLineageEntity) {
+	return [
+		row.sourceCode,
+		row.sourceName,
+		row.sourceType,
+		sourceServer(row),
+		sourceDbObject(row),
+	]
+		.filter((item) => item && item !== '--')
+		.join(' / ');
+}
+
+function targetTooltip(row: MdpLineageEntity) {
+	return [
+		row.sourceCode,
+		row.sourceName,
+		targetServer(row),
+		targetDbObject(row),
+	]
+		.filter((item) => item && item !== '--')
+		.join(' / ');
+}
+
+async function loadModules() {
+	moduleOptions.value = await fetchMdpMonitorModules();
+}
+
+async function loadLatest() {
+	latest.value = await fetchMdpMonitorLatest(buildParams());
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchMdpMonitorRunLogList({
+			...buildParams(),
+			page: query.page,
+			pageSize: query.pageSize,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadAll() {
+	await Promise.all([loadLatest(), loadList()]);
+}
+
+function doSearch() {
+	query.page = 1;
+	loadAll();
+}
+
+function resetQuery() {
+	query.moduleCode = 'S1';
+	query.jobCode = '';
+	query.batchId = '';
+	query.status = '';
+	query.timeRange = [];
+	query.page = 1;
+	loadAll();
+}
+
+function onModuleChange() {
+	if (query.moduleCode) query.jobCode = '';
+}
+
+function onJobCodeChange() {
+	if (query.jobCode) query.moduleCode = '';
+}
+
+async function openDetail(row: MdpRunLogRow) {
+	detail.value = await fetchMdpMonitorRunLogDetail(row.id, buildParams());
+	detailVisible.value = true;
+}
+
+async function openLineage(row?: MdpRunLogRow) {
+	lineageVisible.value = true;
+	lineageLoading.value = true;
+	try {
+		lineage.value = await fetchMdpMonitorLineage({
+			moduleCode: query.moduleCode || undefined,
+			jobCode: row?.jobCode || query.jobCode || undefined,
+			batchId: row?.batchId || undefined,
+		});
+	} finally {
+		lineageLoading.value = false;
+	}
+}
+
+function goJob() {
+	router.push('/platform/job');
+}
+
+onMounted(async () => {
+	await loadModules();
+	await loadAll();
+});
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/aidop/styles/aidop-demo.scss';
+.summary-row {
+	margin-bottom: 12px;
+}
+.summary-label {
+	color: var(--el-text-color-secondary);
+	font-size: 13px;
+	margin-bottom: 8px;
+}
+.summary-value {
+	font-size: 16px;
+	font-weight: 600;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	display: flex;
+	justify-content: flex-end;
+	margin-top: 12px;
+}
+.detail-text {
+	margin: 0;
+	white-space: pre-wrap;
+	word-break: break-word;
+}
+.lineage-stage-title {
+	font-weight: 600;
+	margin-bottom: 4px;
+}
+.lineage-stage-desc {
+	margin-bottom: 4px;
+}
+.lineage-stage-meta {
+	color: var(--el-text-color-secondary);
+	font-size: 13px;
+	line-height: 1.7;
+}
+.mapping-table {
+	margin: 8px 16px 12px 16px;
+	width: calc(100% - 32px);
+}
+</style>

+ 1 - 0
Web/src/views/aidop/data-platform/overview.vue

@@ -83,6 +83,7 @@ const entries = [
 	{ title: '数据源管理', desc: '集中维护外部系统、数据库连接、API 端点与健康状态', path: '/aidop/data-platform/sources' },
 	{ title: '数据任务管理', desc: '查看 DB/API 入站出站任务,并跳转 Admin.NET 任务调度维护 CRON', path: '/aidop/data-platform/sync-tasks' },
 	{ title: '数据任务日志', desc: '查看批次、调用结果、读取/写入行数与异常样本', path: '/aidop/data-platform/sync-logs' },
+	{ title: 'MDP运行监控', desc: '统一查看 S1/S3 等模块同步、转换、DWD 与 KPI 写入状态', path: '/aidop/data-platform/mdp-monitor' },
 	{ title: '任务调度', desc: '复用平台任务调度,不在数据中台重复造调度管理', path: '/platform/job' },
 ];
 

+ 3 - 1
Web/src/views/system/role/component/editRole.vue

@@ -115,7 +115,9 @@ const cancel = () => {
 const submit = () => {
 	ruleFormRef.value.validate(async (valid: boolean) => {
 		if (!valid) return;
-		state.ruleForm.menuIdList = treeRef.value?.getCheckedKeys() as Array<number>; //.concat(treeRef.value?.getHalfCheckedKeys());
+		const checkedKeys = (treeRef.value?.getCheckedKeys() ?? []) as Array<number>;
+		const halfCheckedKeys = (treeRef.value?.getHalfCheckedKeys() ?? []) as Array<number>;
+		state.ruleForm.menuIdList = Array.from(new Set([...checkedKeys, ...halfCheckedKeys]));
 		if (state.ruleForm.id != undefined && state.ruleForm.id > 0) {
 			await getAPI(SysRoleApi).apiSysRoleUpdatePost(state.ruleForm);
 		} else {

+ 2 - 0
doc/README.md

@@ -46,6 +46,8 @@
 | [审批流集成开发指南.md](./审批流集成开发指南.md) | `IFlowBizHandler`、`ApprovalPanel`、配置与 API |
 | [Windows后端WinSW守护重启方案.md](./Windows后端WinSW守护重启方案.md) | Windows 服务器上使用 WinSW 守护后端服务、自动重启与日志落盘 |
 | [plan/S1/S1-订单管理-审批流程实施方案.md](./plan/S1/S1-订单管理-审批流程实施方案.md) | S1 订单审批实施方案 |
+| [plan/数据库迁移/S1/S1-任务交接记忆.md](./plan/数据库迁移/S1/S1-任务交接记忆.md) | **S1 数据中台迁移**跨会话交接(当前进度、阻塞、验收 SQL) |
+| [plan/数据库迁移/S1/S1数据中台迁移实施计划.md](./plan/数据库迁移/S1/S1数据中台迁移实施计划.md) | S1 数据中台迁移主方案与分块步骤 |
 | [plan/数据中台模块扩展开发指南-S3范式.md](./plan/数据中台模块扩展开发指南-S3范式.md) | 数据中台扩展(仿 S3):四库逻辑、作业与 Cursor 协作 |
 | [db/mdp/README.md](./db/mdp/README.md) | S4/S8 相关 mdp、dwd 等建表脚本说明与执行顺序 |
 | [指标模型动态配置方案.md](./指标模型动态配置方案.md) | 指标模型动态配置总体方案 |

+ 346 - 0
doc/plan/数据库迁移/S1/S1-任务交接记忆.md

@@ -0,0 +1,346 @@
+# S1 数据中台迁移 — 任务交接记忆
+
+> **用途**:供任意 Cursor 账号 / 开发者在新会话中读取后,**无需依赖聊天历史**即可继续本任务。
+> **维护**:每完成一个分块或遇到新阻塞时,更新本文 **§2 当前状态** 与 **§8 变更记录**(只改这两节即可)。
+> **最后更新**:2026-05-26(加固已在远端验证;待 Git 提交 + 前端人工核对)
+> **关联主方案**:[S1数据中台迁移实施计划.md](./S1数据中台迁移实施计划.md)
+
+---
+
+## 0. 新会话如何快速接上(复制给 Cursor)
+
+在新对话第一条消息粘贴:
+
+```text
+请阅读并严格按 doc/plan/数据库迁移/S1/S1-任务交接记忆.md 继续 S1 数据中台迁移。
+当前 Block 7 已通过(mdp_transform_run_log 最近两条 SUCCESS、各层行数稳定、KPI 已由本轮 batch 写入)。
+若需进一步收尾:审阅 §10 收尾建议,按需收敛运行配置临时改动并准备提交。
+动手改代码前先列改动清单等我确认(见仓库 AGENTS.md / collaboration-scope 规则)。
+```
+
+也可 `@doc/plan/数据库迁移/S1/S1-任务交接记忆.md` 直接引用本文件。
+
+---
+
+## 1. 任务总览
+
+| 项 | 内容 |
+|----|------|
+| **目标** | 按 S3 范式完成 S1 产销协同 MDP 全链路:贴源 → 标准 → DWD → KPI,并切换核心读接口、替代旧存储过程 |
+| **目标库** | MySQL `aidopdev`(见 `server/Admin.NET.Application/Configuration/Database.json`) |
+| **后端版本** | `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` → **1.0.126** |
+| **前端版本** | `Web/package.json` → **2.4.162**(S1 本轮若只改后端提交,前端版本可不升) |
+| **DDL 脚本** | `server/Admin.NET.Web.Entry/UpdateScripts/1.0.125.sql`(标签 `S1-MDP-FOUNDATION-1`) |
+| **作业编码** | `S1_MDP_SYNC_TRANSFORM`,批次号格式 `S1_MDP_FULL_yyyyMMddHHmmss` |
+| **执行范式** | 分 7 块迭代;**不做**新旧读路径开关;指标只写 `ado_s9_kpi_value_*` |
+
+---
+
+## 2. 当前状态(最重要 — 每次收工必更新)
+
+### 2.1 分块进度
+
+| 分块 | 内容 | 代码 | 实库 | 备注 |
+|------|------|------|------|------|
+| 1 | DDL / mdp_entity / 基础表 | ✅ | ✅ | `1.0.125.sql` 已于 2026-05-25 经 AutoVersionUpdate 落库 |
+| 2 | 同步转换服务 / Job / 监控 | ✅ | ✅ | 服务/Job 跑通;整轮 RunFull SUCCESS(id=440/441) |
+| 3 | 核心读接口切换(列表) | ✅ | ✅ | `SeOrderService`、`OrderDeliveryService` 已切 MDP/DWD |
+| 4 | 旧存储过程 / API 替代 | ✅ | ✅ | `LinkagePlanService.Refresh` → RunFull;`ShippingPlanService` 新 ASN |
+| 5 | S1 KPI / 看板 | ✅ | ✅ | 本轮 batch 已写入 9 条 L1 指标(S1_L1_001/002/003) |
+| 6 | 菜单与前端入口 | ✅ | — | 统一 MDP 监控页(非 S1 专用页) |
+| 7 | 整体验证 | ✅ | ✅ | **已通过**:两轮 SUCCESS、行数稳定、KPI 由本轮写入 |
+
+### 2.2 实库已确认(2026-05-25 15:55,库 `aidopdev`)
+
+- ✅ 表存在:`mdp_stg_so`、`mdp_stg_ship_trans`、`mdp_std_so`、`mdp_std_ship_trans`、`dwd_ship_trans`
+- ✅ `mdp_entity` 已登记 10 个 `S1_%` 实体
+- ✅ `mdp_transform_run_log` 三次 MANUAL 均 `status=SUCCESS`:
+  - `id=444 batch=S1_MDP_FULL_20260525155503 stage=21 std=18 dwd=16 duration=5649ms`(第三次)
+  - `id=441 batch=S1_MDP_FULL_20260525154701 stage=21 std=18 dwd=16 duration=5498ms`(第二次,验幂等)
+  - `id=440 batch=S1_MDP_FULL_20260525154551 stage=21 std=17 dwd=8 duration=6158ms`(首次成功)
+- ✅ `id=442` 已清理为 `FAILED`(`end_time=2026-05-25 16:45:11`,`error_message='env:db disconnected during AUTO run'`)。原因:15:53 远端 MySQL 瞬断(`Unable to connect to any of the specified MySQL hosts` / `远程主机强迫关闭了一个现有的连接`),`MarkTransformRunFailedAsync` 自身也写不了,留成孤儿 RUNNING。属环境抖动,**非代码问题**。
+- ✅ 各层行数:`mdp_stg_so=17 / mdp_stg_ship_trans=2 / mdp_std_so=8 / mdp_std_ship_trans=1 / dwd_ship_trans=8`,多轮稳定
+- ✅ 数据租户分布:`mdp_std_so` / `dwd_ship_trans` 各跨两租户 `1300000000001` 与 `797403760988229`
+- ✅ `ado_s9_kpi_value_l1_day` 中 `module_code='S1'` 顶部 9 条由本轮 batch 写入(S1_L1_001/002/003 × 两租户)
+
+### 2.2.1 前端核对(API 通路已验,页面待人工核对)
+
+- ✅ `GET /api/DataPlatform/mdp-monitor/modules` 返回 `[{S1, S1_MDP_SYNC_TRANSFORM}, {S3, S3_MDP_SYNC_TRANSFORM}]`
+- ✅ `GET /api/DataPlatform/mdp-monitor/list?moduleCode=S1` 返回 6 条,最新 3 条与上述 444/441/440 一致
+- ⚠️ `GET /api/Order/seorder/list` / `GET /api/Order/delivery/list` 匿名访问 `tenantId=0` 返回 200/空列表(**预期**:数据租户为 `1300000000001` 与 `797403760988229`,需登录后访问对应租户)
+- 🔲 **待人工核对**:浏览器登录 → `/aidop/data-platform/mdp-monitor?moduleCode=S1` 看监控页;进 S1 订单列表、订单交付列表抽检
+
+### 2.3 当前阻塞
+
+**无**。Block 7 已通过。后续若再失败可参考 §2.5 历史阻塞与修复模式继续延展。
+
+### 2.4 本轮已修复的根因
+
+1. **`Truncated incorrect INTEGER value: 'null'`**(id 429/430/433)
+   - `S1MdpSyncTransformService.BuildStandardCommands` 中 10 处裸 `CAST(JSON_UNQUOTE(...) AS SIGNED)` 全部包装为 `CASE WHEN JSON_UNQUOTE(...) REGEXP '^-?[0-9]+$' THEN CAST(...) END`,其中 `IsDeleted` 改 `COALESCE(..., 0)`。
+2. **`Column 'tenant_id' cannot be null`**(id 439)
+   - `mdp_std_so` 第一条 SELECT `IFNULL(e.tenant_id, h.tenant_id)` → `COALESCE(e.tenant_id, h.tenant_id, 0)`;`mdp_std_ship_trans` 两条 `d.tenant_id` → `IFNULL(d.tenant_id, 0)`。与 S3 范式(`IFNULL(s.tenant_id, 0)`)对齐。
+
+### 2.5 历史阻塞修复模式(备查)
+
+- JSON 整型字段写入 NOT NULL 列:永远先 REGEXP 数字判定再 CAST;标志位/`IsDeleted` 用 `COALESCE(..., 0)` 兜底。
+- 标准层 `tenant_id NOT NULL`,贴源层 `tenant_id NULL`:在 SELECT 列上加 `0` 兜底。
+
+### 2.6 本轮运行配置变更(**已决策:保留并纳入提交**)
+
+- `server/Admin.NET.Application/Configuration/Database.json` 默认连接串追加:
+  - `AllowPublicKeyRetrieval=True;`:解决新机首次 `dotnet run` 连 `aidopdev` 时 `caching_sha2_password` 握手取公钥失败的问题
+  - `Pooling=True;MinimumPoolSize=2;MaximumPoolSize=50;ConnectionLifeTime=300;`:显式连接池参数(之前依赖 MySqlConnector 默认值 `Max=100`)
+- 决策(2026-05-26):**保留**。当前连接串本就 `SslMode=None`、密码明文,`AllowPublicKeyRetrieval=True` 不额外提高攻击面,反而消除"新机首次启动炸"反复踩坑。下个迭代单独提"连接串 SSL + 密码外置"安全收敛 PR。
+
+### 2.7 本轮加固(2026-05-26,**已在远端验证**)
+
+| 文件 | 变化 | 目的 |
+|------|------|------|
+| `S1MdpSyncTransformService.cs` `MarkSyncLogFailedAsync` / `MarkTransformRunFailedAsync` | 包 try-catch,写库自身失败时 `Console.Error.WriteLine` 兜底,不抛 | 避免远端瞬断时 MarkFailed 自身再炸(昨天 id=442 孤儿 RUNNING 的成因) |
+| `S3MdpSyncTransformService.cs` `MarkSyncLogFailedAsync` / `MarkTransformRunFailedAsync` | 同 S1 | S3 同步链路同样问题(昨天日志看到 S3 mdp_sync_log 因 MarkFailed 自身炸而留痕) |
+| `Database.json` | 连接串显式加 `Pooling/MinimumPoolSize=2/MaximumPoolSize=50/ConnectionLifeTime=300` | 明示池大小,半死连接更快回收,降低累积握手失败触发 `max_connect_errors` 的概率 |
+
+**远端验证**(2026-05-26 10:27):MySQL 与 Git 服务(165 磁盘满)恢复后,`dotnet run` + `POST /api/Order/linkageplan/refresh` → **HTTP 200**,batch=`S1_MDP_FULL_20260526102739`,`mdp_transform_run_log id=478 SUCCESS`,duration≈6235ms,stage=21 / std=18 / dwd=8。
+
+### 2.9 本次会话操作记录(2026-05-26)
+
+- 10:21 删除最早远端备份分支 `backup/full-2026-04-06`(`git push origin --delete` 成功;磁盘满期间报 500,恢复后成功)
+- 10:27 后端重启 + `POST refresh` 现网验证通过(见 §2.7)
+- 10:37 清理孤儿 `mdp_transform_run_log id=471`(磁盘满凌晨遗留 RUNNING → FAILED `env:disk full during AUTO run`)
+- 待清:`mdp_sync_log` 共 32 条历史孤儿 RUNNING(19 条 2026-05-10 历史、12 条 2026-05-25 抖动 / 磁盘满期间),见 §2.10。
+
+### 2.8 待 DBA / 165 运维侧加固(不在本轮提交范围)
+
+1. 服务端 `max_connect_errors` 调到 10000+(默认 100 太敏感)
+2. 服务端定期 `FLUSH HOSTS`(cron 每天一次)或监控告警
+3. 若有 RDS 控制台,可考虑放开 SUPER 权限给 aidopremote,便于应用侧自愈
+4. **165 磁盘监控 + 日志/binlog 轮转**(2026-05-26 凌晨磁盘满导致 Git 500、MySQL `Table 'mdp_sync_log' is full`、孤儿 RUNNING)
+5. 磁盘恢复后建议 `git fsck` 一次 Gitea 仓库(删分支期间出现过 `fatal: bad object 0000...`)
+
+### 2.10 历史孤儿 RUNNING 清理记录(已完成)
+
+**2026-05-26 11:06 已按方案 A 清理 32 条**:
+
+| 时段 | 条数 | 模块 | 起因 |
+|------|------|------|------|
+| 2026-05-10 17:34 之前 | 19 | 全 S3 | 同批次 `S3_MDP_FULL_20260511013415` 卡在贴源(15 天前历史) |
+| 2026-05-25 13–23 时 | 12 | 11 S3 + 1 S1(id=8369) | 5/25 15:53 MySQL 瞬断 + 凌晨磁盘满,`MarkSyncLogFailed` 自身写不进 |
+
+**未来不会再产生**:2.7 加固后 `MarkSyncLogFailedAsync` 已包 try-catch。两种清理方案:
+
+```sql
+-- 方案 A(按时间一刀切):清今天加固之前的全部孤儿
+UPDATE mdp_sync_log
+SET sync_end = NOW(),
+    status   = 'FAILED',
+    error_msg = COALESCE(error_msg, 'env:cleared by ops 2026-05-26 (legacy RUNNING before MarkFailed try-catch)')
+WHERE status = 'RUNNING'
+  AND sync_start < '2026-05-26 09:00:00';
+
+-- 方案 B(最小):只清 2026-05-25 那 12 条
+UPDATE mdp_sync_log
+SET sync_end = NOW(),
+    status   = 'FAILED',
+    error_msg = COALESCE(error_msg, 'env:disconnected/disk-full during AUTO run 2026-05-25')
+WHERE status = 'RUNNING'
+  AND sync_start >= '2026-05-25 00:00:00'
+  AND sync_start <  '2026-05-26 00:00:00';
+```
+
+### 2.11 MDP 链路可视化(第一阶段)
+
+**设计结论**:原整体方案是“配置化元数据 + 代码执行范式”。`mdp_source` / `mdp_entity` / `mdp_field_mapping` / `mdp_sync_log` 负责源对象、字段映射和运行留痕;S1/S3 当前同步、标准层、DWD、KPI 仍由模块后端 `*MdpSyncTransformService` 承载。`mdp_pipeline*` 纯配置驱动执行器属于后续演进,本轮不引入。
+
+**2026-05-26 已落地第一阶段只读可视化**:
+
+- 后端统一监控新增 `/api/DataPlatform/mdp-monitor/lineage`,按 `moduleCode` / `batchId` 返回阶段说明、实体清单、字段映射、本批次实体日志。
+- 前端 `MDP运行监控` 增加「链路详情」入口;运行记录行增加「链路」操作,可查看该 batch 的 `mdp_sync_log` 行数与错误。
+- 页面明确提示:标准层 / DWD / KPI 当前仍由后端 Service 代码执行,尚非纯配置生成。
+
+**后续阶段建议**:
+
+1. 配置维护页:允许维护 `mdp_source` / `mdp_entity` / `mdp_field_mapping`,但仍不直接驱动执行。
+2. 配置驱动执行器:让 `mdp_pipeline*` 消费配置,先覆盖贴源/简单标准化。
+3. 逐步替换模块硬编码 SQL:按 S1/S3/S4 等模块逐步迁移,不一次性推翻现有稳定链路。
+
+---
+
+## 3. 已落地代码清单(按模块)
+
+### 3.1 数据库 / 版本
+
+| 路径 | 说明 |
+|------|------|
+| `server/Admin.NET.Web.Entry/UpdateScripts/1.0.125.sql` | S1 MDP 底座 DDL + 10 实体登记 |
+| `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` | Version 1.0.125,脚本 CopyToOutputDirectory |
+
+**AutoVersionUpdate 机制**:
+
+- 入口:`Admin.NET.Core/Update/AutoVersionUpdate.cs`,`Startup.cs` 中 `UseAutoVersionUpdate()`
+- 仅 **WorkerId=1** 主节点执行(`Configuration/App.json`)
+- 版本状态文件:运行目录 `version.txt`,格式 `{version}^{datetime}^{isRunScript}`
+- **注意**:若 `version.txt` 不存在,首次启动会**跳过所有历史脚本**;手动补跑时需把 `version.txt` 设为低于目标版本的已执行号(如 `1.0.124^...^True`)再启动
+
+### 3.2 后端 — S1 MDP 核心
+
+| 路径 | 说明 |
+|------|------|
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs` | 贴源 / 标准 / DWD / KPI 主服务 |
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Job/S1MdpSyncTransformJob.cs` | 定时 Job,Period=3600000(60 分钟) |
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpMonitorService.cs` | S1 专用监控(已被统一监控替代,可保留) |
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Order/MdpMonitorService.cs` | **统一** MDP 监控 API,`/api/DataPlatform/mdp-monitor/*` |
+
+### 3.3 后端 — 接口切换与旧过程替代
+
+| 路径 | 说明 |
+|------|------|
+| `Order/SeOrderService.cs` | `GetSeOrderList` → `mdp_std_so` / `mdp_stg_so` |
+| `Order/OrderDeliveryService.cs` | `GetDeliveryList` → `dwd_ship_trans`,已加 `tenant_id` 过滤 |
+| `Order/LinkagePlanService.cs` | `RefreshLinkagePlanData` → `RunFullAsync(triggerType:"MANUAL")`,替代 `pr_Mes_LinkagePlan` |
+| `Order/ShippingPlanService.cs` | `ShipShippingPlan` → 新系统写 ASN,替代 `pr_WMS_AutoCreateShipper` |
+
+### 3.4 前端 — 统一 MDP 监控
+
+| 路径 | 说明 |
+|------|------|
+| `Web/src/views/aidop/data-platform/mdpMonitor.vue` | 统一监控页,默认 moduleCode=S1 |
+| `Web/src/views/aidop/data-platform/api/mdpMonitor.ts` | 监控 API 封装 |
+| `Web/src/views/aidop/data-platform/overview.vue` | 数据中台概览快捷入口 |
+| `SeedData/SysMenuSeedData.cs` | 菜单「MDP运行监控」→ `/aidop/data-platform/mdp-monitor` |
+
+### 3.5 参考(S3 范式)
+
+| 路径 | 说明 |
+|------|------|
+| `Supply/S3MdpSyncTransformService.cs` | 同步转换结构参考 |
+| `Job/S3MdpSyncTransformJob.cs` | Job 参考 |
+| `doc/plan/数据库迁移/S3/数据中台模块扩展开发指南-S3范式.md` | 范式文档 |
+
+---
+
+## 4. 关键设计决策(勿随意推翻)
+
+1. **统一 MDP 监控页**:S1/S3 共用 `mdpMonitor.vue` + `MdpMonitorService`,按 `moduleCode` / `jobCode` 筛选;不为 S1 单独再做一套页面。
+2. **Refresh 即全量 MDP**:计划联动「刷新」按钮触发整轮 `RunFullAsync`(贴源+标准+DWD+KPI),不是只刷 LinkagePlan 表。
+3. **不做读路径开关**:接口直接切 MDP/DWD,不保留 crm 直读回退。
+4. **跨模块只读**:S2/S3/S4/S7 写入规则本轮不改。
+5. **协作规则**:改代码前先列清单等负责人确认(`.cursor/rules/collaboration-scope.mdc`);提交时按实际改动端递增版本号(`.cursor/rules/version-bump-on-commit.mdc`)。
+
+---
+
+## 5. 下一步操作清单(按顺序)
+
+Block 7 已通过,本节为收尾建议:
+
+- [x] **Fix-1** `S1MdpSyncTransformService.BuildStandardCommands` JSON 整型 CAST 守卫 + `tenant_id` 兜底(见 §2.4)
+- [x] **Build-1** `dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj`(0 错误)
+- [x] **Run-1** 启动后端 `http://localhost:5005`
+- [x] **Sync-1** `POST /api/Order/linkageplan/refresh`:batch=`S1_MDP_FULL_20260525154551` stage=21 std=17 dwd=8
+- [x] **Verify-1** `mdp_transform_run_log` id=440/441 均 SUCCESS;各层行数稳定
+- [x] **Verify-2** 第二次 refresh 幂等:表行数完全无变化;第三次(id=444)也 SUCCESS
+- [x] **Verify-3a** 后端监控 API 通路:`/api/DataPlatform/mdp-monitor/modules` + `list?moduleCode=S1` 正常
+- [x] **Verify-4a** 后端订单/交付 API 通路:匿名 200 + 空列表(受 `_userManager.TenantId` 过滤)
+- [ ] **Verify-3b** 前端页面:`/aidop/data-platform/mdp-monitor?moduleCode=S1` 登录后能看到 444/441/440 batch(人工)
+- [ ] **Verify-4b** 前端页面:S1 订单列表 / 订单交付列表登录后能看到本轮数据(人工)
+- [x] **Cleanup-1** 已清理孤儿 RUNNING (`id=442` → FAILED;2026-05-26 又清 `id=471` → FAILED)
+- [x] **Cleanup-2** 已清理 `mdp_sync_log` 32 条历史孤儿 RUNNING(方案 A,2026-05-26 11:06)
+- [x] **Verify-Remote-1** 2026-05-26 现网 refresh 验加固:`mdp_transform_run_log id=478` SUCCESS(见 §2.7)
+- [x] **Decide-1** `Database.json` 末尾 `AllowPublicKeyRetrieval=True;` 保留(B1,2026-05-26);安全收敛留作下迭代单独 PR(SSL + 密码外置)
+- [x] **Menu-1** 已补 `MDP运行监控` 正式落库脚本 `1.0.126.sql`,并修复角色授权保存链路:角色配置勾选该菜单后按租户菜单范围正常持久化(2026-05-26)
+- [x] **Menu-2** 已增强 `MDP运行监控` 链路可视化(第一阶段只读):阶段说明、实体链路、字段映射、本批次实体日志(见 §2.11)
+- [ ] **Commit-1** 按 `.cursor/rules/version-bump-on-commit.mdc`,后端版本已 bump 至 1.0.126,提交:<br>• `S1MdpSyncTransformService.cs`(CAST 守卫 + tenant_id 兜底 + MarkFailed try-catch)<br>• `S3MdpSyncTransformService.cs`(MarkFailed try-catch)<br>• `Database.json`(保留 `AllowPublicKeyRetrieval` + 连接池参数)<br>• `SysRoleMenuService.cs` / `editRole.vue` / `1.0.126.sql`(MDP 菜单授权彻底修复) |
+
+---
+
+## 6. 验收 SQL(只读,逐条执行)
+
+```sql
+-- A. 核心表是否存在
+SELECT table_name FROM information_schema.tables
+WHERE table_schema = DATABASE()
+  AND table_name IN (
+    'mdp_stg_so','mdp_stg_ship_trans','mdp_std_so','mdp_std_ship_trans','dwd_ship_trans',
+    'mdp_entity','mdp_transform_run_log','mdp_sync_log'
+  )
+ORDER BY table_name;
+
+-- B. S1 实体登记
+SELECT entity_code, target_table_name, sync_mode, status
+FROM mdp_entity WHERE entity_code LIKE 'S1_%' ORDER BY entity_code;
+
+-- C. 最近一次 S1 整轮运行
+SELECT id, batch_id, status, trigger_type,
+       stage_rows, standard_rows, dwd_rows,
+       start_time, end_time, duration_ms,
+       LEFT(error_message, 300) AS error_message
+FROM mdp_transform_run_log
+WHERE job_code = 'S1_MDP_SYNC_TRANSFORM'
+ORDER BY start_time DESC LIMIT 5;
+
+-- D. 各层行数
+SELECT 'mdp_stg_so' AS tbl, COUNT(*) AS cnt FROM mdp_stg_so
+UNION ALL SELECT 'mdp_stg_ship_trans', COUNT(*) FROM mdp_stg_ship_trans
+UNION ALL SELECT 'mdp_std_so', COUNT(*) FROM mdp_std_so
+UNION ALL SELECT 'mdp_std_ship_trans', COUNT(*) FROM mdp_std_ship_trans
+UNION ALL SELECT 'dwd_ship_trans', COUNT(*) FROM dwd_ship_trans;
+
+-- E. S1 KPI(需结合 calc_time / 来源 batch 判断是否本轮写入)
+SELECT kpi_code, stat_date, metric_value, update_time
+FROM ado_s9_kpi_value_l1_day
+WHERE module_code = 'S1'
+ORDER BY update_time DESC LIMIT 20;
+```
+
+**MCP**:可用 `user-mysql-aidopdev` → `execute_sql`(**一次一条 SQL**,不要多语句拼接)。
+
+---
+
+## 7. 本地运行备忘
+
+```powershell
+# 构建
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug
+
+# 若需强制重跑 1.0.125 脚本(一般只需一次):
+# 在 bin/Debug/net10.0/version.txt 写入:1.0.124^2026-05-24 00:00:00^True
+
+# 启动后端
+dotnet run --project server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug
+
+# 触发 S1 MDP(PowerShell)
+Invoke-RestMethod -Uri "http://localhost:5005/api/Order/linkageplan/refresh" -Method POST
+```
+
+**数据库连接**:`server/Admin.NET.Application/Configuration/Database.json` → `aidopdev` @ `123.60.180.165:3306`
+
+**前端**(验监控页时):
+
+```powershell
+cd Web; pnpm dev
+# 路由:/aidop/data-platform/mdp-monitor?moduleCode=S1
+```
+
+---
+
+## 8. 变更记录
+
+| 日期 | 更新人/会话 | 摘要 |
+|------|-------------|------|
+| 2026-05-25 | Cursor 会话 | 创建交接记忆;Block 1–6 代码完成;1.0.125 已落库;Block 7 阻塞于 `mdp_std_so` JSON 整型 CAST |
+| 2026-05-25 | Cursor 会话 | Block 7 通过:修复 `BuildStandardCommands` 10 处 JSON 整型 CAST + 3 处 `tenant_id` NOT NULL 兜底;两轮 RunFull SUCCESS(id=440/441),KPI 由 batch 440 写入;本机临时为 `Database.json` 加 `AllowPublicKeyRetrieval=True;`(未提交) |
+| 2026-05-25 | Cursor 会话 | 后端 API 通路核对完成(监控/订单/交付);新增第三次 MANUAL SUCCESS(id=444);记录 15:53 远端 MySQL 瞬断导致 AUTO id=442 留孤儿 RUNNING;前端页面核对待人工 |
+| 2026-05-25 | Cursor 会话 | 16:13 源 IP `111.32.81.231` 累计连接错误触发 `max_connect_errors` 被 MySQL host cache 封禁;本机 `mysqlsh` 用 root 执行 `FLUSH HOSTS` 解封;第 4 次 MANUAL SUCCESS(batch=S1_MDP_FULL_20260525161408);孤儿 id=442 已标 FAILED 清理 |
+| 2026-05-26 | Cursor 会话 | 应用侧加固:S1/S3 的 `MarkSyncLogFailedAsync` / `MarkTransformRunFailedAsync` 均包 try-catch;`Database.json` 加 `Pooling/MinPool=2/MaxPool=50/Lifetime=300`;`dotnet build` 通过;上午 165 磁盘满导致 MySQL/Git 短暂不可用,恢复后 10:27 现网 refresh `S1_MDP_FULL_20260526102739` SUCCESS(id=478);删最早远端备份分支 `backup/full-2026-04-06`;清孤儿 `mdp_transform_run_log id=471 → FAILED` |
+| 2026-05-26 | Cursor 会话 | 修复「数据中台管理 → MDP运行监控」菜单不显示 / 概览跳转 404:补 `1.0.126.sql` 幂等写入 `SysMenu 1320990000406` 与各租户 `SysTenantMenu`;前端角色授权提交 checked+halfChecked;后端 `GrantRoleMenu` 按角色租户的 `SysTenantMenu` 过滤并持久化授权;前端 `pnpm build`、后端 `dotnet build` 均通过;后端已重启到 1.0.126 且脚本执行成功。 |
+| 2026-05-26 | Cursor 会话 | MDP 链路可视化第一阶段:确认原设计为“配置化元数据 + 代码执行范式”,新增统一监控 lineage 只读接口和前端链路详情抽屉,可查看阶段说明、实体源/目标、字段映射、本批次 `mdp_sync_log` 行数与错误;不做前端配置写入,不引入 `mdp_pipeline*` 配置驱动执行器。 |
+
+---
+
+## 9. 明确不在本轮范围
+
+- 不做 `mdp_pipeline*` 配置驱动执行器
+- 不改 ApprovalFlow 核心插件
+- 不新建并行 KPI 值表
+- 不重构 S2/S3/S4/S7 业务写入
+- 不做新旧读路径 feature flag

+ 102 - 0
doc/plan/数据库迁移/S1/S1数据中台迁移实施计划.md

@@ -0,0 +1,102 @@
+# S1 数据中台试点扩展计划
+
+## 目标范围
+- 按 S3 范式实现 S1 首批完整链路:销售订单、订单变更、合同评审、产品设计关联、发货计划、ASN 发货、产销联动追溯、S1 KPI。
+- 数据分层采用现有设计中的 `mdp_stg_so`、`mdp_std_so`、`mdp_stg_ship_trans`、`mdp_std_ship_trans`、`dwd_ship_trans`,必要时补齐字段到能支撑 S1 页面与指标。
+- 研发阶段直接切换 S1 相关接口到 MDP / DWD 读数,不做新旧读路径开关。
+
+## 执行节奏
+- S1 整体参考 S3 的分层范式、同步转换结构和硬约束,但不照搬 S3 试点阶段的全套细化任务包。
+- 采用“主方案 + 分块任务卡 + 开发测试迭代”的方式推进,每个分块都要能独立开发、独立验证、发现问题后及时回修。
+- 单次迭代避免同时横跨 DDL、同步作业、接口切换、前端入口和 KPI 全链路,降低改动过大带来的回归风险。
+- 文档颗粒度以支撑实施和验收为准;必要时只补当前分块的任务卡,不预先生成所有 S1 细化包。
+
+## 硬约束
+- **表的分层必须清晰**:L1 贴源 `mdp_stg_*` 只保留源行与 `raw_data`;L2 标准 `mdp_std_*` 承载统一订单/发货/评审/联动对象;L3 明细 `dwd_*` 面向 S1 页面、价值流和诊断读数;L4 指标继续写现有 `ado_s9_kpi_value_*`。不得把页面临时 JOIN、业务事务表和指标结果混在同一层。
+- **幂等与血缘字段必须齐全**:每层表和转换 SQL 必须保留 `source_system`、`source_table`、`source_row_id`、`source_biz_key`、`sync_batch_id`、`sync_time` 等血缘字段;`mdp_std_*` / `dwd_*` 重跑不能重复造订单、发货单、联动事实或指标值。
+- **ETL 不得承载业务副作用**:`S1MdpSyncTransformService` 只负责贴源同步、标准化、DWD 构建和 KPI 写入;生成发货单、推进订单状态、调用外部系统等动作必须在业务 Service / Job 中完成并记录业务日志。
+- **DWD 必须可重建**:`dwd_ship_trans` 等宽表必须能从 `mdp_std_*` 和核心运行表重建;人工修正、流程状态、审批结论、业务确认结果不得只写在 DWD 中。
+- **租户与工厂口径必须显式**:S1 表结构、实体配置、转换 SQL 和查询接口必须带 `tenant_id`,需要时带 `factory_id` / `company_id`;除全局配置类数据外,不得长期写死租户 `0` 或 `1`。
+- **同步日志是上线门禁**:每轮 S1 同步必须写 `mdp_transform_run_log`,每个源实体必须写 `mdp_sync_log`;失败要记录实体、批次、阶段、错误摘要和影响行数,不能只依赖后端文本日志。
+- **旧系统存储过程必须转成新模式**:`CALL pr_Mes_LinkagePlan`、`CALL pr_WMS_AutoCreateShipper` 等旧过程不能继续作为 S1 正式链路;要拆成新系统 `.NET Service`、`IJob`、规则/公式配置、编号服务和操作日志,保证可测试、可追溯、可重跑。
+- **旧系统 API 调用必须统一收口**:若 S1 前端或后端仍调用旧 `api/business/*` 或其它旧系统服务,必须迁入新系统后端 API / Service 架构;前端只调用新系统接口,外部系统交互只允许由后端白名单 Service、任务日志和必要的外部事务记录承载。
+- **接口兼容优先**:现有 S1 页面接口路径和 DTO 字段尽量保持兼容;允许新增字段和切换数据源,不得随意删除或改名导致前端一次性大改。
+- **跨模块只读优先**:S1 可以读取 S2/S3/S4/S7 的标准层或 DWD 聚合上下文,但本轮不修改这些模块的业务写入规则、数据口径和种子数据。
+- **指标不另起体系**:S1 KPI 只能登记到 `ado_smart_ops_kpi_master` 并写入现有 `ado_s9_kpi_value_*`,不得新建并行指标表或绕过 S9 指标体系。
+
+## 关键依据
+- 总体设计:[../新系统数据库设计方案.md](../../新系统数据库设计方案.md)
+- S3 范式指南:[../S3/数据中台模块扩展开发指南-S3范式.md](../S3/数据中台模块扩展开发指南-S3范式.md)
+- S3 同步转换参考:[S3MdpSyncTransformService.cs](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs)
+- S3 作业参考:[S3MdpSyncTransformJob.cs](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Job/S3MdpSyncTransformJob.cs)
+- S1 现有接口主要落点:[SeOrderService.cs](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Order/SeOrderService.cs)、[OrderDeliveryService.cs](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderDeliveryService.cs)、[AsnShipperService.cs](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Order/AsnShipperService.cs)
+- S1 菜单落点:[SysMenuSeedData.cs](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs)
+
+## 跨模块影响
+- S2 / S6:S1 订单交付价值流会关联工单、排程、报工和生产执行状态,DWD 口径不能破坏现有工单页面。
+- S3 / S4:订单交付价值流会关联采购、交货计划、发货与退货数据,若复用 `dwd_supplier_delivery` 或 S4 发货对象,只读聚合优先,不改其业务写入规则。
+- S7:ASN / 发货事实可能与成品仓储出库复用 `dwd_ship_trans`,首批只服务 S1 读数,避免擅自重定义 S7 出入库口径。
+- S8 / S9:S1 KPI 和订单链路状态会被异常监控、九宫格和 ChatBI 消费,新增指标要写入现有 `ado_s9_kpi_value_*`,不新建并行指标表。
+
+## 实施步骤
+1. **DDL / mdp_entity / 基础同步**
+   - 新增或补齐 UpdateScript:L1 `mdp_stg_so` / `mdp_stg_ship_trans`,L2 `mdp_std_so` / `mdp_std_ship_trans`,L3 `dwd_ship_trans`,L4 沿用 `ado_s9_kpi_value_*`。
+   - 登记 S1 源实体到 `mdp_entity`:`crm_seorder`、`crm_seorderentry`、`crm_seorder_change`、`ContractReview` / `contract_review`、`ShippingPlan` / `ShippingPlanDetail`、`ASNBOLShipperMaster` / `ASNBOLShipperDetail`、`LinkagePlan`。
+   - 字段以 S1 页面和指标为准补齐:订单号、客户、物料、数量、计划交期、承诺交期、订单状态、评审状态、发货计划、实际发货、工单号、链路状态。
+   - 明确每张表的层级职责和禁止事项:`mdp_stg_*` 不做业务聚合,`mdp_std_*` 不写页面临时统计,`dwd_*` 不承载业务写入副作用。
+   - 本块验收:作业可跑到基础表,日志有记录,标准层和 DWD 行数稳定。
+
+2. **S1 同步转换服务 / 作业 / 监控**
+   - 新建 `S1MdpSyncTransformService`,复制 S3 的结构:`RunFullAsync`、`SyncStagingAsync`、`TransformStandardAsync`、`BuildDwdAsync`、`BuildS1KpiValuesAsync`。
+   - 新建 `S1MdpEntityConfig`,集中定义源表、目标 staging 表、业务键、行 ID 表达式。
+   - 日志写入 `mdp_sync_log` 和 `mdp_transform_run_log`,`job_code` 建议 `S1_MDP_SYNC_TRANSFORM`,批次号建议 `S1_MDP_FULL_yyyyMMddHHmmss`。
+   - 转换 SQL 使用批次号、来源业务键和租户条件做幂等 upsert / delete-insert,确保重复执行结果稳定。
+   - 新建 `S1MdpSyncTransformJob`,按 S3 作业方式接入 Furion Schedule。
+   - 新建或泛化监控接口,优先复制 `S3MdpMonitorService` 为 `S1MdpMonitorService`,路径建议 `/api/Order/s1-mdp-monitor/*` 或 `/api/DataPlatform/mdp-monitor?jobCode=S1_MDP_SYNC_TRANSFORM`。
+   - 前端新增 S1 MDP 运行监控页面,可复用 S3 `mdpMonitorList.vue` 的结构,标题和 API 改为 S1。
+   - 本块验收:可手动触发、可重复执行、不重复造数、失败可追踪。
+
+3. **核心读接口切换**
+   - `SeOrderService.GetSeOrderList`:列表默认读 `mdp_std_so` / 订单评审聚合结果,不继续直接拼 `crm_seorder` 多表。
+   - `OrderDeliveryService.GetDeliveryList`:列表和进度状态读 `dwd_ship_trans` + 必要的工单/采购 DWD 聚合。
+   - `OrderDeliveryService.GetDeliveryFlow`:保留 8 个 Tab 的前端返回结构,但数据源改为标准层 / DWD 聚合,减少直接散表 JOIN。
+   - `AsnShipperService`、`ShippingPlanService`:发货计划和 ASN 写入后触发或参与 S1 DWD 刷新,保证页面保存后中台读数可见。
+   - 本块验收:订单、交付、详情页可用,接口路径和 DTO 基本兼容,读数来自 MDP / DWD。
+
+4. **旧存储过程 / 旧 API 替代**
+   - `LinkagePlanService` 中的 `pr_Mes_LinkagePlan` 改为新系统 `LinkagePlan` 应用服务 + 可调度 Job,输出 `plan_linkage` / `dwd_ship_trans` 所需联动事实。
+   - `ShippingPlanService` 中的 `pr_WMS_AutoCreateShipper` 改为新系统发货生成 Service,负责写入发货运行表、标准层和 DWD 刷新所需数据。
+   - 全仓扫描 S1 相关旧系统 API 调用;发现前端直连或后端转调旧 `api/business/*` 时,迁移为新系统 `/api/Order/*` 或模块内 Service,不保留长期旧 API 依赖。
+   - 对有副作用动作记录结构化执行摘要和错误信息,复用 `sys_log_op` 或新增模块日志,避免只返回“成功”。
+   - 本块验收:S1 正式链路不再依赖旧存储过程和旧 `api/business/*`;如有临时外部依赖,必须白名单化并留日志。
+
+5. **S1 KPI / 看板联动**
+   - 补齐 `ado_smart_ops_kpi_master` 中 S1 指标种子或确认已有指标编码。
+   - 计算结果写入 `ado_s9_kpi_value_l1_day` / `ado_s9_kpi_value_l2_day`,`module_code='S1'`。
+   - 首批建议指标:订单达成率、交期偏差、订单评审周期、发货达成率、计划联动达成率。
+   - 本块验收:S1 KPI 写入 `ado_s9_kpi_value_*`,九宫格 / S1 看板能读取最新值。
+
+6. **菜单与前端入口**
+   - 在 S1 产销协同看板或数据中台管理下增加 S1 MDP 运行监控入口。
+   - 更新前端 API 封装,避免页面直连旧表语义接口;接口路径可保持现有页面路径,降低页面改动。
+   - 对 `orderList.vue`、`orderDeliveryList.vue`、`orderDeliveryFlow.vue`、`asnShipperList/Form`、`linkagePlanList` 做字段兼容检查。
+
+7. **整体验证**
+   - 运行后端构建与前端构建。
+   - 手动触发 S1 MDP 作业,检查 `mdp_transform_run_log`、`mdp_sync_log`、各 `mdp_std_*`、`dwd_ship_trans` 行数。
+   - 验证空数据场景不报错,样本数据能跑通订单、评审、联动、发货计划 / ASN、DWD 和 KPI。
+   - 重复触发同一批或连续触发作业,验证标准层、DWD 和 KPI 不重复造数。
+   - 验证 S1 订单评审、订单交付、订单发货、计划联动看板页面无报错,分页/查询/详情仍可用。
+   - 验证 S1 KPI 当日写入,九宫格/S1 看板能读取最新值。
+   - 验证旧存储过程和旧 API 不再出现在 S1 正式调用链中;保留的历史表只作为贴源或迁移输入。
+
+## 任务交接记忆
+
+- 跨会话 / 跨 Cursor 账号继续开发时,**优先阅读并维护**:[S1-任务交接记忆.md](./S1-任务交接记忆.md)(含当前阻塞、验收 SQL、下一步清单)。
+
+## 不做事项
+- 不做新旧读路径开关。
+- 不引入 `mdp_pipeline*` 纯配置驱动执行器,仍按 S3 当前代码内 SQL 范式实现。
+- 不修改 ApprovalFlow 核心插件。
+- 不新建与 `ado_s9_kpi_value_*` 并行的指标值表。
+- 不重构 S2/S3/S4/S7 既有业务写入规则,只在 S1 侧做只读聚合或引用。

+ 0 - 0
doc/plan/S3调用旧系统Business API解析.md → doc/plan/数据库迁移/S3/S3调用旧系统Business API解析.md


+ 0 - 0
doc/plan/数据中台模块扩展开发指南-S3范式.md → doc/plan/数据库迁移/S3/数据中台模块扩展开发指南-S3范式.md


+ 0 - 0
doc/plan/数据库迁移-S3供应协同试点实施方案.md → doc/plan/数据库迁移/S3/数据库迁移-S3供应协同试点实施方案.md


+ 0 - 0
doc/plan/数据库迁移-S3存储过程替代任务包.md → doc/plan/数据库迁移/S3/数据库迁移-S3存储过程替代任务包.md


+ 0 - 0
doc/plan/数据库迁移-S3首批DDL任务包.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批DDL任务包.md


+ 0 - 0
doc/plan/数据库迁移-S3首批MDP同步配置草案.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批MDP同步配置草案.md


+ 0 - 0
doc/plan/数据库迁移-S3首批前端接口联调验收清单.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批前端接口联调验收清单.md


+ 0 - 0
doc/plan/数据库迁移-S3首批后端改造任务清单.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批后端改造任务清单.md


+ 0 - 0
doc/plan/数据库迁移-S3首批实施任务拆解.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批实施任务拆解.md


+ 0 - 0
doc/plan/数据库迁移-S3首批建表SQL草案.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批建表SQL草案.md


+ 0 - 0
doc/plan/数据库迁移-S3首批标准化转换规则草案.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批标准化转换规则草案.md


+ 0 - 0
doc/plan/数据库迁移-S3首批落库评审清单.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批落库评审清单.md


+ 0 - 0
doc/plan/数据库迁移-S3首批评审汇总与决策清单.md → doc/plan/数据库迁移/S3/数据库迁移-S3首批评审汇总与决策清单.md


二进制
doc/ppt/Ai-DOP项目周报_20260511-0515.pptx


二进制
doc/ppt/Ai-DOP项目周报_20260511-0517.pptx


二进制
doc/ppt/Ai-DOP项目周报_20260518-0522.pptx


+ 465 - 0
doc/ppt/generate_weekly_report.py

@@ -0,0 +1,465 @@
+"""
+Ai-DOP 项目周报 PPT 生成脚本
+当前周期:2026.05.11 — 2026.05.15
+"""
+from pptx import Presentation
+from pptx.util import Inches, Pt, Emu
+from pptx.dml.color import RGBColor
+from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
+from pptx.enum.shapes import MSO_SHAPE
+import datetime
+
+# ============ 配色方案 ============
+PRIMARY = RGBColor(0x1A, 0x56, 0xDB)      # 深蓝主色
+ACCENT = RGBColor(0x10, 0xB9, 0x81)       # 绿色-完成
+WARNING = RGBColor(0xF5, 0x9E, 0x0B)      # 橙色-进行中
+DANGER = RGBColor(0xEF, 0x44, 0x44)       # 红色-风险
+DARK = RGBColor(0x1E, 0x29, 0x3B)         # 深色文字
+GRAY = RGBColor(0x6B, 0x72, 0x80)         # 灰色
+LIGHT_BG = RGBColor(0xF8, 0xFA, 0xFC)     # 浅灰背景
+WHITE = RGBColor(0xFF, 0xFF, 0xFF)
+
+prs = Presentation()
+prs.slide_width = Inches(13.333)  # 16:9 宽屏
+prs.slide_height = Inches(7.5)
+
+
+def add_bg(slide, color):
+    """设置幻灯片背景色"""
+    bg = slide.background
+    fill = bg.fill
+    fill.solid()
+    fill.fore_color.rgb = color
+
+
+def add_rect(slide, left, top, width, height, color, transparency=0):
+    """添加矩形色块"""
+    shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
+    shape.fill.solid()
+    shape.fill.fore_color.rgb = color
+    shape.line.fill.background()
+    return shape
+
+
+def add_text(slide, left, top, width, height, text, font_size=14, color=DARK, bold=False, alignment=PP_ALIGN.LEFT, font_name='Microsoft YaHei'):
+    """添加文本框"""
+    txBox = slide.shapes.add_textbox(left, top, width, height)
+    tf = txBox.text_frame
+    tf.word_wrap = True
+    p = tf.paragraphs[0]
+    p.text = text
+    p.font.size = Pt(font_size)
+    p.font.color.rgb = color
+    p.font.bold = bold
+    p.font.name = font_name
+    p.alignment = alignment
+    return txBox
+
+
+def add_table(slide, left, top, width, height, rows, cols, data, col_widths=None):
+    """添加表格"""
+    table_shape = slide.shapes.add_table(rows, cols, left, top, width, height)
+    table = table_shape.table
+
+    if col_widths:
+        for i, w in enumerate(col_widths):
+            table.columns[i].width = w
+
+    for r in range(rows):
+        for c in range(cols):
+            cell = table.cell(r, c)
+            cell.text = str(data[r][c]) if r < len(data) and c < len(data[r]) else ''
+            for paragraph in cell.text_frame.paragraphs:
+                paragraph.font.size = Pt(11)
+                paragraph.font.name = 'Microsoft YaHei'
+                if r == 0:  # 表头
+                    paragraph.font.bold = True
+                    paragraph.font.color.rgb = WHITE
+                    paragraph.font.size = Pt(12)
+                else:
+                    paragraph.font.color.rgb = DARK
+
+            # 表头背景
+            if r == 0:
+                cell.fill.solid()
+                cell.fill.fore_color.rgb = PRIMARY
+            elif r % 2 == 0:
+                cell.fill.solid()
+                cell.fill.fore_color.rgb = LIGHT_BG
+
+    return table_shape
+
+
+def add_module_bar(slide, left, top, width, progress_pct, color=ACCENT):
+    """添加进度条"""
+    # 背景条
+    add_rect(slide, left, top, width, Pt(16), RGBColor(0xE5, 0xE7, 0xEB))
+    # 进度条
+    if progress_pct > 0:
+        add_rect(slide, left, top, int(width * progress_pct / 100), Pt(16), color)
+    # 百分比文字
+    add_text(slide, left + width + Pt(6), top - Pt(1), Pt(40), Pt(16),
+             f'{progress_pct}%', font_size=10, color=GRAY)
+
+
+# ============================================================
+# 第 1 页:封面
+# ============================================================
+slide1 = prs.slides.add_slide(prs.slide_layouts[6])  # 空白布局
+add_bg(slide1, PRIMARY)
+
+# 顶部装饰条
+add_rect(slide1, Inches(0), Inches(0), prs.slide_width, Inches(0.08), ACCENT)
+
+# 标题
+add_text(slide1, Inches(1.5), Inches(1.8), Inches(10), Inches(1.2),
+         'Ai-DOP 企业智慧运营管理平台', font_size=44, color=WHITE, bold=True)
+
+# 副标题
+add_text(slide1, Inches(1.5), Inches(2.9), Inches(10), Inches(0.8),
+         '产互联项目 · 周报', font_size=28, color=RGBColor(0xBF, 0xDB, 0xFE))
+
+# 分隔线
+add_rect(slide1, Inches(1.5), Inches(3.9), Inches(3), Inches(0.04), ACCENT)
+
+# 周期信息
+add_text(slide1, Inches(1.5), Inches(4.3), Inches(5), Inches(0.5),
+         '周期:2026.05.11 — 2026.05.15', font_size=18, color=RGBColor(0xBF, 0xDB, 0xFE))
+
+# 右下角信息
+add_text(slide1, Inches(1.5), Inches(5.8), Inches(5), Inches(0.4),
+         '项目整体周期:2026.04.15 — 2026.07.31  |  当前进度约 20%', font_size=14, color=RGBColor(0x93, 0xAD, 0xD6))
+
+add_text(slide1, Inches(9.5), Inches(6.5), Inches(3), Inches(0.4),
+         '2026.05.15', font_size=12, color=RGBColor(0x93, 0xAD, 0xD6), alignment=PP_ALIGN.RIGHT)
+
+# ============================================================
+# 第 2 页:项目整体里程碑
+# ============================================================
+slide2 = prs.slides.add_slide(prs.slide_layouts[6])
+add_bg(slide2, WHITE)
+
+# 页眉
+add_rect(slide2, Inches(0), Inches(0), prs.slide_width, Inches(0.08), PRIMARY)
+add_text(slide2, Inches(0.8), Inches(0.35), Inches(10), Inches(0.6),
+         '项目整体里程碑', font_size=28, color=DARK, bold=True)
+add_text(slide2, Inches(10), Inches(6.9), Inches(3), Inches(0.4),
+         'Ai-DOP 项目周报 | 2026.05.11-05.15', font_size=9, color=GRAY, alignment=PP_ALIGN.RIGHT)
+
+# 里程碑时间轴数据
+milestones = [
+    ('需求沟通\n与项目准备', '04/15 - 04/20', '已完成', ACCENT, 100),
+    ('项目启动', '04/20 - 04/24', '已完成', ACCENT, 100),
+    ('功能实现与\n业务模块测试', '04/27 - 07/17', '进行中', WARNING, 15),
+    ('UAT\n集成测试', '07/17 - 07/28', '待启动', GRAY, 0),
+    ('系统部署\n与上线', '07/29 - 07/31', '待启动', GRAY, 0),
+]
+
+bar_y = Inches(1.6)
+for i, (name, date, status, color, pct) in enumerate(milestones):
+    x = Inches(1.2 + i * 2.4)
+    # 阶段卡片
+    card = add_rect(slide2, x, bar_y, Inches(2.0), Inches(2.8), WHITE)
+    card.shadow.inherit = False
+
+    # 顶部色条
+    add_rect(slide2, x, bar_y, Inches(2.0), Inches(0.06), color)
+
+    add_text(slide2, x + Inches(0.15), bar_y + Inches(0.2), Inches(1.7), Inches(0.8),
+             name, font_size=14, color=DARK, bold=True)
+    add_text(slide2, x + Inches(0.15), bar_y + Inches(1.1), Inches(1.7), Inches(0.4),
+             date, font_size=11, color=GRAY)
+    add_text(slide2, x + Inches(0.15), bar_y + Inches(1.5), Inches(1.7), Inches(0.4),
+             status, font_size=13, color=color, bold=True)
+
+    # 进度条
+    add_module_bar(slide2, x + Inches(0.15), bar_y + Inches(2.1), Inches(1.3), pct, color)
+
+    # 箭头连接符(除了最后一个)
+    if i < len(milestones) - 1:
+        add_text(slide2, x + Inches(2.05), bar_y + Inches(1.1), Inches(0.3), Inches(0.4),
+                 '▶', font_size=16, color=GRAY)
+
+# 关键数据
+data_y = Inches(5.0)
+add_rect(slide2, Inches(0.8), data_y, Inches(11.7), Inches(0.01), RGBColor(0xE5, 0xE7, 0xEB))
+
+metrics = [
+    ('15 个', '业务模块'),
+    ('S0-S9', '业务域覆盖'),
+    ('3.5 个月', '项目总周期'),
+    ('20%', '当前总进度'),
+    ('5/8-6/12', '数据库迁移中'),
+]
+for i, (num, label) in enumerate(metrics):
+    x = Inches(1.0 + i * 2.4)
+    add_text(slide2, x, data_y + Inches(0.3), Inches(2.0), Inches(0.5),
+             num, font_size=24, color=PRIMARY, bold=True)
+    add_text(slide2, x, data_y + Inches(0.8), Inches(2.0), Inches(0.4),
+             label, font_size=12, color=GRAY)
+
+# ============================================================
+# 第 3 页:本周摘要 (5/11-5/17)
+# ============================================================
+slide3 = prs.slides.add_slide(prs.slide_layouts[6])
+add_bg(slide3, WHITE)
+add_rect(slide3, Inches(0), Inches(0), prs.slide_width, Inches(0.08), PRIMARY)
+add_text(slide3, Inches(0.8), Inches(0.35), Inches(10), Inches(0.6),
+         '本周核心摘要', font_size=28, color=DARK, bold=True)
+add_text(slide3, Inches(10), Inches(6.9), Inches(3), Inches(0.4),
+         'Ai-DOP 项目周报 | 2026.05.11-05.15', font_size=9, color=GRAY, alignment=PP_ALIGN.RIGHT)
+
+# 三列布局
+col_data = [
+    ('✅ 本周已完成', ACCENT, [
+        '数据库模型迁移完成\n(SQL Server → MySQL)',
+        'S0/S1 技术方案设计交付',
+        'S2 制造协同蓝图设计交付',
+        'S8/S9 蓝图设计推进过半',
+    ]),
+    ('🔄 持续推进中', WARNING, [
+        'S0/S1 功能开发启动',
+        'S8 全流程即时异常监控\n蓝图(截止 5/22)',
+        'S9 九宫格智慧运营看板\n蓝图(截止 5/22)',
+        '存储过程重构 + 程序迁移\n(5/11-6/12)',
+    ]),
+    ('⚠️ 重点', DANGER, [
+        '5 月多条线并行\nS0/S1开发 + S2方案 +\nS8/S9蓝图',
+    ]),
+]
+
+for i, (title, color, items) in enumerate(col_data):
+    x = Inches(0.7 + i * 4.2)
+    # 标题
+    add_rect(slide3, x, Inches(1.3), Inches(3.6), Inches(0.5), color)
+    add_text(slide3, x + Inches(0.15), Inches(1.32), Inches(3.3), Inches(0.45),
+             title, font_size=16, color=WHITE, bold=True)
+
+    # 条目
+    y = Inches(2.1)
+    for item in items:
+        item_box = add_rect(slide3, x, y, Inches(3.6), Inches(0.82), LIGHT_BG)
+        add_text(slide3, x + Inches(0.12), y + Inches(0.05), Inches(3.35), Inches(0.72),
+                 f'• {item}', font_size=12, color=DARK)
+        y += Inches(0.92)
+
+# ============================================================
+# 第 4 页:模块进度详情
+# ============================================================
+slide4 = prs.slides.add_slide(prs.slide_layouts[6])
+add_bg(slide4, WHITE)
+add_rect(slide4, Inches(0), Inches(0), prs.slide_width, Inches(0.08), PRIMARY)
+add_text(slide4, Inches(0.8), Inches(0.35), Inches(10), Inches(0.6),
+         '各模块进度详情', font_size=28, color=DARK, bold=True)
+add_text(slide4, Inches(10), Inches(6.9), Inches(3), Inches(0.4),
+         'Ai-DOP 项目周报 | 2026.05.11-05.15', font_size=9, color=GRAY, alignment=PP_ALIGN.RIGHT)
+
+# 表格数据
+table_data = [
+    ['模块', '蓝图设计', '技术方案', '功能开发', '功能测试', '整体进度', '状态'],
+    ['S0 运营建模', '4/27-5/6', '5/7-5/13', '5/14-5/27', '5/28-6/3', '45%', '正常'],
+    ['S1 产销协同', '4/27-5/6', '5/7-5/13', '5/14-5/27', '5/28-6/3', '45%', '正常'],
+    ['S2 制造协同', '5/6-5/12', '5/13-5/19', '5/20-6/2', '6/3-6/9', '30%', '正常'],
+    ['S3 供应协同', '5/25-5/29', '6/1-6/5', '6/8-6/22', '6/23-6/29', '0%', '待启动'],
+    ['S4 采购执行', '6/8-6/12', '6/15-6/22', '6/23-7/6', '7/7-7/13', '0%', '待启动'],
+    ['S5 物料仓储', '6/1-6/5', '6/8-6/12', '6/15-6/19', '6/22-6/26', '0%', '待启动'],
+    ['S6 生产执行', '6/15-6/19', '6/22-6/26', '6/29-7/3', '7/6-7/10', '0%', '待启动'],
+    ['S7 成品仓储', '6/22-6/26', '6/29-7/3', '7/6-7/10', '7/13-7/17', '0%', '待启动'],
+    ['S8 异常监控', '5/11-5/22', '5/25-5/29', '6/1-7/3', '7/6-7/10', '15%', '正常'],
+    ['S9 智慧看板', '5/11-5/22', '5/25-5/29', '6/1-7/3', '7/6-7/10', '15%', '正常'],
+]
+
+add_table(slide4, Inches(0.5), Inches(1.3), Inches(12.3), Inches(4.4),
+          len(table_data), len(table_data[0]), table_data)
+
+# 数据库迁移专项
+add_text(slide4, Inches(0.8), Inches(5.9), Inches(4), Inches(0.4),
+         '📦 数据库迁移专项 (SQL Server → MySQL)', font_size=14, color=DARK, bold=True)
+
+db_data = [
+    ['任务', '时间', '进度', '状态'],
+    ['数据库模型迁移', '5/8 - 5/15', '100%', '已完成'],
+    ['存储过程重构', '5/11 - 6/12', '20%', '进行中'],
+    ['程序调用迁移', '5/11 - 6/12', '20%', '进行中'],
+]
+add_table(slide4, Inches(0.5), Inches(6.3), Inches(12.3), Inches(0),
+          len(db_data), len(db_data[0]), db_data)
+
+# ============================================================
+# 第 5 页:交付物时间计划
+# ============================================================
+slide5 = prs.slides.add_slide(prs.slide_layouts[6])
+add_bg(slide5, WHITE)
+add_rect(slide5, Inches(0), Inches(0), prs.slide_width, Inches(0.08), PRIMARY)
+add_text(slide5, Inches(0.8), Inches(0.35), Inches(10), Inches(0.6),
+         '交付物时间计划', font_size=28, color=DARK, bold=True)
+add_text(slide5, Inches(10), Inches(6.9), Inches(3), Inches(0.4),
+         'Ai-DOP 项目周报 | 2026.05.11-05.15', font_size=9, color=GRAY, alignment=PP_ALIGN.RIGHT)
+
+# 根据各模块测试结束日期 + 1 周后的周五计算交付日期
+import datetime as _dt
+
+def _next_friday_after_one_week(d):
+    one_week = d + _dt.timedelta(days=7)
+    days_to_fri = (4 - one_week.weekday()) % 7
+    if days_to_fri == 0 and one_week.weekday() != 4:
+        days_to_fri = 7
+    return one_week + _dt.timedelta(days=days_to_fri)
+
+# 交付物数据:(交付物名称, 测试/阶段结束日期)
+_deliverables = [
+    ('S0运营建模模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 6, 3)),
+    ('S1产销协同模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 6, 3)),
+    ('S2制造协同模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 6, 9)),
+    ('S3供应协同模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 6, 29)),
+    ('S4采购执行模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 13)),
+    ('S5物料仓储和内物流模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 6, 26)),
+    ('S6生产执行模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 10)),
+    ('S7成品仓储和外物流模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 17)),
+    ('S8全流程即时异常监控模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 10)),
+    ('S9九宫格智慧运营看板模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 10)),
+    ('运营诊断模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 17)),
+    ('运营改善模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 17)),
+    ('ChatBI智能报表模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 17)),
+    ('APP端模块:《业务需求说明书》《概要设计说明书》《功能测试报告》', _dt.date(2026, 7, 17)),
+    ('系统集成:《概要设计说明书》《集成测试报告》', _dt.date(2026, 7, 17)),
+    ('UAT集成测试:《UAT测试报告》', _dt.date(2026, 7, 23)),
+    ('系统部署和上线:《验收报告》、《操作手册》、《运维手册》', _dt.date(2026, 7, 31)),
+]
+
+delivery_data = [['交付物', '测试完成日期', '计划交付日期']]
+for name, end_date in _deliverables:
+    delivery = _next_friday_after_one_week(end_date)
+    delivery_data.append([
+        name,
+        end_date.strftime('%m/%d'),
+        delivery.strftime('%m/%d'),
+    ])
+
+delivery_tbl = add_table(slide5, Inches(0.5), Inches(1.2), Inches(12.3), Inches(5.5),
+                         len(delivery_data), len(delivery_data[0]), delivery_data,
+                         col_widths=[Inches(7.5), Inches(2.4), Inches(2.4)])
+
+# 日期列居中对齐
+for row_idx, row in enumerate(delivery_tbl.table.rows):
+    for col_idx in (1, 2):  # 测试完成日期、计划交付日期两列
+        cell = row.cells[col_idx]
+        for paragraph in cell.text_frame.paragraphs:
+            paragraph.alignment = PP_ALIGN.CENTER
+
+# ============================================================
+# 第 6 页:风险与问题
+# ============================================================
+slide6 = prs.slides.add_slide(prs.slide_layouts[6])
+add_bg(slide6, WHITE)
+add_rect(slide6, Inches(0), Inches(0), prs.slide_width, Inches(0.08), PRIMARY)
+add_text(slide6, Inches(0.8), Inches(0.35), Inches(10), Inches(0.6),
+         '关键风险与问题', font_size=28, color=DARK, bold=True)
+add_text(slide6, Inches(10), Inches(6.9), Inches(3), Inches(0.4),
+         'Ai-DOP 项目周报 | 2026.05.11-05.15', font_size=9, color=GRAY, alignment=PP_ALIGN.RIGHT)
+
+risk_data = [
+    ['风险项', '影响范围', '严重度', '应对措施', '需支持'],
+    ['', '', '', '', ''],
+    ['', '', '', '', ''],
+    ['', '', '', '', ''],
+    ['', '', '', '', ''],
+]
+
+tbl = add_table(slide6, Inches(0.5), Inches(1.3), Inches(12.3), Inches(3.5),
+                len(risk_data), len(risk_data[0]), risk_data)
+
+# 调整列宽
+tbl.table.columns[0].width = Inches(3.0)
+tbl.table.columns[1].width = Inches(2.2)
+tbl.table.columns[2].width = Inches(1.0)
+tbl.table.columns[3].width = Inches(3.5)
+tbl.table.columns[4].width = Inches(2.6)
+
+# ============================================================
+# 第 7 页:下周计划 (5/18-5/24)
+# ============================================================
+slide7 = prs.slides.add_slide(prs.slide_layouts[6])
+add_bg(slide7, WHITE)
+add_rect(slide7, Inches(0), Inches(0), prs.slide_width, Inches(0.08), PRIMARY)
+add_text(slide7, Inches(0.8), Inches(0.35), Inches(10), Inches(0.6),
+         '下周计划(5/18 — 5/24)', font_size=28, color=DARK, bold=True)
+add_text(slide7, Inches(10), Inches(6.9), Inches(3), Inches(0.4),
+         'Ai-DOP 项目周报 | 2026.05.11-05.15', font_size=9, color=GRAY, alignment=PP_ALIGN.RIGHT)
+
+# 三列:业务开发 / 蓝图设计 / 基础建设
+columns = [
+    ('业务开发 (S0/S1)', ACCENT, [
+        ('S0 运营建模功能开发', '完成核心建模页面\n及基础 CRUD 接口'),
+        ('S1 产销协同功能开发', '完成产销计划\n编制页面开发'),
+    ]),
+    ('蓝图设计 (S2/S8/S9 + 新增)', WARNING, [
+        ('S2 技术方案设计', '输出《概要设计说明书》'),
+        ('S8/S9 蓝图收尾', '5/22 前完成\n《业务需求说明书》'),
+        ('运营诊断 · 改善 · ChatBI\n· APP 端蓝图启动', '需求调研 + 启动'),
+    ]),
+    ('平台基建', GRAY, [
+        ('数据库存储过程重构', '完成率达 40%'),
+        ('数据库程序调用迁移', '完成率达 40%'),
+        ('S3 供应协同准备', '提前熟悉需求,\n5/25 蓝图启动'),
+    ]),
+]
+
+for i, (title, color, items) in enumerate(columns):
+    x = Inches(0.5 + i * 4.2)
+    add_rect(slide7, x, Inches(1.3), Inches(3.8), Inches(0.5), color)
+    add_text(slide7, x + Inches(0.12), Inches(1.33), Inches(3.5), Inches(0.45),
+             title, font_size=15, color=WHITE, bold=True)
+
+    y = Inches(2.1)
+    for item_title, item_desc in items:
+        add_text(slide7, x + Inches(0.1), y, Inches(3.5), Inches(0.35),
+                 f'▸ {item_title}', font_size=13, color=DARK, bold=True)
+        add_text(slide7, x + Inches(0.25), y + Inches(0.35), Inches(3.3), Inches(0.5),
+                 item_desc, font_size=11, color=GRAY)
+        y += Inches(1.1)
+
+# ============================================================
+# 第 8 页:需协调 / 决策事项
+# ============================================================
+slide8 = prs.slides.add_slide(prs.slide_layouts[6])
+add_bg(slide8, WHITE)
+add_rect(slide8, Inches(0), Inches(0), prs.slide_width, Inches(0.08), PRIMARY)
+add_text(slide8, Inches(0.8), Inches(0.35), Inches(10), Inches(0.6),
+         '需要协调 / 决策事项', font_size=28, color=DARK, bold=True)
+add_text(slide8, Inches(10), Inches(6.9), Inches(3), Inches(0.4),
+         'Ai-DOP 项目周报 | 2026.05.11-05.15', font_size=9, color=GRAY, alignment=PP_ALIGN.RIGHT)
+
+# 决策事项
+add_rect(slide8, Inches(0.8), Inches(1.3), Inches(5.5), Inches(0.45), DANGER)
+add_text(slide8, Inches(0.95), Inches(1.32), Inches(5), Inches(0.4),
+         '📋 需决策', font_size=16, color=WHITE, bold=True)
+
+decisions = [
+    '1. ',
+    '2. ',
+    '3. ',
+]
+for i, d in enumerate(decisions):
+    add_text(slide8, Inches(1.1), Inches(2.0 + i * 0.45), Inches(5), Inches(0.4),
+             d, font_size=13, color=DARK)
+
+# 协调事项
+add_rect(slide8, Inches(7.0), Inches(1.3), Inches(5.5), Inches(0.45), WARNING)
+add_text(slide8, Inches(7.15), Inches(1.32), Inches(5), Inches(0.4),
+         '🤝 需协调', font_size=16, color=WHITE, bold=True)
+
+coordinations = [
+    '1. ',
+    '2. ',
+    '3. ',
+    '4. ',
+]
+for i, c in enumerate(coordinations):
+    add_text(slide8, Inches(7.3), Inches(2.0 + i * 0.45), Inches(5), Inches(0.4),
+             c, font_size=13, color=DARK)
+
+# ============ 保存 ============
+output_path = r'd:\Projects\Ai-DOP\SourceCode\ZZYDOP\doc\ppt\Ai-DOP项目周报_20260511-0515.pptx'
+prs.save(output_path)
+print(f'✅ PPT 已生成:{output_path}')

+ 1 - 1
server/Admin.NET.Application/Configuration/Database.json

@@ -12,7 +12,7 @@
         "DbNickName": "系统库",
         //"ConnectionString": "Server=123.60.180.165;Port=3306;Database=dopdemo;Uid=root;Pwd=5heng=uN;SslMode=None;Charset=utf8mb4;AllowLoadLocalInfile=true;AllowUserVariables=true;",
         //"ConnectionString": "Server=106.14.73.46;Port=3306;Database=aidopcore;Uid=aidopremote;Pwd=AidOp#Remote2026$Secure;SslMode=None;Charset=utf8mb4;AllowLoadLocalInfile=true;AllowUserVariables=true;",
-        "ConnectionString": "Server=123.60.180.165;Port=3306;Database=aidopdev;Uid=aidopremote;Pwd=1234567890aiDOP#;SslMode=None;Charset=utf8mb4;AllowLoadLocalInfile=true;AllowUserVariables=true;Pooling=true;Minimum Pool Size=0;Maximum Pool Size=20;Connection Timeout=10;Connection Idle Timeout=180;Connection LifeTime=300;",
+        "ConnectionString": "Server=123.60.180.165;Port=3306;Database=aidopdev;Uid=aidopremote;Pwd=1234567890aiDOP#;SslMode=None;Charset=utf8mb4;AllowLoadLocalInfile=true;AllowUserVariables=true;AllowPublicKeyRetrieval=True;Pooling=true;Minimum Pool Size=0;Maximum Pool Size=20;Connection Timeout=10;Connection Idle Timeout=180;Connection LifeTime=300;",
         // 本地 SQLite 示例(切回时改 DbType 为 Sqlite 并恢复下行连接串)
         //"DbType": "Sqlite",
         //"ConnectionString": "DataSource=./Admin.NET.db",

+ 56 - 20
server/Admin.NET.Core/Service/Role/SysRoleMenuService.cs

@@ -42,41 +42,77 @@ public class SysRoleMenuService : ITransient
     [DisplayName("授权角色菜单")]
     public async Task GrantRoleMenu(RoleMenuInput input)
     {
-        await _sysRoleMenuRep.DeleteAsync(u => u.RoleId == input.Id);
+        var targetRoleIds = new[] { input.Id }
+            .Concat(input.RoleIdList ?? new List<long>())
+            .Distinct()
+            .ToList();
+        if (targetRoleIds.Count == 0) return;
 
-        // 追加父级菜单
-        var allIdList = await _sysRoleMenuRep.Context.Queryable<SysMenu>().Select(u => new { u.Id, u.Pid }).ToListAsync();
-        var pIdList = allIdList.ToChildList(u => u.Pid, u => u.Id, u => input.MenuIdList.Contains(u.Id)).Select(u => u.Pid).Distinct().ToList();
-        input.MenuIdList = input.MenuIdList.Concat(pIdList).Distinct().Where(u => u != 0).ToList();
+        var allIdList = await _sysRoleMenuRep.Context.Queryable<SysMenu>().ToListAsync();
+        var requestedMenuIds = ExpandMenuIdsWithAncestors(input.MenuIdList ?? new List<long>(), allIdList);
+
+        var roleTenants = await _sysRoleMenuRep.Context.Queryable<SysRole>()
+            .Where(u => targetRoleIds.Contains(u.Id))
+            .Select(u => new { u.Id, u.TenantId })
+            .ToListAsync();
+        if (roleTenants.Count == 0) return;
+
+        var tenantIds = roleTenants
+            .Where(u => u.TenantId.HasValue)
+            .Select(u => u.TenantId!.Value)
+            .Distinct()
+            .ToList();
+        var tenantMenus = await _sysRoleMenuRep.Context.Queryable<SysTenantMenu>()
+            .Where(u => tenantIds.Contains(u.TenantId))
+            .Select(u => new { u.TenantId, u.MenuId })
+            .ToListAsync();
+        var tenantMenuMap = tenantMenus
+            .GroupBy(u => u.TenantId)
+            .ToDictionary(u => u.Key, u => u.Select(x => x.MenuId).ToHashSet());
+
+        await _sysRoleMenuRep.DeleteAsync(u => targetRoleIds.Contains(u.RoleId));
 
         // 保存授权数据
-        var menus = input.MenuIdList.Select(u => new SysRoleMenu
+        var menus = new List<SysRoleMenu>();
+        foreach (var roleTenant in roleTenants)
         {
-            RoleId = input.Id,
-            MenuId = u
-        }).ToList();
+            if (!roleTenant.TenantId.HasValue) continue;
+            if (!tenantMenuMap.TryGetValue(roleTenant.TenantId.Value, out var allowedMenuIds)) continue;
 
-        // 同步授权数据
-        if (input.RoleIdList?.Count() > 0)
-        {
-            await _sysRoleMenuRep.DeleteAsync(u => input.RoleIdList.Contains(u.RoleId));
-            input.RoleIdList.ForEach(u =>
+            menus.AddRange(requestedMenuIds
+                .Where(allowedMenuIds.Contains)
+                .Select(menuId => new SysRoleMenu
             {
-                menus.AddRange(input.MenuIdList.Select(v => new SysRoleMenu
-                {
-                    RoleId = u,
-                    MenuId = v
+                    RoleId = roleTenant.Id,
+                    MenuId = menuId
                 }));
-            });
         }
 
-        await _sysRoleMenuRep.InsertRangeAsync(menus);
+        if (menus.Count > 0) await _sysRoleMenuRep.InsertRangeAsync(menus);
 
         // 清除缓存
         // _sysCacheService.RemoveByPrefixKey(CacheConst.KeyUserMenu);
         _sysCacheService.RemoveByPrefixKey(CacheConst.KeyUserButton);
     }
 
+    private static List<long> ExpandMenuIdsWithAncestors(List<long> menuIds, IEnumerable<SysMenu> allMenus)
+    {
+        var menuMap = allMenus.ToDictionary(u => u.Id, u => u.Pid);
+        var result = new HashSet<long>(menuIds.Where(u => u != 0));
+
+        foreach (var menuId in menuIds)
+        {
+            var current = menuId;
+            while (menuMap.TryGetValue(current, out var pid) && pid != 0)
+            {
+                if (!result.Add(pid)) break;
+                current = pid;
+            }
+        }
+
+        return result.ToList();
+    }
+
     /// <summary>
     /// 根据菜单Id集合删除角色菜单
     /// </summary>

+ 9 - 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.130</AssemblyVersion>
-    <FileVersion>1.0.130</FileVersion>
-    <Version>1.0.130</Version>
+    <AssemblyVersion>1.0.131</AssemblyVersion>
+    <FileVersion>1.0.131</FileVersion>
+    <Version>1.0.131</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -79,6 +79,12 @@
     <None Update="UpdateScripts\1.0.128.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.130.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.131.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 464 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.130.sql

@@ -0,0 +1,464 @@
+-- 1.0.125.sql
+-- S1-MDP-FOUNDATION-1
+-- 为 S1 产销协同首批数据中台试点补齐基础落库对象与 mdp_entity 登记。
+--
+-- 范围:
+--   1) 兜底创建通用 MDP 配置/日志表:mdp_source / mdp_entity / mdp_field_mapping / mdp_sync_log / mdp_transform_run_log。
+--   2) 新增 S1 贴源层:mdp_stg_so / mdp_stg_ship_trans。
+--   3) 新增 S1 标准层:mdp_std_so / mdp_std_ship_trans。
+--   4) 新增 S1 DWD 明细宽表:dwd_ship_trans。
+--   5) 登记 S1 首批源实体到 mdp_entity,并写入基础血缘字段映射。
+--
+-- 安全边界:
+--   * 仅 CREATE TABLE IF NOT EXISTS、INSERT ... ON DUPLICATE KEY UPDATE 和 TEMPORARY TABLE。
+--   * 不删除、不清空、不改写业务运行表;不切换任何接口读路径。
+--   * 不修改 S2/S3/S4/S7 既有业务写入规则和数据口径。
+--
+-- 幂等性:
+--   * 表结构使用 CREATE TABLE IF NOT EXISTS。
+--   * mdp_source / mdp_entity / mdp_field_mapping 使用唯一键 upsert。
+--   * 重复执行不会重复登记实体或字段映射。
+--
+-- 执行入口:AutoVersionUpdate.UseAutoVersionUpdate(),csproj Version=1.0.125 主节点首次启动时触发。
+-- 2026-05-25
+
+SET @tenant_id := 0;
+SET @source_code := 'AIDOPDEV_MYSQL';
+
+-- ─── 1) MDP 通用底座兜底 ───
+CREATE TABLE IF NOT EXISTS mdp_source (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    source_code VARCHAR(50) NOT NULL,
+    source_name VARCHAR(100) NOT NULL,
+    source_type ENUM('DB','API') NOT NULL,
+    status TINYINT DEFAULT 1,
+    db_type ENUM('MySQL','SQLServer','Oracle','PostgreSQL'),
+    db_host VARCHAR(200),
+    db_port INT,
+    db_name VARCHAR(100),
+    db_user VARCHAR(100),
+    db_password_enc VARCHAR(500),
+    db_extra_params VARCHAR(500),
+    api_base_url VARCHAR(500),
+    api_auth_type ENUM('NONE','TOKEN','OAUTH2','APIKEY'),
+    api_auth_config JSON,
+    last_health_check DATETIME,
+    health_status TINYINT DEFAULT 0,
+    health_msg VARCHAR(500),
+    remark VARCHAR(500),
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_tenant_source (tenant_id, source_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MDP数据源配置';
+
+CREATE TABLE IF NOT EXISTS mdp_entity (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    source_id BIGINT NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    entity_name VARCHAR(200) NOT NULL,
+    entity_type ENUM('TABLE','VIEW','API') NOT NULL DEFAULT 'TABLE',
+    source_table_name VARCHAR(200),
+    source_api_path VARCHAR(500),
+    api_config_id BIGINT,
+    target_table_name VARCHAR(200),
+    sync_mode ENUM('FULL','INCR','CDC','PAGE','CURSOR','TIME_WINDOW','NONE') DEFAULT 'INCR',
+    incr_column VARCHAR(100),
+    batch_size INT DEFAULT 5000,
+    response_data_path VARCHAR(200),
+    dedup_key_path VARCHAR(200),
+    last_cursor VARCHAR(500),
+    last_sync_to DATETIME,
+    job_id VARCHAR(100),
+    status TINYINT DEFAULT 1,
+    remark VARCHAR(500),
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_tenant_entity (tenant_id, entity_code),
+    KEY idx_source (source_id),
+    KEY idx_api_config (api_config_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MDP同步实体配置';
+
+CREATE TABLE IF NOT EXISTS mdp_field_mapping (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    entity_id BIGINT NOT NULL,
+    source_field VARCHAR(200) NOT NULL,
+    target_field VARCHAR(200) NOT NULL,
+    field_type ENUM('DIRECT','JSONPATH','SCRIPT','CONST','LOOKUP') DEFAULT 'DIRECT',
+    transform_script TEXT,
+    const_value VARCHAR(500),
+    lookup_table VARCHAR(200),
+    is_required TINYINT DEFAULT 0,
+    default_value VARCHAR(500),
+    sort_order INT DEFAULT 0,
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_entity_field (entity_id, target_field)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MDP字段映射配置';
+
+CREATE TABLE IF NOT EXISTS mdp_sync_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    entity_id BIGINT NOT NULL,
+    source_code VARCHAR(50) NOT NULL,
+    entity_name VARCHAR(200),
+    sync_batch_id VARCHAR(50) NOT NULL,
+    sync_type ENUM('FULL','INCR') NOT NULL,
+    trigger_type ENUM('AUTO','MANUAL') DEFAULT 'AUTO',
+    sync_start DATETIME,
+    sync_end DATETIME,
+    duration_ms INT,
+    rows_read BIGINT DEFAULT 0,
+    rows_insert BIGINT DEFAULT 0,
+    rows_update BIGINT DEFAULT 0,
+    rows_skip BIGINT DEFAULT 0,
+    rows_error BIGINT DEFAULT 0,
+    status ENUM('RUNNING','SUCCESS','PARTIAL','FAILED') DEFAULT 'RUNNING',
+    error_msg TEXT,
+    error_sample JSON,
+    KEY idx_tenant_entity_time (tenant_id, entity_id, sync_start),
+    KEY idx_batch (sync_batch_id),
+    KEY idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MDP同步执行日志';
+
+CREATE TABLE IF NOT EXISTS mdp_transform_run_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    job_code VARCHAR(100) NOT NULL,
+    job_name VARCHAR(200) NOT NULL,
+    trigger_type VARCHAR(30) NOT NULL DEFAULT 'AUTO',
+    batch_id VARCHAR(100) NOT NULL,
+    status VARCHAR(30) NOT NULL DEFAULT 'RUNNING',
+    start_time DATETIME NOT NULL,
+    end_time DATETIME NULL,
+    duration_ms INT NULL,
+    stage_rows INT NOT NULL DEFAULT 0,
+    standard_rows INT NOT NULL DEFAULT 0,
+    dwd_rows INT NOT NULL DEFAULT 0,
+    error_message TEXT NULL,
+    summary_json JSON NULL,
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_batch (batch_id),
+    KEY idx_job_start (job_code, start_time),
+    KEY idx_status_start (status, start_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MDP整轮同步转换运行日志';
+
+-- ─── 2) S1 贴源层 ───
+CREATE TABLE IF NOT EXISTS mdp_stg_so (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NULL,
+    factory_id BIGINT NULL,
+    company_id BIGINT NULL,
+    source_system VARCHAR(50) NOT NULL,
+    source_table VARCHAR(100) NOT NULL,
+    source_row_id VARCHAR(200) NULL,
+    source_biz_key VARCHAR(300) NOT NULL,
+    sync_batch_id VARCHAR(64) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+    process_message VARCHAR(1000) NULL,
+    raw_data JSON NULL,
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    KEY idx_batch_status (sync_batch_id, process_status),
+    UNIQUE KEY uk_source_key (source_system, source_table, source_biz_key),
+    KEY idx_tenant_time (tenant_id, sync_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S1订单/评审贴源表';
+
+CREATE TABLE IF NOT EXISTS mdp_stg_ship_trans LIKE mdp_stg_so;
+ALTER TABLE mdp_stg_ship_trans COMMENT='S1发货/ASN/联动贴源表';
+
+-- ─── 3) S1 标准层 ───
+CREATE TABLE IF NOT EXISTS mdp_std_so (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    factory_id BIGINT NULL,
+    company_id BIGINT NULL,
+    source_system VARCHAR(50) NOT NULL,
+    order_id BIGINT NULL,
+    order_entry_id BIGINT NULL,
+    order_no VARCHAR(100) NOT NULL,
+    order_line VARCHAR(50) NULL,
+    order_type VARCHAR(50) NULL,
+    customer_id BIGINT NULL,
+    customer_no VARCHAR(100) NULL,
+    customer_name VARCHAR(200) NULL,
+    customer_order_no VARCHAR(100) NULL,
+    country VARCHAR(100) NULL,
+    item_code VARCHAR(100) NULL,
+    item_name VARCHAR(200) NULL,
+    item_spec VARCHAR(300) NULL,
+    map_number VARCHAR(300) NULL,
+    map_name VARCHAR(300) NULL,
+    bom_number VARCHAR(300) NULL,
+    unit VARCHAR(50) NULL,
+    order_qty DECIMAL(18,6) NULL,
+    delivered_notice_qty DECIMAL(18,6) NULL,
+    delivered_qty DECIMAL(18,6) NULL,
+    price DECIMAL(18,6) NULL,
+    tax_price DECIMAL(18,6) NULL,
+    amount DECIMAL(18,6) NULL,
+    total_amount DECIMAL(18,6) NULL,
+    order_date DATETIME NULL,
+    customer_request_date DATETIME NULL,
+    plan_delivery_date DATETIME NULL,
+    promised_delivery_date DATETIME NULL,
+    capacity_date DATETIME NULL,
+    material_ready_date DATETIME NULL,
+    planner_no VARCHAR(100) NULL,
+    planner_name VARCHAR(100) NULL,
+    order_status VARCHAR(50) NULL,
+    review_status VARCHAR(50) NULL,
+    review_stage VARCHAR(100) NULL,
+    flow_state VARCHAR(100) NULL,
+    progress VARCHAR(50) NULL,
+    urgent TINYINT NULL,
+    closed TINYINT NULL,
+    deleted_flag TINYINT NOT NULL DEFAULT 0,
+    source_table VARCHAR(100) NOT NULL,
+    source_row_id VARCHAR(200) NULL,
+    source_biz_key VARCHAR(300) NOT NULL,
+    sync_batch_id VARCHAR(64) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_key (tenant_id, source_system, source_table, source_biz_key),
+    KEY idx_order_line (tenant_id, order_no, order_line),
+    KEY idx_customer_date (tenant_id, customer_no, plan_delivery_date),
+    KEY idx_item_date (tenant_id, item_code, plan_delivery_date),
+    KEY idx_status_date (tenant_id, order_status, review_status, plan_delivery_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S1标准销售订单/评审对象';
+
+CREATE TABLE IF NOT EXISTS mdp_std_ship_trans (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    factory_id BIGINT NULL,
+    company_id BIGINT NULL,
+    source_system VARCHAR(50) NOT NULL,
+    trans_type VARCHAR(50) NOT NULL,
+    plan_id BIGINT NULL,
+    plan_no VARCHAR(100) NULL,
+    plan_line VARCHAR(50) NULL,
+    shipper_rec_id BIGINT NULL,
+    shipper_no VARCHAR(100) NULL,
+    shipper_line VARCHAR(50) NULL,
+    order_id BIGINT NULL,
+    order_entry_id BIGINT NULL,
+    order_no VARCHAR(100) NULL,
+    order_line VARCHAR(50) NULL,
+    customer_no VARCHAR(100) NULL,
+    customer_name VARCHAR(200) NULL,
+    country VARCHAR(100) NULL,
+    item_code VARCHAR(100) NULL,
+    item_name VARCHAR(200) NULL,
+    item_spec VARCHAR(300) NULL,
+    qty DECIMAL(18,6) NULL,
+    plan_qty DECIMAL(18,6) NULL,
+    qty_to_ship DECIMAL(18,6) NULL,
+    picking_qty DECIMAL(18,6) NULL,
+    real_qty DECIMAL(18,6) NULL,
+    weight DECIMAL(18,6) NULL,
+    gross_weight DECIMAL(18,6) NULL,
+    net_weight DECIMAL(18,6) NULL,
+    volume DECIMAL(18,6) NULL,
+    order_date DATETIME NULL,
+    plan_ship_date DATETIME NULL,
+    actual_ship_date DATETIME NULL,
+    site VARCHAR(100) NULL,
+    shipping_site VARCHAR(200) NULL,
+    shipping_address VARCHAR(500) NULL,
+    consignee VARCHAR(200) NULL,
+    telephone VARCHAR(100) NULL,
+    status VARCHAR(50) NULL,
+    confirm_status VARCHAR(50) NULL,
+    linkage_status VARCHAR(50) NULL,
+    source_table VARCHAR(100) NOT NULL,
+    source_row_id VARCHAR(200) NULL,
+    source_biz_key VARCHAR(300) NOT NULL,
+    sync_batch_id VARCHAR(64) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_key (tenant_id, source_system, source_table, source_biz_key),
+    KEY idx_order_line (tenant_id, order_no, order_line),
+    KEY idx_plan (tenant_id, plan_no, plan_line),
+    KEY idx_shipper (tenant_id, shipper_no, shipper_line),
+    KEY idx_ship_date (tenant_id, actual_ship_date, plan_ship_date),
+    KEY idx_status_date (tenant_id, status, plan_ship_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S1标准发货/ASN/联动对象';
+
+-- ─── 4) S1 DWD 明细宽表 ───
+CREATE TABLE IF NOT EXISTS dwd_ship_trans (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    factory_id BIGINT NULL,
+    company_id BIGINT NULL,
+    stat_date DATE NOT NULL,
+    order_id BIGINT NULL,
+    order_entry_id BIGINT NULL,
+    order_no VARCHAR(100) NOT NULL,
+    order_line VARCHAR(50) NULL,
+    customer_no VARCHAR(100) NULL,
+    customer_name VARCHAR(200) NULL,
+    country VARCHAR(100) NULL,
+    item_code VARCHAR(100) NULL,
+    item_name VARCHAR(200) NULL,
+    item_spec VARCHAR(300) NULL,
+    order_qty DECIMAL(18,6) NULL,
+    planned_ship_qty DECIMAL(18,6) NULL,
+    shipped_qty DECIMAL(18,6) NULL,
+    remaining_qty DECIMAL(18,6) NULL,
+    order_date DATETIME NULL,
+    customer_request_date DATETIME NULL,
+    plan_delivery_date DATETIME NULL,
+    promised_delivery_date DATETIME NULL,
+    plan_ship_date DATETIME NULL,
+    actual_ship_date DATETIME NULL,
+    review_status VARCHAR(50) NULL,
+    order_status VARCHAR(50) NULL,
+    delivery_status VARCHAR(50) NULL,
+    linkage_status VARCHAR(50) NULL,
+    risk_level VARCHAR(50) NULL,
+    source_system VARCHAR(50) NULL,
+    source_table VARCHAR(100) NULL,
+    source_row_id VARCHAR(200) NULL,
+    source_biz_key VARCHAR(300) NULL,
+    sync_batch_id VARCHAR(64) NULL,
+    calc_batch_id VARCHAR(64) NOT NULL,
+    calc_time DATETIME NOT NULL,
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_order_line_stat (tenant_id, stat_date, order_no, order_line, item_code),
+    KEY idx_order_line (tenant_id, order_no, order_line),
+    KEY idx_customer_date (tenant_id, customer_no, stat_date),
+    KEY idx_item_date (tenant_id, item_code, stat_date),
+    KEY idx_status_date (tenant_id, delivery_status, risk_level, stat_date),
+    KEY idx_batch (tenant_id, calc_batch_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S1订单交付发货DWD宽表';
+
+-- ─── 5) S1 数据源与实体登记 ───
+INSERT INTO mdp_source
+(tenant_id, source_code, source_name, source_type, status, db_type, db_host, db_port, db_name, db_user, db_password_enc, remark)
+VALUES
+(@tenant_id, @source_code, 'aidopdev 当前项目库', 'DB', 1, 'MySQL', NULL, 3306, 'aidopdev', NULL, NULL, 'S1产销协同首批迁移数据源,不在配置表保存明文密码')
+ON DUPLICATE KEY UPDATE
+    source_name = VALUES(source_name),
+    source_type = VALUES(source_type),
+    status = VALUES(status),
+    db_type = VALUES(db_type),
+    db_port = VALUES(db_port),
+    db_name = VALUES(db_name),
+    remark = VALUES(remark),
+    update_time = CURRENT_TIMESTAMP;
+
+SELECT @source_id := id
+FROM mdp_source
+WHERE tenant_id = @tenant_id
+  AND source_code = @source_code
+LIMIT 1;
+
+CREATE TEMPORARY TABLE tmp_s1_mdp_entity (
+    entity_code VARCHAR(100) PRIMARY KEY,
+    entity_name VARCHAR(200) NOT NULL,
+    source_table_name VARCHAR(200) NOT NULL,
+    target_table_name VARCHAR(200) NOT NULL,
+    sync_mode VARCHAR(20) NOT NULL,
+    incr_column VARCHAR(100) NULL,
+    source_row_id_expr VARCHAR(200) NULL,
+    source_biz_key_expr VARCHAR(500) NOT NULL,
+    remark VARCHAR(500) NULL
+);
+
+INSERT INTO tmp_s1_mdp_entity
+(entity_code, entity_name, source_table_name, target_table_name, sync_mode, incr_column, source_row_id_expr, source_biz_key_expr, remark)
+VALUES
+('S1_SEORDER', 'S1销售订单主表', 'crm_seorder', 'mdp_stg_so', 'INCR', 'update_time', 'Id', 'COALESCE(bill_no, CAST(Id AS CHAR))', '订单主表,进入订单标准层'),
+('S1_SEORDER_ENTRY', 'S1销售订单明细', 'crm_seorderentry', 'mdp_stg_so', 'INCR', 'update_time', 'Id', 'CONCAT(COALESCE(bill_no, ''''), '':'', COALESCE(CAST(entry_seq AS CHAR), CAST(Id AS CHAR)))', '订单明细,进入订单标准层'),
+('S1_SEORDER_CHANGE', 'S1销售订单变更', 'crm_seorder_change', 'mdp_stg_so', 'INCR', 'update_time', 'Id', 'CONCAT(COALESCE(bill_no, ''''), '':'', CAST(Id AS CHAR))', '订单变更,进入订单评审/状态上下文'),
+('S1_CONTRACT_REVIEW', 'S1合同评审主表', 'ado_contract_review', 'mdp_stg_so', 'INCR', 'UpdateTime', 'RecID', 'COALESCE(BillNo, CAST(RecID AS CHAR))', '合同评审主表,进入订单评审上下文'),
+('S1_CONTRACT_REVIEW_FLOW', 'S1合同评审流程节点', 'ado_contract_review_flow', 'mdp_stg_so', 'FULL', NULL, 'RecID', 'CONCAT(COALESCE(ReviewBillNo, ''''), '':'', CAST(StageNo AS CHAR), '':'', CAST(RecID AS CHAR))', '合同评审节点,空表或全量同步均应成功'),
+('S1_SHIPPING_PLAN', 'S1发货计划主表', 'ShippingPlan', 'mdp_stg_ship_trans', 'INCR', 'UpdateTime', 'RecID', 'COALESCE(LotSerial, CAST(RecID AS CHAR))', '发货计划主表,进入发货标准层'),
+('S1_SHIPPING_PLAN_DETAIL', 'S1发货计划明细', 'ShippingPlanDetail', 'mdp_stg_ship_trans', 'INCR', 'UpdateTime', 'RecID', 'CONCAT(COALESCE(CAST(plan_id AS CHAR), ''''), '':'', COALESCE(OrdNbr, ''''), '':'', CAST(RecID AS CHAR))', '发货计划明细,进入发货标准层'),
+('S1_ASN_SHIPPER_MASTER', 'S1 ASN发货主表', 'ASNBOLShipperMaster', 'mdp_stg_ship_trans', 'INCR', 'UpdateTime', 'RecID', 'COALESCE(Id, CONCAT(COALESCE(OrdNbr, ''''), '':'', CAST(RecID AS CHAR)))', 'ASN发货主表,进入发货标准层'),
+('S1_ASN_SHIPPER_DETAIL', 'S1 ASN发货明细', 'ASNBOLShipperDetail', 'mdp_stg_ship_trans', 'INCR', 'UpdateTime', 'RecID', 'CONCAT(COALESCE(Id, ''''), '':'', COALESCE(CAST(Line AS CHAR), CAST(RecID AS CHAR)))', 'ASN发货明细,进入发货标准层'),
+('S1_LINKAGE_PLAN', 'S1计划联动看板', 'LinkagePlan', 'mdp_stg_ship_trans', 'INCR', 'update_time', 'id', 'CONCAT(COALESCE(bill_no, ''''), '':'', COALESCE(item_number, ''''), '':'', CAST(id AS CHAR))', '计划联动事实,进入订单交付DWD上下文');
+
+INSERT INTO mdp_entity
+(tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, incr_column, batch_size, status, remark)
+SELECT
+    @tenant_id,
+    @source_id,
+    entity_code,
+    entity_name,
+    'TABLE',
+    source_table_name,
+    target_table_name,
+    sync_mode,
+    incr_column,
+    5000,
+    1,
+    remark
+FROM tmp_s1_mdp_entity
+ON DUPLICATE KEY UPDATE
+    source_id = VALUES(source_id),
+    entity_name = VALUES(entity_name),
+    entity_type = VALUES(entity_type),
+    source_table_name = VALUES(source_table_name),
+    target_table_name = VALUES(target_table_name),
+    sync_mode = VALUES(sync_mode),
+    incr_column = VALUES(incr_column),
+    batch_size = VALUES(batch_size),
+    status = VALUES(status),
+    remark = VALUES(remark),
+    update_time = CURRENT_TIMESTAMP;
+
+INSERT INTO mdp_field_mapping
+(entity_id, source_field, target_field, field_type, transform_script, const_value, is_required, sort_order)
+SELECT e.id, 'tenant_id', 'tenant_id', 'DIRECT', NULL, NULL, 0, 10
+FROM mdp_entity e
+JOIN tmp_s1_mdp_entity t ON e.entity_code = t.entity_code
+WHERE e.tenant_id = @tenant_id
+ON DUPLICATE KEY UPDATE source_field=VALUES(source_field), field_type=VALUES(field_type), sort_order=VALUES(sort_order);
+
+INSERT INTO mdp_field_mapping
+(entity_id, source_field, target_field, field_type, transform_script, const_value, is_required, sort_order)
+SELECT e.id, 'CONST:AIDOP', 'source_system', 'CONST', NULL, 'AIDOP', 1, 20
+FROM mdp_entity e
+JOIN tmp_s1_mdp_entity t ON e.entity_code = t.entity_code
+WHERE e.tenant_id = @tenant_id
+ON DUPLICATE KEY UPDATE source_field=VALUES(source_field), field_type=VALUES(field_type), const_value=VALUES(const_value), is_required=VALUES(is_required), sort_order=VALUES(sort_order);
+
+INSERT INTO mdp_field_mapping
+(entity_id, source_field, target_field, field_type, transform_script, const_value, is_required, sort_order)
+SELECT e.id, CONCAT('CONST:', t.source_table_name), 'source_table', 'CONST', NULL, t.source_table_name, 1, 30
+FROM mdp_entity e
+JOIN tmp_s1_mdp_entity t ON e.entity_code = t.entity_code
+WHERE e.tenant_id = @tenant_id
+ON DUPLICATE KEY UPDATE source_field=VALUES(source_field), field_type=VALUES(field_type), const_value=VALUES(const_value), is_required=VALUES(is_required), sort_order=VALUES(sort_order);
+
+INSERT INTO mdp_field_mapping
+(entity_id, source_field, target_field, field_type, transform_script, const_value, is_required, sort_order)
+SELECT e.id, IFNULL(t.source_row_id_expr, ''), 'source_row_id', 'SCRIPT', t.source_row_id_expr, NULL, 0, 40
+FROM mdp_entity e
+JOIN tmp_s1_mdp_entity t ON e.entity_code = t.entity_code
+WHERE e.tenant_id = @tenant_id
+ON DUPLICATE KEY UPDATE source_field=VALUES(source_field), field_type=VALUES(field_type), transform_script=VALUES(transform_script), sort_order=VALUES(sort_order);
+
+INSERT INTO mdp_field_mapping
+(entity_id, source_field, target_field, field_type, transform_script, const_value, is_required, sort_order)
+SELECT e.id, t.source_biz_key_expr, 'source_biz_key', 'SCRIPT', t.source_biz_key_expr, NULL, 1, 50
+FROM mdp_entity e
+JOIN tmp_s1_mdp_entity t ON e.entity_code = t.entity_code
+WHERE e.tenant_id = @tenant_id
+ON DUPLICATE KEY UPDATE source_field=VALUES(source_field), field_type=VALUES(field_type), transform_script=VALUES(transform_script), is_required=VALUES(is_required), sort_order=VALUES(sort_order);
+
+INSERT INTO mdp_field_mapping
+(entity_id, source_field, target_field, field_type, transform_script, const_value, is_required, sort_order)
+SELECT e.id, 'TO_JSON(row)', 'raw_data', 'SCRIPT', 'TO_JSON(row)', NULL, 0, 60
+FROM mdp_entity e
+JOIN tmp_s1_mdp_entity t ON e.entity_code = t.entity_code
+WHERE e.tenant_id = @tenant_id
+ON DUPLICATE KEY UPDATE source_field=VALUES(source_field), field_type=VALUES(field_type), transform_script=VALUES(transform_script), sort_order=VALUES(sort_order);
+
+DROP TEMPORARY TABLE tmp_s1_mdp_entity;

+ 59 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.131.sql

@@ -0,0 +1,59 @@
+-- 1.0.126.sql
+-- S1-MDP-MENU-1
+-- 补齐统一 MDP 运行监控菜单及租户菜单范围,确保角色授权后可正常显示。
+
+INSERT INTO SysMenu (
+    Id, Pid, Type, Name, Path, Component, Title, Icon,
+    IsIframe, IsHide, IsKeepAlive, IsAffix,
+    OrderNo, Status, Remark, CreateTime
+)
+VALUES (
+    1320990000406, 1320990000400, 2,
+    'aidopDataPlatformMdpMonitor',
+    '/aidop/data-platform/mdp-monitor',
+    '/aidop/data-platform/mdpMonitor',
+    'MDP运行监控',
+    'ele-Monitor',
+    0, 0, 1, 0,
+    60, 1,
+    '统一查看 S1/S3 等模块 MDP 同步与转换运行状态',
+    NOW()
+)
+ON DUPLICATE KEY UPDATE
+    Pid = VALUES(Pid),
+    Type = VALUES(Type),
+    Name = VALUES(Name),
+    Path = VALUES(Path),
+    Component = VALUES(Component),
+    Title = VALUES(Title),
+    Icon = VALUES(Icon),
+    IsIframe = VALUES(IsIframe),
+    IsHide = VALUES(IsHide),
+    IsKeepAlive = VALUES(IsKeepAlive),
+    IsAffix = VALUES(IsAffix),
+    OrderNo = VALUES(OrderNo),
+    Status = VALUES(Status),
+    Remark = VALUES(Remark),
+    UpdateTime = NOW();
+
+INSERT INTO SysTenantMenu (Id, TenantId, MenuId)
+SELECT CAST(CONCAT('406', RIGHT(CAST(t.TenantId AS CHAR), 10)) AS UNSIGNED) AS Id,
+       t.TenantId,
+       1320990000406 AS MenuId
+FROM (
+    SELECT DISTINCT TenantId
+    FROM SysTenantMenu
+    WHERE MenuId = 1320990000400
+) t
+WHERE NOT EXISTS (
+    SELECT 1
+    FROM SysTenantMenu existing
+    WHERE existing.TenantId = t.TenantId
+      AND existing.MenuId = 1320990000406
+);
+
+UPDATE mdp_source
+SET db_host = COALESCE(NULLIF(db_host, ''), '123.60.180.165'),
+    db_port = COALESCE(db_port, 3306),
+    db_name = COALESCE(NULLIF(db_name, ''), 'aidopdev')
+WHERE source_code = 'AIDOPDEV_MYSQL';

+ 44 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S1MdpSyncTransformJob.cs

@@ -0,0 +1,44 @@
+using Admin.NET.Plugin.AiDOP.Order;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// S1 首批 MDP 同步与标准化转换定时任务。
+/// </summary>
+[JobDetail("job_s1_mdp_sync_transform", Description = "S1 MDP同步与标准化转换", GroupName = "default", Concurrent = false)]
+[Period(3600000, TriggerId = "trigger_s1_mdp_sync_transform", Description = "每60分钟执行")]
+public class S1MdpSyncTransformJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public S1MdpSyncTransformJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(S1MdpSyncTransformJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var service = scope.ServiceProvider.GetRequiredService<S1MdpSyncTransformService>();
+
+        try
+        {
+            var result = await service.RunFullAsync(stoppingToken);
+            _logger.LogInformation("S1MdpSyncTransformJob 完成 {Payload}", JsonSerializer.Serialize(result));
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation("S1MdpSyncTransformJob 收到停止信号,结束本轮转换");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "S1MdpSyncTransformJob 执行失败");
+        }
+    }
+}

+ 14 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Order/LinkagePlanService.cs

@@ -13,15 +13,18 @@ public class LinkagePlanService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly SqlSugarRepository<LinkagePlan> _linkagePlanRep;
     private readonly UserManager _userManager;
+    private readonly S1MdpSyncTransformService _s1MdpSyncTransformService;
 
     public LinkagePlanService(
         ISqlSugarClient db,
         SqlSugarRepository<LinkagePlan> linkagePlanRep,
-        UserManager userManager)
+        UserManager userManager,
+        S1MdpSyncTransformService s1MdpSyncTransformService)
     {
         _db = db;
         _linkagePlanRep = linkagePlanRep;
         _userManager = userManager;
+        _s1MdpSyncTransformService = s1MdpSyncTransformService;
     }
 
     [DisplayName("获取计划联动看板列表")]
@@ -136,10 +139,16 @@ public class LinkagePlanService : IDynamicApiController, ITransient
     {
         try
         {
-            // 调用存储过程,不传递参数或传递空字符串
-            await _db.Ado.ExecuteCommandAsync("CALL pr_Mes_LinkagePlan('')");
-            
-            return new { message = "数据刷新成功" };
+            var result = await _s1MdpSyncTransformService.RunFullAsync(triggerType: "MANUAL");
+
+            return new
+            {
+                message = "数据刷新成功",
+                batchId = result.BatchId,
+                result.StageRows,
+                result.StandardRows,
+                result.DwdRows
+            };
         }
         catch (Exception ex)
         {

+ 424 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/MdpMonitorService.cs

@@ -0,0 +1,424 @@
+namespace Admin.NET.Plugin.AiDOP.Order;
+
+/// <summary>
+/// 数据中台统一 MDP 运行监控。
+/// </summary>
+[ApiDescriptionSettings(Order = 322, Description = "统一MDP运行监控")]
+[Route("api/DataPlatform")]
+[AllowAnonymous]
+[NonUnify]
+public class MdpMonitorService : IDynamicApiController, ITransient
+{
+    private static readonly Dictionary<string, string> ModuleJobCodes = new(StringComparer.OrdinalIgnoreCase)
+    {
+        ["S1"] = "S1_MDP_SYNC_TRANSFORM",
+        ["S3"] = "S3_MDP_SYNC_TRANSFORM"
+    };
+
+    private readonly ISqlSugarClient _db;
+
+    public MdpMonitorService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    [DisplayName("MDP模块选项")]
+    [HttpGet("mdp-monitor/modules")]
+    public object GetModules()
+    {
+        return ModuleJobCodes
+            .OrderBy(u => u.Key)
+            .Select(u => new { moduleCode = u.Key, jobCode = u.Value })
+            .ToList();
+    }
+
+    [DisplayName("MDP最近运行状态")]
+    [HttpGet("mdp-monitor/latest")]
+    public async Task<object> GetLatest([FromQuery] MdpMonitorQueryInput input)
+    {
+        var (whereSql, pars) = BuildWhere(input);
+        return await _db.Ado.SqlQuerySingleAsync<MdpMonitorRunLogRow>(
+            $"""
+            {SelectColumnsSql()}
+            FROM mdp_transform_run_log
+            WHERE {whereSql}
+            ORDER BY start_time DESC, id DESC
+            LIMIT 1
+            """,
+            pars)
+            ?? new MdpMonitorRunLogRow();
+    }
+
+    [DisplayName("MDP运行日志列表")]
+    [HttpGet("mdp-monitor/list")]
+    public async Task<object> GetList([FromQuery] MdpMonitorListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+        var (whereSql, pars) = BuildWhere(input);
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM mdp_transform_run_log WHERE {whereSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<MdpMonitorRunLogRow>(
+            $"""
+            {SelectColumnsSql()}
+            FROM mdp_transform_run_log
+            WHERE {whereSql}
+            ORDER BY start_time DESC, id DESC
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("MDP运行日志详情")]
+    [HttpGet("mdp-monitor/detail/{id}")]
+    public async Task<object> GetDetail(long id, [FromQuery] MdpMonitorQueryInput input)
+    {
+        var (whereSql, pars) = BuildWhere(input);
+        pars.Add(new SugarParameter("@Id", id));
+
+        var row = await _db.Ado.SqlQuerySingleAsync<MdpMonitorRunLogRow>(
+            $"""
+            {SelectColumnsSql()}
+            FROM mdp_transform_run_log
+            WHERE id=@Id AND {whereSql}
+            LIMIT 1
+            """,
+            pars);
+        return row ?? throw Oops.Oh("运行日志不存在");
+    }
+
+    [DisplayName("MDP同步链路详情")]
+    [HttpGet("mdp-monitor/lineage")]
+    public async Task<object> GetLineage([FromQuery] MdpMonitorLineageInput input)
+    {
+        var moduleCode = ResolveModuleCode(input.ModuleCode, input.JobCode);
+        if (string.IsNullOrWhiteSpace(moduleCode))
+            throw Oops.Oh("请选择 MDP 模块");
+
+        var jobCode = ResolveJobCode(moduleCode, input.JobCode);
+        var entityPrefix = $"{moduleCode}_%";
+
+        var entities = await _db.Ado.SqlQueryAsync<MdpLineageEntityRow>(
+            """
+            SELECT e.id AS Id, e.entity_code AS EntityCode, e.entity_name AS EntityName,
+                   e.entity_type AS EntityType, s.source_code AS SourceCode,
+                   s.source_name AS SourceName, s.source_type AS SourceType,
+                   s.db_type AS SourceDbType, s.db_host AS SourceDbHost,
+                   s.db_port AS SourceDbPort, s.db_name AS SourceDbName,
+                   e.source_table_name AS SourceTableName, e.source_api_path AS SourceApiPath,
+                   s.db_type AS TargetDbType, s.db_host AS TargetDbHost,
+                   s.db_port AS TargetDbPort, s.db_name AS TargetDbName,
+                   e.target_table_name AS TargetTableName, e.sync_mode AS SyncMode,
+                   e.incr_column AS IncrColumn, e.status AS Status
+            FROM mdp_entity e
+            LEFT JOIN mdp_source s ON s.id = e.source_id
+            WHERE e.entity_code LIKE @EntityPrefix
+            ORDER BY e.entity_code
+            """,
+            new SugarParameter("@EntityPrefix", entityPrefix));
+
+        if (entities.Count == 0)
+        {
+            return new MdpLineageOutput
+            {
+                ModuleCode = moduleCode,
+                JobCode = jobCode,
+                Stages = BuildStageDescriptions(moduleCode),
+                Entities = new List<MdpLineageEntityRow>()
+            };
+        }
+
+        var entityIds = string.Join(",", entities.Select(u => u.Id));
+        var mappings = await _db.Ado.SqlQueryAsync<MdpLineageFieldMappingRow>(
+            $"""
+            SELECT entity_id AS EntityId, source_field AS SourceField, target_field AS TargetField,
+                   field_type AS FieldType, transform_script AS TransformScript,
+                   const_value AS ConstValue, lookup_table AS LookupTable,
+                   is_required AS IsRequired, default_value AS DefaultValue, sort_order AS SortOrder
+            FROM mdp_field_mapping
+            WHERE entity_id IN ({entityIds})
+            ORDER BY entity_id, sort_order, target_field
+            """);
+        var mappingsByEntity = mappings.GroupBy(u => u.EntityId).ToDictionary(u => u.Key, u => u.ToList());
+
+        Dictionary<long, MdpLineageSyncLogRow> syncLogsByEntity = new();
+        if (!string.IsNullOrWhiteSpace(input.BatchId))
+        {
+            var syncLogs = await _db.Ado.SqlQueryAsync<MdpLineageSyncLogRow>(
+                """
+                SELECT entity_id AS EntityId, entity_name AS EntityName, status AS Status,
+                       rows_read AS RowsRead, rows_insert AS RowsInsert, rows_update AS RowsUpdate,
+                       rows_skip AS RowsSkip, rows_error AS RowsError,
+                       sync_start AS SyncStart, sync_end AS SyncEnd, duration_ms AS DurationMs,
+                       error_msg AS ErrorMsg
+                FROM mdp_sync_log
+                WHERE sync_batch_id = @BatchId
+                  AND entity_id IN (
+                      SELECT id FROM mdp_entity WHERE entity_code LIKE @EntityPrefix
+                  )
+                ORDER BY sync_start, id
+                """,
+                new SugarParameter("@BatchId", input.BatchId.Trim()),
+                new SugarParameter("@EntityPrefix", entityPrefix));
+            syncLogsByEntity = syncLogs
+                .GroupBy(u => u.EntityId)
+                .ToDictionary(u => u.Key, u => u.OrderByDescending(x => x.SyncStart).First());
+        }
+
+        foreach (var entity in entities)
+        {
+            entity.SourceFullName = BuildObjectFullName(entity.SourceDbType, entity.SourceDbHost, entity.SourceDbPort, entity.SourceDbName, entity.SourceTableName ?? entity.SourceApiPath);
+            entity.TargetFullName = BuildObjectFullName(entity.TargetDbType, entity.TargetDbHost, entity.TargetDbPort, entity.TargetDbName, entity.TargetTableName);
+
+            if (mappingsByEntity.TryGetValue(entity.Id, out var entityMappings))
+            {
+                entity.FieldMappings = entityMappings;
+                entity.FieldMappingCount = entityMappings.Count;
+            }
+            if (syncLogsByEntity.TryGetValue(entity.Id, out var syncLog))
+                entity.SyncLog = syncLog;
+        }
+
+        return new MdpLineageOutput
+        {
+            ModuleCode = moduleCode,
+            JobCode = jobCode,
+            BatchId = input.BatchId,
+            Stages = BuildStageDescriptions(moduleCode),
+            Entities = entities
+        };
+    }
+
+    private static (string WhereSql, List<SugarParameter> Parameters) BuildWhere(MdpMonitorQueryInput input)
+    {
+        var where = new List<string> { "IFNULL(job_code, '') LIKE '%MDP%'" };
+        var pars = new List<SugarParameter>();
+
+        var jobCode = ResolveJobCode(input.ModuleCode, input.JobCode);
+        if (!string.IsNullOrWhiteSpace(jobCode))
+        {
+            where.Add("job_code=@JobCode");
+            pars.Add(new SugarParameter("@JobCode", jobCode));
+        }
+        if (!string.IsNullOrWhiteSpace(input.BatchId))
+        {
+            where.Add("batch_id LIKE @BatchId");
+            pars.Add(new SugarParameter("@BatchId", $"%{input.BatchId.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Status))
+        {
+            where.Add("status=@Status");
+            pars.Add(new SugarParameter("@Status", input.Status.Trim().ToUpperInvariant()));
+        }
+        if (input.StartTime.HasValue)
+        {
+            where.Add("start_time >= @StartTime");
+            pars.Add(new SugarParameter("@StartTime", input.StartTime.Value));
+        }
+        if (input.EndTime.HasValue)
+        {
+            where.Add("start_time <= @EndTime");
+            pars.Add(new SugarParameter("@EndTime", input.EndTime.Value));
+        }
+
+        return (string.Join(" AND ", where), pars);
+    }
+
+    private static string? ResolveJobCode(string? moduleCode, string? jobCode)
+    {
+        if (!string.IsNullOrWhiteSpace(jobCode))
+            return jobCode.Trim().ToUpperInvariant();
+
+        if (string.IsNullOrWhiteSpace(moduleCode))
+            return null;
+
+        return ModuleJobCodes.TryGetValue(moduleCode.Trim(), out var mapped) ? mapped : null;
+    }
+
+    private static string? ResolveModuleCode(string? moduleCode, string? jobCode)
+    {
+        if (!string.IsNullOrWhiteSpace(moduleCode))
+            return moduleCode.Trim().ToUpperInvariant();
+
+        if (string.IsNullOrWhiteSpace(jobCode))
+            return null;
+
+        var normalizedJobCode = jobCode.Trim();
+        return ModuleJobCodes.FirstOrDefault(u => string.Equals(u.Value, normalizedJobCode, StringComparison.OrdinalIgnoreCase)).Key;
+    }
+
+    private static string? BuildObjectFullName(string? dbType, string? host, int? port, string? dbName, string? objectName)
+    {
+        if (string.IsNullOrWhiteSpace(objectName))
+            return null;
+
+        var databaseObject = string.IsNullOrWhiteSpace(dbName) ? objectName : $"{dbName}.{objectName}";
+        var hostPart = string.IsNullOrWhiteSpace(host) ? null : port.HasValue ? $"{host}:{port}" : host;
+        return string.Join(" / ", new[] { dbType, hostPart, databaseObject }.Where(u => !string.IsNullOrWhiteSpace(u)));
+    }
+
+    private static List<MdpLineageStageRow> BuildStageDescriptions(string moduleCode)
+    {
+        if (string.Equals(moduleCode, "S1", StringComparison.OrdinalIgnoreCase))
+        {
+            return new List<MdpLineageStageRow>
+            {
+                new() { StageCode = "STAGING", StageName = "贴源同步", Layer = "mdp_stg", Description = "按 mdp_entity 登记的 S1 源对象抽取数据,保留 raw_data JSON 便于追溯。", InputObjects = "旧系统 / 当前库源对象", OutputObjects = "mdp_stg_so, mdp_stg_ship_trans", Execution = "S1MdpSyncTransformService.SyncStagingAsync" },
+                new() { StageCode = "STANDARD", StageName = "标准层转换", Layer = "mdp_std", Description = "解析贴源 raw_data,做字段标准化、租户兜底和幂等写入。", InputObjects = "mdp_stg_so, mdp_stg_ship_trans", OutputObjects = "mdp_std_so, mdp_std_ship_trans", Execution = "S1MdpSyncTransformService.BuildStandardCommands" },
+                new() { StageCode = "DWD", StageName = "DWD宽表", Layer = "dwd", Description = "沉淀 S1 订单交付事实,供订单交付、看板和诊断读取。", InputObjects = "mdp_std_so, mdp_std_ship_trans", OutputObjects = "dwd_ship_trans", Execution = "S1MdpSyncTransformService.BuildDwdAsync" },
+                new() { StageCode = "KPI", StageName = "指标写入", Layer = "ado_s9", Description = "计算 S1 L1 指标并写入统一指标值表。", InputObjects = "mdp_std_so, dwd_ship_trans", OutputObjects = "ado_s9_kpi_value_l1_day", Execution = "S1MdpSyncTransformService.BuildS1KpiValuesAsync" }
+            };
+        }
+
+        if (string.Equals(moduleCode, "S3", StringComparison.OrdinalIgnoreCase))
+        {
+            return new List<MdpLineageStageRow>
+            {
+                new() { StageCode = "STAGING", StageName = "贴源同步", Layer = "mdp_stg", Description = "按 mdp_entity 登记的 S3 源对象抽取数据,保留 raw_data JSON 便于追溯。", InputObjects = "S3 源对象", OutputObjects = "mdp_stg_*", Execution = "S3MdpSyncTransformService.SyncStagingAsync" },
+                new() { StageCode = "STANDARD", StageName = "标准层转换", Layer = "mdp_std", Description = "将供应、物料、采购、交付等对象标准化。", InputObjects = "mdp_stg_*", OutputObjects = "mdp_std_*", Execution = "S3MdpSyncTransformService.BuildStandardCommands" },
+                new() { StageCode = "DWD", StageName = "DWD宽表", Layer = "dwd", Description = "生成供应交付、齐套、风险等分析宽表。", InputObjects = "mdp_std_*", OutputObjects = "dwd_supplier_delivery / dwd_material_readiness 等", Execution = "S3MdpSyncTransformService.BuildDwdAsync" },
+                new() { StageCode = "KPI", StageName = "指标写入", Layer = "ado_s9", Description = "写入 S3 供应协同指标。", InputObjects = "mdp_std_* / dwd_*", OutputObjects = "ado_s9_kpi_value_*", Execution = "S3MdpSyncTransformService.BuildS3KpiValuesAsync" }
+            };
+        }
+
+        return new List<MdpLineageStageRow>
+        {
+            new() { StageCode = "STAGING", StageName = "贴源同步", Layer = "mdp_stg", Description = "按 mdp_entity 登记源对象抽取数据。", InputObjects = "源对象", OutputObjects = "mdp_stg_*", Execution = "模块 MDP 同步服务" },
+            new() { StageCode = "STANDARD", StageName = "标准层转换", Layer = "mdp_std", Description = "标准层/DWD/KPI 当前由模块后端 Service 承载。", InputObjects = "mdp_stg_*", OutputObjects = "mdp_std_* / dwd_* / 指标表", Execution = "模块 MDP 转换服务" }
+        };
+    }
+
+    private static string SelectColumnsSql()
+    {
+        return """
+            SELECT id AS Id, tenant_id AS TenantId, job_code AS JobCode, job_name AS JobName, trigger_type AS TriggerType,
+                   batch_id AS BatchId, status AS Status, start_time AS StartTime, end_time AS EndTime, duration_ms AS DurationMs,
+                   stage_rows AS StageRows, standard_rows AS StandardRows, dwd_rows AS DwdRows,
+                   error_message AS ErrorMessage, summary_json AS SummaryJson, create_time AS CreateTime, update_time AS UpdateTime
+            """;
+    }
+}
+
+public class MdpMonitorQueryInput
+{
+    public string? ModuleCode { get; set; }
+    public string? JobCode { get; set; }
+    public string? BatchId { get; set; }
+    public string? Status { get; set; }
+    public DateTime? StartTime { get; set; }
+    public DateTime? EndTime { get; set; }
+}
+
+public sealed class MdpMonitorListInput : MdpMonitorQueryInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+}
+
+public sealed class MdpMonitorLineageInput : MdpMonitorQueryInput
+{
+}
+
+public sealed class MdpMonitorRunLogRow
+{
+    public long Id { get; set; }
+    public long TenantId { get; set; }
+    public string? JobCode { get; set; }
+    public string? JobName { get; set; }
+    public string? TriggerType { get; set; }
+    public string? BatchId { get; set; }
+    public string? Status { get; set; }
+    public DateTime? StartTime { get; set; }
+    public DateTime? EndTime { get; set; }
+    public int? DurationMs { get; set; }
+    public int? StageRows { get; set; }
+    public int? StandardRows { get; set; }
+    public int? DwdRows { get; set; }
+    public string? ErrorMessage { get; set; }
+    public string? SummaryJson { get; set; }
+    public DateTime? CreateTime { get; set; }
+    public DateTime? UpdateTime { get; set; }
+}
+
+public sealed class MdpLineageOutput
+{
+    public string? ModuleCode { get; set; }
+    public string? JobCode { get; set; }
+    public string? BatchId { get; set; }
+    public List<MdpLineageStageRow> Stages { get; set; } = new();
+    public List<MdpLineageEntityRow> Entities { get; set; } = new();
+}
+
+public sealed class MdpLineageStageRow
+{
+    public string? StageCode { get; set; }
+    public string? StageName { get; set; }
+    public string? Layer { get; set; }
+    public string? Description { get; set; }
+    public string? InputObjects { get; set; }
+    public string? OutputObjects { get; set; }
+    public string? Execution { get; set; }
+}
+
+public sealed class MdpLineageEntityRow
+{
+    public long Id { get; set; }
+    public string? EntityCode { get; set; }
+    public string? EntityName { get; set; }
+    public string? EntityType { get; set; }
+    public string? SourceCode { get; set; }
+    public string? SourceName { get; set; }
+    public string? SourceType { get; set; }
+    public string? SourceDbType { get; set; }
+    public string? SourceDbHost { get; set; }
+    public int? SourceDbPort { get; set; }
+    public string? SourceDbName { get; set; }
+    public string? SourceTableName { get; set; }
+    public string? SourceApiPath { get; set; }
+    public string? SourceFullName { get; set; }
+    public string? TargetDbType { get; set; }
+    public string? TargetDbHost { get; set; }
+    public int? TargetDbPort { get; set; }
+    public string? TargetDbName { get; set; }
+    public string? TargetTableName { get; set; }
+    public string? TargetFullName { get; set; }
+    public string? SyncMode { get; set; }
+    public string? IncrColumn { get; set; }
+    public int? Status { get; set; }
+    public int FieldMappingCount { get; set; }
+    public List<MdpLineageFieldMappingRow> FieldMappings { get; set; } = new();
+    public MdpLineageSyncLogRow? SyncLog { get; set; }
+}
+
+public sealed class MdpLineageFieldMappingRow
+{
+    public long EntityId { get; set; }
+    public string? SourceField { get; set; }
+    public string? TargetField { get; set; }
+    public string? FieldType { get; set; }
+    public string? TransformScript { get; set; }
+    public string? ConstValue { get; set; }
+    public string? LookupTable { get; set; }
+    public bool IsRequired { get; set; }
+    public string? DefaultValue { get; set; }
+    public int SortOrder { get; set; }
+}
+
+public sealed class MdpLineageSyncLogRow
+{
+    public long EntityId { get; set; }
+    public string? EntityName { get; set; }
+    public string? Status { get; set; }
+    public long? RowsRead { get; set; }
+    public long? RowsInsert { get; set; }
+    public long? RowsUpdate { get; set; }
+    public long? RowsSkip { get; set; }
+    public long? RowsError { get; set; }
+    public DateTime? SyncStart { get; set; }
+    public DateTime? SyncEnd { get; set; }
+    public int? DurationMs { get; set; }
+    public string? ErrorMsg { get; set; }
+}

+ 47 - 78
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderDeliveryService.cs

@@ -11,10 +11,12 @@ namespace Admin.NET.Plugin.AiDOP.Order;
 public class OrderDeliveryService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
 
-    public OrderDeliveryService(ISqlSugarClient db)
+    public OrderDeliveryService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
+        _userManager = userManager;
     }
 
     // ══════════════════════════════════════════════════════════════
@@ -25,31 +27,32 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
     [HttpGet("delivery/list")]
     public async Task<object> GetDeliveryList([FromQuery] OrderDeliveryListInput input)
     {
-        var pars = new List<SugarParameter>();
+        var tenantId = _userManager.TenantId;
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
         var innerConditions = new List<string>
         {
-            "entry.IsDeleted=0",
-            "sorder.bill_no IS NOT NULL"
+            "d.tenant_id = @TenantId",
+            "d.order_no IS NOT NULL"
         };
 
         if (!string.IsNullOrWhiteSpace(input.BillNo))
         {
-            innerConditions.Add("sorder.bill_no LIKE @BillNo");
+            innerConditions.Add("d.order_no LIKE @BillNo");
             pars.Add(new SugarParameter("@BillNo", $"%{input.BillNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.CustomNo))
         {
-            innerConditions.Add("sorder.custom_no LIKE @CustomNo");
+            innerConditions.Add("d.customer_no LIKE @CustomNo");
             pars.Add(new SugarParameter("@CustomNo", $"%{input.CustomNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.ItemNumber))
         {
-            innerConditions.Add("entry.item_number LIKE @ItemNumber");
+            innerConditions.Add("d.item_code LIKE @ItemNumber");
             pars.Add(new SugarParameter("@ItemNumber", $"%{input.ItemNumber.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.PlanDateFrom))
         {
-            innerConditions.Add("entry.plan_date >= @PlanDateFrom");
+            innerConditions.Add("d.plan_delivery_date >= @PlanDateFrom");
             pars.Add(new SugarParameter("@PlanDateFrom", input.PlanDateFrom.Trim()));
         }
 
@@ -76,78 +79,44 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
 
     private static string BuildListBaseSql(string innerWhere) => $"""
         SELECT
-            entry.Id                                                    AS Id,
-            sorder.Id                                                   AS SeOrderId,
-            sorder.bill_no                                              AS BillNo,
-            entry.rstate                                                AS Rstate,
-            entry.entry_seq                                             AS EntrySeq,
-            entry.item_number                                           AS ItemNumber,
-            entry.item_name                                             AS ItemName,
-            entry.specification                                         AS Specification,
-            entry.unit                                                  AS Unit,
-            sorder.custom_no                                            AS CustomNo,
-            entry.custom_order_bill_no                                  AS CustomOrderBillNo,
-            entry.custom_order_itemno                                   AS CustomOrderItemNo,
-            sorder.custom_level                                         AS CustomLevel,
-            entry.qty                                                   AS Qty,
-            entry.deliver_count                                         AS DeliverCount,
-            entry.bom_number                                            AS BomNumber,
-            sorder.emp_name                                             AS EmpName,
-            entry.planner_name                                          AS PlannerName,
-            entry.plan_date                                             AS PlanDate,
-            entry.sys_capacity_date                                     AS SysCapacityDate,
-            entry.date                                                  AS Date,
-            entry.create_time                                           AS CreateTime,
-            entry.create_by_name                                        AS CreateByName,
-            sorder.auditor                                              AS Auditor,
+            COALESCE(d.order_entry_id, d.id)                            AS Id,
+            d.order_id                                                  AS SeOrderId,
+            d.order_no                                                  AS BillNo,
+            NULL                                                        AS Rstate,
+            CAST(NULLIF(d.order_line, '') AS SIGNED)                    AS EntrySeq,
+            d.item_code                                                 AS ItemNumber,
+            d.item_name                                                 AS ItemName,
+            d.item_spec                                                 AS Specification,
+            NULL                                                        AS Unit,
+            d.customer_no                                               AS CustomNo,
+            NULL                                                        AS CustomOrderBillNo,
+            NULL                                                        AS CustomOrderItemNo,
+            NULL                                                        AS CustomLevel,
+            d.order_qty                                                 AS Qty,
+            d.shipped_qty                                               AS DeliverCount,
+            NULL                                                        AS BomNumber,
+            NULL                                                        AS EmpName,
+            NULL                                                        AS PlannerName,
+            d.plan_delivery_date                                        AS PlanDate,
+            d.promised_delivery_date                                    AS SysCapacityDate,
+            d.promised_delivery_date                                    AS Date,
+            d.create_time                                               AS CreateTime,
+            NULL                                                        AS CreateByName,
+            NULL                                                        AS Auditor,
             CASE
-                WHEN entry.progress = 0 THEN '0'
-                WHEN nbo.nboNum > 0     THEN '7'
-                WHEN compp.num > 0      THEN '6'
-                WHEN cnb.num > 0        THEN '5'
-                WHEN pr.num > 0         THEN '4'
-                WHEN nb.num > 0         THEN '3'
-                WHEN p.num > 0          THEN '2'
-                WHEN entry.progress = 3 THEN '1'
-                ELSE CAST(entry.progress AS CHAR)
+                WHEN d.delivery_status = 'COMPLETED' THEN '7'
+                WHEN d.planned_ship_qty > 0 THEN '7'
+                WHEN d.order_status = 'CLOSED' THEN '7'
+                WHEN d.review_status IS NOT NULL THEN '1'
+                ELSE '1'
             END                                                         AS Progress,
-            yeard.ProdLine                                              AS ProdLine,
-            yeard.ProdRange                                             AS ProdRange,
-            asn.ShipDate                                                AS ShipDate,
-            CAST(sp.recid AS CHAR)                                      AS Recid,
-            IF(sp.recid IS NOT NULL, '是', '否')                       AS Spstatus,
-            mo.moentry_mono                                             AS MoentryMono
-        FROM crm_seorderentry entry
-        LEFT JOIN crm_seorder sorder ON entry.seorder_id = sorder.Id
-        LEFT JOIN (
-            SELECT a.id, a.soentry_id, b.product_code, a.moentry_mono
-            FROM mes_moentry a
-            JOIN mes_morder b ON a.moentry_mono = b.morder_no        ) mo ON entry.id = mo.soentry_id AND entry.item_number = mo.product_code        LEFT JOIN (
-            SELECT WorkOrd, COUNT(*) AS num
-            FROM NbrMaster WHERE type = 'SM' GROUP BY WorkOrd
-        ) nb ON mo.moentry_mono = nb.WorkOrd        LEFT JOIN (
-            SELECT WorkOrds, COUNT(*) AS num
-            FROM PeriodSequenceDet GROUP BY WorkOrds
-        ) p ON mo.moentry_mono = p.WorkOrds        LEFT JOIN (
-            SELECT pr_mono, COUNT(*) AS num
-            FROM srm_pr_main WHERE state > 1 GROUP BY pr_mono
-        ) pr ON mo.moentry_mono = pr.pr_mono        LEFT JOIN (
-            SELECT WorkOrds, COUNT(*) AS num
-            FROM PeriodSequenceDet WHERE CompQty > 0 GROUP BY WorkOrds
-        ) compp ON mo.moentry_mono = compp.WorkOrds        LEFT JOIN (
-            SELECT WorkOrd, COUNT(*) AS num
-            FROM NbrDetail WHERE type = 'SM' AND QtyFrom > 0 GROUP BY WorkOrd
-        ) cnb ON mo.moentry_mono = cnb.WorkOrd        LEFT JOIN (
-            SELECT ProdLine, ProdRange, SAPItemNumber
-            FROM YearDemandManagement GROUP BY ProdLine, ProdRange, SAPItemNumber
-        ) yeard ON entry.item_number = yeard.SAPItemNumber        LEFT JOIN (
-            SELECT OrdNbr, ContainerItem, MAX(UpdateTime) AS ShipDate
-            FROM ASNBOLShipperDetail GROUP BY OrdNbr, ContainerItem
-        ) asn ON asn.OrdNbr = sorder.bill_no AND asn.ContainerItem = entry.item_number        LEFT JOIN ShippingPlanDetail sp ON entry.id = sp.sentry_id
-        LEFT JOIN LATERAL (
-            SELECT COUNT(*) AS nboNum
-            FROM ASNBOLShipperDetail
-            WHERE OrdNbr = sorder.bill_no AND ContainerItem = entry.item_number        ) AS nbo ON TRUE
+            NULL                                                        AS ProdLine,
+            NULL                                                        AS ProdRange,
+            d.actual_ship_date                                          AS ShipDate,
+            NULL                                                        AS Recid,
+            IF(IFNULL(d.planned_ship_qty, 0) > 0, '是', '否')          AS Spstatus,
+            NULL                                                        AS MoentryMono
+        FROM dwd_ship_trans d
         WHERE {innerWhere}
         """;
 

+ 127 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpMonitorService.cs

@@ -0,0 +1,127 @@
+namespace Admin.NET.Plugin.AiDOP.Order;
+
+/// <summary>
+/// S1 MDP 运行监控。
+/// </summary>
+[ApiDescriptionSettings(Order = 321, Description = "S1 MDP运行监控")]
+[Route("api/Order")]
+[AllowAnonymous]
+[NonUnify]
+public class S1MdpMonitorService : IDynamicApiController, ITransient
+{
+    private const string JobCode = "S1_MDP_SYNC_TRANSFORM";
+    private readonly ISqlSugarClient _db;
+
+    public S1MdpMonitorService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    [DisplayName("S1 MDP最近运行状态")]
+    [HttpGet("s1-mdp-monitor/latest")]
+    public async Task<object> GetLatest()
+    {
+        return await _db.Ado.SqlQuerySingleAsync<S1MdpRunLogRow>(
+            $"{SelectColumnsSql()} FROM mdp_transform_run_log WHERE job_code=@JobCode ORDER BY start_time DESC, id DESC LIMIT 1",
+            new SugarParameter("@JobCode", JobCode))
+            ?? new S1MdpRunLogRow();
+    }
+
+    [DisplayName("S1 MDP运行日志列表")]
+    [HttpGet("s1-mdp-monitor/list")]
+    public async Task<object> GetList([FromQuery] S1MdpMonitorListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+        var where = new List<string> { "job_code=@JobCode" };
+        var pars = new List<SugarParameter> { new("@JobCode", JobCode) };
+
+        if (!string.IsNullOrWhiteSpace(input.BatchId))
+        {
+            where.Add("batch_id LIKE @BatchId");
+            pars.Add(new SugarParameter("@BatchId", $"%{input.BatchId.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Status))
+        {
+            where.Add("status=@Status");
+            pars.Add(new SugarParameter("@Status", input.Status.Trim().ToUpperInvariant()));
+        }
+        if (input.StartTime.HasValue)
+        {
+            where.Add("start_time >= @StartTime");
+            pars.Add(new SugarParameter("@StartTime", input.StartTime.Value));
+        }
+        if (input.EndTime.HasValue)
+        {
+            where.Add("start_time <= @EndTime");
+            pars.Add(new SugarParameter("@EndTime", input.EndTime.Value));
+        }
+
+        var whereSql = string.Join(" AND ", where);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM mdp_transform_run_log WHERE {whereSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<S1MdpRunLogRow>(
+            $"""
+            {SelectColumnsSql()}
+            FROM mdp_transform_run_log
+            WHERE {whereSql}
+            ORDER BY start_time DESC, id DESC
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("S1 MDP运行日志详情")]
+    [HttpGet("s1-mdp-monitor/detail/{id}")]
+    public async Task<object> GetDetail(long id)
+    {
+        var row = await _db.Ado.SqlQuerySingleAsync<S1MdpRunLogRow>(
+            $"{SelectColumnsSql()} FROM mdp_transform_run_log WHERE id=@Id AND job_code=@JobCode LIMIT 1",
+            new SugarParameter("@Id", id),
+            new SugarParameter("@JobCode", JobCode));
+        return row ?? throw Oops.Oh("运行日志不存在");
+    }
+
+    private static string SelectColumnsSql()
+    {
+        return """
+            SELECT id AS Id, tenant_id AS TenantId, job_code AS JobCode, job_name AS JobName, trigger_type AS TriggerType,
+                   batch_id AS BatchId, status AS Status, start_time AS StartTime, end_time AS EndTime, duration_ms AS DurationMs,
+                   stage_rows AS StageRows, standard_rows AS StandardRows, dwd_rows AS DwdRows,
+                   error_message AS ErrorMessage, summary_json AS SummaryJson, create_time AS CreateTime, update_time AS UpdateTime
+            """;
+    }
+}
+
+public sealed class S1MdpMonitorListInput
+{
+    public string? BatchId { get; set; }
+    public string? Status { get; set; }
+    public DateTime? StartTime { get; set; }
+    public DateTime? EndTime { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+}
+
+public sealed class S1MdpRunLogRow
+{
+    public long Id { get; set; }
+    public long TenantId { get; set; }
+    public string? JobCode { get; set; }
+    public string? JobName { get; set; }
+    public string? TriggerType { get; set; }
+    public string? BatchId { get; set; }
+    public string? Status { get; set; }
+    public DateTime? StartTime { get; set; }
+    public DateTime? EndTime { get; set; }
+    public int? DurationMs { get; set; }
+    public int? StageRows { get; set; }
+    public int? StandardRows { get; set; }
+    public int? DwdRows { get; set; }
+    public string? ErrorMessage { get; set; }
+    public string? SummaryJson { get; set; }
+    public DateTime? CreateTime { get; set; }
+    public DateTime? UpdateTime { get; set; }
+}

+ 858 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs

@@ -0,0 +1,858 @@
+namespace Admin.NET.Plugin.AiDOP.Order;
+
+/// <summary>
+/// S1 首批 MDP 同步和标准化转换服务。
+/// </summary>
+public class S1MdpSyncTransformService : ITransient
+{
+    private const string JobCode = "S1_MDP_SYNC_TRANSFORM";
+    private readonly ISqlSugarClient _db;
+
+    public S1MdpSyncTransformService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<S1MdpSyncTransformResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        var now = DateTime.Now;
+        var batchId = $"S1_MDP_FULL_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertTransformRunLogAsync(batchId, now, triggerType);
+        var result = new S1MdpSyncTransformResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            result.StageRows = await SyncStagingAsync(batchId, now, cancellationToken);
+            result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
+            result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
+            result.KpiRows = await BuildS1KpiValuesAsync(now, cancellationToken);
+            await MarkTransformRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    private async Task<int> SyncStagingAsync(string batchId, DateTime now, CancellationToken cancellationToken)
+    {
+        var total = 0;
+        foreach (var entity in S1MdpEntityConfig.All)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await SyncOneEntityAsync(entity, batchId, now);
+        }
+        return total;
+    }
+
+    private async Task<int> SyncOneEntityAsync(S1MdpEntityConfig entity, string batchId, DateTime now)
+    {
+        var entityRow = await _db.Ado.SqlQuerySingleAsync<S1MdpEntityRow>(
+            "SELECT id AS Id, entity_name AS EntityName FROM mdp_entity WHERE tenant_id=0 AND entity_code=@EntityCode LIMIT 1",
+            new SugarParameter("@EntityCode", entity.EntityCode));
+        if (entityRow == null) throw Oops.Oh($"未找到 MDP 实体配置:{entity.EntityCode}");
+
+        var columns = await _db.Ado.SqlQueryAsync<S1ColumnRow>(
+            """
+            SELECT COLUMN_NAME AS ColumnName
+            FROM information_schema.COLUMNS
+            WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@TableName
+            ORDER BY ORDINAL_POSITION
+            """,
+            new SugarParameter("@TableName", entity.SourceTable));
+        if (columns.Count == 0) throw Oops.Oh($"未找到源表:{entity.SourceTable}");
+
+        var names = columns.Select(u => u.ColumnName).ToList();
+        var tenantExpr = BuildOptionalColumnExpr(names, "tenant_id", "0");
+        var factoryExpr = BuildOptionalColumnExpr(names, "factory_id", "NULL");
+        var companyExpr = BuildOptionalColumnExpr(names, "company_id", "NULL");
+        var sourceRowExpr = names.Any(u => string.Equals(u, entity.SourceRowIdExpression, StringComparison.OrdinalIgnoreCase))
+            ? $"s.`{FindColumn(names, entity.SourceRowIdExpression)}`"
+            : entity.SourceRowIdExpression;
+        var rawDataExpr = BuildJsonObjectExpression(names);
+
+        var rowsRead = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM `{entity.SourceTable}`");
+        var logId = await InsertSyncLogAsync(entityRow.Id, entityRow.EntityName, batchId, rowsRead);
+        var started = DateTime.Now;
+
+        try
+        {
+            var affected = await _db.Ado.ExecuteCommandAsync(
+                $"""
+                INSERT INTO `{entity.TargetTable}`
+                (tenant_id, factory_id, company_id, source_system, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time, process_status, raw_data)
+                SELECT
+                    {tenantExpr},
+                    {factoryExpr},
+                    {companyExpr},
+                    'AIDOP',
+                    @SourceTable,
+                    CAST({sourceRowExpr} AS CHAR),
+                    CAST(COALESCE({entity.SourceBizKeyExpression}, CAST({sourceRowExpr} AS CHAR)) AS CHAR),
+                    @BatchId,
+                    @Now,
+                    'PENDING',
+                    {rawDataExpr}
+                FROM `{entity.SourceTable}` s
+                ON DUPLICATE KEY UPDATE
+                    tenant_id=VALUES(tenant_id),
+                    factory_id=VALUES(factory_id),
+                    company_id=VALUES(company_id),
+                    source_row_id=VALUES(source_row_id),
+                    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("@SourceTable", entity.SourceTable),
+                new SugarParameter("@BatchId", batchId),
+                new SugarParameter("@Now", now));
+
+            await MarkSyncLogSuccessAsync(logId, started, affected);
+            return rowsRead;
+        }
+        catch (Exception ex)
+        {
+            await MarkSyncLogFailedAsync(logId, started, ex.Message);
+            throw;
+        }
+    }
+
+    private async Task<int> TransformStandardAsync(string batchId, DateTime now, CancellationToken cancellationToken)
+    {
+        var total = 0;
+        foreach (var command in BuildStandardCommands(batchId, now))
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await _db.Ado.ExecuteCommandAsync(command.Sql, command.Parameters);
+        }
+        return total;
+    }
+
+    private async Task<int> BuildDwdAsync(string batchId, DateTime now, CancellationToken cancellationToken)
+    {
+        var total = 0;
+        foreach (var command in BuildDwdCommands(batchId, now))
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await _db.Ado.ExecuteCommandAsync(command.Sql, command.Parameters);
+        }
+        return total;
+    }
+
+    private async Task<int> BuildS1KpiValuesAsync(DateTime now, CancellationToken cancellationToken)
+    {
+        var statDate = now.Date;
+        var rows = await CalculateS1KpiValuesAsync(statDate);
+        var affected = 0;
+        foreach (var row in rows)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            affected += await UpsertS1KpiValueAsync(row, statDate, now);
+        }
+
+        return affected;
+    }
+
+    private async Task<List<S1KpiCalcRow>> CalculateS1KpiValuesAsync(DateTime statDate)
+    {
+        return await _db.Ado.SqlQueryAsync<S1KpiCalcRow>(
+            """
+            SELECT tenant_id AS TenantId, COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId,
+                   'S1_L1_001' AS MetricCode,
+                   ROUND(AVG(TIMESTAMPDIFF(HOUR, order_date, COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date)) / 24), 4) AS MetricValue
+            FROM mdp_std_so
+            WHERE order_date IS NOT NULL
+              AND COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date) IS NOT NULL
+              AND COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date) >= order_date
+            GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
+            UNION ALL
+            SELECT tenant_id AS TenantId, COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId,
+                   'S1_L1_002' AS MetricCode,
+                   ROUND(100 * SUM(CASE WHEN TIMESTAMPDIFF(HOUR, order_date, COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date)) <= 72 THEN 1 ELSE 0 END) / COUNT(1), 4) AS MetricValue
+            FROM mdp_std_so
+            WHERE order_date IS NOT NULL
+              AND COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date) IS NOT NULL
+            GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
+            UNION ALL
+            SELECT tenant_id AS TenantId, COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId,
+                   'S1_L1_003' AS MetricCode,
+                   ROUND(COUNT(1) / GREATEST(COUNT(DISTINCT NULLIF(planner_no, '')), 1), 4) AS MetricValue
+            FROM mdp_std_so
+            WHERE order_date IS NOT NULL AND order_date <= @StatDate
+            GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
+            UNION ALL
+            SELECT tenant_id AS TenantId, COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId,
+                   'S1_L2_010' AS MetricCode,
+                   ROUND(AVG(TIMESTAMPDIFF(HOUR, order_date, COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date)) / 24), 4) AS MetricValue
+            FROM mdp_std_so
+            WHERE order_date IS NOT NULL
+              AND COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date) IS NOT NULL
+              AND COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date) >= order_date
+            GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
+            UNION ALL
+            SELECT tenant_id AS TenantId, COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId,
+                   'S1_L2_011' AS MetricCode,
+                   ROUND(100 * SUM(CASE WHEN TIMESTAMPDIFF(HOUR, order_date, COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date)) <= 72 THEN 1 ELSE 0 END) / COUNT(1), 4) AS MetricValue
+            FROM mdp_std_so
+            WHERE order_date IS NOT NULL
+              AND COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date) IS NOT NULL
+            GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
+            UNION ALL
+            SELECT tenant_id AS TenantId, COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId,
+                   'S1_L2_012' AS MetricCode,
+                   ROUND(COUNT(1) / GREATEST(COUNT(DISTINCT NULLIF(planner_no, '')), 1), 4) AS MetricValue
+            FROM mdp_std_so
+            WHERE order_date IS NOT NULL AND order_date <= @StatDate
+            GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
+            """,
+            new SugarParameter("@StatDate", statDate));
+    }
+
+    private async Task<int> UpsertS1KpiValueAsync(S1KpiCalcRow row, DateTime statDate, DateTime now)
+    {
+        var meta = await _db.Ado.SqlQuerySingleAsync<S1KpiMetaRow>(
+            """
+            SELECT MetricLevel, Direction, YellowThreshold, RedThreshold
+            FROM ado_smart_ops_kpi_master
+            WHERE TenantId=@TenantId AND ModuleCode='S1' AND MetricCode=@MetricCode AND IsEnabled=1
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", row.TenantId),
+            new SugarParameter("@MetricCode", row.MetricCode));
+        if (meta == null || row.MetricValue == null)
+            return 0;
+
+        var table = ResolveKpiValueTable(meta.MetricLevel);
+        var current = await _db.Ado.SqlQuerySingleAsync<S1KpiValueRow>(
+            $"""
+            SELECT id AS Id, metric_value AS MetricValue, target_value AS TargetValue
+            FROM {table}
+            WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND module_code='S1'
+              AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0
+            ORDER BY id
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", row.TenantId),
+            new SugarParameter("@FactoryId", row.FactoryId),
+            new SugarParameter("@MetricCode", row.MetricCode),
+            new SugarParameter("@BizDate", statDate));
+        var prior = await _db.Ado.SqlQuerySingleAsync<S1KpiValueRow>(
+            $"""
+            SELECT id AS Id, metric_value AS MetricValue, target_value AS TargetValue
+            FROM {table}
+            WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND module_code='S1'
+              AND metric_code=@MetricCode AND biz_date<@BizDate AND is_deleted=0
+            ORDER BY biz_date DESC, id DESC
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", row.TenantId),
+            new SugarParameter("@FactoryId", row.FactoryId),
+            new SugarParameter("@MetricCode", row.MetricCode),
+            new SugarParameter("@BizDate", statDate));
+
+        var actual = Math.Round(row.MetricValue.Value, 4);
+        var target = current?.TargetValue ?? prior?.TargetValue ?? DefaultS1Target(row.MetricCode);
+        var status = ResolveKpiStatus(actual, target, meta.Direction, meta.YellowThreshold, meta.RedThreshold);
+        var trend = ResolveTrendFlag(actual, prior?.MetricValue);
+
+        if (current != null)
+        {
+            return await _db.Ado.ExecuteCommandAsync(
+                $"""
+                UPDATE {table}
+                SET metric_value=@MetricValue, target_value=@TargetValue, status_color=@StatusColor, trend_flag=@TrendFlag,
+                    is_active=1, status='ACTIVE', calc_time=@CalcTime, update_time=@CalcTime
+                WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND module_code='S1'
+                  AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0
+                """,
+                new SugarParameter("@MetricValue", actual),
+                new SugarParameter("@TargetValue", target),
+                new SugarParameter("@StatusColor", status),
+                new SugarParameter("@TrendFlag", trend),
+                new SugarParameter("@CalcTime", now),
+                new SugarParameter("@TenantId", row.TenantId),
+                new SugarParameter("@FactoryId", row.FactoryId),
+                new SugarParameter("@MetricCode", row.MetricCode),
+                new SugarParameter("@BizDate", statDate));
+        }
+
+        var nextId = await _db.Ado.GetLongAsync($"SELECT COALESCE(MAX(id), 0) + 1 FROM {table}");
+        return await _db.Ado.ExecuteCommandAsync(
+            $"""
+            INSERT INTO {table}
+            (id, tenant_id, factory_id, status, biz_date, create_time, update_time, is_deleted, is_active,
+             module_code, metric_code, metric_value, target_value, status_color, trend_flag, calc_time)
+            VALUES
+            (@Id, @TenantId, @FactoryId, 'ACTIVE', @BizDate, @CalcTime, @CalcTime, 0, 1,
+             'S1', @MetricCode, @MetricValue, @TargetValue, @StatusColor, @TrendFlag, @CalcTime)
+            """,
+            new SugarParameter("@Id", nextId),
+            new SugarParameter("@TenantId", row.TenantId),
+            new SugarParameter("@FactoryId", row.FactoryId),
+            new SugarParameter("@BizDate", statDate),
+            new SugarParameter("@CalcTime", now),
+            new SugarParameter("@MetricCode", row.MetricCode),
+            new SugarParameter("@MetricValue", actual),
+            new SugarParameter("@TargetValue", target),
+            new SugarParameter("@StatusColor", status),
+            new SugarParameter("@TrendFlag", trend));
+    }
+
+    private IEnumerable<S1MdpSqlCommand> BuildStandardCommands(string batchId, DateTime now)
+    {
+        yield return Cmd(
+            """
+            INSERT INTO mdp_std_so
+            (tenant_id, factory_id, company_id, source_system, order_id, order_entry_id, order_no, order_line, order_type,
+             customer_id, customer_no, customer_name, customer_order_no, country, item_code, item_name, item_spec,
+             map_number, map_name, bom_number, unit, order_qty, delivered_notice_qty, delivered_qty, price, tax_price,
+             amount, total_amount, order_date, customer_request_date, plan_delivery_date, promised_delivery_date,
+             capacity_date, material_ready_date, planner_no, planner_name, order_status, review_status, review_stage,
+             flow_state, progress, urgent, closed, deleted_flag, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                COALESCE(e.tenant_id, h.tenant_id, 0),
+                COALESCE(e.factory_id, h.factory_id),
+                e.company_id,
+                'AIDOP',
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.Id')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.Id')) AS SIGNED) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.Id')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.Id')) AS SIGNED) END,
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.bill_no')), JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.bill_no')), e.source_biz_key),
+                CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.entry_seq')), JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.Id'))) AS CHAR),
+                CAST(JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.order_type')) AS CHAR),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.custom_id')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.custom_id')) AS SIGNED) END,
+                JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.custom_no')),
+                JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.custom_name')),
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.custom_order_bill_no')), JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.bill_from'))),
+                JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.country')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.item_number')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.item_name')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.specification')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.map_number')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.map_name')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.bom_number')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.unit')),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.qty')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.deliver_notice_count')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.deliver_notice_count')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.deliver_count')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.deliver_count')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.price')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.price')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.tax_price')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.tax_price')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.amount')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.amount')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.total_amount')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.total_amount')) AS DECIMAL(18,6)) END,
+                NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.date')), 'null'), ''),
+                NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.rdate')), 'null'), ''),
+                NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.plan_date')), 'null'), ''),
+                NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.date')), 'null'), ''),
+                NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.sys_capacity_date')), 'null'), ''),
+                NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.sys_material_date')), 'null'), ''),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.planner_no')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.planner_name')),
+                CASE WHEN JSON_EXTRACT(h.raw_data,'$.closed') IN (1, true) THEN 'CLOSED' ELSE 'OPEN' END,
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.FlowStatus')), JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.flowstate'))),
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.CurrentDept')), JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.CurrentStage'))),
+                JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.flowstate')),
+                JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.progress')),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(COALESCE(e.raw_data, h.raw_data),'$.urgent')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(COALESCE(e.raw_data, h.raw_data),'$.urgent')) AS SIGNED) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.closed')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.closed')) AS SIGNED) END,
+                COALESCE(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(COALESCE(e.raw_data, h.raw_data),'$.IsDeleted')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(COALESCE(e.raw_data, h.raw_data),'$.IsDeleted')) AS SIGNED) END, 0),
+                e.source_table,
+                e.source_row_id,
+                e.source_biz_key,
+                @BatchId,
+                @Now
+            FROM mdp_stg_so e
+            LEFT JOIN mdp_stg_so h ON h.source_table='crm_seorder'
+                AND JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.Id')) = JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.seorder_id'))
+            LEFT JOIN mdp_stg_so r ON r.source_table='ado_contract_review'
+                AND JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.BillNo')) = COALESCE(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.contract_no')), JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.bill_no')), JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.bill_no')))
+            WHERE e.source_table='crm_seorderentry'
+              AND IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.bill_no')), JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.bill_no'))), '') <> ''
+            ON DUPLICATE KEY UPDATE
+                customer_no=VALUES(customer_no), customer_name=VALUES(customer_name), item_code=VALUES(item_code),
+                item_name=VALUES(item_name), item_spec=VALUES(item_spec), order_qty=VALUES(order_qty),
+                delivered_notice_qty=VALUES(delivered_notice_qty), delivered_qty=VALUES(delivered_qty),
+                plan_delivery_date=VALUES(plan_delivery_date), promised_delivery_date=VALUES(promised_delivery_date),
+                capacity_date=VALUES(capacity_date), material_ready_date=VALUES(material_ready_date),
+                order_status=VALUES(order_status), review_status=VALUES(review_status), review_stage=VALUES(review_stage),
+                flow_state=VALUES(flow_state), progress=VALUES(progress), deleted_flag=VALUES(deleted_flag),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
+
+        yield return Cmd(
+            """
+            INSERT INTO mdp_std_ship_trans
+            (tenant_id, factory_id, company_id, source_system, trans_type, plan_id, plan_no, plan_line, order_id, order_entry_id,
+             order_no, order_line, customer_no, customer_name, country, item_code, item_name, item_spec, qty, plan_qty,
+             weight, volume, order_date, plan_ship_date, shipping_site, shipping_address, consignee, telephone,
+             status, confirm_status, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                IFNULL(d.tenant_id, 0),
+                d.factory_id,
+                d.company_id,
+                'AIDOP',
+                'SHIP_PLAN',
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.plan_id')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.plan_id')) AS SIGNED) END,
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.LotSerial')), CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.plan_id')) AS CHAR)),
+                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.RecID')) AS CHAR),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.seorder_id')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.seorder_id')) AS SIGNED) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.sentry_id')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.sentry_id')) AS SIGNED) END,
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.bill_no')), JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.OrdNbr'))),
+                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.sentry_id')) AS CHAR),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.CustomNo')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.CustomName')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Country')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ItemNum')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ItemName')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Specification')),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Qty')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Qty')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Weight')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Weight')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Volume')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Volume')) AS DECIMAL(18,6)) END,
+                NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.OrdDate')), 'null'), ''),
+                NULLIF(NULLIF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.ShippingDate')), JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.CreateTime'))), 'null'), ''),
+                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.ShippingSite')),
+                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.ShippingAddress')),
+                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Consignee')),
+                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Telephone')),
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Status')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Status'))),
+                CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.IsConfirm')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.IsConfirm'))) AS CHAR),
+                d.source_table,
+                d.source_row_id,
+                d.source_biz_key,
+                @BatchId,
+                @Now
+            FROM mdp_stg_ship_trans d
+            LEFT JOIN mdp_stg_ship_trans m ON m.source_table='ShippingPlan'
+                AND JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.RecID')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.plan_id'))
+            WHERE d.source_table='ShippingPlanDetail'
+              AND IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.bill_no')), JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.OrdNbr'))), '') <> ''
+            ON DUPLICATE KEY UPDATE
+                plan_no=VALUES(plan_no), order_no=VALUES(order_no), customer_no=VALUES(customer_no), customer_name=VALUES(customer_name),
+                item_code=VALUES(item_code), item_name=VALUES(item_name), qty=VALUES(qty), plan_qty=VALUES(plan_qty),
+                plan_ship_date=VALUES(plan_ship_date), shipping_site=VALUES(shipping_site), shipping_address=VALUES(shipping_address),
+                status=VALUES(status), confirm_status=VALUES(confirm_status), sync_batch_id=VALUES(sync_batch_id),
+                sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
+
+        yield return Cmd(
+            """
+            INSERT INTO mdp_std_ship_trans
+            (tenant_id, factory_id, source_system, trans_type, shipper_rec_id, shipper_no, shipper_line, order_no, order_line,
+             customer_no, item_code, item_name, qty_to_ship, picking_qty, real_qty, gross_weight, net_weight, volume,
+             plan_ship_date, actual_ship_date, site, status, confirm_status, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                IFNULL(d.tenant_id, 0),
+                d.factory_id,
+                'AIDOP',
+                'ASN_SHIPPER',
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ASNBOLShipperRecID')) REGEXP '^-?[0-9]+$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ASNBOLShipperRecID')) AS SIGNED) END,
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Id')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Id'))),
+                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Line')) AS CHAR),
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.OrdNbr')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.OrdNbr'))),
+                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.OrdLine')) AS CHAR),
+                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.SoldTo')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ContainerItem')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Descr')),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.QtyToShip')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.QtyToShip')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.PickingQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.PickingQty')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.RealQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.RealQty')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.GrossWeight')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.GrossWeight')) AS DECIMAL(18,6)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.NetWeight')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.NetWeight')) AS DECIMAL(18,6)) END,
+                CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Volume')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Volume'))) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Volume')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Volume'))) AS DECIMAL(18,6)) END,
+                NULLIF(NULLIF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ShipDate')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.ShipDate'))), 'null'), ''),
+                NULLIF(NULLIF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ShipDate')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.ShipDate'))), 'null'), ''),
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Site')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Site'))),
+                COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Status')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Status'))),
+                CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.IsConfirm')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.IsConfirm'))) AS CHAR),
+                d.source_table,
+                d.source_row_id,
+                d.source_biz_key,
+                @BatchId,
+                @Now
+            FROM mdp_stg_ship_trans d
+            LEFT JOIN mdp_stg_ship_trans m ON m.source_table='ASNBOLShipperMaster'
+                AND JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.RecID')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ASNBOLShipperRecID'))
+            WHERE d.source_table='ASNBOLShipperDetail'
+              AND IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Id')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Id'))), '') <> ''
+            ON DUPLICATE KEY UPDATE
+                shipper_no=VALUES(shipper_no), order_no=VALUES(order_no), order_line=VALUES(order_line),
+                customer_no=VALUES(customer_no), item_code=VALUES(item_code), item_name=VALUES(item_name),
+                qty_to_ship=VALUES(qty_to_ship), picking_qty=VALUES(picking_qty), real_qty=VALUES(real_qty),
+                actual_ship_date=VALUES(actual_ship_date), site=VALUES(site), status=VALUES(status),
+                confirm_status=VALUES(confirm_status), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
+                update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
+    }
+
+    private IEnumerable<S1MdpSqlCommand> BuildDwdCommands(string batchId, DateTime now)
+    {
+        yield return Cmd(
+            """
+            INSERT INTO dwd_ship_trans
+            (tenant_id, factory_id, company_id, stat_date, order_id, order_entry_id, order_no, order_line, customer_no,
+             customer_name, country, item_code, item_name, item_spec, order_qty, planned_ship_qty, shipped_qty,
+             remaining_qty, order_date, customer_request_date, plan_delivery_date, promised_delivery_date,
+             plan_ship_date, actual_ship_date, review_status, order_status, delivery_status, linkage_status, risk_level,
+             source_system, source_table, source_row_id, source_biz_key, sync_batch_id, calc_batch_id, calc_time)
+            SELECT
+                so.tenant_id,
+                so.factory_id,
+                so.company_id,
+                @StatDate,
+                so.order_id,
+                so.order_entry_id,
+                so.order_no,
+                IFNULL(so.order_line, ''),
+                so.customer_no,
+                so.customer_name,
+                so.country,
+                IFNULL(so.item_code, ''),
+                so.item_name,
+                so.item_spec,
+                IFNULL(so.order_qty, 0),
+                IFNULL(p.plan_qty, 0),
+                IFNULL(a.real_qty, 0),
+                GREATEST(IFNULL(so.order_qty, 0) - IFNULL(a.real_qty, 0), 0),
+                so.order_date,
+                so.customer_request_date,
+                so.plan_delivery_date,
+                so.promised_delivery_date,
+                p.plan_ship_date,
+                a.actual_ship_date,
+                so.review_status,
+                so.order_status,
+                CASE
+                    WHEN IFNULL(so.order_qty, 0) > 0 AND IFNULL(a.real_qty, 0) >= IFNULL(so.order_qty, 0) THEN 'COMPLETED'
+                    WHEN COALESCE(p.plan_ship_date, so.promised_delivery_date, so.plan_delivery_date) < @Now THEN 'DELAYED'
+                    WHEN IFNULL(p.plan_qty, 0) > 0 THEN 'PLANNED'
+                    ELSE 'OPEN'
+                END,
+                l.linkage_status,
+                CASE
+                    WHEN COALESCE(p.plan_ship_date, so.promised_delivery_date, so.plan_delivery_date) < @Now
+                         AND IFNULL(a.real_qty, 0) < IFNULL(so.order_qty, 0) THEN 'HIGH'
+                    WHEN IFNULL(a.real_qty, 0) < IFNULL(so.order_qty, 0) THEN 'MEDIUM'
+                    ELSE 'LOW'
+                END,
+                'AIDOP',
+                so.source_table,
+                so.source_row_id,
+                so.source_biz_key,
+                so.sync_batch_id,
+                @BatchId,
+                @Now
+            FROM mdp_std_so so
+            LEFT JOIN (
+                SELECT tenant_id, order_no, IFNULL(order_line, '') AS order_line, IFNULL(item_code, '') AS item_code,
+                       SUM(IFNULL(plan_qty, IFNULL(qty, 0))) AS plan_qty,
+                       MIN(plan_ship_date) AS plan_ship_date
+                FROM mdp_std_ship_trans
+                WHERE trans_type='SHIP_PLAN'
+                GROUP BY tenant_id, order_no, IFNULL(order_line, ''), IFNULL(item_code, '')
+            ) p ON so.tenant_id=p.tenant_id AND so.order_no=p.order_no AND IFNULL(so.order_line, '')=p.order_line AND IFNULL(so.item_code, '')=p.item_code
+            LEFT JOIN (
+                SELECT tenant_id, order_no, IFNULL(order_line, '') AS order_line, IFNULL(item_code, '') AS item_code,
+                       SUM(IFNULL(real_qty, IFNULL(qty_to_ship, 0))) AS real_qty,
+                       MAX(actual_ship_date) AS actual_ship_date
+                FROM mdp_std_ship_trans
+                WHERE trans_type='ASN_SHIPPER'
+                GROUP BY tenant_id, order_no, IFNULL(order_line, ''), IFNULL(item_code, '')
+            ) a ON so.tenant_id=a.tenant_id AND so.order_no=a.order_no AND IFNULL(so.order_line, '')=a.order_line AND IFNULL(so.item_code, '')=a.item_code
+            LEFT JOIN (
+                SELECT tenant_id, order_no, item_code, MAX(linkage_status) AS linkage_status
+                FROM mdp_std_ship_trans
+                WHERE IFNULL(linkage_status, '') <> ''
+                GROUP BY tenant_id, order_no, item_code
+            ) l ON so.tenant_id=l.tenant_id AND so.order_no=l.order_no AND IFNULL(so.item_code, '')=IFNULL(l.item_code, '')
+            WHERE IFNULL(so.order_no, '') <> ''
+            ON DUPLICATE KEY UPDATE
+                customer_no=VALUES(customer_no), customer_name=VALUES(customer_name), item_name=VALUES(item_name),
+                order_qty=VALUES(order_qty), planned_ship_qty=VALUES(planned_ship_qty), shipped_qty=VALUES(shipped_qty),
+                remaining_qty=VALUES(remaining_qty), plan_ship_date=VALUES(plan_ship_date), actual_ship_date=VALUES(actual_ship_date),
+                review_status=VALUES(review_status), order_status=VALUES(order_status), delivery_status=VALUES(delivery_status),
+                linkage_status=VALUES(linkage_status), risk_level=VALUES(risk_level), sync_batch_id=VALUES(sync_batch_id),
+                calc_batch_id=VALUES(calc_batch_id), calc_time=VALUES(calc_time), update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
+    }
+
+    private async Task<long> InsertSyncLogAsync(long entityId, string entityName, string batchId, int rowsRead)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_sync_log
+            (tenant_id, entity_id, source_code, entity_name, sync_batch_id, sync_type, trigger_type, sync_start, rows_read, status)
+            VALUES (0, @EntityId, 'AIDOPDEV_MYSQL', @EntityName, @BatchId, 'FULL', 'AUTO', NOW(), @RowsRead, 'RUNNING')
+            """,
+            new SugarParameter("@EntityId", entityId),
+            new SugarParameter("@EntityName", entityName),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@RowsRead", rowsRead));
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_sync_log WHERE sync_batch_id=@BatchId AND entity_id=@EntityId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter>
+            {
+                new("@BatchId", batchId),
+                new("@EntityId", entityId)
+            });
+    }
+
+    private async Task MarkSyncLogSuccessAsync(long logId, DateTime started, int affected)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_sync_log
+            SET sync_end=NOW(), duration_ms=@DurationMs, rows_insert=@RowsInsert, rows_update=0, rows_skip=0, rows_error=0, status='SUCCESS'
+            WHERE id=@Id
+            """,
+            new SugarParameter("@DurationMs", (int)(DateTime.Now - started).TotalMilliseconds),
+            new SugarParameter("@RowsInsert", affected),
+            new SugarParameter("@Id", logId));
+    }
+
+    private async Task MarkSyncLogFailedAsync(long logId, DateTime started, string message)
+    {
+        try
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE mdp_sync_log
+                SET sync_end=NOW(), duration_ms=@DurationMs, rows_error=1, status='FAILED', error_msg=@ErrorMsg
+                WHERE id=@Id
+                """,
+                new SugarParameter("@DurationMs", (int)(DateTime.Now - started).TotalMilliseconds),
+                new SugarParameter("@ErrorMsg", Truncate(message, 1000)),
+                new SugarParameter("@Id", logId));
+        }
+        catch (Exception ex)
+        {
+            // 写库自身失败兜底:避免再抛掩盖原异常;遗留 RUNNING 行可由运维手动清理
+            Console.Error.WriteLine($"[S1MdpSyncTransform] MarkSyncLogFailed write failed (syncLogId={logId}): {ex.Message}");
+        }
+    }
+
+    private async Task<long> InsertTransformRunLogAsync(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, 'S1 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 MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S1MdpSyncTransformResult 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=@DwdRows,
+                summary_json=@SummaryJson, 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.StandardRows),
+            new SugarParameter("@DwdRows", result.DwdRows),
+            new SugarParameter("@SummaryJson", BuildRunSummaryJson(result)),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        try
+        {
+            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", Truncate(message, 2000)),
+                new SugarParameter("@Id", runLogId));
+        }
+        catch (Exception ex)
+        {
+            // 写库自身失败兜底(典型场景:远端 MySQL 瞬断导致 MarkFailed 自身也连不上):
+            // 避免再抛二次异常掩盖原错;遗留 RUNNING 行可由运维手动清理。
+            Console.Error.WriteLine($"[S1MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
+        }
+    }
+
+    private static S1MdpSqlCommand Cmd(string sql, string batchId, DateTime now)
+    {
+        return new S1MdpSqlCommand(sql, new[]
+        {
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@StatDate", now.Date)
+        });
+    }
+
+    private static string BuildJsonObjectExpression(IEnumerable<string> columns)
+    {
+        var parts = columns.SelectMany(c => new[] { $"'{c.Replace("'", "''")}'", $"s.`{c}`" });
+        return $"JSON_OBJECT({string.Join(",", parts)})";
+    }
+
+    private static string BuildOptionalColumnExpr(IReadOnlyCollection<string> columns, string expected, string fallback)
+    {
+        return columns.Any(u => string.Equals(u, expected, StringComparison.OrdinalIgnoreCase))
+            ? $"s.`{FindColumn(columns, expected)}`"
+            : fallback;
+    }
+
+    private static string FindColumn(IEnumerable<string> columns, string expected)
+    {
+        return columns.First(u => string.Equals(u, expected, StringComparison.OrdinalIgnoreCase));
+    }
+
+    private static string NormalizeTriggerType(string? triggerType)
+    {
+        return string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
+    }
+
+    private static string BuildRunSummaryJson(S1MdpSyncTransformResult result)
+    {
+        return $$"""{"batchId":"{{result.BatchId}}","stageRows":{{result.StageRows}},"standardRows":{{result.StandardRows}},"dwdRows":{{result.DwdRows}},"kpiRows":{{result.KpiRows}}}""";
+    }
+
+    private static string ResolveKpiValueTable(int metricLevel)
+    {
+        return metricLevel switch
+        {
+            1 => "ado_s9_kpi_value_l1_day",
+            2 => "ado_s9_kpi_value_l2_day",
+            3 => "ado_s9_kpi_value_l3_day",
+            4 => "ado_s9_kpi_value_l4_day",
+            _ => "ado_s9_kpi_value_l2_day"
+        };
+    }
+
+    private static decimal DefaultS1Target(string metricCode)
+    {
+        return metricCode switch
+        {
+            "S1_L1_001" or "S1_L2_010" => 3m,
+            "S1_L1_002" or "S1_L2_011" => 95m,
+            "S1_L1_003" or "S1_L2_012" => 100m,
+            _ => 0m
+        };
+    }
+
+    private static string ResolveKpiStatus(decimal actual, decimal target, string? direction, decimal? yellowThreshold, decimal? redThreshold)
+    {
+        if (target <= 0) return "gray";
+
+        var ratio = actual / target * 100m;
+        if (string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase))
+        {
+            if (actual <= target) return "green";
+            if (ratio <= (yellowThreshold ?? 110m)) return "yellow";
+            return ratio >= (redThreshold ?? 120m) ? "red" : "yellow";
+        }
+
+        if (actual >= target) return "green";
+        if (ratio >= (yellowThreshold ?? 95m)) return "yellow";
+        return ratio <= (redThreshold ?? 80m) ? "red" : "yellow";
+    }
+
+    private static string ResolveTrendFlag(decimal actual, decimal? previous)
+    {
+        if (previous == null) return "flat";
+        if (actual > previous.Value) return "up";
+        if (actual < previous.Value) return "down";
+        return "flat";
+    }
+
+    private static string Truncate(string? raw, int maxLength)
+    {
+        if (string.IsNullOrEmpty(raw)) return string.Empty;
+        return raw.Length <= maxLength ? raw : raw[..maxLength];
+    }
+
+    private sealed class S1ColumnRow
+    {
+        public string ColumnName { get; set; } = string.Empty;
+    }
+
+    private sealed class S1MdpEntityRow
+    {
+        public long Id { get; set; }
+        public string EntityName { get; set; } = string.Empty;
+    }
+
+    private sealed class S1KpiCalcRow
+    {
+        public long TenantId { get; set; }
+        public long FactoryId { get; set; }
+        public string MetricCode { get; set; } = string.Empty;
+        public decimal? MetricValue { get; set; }
+    }
+
+    private sealed class S1KpiMetaRow
+    {
+        public int MetricLevel { get; set; }
+        public string Direction { get; set; } = "higher_is_better";
+        public decimal? YellowThreshold { get; set; }
+        public decimal? RedThreshold { get; set; }
+    }
+
+    private sealed class S1KpiValueRow
+    {
+        public long Id { get; set; }
+        public decimal? MetricValue { get; set; }
+        public decimal? TargetValue { get; set; }
+    }
+}
+
+public sealed class S1MdpSyncTransformResult
+{
+    public long RunLogId { get; set; }
+    public string BatchId { get; set; } = string.Empty;
+    public int StageRows { get; set; }
+    public int StandardRows { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+}
+
+internal sealed record S1MdpSqlCommand(string Sql, SugarParameter[] Parameters);
+
+internal sealed record S1MdpEntityConfig(
+    string EntityCode,
+    string SourceTable,
+    string TargetTable,
+    string SourceRowIdExpression,
+    string SourceBizKeyExpression)
+{
+    public static readonly IReadOnlyList<S1MdpEntityConfig> All = new List<S1MdpEntityConfig>
+    {
+        new("S1_SEORDER", "crm_seorder", "mdp_stg_so", "Id", "COALESCE(s.`bill_no`, CAST(s.`Id` AS CHAR))"),
+        new("S1_SEORDER_ENTRY", "crm_seorderentry", "mdp_stg_so", "Id", "CONCAT(IFNULL(s.`bill_no`,''), ':', IFNULL(s.`entry_seq`, CAST(s.`Id` AS CHAR)))"),
+        new("S1_SEORDER_CHANGE", "crm_seorder_change", "mdp_stg_so", "Id", "CONCAT(IFNULL(s.`bill_no`,''), ':', CAST(s.`Id` AS CHAR))"),
+        new("S1_CONTRACT_REVIEW", "ado_contract_review", "mdp_stg_so", "RecID", "COALESCE(s.`BillNo`, CAST(s.`RecID` AS CHAR))"),
+        new("S1_CONTRACT_REVIEW_FLOW", "ado_contract_review_flow", "mdp_stg_so", "RecID", "CONCAT(IFNULL(s.`ReviewBillNo`,''), ':', IFNULL(s.`StageNo`,''), ':', CAST(s.`RecID` AS CHAR))"),
+        new("S1_SHIPPING_PLAN", "ShippingPlan", "mdp_stg_ship_trans", "RecID", "COALESCE(s.`LotSerial`, CAST(s.`RecID` AS CHAR))"),
+        new("S1_SHIPPING_PLAN_DETAIL", "ShippingPlanDetail", "mdp_stg_ship_trans", "RecID", "CONCAT(IFNULL(s.`plan_id`,''), ':', IFNULL(s.`OrdNbr`,''), ':', CAST(s.`RecID` AS CHAR))"),
+        new("S1_ASN_SHIPPER_MASTER", "ASNBOLShipperMaster", "mdp_stg_ship_trans", "RecID", "COALESCE(s.`Id`, CONCAT(IFNULL(s.`OrdNbr`,''), ':', CAST(s.`RecID` AS CHAR)))"),
+        new("S1_ASN_SHIPPER_DETAIL", "ASNBOLShipperDetail", "mdp_stg_ship_trans", "RecID", "CONCAT(IFNULL(s.`Id`,''), ':', IFNULL(s.`Line`, CAST(s.`RecID` AS CHAR)))"),
+        new("S1_LINKAGE_PLAN", "LinkagePlan", "mdp_stg_ship_trans", "id", "CONCAT(IFNULL(s.`bill_no`,''), ':', IFNULL(s.`item_number`,''), ':', CAST(s.`id` AS CHAR))")
+    };
+}

+ 33 - 40
server/Plugins/Admin.NET.Plugin.AiDOP/Order/SeOrderService.cs

@@ -45,21 +45,21 @@ public class SeOrderService : IDynamicApiController, ITransient
     {
         var tenantId = _userManager.TenantId;
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
-        var innerConditions = new List<string> { "a.IsDeleted = 0", "a.tenant_id = @TenantId" };
+        var innerConditions = new List<string> { "s.deleted_flag = 0", "s.tenant_id = @TenantId" };
 
         if (!string.IsNullOrWhiteSpace(input.BillNo))
         {
-            innerConditions.Add("a.bill_no LIKE @BillNo");
+            innerConditions.Add("s.order_no LIKE @BillNo");
             pars.Add(new SugarParameter("@BillNo", $"%{input.BillNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.CustomNo))
         {
-            innerConditions.Add("a.custom_no LIKE @CustomNo");
+            innerConditions.Add("s.customer_no LIKE @CustomNo");
             pars.Add(new SugarParameter("@CustomNo", $"%{input.CustomNo.Trim()}%"));
         }
         if (input.OrderType.HasValue)
         {
-            innerConditions.Add("a.order_type = @OrderType");
+            innerConditions.Add("s.order_type = @OrderType");
             pars.Add(new SugarParameter("@OrderType", input.OrderType.Value));
         }
 
@@ -84,53 +84,46 @@ public class SeOrderService : IDynamicApiController, ITransient
 
     private static string BuildListBaseSql(string innerWhere) => $"""
         SELECT
-            a.id               AS Id,
-            a.bill_no          AS BillNo,
-            a.order_type       AS OrderType,
-            a.custom_no        AS CustomNo,
-            b.SortName         AS SortName,
-            a.rdate            AS RDate,
-            a.flowstate        AS FlowState,
-            entry.progress     AS Progress,
+            MIN(s.order_id)    AS Id,
+            s.order_no         AS BillNo,
+            CAST(MAX(s.order_type) AS SIGNED) AS OrderType,
+            MAX(s.customer_no) AS CustomNo,
+            MAX(s.customer_name) AS SortName,
+            MIN(s.customer_request_date) AS RDate,
+            MAX(s.flow_state)  AS FlowState,
+            MIN(s.progress)    AS Progress,
             CASE
-                WHEN entry.progress = '1' THEN '新建'
-                WHEN entry.progress = '2' THEN '评审'
-                WHEN entry.progress = '0' THEN '再评审'
-                WHEN entry.progress = '3' THEN '确认'
+                WHEN MIN(s.progress) = '1' THEN '新建'
+                WHEN MIN(s.progress) = '2' THEN '评审'
+                WHEN MIN(s.progress) = '0' THEN '再评审'
+                WHEN MIN(s.progress) = '3' THEN '确认'
                 ELSE '确认'
             END                AS State,
             chg.change_content AS ChangeContent,
             cN.changeNum       AS ChangeNum
-        FROM crm_seorder a
-        LEFT JOIN CustMaster b ON a.custom_no = b.Cust
+        FROM mdp_std_so s
         LEFT JOIN (
-            SELECT sorderid, MAX(create_time) AS create_time
-            FROM b_examine_result
-            GROUP BY sorderid
-        ) d ON a.id = d.sorderid
-        LEFT JOIN (
-            SELECT bill_no, change_content
+            SELECT order_no, change_content
             FROM (
-                SELECT bill_no, change_content, update_time,
-                       ROW_NUMBER() OVER (PARTITION BY bill_no ORDER BY update_time DESC) AS RowNum
-                FROM crm_seorder_change
-                WHERE bill_no IS NOT NULL
+                SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) AS order_no,
+                       JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.change_content')) AS change_content,
+                       sync_time,
+                       ROW_NUMBER() OVER (PARTITION BY JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) ORDER BY sync_time DESC, id DESC) AS RowNum
+                FROM mdp_stg_so
+                WHERE source_table='crm_seorder_change'
+                  AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) IS NOT NULL
             ) ranked
             WHERE RowNum = 1
-        ) chg ON chg.bill_no = a.bill_no
-        LEFT JOIN (
-            SELECT COUNT(*) AS changeNum, bill_no
-            FROM crm_seorder_change
-            WHERE bill_no IS NOT NULL
-            GROUP BY bill_no
-        ) cN ON cN.bill_no = a.bill_no
+        ) chg ON chg.order_no = s.order_no
         LEFT JOIN (
-            SELECT seorder_id, progress,
-                   ROW_NUMBER() OVER (PARTITION BY seorder_id ORDER BY Id) AS rn
-            FROM crm_seorderentry
-            WHERE IsDeleted = 0
-        ) entry ON a.id = entry.seorder_id AND entry.rn = 1
+            SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) AS order_no, COUNT(*) AS changeNum
+            FROM mdp_stg_so
+            WHERE source_table='crm_seorder_change'
+              AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) IS NOT NULL
+            GROUP BY JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no'))
+        ) cN ON cN.order_no = s.order_no
         WHERE {innerWhere}
+        GROUP BY s.tenant_id, s.order_no, chg.change_content, cN.changeNum
         """;
 
     // ══════════════════════════════════════════════════════════════

+ 187 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ShippingPlanService.cs

@@ -13,17 +13,23 @@ public class ShippingPlanService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly SqlSugarRepository<ShippingPlan> _planRep;
     private readonly SqlSugarRepository<ShippingPlanDetail> _detailRep;
+    private readonly SqlSugarRepository<AsnShipperMaster> _asnMasterRep;
+    private readonly SqlSugarRepository<AsnShipperDetail> _asnDetailRep;
     private readonly UserManager _userManager;
 
     public ShippingPlanService(
         ISqlSugarClient db,
         SqlSugarRepository<ShippingPlan> planRep,
         SqlSugarRepository<ShippingPlanDetail> detailRep,
+        SqlSugarRepository<AsnShipperMaster> asnMasterRep,
+        SqlSugarRepository<AsnShipperDetail> asnDetailRep,
         UserManager userManager)
     {
         _db = db;
         _planRep = planRep;
         _detailRep = detailRep;
+        _asnMasterRep = asnMasterRep;
+        _asnDetailRep = asnDetailRep;
         _userManager = userManager;
     }
 
@@ -299,22 +305,194 @@ public class ShippingPlanService : IDynamicApiController, ITransient
     // ══════════════════════════════════════════════════════════════
     // 销售出库 POST /api/Order/shippingplan/ship
     // ══════════════════════════════════════════════════════════════
-    /// <summary>销售出库(调用存储过程 pr_WMS_AutoCreateShipper)📦</summary>
+    /// <summary>销售出库(按出货计划生成 ASN 发货单)📦</summary>
     [DisplayName("销售出库")]
     [ApiDescriptionSettings(Name = "ShipShippingPlan"), HttpPost("shippingplan/ship")]
     public async Task<object> ShipShippingPlan([FromBody] ShippingPlanShipInput input)
     {
-        var orgNo = ""; // 组织编号,可从配置或 UserManager 获取
+        var ids = ParseIds(input.Ids);
+        if (ids.Count == 0)
+            throw Oops.Oh("ids 不能为空");
+
+        var tenantId = _userManager.TenantId;
         var account = _userManager.Account ?? "system";
-        var ids = input.Ids;
+        var now = DateTime.Now;
+
+        var plans = await _planRep.GetListAsync(u => ids.Contains(u.RecID) && u.TenantId == tenantId);
+        var planIds = plans.Select(u => u.RecID).ToHashSet();
+        var details = await _detailRep.GetListAsync(u =>
+            u.TenantId == tenantId &&
+            ((u.PlanId.HasValue && planIds.Contains(u.PlanId.Value)) || ids.Contains(u.RecID)));
+
+        if (details.Count == 0)
+            throw Oops.Oh("未找到可出库的出货计划明细");
+
+        var missingPlanIds = details
+            .Where(u => u.PlanId.HasValue && !planIds.Contains(u.PlanId.Value))
+            .Select(u => u.PlanId!.Value)
+            .Distinct()
+            .ToList();
+        if (missingPlanIds.Count > 0)
+        {
+            var extraPlans = await _planRep.GetListAsync(u => missingPlanIds.Contains(u.RecID) && u.TenantId == tenantId);
+            plans.AddRange(extraPlans);
+            planIds = plans.Select(u => u.RecID).ToHashSet();
+        }
+
+        var planById = plans.ToDictionary(u => u.RecID);
+        var shippableRows = details
+            .Where(u => u.PlanId.HasValue && planById.ContainsKey(u.PlanId.Value))
+            .Where(u => !string.IsNullOrWhiteSpace(u.BillNo) && !string.IsNullOrWhiteSpace(u.ItemNum))
+            .ToList();
+        if (shippableRows.Count == 0)
+            throw Oops.Oh("出货计划明细缺少订单号或物料编号,不能出库");
+
+        var skipped = 0;
+        var createdMasters = 0;
+        var createdDetails = 0;
+        var createdShipIds = new List<string>();
+
+        _db.Ado.BeginTran();
+        try
+        {
+            foreach (var group in shippableRows.GroupBy(u => u.BillNo!.Trim()))
+            {
+                var rows = new List<ShippingPlanDetail>();
+                foreach (var row in group)
+                {
+                    var exists = await _asnDetailRep.IsAnyAsync(u =>
+                        u.TenantId == tenantId &&
+                        u.ShType == "SH" &&
+                        u.Typed != "S" &&
+                        u.IsActive == 1 &&
+                        u.OrdNbr == row.BillNo &&
+                        u.ContainerItem == row.ItemNum);
+                    if (exists)
+                    {
+                        skipped++;
+                        continue;
+                    }
+
+                    rows.Add(row);
+                }
+
+                if (rows.Count == 0)
+                    continue;
+
+                var first = rows[0];
+                var firstPlan = planById[first.PlanId!.Value];
+                var shipId = await GenerateShipIdAsync(tenantId);
+                var master = new AsnShipperMaster
+                {
+                    Domain = firstPlan.Domain,
+                    Id = shipId,
+                    OrdNbr = group.Key,
+                    SoldTo = first.CustomNo,
+                    ShipDate = firstPlan.ShippingDate,
+                    Status = string.Empty,
+                    Remark = $"由出货计划生成:{string.Join(",", rows.Select(u => u.RecID))}",
+                    ShType = "SH",
+                    Typed = string.Empty,
+                    IsActive = 1,
+                    IsConfirm = 0,
+                    Site = firstPlan.ShippingSite,
+                    GrossWeight = rows.Sum(u => u.Weight ?? 0),
+                    NetWeight = rows.Sum(u => u.Weight ?? 0),
+                    Volume = rows.Sum(u => u.Volume ?? 0),
+                    CreateUser = account,
+                    CreateTime = now,
+                    TenantId = tenantId
+                };
+
+                var inserted = await _asnMasterRep.AsInsertable(master).ExecuteReturnEntityAsync();
+                createdMasters++;
+                createdShipIds.Add(shipId);
+
+                for (var i = 0; i < rows.Count; i++)
+                {
+                    var row = rows[i];
+                    var plan = planById[row.PlanId!.Value];
+                    var detail = new AsnShipperDetail
+                    {
+                        Domain = row.Domain ?? plan.Domain,
+                        Id = shipId,
+                        AsnShipperRecID = inserted.RecID,
+                        Line = i + 1,
+                        OrdNbr = row.BillNo,
+                        ContainerItem = row.ItemNum,
+                        Descr = row.ItemName,
+                        LotSerial = plan.LotSerial,
+                        QtyToShip = row.Qty,
+                        PickingQty = null,
+                        RealQty = null,
+                        Status = string.Empty,
+                        Remark = BuildShipDetailRemark(row),
+                        ShType = "SH",
+                        Typed = string.Empty,
+                        IsActive = 1,
+                        IsConfirm = 0,
+                        ShipDate = plan.ShippingDate,
+                        Site = plan.ShippingSite,
+                        CreateUser = account,
+                        CreateTime = now,
+                        TenantId = tenantId
+                    };
 
-        await _db.Ado.ExecuteCommandAsync(
-            "CALL pr_WMS_AutoCreateShipper(@OrgNo, @Account, @Ids)",
-            new SugarParameter("@OrgNo", orgNo),
-            new SugarParameter("@Account", account),
-            new SugarParameter("@Ids", ids));
+                    await _asnDetailRep.InsertAsync(detail);
+                    createdDetails++;
+                }
+            }
+
+            _db.Ado.CommitTran();
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+
+        if (createdDetails == 0)
+            return new { message = "勾选明细已生成过销售出库单", createdMasters, createdDetails, skipped };
+
+        return new { message = "销售出库执行成功", createdMasters, createdDetails, skipped, shipIds = createdShipIds };
+    }
+
+    private static List<int> ParseIds(string ids)
+    {
+        return ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+            .Select(u => int.TryParse(u, out var id) ? id : 0)
+            .Where(u => u > 0)
+            .Distinct()
+            .ToList();
+    }
+
+    private static string BuildShipDetailRemark(ShippingPlanDetail row)
+    {
+        var source = $"来源出货计划明细:{row.RecID}";
+        return string.IsNullOrWhiteSpace(row.Remark) ? source : $"{row.Remark}; {source}";
+    }
+
+    /// <summary>
+    /// 生成发货单号:SH + yyyyMMdd + 4位流水号,例如 SH202605210001。
+    /// </summary>
+    private async Task<string> GenerateShipIdAsync(long tenantId)
+    {
+        var today = DateTime.Now.ToString("yyyyMMdd");
+        var prefix = $"SH{today}";
+
+        var maxId = await _db.Queryable<AsnShipperMaster>()
+            .Where(u => u.TenantId == tenantId && u.Id != null && u.Id.StartsWith(prefix))
+            .MaxAsync(u => u.Id);
+
+        var next = 1;
+        if (!string.IsNullOrWhiteSpace(maxId) &&
+            maxId.Length >= prefix.Length + 4 &&
+            int.TryParse(maxId[^4..], out var current))
+        {
+            next = current + 1;
+        }
 
-        return new { message = "销售出库执行成功" };
+        return $"{prefix}{next:D4}";
     }
 
     // ──────────────── 内部查询结果映射类 ────────────────

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -298,6 +298,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             (1320990000403L, "/aidop/data-platform/sources", "aidopDataPlatformSources", "数据源管理", "/aidop/data-platform/sources", "ele-Coin", 30, "外部系统、数据库与 API 连接配置入口"),
             (1320990000404L, "/aidop/data-platform/sync-tasks", "aidopDataPlatformSyncTasks", "数据任务管理", "/aidop/data-platform/syncTasks", "ele-Operation", 40, "DB/API 入站出站任务与 Admin.NET 调度任务关联入口"),
             (1320990000405L, "/aidop/data-platform/sync-logs", "aidopDataPlatformSyncLogs", "数据任务日志", "/aidop/data-platform/syncLogs", "ele-Tickets", 50, "数据任务批次日志与异常样本入口"),
+            (1320990000406L, "/aidop/data-platform/mdp-monitor", "aidopDataPlatformMdpMonitor", "MDP运行监控", "/aidop/data-platform/mdpMonitor", "ele-Monitor", 60, "统一查看 S1/S3 等模块 MDP 同步与转换运行状态"),
         };
 
         foreach (var (id, path, name, title, component, icon, order, remark) in children)

+ 37 - 21
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

@@ -770,15 +770,23 @@ public class S3MdpSyncTransformService : ITransient
 
     private async Task MarkSyncLogFailedAsync(long logId, DateTime started, string message)
     {
-        await _db.Ado.ExecuteCommandAsync(
-            """
-            UPDATE mdp_sync_log
-            SET sync_end=NOW(), duration_ms=@DurationMs, rows_error=1, status='FAILED', error_msg=@ErrorMsg
-            WHERE id=@Id
-            """,
-            new SugarParameter("@DurationMs", (int)(DateTime.Now - started).TotalMilliseconds),
-            new SugarParameter("@ErrorMsg", message.Length <= 1000 ? message : message[..1000]),
-            new SugarParameter("@Id", logId));
+        try
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE mdp_sync_log
+                SET sync_end=NOW(), duration_ms=@DurationMs, rows_error=1, status='FAILED', error_msg=@ErrorMsg
+                WHERE id=@Id
+                """,
+                new SugarParameter("@DurationMs", (int)(DateTime.Now - started).TotalMilliseconds),
+                new SugarParameter("@ErrorMsg", message.Length <= 1000 ? message : message[..1000]),
+                new SugarParameter("@Id", logId));
+        }
+        catch (Exception ex)
+        {
+            // 写库自身失败兜底:避免再抛掩盖原异常;遗留 RUNNING 行可由运维手动清理
+            Console.Error.WriteLine($"[S3MdpSyncTransform] MarkSyncLogFailed write failed (syncLogId={logId}): {ex.Message}");
+        }
     }
 
     private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType)
@@ -819,18 +827,26 @@ public class S3MdpSyncTransformService : ITransient
 
     private async Task MarkTransformRunFailedAsync(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", Truncate(message, 2000)),
-            new SugarParameter("@Id", runLogId));
+        try
+        {
+            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", Truncate(message, 2000)),
+                new SugarParameter("@Id", runLogId));
+        }
+        catch (Exception ex)
+        {
+            // 写库自身失败兜底(典型场景:远端 MySQL 瞬断导致 MarkFailed 自身也连不上)
+            Console.Error.WriteLine($"[S3MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
+        }
     }
 
     private static S3MdpSqlCommand Cmd(string sql, string batchId, DateTime now)