Browse Source

feat(aidop): complete S2 MDP dashboard integration

Extend S2 with the same MDP-to-KPI path used by S1 and finish the manufacturing collaboration dashboard with live aggregates, refresh, export, and acceptance notes. Bumps Web 2.4.170 and server 1.0.133.

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 2 tháng trước cách đây
mục cha
commit
b6d6367aa3

+ 1 - 1
Web/package.json

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

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

@@ -232,9 +232,57 @@ export interface ModuleDetailPayload {
 	moduleCode: string;
 	l2: ModuleKpiRow[];
 	l3: ModuleKpiRow[];
+	syncStatus?: S2SyncStatus | null;
+	decomposition?: S2DecompositionSection[];
+	trend?: S2TrendPoint[];
+	distribution?: S2DistributionRow[];
+	schedules?: S2ScheduleRow[];
 	alerts: Array<{ time: string; message: string; level: string }>;
 }
 
+export interface S2ScheduleRow {
+	orderNo?: string;
+	customer?: string;
+	product?: string;
+	productionLine?: string;
+	quantity?: number;
+	deliveryDate?: string;
+	startTime?: string;
+	cycleDays?: number;
+	satisfaction?: number;
+	status?: string;
+}
+
+export interface S2SyncStatus {
+	batchId?: string;
+	status?: string;
+	stageRows?: number;
+	standardRows?: number;
+	dwdRows?: number;
+	startTime?: string;
+	endTime?: string;
+	errorMessage?: string;
+}
+
+export interface S2DecompositionSection {
+	title: string;
+	active?: boolean;
+	metrics: string[];
+}
+
+export interface S2TrendPoint {
+	dateLabel?: string;
+	cycleDays?: number;
+	satisfactionPct?: number;
+}
+
+export interface S2DistributionRow {
+	name?: string;
+	satisfactionPct?: number;
+	totalCount?: number;
+	riskCount?: number;
+}
+
 /** 与 smartOpsBaseQuery.baseQueryToApiParams 对齐,多余参数后端可忽略 */
 export type KanbanExtraQuery = Record<string, string>;
 
@@ -591,6 +639,35 @@ export async function fetchModuleDetail(
 	}
 }
 
+export async function refreshS2Mdp(): Promise<{
+	ok: boolean;
+	batchId?: string;
+	stageRows?: number;
+	standardRows?: number;
+	dwdRows?: number;
+	kpiRows?: number;
+	message?: string;
+}> {
+	try {
+		const res = await service.post('/api/AidopKanban/s2-mdp/refresh');
+		const d = res.data ?? {};
+		return {
+			ok: Boolean(d.ok),
+			batchId: d.batchId,
+			stageRows: d.stageRows,
+			standardRows: d.standardRows,
+			dwdRows: d.dwdRows,
+			kpiRows: d.kpiRows,
+		};
+	} catch (e: any) {
+		const msg =
+			e?.response?.data?.message ??
+			(typeof e?.response?.data === 'string' ? e.response.data : undefined) ??
+			e?.message;
+		return { ok: false, message: typeof msg === 'string' ? msg : '刷新失败' };
+	}
+}
+
 // ───────────── 通用运营看板接口(S1~S9 除 S8,基于 KpiMaster + LayoutItem 动态) ─────────────
 
 /** 通用九宫格首页(L1)。返回与 S4HomeGridPayload 同构。 */

+ 40 - 14
Web/src/views/aidop/kanban/data/s2Kpis.ts

@@ -87,23 +87,49 @@ export const s2TrendSeries = reactive({
 
 let loaded = false;
 
-export async function loadS2Kpis(factoryId = 1): Promise<void> {
-  if (loaded) return;
+export async function loadS2Kpis(factoryId = 1, force = false): Promise<void> {
+  if (loaded && !force) return;
   try {
-    const res = await service.get('/api/AidopKanban/home-l1', { params: { factoryId } });
-    const list = (res.data ?? []) as Array<{ moduleCode?: string; metricValue?: number; targetValue?: number }>;
-    const s2 = list.find((x) => x.moduleCode === 'S2');
-    if (s2) {
-      const v = Number(s2.metricValue ?? 85);
-      const t = Number(s2.targetValue ?? 99);
-      s2HomeKpiList[0].valueText = `${v}%`;
-      s2HomeKpiList[0].targetText = `目标 ${t}%`;
-      s2DetailTopCards[0].valueMain = `${v}`;
-      s2DetailTopCards[0].barWidthPct = Math.max(5, Math.min(100, Math.round(v)));
-      s2TrendSeries.satisfactionPct = [90, 89, 88, 87, 86, 85.5, v];
-    }
+    const grid = await service.get('/api/AidopKanban/home-grid/S2', { params: { factoryId } });
+    const items = (grid.data?.items ?? []) as Array<{
+      metricCode?: string;
+      currentValue?: number;
+      targetValue?: number;
+      unit?: string;
+    }>;
+    const byCode = new Map(items.map((x) => [x.metricCode, x]));
+    applyCard('satisfaction', byCode.get('S2_L1_002'), '%');
+    applyCard('cycle', byCode.get('S2_L1_001'), '天');
+    applyCard('wip', byCode.get('S2_L1_004'), '天');
+    applyCard('labor', byCode.get('S2_L1_003'), '单/人·日');
+    const satisfaction = Number(byCode.get('S2_L1_002')?.currentValue ?? s2DetailTopCards[0].valueMain);
+    const cycle = Number(byCode.get('S2_L1_001')?.currentValue ?? s2DetailTopCards[1].valueMain);
+    s2TrendSeries.satisfactionPct = [90, 89, 88, 87, 86, 85.5, satisfaction];
+    s2TrendSeries.cycleDays = [20, 20.5, 21, 21.5, 22, 22, cycle];
     loaded = true;
   } catch {
     // keep defaults
   }
 }
+
+function applyCard(
+  key: 'satisfaction' | 'cycle' | 'wip' | 'labor',
+  row: { currentValue?: number; targetValue?: number; unit?: string } | undefined,
+  fallbackUnit: string
+) {
+  if (!row || row.currentValue == null) return;
+  const home = s2HomeKpiList.find((x) => x.key === key);
+  const detail = s2DetailTopCards.find((x) => x.key === key);
+  const value = Number(row.currentValue);
+  const target = row.targetValue == null ? undefined : Number(row.targetValue);
+  const unit = row.unit || fallbackUnit;
+  if (home) {
+    home.valueText = `${Number(value.toFixed(2))}${unit}`;
+    if (target != null) home.targetText = `目标 ${Number(target.toFixed(2))}${unit}`;
+  }
+  if (detail) {
+    detail.valueMain = `${Number(value.toFixed(2))}`;
+    detail.valueUnit = unit;
+    detail.barWidthPct = Math.max(5, Math.min(100, Math.round(unit === '%' ? value : target ? (value / target) * 100 : value)));
+  }
+}

+ 145 - 47
Web/src/views/aidop/kanban/s2.vue

@@ -20,8 +20,9 @@
           <el-button size="small" type="primary">本周</el-button>
           <el-button size="small">本月</el-button>
         </el-button-group>
-        <el-button size="small" circle><el-icon><Refresh /></el-icon></el-button>
-        <el-button size="small" circle><el-icon><Upload /></el-icon></el-button>
+        <el-tag v-if="syncStatusText" size="small" :type="syncStatusType">{{ syncStatusText }}</el-tag>
+        <el-button size="small" circle :loading="isRefreshing" @click="refreshS2Data"><el-icon><Refresh /></el-icon></el-button>
+        <el-button size="small" circle @click="exportScheduleRows"><el-icon><Upload /></el-icon></el-button>
         <el-button size="small" circle><el-icon><Setting /></el-icon></el-button>
       </div>
     </div>
@@ -75,37 +76,10 @@
           </div>
         </div>
         <div class="decomposition-cards">
-          <div class="decomp-card active">
-            <div class="decomp-title">订单排程</div>
+          <div v-for="section in decompositionCards" :key="section.title" class="decomp-card" :class="{ active: section.active }">
+            <div class="decomp-title">{{ section.title }}</div>
             <div class="decomp-metrics">
-              <div class="metric">1. 周期:22D</div>
-              <div class="metric">2. 满足率:85%</div>
-              <div class="metric">3. 库存:23D</div>
-              <div class="metric">4. 人效:18.5</div>
-            </div>
-          </div>
-          <div class="decomp-card">
-            <div class="decomp-title">工单排程</div>
-            <div class="decomp-metrics">
-              <div class="metric">1. 周期:1.2D</div>
-              <div class="metric">2. 满足率:94.5%</div>
-              <div class="metric">3. 人数:120/H</div>
-            </div>
-          </div>
-          <div class="decomp-card">
-            <div class="decomp-title">工序排程</div>
-            <div class="decomp-metrics">
-              <div class="metric">1. 周期:0.8D</div>
-              <div class="metric">2. 满足率:92.1%</div>
-              <div class="metric">3. 转产:2.5H</div>
-            </div>
-          </div>
-          <div class="decomp-card">
-            <div class="decomp-title">设备排程</div>
-            <div class="decomp-metrics">
-              <div class="metric">1. OEE:88.4%</div>
-              <div class="metric">2. 稼动率:91.2%</div>
-              <div class="metric">3. 停机:1.2%</div>
+              <div v-for="metric in section.metrics" :key="metric" class="metric">{{ metric }}</div>
             </div>
           </div>
         </div>
@@ -169,7 +143,7 @@
             <el-icon><Document /></el-icon>
             实时排程日志
           </span>
-          <span class="update-time">更新:约 3 秒前</span>
+          <span class="update-time">更新:{{ lastSyncTimeText }}</span>
         </div>
         <div class="log-list">
           <div v-for="(item, idx) in logItems" :key="`${item.time}-${idx}`" class="log-item" :class="item.levelClass">
@@ -197,7 +171,7 @@
               size="small"
               clearable
             />
-            <el-button type="primary" size="small">
+            <el-button type="primary" size="small" @click="exportScheduleRows">
               <el-icon><Download /></el-icon>
               导出排程表
             </el-button>
@@ -230,8 +204,8 @@
             </template>
           </el-table-column>
           <el-table-column label="操作" width="100" fixed="right">
-            <template #default>
-              <el-button link type="primary" size="small">详情</el-button>
+            <template #default="{ row }">
+              <el-button link type="primary" size="small" @click="showScheduleDetail(row)">详情</el-button>
             </template>
           </el-table-column>
         </el-table>
@@ -254,7 +228,7 @@ import {
 } from '@element-plus/icons-vue'
 import * as echarts from 'echarts'
 import { s2DetailTopCards, s2TrendSeries, loadS2Kpis } from './data/s2Kpis'
-import { fetchModuleDetail, fetchHomeGrid, fetchDetailKpis } from '../api/kanbanData'
+import { fetchModuleDetail, fetchHomeGrid, fetchDetailKpis, refreshS2Mdp } from '../api/kanbanData'
 import { AIDOP_LAYOUT_SAVED } from './utils/s4LayoutEvents'
 
 const MODULE_CODE = 'S2'
@@ -282,6 +256,22 @@ const router = useRouter()
 const currentTime = ref('')
 const layerView = ref('strategy')
 const searchText = ref('')
+const isRefreshing = ref(false)
+const syncStatus = ref(null)
+const lastSyncTimeText = ref('暂无同步')
+const syncStatusText = computed(() => {
+  if (!syncStatus.value) return '未同步'
+  const status = syncStatus.value.status || 'UNKNOWN'
+  const batch = syncStatus.value.batchId ? String(syncStatus.value.batchId).slice(-14) : ''
+  return `${status}${batch ? ` ${batch}` : ''}`
+})
+const syncStatusType = computed(() => {
+  const status = String(syncStatus.value?.status || '').toUpperCase()
+  if (status === 'SUCCESS') return 'success'
+  if (status === 'FAILED') return 'danger'
+  if (status === 'RUNNING') return 'warning'
+  return 'info'
+})
 
 const detailQuery = ref(emptySmartOpsBaseQuery())
 
@@ -302,6 +292,12 @@ const s2DetailCardIcons = {
 }
 
 const orderListAll = ref([])
+const decompositionCards = ref([
+  { title: '订单排程', active: true, metrics: ['1. 周期:--天', '2. 满足率:--%', '3. 在制库存:--天', '4. 人效:--单/人'] },
+  { title: '工单排程', metrics: ['1. 周期:--天', '2. 满足率:--%', '3. 人效:--单/人'] },
+  { title: '工序排程', metrics: ['1. 工序数:--', '2. 完成量:--', '3. 排程量:--'] },
+  { title: '资源排程', metrics: ['1. 人员数:--', '2. 异常数:--', '3. 产线数:--'] }
+])
 
 const filteredOrderList = computed(() => {
   const q = detailQuery.value
@@ -349,7 +345,7 @@ const initAlertGauge = () => {
   const chartDom = document.getElementById('alert-gauge')
   if (!chartDom) return
   
-  alertGauge = echarts.init(chartDom)
+  alertGauge = echarts.getInstanceByDom(chartDom) || echarts.init(chartDom)
   const option = {
     series: [{
       type: 'gauge',
@@ -382,7 +378,7 @@ const initTrendChart = () => {
   const chartDom = document.getElementById('chart-trend')
   if (!chartDom) return
   
-  trendChart = echarts.init(chartDom)
+  trendChart = echarts.getInstanceByDom(chartDom) || echarts.init(chartDom)
   const option = {
     tooltip: {
       trigger: 'axis',
@@ -460,7 +456,7 @@ const initBarChart = () => {
   const chartDom = document.getElementById('chart-bar')
   if (!chartDom) return
   
-  barChart = echarts.init(chartDom)
+  barChart = echarts.getInstanceByDom(chartDom) || echarts.init(chartDom)
   const option = {
     tooltip: {
       trigger: 'axis',
@@ -507,12 +503,36 @@ const initCharts = () => {
   })
 }
 
-onMounted(async () => {
-  window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
-  loadLayoutKpis()
-  await loadS2Kpis()
+async function loadS2DashboardData() {
   const detail = await fetchModuleDetail('S2')
-  orderListAll.value = (detail.l3 ?? []).slice(0, 8).map((x, idx) => {
+  syncStatus.value = detail.syncStatus || null
+  lastSyncTimeText.value = formatDateTime(syncStatus.value?.endTime || syncStatus.value?.startTime)
+  if (Array.isArray(detail.decomposition) && detail.decomposition.length > 0) {
+    decompositionCards.value = detail.decomposition.map((x, idx) => ({
+      title: x.title || `排程层级${idx + 1}`,
+      active: Boolean(x.active) || idx === 0,
+      metrics: Array.isArray(x.metrics) && x.metrics.length > 0 ? x.metrics : ['暂无数据']
+    }))
+  }
+  const scheduleRows = Array.isArray(detail.schedules) ? detail.schedules : []
+  orderListAll.value = scheduleRows.length > 0
+    ? scheduleRows.slice(0, 12).map((x, idx) => {
+      const sat = Math.max(0, Math.min(100, Math.round(Number(x.satisfaction ?? 0))))
+      return {
+        orderNo: x.orderNo || `#S2-${idx + 1}`,
+        customer: x.customer || x.product || `S2 排程 ${idx + 1}`,
+        product: x.product || '',
+        productionLine: x.productionLine || '',
+        quantity: `${Number(x.quantity ?? 0).toLocaleString()} PCS`,
+        deliveryDate: x.deliveryDate ? String(x.deliveryDate).slice(0, 10) : '--',
+        status: x.status || (sat >= 95 ? '已锁定' : sat >= 80 ? '排程中' : '资源异常'),
+        statusType: sat >= 95 ? 'success' : sat >= 80 ? '' : 'danger',
+        startTime: x.startTime ? String(x.startTime).replace('T', ' ').slice(0, 16) : '-- : --',
+        satisfaction: sat,
+        satisfactionClass: sat >= 95 ? 'success' : sat >= 80 ? 'primary' : 'danger'
+      }
+    })
+    : (detail.l3 ?? []).slice(0, 8).map((x, idx) => {
     const v = Number(x.metricValue ?? 0)
     const sat = Math.max(0, Math.min(100, Math.round(v)))
     return {
@@ -533,7 +553,7 @@ onMounted(async () => {
     time: x.time ?? '--:--:--',
     tag: String(x.level ?? 'info').toUpperCase(),
     message: x.message ?? '排程告警',
-    detail: '来源:S2 实时告警',
+    detail: '来源:S2 排程DWD/告警',
     levelClass: ['critical', 'high'].includes(String(x.level)) ? 'critical' : String(x.level) === 'warning' ? 'medium' : 'info'
   }))
   alertTotal.value = (detail.alerts ?? []).length
@@ -541,14 +561,92 @@ onMounted(async () => {
   alertWarning.value = (detail.alerts ?? []).filter((x) => String(x.level) === 'warning').length
   alertInfo.value = Math.max(0, alertTotal.value - alertCritical.value - alertWarning.value)
   const l2 = detail.l2 ?? []
-  if (l2.length > 0) {
+  const trendRows = Array.isArray(detail.trend) ? [...detail.trend].reverse() : []
+  if (trendRows.length > 0) {
+    trendXAxis.value = trendRows.map((x, i) => x.dateLabel || `D${i + 1}`)
+    s2TrendSeries.cycleDays = trendRows.map((x) => Number(Number(x.cycleDays ?? 0).toFixed(2)))
+    s2TrendSeries.satisfactionPct = trendRows.map((x) => Math.max(0, Math.min(100, Number(Number(x.satisfactionPct ?? 0).toFixed(2)))))
+  } else if (l2.length > 0) {
     const vals = l2.slice(0, 7).map((x) => Number(x.metricValue ?? 0))
     trendXAxis.value = l2.slice(0, 7).map((x, i) => (x.statDate ? String(x.statDate).slice(5, 10) : `D${i + 1}`))
     s2TrendSeries.cycleDays = vals.map((v) => Number((Math.max(v, 1) / 4).toFixed(1)))
     s2TrendSeries.satisfactionPct = vals.map((v) => Math.max(0, Math.min(100, Number(v.toFixed(1)))))
+  }
+  const distributionRows = Array.isArray(detail.distribution) ? detail.distribution : []
+  if (distributionRows.length > 0) {
+    barXAxis.value = distributionRows.map((x, i) => x.name || `线体${i + 1}`)
+    barValues.value = distributionRows.map((x) => Math.max(0, Math.min(100, Number(x.satisfactionPct ?? 0))))
+  } else if (l2.length > 0) {
     barXAxis.value = l2.slice(0, 4).map((x, i) => x.metricName || `线体${i + 1}`)
     barValues.value = l2.slice(0, 4).map((x) => Math.max(0, Math.min(100, Number(x.metricValue ?? 0))))
   }
+  nextTick(() => {
+    initAlertGauge()
+    initTrendChart()
+    initBarChart()
+  })
+}
+
+async function refreshS2Data() {
+  isRefreshing.value = true
+  try {
+    const res = await refreshS2Mdp()
+    if (!res.ok) {
+      ElMessage.error(res.message || 'S2 数据刷新失败')
+      return
+    }
+    ElMessage.success(`S2 数据已刷新:${res.batchId || ''}`)
+    await loadS2Kpis(1, true)
+    await loadS2DashboardData()
+  } finally {
+    isRefreshing.value = false
+  }
+}
+
+function exportScheduleRows() {
+  const rows = filteredOrderList.value
+  if (!rows.length) {
+    ElMessage.warning('暂无可导出的排程明细')
+    return
+  }
+  const headers = ['订单编号', '客户信息', '产品', '产线', '交付数量', '期望交期', '排程状态', '预计开工', '资源满足度']
+  const csvRows = rows.map((r) => [
+    r.orderNo,
+    r.customer,
+    r.product,
+    r.productionLine,
+    r.quantity,
+    r.deliveryDate,
+    r.status,
+    r.startTime,
+    `${r.satisfaction}%`
+  ])
+  const content = [headers, ...csvRows]
+    .map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
+    .join('\n')
+  const blob = new Blob([`\ufeff${content}`], { type: 'text/csv;charset=utf-8;' })
+  const url = URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = `S2排程明细_${new Date().toISOString().slice(0, 10)}.csv`
+  link.click()
+  URL.revokeObjectURL(url)
+}
+
+function showScheduleDetail(row) {
+  ElMessage.info(`工单 ${row.orderNo}:${row.status},资源满足度 ${row.satisfaction}%`)
+}
+
+function formatDateTime(raw) {
+  if (!raw) return '暂无同步'
+  return String(raw).replace('T', ' ').slice(0, 19)
+}
+
+onMounted(async () => {
+  window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
+  loadLayoutKpis()
+  await loadS2Kpis()
+  await loadS2DashboardData()
   updateTime()
   setInterval(updateTime, 1000)
   initCharts()

+ 1 - 0
doc/README.md

@@ -51,6 +51,7 @@
 | [plan/新系统数据库简明介绍与S1数据流.md](./plan/新系统数据库简明介绍与S1数据流.md) | 新系统数据库简明说明,并以 S1 为例说明数据中台流转 |
 | [plan/数据库迁移/S1/S1-任务交接记忆.md](./plan/数据库迁移/S1/S1-任务交接记忆.md) | **S1 数据中台迁移**跨会话交接(当前进度、阻塞、验收 SQL) |
 | [plan/数据库迁移/S1/S1数据中台迁移实施计划.md](./plan/数据库迁移/S1/S1数据中台迁移实施计划.md) | S1 数据中台迁移主方案与分块步骤 |
+| [plan/数据库迁移/S2/S2数据中台与KPI扩展验收.md](./plan/数据库迁移/S2/S2数据中台与KPI扩展验收.md) | S2 生产排程数据中台链路、KPI 写入与验收 SQL |
 | [plan/数据中台模块扩展开发指南-S3范式.md](./plan/数据中台模块扩展开发指南-S3范式.md) | 数据中台扩展(仿 S3):四库逻辑、作业与 Cursor 协作 |
 | [db/mdp/README.md](./db/mdp/README.md) | S4/S8 相关 mdp、dwd 等建表脚本说明与执行顺序 |
 | [指标模型动态配置方案.md](./指标模型动态配置方案.md) | 指标模型动态配置总体方案 |

+ 148 - 0
doc/plan/数据库迁移/S2/S2数据中台与KPI扩展验收.md

@@ -0,0 +1,148 @@
+# S2 数据中台与 KPI 扩展验收
+
+## 目标范围
+
+本轮按 S1 范式为 S2 生产排程建立首批数据链路:
+
+```text
+WorkOrdMaster / WorkOrdRouting / WorkOrdDetail / PeriodSequenceDet / ScheduleResultOpMaster
+        │
+        ▼
+mdp_stg_schedule
+        │
+        ▼
+mdp_std_work_order_schedule / mdp_std_operation_schedule
+        │
+        ▼
+dwd_order_schedule_trans
+        │
+        ├─► ado_s9_kpi_value_l1_day / ado_s9_kpi_value_l2_day
+        └─► S2 看板 module-detail 明细
+```
+
+本轮只读生产排程相关运行表,不改 S6 生产执行、S3/S4 供应采购、S8 异常规则引擎的业务写入。
+
+## 作业与对象
+
+| 项 | 内容 |
+|----|------|
+| 作业编码 | `S2_MDP_SYNC_TRANSFORM` |
+| 批次格式 | `S2_MDP_FULL_yyyyMMddHHmmss` |
+| 贴源表 | `mdp_stg_schedule` |
+| 标准层 | `mdp_std_work_order_schedule`、`mdp_std_operation_schedule` |
+| DWD | `dwd_order_schedule_trans` |
+| KPI | `S2_L1_001~004`、`S2_L2_001~003` |
+| 前端消费 | `GET /api/AidopKanban/home-grid/S2`、`GET /api/AidopKanban/module-detail?moduleCode=S2` |
+| 手动刷新 | `POST /api/AidopKanban/s2-mdp/refresh` |
+
+## 看板功能覆盖
+
+S2 制造协同看板已从静态展示扩展为以下动态读数:
+
+- 顶部 KPI:读取 `ado_s9_kpi_value_l1_day` 的 S2 L1 指标。
+- 全流程分解柱:订单排程、工单排程读取 S2 KPI,工序排程、资源排程读取最新 DWD 批次聚合。
+- 趋势图:读取近 7 日 S2 L1 排程周期与满足率。
+- 车间/产线分布:读取 `dwd_order_schedule_trans` 中最新批次按产线/站点聚合的满足率。
+- 异常与日志:优先读取 S2 DWD 派生异常(未排程、晚于交期、资源人员缺失),无数据时保留接口兜底。
+- 当前待排明细:读取最新 `dwd_order_schedule_trans` 批次。
+- 页面交互:支持手动刷新 S2 MDP、显示最后同步状态、导出当前筛选明细、查看单行简要详情。
+
+## 跨模块边界
+
+- S6 生产执行:本轮只读取工单/工序/排程结果,不改生产报工、完工、质检或制造执行写入规则。
+- S8 异常监控:S2 页面可消费告警或基于 DWD 派生排程异常,但不改 S8 规则引擎和告警落库流程。
+- S3/S4 供应采购:缺料/采购影响暂不纳入 S2 首批深层链路,避免重定义采购齐套和供应交付口径。
+- S9/九宫格:沿用 `ado_s9_kpi_value_*`,不新建并行 KPI 体系。
+
+## 验证 SQL
+
+1. 查看最近一次 S2 总任务:
+
+```sql
+SELECT id, job_code, batch_id, status, stage_rows, standard_rows, dwd_rows, start_time, end_time, error_message
+FROM mdp_transform_run_log
+WHERE job_code = 'S2_MDP_SYNC_TRANSFORM'
+ORDER BY id DESC
+LIMIT 5;
+```
+
+2. 查看 S2 各实体贴源日志:
+
+```sql
+SELECT e.entity_code, l.sync_batch_id, l.status, l.rows_read, l.rows_insert, l.rows_error, l.error_msg
+FROM mdp_sync_log l
+JOIN mdp_entity e ON e.id = l.entity_id
+WHERE e.entity_code LIKE 'S2_%'
+ORDER BY l.id DESC
+LIMIT 20;
+```
+
+3. 核对分层行数:
+
+```sql
+SELECT 'stg' AS layer, COUNT(*) AS rows_count FROM mdp_stg_schedule
+UNION ALL SELECT 'std_work_order', COUNT(*) FROM mdp_std_work_order_schedule
+UNION ALL SELECT 'std_operation', COUNT(*) FROM mdp_std_operation_schedule
+UNION ALL SELECT 'dwd_order_schedule', COUNT(*) FROM dwd_order_schedule_trans;
+```
+
+4. 查看最新 DWD 批次明细:
+
+```sql
+SELECT work_order, item_code, item_name, due_date, first_plan_date, last_plan_date,
+       schedule_cycle_days, schedule_satisfaction_flag, wip_qty, resource_person_count
+FROM dwd_order_schedule_trans
+WHERE calc_batch_id = (
+  SELECT calc_batch_id
+  FROM dwd_order_schedule_trans
+  ORDER BY calc_time DESC, id DESC
+  LIMIT 1
+)
+ORDER BY urgent_flag DESC, due_date IS NULL, due_date
+LIMIT 20;
+```
+
+5. 查看 S2 KPI 写入:
+
+```sql
+SELECT 'L1' AS level_name, module_code, metric_code, metric_value, target_value, status_color, trend_flag, biz_date
+FROM ado_s9_kpi_value_l1_day
+WHERE module_code = 'S2' AND is_deleted = 0
+UNION ALL
+SELECT 'L2', module_code, metric_code, metric_value, target_value, status_color, trend_flag, biz_date
+FROM ado_s9_kpi_value_l2_day
+WHERE module_code = 'S2' AND is_deleted = 0
+ORDER BY biz_date DESC, metric_code;
+```
+
+## 验收记录
+
+- 后端版本脚本:`server/Admin.NET.Web.Entry/UpdateScripts/1.0.133.sql`
+- 后端作业:`S2MdpSyncTransformJob`
+- 后端服务:`S2MdpSyncTransformService`
+- 统一监控:`MdpMonitorService` 已登记 `S2 -> S2_MDP_SYNC_TRANSFORM`
+- 看板入口:S2 顶部 KPI、分解柱、趋势、分布、异常、日志和当前待排明细均优先读取 S2 KPI/DWD 真实数据
+- 手动刷新:S2 页面刷新按钮调用 `POST /api/AidopKanban/s2-mdp/refresh`
+
+待环境验证项:
+
+- 手动或调度触发后,`mdp_transform_run_log` 最近批次为 `SUCCESS`。
+- `mdp_sync_log` 中 S2 五个首批实体均有成功记录。
+- 重复执行同一作业不会在标准层和 DWD 中按同一业务键重复造数。
+- S2 看板无数据时保留兜底,不影响页面打开。
+
+## 本轮数据库验收记录
+
+2026-06-03 使用数据库 MCP 完成一次手动等价批次验证:
+
+| 项 | 结果 |
+|----|------|
+| 批次 | `S2_MDP_FULL_MANUAL_202606030230` |
+| 运行日志 | `mdp_transform_run_log.status = SUCCESS` |
+| 贴源行数 | `mdp_stg_schedule = 39` |
+| 标准层行数 | `mdp_std_work_order_schedule = 5`、`mdp_std_operation_schedule = 18` |
+| DWD 行数 | `dwd_order_schedule_trans = 5` |
+| 同步日志 | `S2_WORK_ORDER_MASTER`、`S2_WORK_ORDER_ROUTING`、`S2_WORK_ORDER_DETAIL`、`S2_PERIOD_SEQUENCE_DET`、`S2_SCHEDULE_RESULT_OP` 均为 `SUCCESS` |
+| KPI | `ado_s9_kpi_value_l1_day`、`ado_s9_kpi_value_l2_day` 已写入 S2 当日指标 |
+
+说明:本地 `dotnet run` 后端启动仍会卡在既有 CodeFirst 初始化阶段,未能通过 HTTP 直接调用 `POST /api/AidopKanban/s2-mdp/refresh`。本轮已验证版本脚本、实体配置、分层转换 SQL、DWD、KPI 和看板读数所需数据。服务正常启动后,页面刷新按钮会走同一套 `S2MdpSyncTransformService`。

+ 6 - 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.145</AssemblyVersion>
-    <FileVersion>1.0.145</FileVersion>
-    <Version>1.0.145</Version>
+    <AssemblyVersion>1.0.146</AssemblyVersion>
+    <FileVersion>1.0.146</FileVersion>
+    <Version>1.0.146</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -97,6 +97,9 @@
     <None Update="UpdateScripts\1.0.145.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.146.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 138 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.146.sql

@@ -0,0 +1,138 @@
+-- 1.0.146.sql
+-- S2-MDP-SCHEDULE-KPI-1
+-- 为 S2 生产排程建立 stg -> std -> dwd -> KPI 链路的基础表与实体配置。
+
+CREATE TABLE IF NOT EXISTS mdp_stg_schedule (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id VARCHAR(64) NULL,
+    source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+    source_table VARCHAR(100) NOT NULL,
+    source_row_id VARCHAR(100) NOT NULL,
+    source_biz_key VARCHAR(200) NULL,
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+    raw_data JSON NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_mdp_stg_schedule (tenant_id, source_table, source_row_id),
+    KEY idx_mdp_stg_schedule_batch (sync_batch_id),
+    KEY idx_mdp_stg_schedule_biz (source_biz_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2生产排程贴源层';
+
+CREATE TABLE IF NOT EXISTS mdp_std_work_order_schedule (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id BIGINT NULL,
+    source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+    work_order VARCHAR(100) NOT NULL,
+    sales_order_no VARCHAR(100) NULL,
+    item_code VARCHAR(100) NULL,
+    item_name VARCHAR(200) NULL,
+    site_code VARCHAR(50) NULL,
+    status VARCHAR(50) NULL,
+    priority DECIMAL(18,6) NULL,
+    urgent_flag TINYINT NOT NULL DEFAULT 0,
+    qty_ordered DECIMAL(18,6) NULL,
+    qty_completed DECIMAL(18,6) NULL,
+    order_date DATETIME NULL,
+    due_date DATETIME NULL,
+    release_date DATETIME NULL,
+    prod_line VARCHAR(100) NULL,
+    source_biz_key VARCHAR(200) NULL,
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_std_work_order_schedule (tenant_id, work_order),
+    KEY idx_std_work_order_schedule_batch (sync_batch_id),
+    KEY idx_std_work_order_schedule_due (tenant_id, due_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2标准工单排程';
+
+CREATE TABLE IF NOT EXISTS mdp_std_operation_schedule (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id BIGINT NULL,
+    source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+    work_order VARCHAR(100) NOT NULL,
+    op_no VARCHAR(50) NULL,
+    work_center VARCHAR(100) NULL,
+    line_code VARCHAR(100) NULL,
+    item_code VARCHAR(100) NULL,
+    plan_date DATETIME NULL,
+    prod_date DATETIME NULL,
+    start_time DATETIME NULL,
+    end_time DATETIME NULL,
+    ord_qty DECIMAL(18,6) NULL,
+    comp_qty DECIMAL(18,6) NULL,
+    run_crew DECIMAL(18,6) NULL,
+    employee VARCHAR(200) NULL,
+    source_biz_key VARCHAR(200) NULL,
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_std_operation_schedule (tenant_id, source_biz_key),
+    KEY idx_std_operation_schedule_work_order (tenant_id, work_order),
+    KEY idx_std_operation_schedule_batch (sync_batch_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2标准工序排程';
+
+CREATE TABLE IF NOT EXISTS dwd_order_schedule_trans (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id BIGINT NOT NULL DEFAULT 1,
+    stat_date DATE NOT NULL,
+    work_order VARCHAR(100) NOT NULL,
+    sales_order_no VARCHAR(100) NULL,
+    item_code VARCHAR(100) NULL,
+    item_name VARCHAR(200) NULL,
+    site_code VARCHAR(50) NULL,
+    prod_line VARCHAR(100) NULL,
+    status VARCHAR(50) NULL,
+    urgent_flag TINYINT NOT NULL DEFAULT 0,
+    qty_ordered DECIMAL(18,6) NULL,
+    qty_completed DECIMAL(18,6) NULL,
+    order_date DATETIME NULL,
+    due_date DATETIME NULL,
+    release_date DATETIME NULL,
+    first_plan_date DATETIME NULL,
+    last_plan_date DATETIME NULL,
+    first_start_time DATETIME NULL,
+    last_end_time DATETIME NULL,
+    operation_count INT NOT NULL DEFAULT 0,
+    scheduled_qty DECIMAL(18,6) NULL,
+    completed_op_qty DECIMAL(18,6) NULL,
+    schedule_cycle_days DECIMAL(18,6) NULL,
+    schedule_satisfaction_flag TINYINT NOT NULL DEFAULT 0,
+    wip_qty DECIMAL(18,6) NULL,
+    resource_person_count DECIMAL(18,6) NULL,
+    calc_batch_id VARCHAR(100) NOT NULL,
+    calc_time DATETIME NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_dwd_order_schedule_trans (tenant_id, work_order, calc_batch_id),
+    KEY idx_dwd_order_schedule_trans_batch (calc_batch_id),
+    KEY idx_dwd_order_schedule_trans_stat (tenant_id, stat_date),
+    KEY idx_dwd_order_schedule_trans_order (tenant_id, sales_order_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2订单工单排程DWD';
+
+INSERT INTO mdp_entity
+(tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, status, remark)
+SELECT 0, s.id, v.entity_code, v.entity_name, 'TABLE', v.source_table_name, 'mdp_stg_schedule', 'FULL', 5000, 1, v.remark
+FROM mdp_source s
+JOIN (
+    SELECT 'S2_WORK_ORDER_MASTER' AS entity_code, 'S2工单主数据' AS entity_name, 'WorkOrdMaster' AS source_table_name, '工单主数据进入 S2 贴源层' AS remark
+    UNION ALL SELECT 'S2_WORK_ORDER_ROUTING', 'S2工单工艺路线', 'WorkOrdRouting', '工单工艺路线进入 S2 贴源层'
+    UNION ALL SELECT 'S2_WORK_ORDER_DETAIL', 'S2工单物料明细', 'WorkOrdDetail', '工单物料需求进入 S2 贴源层'
+    UNION ALL SELECT 'S2_PERIOD_SEQUENCE_DET', 'S2工序排程计划', 'PeriodSequenceDet', '工序间衔接与排程计划进入 S2 贴源层'
+    UNION ALL SELECT 'S2_SCHEDULE_RESULT_OP', 'S2工序排产结果', 'ScheduleResultOpMaster', '工序排产结果进入 S2 贴源层'
+) v
+WHERE s.tenant_id=0 AND s.source_code='AIDOPDEV_MYSQL'
+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),
+    batch_size=VALUES(batch_size),
+    status=VALUES(status),
+    remark=VALUES(remark),
+    update_time=CURRENT_TIMESTAMP;

+ 375 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -1,6 +1,7 @@
 using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Production;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -12,10 +13,12 @@ namespace Admin.NET.Plugin.AiDOP.Controllers;
 public partial class AidopKanbanController : ControllerBase
 {
     private readonly ISqlSugarClient _db;
+    private readonly S2MdpSyncTransformService _s2MdpSyncTransformService;
 
-    public AidopKanbanController(ISqlSugarClient db)
+    public AidopKanbanController(ISqlSugarClient db, S2MdpSyncTransformService s2MdpSyncTransformService)
     {
         _db = db;
+        _s2MdpSyncTransformService = s2MdpSyncTransformService;
     }
 
     [HttpGet("home-l1")]
@@ -138,11 +141,64 @@ LIMIT 60
         catch { l3 = await _db.Ado.SqlQueryAsync<KpiDetailDto>(l3FallbackSql, new { tenantId, factoryId }); }
         try { alerts = await _db.Ado.SqlQueryAsync<S8AlertDto>(alertSql, new { moduleCode, tenantId, factoryId }); }
         catch { alerts = new List<S8AlertDto>(); }
+        var schedules = new List<S2ScheduleDto>();
+        var s2Alerts = new List<S8AlertDto>();
+        var decomposition = new List<S2DecompositionDto>();
+        var trend = new List<S2TrendDto>();
+        var distribution = new List<S2DistributionDto>();
+        S2SyncStatusDto? syncStatus = null;
+        if (moduleCode == "S2")
+        {
+            syncStatus = await GetS2SyncStatusAsync();
+            s2Alerts = await GetS2DerivedAlertsAsync(tenantId, factoryId);
+            if (s2Alerts.Count > 0)
+                alerts = s2Alerts;
+            decomposition = await GetS2DecompositionAsync(tenantId, factoryId, l2);
+            trend = await GetS2TrendAsync(tenantId, factoryId);
+            distribution = await GetS2DistributionAsync(tenantId, factoryId);
+            try
+            {
+                schedules = await _db.Ado.SqlQueryAsync<S2ScheduleDto>(
+                    """
+                    SELECT work_order AS OrderNo,
+                           COALESCE(NULLIF(item_name, ''), item_code, work_order) AS Product,
+                           COALESCE(NULLIF(sales_order_no, ''), work_order) AS Customer,
+                           prod_line AS ProductionLine,
+                           qty_ordered AS Quantity,
+                           due_date AS DeliveryDate,
+                           first_start_time AS StartTime,
+                           schedule_cycle_days AS CycleDays,
+                           CASE WHEN schedule_satisfaction_flag = 1 THEN 100
+                                WHEN due_date IS NULL OR last_plan_date IS NULL THEN 70
+                                ELSE 65 END AS Satisfaction,
+                           CASE WHEN schedule_satisfaction_flag = 1 THEN '已锁定'
+                                WHEN last_plan_date IS NULL THEN '待排程'
+                                ELSE '资源异常' END AS Status
+                    FROM dwd_order_schedule_trans
+                    WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                      AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
+                                         WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                                         ORDER BY calc_time DESC, id DESC LIMIT 1)
+                    ORDER BY urgent_flag DESC, due_date IS NULL, due_date, id DESC
+                    LIMIT 30
+                    """,
+                    new { tenantId, factoryId });
+            }
+            catch
+            {
+                schedules = new List<S2ScheduleDto>();
+            }
+        }
         return Ok(new
         {
             moduleCode,
             l2,
             l3,
+            syncStatus,
+            decomposition,
+            trend,
+            distribution,
+            schedules,
             alerts = alerts.Select(x => new
             {
                 time = x.Time,
@@ -152,6 +208,262 @@ LIMIT 60
         });
     }
 
+    [HttpPost("s2-mdp/refresh")]
+    public async Task<IActionResult> RefreshS2Mdp(CancellationToken cancellationToken)
+    {
+        var result = await _s2MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            result.BatchId,
+            result.StageRows,
+            result.StandardRows,
+            result.DwdRows,
+            result.KpiRows
+        });
+    }
+
+    private async Task<S2SyncStatusDto?> GetS2SyncStatusAsync()
+    {
+        try
+        {
+            return await _db.Ado.SqlQuerySingleAsync<S2SyncStatusDto>(
+                """
+                SELECT batch_id AS BatchId, status AS Status, stage_rows AS StageRows,
+                       standard_rows AS StandardRows, dwd_rows AS DwdRows,
+                       start_time AS StartTime, end_time AS EndTime, error_message AS ErrorMessage
+                FROM mdp_transform_run_log
+                WHERE job_code='S2_MDP_SYNC_TRANSFORM'
+                ORDER BY start_time DESC, id DESC
+                LIMIT 1
+                """);
+        }
+        catch
+        {
+            return null;
+        }
+    }
+
+    private async Task<List<S2DecompositionDto>> GetS2DecompositionAsync(long tenantId, long factoryId, List<KpiDetailDto> l2)
+    {
+        var latestL1 = new Dictionary<string, KpiDetailDto>(StringComparer.OrdinalIgnoreCase);
+        try
+        {
+            var rows = await _db.Ado.SqlQueryAsync<KpiDetailDto>(
+                """
+                SELECT v.module_code AS ModuleCode, v.metric_code AS MetricCode, k.MetricName AS MetricName,
+                       v.metric_value AS MetricValue, v.target_value AS TargetValue,
+                       v.status_color AS StatusColor, v.trend_flag AS TrendFlag, v.biz_date AS StatDate
+                FROM ado_s9_kpi_value_l1_day v
+                LEFT JOIN ado_smart_ops_kpi_master k ON k.TenantId=v.tenant_id AND k.MetricCode=v.metric_code
+                WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.module_code='S2' AND v.is_deleted=0
+                  AND v.biz_date=(SELECT MAX(biz_date) FROM ado_s9_kpi_value_l1_day
+                                  WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code='S2' AND is_deleted=0)
+                """,
+                new { tenantId, factoryId });
+            latestL1 = rows.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
+                .ToDictionary(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase);
+        }
+        catch
+        {
+            latestL1 = new Dictionary<string, KpiDetailDto>(StringComparer.OrdinalIgnoreCase);
+        }
+
+        var latestL2 = l2.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
+            .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
+            .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
+
+        var operationSummary = await GetS2OperationSummaryAsync(tenantId, factoryId);
+        var resourceSummary = await GetS2ResourceSummaryAsync(tenantId, factoryId);
+
+        return new List<S2DecompositionDto>
+        {
+            new()
+            {
+                Title = "订单排程",
+                Active = true,
+                Metrics = new List<string>
+                {
+                    $"1. 周期:{FormatMetric(latestL1, "S2_L1_001", "天")}",
+                    $"2. 满足率:{FormatMetric(latestL1, "S2_L1_002", "%")}",
+                    $"3. 在制库存:{FormatMetric(latestL1, "S2_L1_004", "天")}",
+                    $"4. 人效:{FormatMetric(latestL1, "S2_L1_003", "单/人")}"
+                }
+            },
+            new()
+            {
+                Title = "工单排程",
+                Metrics = new List<string>
+                {
+                    $"1. 周期:{FormatMetric(latestL2, "S2_L2_001", "天")}",
+                    $"2. 满足率:{FormatMetric(latestL2, "S2_L2_002", "%")}",
+                    $"3. 人效:{FormatMetric(latestL2, "S2_L2_003", "单/人")}"
+                }
+            },
+            new()
+            {
+                Title = "工序排程",
+                Metrics = new List<string>
+                {
+                    $"1. 工序数:{operationSummary.OperationCount}",
+                    $"2. 完成量:{Math.Round(operationSummary.CompletedQty ?? 0, 2)}",
+                    $"3. 排程量:{Math.Round(operationSummary.ScheduledQty ?? 0, 2)}"
+                }
+            },
+            new()
+            {
+                Title = "资源排程",
+                Metrics = new List<string>
+                {
+                    $"1. 人员数:{Math.Round(resourceSummary.PersonCount ?? 0, 2)}",
+                    $"2. 异常数:{resourceSummary.RiskCount}",
+                    $"3. 产线数:{resourceSummary.LineCount}"
+                }
+            }
+        };
+    }
+
+    private async Task<S2OperationSummaryDto> GetS2OperationSummaryAsync(long tenantId, long factoryId)
+    {
+        try
+        {
+            return await _db.Ado.SqlQuerySingleAsync<S2OperationSummaryDto>(
+                """
+                SELECT SUM(operation_count) AS OperationCount,
+                       SUM(completed_op_qty) AS CompletedQty,
+                       SUM(scheduled_qty) AS ScheduledQty
+                FROM dwd_order_schedule_trans
+                WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                  AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
+                                     WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                                     ORDER BY calc_time DESC, id DESC LIMIT 1)
+                """,
+                new { tenantId, factoryId }) ?? new S2OperationSummaryDto();
+        }
+        catch
+        {
+            return new S2OperationSummaryDto();
+        }
+    }
+
+    private async Task<S2ResourceSummaryDto> GetS2ResourceSummaryAsync(long tenantId, long factoryId)
+    {
+        try
+        {
+            return await _db.Ado.SqlQuerySingleAsync<S2ResourceSummaryDto>(
+                """
+                SELECT SUM(resource_person_count) AS PersonCount,
+                       SUM(CASE WHEN schedule_satisfaction_flag=0 THEN 1 ELSE 0 END) AS RiskCount,
+                       COUNT(DISTINCT COALESCE(NULLIF(prod_line,''), NULLIF(site_code,''), '未分配')) AS LineCount
+                FROM dwd_order_schedule_trans
+                WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                  AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
+                                     WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                                     ORDER BY calc_time DESC, id DESC LIMIT 1)
+                """,
+                new { tenantId, factoryId }) ?? new S2ResourceSummaryDto();
+        }
+        catch
+        {
+            return new S2ResourceSummaryDto();
+        }
+    }
+
+    private async Task<List<S2TrendDto>> GetS2TrendAsync(long tenantId, long factoryId)
+    {
+        try
+        {
+            return await _db.Ado.SqlQueryAsync<S2TrendDto>(
+                """
+                SELECT DATE_FORMAT(d.biz_date, '%m-%d') AS DateLabel,
+                       MAX(CASE WHEN d.metric_code='S2_L1_001' THEN d.metric_value END) AS CycleDays,
+                       MAX(CASE WHEN d.metric_code='S2_L1_002' THEN d.metric_value END) AS SatisfactionPct
+                FROM ado_s9_kpi_value_l1_day d
+                WHERE d.tenant_id=@tenantId AND d.factory_id=@factoryId AND d.module_code='S2' AND d.is_deleted=0
+                  AND d.metric_code IN ('S2_L1_001','S2_L1_002')
+                GROUP BY d.biz_date
+                ORDER BY d.biz_date DESC
+                LIMIT 7
+                """,
+                new { tenantId, factoryId });
+        }
+        catch
+        {
+            return new List<S2TrendDto>();
+        }
+    }
+
+    private async Task<List<S2DistributionDto>> GetS2DistributionAsync(long tenantId, long factoryId)
+    {
+        try
+        {
+            return await _db.Ado.SqlQueryAsync<S2DistributionDto>(
+                """
+                SELECT COALESCE(NULLIF(prod_line,''), NULLIF(site_code,''), '未分配') AS Name,
+                       ROUND(100 * SUM(schedule_satisfaction_flag) / NULLIF(COUNT(1), 0), 2) AS SatisfactionPct,
+                       COUNT(1) AS TotalCount,
+                       SUM(CASE WHEN schedule_satisfaction_flag=0 THEN 1 ELSE 0 END) AS RiskCount
+                FROM dwd_order_schedule_trans
+                WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                  AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
+                                     WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                                     ORDER BY calc_time DESC, id DESC LIMIT 1)
+                GROUP BY COALESCE(NULLIF(prod_line,''), NULLIF(site_code,''), '未分配')
+                ORDER BY SatisfactionPct, TotalCount DESC
+                LIMIT 8
+                """,
+                new { tenantId, factoryId });
+        }
+        catch
+        {
+            return new List<S2DistributionDto>();
+        }
+    }
+
+    private async Task<List<S8AlertDto>> GetS2DerivedAlertsAsync(long tenantId, long factoryId)
+    {
+        try
+        {
+            return await _db.Ado.SqlQueryAsync<S8AlertDto>(
+                """
+                SELECT DATE_FORMAT(calc_time, '%H:%i:%s') AS Time,
+                       CASE
+                         WHEN last_plan_date IS NULL THEN 'high'
+                         WHEN due_date IS NOT NULL AND last_plan_date > due_date THEN 'critical'
+                         WHEN IFNULL(resource_person_count, 0) <= 0 THEN 'medium'
+                         ELSE 'info'
+                       END AS LevelCode,
+                       CONCAT('工单 ', work_order, ' ',
+                         CASE
+                           WHEN last_plan_date IS NULL THEN '尚未形成排程'
+                           WHEN due_date IS NOT NULL AND last_plan_date > due_date THEN '排程晚于交期'
+                           WHEN IFNULL(resource_person_count, 0) <= 0 THEN '缺少资源人员配置'
+                           ELSE '排程状态正常'
+                         END) AS Message
+                FROM dwd_order_schedule_trans
+                WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                  AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
+                                     WHERE tenant_id=@tenantId AND factory_id=@factoryId
+                                     ORDER BY calc_time DESC, id DESC LIMIT 1)
+                  AND (last_plan_date IS NULL OR (due_date IS NOT NULL AND last_plan_date > due_date) OR IFNULL(resource_person_count, 0) <= 0)
+                ORDER BY FIELD(LevelCode, 'critical', 'high', 'medium', 'info'), due_date IS NULL, due_date
+                LIMIT 20
+                """,
+                new { tenantId, factoryId });
+        }
+        catch
+        {
+            return new List<S8AlertDto>();
+        }
+    }
+
+    private static string FormatMetric(Dictionary<string, KpiDetailDto> rows, string metricCode, string unit)
+    {
+        if (!rows.TryGetValue(metricCode, out var row) || row.MetricValue == null)
+            return $"--{unit}";
+        return $"{Math.Round(row.MetricValue.Value, 2)}{unit}";
+    }
+
     /// <summary>
     /// 智慧运营看板基础查询下拉:产品、订单号、产线(来自 Demo 业务表;无租户列时忽略 tenant/factory)。
     /// </summary>
@@ -282,5 +594,67 @@ LIMIT 60
         public string? TrendFlag { get; set; }
         public DateTime? StatDate { get; set; }
     }
+
+    private sealed class S2ScheduleDto
+    {
+        public string? OrderNo { get; set; }
+        public string? Customer { get; set; }
+        public string? Product { get; set; }
+        public string? ProductionLine { get; set; }
+        public decimal? Quantity { get; set; }
+        public DateTime? DeliveryDate { get; set; }
+        public DateTime? StartTime { get; set; }
+        public decimal? CycleDays { get; set; }
+        public decimal? Satisfaction { get; set; }
+        public string? Status { get; set; }
+    }
+
+    private sealed class S2SyncStatusDto
+    {
+        public string? BatchId { get; set; }
+        public string? Status { get; set; }
+        public int? StageRows { get; set; }
+        public int? StandardRows { get; set; }
+        public int? DwdRows { get; set; }
+        public DateTime? StartTime { get; set; }
+        public DateTime? EndTime { get; set; }
+        public string? ErrorMessage { get; set; }
+    }
+
+    private sealed class S2DecompositionDto
+    {
+        public string Title { get; set; } = string.Empty;
+        public bool Active { get; set; }
+        public List<string> Metrics { get; set; } = new();
+    }
+
+    private sealed class S2TrendDto
+    {
+        public string? DateLabel { get; set; }
+        public decimal? CycleDays { get; set; }
+        public decimal? SatisfactionPct { get; set; }
+    }
+
+    private sealed class S2DistributionDto
+    {
+        public string? Name { get; set; }
+        public decimal? SatisfactionPct { get; set; }
+        public int TotalCount { get; set; }
+        public int RiskCount { get; set; }
+    }
+
+    private sealed class S2OperationSummaryDto
+    {
+        public int OperationCount { get; set; }
+        public decimal? CompletedQty { get; set; }
+        public decimal? ScheduledQty { get; set; }
+    }
+
+    private sealed class S2ResourceSummaryDto
+    {
+        public decimal? PersonCount { get; set; }
+        public int RiskCount { get; set; }
+        public int LineCount { get; set; }
+    }
 }
 

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

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

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

@@ -12,6 +12,7 @@ public class MdpMonitorService : IDynamicApiController, ITransient
     private static readonly Dictionary<string, string> ModuleJobCodes = new(StringComparer.OrdinalIgnoreCase)
     {
         ["S1"] = "S1_MDP_SYNC_TRANSFORM",
+        ["S2"] = "S2_MDP_SYNC_TRANSFORM",
         ["S3"] = "S3_MDP_SYNC_TRANSFORM"
     };
 

+ 860 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs

@@ -0,0 +1,860 @@
+namespace Admin.NET.Plugin.AiDOP.Production;
+
+/// <summary>
+/// S2 生产排程 MDP 同步、标准化、DWD 与 KPI 计算服务。
+/// </summary>
+public class S2MdpSyncTransformService : ITransient
+{
+    private const string JobCode = "S2_MDP_SYNC_TRANSFORM";
+    private readonly ISqlSugarClient _db;
+
+    public S2MdpSyncTransformService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<S2MdpSyncTransformResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        var now = DateTime.Now;
+        var batchId = $"S2_MDP_FULL_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertTransformRunLogAsync(batchId, now, triggerType);
+        var result = new S2MdpSyncTransformResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            await EnsureS2RuntimeObjectsAsync();
+            result.StageRows = await SyncStagingAsync(batchId, now, cancellationToken);
+            result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
+            result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
+            result.KpiRows = await BuildS2KpiValuesAsync(batchId, now, cancellationToken);
+            await MarkTransformRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    private async Task EnsureS2RuntimeObjectsAsync()
+    {
+        foreach (var sql in S2MdpDdl.SqlBlocks)
+        {
+            await _db.Ado.ExecuteCommandAsync(sql);
+        }
+    }
+
+    private async Task<int> SyncStagingAsync(string batchId, DateTime now, CancellationToken cancellationToken)
+    {
+        var total = 0;
+        foreach (var entity in S2MdpEntityConfig.All)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await SyncOneEntityAsync(entity, batchId, now);
+        }
+        return total;
+    }
+
+    private async Task<int> SyncOneEntityAsync(S2MdpEntityConfig entity, string batchId, DateTime now)
+    {
+        var entityRow = await _db.Ado.SqlQuerySingleAsync<S2MdpEntityRow>(
+            "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<S2ColumnRow>(
+            """
+            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 = names.Any(u => string.Equals(u, "tenant_id", StringComparison.OrdinalIgnoreCase))
+            ? $"IFNULL(s.`{FindColumn(names, "tenant_id")}`,0)"
+            : "0";
+        var factoryExpr = names.Any(u => string.Equals(u, "factory_id", StringComparison.OrdinalIgnoreCase))
+            ? $"s.`{FindColumn(names, "factory_id")}`"
+            : "NULL";
+        var domainExpr = names.Any(u => string.Equals(u, "Domain", StringComparison.OrdinalIgnoreCase))
+            ? $"s.`{FindColumn(names, "Domain")}`"
+            : "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, source_system, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time, process_status, raw_data)
+                SELECT
+                    {tenantExpr},
+                    COALESCE({factoryExpr}, {domainExpr}),
+                    '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),
+                    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> BuildS2KpiValuesAsync(string batchId, DateTime now, CancellationToken cancellationToken)
+    {
+        var rows = await CalculateS2KpiValuesAsync(batchId, now.Date);
+        var affected = 0;
+        foreach (var row in rows)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            affected += await UpsertS2KpiValueAsync(row, now.Date, now);
+        }
+        return affected;
+    }
+
+    private IEnumerable<S2MdpSqlCommand> BuildStandardCommands(string batchId, DateTime now)
+    {
+        yield return Cmd(
+            """
+            INSERT INTO mdp_std_work_order_schedule
+            (tenant_id, factory_id, source_system, work_order, sales_order_no, item_code, item_name, site_code, status,
+             priority, urgent_flag, qty_ordered, qty_completed, order_date, due_date, release_date, prod_line,
+             source_biz_key, sync_batch_id, sync_time)
+            SELECT tenant_id,
+                   CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
+                   'AIDOP',
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.SalesJob')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemName')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Site')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Status')),
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Priority')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Priority')) AS DECIMAL(18,6)) END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Urgent')) IN ('1','true','True') THEN 1 ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyOrded')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyOrded')) AS DECIMAL(18,6)) ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyCompleted')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyCompleted')) AS DECIMAL(18,6)) ELSE 0 END,
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.OrdDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DueDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ReleaseDate')), 'null'), ''),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdLine')),
+                   source_biz_key, @BatchId, @Now
+            FROM mdp_stg_schedule
+            WHERE source_table='WorkOrdMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')), '') <> ''
+            ON DUPLICATE KEY UPDATE
+                sales_order_no=VALUES(sales_order_no), item_code=VALUES(item_code), item_name=VALUES(item_name),
+                site_code=VALUES(site_code), status=VALUES(status), priority=VALUES(priority), urgent_flag=VALUES(urgent_flag),
+                qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed), order_date=VALUES(order_date),
+                due_date=VALUES(due_date), release_date=VALUES(release_date), prod_line=VALUES(prod_line),
+                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_operation_schedule
+            (tenant_id, factory_id, source_system, work_order, op_no, work_center, line_code, item_code,
+             plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee,
+             source_biz_key, sync_batch_id, sync_time)
+            SELECT tenant_id,
+                   CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
+                   'AIDOP',
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrds')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Op')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkCtr')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Line')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PlanDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StartTime')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.EndTime')), 'null'), ''),
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.OrdQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.OrdQty')) AS DECIMAL(18,6)) ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) AS DECIMAL(18,6)) ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) AS DECIMAL(18,6)) END,
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Employee')),
+                   source_biz_key, @BatchId, @Now
+            FROM mdp_stg_schedule
+            WHERE source_table='PeriodSequenceDet' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrds')), '') <> ''
+            ON DUPLICATE KEY UPDATE
+                work_center=VALUES(work_center), line_code=VALUES(line_code), item_code=VALUES(item_code),
+                plan_date=VALUES(plan_date), prod_date=VALUES(prod_date), start_time=VALUES(start_time), end_time=VALUES(end_time),
+                ord_qty=VALUES(ord_qty), comp_qty=VALUES(comp_qty), run_crew=VALUES(run_crew), employee=VALUES(employee),
+                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_operation_schedule
+            (tenant_id, factory_id, source_system, work_order, op_no, work_center, line_code, item_code,
+             plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee,
+             source_biz_key, sync_batch_id, sync_time)
+            SELECT tenant_id,
+                   CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
+                   'AIDOP',
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Op')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkCtr')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Line')),
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkStartTime')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkEndTime')), 'null'), ''),
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) AS DECIMAL(18,6)) ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) AS DECIMAL(18,6)) ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.AssignedPersonnelCount')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.AssignedPersonnelCount')) AS DECIMAL(18,6)) END,
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.AssignedEmployeeID')),
+                   source_biz_key, @BatchId, @Now
+            FROM mdp_stg_schedule
+            WHERE source_table='ScheduleResultOpMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')), '') <> ''
+            ON DUPLICATE KEY UPDATE
+                work_center=VALUES(work_center), line_code=VALUES(line_code), item_code=VALUES(item_code),
+                plan_date=VALUES(plan_date), prod_date=VALUES(prod_date), start_time=VALUES(start_time), end_time=VALUES(end_time),
+                ord_qty=VALUES(ord_qty), comp_qty=VALUES(comp_qty), run_crew=VALUES(run_crew), employee=VALUES(employee),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
+    }
+
+    private IEnumerable<S2MdpSqlCommand> BuildDwdCommands(string batchId, DateTime now)
+    {
+        yield return Cmd(
+            """
+            INSERT INTO dwd_order_schedule_trans
+            (tenant_id, factory_id, stat_date, work_order, sales_order_no, item_code, item_name, site_code, prod_line,
+             status, urgent_flag, qty_ordered, qty_completed, order_date, due_date, release_date, first_plan_date,
+             last_plan_date, first_start_time, last_end_time, operation_count, scheduled_qty, completed_op_qty,
+             schedule_cycle_days, schedule_satisfaction_flag, wip_qty, resource_person_count, calc_batch_id, calc_time)
+            SELECT w.tenant_id, COALESCE(w.factory_id, 1), @StatDate,
+                   w.work_order, w.sales_order_no, w.item_code, w.item_name, w.site_code, w.prod_line,
+                   w.status, w.urgent_flag, w.qty_ordered, w.qty_completed, w.order_date, w.due_date, w.release_date,
+                   MIN(o.plan_date), MAX(o.plan_date), MIN(o.start_time), MAX(o.end_time),
+                   COUNT(o.id), SUM(IFNULL(o.ord_qty, 0)), SUM(IFNULL(o.comp_qty, 0)),
+                   CASE
+                       WHEN COALESCE(MIN(o.plan_date), MAX(o.plan_date)) IS NOT NULL
+                            AND COALESCE(w.release_date, w.order_date) IS NOT NULL
+                       THEN TIMESTAMPDIFF(HOUR, COALESCE(w.release_date, w.order_date), COALESCE(MAX(o.plan_date), MIN(o.plan_date))) / 24
+                   END,
+                   CASE
+                       WHEN w.due_date IS NOT NULL AND COALESCE(MAX(o.plan_date), MIN(o.plan_date)) IS NOT NULL
+                            AND DATE(COALESCE(MAX(o.plan_date), MIN(o.plan_date))) <= DATE(w.due_date) THEN 1
+                       ELSE 0
+                   END,
+                   GREATEST(IFNULL(w.qty_ordered, 0) - IFNULL(w.qty_completed, 0), 0),
+                   SUM(IFNULL(o.run_crew, 0)),
+                   @BatchId, @Now
+            FROM mdp_std_work_order_schedule w
+            LEFT JOIN mdp_std_operation_schedule o
+              ON o.tenant_id=w.tenant_id AND IFNULL(o.work_order,'')=IFNULL(w.work_order,'')
+            WHERE IFNULL(w.work_order, '') <> ''
+            GROUP BY w.tenant_id, COALESCE(w.factory_id, 1), w.work_order, w.sales_order_no, w.item_code, w.item_name,
+                     w.site_code, w.prod_line, w.status, w.urgent_flag, w.qty_ordered, w.qty_completed,
+                     w.order_date, w.due_date, w.release_date
+            ON DUPLICATE KEY UPDATE
+                sales_order_no=VALUES(sales_order_no), item_code=VALUES(item_code), item_name=VALUES(item_name),
+                site_code=VALUES(site_code), prod_line=VALUES(prod_line), status=VALUES(status), urgent_flag=VALUES(urgent_flag),
+                qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed), order_date=VALUES(order_date),
+                due_date=VALUES(due_date), release_date=VALUES(release_date), first_plan_date=VALUES(first_plan_date),
+                last_plan_date=VALUES(last_plan_date), first_start_time=VALUES(first_start_time), last_end_time=VALUES(last_end_time),
+                operation_count=VALUES(operation_count), scheduled_qty=VALUES(scheduled_qty), completed_op_qty=VALUES(completed_op_qty),
+                schedule_cycle_days=VALUES(schedule_cycle_days), schedule_satisfaction_flag=VALUES(schedule_satisfaction_flag),
+                wip_qty=VALUES(wip_qty), resource_person_count=VALUES(resource_person_count), calc_time=VALUES(calc_time),
+                update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
+    }
+
+    private async Task<List<S2KpiCalcRow>> CalculateS2KpiValuesAsync(string batchId, DateTime statDate)
+    {
+        return await _db.Ado.SqlQueryAsync<S2KpiCalcRow>(
+            """
+            SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_001' AS MetricCode,
+                   ROUND(AVG(schedule_cycle_days), 4) AS MetricValue
+            FROM dwd_order_schedule_trans
+            WHERE calc_batch_id=@BatchId AND schedule_cycle_days IS NOT NULL AND schedule_cycle_days >= 0
+            GROUP BY tenant_id, factory_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_002' AS MetricCode,
+                   ROUND(100 * SUM(schedule_satisfaction_flag) / NULLIF(COUNT(1), 0), 4) AS MetricValue
+            FROM dwd_order_schedule_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id, factory_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_003' AS MetricCode,
+                   ROUND(COUNT(1) / NULLIF(SUM(CASE WHEN IFNULL(resource_person_count, 0) > 0 THEN resource_person_count ELSE 1 END), 0), 4) AS MetricValue
+            FROM dwd_order_schedule_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id, factory_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_004' AS MetricCode,
+                   ROUND(SUM(wip_qty) / NULLIF(SUM(CASE WHEN qty_completed > 0 THEN qty_completed ELSE completed_op_qty END), 0) * 30, 4) AS MetricValue
+            FROM dwd_order_schedule_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id, factory_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L2_001' AS MetricCode,
+                   ROUND(AVG(schedule_cycle_days), 4) AS MetricValue
+            FROM dwd_order_schedule_trans
+            WHERE calc_batch_id=@BatchId AND schedule_cycle_days IS NOT NULL AND schedule_cycle_days >= 0
+            GROUP BY tenant_id, factory_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L2_002' AS MetricCode,
+                   ROUND(100 * SUM(schedule_satisfaction_flag) / NULLIF(COUNT(1), 0), 4) AS MetricValue
+            FROM dwd_order_schedule_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id, factory_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L2_003' AS MetricCode,
+                   ROUND(COUNT(1) / NULLIF(SUM(CASE WHEN IFNULL(resource_person_count, 0) > 0 THEN resource_person_count ELSE 1 END), 0), 4) AS MetricValue
+            FROM dwd_order_schedule_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id, factory_id
+            """,
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StatDate", statDate));
+    }
+
+    private async Task<int> UpsertS2KpiValueAsync(S2KpiCalcRow row, DateTime statDate, DateTime now)
+    {
+        if (row.MetricValue == null) return 0;
+
+        var meta = await _db.Ado.SqlQuerySingleAsync<S2KpiMetaRow>(
+            """
+            SELECT MetricLevel, Direction, YellowThreshold, RedThreshold
+            FROM ado_smart_ops_kpi_master
+            WHERE TenantId=@TenantId AND ModuleCode='S2' AND MetricCode=@MetricCode AND IsEnabled=1
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", row.TenantId),
+            new SugarParameter("@MetricCode", row.MetricCode));
+        if (meta == null) return 0;
+
+        var table = ResolveKpiValueTable(meta.MetricLevel);
+        var current = await _db.Ado.SqlQuerySingleAsync<S2KpiValueRow>(
+            $"""
+            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='S2'
+              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<S2KpiValueRow>(
+            $"""
+            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='S2'
+              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 ?? DefaultS2Target(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='S2'
+                  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,
+             'S2', @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 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)
+        {
+            Console.Error.WriteLine($"[S2MdpSyncTransform] 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, 'S2_MDP_SYNC_TRANSFORM', 'S2 MDP同步与KPI计算', @TriggerType, @BatchId, 'RUNNING', @StartTime)
+            """,
+            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, S2MdpSyncTransformResult 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)
+        {
+            Console.Error.WriteLine($"[S2MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
+        }
+    }
+
+    private static S2MdpSqlCommand Cmd(string sql, string batchId, DateTime now)
+    {
+        return new S2MdpSqlCommand(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 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(S2MdpSyncTransformResult 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 DefaultS2Target(string metricCode)
+    {
+        return metricCode switch
+        {
+            "S2_L1_001" => 20m,
+            "S2_L1_002" => 99m,
+            "S2_L1_003" => 20m,
+            "S2_L1_004" => 18m,
+            "S2_L2_001" => 1.2m,
+            "S2_L2_002" => 95m,
+            "S2_L2_003" => 20m,
+            _ => 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 S2ColumnRow
+    {
+        public string ColumnName { get; set; } = string.Empty;
+    }
+
+    private sealed class S2MdpEntityRow
+    {
+        public long Id { get; set; }
+        public string EntityName { get; set; } = string.Empty;
+    }
+
+    private sealed class S2KpiCalcRow
+    {
+        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 S2KpiMetaRow
+    {
+        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 S2KpiValueRow
+    {
+        public long Id { get; set; }
+        public decimal? MetricValue { get; set; }
+        public decimal? TargetValue { get; set; }
+    }
+}
+
+public sealed class S2MdpSyncTransformResult
+{
+    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 S2MdpSqlCommand(string Sql, SugarParameter[] Parameters);
+
+internal sealed record S2MdpEntityConfig(
+    string EntityCode,
+    string SourceTable,
+    string TargetTable,
+    string SourceRowIdExpression,
+    string SourceBizKeyExpression)
+{
+    public static readonly IReadOnlyList<S2MdpEntityConfig> All = new List<S2MdpEntityConfig>
+    {
+        new("S2_WORK_ORDER_MASTER", "WorkOrdMaster", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''))"),
+        new("S2_WORK_ORDER_ROUTING", "WorkOrdRouting", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`OP`,''))"),
+        new("S2_WORK_ORDER_DETAIL", "WorkOrdDetail", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`ItemNum`,''))"),
+        new("S2_PERIOD_SEQUENCE_DET", "PeriodSequenceDet", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrds`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`Sequence`,''))"),
+        new("S2_SCHEDULE_RESULT_OP", "ScheduleResultOpMaster", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`WorkDate`,''))")
+    };
+}
+
+internal static class S2MdpDdl
+{
+    public static readonly IReadOnlyList<string> SqlBlocks = new[]
+    {
+        """
+        CREATE TABLE IF NOT EXISTS mdp_stg_schedule (
+            id BIGINT AUTO_INCREMENT PRIMARY KEY,
+            tenant_id BIGINT NOT NULL DEFAULT 0,
+            factory_id VARCHAR(64) NULL,
+            source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+            source_table VARCHAR(100) NOT NULL,
+            source_row_id VARCHAR(100) NOT NULL,
+            source_biz_key VARCHAR(200) NULL,
+            sync_batch_id VARCHAR(100) NOT NULL,
+            sync_time DATETIME NOT NULL,
+            process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+            raw_data JSON NOT NULL,
+            update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+            UNIQUE KEY uk_mdp_stg_schedule (tenant_id, source_table, source_row_id),
+            KEY idx_mdp_stg_schedule_batch (sync_batch_id),
+            KEY idx_mdp_stg_schedule_biz (source_biz_key)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2生产排程贴源层';
+        """,
+        """
+        CREATE TABLE IF NOT EXISTS mdp_std_work_order_schedule (
+            id BIGINT AUTO_INCREMENT PRIMARY KEY,
+            tenant_id BIGINT NOT NULL DEFAULT 0,
+            factory_id BIGINT NULL,
+            source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+            work_order VARCHAR(100) NOT NULL,
+            sales_order_no VARCHAR(100) NULL,
+            item_code VARCHAR(100) NULL,
+            item_name VARCHAR(200) NULL,
+            site_code VARCHAR(50) NULL,
+            status VARCHAR(50) NULL,
+            priority DECIMAL(18,6) NULL,
+            urgent_flag TINYINT NOT NULL DEFAULT 0,
+            qty_ordered DECIMAL(18,6) NULL,
+            qty_completed DECIMAL(18,6) NULL,
+            order_date DATETIME NULL,
+            due_date DATETIME NULL,
+            release_date DATETIME NULL,
+            prod_line VARCHAR(100) NULL,
+            source_biz_key VARCHAR(200) NULL,
+            sync_batch_id VARCHAR(100) NOT NULL,
+            sync_time DATETIME NOT NULL,
+            update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+            UNIQUE KEY uk_std_work_order_schedule (tenant_id, work_order),
+            KEY idx_std_work_order_schedule_batch (sync_batch_id),
+            KEY idx_std_work_order_schedule_due (tenant_id, due_date)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2标准工单排程';
+        """,
+        """
+        CREATE TABLE IF NOT EXISTS mdp_std_operation_schedule (
+            id BIGINT AUTO_INCREMENT PRIMARY KEY,
+            tenant_id BIGINT NOT NULL DEFAULT 0,
+            factory_id BIGINT NULL,
+            source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+            work_order VARCHAR(100) NOT NULL,
+            op_no VARCHAR(50) NULL,
+            work_center VARCHAR(100) NULL,
+            line_code VARCHAR(100) NULL,
+            item_code VARCHAR(100) NULL,
+            plan_date DATETIME NULL,
+            prod_date DATETIME NULL,
+            start_time DATETIME NULL,
+            end_time DATETIME NULL,
+            ord_qty DECIMAL(18,6) NULL,
+            comp_qty DECIMAL(18,6) NULL,
+            run_crew DECIMAL(18,6) NULL,
+            employee VARCHAR(200) NULL,
+            source_biz_key VARCHAR(200) NULL,
+            sync_batch_id VARCHAR(100) NOT NULL,
+            sync_time DATETIME NOT NULL,
+            update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+            UNIQUE KEY uk_std_operation_schedule (tenant_id, source_biz_key),
+            KEY idx_std_operation_schedule_work_order (tenant_id, work_order),
+            KEY idx_std_operation_schedule_batch (sync_batch_id)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2标准工序排程';
+        """,
+        """
+        CREATE TABLE IF NOT EXISTS dwd_order_schedule_trans (
+            id BIGINT AUTO_INCREMENT PRIMARY KEY,
+            tenant_id BIGINT NOT NULL DEFAULT 0,
+            factory_id BIGINT NOT NULL DEFAULT 1,
+            stat_date DATE NOT NULL,
+            work_order VARCHAR(100) NOT NULL,
+            sales_order_no VARCHAR(100) NULL,
+            item_code VARCHAR(100) NULL,
+            item_name VARCHAR(200) NULL,
+            site_code VARCHAR(50) NULL,
+            prod_line VARCHAR(100) NULL,
+            status VARCHAR(50) NULL,
+            urgent_flag TINYINT NOT NULL DEFAULT 0,
+            qty_ordered DECIMAL(18,6) NULL,
+            qty_completed DECIMAL(18,6) NULL,
+            order_date DATETIME NULL,
+            due_date DATETIME NULL,
+            release_date DATETIME NULL,
+            first_plan_date DATETIME NULL,
+            last_plan_date DATETIME NULL,
+            first_start_time DATETIME NULL,
+            last_end_time DATETIME NULL,
+            operation_count INT NOT NULL DEFAULT 0,
+            scheduled_qty DECIMAL(18,6) NULL,
+            completed_op_qty DECIMAL(18,6) NULL,
+            schedule_cycle_days DECIMAL(18,6) NULL,
+            schedule_satisfaction_flag TINYINT NOT NULL DEFAULT 0,
+            wip_qty DECIMAL(18,6) NULL,
+            resource_person_count DECIMAL(18,6) NULL,
+            calc_batch_id VARCHAR(100) NOT NULL,
+            calc_time DATETIME NOT NULL,
+            update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+            UNIQUE KEY uk_dwd_order_schedule_trans (tenant_id, work_order, calc_batch_id),
+            KEY idx_dwd_order_schedule_trans_batch (calc_batch_id),
+            KEY idx_dwd_order_schedule_trans_stat (tenant_id, stat_date),
+            KEY idx_dwd_order_schedule_trans_order (tenant_id, sales_order_no)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S2订单工单排程DWD';
+        """,
+        """
+        INSERT INTO mdp_entity
+        (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, status, remark)
+        SELECT 0, s.id, v.entity_code, v.entity_name, 'TABLE', v.source_table_name, 'mdp_stg_schedule', 'FULL', 5000, 1, v.remark
+        FROM mdp_source s
+        JOIN (
+            SELECT 'S2_WORK_ORDER_MASTER' AS entity_code, 'S2工单主数据' AS entity_name, 'WorkOrdMaster' AS source_table_name, '工单主数据进入 S2 贴源层' AS remark
+            UNION ALL SELECT 'S2_WORK_ORDER_ROUTING', 'S2工单工艺路线', 'WorkOrdRouting', '工单工艺路线进入 S2 贴源层'
+            UNION ALL SELECT 'S2_WORK_ORDER_DETAIL', 'S2工单物料明细', 'WorkOrdDetail', '工单物料需求进入 S2 贴源层'
+            UNION ALL SELECT 'S2_PERIOD_SEQUENCE_DET', 'S2工序排程计划', 'PeriodSequenceDet', '工序间衔接与排程计划进入 S2 贴源层'
+            UNION ALL SELECT 'S2_SCHEDULE_RESULT_OP', 'S2工序排产结果', 'ScheduleResultOpMaster', '工序排产结果进入 S2 贴源层'
+        ) v
+        WHERE s.tenant_id=0 AND s.source_code='AIDOPDEV_MYSQL'
+        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), batch_size=VALUES(batch_size), status=VALUES(status),
+            remark=VALUES(remark), update_time=CURRENT_TIMESTAMP;
+        """
+    };
+}