瀏覽代碼

feat(aidop): complete S3 dashboard integration

Complete the S3 MDP-backed smart operations dashboard flow and fix S3 transform null item codes so the refresh job can finish successfully. Bump Web 2.4.176 / server 1.0.147.

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 3 天之前
父節點
當前提交
08860ba775

+ 1 - 1
Web/package.json

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

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

@@ -234,6 +234,7 @@ export interface ModuleDetailPayload {
 	l3: ModuleKpiRow[];
 	syncStatus?: S2SyncStatus | null;
 	decomposition?: S2DecompositionSection[];
+	branchKpis?: S3BranchKpiGroup[];
 	trend?: S2TrendPoint[];
 	distribution?: S2DistributionRow[];
 	schedules?: S2ScheduleRow[];
@@ -283,6 +284,20 @@ export interface S2DistributionRow {
 	riskCount?: number;
 }
 
+export interface S3BranchKpiGroup {
+	branch: string;
+	title: string;
+	items: S3BranchKpi[];
+}
+
+export interface S3BranchKpi {
+	label: string;
+	value?: number | null;
+	unit?: string;
+	barPct?: number;
+	status?: 'success' | 'warning' | 'danger' | 'info' | string;
+}
+
 /** 与 smartOpsBaseQuery.baseQueryToApiParams 对齐,多余参数后端可忽略 */
 export type KanbanExtraQuery = Record<string, string>;
 
@@ -668,6 +683,35 @@ export async function refreshS2Mdp(): Promise<{
 	}
 }
 
+export async function refreshS3Mdp(): Promise<{
+	ok: boolean;
+	batchId?: string;
+	stageRows?: number;
+	standardRows?: number;
+	dwdRows?: number;
+	kpiRows?: number;
+	message?: string;
+}> {
+	try {
+		const res = await service.post('/api/AidopKanban/s3-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 同构。 */

+ 134 - 43
Web/src/views/aidop/kanban/s3.vue

@@ -11,11 +11,15 @@
           <el-button size="small" text type="primary" @click="goModelingS3">运营指标建模</el-button>
           <el-button size="small" text @click="goModelingS4Pilot">S4 服务端试点</el-button>
         </div>
-        <el-button size="small" class="btn-export">
+        <el-tag v-if="syncStatusText" size="small" :type="syncStatusType">{{ syncStatusText }}</el-tag>
+        <el-button size="small" circle :loading="isRefreshing" @click="refreshS3Data">
+          <el-icon><Refresh /></el-icon>
+        </el-button>
+        <el-button size="small" class="btn-export" @click="exportS3Report">
           <el-icon><Download /></el-icon>
           导出报表
         </el-button>
-        <el-button size="small" type="primary" class="btn-sim">
+        <el-button size="small" type="primary" class="btn-sim" @click="showStrategySimulation">
           <el-icon><DataAnalysis /></el-icon>
           策略模拟
         </el-button>
@@ -138,20 +142,10 @@
           <span class="branch-desc">侧重前端预测与拆解精准度</span>
         </div>
         <div class="branch-kpis">
-          <div class="branch-kpi">
-            <div class="bkpi-label">需求计划周期</div>
-            <div class="bkpi-value">0.8<span class="unit">d</span></div>
-            <div class="bkpi-bar"><div class="bar-fill" style="width: 80%"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">需求计划满足率</div>
-            <div class="bkpi-value success">94.2<span class="unit">%</span></div>
-            <div class="bkpi-bar success"><div class="bar-fill" style="width: 94.2%"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">需求计划人数</div>
-            <div class="bkpi-value">450<span class="unit">L/H</span></div>
-            <div class="bkpi-bar"><div class="bar-fill" style="width: 70%"></div></div>
+          <div v-for="item in demandBranchKpis" :key="item.label" class="branch-kpi">
+            <div class="bkpi-label">{{ item.label }}</div>
+            <div class="bkpi-value" :class="item.status">{{ formatKpiValue(item.value) }}<span class="unit">{{ item.unit }}</span></div>
+            <div class="bkpi-bar" :class="item.status"><div class="bar-fill" :style="{ width: `${item.barPct ?? 0}%` }"></div></div>
           </div>
         </div>
         <div class="trend-chart">
@@ -170,26 +164,18 @@
           <span class="branch-desc">侧重后端供应可执行性及交付</span>
         </div>
         <div class="branch-kpis">
-          <div class="branch-kpi">
-            <div class="bkpi-label">交货计划周期</div>
-            <div class="bkpi-value warning">2.1<span class="unit">d</span></div>
-            <div class="bkpi-bar warning"><div class="bar-fill" style="width: 60%"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">交货计划满足率</div>
-            <div class="bkpi-value danger">{{ homeS3.demandKitRatePct }}<span class="unit">%</span></div>
-            <div class="bkpi-bar danger"><div class="bar-fill" :style="{ width: `${homeS3.demandKitRatePct}%` }"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">交货计划人数</div>
-            <div class="bkpi-value">280<span class="unit">L/H</span></div>
-            <div class="bkpi-bar"><div class="bar-fill" style="width: 50%"></div></div>
+          <div v-for="item in deliveryBranchKpis" :key="item.label" class="branch-kpi">
+            <div class="bkpi-label">{{ item.label }}</div>
+            <div class="bkpi-value" :class="item.status">{{ formatKpiValue(item.value) }}<span class="unit">{{ item.unit }}</span></div>
+            <div class="bkpi-bar" :class="item.status"><div class="bar-fill" :style="{ width: `${item.barPct ?? 0}%` }"></div></div>
           </div>
         </div>
         <div class="log-section">
           <div class="log-header">
             <span class="log-title">交货异常实时日志</span>
-            <el-tag size="small" type="danger" class="critical-badge">3 条新增严重项</el-tag>
+            <el-tag size="small" :type="criticalLogCount > 0 ? 'danger' : 'success'" class="critical-badge">
+              {{ criticalLogCount }} 条严重项
+            </el-tag>
           </div>
           <div class="log-list">
             <div v-for="(item, idx) in filteredLogItems" :key="`${item.time}-${idx}`" class="log-item" :class="item.levelClass">
@@ -234,7 +220,7 @@ import { Download, DataAnalysis, Timer, CircleCheck, User, Refresh, ArrowUp, Arr
 import * as echarts from 'echarts'
 import { homeS3 } from './data/homeModulesSync'
 import { loadHomeModuleMetrics } from './data/homeModulesSync'
-import { fetchModuleDetail, fetchHomeGrid, fetchDetailKpis } from '../api/kanbanData'
+import { fetchModuleDetail, fetchHomeGrid, fetchDetailKpis, refreshS3Mdp } from '../api/kanbanData'
 import { AIDOP_LAYOUT_SAVED } from './utils/s4LayoutEvents'
 
 const MODULE_CODE = 'S3'
@@ -273,6 +259,26 @@ const s3TrendA = ref('12.5%')
 const s3TrendB = ref('4.2%')
 const s3TrendC = ref('8.1%')
 const s3TrendD = ref('2.3%')
+const syncStatus = ref(null)
+const isRefreshing = ref(false)
+const branchKpiGroups = ref([])
+
+const syncStatusText = computed(() => {
+  if (!syncStatus.value) return ''
+  const status = syncStatus.value.status || 'UNKNOWN'
+  const rows = [syncStatus.value.stageRows, syncStatus.value.standardRows, syncStatus.value.dwdRows]
+    .filter((x) => typeof x === 'number')
+    .join('/')
+  return rows ? `MDP ${status} ${rows}` : `MDP ${status}`
+})
+
+const syncStatusType = computed(() => {
+  const status = String(syncStatus.value?.status || '').toUpperCase()
+  if (status === 'SUCCESS') return 'success'
+  if (status === 'RUNNING') return 'warning'
+  if (status === 'FAILED') return 'danger'
+  return 'info'
+})
 
 const detailQuery = ref({
   ...emptySmartOpsBaseQuery(),
@@ -292,6 +298,7 @@ function onDetailQueryReset() {
 let mrpTrendChart = null
 let drillChart = null
 const logItemsAll = ref([])
+const criticalLogCount = computed(() => logItemsAll.value.filter((x) => x.levelClass === 'critical').length)
 
 const filteredLogItems = computed(() =>
   logItemsAll.value.filter((x) => {
@@ -303,10 +310,32 @@ const filteredLogItems = computed(() =>
   })
 )
 const mrpXAxis = ref(['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'])
+const mrpSeries = ref(Array.isArray(homeS3.mrpTrendSeries) ? homeS3.mrpTrendSeries : [88, 90, 89, 91, 92, 93, 94])
 const drillXAxis = ref(['节点1', '节点2', '节点3', '节点4', '节点5', '节点6', '节点7'])
 const drillPrimary = ref([82, 80, 78, 76, 75, 74, 72])
 const drillSecondary = ref([95, 94, 93, 92, 91, 93, 92])
 
+const defaultDemandBranchKpis = computed(() => [
+  { label: '需求计划周期', value: Number(homeS3.mrpDeliveryDays ?? homeS3.kitCycleDays ?? 0), unit: 'd', barPct: 80, status: 'success' },
+  { label: '需求计划满足率', value: Number(homeS3.demandKitRatePct ?? 0), unit: '%', barPct: Number(homeS3.demandKitRatePct ?? 0), status: 'success' },
+  { label: '计划覆盖SKU', value: Number(String(s3HeadCount.value).replace(/,/g, '')) || 0, unit: 'SKU', barPct: 70, status: 'info' },
+])
+
+const defaultDeliveryBranchKpis = computed(() => [
+  { label: '交货计划周期', value: Number(homeS3.deliveryPlanDays ?? 0), unit: 'd', barPct: 60, status: 'warning' },
+  { label: '交货计划满足率', value: Number(homeS3.demandKitRatePct ?? 0), unit: '%', barPct: Number(homeS3.demandKitRatePct ?? 0), status: 'danger' },
+  { label: '风险供应商', value: criticalLogCount.value, unit: '家', barPct: criticalLogCount.value > 0 ? 50 : 100, status: criticalLogCount.value > 0 ? 'danger' : 'success' },
+])
+
+const demandBranchKpis = computed(() => branchKpiGroups.value.find((x) => x.branch === 'MRP')?.items ?? defaultDemandBranchKpis.value)
+const deliveryBranchKpis = computed(() => branchKpiGroups.value.find((x) => x.branch === 'MDP')?.items ?? defaultDeliveryBranchKpis.value)
+
+const formatKpiValue = (value) => {
+  const num = Number(value)
+  if (!Number.isFinite(num)) return '--'
+  return num % 1 === 0 ? String(num) : num.toFixed(1)
+}
+
 const initMrpTrendChart = () => {
   const chartDom = document.getElementById('mrp-trend')
   if (!chartDom) return
@@ -334,7 +363,7 @@ const initMrpTrendChart = () => {
       axisLabel: { color: '#64748b' }
     },
     series: [{
-      data: homeS3.mrpTrendSeries,
+      data: mrpSeries.value,
       type: 'line',
       smooth: true,
       symbol: 'none',
@@ -409,6 +438,8 @@ const initDrillChart = () => {
 
 const initCharts = () => {
   nextTick(() => {
+    mrpTrendChart?.dispose()
+    drillChart?.dispose()
     initMrpTrendChart()
     initDrillChart()
     
@@ -419,17 +450,33 @@ const initCharts = () => {
   })
 }
 
-onMounted(async () => {
-  window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
-  loadLayoutKpis()
-  await loadHomeModuleMetrics()
-  const detail = await fetchModuleDetail('S3')
+const applyS3Detail = (detail) => {
+  syncStatus.value = detail.syncStatus ?? null
+  branchKpiGroups.value = Array.isArray(detail.branchKpis) ? detail.branchKpis : []
+
   logItemsAll.value = (detail.alerts ?? []).slice(0, 8).map((x) => ({
     time: x.time ?? '--:--:--',
     tag: `[${String(x.level ?? 'info').toUpperCase()}]`,
     msg: x.message ?? '供应协同告警',
-    levelClass: ['critical', 'high'].includes(String(x.level)) ? 'critical' : String(x.level) === 'warning' ? 'warning' : 'info'
+    levelClass: ['critical', 'danger', 'high'].includes(String(x.level)) ? 'critical' : String(x.level) === 'warning' ? 'warning' : 'info'
   }))
+
+  if (Array.isArray(detail.trend) && detail.trend.length > 0) {
+    const rows = [...detail.trend].reverse()
+    mrpXAxis.value = rows.map((x, i) => x.dateLabel || `D${i + 1}`)
+    mrpSeries.value = rows.map((x) => Math.max(0, Math.min(100, Number(x.satisfactionPct ?? x.cycleDays ?? 0))))
+  }
+
+  if (Array.isArray(detail.distribution) && detail.distribution.length > 0) {
+    drillXAxis.value = detail.distribution.map((x, i) => x.name || `节点${i + 1}`)
+    drillPrimary.value = detail.distribution.map((x) => Math.max(0, Math.min(100, Number(x.satisfactionPct ?? 0))))
+    drillSecondary.value = detail.distribution.map((x) => Math.max(0, Math.min(100, Number(x.satisfactionPct ?? 0) + 8 - Number(x.riskCount ?? 0))))
+  } else if ((detail.l2 ?? []).length > 0) {
+    drillXAxis.value = detail.l2.slice(0, 7).map((x, i) => x.metricName || `节点${i + 1}`)
+    drillPrimary.value = detail.l2.slice(0, 7).map((x) => Math.max(0, Math.min(100, Number(x.metricValue ?? 0))))
+    drillSecondary.value = drillPrimary.value.map((v) => Math.max(0, Math.min(100, Number((v + 8).toFixed(1)))))
+  }
+
   if ((detail.l2 ?? []).length > 0) {
     const vals = detail.l2.map((x) => Number(x.metricValue ?? 0))
     s3HeadCount.value = String(Math.max(1, Math.round((vals[0] ?? 124) * 10)))
@@ -437,16 +484,60 @@ onMounted(async () => {
     s3TrendB.value = `${Math.abs((vals[2] ?? 4.2) % 10).toFixed(1)}%`
     s3TrendC.value = `${Math.abs((vals[3] ?? 8.1) % 15).toFixed(1)}%`
     s3TrendD.value = `${Math.abs((vals[4] ?? 2.3) % 8).toFixed(1)}%`
-    mrpXAxis.value = detail.l2.slice(0, 7).map((x, i) => (x.statDate ? String(x.statDate).slice(5, 10) : `D${i + 1}`))
-    drillXAxis.value = detail.l2.slice(0, 7).map((x, i) => x.metricName || `节点${i + 1}`)
-    drillPrimary.value = detail.l2.slice(0, 7).map((x) => Math.max(0, Math.min(100, Number(x.metricValue ?? 0))))
-    drillSecondary.value = drillPrimary.value.map((v) => Math.max(0, Math.min(100, Number((v + 8).toFixed(1)))))
   }
+}
+
+const loadS3DashboardData = async () => {
+  await Promise.all([loadHomeModuleMetrics(), loadLayoutKpis()])
+  const detail = await fetchModuleDetail('S3')
+  applyS3Detail(detail)
   initCharts()
+}
+
+const refreshS3Data = async () => {
+  isRefreshing.value = true
+  try {
+    const result = await refreshS3Mdp()
+    if (!result.ok) {
+      ElMessage.error(result.message || 'S3 MDP 刷新失败')
+      return
+    }
+    ElMessage.success(`S3 MDP 刷新完成,批次 ${result.batchId || '-'}`)
+    await loadS3DashboardData()
+  } finally {
+    isRefreshing.value = false
+  }
+}
+
+const exportS3Report = () => {
+  const rows = [
+    ['类别', '指标', '数值', '单位'],
+    ...branchKpiGroups.value.flatMap((group) => (group.items ?? []).map((item) => [group.title, item.label, item.value ?? '', item.unit ?? ''])),
+    ...filteredLogItems.value.map((log) => ['异常日志', log.tag, log.time, log.msg]),
+  ]
+  const csv = rows.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n')
+  const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' })
+  const url = URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = `S3供应协同看板_${new Date().toISOString().slice(0, 10)}.csv`
+  link.click()
+  URL.revokeObjectURL(url)
+}
+
+const showStrategySimulation = () => {
+  ElMessage.info('策略模拟将基于 S8 预警与 S6/S3 供应策略闭环,本页当前展示只读数据底座。')
+}
+
+onMounted(async () => {
+  window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
+  await loadS3DashboardData()
 })
 
 onUnmounted(() => {
   window.removeEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
+  mrpTrendChart?.dispose()
+  drillChart?.dispose()
 })
 </script>
 

+ 1 - 0
doc/README.md

@@ -53,6 +53,7 @@
 | [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/S3数据中台与看板动态化验收.md](./plan/数据库迁移/S3/S3数据中台与看板动态化验收.md) | S3 供应协同数据中台链路、看板动态化与验收 SQL |
 | [plan/数据中台模块扩展开发指南-S3范式.md](./plan/数据中台模块扩展开发指南-S3范式.md) | 数据中台扩展(仿 S3):四库逻辑、作业与 Cursor 协作 |
 | [db/mdp/README.md](./db/mdp/README.md) | S4/S8 相关 mdp、dwd 等建表脚本说明与执行顺序 |
 | [指标模型动态配置方案.md](./指标模型动态配置方案.md) | 指标模型动态配置总体方案 |

+ 81 - 0
doc/plan/数据库迁移/S3/S3数据中台与看板动态化验收.md

@@ -0,0 +1,81 @@
+# S3 数据中台与看板动态化验收
+
+## 目标范围
+
+本轮将 S3 供应协同详情看板补齐到参照 S1 的动态化水平,覆盖:
+
+- 后端 `module-detail` 返回 S3 同步状态、分支 KPI、近 7 日趋势、供应商健康度分布、异常日志。
+- 前端 `S3 供应协同` 详情页由接口数据驱动顶部状态、分支 KPI、趋势图、健康度图和异常日志。
+- 页面支持手动触发 `S3_MDP_SYNC_TRANSFORM`、导出当前看板 CSV,并对策略模拟给出只读底座提示。
+
+## 后端接口
+
+| 接口 | 用途 |
+|------|------|
+| `GET /api/AidopKanban/module-detail?moduleCode=S3&factoryId=1` | 读取 S3 L2/L3 KPI、MDP 同步状态、分支 KPI、趋势、分布、异常日志 |
+| `POST /api/AidopKanban/s3-mdp/refresh` | 手动触发 S3 MDP/STG/STD/DWD/KPI 全链路转换 |
+
+主要数据来源:
+
+- `mdp_transform_run_log`:最近一次 `S3_MDP_SYNC_TRANSFORM` 状态。
+- `ado_s9_kpi_value_l1_day`、`ado_s9_kpi_value_l2_day`:S3 L1/L2 KPI 与趋势。
+- `dwd_material_readiness`:物料需求计划覆盖、齐套率与缺口。
+- `dwd_supplier_delivery`:供应商交货满足率、风险数量与健康度分布。
+- `dwd_supplier_risk`:交货异常实时日志。
+
+## 前端验收点
+
+- 页面顶部显示最近一次 MDP 状态,并支持刷新按钮触发 S3 转换。
+- 分支一「物料需求计划」展示需求计划周期、需求计划满足率、计划覆盖 SKU。
+- 分支二「物料交货计划」展示交货计划周期、交货计划满足率、风险供应商。
+- 近 7 日趋势图优先使用接口 `trend` 数据,无数据时保持本地兜底。
+- 健康度下钻图优先使用接口 `distribution` 数据,无数据时回退 L2 KPI。
+- 异常日志来自 `dwd_supplier_risk`,严重项徽标随日志级别动态变化。
+- 导出报表输出当前分支 KPI 和筛选后的异常日志。
+
+## 验收 SQL
+
+```sql
+SELECT job_code, status, stage_rows, standard_rows, dwd_rows, start_time, end_time, error_message
+FROM mdp_transform_run_log
+WHERE job_code = 'S3_MDP_SYNC_TRANSFORM'
+ORDER BY start_time DESC, id DESC
+LIMIT 5;
+
+SELECT module_code, metric_code, metric_value, target_value, status_color, trend_flag, biz_date
+FROM ado_s9_kpi_value_l1_day
+WHERE module_code = 'S3' AND is_deleted = 0
+ORDER BY biz_date DESC, metric_code
+LIMIT 20;
+
+SELECT stat_date, COUNT(*) AS rows_count, COUNT(DISTINCT component_item_code) AS sku_count,
+       SUM(IFNULL(shortage_qty, 0)) AS shortage_qty
+FROM dwd_material_readiness
+GROUP BY stat_date
+ORDER BY stat_date DESC
+LIMIT 5;
+
+SELECT stat_date, COUNT(*) AS rows_count, COUNT(DISTINCT supplier_code) AS supplier_count,
+       SUM(CASE WHEN risk_level IN ('HIGH','MEDIUM') THEN 1 ELSE 0 END) AS risk_rows
+FROM dwd_supplier_delivery
+GROUP BY stat_date
+ORDER BY stat_date DESC
+LIMIT 5;
+
+SELECT stat_date, risk_level, COUNT(*) AS risk_count
+FROM dwd_supplier_risk
+GROUP BY stat_date, risk_level
+ORDER BY stat_date DESC, FIELD(risk_level, 'HIGH', 'MEDIUM', 'LOW');
+```
+
+## 本轮验收记录
+
+- 首次验收发现最近 `S3_MDP_SYNC_TRANSFORM` 失败,错误为 `Column 'item_code' cannot be null`。
+- 已在 S3 标准层/DWD 转换 SQL 中对历史脏数据的 `item_code` 做空串兜底,并避免空物料编码中断批次。
+- 手动调用 `POST /api/AidopKanban/s3-mdp/refresh` 成功,返回批次 `S3_MDP_FULL_20260603033924`。
+- 最新转换日志:`status=SUCCESS`,`stage_rows=41636`,`standard_rows=111478`,`dwd_rows=4115`,`error_message=NULL`。
+- 数据库抽查:`ado_s9_kpi_value_l1_day` 已有 S3 L1 指标;`dwd_material_readiness` 最新统计 13 行、6 个 SKU、缺口 36;`dwd_supplier_delivery` 最新统计 12 行、3 个供应商、5 条中高风险。
+
+## 边界说明
+
+本轮只补 S3 供应协同详情看板的数据读取、刷新、展示和导出,不新增 S6/S8 写入闭环,不修改 S3 建表结构,也不改变已存在的 S3 MDP 转换口径。

+ 3 - 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.146</AssemblyVersion>
-    <FileVersion>1.0.146</FileVersion>
-    <Version>1.0.146</Version>
+    <AssemblyVersion>1.0.147</AssemblyVersion>
+    <FileVersion>1.0.147</FileVersion>
+    <Version>1.0.147</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 325 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -2,6 +2,7 @@ using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Production;
+using Admin.NET.Plugin.AiDOP.Supply;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -14,11 +15,13 @@ public partial class AidopKanbanController : ControllerBase
 {
     private readonly ISqlSugarClient _db;
     private readonly S2MdpSyncTransformService _s2MdpSyncTransformService;
+    private readonly S3MdpSyncTransformService _s3MdpSyncTransformService;
 
-    public AidopKanbanController(ISqlSugarClient db, S2MdpSyncTransformService s2MdpSyncTransformService)
+    public AidopKanbanController(ISqlSugarClient db, S2MdpSyncTransformService s2MdpSyncTransformService, S3MdpSyncTransformService s3MdpSyncTransformService)
     {
         _db = db;
         _s2MdpSyncTransformService = s2MdpSyncTransformService;
+        _s3MdpSyncTransformService = s3MdpSyncTransformService;
     }
 
     [HttpGet("home-l1")]
@@ -146,6 +149,7 @@ LIMIT 60
         var decomposition = new List<S2DecompositionDto>();
         var trend = new List<S2TrendDto>();
         var distribution = new List<S2DistributionDto>();
+        var branchKpis = new List<S3BranchKpiGroupDto>();
         S2SyncStatusDto? syncStatus = null;
         if (moduleCode == "S2")
         {
@@ -189,6 +193,17 @@ LIMIT 60
                 schedules = new List<S2ScheduleDto>();
             }
         }
+        if (moduleCode == "S3")
+        {
+            syncStatus = await GetMdpSyncStatusAsync("S3_MDP_SYNC_TRANSFORM");
+            var s3Alerts = await GetS3DerivedAlertsAsync(tenantId, factoryId);
+            if (s3Alerts.Count > 0)
+                alerts = s3Alerts;
+            decomposition = await GetS3DecompositionAsync(tenantId, factoryId, l2);
+            branchKpis = await GetS3BranchKpisAsync(tenantId, factoryId, l2);
+            trend = await GetS3TrendAsync(tenantId, factoryId);
+            distribution = await GetS3DistributionAsync(tenantId, factoryId);
+        }
         return Ok(new
         {
             moduleCode,
@@ -196,6 +211,7 @@ LIMIT 60
             l3,
             syncStatus,
             decomposition,
+            branchKpis,
             trend,
             distribution,
             schedules,
@@ -223,7 +239,27 @@ LIMIT 60
         });
     }
 
+    [HttpPost("s3-mdp/refresh")]
+    public async Task<IActionResult> RefreshS3Mdp(CancellationToken cancellationToken)
+    {
+        var result = await _s3MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            result.BatchId,
+            result.StageRows,
+            result.StandardRows,
+            result.DwdRows,
+            result.KpiRows
+        });
+    }
+
     private async Task<S2SyncStatusDto?> GetS2SyncStatusAsync()
+    {
+        return await GetMdpSyncStatusAsync("S2_MDP_SYNC_TRANSFORM");
+    }
+
+    private async Task<S2SyncStatusDto?> GetMdpSyncStatusAsync(string jobCode)
     {
         try
         {
@@ -233,10 +269,11 @@ LIMIT 60
                        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'
+                WHERE job_code=@JobCode
                 ORDER BY start_time DESC, id DESC
                 LIMIT 1
-                """);
+                """,
+                new SugarParameter("@JobCode", jobCode));
         }
         catch
         {
@@ -457,6 +494,230 @@ LIMIT 60
         }
     }
 
+    private async Task<List<S2DecompositionDto>> GetS3DecompositionAsync(long tenantId, long factoryId, List<KpiDetailDto> l2)
+    {
+        var l1 = await GetLatestModuleL1Async("S3", tenantId, factoryId);
+        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 readiness = await GetS3ReadinessSummaryAsync(tenantId);
+        var delivery = await GetS3DeliverySummaryAsync(tenantId);
+
+        return new List<S2DecompositionDto>
+        {
+            new()
+            {
+                Title = "物料需求计划",
+                Active = true,
+                Metrics = new List<string>
+                {
+                    $"1. 周期:{FormatMetric(l1, "S3_L1_001", "天")}",
+                    $"2. 满足率:{Math.Round(readiness.ReadyRatePct ?? GetMetricValue(l1, "S3_L1_002") ?? 0, 2)}%",
+                    $"3. 覆盖SKU:{readiness.ComponentCount}",
+                    $"4. 缺口数量:{Math.Round(readiness.ShortageQty ?? 0, 2)}"
+                }
+            },
+            new()
+            {
+                Title = "物料交货计划",
+                Metrics = new List<string>
+                {
+                    $"1. 周期:{FormatMetric(latestL2, "S3_L2_004", "天")}",
+                    $"2. 满足率:{FormatMetric(latestL2, "S3_L2_005", "%")}",
+                    $"3. 风险供应商:{delivery.RiskSupplierCount}",
+                    $"4. 待交数量:{Math.Round(delivery.RemainingQty ?? 0, 2)}"
+                }
+            }
+        };
+    }
+
+    private async Task<List<S3BranchKpiGroupDto>> GetS3BranchKpisAsync(long tenantId, long factoryId, List<KpiDetailDto> l2)
+    {
+        var l1 = await GetLatestModuleL1Async("S3", tenantId, factoryId);
+        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 readiness = await GetS3ReadinessSummaryAsync(tenantId);
+        var delivery = await GetS3DeliverySummaryAsync(tenantId);
+
+        var demandCycle = GetMetricValue(l1, "S3_L1_001");
+        var demandReadyRate = readiness.ReadyRatePct ?? GetMetricValue(l1, "S3_L1_002");
+        var deliveryCycle = GetMetricValue(latestL2, "S3_L2_004");
+        var deliveryRate = GetMetricValue(latestL2, "S3_L2_005");
+
+        return new List<S3BranchKpiGroupDto>
+        {
+            new()
+            {
+                Branch = "MRP",
+                Title = "物料需求计划",
+                Items = new List<S3BranchKpiDto>
+                {
+                    BuildBranchKpi("需求计划周期", demandCycle, "d", InverseBar(demandCycle, 15m)),
+                    BuildBranchKpi("需求计划满足率", demandReadyRate, "%", PercentBar(demandReadyRate)),
+                    BuildBranchKpi("计划覆盖SKU", readiness.ComponentCount, "SKU", PercentBar(readiness.ComponentCount, Math.Max(readiness.ComponentCount, 1)))
+                }
+            },
+            new()
+            {
+                Branch = "MDP",
+                Title = "物料交货计划",
+                Items = new List<S3BranchKpiDto>
+                {
+                    BuildBranchKpi("交货计划周期", deliveryCycle, "d", InverseBar(deliveryCycle, 10.5m)),
+                    BuildBranchKpi("交货计划满足率", deliveryRate, "%", PercentBar(deliveryRate)),
+                    BuildBranchKpi("风险供应商", delivery.RiskSupplierCount, "家", InverseBar(delivery.RiskSupplierCount, Math.Max(delivery.SupplierCount, 1)))
+                }
+            }
+        };
+    }
+
+    private async Task<List<S2TrendDto>> GetS3TrendAsync(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='S3_L1_001' THEN d.metric_value END) AS CycleDays,
+                       MAX(CASE WHEN d.metric_code='S3_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='S3' AND d.is_deleted=0
+                  AND d.metric_code IN ('S3_L1_001','S3_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>> GetS3DistributionAsync(long tenantId, long factoryId)
+    {
+        _ = factoryId;
+        try
+        {
+            return await _db.Ado.SqlQueryAsync<S2DistributionDto>(
+                """
+                SELECT COALESCE(NULLIF(supplier_name,''), NULLIF(supplier_code,''), '未分配') AS Name,
+                       ROUND(100 * SUM(CASE WHEN IFNULL(schedule_qty,0) >= IFNULL(order_qty,0) AND IFNULL(order_qty,0) > 0 THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 2) AS SatisfactionPct,
+                       COUNT(1) AS TotalCount,
+                       SUM(CASE WHEN risk_level IN ('HIGH','MEDIUM') THEN 1 ELSE 0 END) AS RiskCount
+                FROM dwd_supplier_delivery
+                WHERE tenant_id=@tenantId
+                  AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_delivery WHERE tenant_id=@tenantId)
+                GROUP BY COALESCE(NULLIF(supplier_name,''), NULLIF(supplier_code,''), '未分配')
+                ORDER BY RiskCount DESC, SatisfactionPct, TotalCount DESC
+                LIMIT 8
+                """,
+                new { tenantId });
+        }
+        catch
+        {
+            return new List<S2DistributionDto>();
+        }
+    }
+
+    private async Task<List<S8AlertDto>> GetS3DerivedAlertsAsync(long tenantId, long factoryId)
+    {
+        _ = factoryId;
+        try
+        {
+            return await _db.Ado.SqlQueryAsync<S8AlertDto>(
+                """
+                SELECT DATE_FORMAT(calc_time, '%H:%i:%s') AS Time,
+                       CASE WHEN risk_level='HIGH' THEN 'critical'
+                            WHEN risk_level='MEDIUM' THEN 'warning'
+                            ELSE 'info' END AS LevelCode,
+                       CONCAT('供应商 ', COALESCE(NULLIF(supplier_name,''), supplier_code, '未分配'),
+                              ' 物料 ', COALESCE(NULLIF(item_code,''), '未指定'),
+                              ':', COALESCE(NULLIF(risk_reason,''), risk_type, '供应风险')) AS Message
+                FROM dwd_supplier_risk
+                WHERE tenant_id=@tenantId
+                  AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_risk WHERE tenant_id=@tenantId)
+                ORDER BY FIELD(risk_level, 'HIGH', 'MEDIUM', 'LOW'), risk_count DESC, calc_time DESC
+                LIMIT 20
+                """,
+                new { tenantId });
+        }
+        catch
+        {
+            return new List<S8AlertDto>();
+        }
+    }
+
+    private async Task<Dictionary<string, KpiDetailDto>> GetLatestModuleL1Async(string moduleCode, long tenantId, long factoryId)
+    {
+        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=@moduleCode 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=@moduleCode AND is_deleted=0)
+                """,
+                new { moduleCode, tenantId, factoryId });
+            return rows.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
+                .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
+                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
+        }
+        catch
+        {
+            return new Dictionary<string, KpiDetailDto>(StringComparer.OrdinalIgnoreCase);
+        }
+    }
+
+    private async Task<S3ReadinessSummaryDto> GetS3ReadinessSummaryAsync(long tenantId)
+    {
+        try
+        {
+            return await _db.Ado.SqlQuerySingleAsync<S3ReadinessSummaryDto>(
+                """
+                SELECT COUNT(DISTINCT component_item_code) AS ComponentCount,
+                       ROUND(100 * SUM(CASE WHEN IFNULL(shortage_qty,0) <= 0 OR UPPER(IFNULL(ready_status,'')) IN ('READY','SUFFICIENT','OK') THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 2) AS ReadyRatePct,
+                       SUM(IFNULL(shortage_qty, 0)) AS ShortageQty
+                FROM dwd_material_readiness
+                WHERE tenant_id=@tenantId
+                  AND stat_date=(SELECT MAX(stat_date) FROM dwd_material_readiness WHERE tenant_id=@tenantId)
+                """,
+                new { tenantId }) ?? new S3ReadinessSummaryDto();
+        }
+        catch
+        {
+            return new S3ReadinessSummaryDto();
+        }
+    }
+
+    private async Task<S3DeliverySummaryDto> GetS3DeliverySummaryAsync(long tenantId)
+    {
+        try
+        {
+            return await _db.Ado.SqlQuerySingleAsync<S3DeliverySummaryDto>(
+                """
+                SELECT COUNT(DISTINCT supplier_code) AS SupplierCount,
+                       COUNT(DISTINCT CASE WHEN risk_level IN ('HIGH','MEDIUM') THEN supplier_code END) AS RiskSupplierCount,
+                       SUM(IFNULL(remaining_qty, 0)) AS RemainingQty
+                FROM dwd_supplier_delivery
+                WHERE tenant_id=@tenantId
+                  AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_delivery WHERE tenant_id=@tenantId)
+                """,
+                new { tenantId }) ?? new S3DeliverySummaryDto();
+        }
+        catch
+        {
+            return new S3DeliverySummaryDto();
+        }
+    }
+
     private static string FormatMetric(Dictionary<string, KpiDetailDto> rows, string metricCode, string unit)
     {
         if (!rows.TryGetValue(metricCode, out var row) || row.MetricValue == null)
@@ -464,6 +725,37 @@ LIMIT 60
         return $"{Math.Round(row.MetricValue.Value, 2)}{unit}";
     }
 
+    private static decimal? GetMetricValue(Dictionary<string, KpiDetailDto> rows, string metricCode)
+    {
+        return rows.TryGetValue(metricCode, out var row) ? row.MetricValue : null;
+    }
+
+    private static S3BranchKpiDto BuildBranchKpi(string label, decimal? value, string unit, decimal barPct)
+    {
+        return new S3BranchKpiDto
+        {
+            Label = label,
+            Value = value,
+            Unit = unit,
+            BarPct = barPct,
+            Status = barPct >= 90 ? "success" : barPct >= 70 ? "warning" : "danger"
+        };
+    }
+
+    private static decimal PercentBar(decimal? value, decimal max = 100m)
+    {
+        if (value == null || max <= 0)
+            return 0;
+        return Math.Round(Math.Max(0, Math.Min(100, value.Value / max * 100)), 2);
+    }
+
+    private static decimal InverseBar(decimal? value, decimal target)
+    {
+        if (value == null || value <= 0 || target <= 0)
+            return 0;
+        return Math.Round(Math.Max(0, Math.Min(100, target / value.Value * 100)), 2);
+    }
+
     /// <summary>
     /// 智慧运营看板基础查询下拉:产品、订单号、产线(来自 Demo 业务表;无租户列时忽略 tenant/factory)。
     /// </summary>
@@ -656,5 +948,35 @@ LIMIT 60
         public int RiskCount { get; set; }
         public int LineCount { get; set; }
     }
+
+    private sealed class S3BranchKpiGroupDto
+    {
+        public string Branch { get; set; } = string.Empty;
+        public string Title { get; set; } = string.Empty;
+        public List<S3BranchKpiDto> Items { get; set; } = new();
+    }
+
+    private sealed class S3BranchKpiDto
+    {
+        public string Label { get; set; } = string.Empty;
+        public decimal? Value { get; set; }
+        public string Unit { get; set; } = string.Empty;
+        public decimal BarPct { get; set; }
+        public string Status { get; set; } = "info";
+    }
+
+    private sealed class S3ReadinessSummaryDto
+    {
+        public int ComponentCount { get; set; }
+        public decimal? ReadyRatePct { get; set; }
+        public decimal? ShortageQty { get; set; }
+    }
+
+    private sealed class S3DeliverySummaryDto
+    {
+        public int SupplierCount { get; set; }
+        public int RiskSupplierCount { get; set; }
+        public decimal? RemainingQty { get; set; }
+    }
 }
 

+ 14 - 13
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

@@ -350,7 +350,7 @@ public class S3MdpSyncTransformService : ITransient
             INSERT INTO mdp_std_supplier_item
             (tenant_id, factory_id, company_id, source_system, item_code, supplier_code, supplier_name, supplier_type, quota_rate, lead_time, min_qty, packaging_qty, price, currency_type, effective_date, expire_date, status, source_biz_key, sync_batch_id, sync_time)
             SELECT tenant_id, factory_id, company_id, 'AIDOP',
-                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.icitem_number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.icitem_name'))),
+                   IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.icitem_number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.icitem_name'))), ''),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.supplier_number')),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.supplier_name')),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.supplier_type')),
@@ -369,6 +369,7 @@ public class S3MdpSyncTransformService : ITransient
                    source_biz_key, @BatchId, @Now
             FROM mdp_stg_source_list
             WHERE source_table='srm_purchase' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.supplier_number')), '') <> ''
+              AND IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.icitem_number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.icitem_name'))), '') <> ''
             ON DUPLICATE KEY UPDATE supplier_name=VALUES(supplier_name), quota_rate=VALUES(quota_rate), lead_time=VALUES(lead_time),
                 min_qty=VALUES(min_qty), packaging_qty=VALUES(packaging_qty), price=VALUES(price), sync_batch_id=VALUES(sync_batch_id),
                 sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
@@ -382,8 +383,8 @@ public class S3MdpSyncTransformService : ITransient
                    COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.number')), source_row_id),
                    COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.line')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Line'))),
                    source_table,
-                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemnum')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum'))),
-                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemname')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_name')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Descr'))),
+                   IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemnum')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_number')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum'))), ''),
+                   IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemname')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_name')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Descr'))), ''),
                    COALESCE(
                        CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.qty')) AS DECIMAL(18,6)) END,
                        CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.required_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.required_qty')) AS DECIMAL(18,6)) END,
@@ -407,8 +408,8 @@ public class S3MdpSyncTransformService : ITransient
             SELECT tenant_id, factory_id, company_id, 'AIDOP',
                    COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.pr_billno')), source_row_id),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.line_no')),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_number')),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_name')),
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_number')), ''),
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_name')), ''),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.supplier_number')),
                    COALESCE(
                        CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.qty')) AS DECIMAL(18,6)) END,
@@ -435,8 +436,8 @@ public class S3MdpSyncTransformService : ITransient
                    CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Line')) AS CHAR),
                    JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Potype')),
                    JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Supp')),
-                   JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ItemNum')),
-                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(i.raw_data,'$.Descr')), JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Descr'))),
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ItemNum')), ''),
+                   IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(i.raw_data,'$.Descr')), JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Descr'))), ''),
                    COALESCE(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.QtyOrded')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.QtyOrded')) AS DECIMAL(18,6)) END, 0),
                    COALESCE(
                        CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.QtyReceived')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.QtyReceived')) AS DECIMAL(18,6)) END,
@@ -471,7 +472,7 @@ public class S3MdpSyncTransformService : ITransient
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.dsnum')),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ponumber')),
                    CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.poline')) AS CHAR),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemnum')),
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemnum')), ''),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.suppliercode')),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.supplier')),
                    COALESCE(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.schedqty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.schedqty')) AS DECIMAL(18,6)) END,0),
@@ -502,7 +503,7 @@ public class S3MdpSyncTransformService : ITransient
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.dsnum')),
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.po_bill')),
                    CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.po_billline')) AS CHAR),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemnum')),
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.itemnum')), ''),
                    COALESCE(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.sh_delivery_quantity')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.sh_delivery_quantity')) AS DECIMAL(18,6)) END,0),
                    0,
                    0,
@@ -524,7 +525,7 @@ public class S3MdpSyncTransformService : ITransient
             SELECT tenant_id, 'AIDOP',
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')),
                    CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Op')) AS CHAR),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PMBOM')),
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PMBOM')), ''),
                    COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')), source_row_id),
                    COALESCE(
                        CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyRequired')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyRequired')) AS DECIMAL(18,6)) END,
@@ -573,7 +574,7 @@ public class S3MdpSyncTransformService : ITransient
             """
             INSERT INTO dwd_supply_demand
             (tenant_id, stat_date, demand_no, demand_line, demand_type, item_code, item_name, supplier_code, required_qty, fulfilled_qty, shortage_qty, required_date, demand_status, source_system, sync_batch_id, calc_time)
-            SELECT tenant_id, @StatDate, demand_no, IFNULL(demand_line,''), demand_type, item_code, item_name, supplier_code,
+            SELECT tenant_id, @StatDate, demand_no, IFNULL(demand_line,''), demand_type, IFNULL(item_code,''), IFNULL(item_name,''), supplier_code,
                    IFNULL(required_qty,0), 0, IFNULL(required_qty,0), required_date, status, source_system, @BatchId, @Now
             FROM mdp_std_supply_demand
             WHERE IFNULL(demand_no,'') <> ''
@@ -587,7 +588,7 @@ public class S3MdpSyncTransformService : ITransient
             INSERT INTO dwd_supplier_delivery
             (tenant_id, stat_date, po_no, po_line, po_type, supplier_code, supplier_name, item_code, item_name, order_qty, schedule_qty, delivery_qty, receipt_qty, return_qty, remaining_qty, due_date, need_date, last_delivery_date, last_receipt_date, delivery_status, risk_level, source_system, sync_batch_id, calc_time)
             SELECT po.tenant_id, @StatDate, po.po_no, po.po_line, po.po_type, po.supplier_code, IFNULL(s.supplier_name, ds.supplier_name),
-                   po.item_code, po.item_name, IFNULL(po.order_qty,0), IFNULL(ds.schedule_qty,0), IFNULL(dr.delivery_qty,0),
+                   IFNULL(po.item_code,''), IFNULL(po.item_name,''), IFNULL(po.order_qty,0), IFNULL(ds.schedule_qty,0), IFNULL(dr.delivery_qty,0),
                    IFNULL(po.received_qty,0) + IFNULL(dr.receipt_qty,0), IFNULL(po.returned_qty,0) + IFNULL(dr.return_qty,0),
                    GREATEST(IFNULL(po.order_qty,0) - IFNULL(po.received_qty,0) - IFNULL(dr.receipt_qty,0) - IFNULL(po.returned_qty,0), 0),
                    po.due_date, COALESCE(ds.need_date, po.need_date), dr.event_time, dr.receipt_date,
@@ -635,7 +636,7 @@ public class S3MdpSyncTransformService : ITransient
             """
             INSERT INTO dwd_supplier_risk
             (tenant_id, stat_date, supplier_code, supplier_name, item_code, risk_type, risk_level, risk_count, risk_qty, risk_reason, source_system, calc_batch_id, calc_time)
-            SELECT tenant_id, stat_date, IFNULL(supplier_code,''), supplier_name, item_code,
+            SELECT tenant_id, stat_date, IFNULL(supplier_code,''), supplier_name, IFNULL(item_code,''),
                    'DELIVERY_DELAY', risk_level, COUNT(1), SUM(IFNULL(remaining_qty,0)),
                    '供应交付存在延期或未完成风险', 'AIDOP', @BatchId, @Now
             FROM dwd_supplier_delivery