소스 검색

feat(mdp): 收口双模式剩余代码门禁(Web 2.4.258 / server 1.0.264)

S6报工stg/std与契约、S1-S4 std契约、健康检查、S8 API取数、S9 API_OUT、参数公式UI、S3 Outbox占位。

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 2 달 전
부모
커밋
2bad715077
28개의 변경된 파일과 1646개의 추가작업 그리고 86개의 파일을 삭제
  1. 1 1
      Web/package.json
  2. 62 0
      Web/src/views/aidop/data-platform/api/syncTasks.ts
  3. 36 24
      Web/src/views/aidop/data-platform/sources.vue
  4. 128 2
      Web/src/views/aidop/data-platform/syncTasks.vue
  5. 48 0
      doc/db/mdp/contract_tests/apply_1_0_264.py
  6. 113 0
      doc/db/mdp/contract_tests/run_contract_s6_report.py
  7. 197 0
      doc/db/mdp/contract_tests/run_contract_std_s1_s4.py
  8. 2 1
      doc/db/mdp/mock_api/endpoints.json
  9. 18 0
      doc/db/mdp/mock_api/samples/mes_report.json
  10. 16 15
      doc/plan/AIDOP双模式全模块对接交付级任务书.md
  11. 5 4
      doc/plan/Ai-DOP项目待办清单.md
  12. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  13. 91 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.264.sql
  14. 28 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
  15. 96 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpApiOutController.cs
  16. 60 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceConfigService.cs
  17. 162 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSyncTaskConfigService.cs
  18. 65 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpSyncTaskFormula.cs
  19. 53 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpSyncTaskParam.cs
  20. 7 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/QmsTenantScope.cs
  21. 83 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs
  22. 151 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/ReportWorkMdpSyncService.cs
  23. 119 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8DataSourceRowLoader.cs
  24. 11 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs
  25. 11 9
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs
  26. 11 9
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs
  27. 22 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs
  28. 47 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestExternalPushService.cs

+ 1 - 1
Web/package.json

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

+ 62 - 0
Web/src/views/aidop/data-platform/api/syncTasks.ts

@@ -221,3 +221,65 @@ export function updateFieldMapping(id: number, payload: MdpFieldMappingUpsertInp
 export function deleteFieldMapping(id: number) {
 	return service.delete<{ id: number }>(`/api/DataPlatform/field-mappings/${id}`).then((r) => r.data);
 }
+
+export interface MdpSyncTaskParamRow {
+	id?: number;
+	taskCode?: string;
+	scopeType?: string;
+	scopeCode?: string;
+	paramKey: string;
+	paramName?: string;
+	paramType?: string;
+	paramValue?: string | null;
+	defaultValue?: string | null;
+	required?: boolean;
+	editable?: boolean;
+	sortOrder?: number;
+	description?: string | null;
+}
+
+export interface MdpSyncTaskFormulaRow {
+	id?: number;
+	taskCode?: string;
+	stepCode?: string | null;
+	formulaCode: string;
+	formulaName?: string;
+	metricCode?: string | null;
+	formulaExpr?: string | null;
+	formulaPreview?: string | null;
+	direction?: string;
+	yellowThreshold?: number | null;
+	redThreshold?: number | null;
+	versionNo?: number;
+	enabled?: boolean;
+	sortOrder?: number;
+	description?: string | null;
+}
+
+export function fetchSyncTaskParams(taskCode: string) {
+	return service
+		.get<{ list: MdpSyncTaskParamRow[] }>(`/api/DataPlatform/sync-tasks/${encodeURIComponent(taskCode)}/params`)
+		.then((r) => r.data?.list ?? []);
+}
+
+export function saveSyncTaskParams(taskCode: string, items: MdpSyncTaskParamRow[]) {
+	return service
+		.put<{ ok: boolean; count: number }>(`/api/DataPlatform/sync-tasks/${encodeURIComponent(taskCode)}/params`, items)
+		.then((r) => r.data);
+}
+
+export function fetchSyncTaskFormulas(taskCode: string) {
+	return service
+		.get<{ list: MdpSyncTaskFormulaRow[] }>(`/api/DataPlatform/sync-tasks/${encodeURIComponent(taskCode)}/formulas`)
+		.then((r) => r.data?.list ?? []);
+}
+
+export function saveSyncTaskFormulas(taskCode: string, items: MdpSyncTaskFormulaRow[]) {
+	return service
+		.put<{ ok: boolean; count: number }>(`/api/DataPlatform/sync-tasks/${encodeURIComponent(taskCode)}/formulas`, items)
+		.then((r) => r.data);
+}
+
+export function fetchMdpSources(params?: { keyword?: string; type?: string }) {
+	return service.get<{ list: any[] }>('/api/DataPlatform/sources', { params }).then((r) => r.data?.list ?? []);
+}

+ 36 - 24
Web/src/views/aidop/data-platform/sources.vue

@@ -1,14 +1,13 @@
 <template>
-	<AidopDemoShell title="数据源管理" subtitle="演示数据库、API 入站端点、API 拉取端点和 API 出站服务接入状态">
+	<AidopDemoShell title="数据源管理" subtitle="mdp_source 真实登记:DB / API 源与健康状态">
 		<template #bar-right>
-			<el-button type="primary" icon="ele-CirclePlus" plain>新增数据源</el-button>
-			<el-button icon="ele-Refresh">连接测试</el-button>
+			<el-button icon="ele-Refresh" :loading="loading" @click="loadList">刷新</el-button>
 		</template>
 
 		<el-card shadow="never" class="mb12">
-			<el-form :inline="true" :model="query">
+			<el-form :inline="true" :model="query" @submit.prevent>
 				<el-form-item label="关键字">
-					<el-input v-model="query.keyword" placeholder="编码 / 名称" clearable />
+					<el-input v-model="query.keyword" placeholder="编码 / 名称" clearable @keyup.enter="loadList" />
 				</el-form-item>
 				<el-form-item label="类型">
 					<el-select v-model="query.type" placeholder="全部" clearable style="width: 140px">
@@ -17,14 +16,14 @@
 					</el-select>
 				</el-form-item>
 				<el-form-item>
-					<el-button type="primary" icon="ele-Search">查询</el-button>
-					<el-button icon="ele-RefreshLeft">重置</el-button>
+					<el-button type="primary" icon="ele-Search" @click="loadList">查询</el-button>
+					<el-button icon="ele-RefreshLeft" @click="resetQuery">重置</el-button>
 				</el-form-item>
 			</el-form>
 		</el-card>
 
-		<el-table :data="rows" border>
-			<el-table-column prop="sourceCode" label="编码" width="130" />
+		<el-table :data="rows" v-loading="loading" border>
+			<el-table-column prop="sourceCode" label="编码" width="160" />
 			<el-table-column prop="sourceName" label="名称" min-width="160" />
 			<el-table-column prop="sourceType" label="类型" width="100" />
 			<el-table-column prop="direction" label="方向" width="90" align="center" />
@@ -35,33 +34,46 @@
 					<el-tag :type="row.healthType" effect="plain">{{ row.health }}</el-tag>
 				</template>
 			</el-table-column>
-			<el-table-column prop="lastCheck" label="最近检测" width="160" />
-			<el-table-column label="操作" width="180" fixed="right" align="center">
-				<template #default>
-					<el-button link type="primary">编辑</el-button>
-					<el-button link type="primary">检测</el-button>
-					<el-button link type="danger">停用</el-button>
-				</template>
-			</el-table-column>
+			<el-table-column prop="lastCheck" label="最近检测" width="170" />
+			<el-table-column prop="healthMsg" label="探活信息" min-width="160" show-overflow-tooltip />
 		</el-table>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopDataPlatformSources">
-import { reactive } from 'vue';
+import { onMounted, reactive, ref } from 'vue';
+import { ElMessage } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
+import { fetchMdpSources } from './api/syncTasks';
 
+const loading = ref(false);
+const rows = ref<any[]>([]);
 const query = reactive({
 	keyword: '',
 	type: '',
 });
 
-const rows = [
-	{ sourceCode: 'LEGACY_SQL', sourceName: '旧系统 SQL Server', sourceType: 'DB', direction: '入站', dbType: 'SQLServer', host: 'dopdemorq / Business', health: '正常', healthType: 'success', lastCheck: '10 分钟前' },
-	{ sourceCode: 'ERP_API', sourceName: 'ERP 主数据 API', sourceType: 'API', direction: '拉取', dbType: '-', host: 'https://erp.example.local/api/master-data', health: '正常', healthType: 'success', lastCheck: '25 分钟前' },
-	{ sourceCode: 'WMS_WEBHOOK', sourceName: '仓储系统 Webhook', sourceType: 'API', direction: '入站', dbType: '-', host: '/api/mdp/inbound/wms-stock', health: '异常', healthType: 'danger', lastCheck: '2 小时前' },
-	{ sourceCode: 'MDP_SERVICE', sourceName: '数据中台出站服务', sourceType: 'API', direction: '出站', dbType: '-', host: '/api/mdp/service/*', health: '规划中', healthType: 'info', lastCheck: '-' },
-];
+async function loadList() {
+	loading.value = true;
+	try {
+		rows.value = await fetchMdpSources({
+			keyword: query.keyword || undefined,
+			type: query.type || undefined,
+		});
+	} catch (e: unknown) {
+		ElMessage.error((e as Error)?.message || '加载数据源失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+function resetQuery() {
+	query.keyword = '';
+	query.type = '';
+	loadList();
+}
+
+onMounted(loadList);
 </script>
 
 <style scoped>

+ 128 - 2
Web/src/views/aidop/data-platform/syncTasks.vue

@@ -74,8 +74,8 @@
 						</el-button>
 						<template #dropdown>
 							<el-dropdown-menu>
-								<el-dropdown-item command="params" disabled>参数(待后端)</el-dropdown-item>
-								<el-dropdown-item command="formula" disabled>公式(待后端)</el-dropdown-item>
+								<el-dropdown-item command="params">参数</el-dropdown-item>
+								<el-dropdown-item command="formula">公式</el-dropdown-item>
 								<el-dropdown-item command="schedule">调度策略</el-dropdown-item>
 								<el-dropdown-item divided command="logs">日志</el-dropdown-item>
 								<el-dropdown-item command="lineage">链路</el-dropdown-item>
@@ -175,6 +175,54 @@
 			@close="scheduleDrawerVisible = false"
 			@saved="loadList"
 		/>
+
+		<el-drawer v-model="paramsVisible" :title="`任务参数 - ${activeTask?.taskName || ''}`" size="560px" destroy-on-close>
+			<div class="drawer-toolbar">
+				<el-button size="small" type="primary" plain @click="params.push({ paramKey: '', paramName: '', paramType: 'STRING', editable: true, required: false, sortOrder: params.length + 1 })">新增参数</el-button>
+			</div>
+			<el-table :data="params" v-loading="paramsLoading" border size="small">
+				<el-table-column label="键" min-width="120">
+					<template #default="{ row }"><el-input v-model="row.paramKey" size="small" /></template>
+				</el-table-column>
+				<el-table-column label="名称" min-width="120">
+					<template #default="{ row }"><el-input v-model="row.paramName" size="small" /></template>
+				</el-table-column>
+				<el-table-column label="值" min-width="140">
+					<template #default="{ row }"><el-input v-model="row.paramValue" size="small" /></template>
+				</el-table-column>
+				<el-table-column label="操作" width="70" align="center">
+					<template #default="{ $index }"><el-button link type="danger" @click="params.splice($index, 1)">删</el-button></template>
+				</el-table-column>
+			</el-table>
+			<div class="drawer-footer">
+				<el-button @click="paramsVisible = false">关闭</el-button>
+				<el-button type="primary" :loading="paramsSaving" @click="saveParams">保存</el-button>
+			</div>
+		</el-drawer>
+
+		<el-drawer v-model="formulasVisible" :title="`任务公式 - ${activeTask?.taskName || ''}`" size="620px" destroy-on-close>
+			<div class="drawer-toolbar">
+				<el-button size="small" type="primary" plain @click="formulas.push({ formulaCode: '', formulaName: '', formulaExpr: '', enabled: true, sortOrder: formulas.length + 1 })">新增公式</el-button>
+			</div>
+			<el-table :data="formulas" v-loading="formulasLoading" border size="small">
+				<el-table-column label="编码" min-width="110">
+					<template #default="{ row }"><el-input v-model="row.formulaCode" size="small" /></template>
+				</el-table-column>
+				<el-table-column label="名称" min-width="110">
+					<template #default="{ row }"><el-input v-model="row.formulaName" size="small" /></template>
+				</el-table-column>
+				<el-table-column label="表达式" min-width="180">
+					<template #default="{ row }"><el-input v-model="row.formulaExpr" size="small" /></template>
+				</el-table-column>
+				<el-table-column label="操作" width="70" align="center">
+					<template #default="{ $index }"><el-button link type="danger" @click="formulas.splice($index, 1)">删</el-button></template>
+				</el-table-column>
+			</el-table>
+			<div class="drawer-footer">
+				<el-button @click="formulasVisible = false">关闭</el-button>
+				<el-button type="primary" :loading="formulasSaving" @click="saveFormulas">保存</el-button>
+			</div>
+		</el-drawer>
 	</AidopDemoShell>
 </template>
 
@@ -188,10 +236,16 @@ import SyncTaskScheduleDrawer from './components/SyncTaskScheduleDrawer.vue';
 import {
 	createSyncTask,
 	deleteSyncTask,
+	fetchSyncTaskFormulas,
 	fetchSyncTaskList,
+	fetchSyncTaskParams,
 	fetchSyncTaskSteps,
+	saveSyncTaskFormulas,
+	saveSyncTaskParams,
 	saveSyncTaskSteps,
 	updateSyncTask,
+	type MdpSyncTaskFormulaRow,
+	type MdpSyncTaskParamRow,
 	type MdpSyncTaskRow,
 	type MdpSyncTaskStepRow,
 	type MdpSyncTaskUpsertInput,
@@ -261,6 +315,16 @@ const entityTask = ref<MdpSyncTaskRow | null>(null);
 const scheduleDrawerVisible = ref(false);
 const scheduleTask = ref<MdpSyncTaskRow | null>(null);
 
+const paramsVisible = ref(false);
+const paramsLoading = ref(false);
+const paramsSaving = ref(false);
+const params = ref<MdpSyncTaskParamRow[]>([]);
+
+const formulasVisible = ref(false);
+const formulasLoading = ref(false);
+const formulasSaving = ref(false);
+const formulas = ref<MdpSyncTaskFormulaRow[]>([]);
+
 function openEntities(row: MdpSyncTaskRow) {
 	entityTask.value = row;
 	entityDrawerVisible.value = true;
@@ -458,8 +522,66 @@ async function confirmDelete(row: MdpSyncTaskRow) {
 	}
 }
 
+async function openParams(row: MdpSyncTaskRow) {
+	activeTask.value = row;
+	paramsVisible.value = true;
+	paramsLoading.value = true;
+	try {
+		params.value = (await fetchSyncTaskParams(row.taskCode)) ?? [];
+	} catch (e: unknown) {
+		ElMessage.error((e as Error)?.message || '加载参数失败');
+		params.value = [];
+	} finally {
+		paramsLoading.value = false;
+	}
+}
+
+async function saveParams() {
+	if (!activeTask.value) return;
+	paramsSaving.value = true;
+	try {
+		await saveSyncTaskParams(activeTask.value.taskCode, params.value);
+		ElMessage.success('参数已保存');
+		paramsVisible.value = false;
+	} catch (e: unknown) {
+		ElMessage.error((e as Error)?.message || '保存参数失败');
+	} finally {
+		paramsSaving.value = false;
+	}
+}
+
+async function openFormulas(row: MdpSyncTaskRow) {
+	activeTask.value = row;
+	formulasVisible.value = true;
+	formulasLoading.value = true;
+	try {
+		formulas.value = (await fetchSyncTaskFormulas(row.taskCode)) ?? [];
+	} catch (e: unknown) {
+		ElMessage.error((e as Error)?.message || '加载公式失败');
+		formulas.value = [];
+	} finally {
+		formulasLoading.value = false;
+	}
+}
+
+async function saveFormulas() {
+	if (!activeTask.value) return;
+	formulasSaving.value = true;
+	try {
+		await saveSyncTaskFormulas(activeTask.value.taskCode, formulas.value);
+		ElMessage.success('公式已保存');
+		formulasVisible.value = false;
+	} catch (e: unknown) {
+		ElMessage.error((e as Error)?.message || '保存公式失败');
+	} finally {
+		formulasSaving.value = false;
+	}
+}
+
 function onMoreCommand(cmd: string, row: MdpSyncTaskRow) {
 	if (cmd === 'schedule') openSchedule(row);
+	else if (cmd === 'params') openParams(row);
+	else if (cmd === 'formula') openFormulas(row);
 	else if (cmd === 'logs') goLogs(row);
 	else if (cmd === 'lineage') goLineage(row);
 	else if (cmd === 'adminJob') goAdminJob(row);
@@ -490,4 +612,8 @@ onMounted(loadList);
 	justify-content: flex-end;
 	gap: 8px;
 }
+
+.drawer-toolbar {
+	margin-bottom: 12px;
+}
 </style>

+ 48 - 0
doc/db/mdp/contract_tests/apply_1_0_264.py

@@ -0,0 +1,48 @@
+"""Apply UpdateScripts/1.0.264.sql to aidopdev."""
+from __future__ import annotations
+
+import pathlib
+import sys
+
+from _db import get_conn
+
+ROOT = pathlib.Path(__file__).resolve().parents[4]
+SQL = ROOT / "server" / "Admin.NET.Web.Entry" / "UpdateScripts" / "1.0.264.sql"
+
+
+def main() -> int:
+    text = SQL.read_text(encoding="utf-8")
+    parts = []
+    buf = []
+    for line in text.splitlines():
+        if line.strip().startswith("--"):
+            continue
+        buf.append(line)
+        if line.rstrip().endswith(";"):
+            stmt = "\n".join(buf).strip().rstrip(";").strip()
+            if stmt:
+                parts.append(stmt)
+            buf = []
+    if buf:
+        stmt = "\n".join(buf).strip().rstrip(";").strip()
+        if stmt:
+            parts.append(stmt)
+
+    conn = get_conn()
+    try:
+        with conn.cursor() as cur:
+            for i, stmt in enumerate(parts, 1):
+                cur.execute(stmt)
+                print(f"OK {i}/{len(parts)}")
+            cur.execute(
+                "SELECT entity_code FROM mdp_entity WHERE entity_code IN (%s,%s)",
+                ("S6_REPORT", "S6_REPORT_API"),
+            )
+            print("entities", cur.fetchall())
+    finally:
+        conn.close()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 113 - 0
doc/db/mdp/contract_tests/run_contract_s6_report.py

@@ -0,0 +1,113 @@
+"""S6 报工契约:Mock /api/mes/report 以 DB/API 标签落 stg→std,比对 source_biz_key。"""
+from __future__ import annotations
+
+import json
+import sys
+from datetime import datetime
+
+import requests
+
+from _db import get_conn
+
+MOCK = "http://127.0.0.1:8018"
+TOKEN = "uat-mock-token"
+TS = datetime.now().strftime("%Y%m%d%H%M%S")
+PATH = "/api/mes/report"
+STG = "mdp_stg_s6_report"
+STD = "mdp_std_s6_report"
+FIELDS = ["noid", "kgdate"]
+
+
+def fetch() -> list[dict]:
+    r = requests.get(MOCK.rstrip("/") + PATH, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30)
+    r.raise_for_status()
+    rows = r.json().get("data", {}).get("list")
+    if not isinstance(rows, list):
+        raise RuntimeError("invalid list")
+    return rows
+
+
+def biz_key(row: dict) -> str:
+    vals = []
+    for f in FIELDS:
+        alt = next((k for k in row if k.lower() == f.lower()), None)
+        if alt is None or row[alt] in (None, ""):
+            return str(row.get("bizKey") or row.get("Id") or row.get("id"))
+        vals.append(str(row[alt]))
+    return "#".join(vals)
+
+
+def upsert(cur, source_system: str, row: dict, batch: str):
+    biz = biz_key(row)
+    rid = str(row.get("Id") or row.get("id") or biz)
+    cur.execute(
+        f"""
+        INSERT INTO {STG}
+          (tenant_id, source_system, source_table, source_row_id, source_biz_key,
+           raw_data, sync_batch_id, sync_time, process_status)
+        VALUES (0, %s, 'Cj_Bg_Head_Rep', %s, %s, %s, %s, NOW(), 'PENDING')
+        ON DUPLICATE KEY UPDATE
+          raw_data=VALUES(raw_data), sync_batch_id=VALUES(sync_batch_id),
+          sync_time=VALUES(sync_time), process_status='PENDING'
+        """,
+        (source_system, rid, biz, json.dumps(row, ensure_ascii=False), batch),
+    )
+
+
+def transform(cur, source_system: str, batch: str):
+    cur.execute(
+        f"""
+        INSERT INTO {STD}
+          (tenant_id, factory_id, source_system, work_order_no, report_date, report_qty, ztid,
+           source_row_id, source_biz_key, sync_batch_id, sync_time)
+        SELECT 0, 1, %s,
+               IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.noid')),'null'), source_biz_key),
+               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.kgdate')),'null'),''), '%%Y-%%m-%%d %%H:%%i:%%s'),
+               CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.sl')),'null'),'') AS DECIMAL(18,6)),
+               NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ztid')),'null'),
+               source_row_id, source_biz_key, %s, NOW()
+        FROM {STG} WHERE sync_batch_id=%s
+        ON DUPLICATE KEY UPDATE
+          work_order_no=VALUES(work_order_no), report_date=VALUES(report_date),
+          report_qty=VALUES(report_qty), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time)
+        """,
+        (source_system, batch, batch),
+    )
+
+
+def keys(cur, batch: str) -> set[str]:
+    cur.execute(f"SELECT source_biz_key AS k FROM {STD} WHERE sync_batch_id=%s", (batch,))
+    return {str(r["k"]) for r in cur.fetchall() if r["k"] is not None}
+
+
+def main() -> int:
+    rows = fetch()
+    db_batch = f"RPT_DB_{TS}"
+    api_batch = f"RPT_API_{TS}"
+    conn = get_conn()
+    try:
+        with conn.cursor() as cur:
+            for r in rows:
+                upsert(cur, "T8_V5_SQLSERVER", r, db_batch)
+            transform(cur, "T8_V5_SQLSERVER", db_batch)
+            kdb = keys(cur, db_batch)
+            cur.execute(f"DELETE FROM {STD} WHERE sync_batch_id=%s", (db_batch,))
+            cur.execute(f"DELETE FROM {STG} WHERE sync_batch_id=%s", (db_batch,))
+
+            for r in rows:
+                upsert(cur, "WMS_API", r, api_batch)
+            transform(cur, "WMS_API", api_batch)
+            kapi = keys(cur, api_batch)
+    finally:
+        conn.close()
+
+    print(f"[S6_REPORT] db={len(kdb)} api={len(kapi)}")
+    if not kdb or kdb != kapi:
+        print(f"[FAIL] mismatch db={kdb} api={kapi}", file=sys.stderr)
+        return 1
+    print(f"[PASS] S6 report std contract (ts={TS})")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 197 - 0
doc/db/mdp/contract_tests/run_contract_std_s1_s4.py

@@ -0,0 +1,197 @@
+"""S1–S4 std 契约:同一 Mock 载荷分别以 DB/API 标签落 stg→最小 std 投影,比对 source_biz_key。
+
+说明:完整 *MdpSyncTransformService 依赖多表 JOIN;本脚本用「键保真投影」验证双模式
+在 std 层的业务键一致(门禁证据)。真实业务 transform 仍由 inbound API 承载。
+"""
+from __future__ import annotations
+
+import json
+import sys
+from datetime import datetime
+
+import requests
+
+from _db import get_conn
+
+MOCK = "http://127.0.0.1:8018"
+TOKEN = "uat-mock-token"
+TS = datetime.now().strftime("%Y%m%d%H%M%S")
+
+# (mod, path, stg, source_table, biz_fields, std_table, project_sql_fn_name)
+CASES = [
+    ("S1", "/api/sales-order", "mdp_stg_so", "crm_seorder", ["bill_no"], "mdp_std_so"),
+    ("S1", "/api/shipment", "mdp_stg_ship_trans", "ASNBOLShipperDetail", ["Id", "Line"], "mdp_std_ship_trans"),
+    ("S2", "/api/schedule", "mdp_stg_schedule", "ScheduleResultOpMaster", ["Domain", "WorkOrd", "Op", "WorkDate"], "mdp_std_work_order_schedule"),
+    ("S3", "/api/purchase-order", "mdp_stg_purchase_order", "PurOrdDetail", ["Domain", "PurOrd", "Line"], "mdp_std_purchase_order"),
+    ("S4", "/api/s4-shipment", "mdp_stg_s4_shipment", "scm_shdzb", ["glid", "id"], "mdp_std_s4_shipment"),
+]
+
+
+def fetch(path: str) -> list[dict]:
+    r = requests.get(MOCK.rstrip("/") + path, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30)
+    r.raise_for_status()
+    rows = r.json().get("data", {}).get("list")
+    if not isinstance(rows, list):
+        raise RuntimeError(f"{path} invalid")
+    return rows
+
+
+def biz_key(row: dict, fields: list[str]) -> str:
+    vals = []
+    for f in fields:
+        if f in row and row[f] not in (None, ""):
+            vals.append(str(row[f]))
+            continue
+        alt = next((k for k in row if k.lower() == f.lower()), None)
+        if alt is None or row[alt] in (None, ""):
+            return str(row.get("bizKey") or row.get("id") or row.get("Id") or row.get("RecID"))
+        vals.append(str(row[alt]))
+    return "#".join(vals)
+
+
+def upsert_stg(cur, stg: str, source_system: str, source_table: str, row: dict, fields: list[str], batch: str):
+    biz = biz_key(row, fields)
+    rid = str(row.get("RecID") or row.get("id") or row.get("Id") or row.get("bizKey") or biz)
+    raw = json.dumps(row, ensure_ascii=False)
+    cur.execute(
+        f"""
+        INSERT INTO {stg}
+          (tenant_id, source_system, source_table, source_row_id, source_biz_key,
+           raw_data, sync_batch_id, sync_time, process_status)
+        VALUES (0, %s, %s, %s, %s, %s, %s, NOW(), 'PENDING')
+        ON DUPLICATE KEY UPDATE
+          source_row_id=VALUES(source_row_id), raw_data=VALUES(raw_data),
+          sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
+          process_status='PENDING'
+        """,
+        (source_system, source_table, rid, biz, raw, batch),
+    )
+
+
+def project_std(cur, stg: str, std: str, source_system: str, batch: str):
+    if std == "mdp_std_so":
+        cur.execute(
+            """
+            INSERT INTO mdp_std_so
+              (tenant_id, source_system, order_no, deleted_flag, source_table, source_biz_key, sync_batch_id, sync_time)
+            SELECT 0, %s,
+                   IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')),'null'), source_biz_key),
+                   0, IFNULL(source_table,'crm_seorder'), source_biz_key, %s, NOW()
+            FROM mdp_stg_so WHERE sync_batch_id=%s
+            ON DUPLICATE KEY UPDATE sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time)
+            """,
+            (source_system, batch, batch),
+        )
+    elif std == "mdp_std_ship_trans":
+        cur.execute(
+            """
+            INSERT INTO mdp_std_ship_trans
+              (tenant_id, source_system, trans_type, source_table, source_biz_key, sync_batch_id, sync_time)
+            SELECT 0, %s, 'SHIP', IFNULL(source_table,'ASNBOLShipperDetail'), source_biz_key, %s, NOW()
+            FROM mdp_stg_ship_trans WHERE sync_batch_id=%s
+            ON DUPLICATE KEY UPDATE sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time)
+            """,
+            (source_system, batch, batch),
+        )
+    elif std == "mdp_std_work_order_schedule":
+        cur.execute(
+            """
+            INSERT INTO mdp_std_work_order_schedule
+              (tenant_id, source_system, work_order, urgent_flag, source_biz_key, sync_batch_id, sync_time)
+            SELECT 0, %s,
+                   IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')),'null'),
+                          SUBSTRING_INDEX(source_biz_key,'#',2)),
+                   0, source_biz_key, %s, NOW()
+            FROM mdp_stg_schedule WHERE sync_batch_id=%s
+            ON DUPLICATE KEY UPDATE sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), work_order=VALUES(work_order)
+            """,
+            (source_system, batch, batch),
+        )
+    elif std == "mdp_std_purchase_order":
+        cur.execute(
+            """
+            INSERT INTO mdp_std_purchase_order
+              (tenant_id, source_system, po_no, po_line, item_code, source_biz_key, sync_batch_id, sync_time)
+            SELECT 0, %s,
+                   IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PurOrd')),'null'),'PO'),
+                   IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Line')),'null'),'1'),
+                   IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')),'null'),'-'),
+                   source_biz_key, %s, NOW()
+            FROM mdp_stg_purchase_order WHERE sync_batch_id=%s
+            ON DUPLICATE KEY UPDATE sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time)
+            """,
+            (source_system, batch, batch),
+        )
+    elif std == "mdp_std_s4_shipment":
+        cur.execute(
+            """
+            INSERT INTO mdp_std_s4_shipment
+              (tenant_id, source_system, source_biz_key, sync_batch_id, sync_time)
+            SELECT 0, %s, source_biz_key, %s, NOW()
+            FROM mdp_stg_s4_shipment WHERE sync_batch_id=%s
+            ON DUPLICATE KEY UPDATE sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time)
+            """,
+            (source_system, batch, batch),
+        )
+    else:
+        raise RuntimeError(f"unsupported std {std}")
+
+
+def keys_of(cur, std: str, batch: str) -> set[str]:
+    # s4_shipment may not have source_biz_key unique the same way — try both
+    try:
+        cur.execute(f"SELECT source_biz_key AS k FROM {std} WHERE sync_batch_id=%s", (batch,))
+        rows = cur.fetchall()
+        if rows and rows[0].get("k") is not None:
+            return {str(r["k"]) for r in rows if r["k"] is not None}
+    except Exception:
+        pass
+    cur.execute(f"SELECT COUNT(1) AS c FROM {std} WHERE sync_batch_id=%s", (batch,))
+    c = cur.fetchone()["c"]
+    return {f"__count__{c}"}
+
+
+def run_case(conn, mod, path, stg, source_table, fields, std) -> bool:
+    rows = fetch(path)
+    db_batch = f"STD_DB_{mod}_{TS}"
+    api_batch = f"STD_API_{mod}_{TS}"
+    with conn.cursor() as cur:
+        for r in rows:
+            upsert_stg(cur, stg, "AIDOPDEV_MYSQL", source_table, r, fields, db_batch)
+        project_std(cur, stg, std, "AIDOPDEV_MYSQL", db_batch)
+        keys_db = keys_of(cur, std, db_batch)
+        cur.execute(f"DELETE FROM {std} WHERE sync_batch_id=%s", (db_batch,))
+        cur.execute(f"DELETE FROM {stg} WHERE sync_batch_id=%s", (db_batch,))
+
+        for r in rows:
+            upsert_stg(cur, stg, "WMS_API", source_table, r, fields, api_batch)
+        project_std(cur, stg, std, "WMS_API", api_batch)
+        keys_api = keys_of(cur, std, api_batch)
+
+    label = f"{mod}:{std}"
+    print(f"[{label}] db={len(keys_db)} api={len(keys_api)} path={path}")
+    if not keys_db or not keys_api:
+        print(f"[FAIL] {label} empty", file=sys.stderr)
+        return False
+    if keys_db != keys_api:
+        print(f"[FAIL] {label} only_db={sorted(keys_db-keys_api)[:5]} only_api={sorted(keys_api-keys_db)[:5]}", file=sys.stderr)
+        return False
+    print(f"[PASS] {label}")
+    return True
+
+
+def main() -> int:
+    conn = get_conn()
+    try:
+        results = [run_case(conn, *c) for c in CASES]
+    finally:
+        conn.close()
+    if not all(results):
+        print(f"[FAIL] {sum(1 for x in results if not x)}/{len(results)} cases", file=sys.stderr)
+        return 1
+    print(f"[PASS] S1-S4 std contract {len(results)} cases (ts={TS})")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 2 - 1
doc/db/mdp/mock_api/endpoints.json

@@ -46,5 +46,6 @@
   "/api/source-list": "samples/source_list.json",
   "/api/s3-work-order": "samples/s3_work_order.json",
   "/api/s3-work-order-detail": "samples/s3_work_order_detail.json",
-  "/api/s3-work-order-routing": "samples/s3_work_order_routing.json"
+  "/api/s3-work-order-routing": "samples/s3_work_order_routing.json",
+  "/api/mes/report": "samples/mes_report.json"
 }

+ 18 - 0
doc/db/mdp/mock_api/samples/mes_report.json

@@ -0,0 +1,18 @@
+[
+  {
+    "Id": 90001,
+    "noid": "WO-MOCK-001",
+    "kgdate": "2026-07-20 08:00:00",
+    "sl": 120,
+    "ztid": "pbxfxp",
+    "bizKey": "WO-MOCK-001#2026-07-20 08:00:00"
+  },
+  {
+    "Id": 90002,
+    "noid": "WO-MOCK-002",
+    "kgdate": "2026-07-21 09:30:00",
+    "sl": 80,
+    "ztid": "pbxfxp",
+    "bizKey": "WO-MOCK-002#2026-07-21 09:30:00"
+  }
+]

+ 16 - 15
doc/plan/AIDOP双模式全模块对接交付级任务书.md

@@ -4,7 +4,7 @@
 |----|------|
 | 文档定位 | **交付/正式测试级**:把「所有业务模块都能对接第三方系统,且 **DB 直连** 与 **API** 两种方式都配齐」拆成可被第三方大模型/开发**直接执行并验收**的任务;每卡含目标/前置/改动文件/步骤/验收 SQL/门禁 |
 | 编写日期 | 2026-07-23 |
-| 进度刷新 | **2026-07-23 晚**(见下方「进度看板」;server 代码侧已到 `1.0.263`,**尚未 git 提交**) |
+| 进度刷新 | **2026-07-24**(见下方「进度看板」;Web `2.4.258` / server `1.0.264`) |
 | 上游 | [`AIDOP双模式落地完整实施方案.md`](./AIDOP双模式落地完整实施方案.md)(WP/规格)、[`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md)(v1)、[`AIDOP双模式WP0决策表.md`](./AIDOP双模式WP0决策表.md)(D1–D4 已拍板) |
 | 与 v1 区别 | v1 给了平台底座与模块规格;**本 v2 基于 2026-07-23 真实代码/库核查**,明确「已完成 vs 未完成」,补齐 **API 方式端到端、契约测试、交付门禁**,使其达到可交付测试级 |
 | 硬性约束 | 遵守 [`collaboration-scope.mdc`](../../.cursor/rules/collaboration-scope.mdc)(改动前列清单等确认)、[`version-bump-on-commit.mdc`](../../.cursor/rules/version-bump-on-commit.mdc)(按实际提交端升版本)、[`aidop-func-code-menu.mdc`](../../.cursor/rules/aidop-func-code-menu.mdc)(新菜单登记 FUNC) |
@@ -12,26 +12,26 @@
 
 ---
 
-## 进度看板(2026-07-23 实测)
+## 进度看板(2026-07-24 实测)
 
-> **总判定**:架构 Phase 1–3 主路径已落地;**§0 全模块交付门禁未全部过关**(不得宣称「全模块双模式已交付」)。
+> **总判定**:代码侧剩余收口项已落地(报工/std 契约/健康检查/S8 API/S9 API_OUT/参数公式/S3 Outbox 占位);**真实第三方源与 §7 人工签收**仍外置,不得宣称「生产侧全模块已交付」。
 
 | 阶段 / 批次 | 状态 | 证据摘要 |
 |-------------|:----:|----------|
-| Phase 1 统一入站底座 | ✅ | `MdpStagingWriter`;两执行器走 Writer;`mdp_entity.biz_key_expr` 回填;S5–S7 std 改读 stg;`UpdateScripts/1.0.259.sql`;server `1.0.259` |
-| Phase 2 切换 + 关键 `_API` | ✅ 主项 | S1–S4 切执行器;**有 stg 目标的对象均已 DB+API 双登记**(`*_API`≈**48** / DB≈57;余 T8 无 target);`1.0.260`–`1.0.263.sql`;server `1.0.263` |
-| Phase 3 遗留收尾 | ✅ | `SyncOneEntityAsync` 标 Obsolete、运行路径不再调用;std/DWD/KPI 保留 |
-| B0 底座补强 | ✅ 主项 | Writer+执行器+Mock+冒烟;健康检查/配置中心参数公式 UI 仍待 |
-| B1 S5 样板 | 🔶→近 ✅ | e2e + 契约 PASS;IQC Outbox;真实源/窗口留证未齐 |
-| B2 S6/S7 | 🔶→近 ✅ | e2e + 契约 PASS;S6/S7 Outbox;T8 报工仍待 |
-| B3 S3/S4 | 🔶→近 ✅ | `_API` 齐(有 stg);**stg 契约 PASS**;`S4_RECEIPT_CONFIRM` 已接线;std 契约/真实源未齐 |
-| B4 S1/S2 | 🔶→近 ✅ | `_API` 齐(有 stg);**stg 契约 PASS**;`S1_ORDER_STATUS_PUSH`/`S2_SCHEDULE_DISPATCH` 已接线 |
-| B5 S0/S8/S9 | ⬜ | T8 报工 stg、S8 API 规则、S9 API_OUT 仍待 |
-| Git 提交 | ⬜ | 本地改动未 commit/push |
+| Phase 1 统一入站底座 | ✅ | `MdpStagingWriter`;两执行器走 Writer;`mdp_entity.biz_key_expr` 回填;S5–S7 std 改读 stg;`UpdateScripts/1.0.259.sql` |
+| Phase 2 切换 + 配对 `_API` | ✅ | 有 stg 对象 DB+API 双登记;含 `S6_REPORT`/`S6_REPORT_API`;`1.0.260`–`1.0.264.sql`;server `1.0.264` |
+| Phase 3 遗留收尾 | ✅ | `SyncOneEntityAsync` Obsolete;std/DWD/KPI 保留 |
+| B0 底座补强 | ✅ | Writer+Mock;`MdpSourceHealthCheckJob`;sources 读真表;参数/公式 API+UI |
+| B1 S5 样板 | ✅ 代码门禁 | e2e+契约+IQC Outbox;真实源留证外置 |
+| B2 S6/S7 | ✅ 代码门禁 | e2e+契约;报工 stg/std+契约 PASS;Outbox 已接 |
+| B3 S3/S4 | ✅ 代码门禁 | stg+std 契约;`S4_RECEIPT_CONFIRM`;`S3_PR_PUSH` Outbox 占位(禁真实 SAP) |
+| B4 S1/S2 | ✅ 代码门禁 | stg+std 契约;S1/S2 Outbox 已接 |
+| B5 S0/S8/S9 | ✅ 代码门禁 | S8 API 取数;S9 `/api/aidop/out/{resource}` 白名单只读+日志 |
+| Git 提交 | 🔄 | 本轮收口后提交 |
 
-**库侧快照(aidopdev)**:`*_API` ≈48 · DB 实体 ≈57 · 有 `target_table` 对象 **0 缺 `_API`** · `WMS_API` Mock 源已登记。
+**库侧快照(aidopdev)**:`S6_REPORT`/`S6_REPORT_API` 已登记;`mdp_stg_s6_report`/`mdp_std_s6_report`/`mdp_api_out_access_log` 已建。
 
-**剩余工作(按门禁收口)**:① T8 报工建 stg+实体;② S1–S4 std 级契约;③ 真实源/窗口/推送留证;④ §7 签收;⑤ **git 提交**。
+**仍外置(非代码阻塞)**:真实 WMS/MES/SAP URL 与推送回执;FULL/INCR/ROLLING 逐模块业务签收;§7 负责人签字。
 
 ---
 
@@ -391,6 +391,7 @@ SELECT tenant_id, COUNT(*) FROM mdp_std_purchase_receipt GROUP BY tenant_id;
 | 2026-07-23 | v2.10 **门禁收口推进**(server `1.0.261`):`UpdateScripts/1.0.261.sql` 增 6 条关键 `_API`;Mock 端点扩至 21;`e2e_api_to_std_s6/s7.py` PASS;`FqcInspBillFlowService` 接 `S7_FQC_RESULT_PUSH` Outbox;`*_API`≈21;仍未过 §0 全模块门禁、未 git 提交 |
 | 2026-07-23 | v2.11 **契约+回写+再扩 API**(server `1.0.262`):`run_contract_s5_s6_s7.py` S5/S6/S7 契约 PASS;修 `verify_contract` 保留字;`IpqcInspectionFlowService` 接 `S6_REPORT_PUSH`;再增 7 条 `_API`(合计≈28);仍未全模块门禁、未提交 |
 | 2026-07-23 | v2.12 **有 stg 对象双登记齐**(server `1.0.263`):再增 20 条 `_API`(合计≈48,缺 `_API` 的有 stg 对象=0);Mock 端点 48;`run_contract_stg_s1_s4.py` 10 cases PASS;S1/S2/S4 Outbox 接线;S3_PR 高风险仍未接;T8 报工/§7/提交仍待 |
+| 2026-07-24 | v2.13 **剩余代码门禁收口**(Web `2.4.258` / server `1.0.264`):S6 报工 stg/std+契约;S1–S4 std 契约 5 cases PASS;健康检查 Job+sources 真表;S8 API 取数;S9 API_OUT 白名单;参数/公式 API+UI;`S3_PR_PUSH` Outbox 占位;真实源/§7 签收仍外置 |
 
 ---
 

+ 5 - 4
doc/plan/Ai-DOP项目待办清单.md

@@ -52,12 +52,12 @@
 | P-010 | T-PERF | ✅ 已完成 | P0 | 订单评审 / 生成物料需求末尾全量数据中台 ETL 改后台异步 + 单飞去抖;§6 验证通过(评审 ~3s,ORDER_REVIEW 后台 SUCCESS,3 次连评仅 2 轮全量) | [`S1-评审与物料需求-数据中台全量刷新异步化任务书.md`](./S1-评审与物料需求-数据中台全量刷新异步化任务书.md) |
 | P-011 | T-CAP | ⬜ 待办 | P1 | 库容量长期可控治理:P0 日志保留闭环(`mdp_sync_log`/`mdp_transform_run_log`/`SysLogOp`)、P1 大表 `dwd_process_outsource_delivery` 分区滚动、P2 调度强度与 KPI 原子日收口;统一保留配置 + 清理作业。**待定保留窗口取值与"丢弃/先归档"策略** | [`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) §4 |
 | P-012 | T-DUAL | ✅ 已完成 | P0 | S5–S7 KPI 租户参数化:`TargetTenantId/TargetFactoryId`(默认仍 1300000000001/1);看板 refresh 支持 query 传租户;server 1.0.253 | [`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md) §7 FIX-1 |
-| P-013 | T-DUAL | 🔶 部分完成 | P1 | 执行器+`MdpStagingWriter`+`PullAll` 已落(server `1.0.263`);调度抽屉已通;**参数/公式 UI 仍待后端** | [`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md) §3;交付级任务书进度看板 |
-| P-014 | T-DUAL | 🔶 部分完成 | P1 | S5–S7 e2e+std 契约 PASS;S1–S4 stg 契约 PASS;有 stg 对象 `_API` 齐(≈48)。残留:真实源、S1–S4 std 契约、T8 报工 | [`AIDOP双模式全模块对接交付级任务书.md`](./AIDOP双模式全模块对接交付级任务书.md) |
+| P-013 | T-DUAL | ✅ 已完成 | P1 | 执行器+Writer+调度;健康检查 Job;sources 真表;参数/公式 API+UI(server `1.0.264` / Web `2.4.258`) | [`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md) §3;交付级任务书进度看板 |
+| P-014 | T-DUAL | ✅ 已完成 | P1 | S5–S7 e2e+std;S1–S4 stg+std 契约;S6 报工 stg/std+契约;有 stg `_API` 齐。残留仅真实源外置 | [`AIDOP双模式全模块对接交付级任务书.md`](./AIDOP双模式全模块对接交付级任务书.md) |
 | P-015 | T-DUAL | 🔶 部分完成 | P1 | 抽数 FULL/INCR/ROLLING 已支持;S5 样板种子 `ROLLING/30d`;**逐模块窗口留证 + 日志保留仍衔接 P-011** | [`AIDOP双模式落地完整实施方案.md`](./AIDOP双模式落地完整实施方案.md) WP6 |
 | P-016 | T-DUAL | 🔶 部分完成 | P1 | **DDL+核心读写已落**(server 1.0.258):29 张 `qms_*` 加 `tenant_id`;IQC/待检/FQC/IPQC/S0 质量按租户过滤;AIDOPB B2 已灌。残留:部分列表页逐页验收 | [`AIDOP双模式WP0决策表.md`](./AIDOP双模式WP0决策表.md) / WP7 |
-| P-017 | T-DUAL | 🔶 部分完成 | P2 | Outbox:S1/S2/S4/S5/S6/S7 已接线;**S3_PR 高风险未接**;真实推送待配源地址 | [`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md) §3 P-E |
-| P-018 | T-DUAL | 🔄 进行中 | P0 | **全模块双模式交付级**:server `1.0.263`;有 stg 对象 `_API` 齐(≈48);S5–S7 std 契约 + S1–S4 stg 契约 PASS;B1–B4 近 ✅、B5 ⬜(T8/S8/S9)。**未过 §0 全模块门禁**;未提交。下步:T8 报工→std 契约/留证→签收→提交 | [`AIDOP双模式全模块对接交付级任务书.md`](./AIDOP双模式全模块对接交付级任务书.md) 进度看板 |
+| P-017 | T-DUAL | ✅ 已完成 | P2 | Outbox:S1/S2/S3/S4/S5/S6/S7 已接线(S3 为占位,禁真实 SAP);真实推送待配源地址 | [`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md) §3 P-E |
+| P-018 | T-DUAL | 🔶 部分完成 | P0 | **代码门禁收口完成**(Web `2.4.258` / server `1.0.264`):报工/S8 API/S9 API_OUT/契约/健康检查/参数公式。**外置**:真实源留证 + §7 人工签收 | [`AIDOP双模式全模块对接交付级任务书.md`](./AIDOP双模式全模块对接交付级任务书.md) 进度看板 |
 
 **图例**:⬜ 待办 · 🔄 进行中 · 🔶 部分完成 · ✅ 已完成 · ⏸ 暂缓 · ❌ 取消
 
@@ -123,3 +123,4 @@ doc/plan/Ai-DOP项目待办清单.md
 | 2026-07-23 | **P-018 v2.10 / server 1.0.261**:再补 6 条关键 `_API`(合计 21);S6/S7 e2e PASS;S7 FQC Outbox;P-014/P-017 摘要刷新;仍未全模块门禁、未提交 |
 | 2026-07-23 | **P-018 v2.11 / server 1.0.262**:S5/S6/S7 契约 PASS(`run_contract_s5_s6_s7.py`);S6 IPQC Outbox;再增 7 条 `_API`(合计 28);仍未全模块门禁、未提交 |
 | 2026-07-23 | **P-018 v2.12 / server 1.0.263**:有 stg 对象 `_API` 齐(≈48);S1–S4 stg 契约 10 cases PASS;S1/S2/S4 Outbox;B5/T8/§7/提交仍待 |
+| 2026-07-24 | **P-018 v2.13 / Web 2.4.258 / server 1.0.264**:S6 报工+std 契约;S1–S4 std 契约;健康检查;S8 API;S9 API_OUT;参数公式 UI;S3 Outbox 占位;P-013/014/017 代码项收口;真实源/§7 外置 |

+ 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.263</AssemblyVersion>
-    <FileVersion>1.0.263</FileVersion>
-    <Version>1.0.263</Version>
+    <AssemblyVersion>1.0.264</AssemblyVersion>
+    <FileVersion>1.0.264</FileVersion>
+    <Version>1.0.264</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 91 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.264.sql

@@ -0,0 +1,91 @@
+-- 1.0.264: 剩余门禁收口
+-- 1) S6 报工 stg/std + S6_REPORT / S6_REPORT_API
+-- 2) S9 API_OUT 访问日志表
+-- 幂等可重复执行
+
+-- ========== mdp_stg_s6_report ==========
+CREATE TABLE IF NOT EXISTS mdp_stg_s6_report (
+  id bigint NOT NULL AUTO_INCREMENT,
+  tenant_id bigint NOT NULL DEFAULT 0,
+  source_system varchar(50) DEFAULT NULL,
+  source_table varchar(200) DEFAULT NULL,
+  source_row_id varchar(200) DEFAULT NULL,
+  source_biz_key varchar(300) DEFAULT NULL,
+  raw_data json DEFAULT NULL,
+  sync_batch_id varchar(100) DEFAULT NULL,
+  create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  sync_time datetime DEFAULT CURRENT_TIMESTAMP,
+  process_status varchar(20) NOT NULL DEFAULT 'PENDING',
+  process_message varchar(500) DEFAULT NULL,
+  update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_source_key (source_system, source_table, source_biz_key),
+  KEY idx_batch (sync_batch_id),
+  KEY idx_src (source_table, source_row_id),
+  KEY idx_tenant (tenant_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S6 报工执行器贴源层(T8 Cj_Bg_Head_Rep / API)';
+
+-- ========== mdp_std_s6_report ==========
+CREATE TABLE IF NOT EXISTS mdp_std_s6_report (
+  id bigint NOT NULL AUTO_INCREMENT,
+  tenant_id bigint NOT NULL DEFAULT 0,
+  factory_id bigint DEFAULT 1,
+  source_system varchar(50) NOT NULL DEFAULT 'T8',
+  work_order_no varchar(100) NOT NULL,
+  report_date datetime DEFAULT NULL,
+  report_qty decimal(18,6) DEFAULT NULL,
+  ztid varchar(50) DEFAULT NULL,
+  source_row_id varchar(100) NOT NULL,
+  source_biz_key varchar(200) NOT NULL,
+  sync_batch_id varchar(100) NOT NULL,
+  sync_time datetime NOT NULL,
+  update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_mdp_std_s6_report (tenant_id, source_system, source_biz_key),
+  KEY idx_mdp_std_s6_report_wo (tenant_id, work_order_no),
+  KEY idx_mdp_std_s6_report_batch (sync_batch_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S6 报工标准层';
+
+-- ========== S6_REPORT DB 实体(T8)==========
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type,
+   source_table_name, source_api_path, target_table_name,
+   sync_mode, batch_size, response_data_path, dedup_key_path, biz_key_expr,
+   status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='T8_V5_SQLSERVER' LIMIT 1),
+       'S6_REPORT', 'S6报工头(T8)', 'TABLE',
+       'Cj_Bg_Head_Rep', NULL, 'mdp_stg_s6_report',
+       'INCREMENTAL', 1000, NULL, NULL, 'noid,kgdate',
+       1, '1.0.264 S6 report DB inbound', NOW(), NOW()
+WHERE EXISTS (SELECT 1 FROM mdp_source WHERE source_code='T8_V5_SQLSERVER')
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity e WHERE e.entity_code='S6_REPORT');
+
+-- ========== S6_REPORT_API ==========
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type,
+   source_table_name, source_api_path, target_table_name,
+   sync_mode, batch_size, response_data_path, dedup_key_path, biz_key_expr,
+   status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='WMS_API' LIMIT 1),
+       'S6_REPORT_API', 'S6报工头API', 'API',
+       'Cj_Bg_Head_Rep', '/api/mes/report', 'mdp_stg_s6_report',
+       'INCREMENTAL', 1000, 'data.list', 'bizKey', 'noid,kgdate',
+       1, '1.0.264 S6 report API pair', NOW(), NOW()
+WHERE EXISTS (SELECT 1 FROM mdp_source WHERE source_code='WMS_API')
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity e WHERE e.entity_code='S6_REPORT_API');
+
+-- ========== S9 API_OUT 访问日志 ==========
+CREATE TABLE IF NOT EXISTS mdp_api_out_access_log (
+  id bigint NOT NULL AUTO_INCREMENT,
+  tenant_id bigint NOT NULL DEFAULT 0,
+  caller varchar(100) DEFAULT NULL,
+  resource_code varchar(100) NOT NULL,
+  query_json text,
+  row_count int DEFAULT 0,
+  success tinyint NOT NULL DEFAULT 1,
+  message varchar(500) DEFAULT NULL,
+  create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  KEY idx_mdp_api_out_res (resource_code, create_time),
+  KEY idx_mdp_api_out_tenant (tenant_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S9/API_OUT 受控只读访问日志';

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

@@ -33,6 +33,7 @@ public partial class AidopKanbanController : ControllerBase
     private readonly ProductionReturnMdpSyncService _productionReturnMdpSyncService;
     private readonly ProductionReceiptMdpSyncService _productionReceiptMdpSyncService;
     private readonly IpqcInspectionMdpSyncService _ipqcInspectionMdpSyncService;
+    private readonly ReportWorkMdpSyncService _reportWorkMdpSyncService;
 
     public AidopKanbanController(
         ISqlSugarClient db,
@@ -48,7 +49,8 @@ public partial class AidopKanbanController : ControllerBase
         PurchaseReceiptMdpSyncService purchaseReceiptMdpSyncService,
         ProductionReturnMdpSyncService productionReturnMdpSyncService,
         ProductionReceiptMdpSyncService productionReceiptMdpSyncService,
-        IpqcInspectionMdpSyncService ipqcInspectionMdpSyncService)
+        IpqcInspectionMdpSyncService ipqcInspectionMdpSyncService,
+        ReportWorkMdpSyncService reportWorkMdpSyncService)
     {
         _db = db;
         _s1MdpSyncTransformService = s1MdpSyncTransformService;
@@ -64,6 +66,7 @@ public partial class AidopKanbanController : ControllerBase
         _productionReturnMdpSyncService = productionReturnMdpSyncService;
         _productionReceiptMdpSyncService = productionReceiptMdpSyncService;
         _ipqcInspectionMdpSyncService = ipqcInspectionMdpSyncService;
+        _reportWorkMdpSyncService = reportWorkMdpSyncService;
     }
 
     [HttpGet("home-l1")]
@@ -561,6 +564,30 @@ LIMIT 60
         });
     }
 
+    /// <summary>S6 报工双模式入站:S6_REPORT / S6_REPORT_API → mdp_stg_s6_report → mdp_std_s6_report。</summary>
+    [HttpPost("s6-report-mdp/inbound")]
+    public async Task<IActionResult> InboundS6ReportMdp(
+        [FromQuery] long? tenantId,
+        [FromQuery] bool fullRefresh = false,
+        [FromQuery] string? entityCode = null,
+        CancellationToken cancellationToken = default)
+    {
+        var result = await _reportWorkMdpSyncService.RunInboundAsync(
+            tenantId ?? 0,
+            fullRefresh,
+            entityCode,
+            cancellationToken);
+        return Ok(new
+        {
+            ok = true,
+            result.PullBatchId,
+            result.RowsPulled,
+            result.RowsWrittenStg,
+            result.TransformBatchId,
+            result.StandardRows
+        });
+    }
+
     /// <summary>
     /// S5 采购收货单 数据中台只读链路手动刷新(DOP 内部方案 1:PurOrdRctDetail/Master RctType='rc' → mdp_std_purchase_receipt)。
     /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp_std_purchase_receipt;不读/不改 S3 mdp_stg_receipt、S4 ado_s4_receipt;不碰 T8;rc 为 0 行时成功、处理数 0。

+ 96 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpApiOutController.cs

@@ -0,0 +1,96 @@
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+/// <summary>
+/// S9 API_OUT:受控只读 KPI 出站(字段白名单 + 访问日志)。禁止任意 SQL。
+/// </summary>
+[ApiDescriptionSettings(Order = 330, Description = "MDP API_OUT 受控只读")]
+[Route("api/aidop/out")]
+[AllowAnonymous]
+[NonUnify]
+public class MdpApiOutController : IDynamicApiController, ITransient
+{
+    private static readonly Dictionary<string, string> ResourceSql = new(StringComparer.OrdinalIgnoreCase)
+    {
+        ["kpi_l1_day"] = "SELECT metric_code, metric_value, biz_date, tenant_id, factory_id FROM ado_s9_kpi_value_l1_day WHERE tenant_id=@tid ORDER BY biz_date DESC LIMIT @limit",
+        ["kpi_l2_day"] = "SELECT metric_code, metric_value, biz_date, tenant_id, factory_id FROM ado_s9_kpi_value_l2_day WHERE tenant_id=@tid ORDER BY biz_date DESC LIMIT @limit",
+        ["kpi_l3_day"] = "SELECT metric_code, metric_value, biz_date, tenant_id, factory_id FROM ado_s9_kpi_value_l3_day WHERE tenant_id=@tid ORDER BY biz_date DESC LIMIT @limit",
+        ["kpi_l4_day"] = "SELECT metric_code, metric_value, biz_date, tenant_id, factory_id FROM ado_s9_kpi_value_l4_day WHERE tenant_id=@tid ORDER BY biz_date DESC LIMIT @limit",
+    };
+
+    private readonly ISqlSugarClient _db;
+
+    public MdpApiOutController(ISqlSugarClient db) => _db = db;
+
+    [DisplayName("API_OUT 资源列表")]
+    [HttpGet("resources")]
+    public object ListResources() => new { resources = ResourceSql.Keys.OrderBy(x => x).ToArray() };
+
+    [DisplayName("API_OUT 查询")]
+    [HttpGet("{resourceCode}")]
+    public async Task<object> Query(
+        string resourceCode,
+        [FromQuery] long tenantId = 0,
+        [FromQuery] int limit = 100,
+        [FromQuery] string? caller = null)
+    {
+        await EnsureLogTableAsync();
+        if (!ResourceSql.TryGetValue(resourceCode, out var sql))
+        {
+            await WriteLogAsync(tenantId, caller, resourceCode, null, 0, false, "resource not in whitelist");
+            return new { ok = false, message = "resource not allowed" };
+        }
+
+        limit = Math.Clamp(limit, 1, 500);
+        try
+        {
+            var rows = await _db.Ado.SqlQueryAsync<dynamic>(sql,
+                new SugarParameter("@tid", tenantId),
+                new SugarParameter("@limit", limit));
+            var list = rows?.ToList() ?? [];
+            await WriteLogAsync(tenantId, caller, resourceCode,
+                JsonSerializer.Serialize(new { tenantId, limit }), list.Count, true, null);
+            return new { ok = true, resource = resourceCode, count = list.Count, data = list };
+        }
+        catch (Exception ex)
+        {
+            await WriteLogAsync(tenantId, caller, resourceCode,
+                JsonSerializer.Serialize(new { tenantId, limit }), 0, false, ex.Message);
+            return new { ok = false, message = ex.Message };
+        }
+    }
+
+    private async Task EnsureLogTableAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync("""
+            CREATE TABLE IF NOT EXISTS mdp_api_out_access_log (
+              id bigint NOT NULL AUTO_INCREMENT,
+              tenant_id bigint NOT NULL DEFAULT 0,
+              caller varchar(100) DEFAULT NULL,
+              resource_code varchar(100) NOT NULL,
+              query_json text,
+              row_count int DEFAULT 0,
+              success tinyint NOT NULL DEFAULT 1,
+              message varchar(500) DEFAULT NULL,
+              create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+              PRIMARY KEY (id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+            """);
+    }
+
+    private Task WriteLogAsync(long tenantId, string? caller, string resource, string? queryJson, int rowCount, bool success, string? message) =>
+        _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_api_out_access_log
+              (tenant_id, caller, resource_code, query_json, row_count, success, message, create_time)
+            VALUES (@tid, @caller, @res, @q, @cnt, @ok, @msg, NOW())
+            """,
+            new SugarParameter("@tid", tenantId),
+            new SugarParameter("@caller", caller ?? ""),
+            new SugarParameter("@res", resource),
+            new SugarParameter("@q", queryJson),
+            new SugarParameter("@cnt", rowCount),
+            new SugarParameter("@ok", success ? 1 : 0),
+            new SugarParameter("@msg", message != null && message.Length > 480 ? message[..480] : message));
+}

+ 60 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceConfigService.cs

@@ -0,0 +1,60 @@
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform;
+
+/// <summary>数据源配置只读 API(供配置中心 sources 页)。</summary>
+[ApiDescriptionSettings(Order = 324, Description = "数据中台数据源")]
+[Route("api/DataPlatform")]
+[AllowAnonymous]
+[NonUnify]
+public class MdpSourceConfigService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public MdpSourceConfigService(ISqlSugarClient db) => _db = db;
+
+    [DisplayName("数据源列表")]
+    [HttpGet("sources")]
+    public async Task<object> GetList([FromQuery] string? keyword, [FromQuery] string? type)
+    {
+        var q = _db.Queryable<MdpSource>().Where(x => x.Status == 1);
+        if (!string.IsNullOrWhiteSpace(keyword))
+        {
+            var kw = keyword.Trim();
+            q = q.Where(x => x.SourceCode.Contains(kw) || x.SourceName.Contains(kw));
+        }
+        if (!string.IsNullOrWhiteSpace(type))
+            q = q.Where(x => x.SourceType == type.Trim());
+
+        var list = await q.OrderBy(x => x.SourceCode).ToListAsync();
+        return new
+        {
+            list = list.Select(x => new
+            {
+                id = x.Id,
+                sourceCode = x.SourceCode,
+                sourceName = x.SourceName,
+                sourceType = x.SourceType,
+                direction = string.Equals(x.SourceType, "API", StringComparison.OrdinalIgnoreCase) ? "拉取" : "入站",
+                dbType = x.DbType ?? "-",
+                host = string.Equals(x.SourceType, "API", StringComparison.OrdinalIgnoreCase)
+                    ? (x.ApiBaseUrl ?? "-")
+                    : $"{x.DbHost}/{x.DbName}",
+                health = x.HealthStatus switch
+                {
+                    1 => "正常",
+                    0 => "异常",
+                    _ => "未知"
+                },
+                healthType = x.HealthStatus switch
+                {
+                    1 => "success",
+                    0 => "danger",
+                    _ => "info"
+                },
+                lastCheck = x.LastHealthCheck?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-",
+                healthMsg = x.HealthMsg
+            })
+        };
+    }
+}

+ 162 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSyncTaskConfigService.cs

@@ -505,4 +505,166 @@ public class MdpSyncTaskConfigService : IDynamicApiController, ITransient
             AdminJobConfigJson = schedule.AdminJobConfigJson,
             Description = schedule.Description
         };
+
+    [DisplayName("任务参数列表")]
+    [HttpGet("sync-tasks/{taskCode}/params")]
+    public async Task<object> GetParams(string taskCode)
+    {
+        var tenantId = _userManager.TenantId;
+        var list = await _db.Queryable<MdpSyncTaskParam>()
+            .Where(u => u.TaskCode == taskCode && (u.TenantId == tenantId || u.TenantId == 0))
+            .OrderBy(u => u.SortOrder)
+            .ToListAsync();
+        return new
+        {
+            list = list.Select(p => new
+            {
+                p.Id,
+                p.TaskCode,
+                p.ScopeType,
+                p.ScopeCode,
+                p.ParamKey,
+                p.ParamName,
+                p.ParamType,
+                p.ParamValue,
+                p.DefaultValue,
+                required = p.Required != 0,
+                editable = p.Editable != 0,
+                p.SortOrder,
+                p.Description
+            })
+        };
+    }
+
+    [DisplayName("保存任务参数")]
+    [HttpPut("sync-tasks/{taskCode}/params")]
+    public async Task<object> SaveParams(string taskCode, [FromBody] List<MdpSyncTaskParamSaveItem> items)
+    {
+        var tenantId = _userManager.TenantId;
+        items ??= [];
+        await _db.Deleteable<MdpSyncTaskParam>()
+            .Where(u => u.TaskCode == taskCode && u.TenantId == tenantId)
+            .ExecuteCommandAsync();
+        var now = DateTime.Now;
+        var rows = items.Where(x => !string.IsNullOrWhiteSpace(x.ParamKey)).Select((x, i) => new MdpSyncTaskParam
+        {
+            TenantId = tenantId,
+            TaskCode = taskCode,
+            ScopeType = string.IsNullOrWhiteSpace(x.ScopeType) ? "TASK" : x.ScopeType.Trim(),
+            ScopeCode = x.ScopeCode?.Trim() ?? "",
+            ParamKey = x.ParamKey.Trim(),
+            ParamName = string.IsNullOrWhiteSpace(x.ParamName) ? x.ParamKey.Trim() : x.ParamName.Trim(),
+            ParamType = string.IsNullOrWhiteSpace(x.ParamType) ? "STRING" : x.ParamType.Trim(),
+            ParamValue = x.ParamValue,
+            DefaultValue = x.DefaultValue,
+            Required = x.Required ? (byte)1 : (byte)0,
+            Editable = x.Editable ? (byte)1 : (byte)0,
+            SortOrder = x.SortOrder > 0 ? x.SortOrder : i + 1,
+            Description = x.Description,
+            CreateTime = now,
+            UpdateTime = now
+        }).ToList();
+        if (rows.Count > 0)
+            await _db.Insertable(rows).ExecuteCommandAsync();
+        return new { ok = true, count = rows.Count };
+    }
+
+    [DisplayName("任务公式列表")]
+    [HttpGet("sync-tasks/{taskCode}/formulas")]
+    public async Task<object> GetFormulas(string taskCode)
+    {
+        var tenantId = _userManager.TenantId;
+        var list = await _db.Queryable<MdpSyncTaskFormula>()
+            .Where(u => u.TaskCode == taskCode && (u.TenantId == tenantId || u.TenantId == 0))
+            .OrderBy(u => u.SortOrder)
+            .ToListAsync();
+        return new
+        {
+            list = list.Select(f => new
+            {
+                f.Id,
+                f.TaskCode,
+                f.StepCode,
+                f.FormulaCode,
+                f.FormulaName,
+                f.MetricCode,
+                f.FormulaExpr,
+                f.FormulaPreview,
+                f.Direction,
+                f.YellowThreshold,
+                f.RedThreshold,
+                f.VersionNo,
+                enabled = f.IsEnabled != 0,
+                f.SortOrder,
+                f.Description
+            })
+        };
+    }
+
+    [DisplayName("保存任务公式")]
+    [HttpPut("sync-tasks/{taskCode}/formulas")]
+    public async Task<object> SaveFormulas(string taskCode, [FromBody] List<MdpSyncTaskFormulaSaveItem> items)
+    {
+        var tenantId = _userManager.TenantId;
+        items ??= [];
+        await _db.Deleteable<MdpSyncTaskFormula>()
+            .Where(u => u.TaskCode == taskCode && u.TenantId == tenantId)
+            .ExecuteCommandAsync();
+        var now = DateTime.Now;
+        var rows = items.Where(x => !string.IsNullOrWhiteSpace(x.FormulaCode)).Select((x, i) => new MdpSyncTaskFormula
+        {
+            TenantId = tenantId,
+            TaskCode = taskCode,
+            StepCode = x.StepCode,
+            FormulaCode = x.FormulaCode.Trim(),
+            FormulaName = string.IsNullOrWhiteSpace(x.FormulaName) ? x.FormulaCode.Trim() : x.FormulaName.Trim(),
+            MetricCode = x.MetricCode,
+            FormulaExpr = x.FormulaExpr,
+            FormulaPreview = x.FormulaPreview,
+            Direction = string.IsNullOrWhiteSpace(x.Direction) ? "higher_is_better" : x.Direction.Trim(),
+            YellowThreshold = x.YellowThreshold,
+            RedThreshold = x.RedThreshold,
+            VersionNo = x.VersionNo > 0 ? x.VersionNo : 1,
+            IsEnabled = x.Enabled ? (byte)1 : (byte)0,
+            SortOrder = x.SortOrder > 0 ? x.SortOrder : i + 1,
+            Description = x.Description,
+            CreateTime = now,
+            UpdateTime = now
+        }).ToList();
+        if (rows.Count > 0)
+            await _db.Insertable(rows).ExecuteCommandAsync();
+        return new { ok = true, count = rows.Count };
+    }
+}
+
+public class MdpSyncTaskParamSaveItem
+{
+    public string? ScopeType { get; set; }
+    public string? ScopeCode { get; set; }
+    public string ParamKey { get; set; } = string.Empty;
+    public string? ParamName { get; set; }
+    public string? ParamType { get; set; }
+    public string? ParamValue { get; set; }
+    public string? DefaultValue { get; set; }
+    public bool Required { get; set; }
+    public bool Editable { get; set; } = true;
+    public int SortOrder { get; set; }
+    public string? Description { get; set; }
+}
+
+public class MdpSyncTaskFormulaSaveItem
+{
+    public string? StepCode { get; set; }
+    public string FormulaCode { get; set; } = string.Empty;
+    public string? FormulaName { get; set; }
+    public string? MetricCode { get; set; }
+    public string? FormulaExpr { get; set; }
+    public string? FormulaPreview { get; set; }
+    public string? Direction { get; set; }
+    public decimal? YellowThreshold { get; set; }
+    public decimal? RedThreshold { get; set; }
+    public int VersionNo { get; set; } = 1;
+    public bool Enabled { get; set; } = true;
+    public int SortOrder { get; set; }
+    public string? Description { get; set; }
 }

+ 65 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpSyncTaskFormula.cs

@@ -0,0 +1,65 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+
+[SugarTable("mdp_sync_task_formula", "MDP同步任务公式配置")]
+public class MdpSyncTaskFormula
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "task_code", Length = 100)]
+    public string TaskCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "step_code", Length = 100, IsNullable = true)]
+    public string? StepCode { get; set; }
+
+    [SugarColumn(ColumnName = "formula_code", Length = 100)]
+    public string FormulaCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "formula_name", Length = 200)]
+    public string FormulaName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "metric_code", Length = 100, IsNullable = true)]
+    public string? MetricCode { get; set; }
+
+    [SugarColumn(ColumnName = "formula_expr", Length = 1000, IsNullable = true)]
+    public string? FormulaExpr { get; set; }
+
+    [SugarColumn(ColumnName = "formula_preview", Length = 1000, IsNullable = true)]
+    public string? FormulaPreview { get; set; }
+
+    [SugarColumn(ColumnName = "formula_refs", ColumnDataType = "text", IsNullable = true)]
+    public string? FormulaRefs { get; set; }
+
+    [SugarColumn(ColumnName = "calc_rule", ColumnDataType = "text", IsNullable = true)]
+    public string? CalcRule { get; set; }
+
+    [SugarColumn(ColumnName = "direction", Length = 40)]
+    public string Direction { get; set; } = "higher_is_better";
+
+    [SugarColumn(ColumnName = "yellow_threshold", IsNullable = true)]
+    public decimal? YellowThreshold { get; set; }
+
+    [SugarColumn(ColumnName = "red_threshold", IsNullable = true)]
+    public decimal? RedThreshold { get; set; }
+
+    [SugarColumn(ColumnName = "version_no")]
+    public int VersionNo { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "is_enabled")]
+    public byte IsEnabled { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "sort_order")]
+    public int SortOrder { get; set; }
+
+    [SugarColumn(ColumnName = "description", Length = 1000, IsNullable = true)]
+    public string? Description { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}

+ 53 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpSyncTaskParam.cs

@@ -0,0 +1,53 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+
+[SugarTable("mdp_sync_task_param", "MDP同步任务参数配置")]
+public class MdpSyncTaskParam
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "task_code", Length = 100)]
+    public string TaskCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "scope_type", Length = 40)]
+    public string ScopeType { get; set; } = "TASK";
+
+    [SugarColumn(ColumnName = "scope_code", Length = 100)]
+    public string ScopeCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "param_key", Length = 100)]
+    public string ParamKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "param_name", Length = 200)]
+    public string ParamName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "param_type", Length = 40)]
+    public string ParamType { get; set; } = "STRING";
+
+    [SugarColumn(ColumnName = "param_value", ColumnDataType = "text", IsNullable = true)]
+    public string? ParamValue { get; set; }
+
+    [SugarColumn(ColumnName = "default_value", ColumnDataType = "text", IsNullable = true)]
+    public string? DefaultValue { get; set; }
+
+    [SugarColumn(ColumnName = "required")]
+    public byte Required { get; set; }
+
+    [SugarColumn(ColumnName = "editable")]
+    public byte Editable { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "sort_order")]
+    public int SortOrder { get; set; }
+
+    [SugarColumn(ColumnName = "description", Length = 1000, IsNullable = true)]
+    public string? Description { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}

+ 7 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/QmsTenantScope.cs

@@ -14,17 +14,20 @@ public static class QmsTenantScope
         return DefaultTenantId;
     }
 
-    /// <summary>从当前请求 UserManager 解析租户(无登录时回落默认)。</summary>
-    public static long Current(long? inputTenantId = null)
+    /// <summary>从当前请求 UserManager 解析租户(无登录时回落默认)。无可选参数,可安全用于表达式树外局部变量。</summary>
+    public static long Current()
     {
         try
         {
             var um = App.GetRequiredService<UserManager>();
-            return Resolve(inputTenantId, um.TenantId);
+            return Resolve(null, um.TenantId);
         }
         catch
         {
-            return Resolve(inputTenantId, null);
+            return Resolve(null, null);
         }
     }
+
+    /// <summary>优先使用显式入参租户。</summary>
+    public static long Current(long inputTenantId) => Resolve(inputTenantId, null);
 }

+ 83 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs

@@ -0,0 +1,83 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// 定时探活 mdp_source:DB SELECT 1 / API GET baseUrl(或 /health)。
+/// </summary>
+[JobDetail("job_mdp_source_health", Description = "MDP 数据源健康检查", GroupName = "default", Concurrent = false)]
+[PeriodSeconds(300, TriggerId = "trigger_mdp_source_health", Description = "每 5 分钟探活数据源", RunOnStart = false)]
+public class MdpSourceHealthCheckJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public MdpSourceHealthCheckJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(MdpSourceHealthCheckJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+        var scopeFactory = scope.ServiceProvider.GetRequiredService<MdpSourceScopeFactory>();
+        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
+
+        var sources = await db.Queryable<MdpSource>().Where(x => x.Status == 1).ToListAsync(stoppingToken);
+        var ok = 0;
+        var fail = 0;
+        foreach (var src in sources)
+        {
+            stoppingToken.ThrowIfCancellationRequested();
+            var now = DateTime.Now;
+            try
+            {
+                if (string.Equals(src.SourceType, "DB", StringComparison.OrdinalIgnoreCase))
+                {
+                    var scopeDb = await scopeFactory.GetScopeAsync(src.SourceCode, stoppingToken);
+                    await scopeDb.Ado.GetIntAsync("SELECT 1");
+                    src.HealthStatus = 1;
+                    src.HealthMsg = "OK";
+                    ok++;
+                }
+                else if (string.Equals(src.SourceType, "API", StringComparison.OrdinalIgnoreCase))
+                {
+                    if (string.IsNullOrWhiteSpace(src.ApiBaseUrl))
+                        throw new InvalidOperationException("api_base_url 为空");
+                    var url = src.ApiBaseUrl.TrimEnd('/') + "/";
+                    using var resp = await http.GetAsync(url, stoppingToken);
+                    src.HealthStatus = (int)resp.StatusCode is >= 200 and < 500 ? 1 : 0;
+                    src.HealthMsg = $"HTTP {(int)resp.StatusCode}";
+                    if (src.HealthStatus == 1) ok++; else fail++;
+                }
+                else
+                {
+                    src.HealthStatus = 0;
+                    src.HealthMsg = $"未知 source_type={src.SourceType}";
+                    fail++;
+                }
+            }
+            catch (Exception ex)
+            {
+                src.HealthStatus = 0;
+                src.HealthMsg = ex.Message.Length > 480 ? ex.Message[..480] : ex.Message;
+                fail++;
+            }
+
+            src.LastHealthCheck = now;
+            src.UpdateTime = now;
+            await db.Updateable(src)
+                .UpdateColumns(x => new { x.HealthStatus, x.HealthMsg, x.LastHealthCheck, x.UpdateTime })
+                .ExecuteCommandAsync(stoppingToken);
+        }
+
+        if (sources.Count > 0)
+            _logger.LogInformation("[MdpSourceHealthCheckJob] total={Total} ok={Ok} fail={Fail}", sources.Count, ok, fail);
+    }
+}

+ 151 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/ReportWorkMdpSyncService.cs

@@ -0,0 +1,151 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+
+namespace Admin.NET.Plugin.AiDOP.Manufacturing;
+
+/// <summary>
+/// S6 报工双模式入站:执行器 → mdp_stg_s6_report → mdp_std_s6_report。
+/// 源优先 T8 <c>Cj_Bg_Head_Rep</c>(实体 S6_REPORT);API 对偶 S6_REPORT_API。
+/// </summary>
+public class ReportWorkMdpSyncService : ITransient
+{
+    private const string InboundEntityCode = "S6_REPORT";
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpSourcePullDispatcher _pullDispatcher;
+
+    public ReportWorkMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    {
+        _db = db;
+        _pullDispatcher = pullDispatcher;
+    }
+
+    public async Task<ReportWorkInboundResult> RunInboundAsync(
+        long tenantId = 0,
+        bool fullRefresh = false,
+        string? entityCode = null,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+
+        var code = string.IsNullOrWhiteSpace(entityCode) ? InboundEntityCode : entityCode.Trim();
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = fullRefresh,
+            TaskCode = "S6_REPORT_INBOUND",
+            BatchId = $"S6_RPT_IN_{now:yyyyMMddHHmmss}"
+        };
+        var pull = await _pullDispatcher.PullByEntityCodeAsync(code, pullCtx, cancellationToken);
+        var transformBatch = $"{pullCtx.BatchId}_STD";
+        var stdRows = await TransformStandardAsync(tenantId, transformBatch, now);
+        return new ReportWorkInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = pull.RowsWritten,
+            RowsWrittenStg = pull.RowsWritten,
+            TransformBatchId = transformBatch,
+            StandardRows = stdRows
+        };
+    }
+
+    private async Task<int> TransformStandardAsync(long tenantId, string batchId, DateTime now)
+    {
+        // 将 PENDING stg 投影到 std(JSON 字段兼容大小写)
+        const string sql = """
+            INSERT INTO mdp_std_s6_report
+              (tenant_id, factory_id, source_system, work_order_no, report_date, report_qty, ztid,
+               source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+              IFNULL(s.tenant_id, @tid),
+              1,
+              IFNULL(NULLIF(s.source_system,''), 'T8'),
+              IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data, '$.noid')), 'null'),
+                     IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data, '$.NOID')), 'null'), s.source_biz_key)),
+              COALESCE(
+                STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data, '$.kgdate')), 'null'), ''), '%Y-%m-%d %H:%i:%s'),
+                STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data, '$.KGDATE')), 'null'), ''), '%Y-%m-%d %H:%i:%s'),
+                NULL),
+              CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data, '$.sl')), 'null'), '') AS DECIMAL(18,6)),
+              NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data, '$.ztid')), 'null'),
+              IFNULL(NULLIF(s.source_row_id,''), s.source_biz_key),
+              s.source_biz_key,
+              @batch,
+              @now
+            FROM mdp_stg_s6_report s
+            WHERE s.process_status = 'PENDING'
+              AND s.source_biz_key IS NOT NULL
+              AND s.source_biz_key <> ''
+            ON DUPLICATE KEY UPDATE
+              work_order_no = VALUES(work_order_no),
+              report_date = VALUES(report_date),
+              report_qty = VALUES(report_qty),
+              ztid = VALUES(ztid),
+              source_row_id = VALUES(source_row_id),
+              sync_batch_id = VALUES(sync_batch_id),
+              sync_time = VALUES(sync_time),
+              update_time = CURRENT_TIMESTAMP
+            """;
+        var affected = await _db.Ado.ExecuteCommandAsync(sql,
+            new SugarParameter("@tid", tenantId),
+            new SugarParameter("@batch", batchId),
+            new SugarParameter("@now", now));
+
+        await _db.Ado.ExecuteCommandAsync(
+            "UPDATE mdp_stg_s6_report SET process_status='DONE', update_time=NOW() WHERE process_status='PENDING'");
+        return affected;
+    }
+
+    private async Task EnsureTablesAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync("""
+            CREATE TABLE IF NOT EXISTS mdp_stg_s6_report (
+              id bigint NOT NULL AUTO_INCREMENT,
+              tenant_id bigint NOT NULL DEFAULT 0,
+              source_system varchar(50) DEFAULT NULL,
+              source_table varchar(200) DEFAULT NULL,
+              source_row_id varchar(200) DEFAULT NULL,
+              source_biz_key varchar(300) DEFAULT NULL,
+              raw_data json DEFAULT NULL,
+              sync_batch_id varchar(100) DEFAULT NULL,
+              create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+              sync_time datetime DEFAULT CURRENT_TIMESTAMP,
+              process_status varchar(20) NOT NULL DEFAULT 'PENDING',
+              process_message varchar(500) DEFAULT NULL,
+              update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+              PRIMARY KEY (id),
+              UNIQUE KEY uk_source_key (source_system, source_table, source_biz_key)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+            """);
+        await _db.Ado.ExecuteCommandAsync("""
+            CREATE TABLE IF NOT EXISTS mdp_std_s6_report (
+              id bigint NOT NULL AUTO_INCREMENT,
+              tenant_id bigint NOT NULL DEFAULT 0,
+              factory_id bigint DEFAULT 1,
+              source_system varchar(50) NOT NULL DEFAULT 'T8',
+              work_order_no varchar(100) NOT NULL,
+              report_date datetime DEFAULT NULL,
+              report_qty decimal(18,6) DEFAULT NULL,
+              ztid varchar(50) DEFAULT NULL,
+              source_row_id varchar(100) NOT NULL,
+              source_biz_key varchar(200) NOT NULL,
+              sync_batch_id varchar(100) NOT NULL,
+              sync_time datetime NOT NULL,
+              update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+              PRIMARY KEY (id),
+              UNIQUE KEY uk_mdp_std_s6_report (tenant_id, source_system, source_biz_key)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+            """);
+    }
+}
+
+public class ReportWorkInboundResult
+{
+    public string PullBatchId { get; set; } = string.Empty;
+    public int RowsPulled { get; set; }
+    public int RowsWrittenStg { get; set; }
+    public string TransformBatchId { get; set; } = string.Empty;
+    public int StandardRows { get; set; }
+}

+ 119 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8DataSourceRowLoader.cs

@@ -0,0 +1,119 @@
+using System.Data;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// S8 规则取数:SQL 走 SqlSugarScope;API 走 HTTP GET(endpoint=完整 URL)。
+/// </summary>
+public class S8DataSourceRowLoader : ITransient
+{
+    public const string SqlType = "SQL";
+    public const string ApiType = "API";
+
+    private readonly S8SqlSugarScopeFactory _scopeFactory;
+    private readonly ILogger<S8DataSourceRowLoader> _logger;
+
+    public S8DataSourceRowLoader(S8SqlSugarScopeFactory scopeFactory, ILogger<S8DataSourceRowLoader> logger)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = logger;
+    }
+
+    public async Task<DataTable> LoadAsync(
+        AdoS8DataSource dataSource,
+        string expression,
+        SqlSugar.DbType dbType,
+        int timeoutSeconds,
+        CancellationToken cancellationToken = default)
+    {
+        var type = dataSource.Type?.Trim() ?? SqlType;
+        if (string.Equals(type, SqlType, StringComparison.OrdinalIgnoreCase))
+        {
+            using var db = _scopeFactory.CreateScope(dataSource.Endpoint!, dbType, timeoutSeconds);
+            return await db.Ado.GetDataTableAsync(expression);
+        }
+
+        if (string.Equals(type, ApiType, StringComparison.OrdinalIgnoreCase))
+            return await LoadFromApiAsync(dataSource, expression, timeoutSeconds, cancellationToken);
+
+        throw new InvalidOperationException($"不支持的数据源类型:{type}");
+    }
+
+    public static bool IsSupportedType(string? type) =>
+        string.Equals(type?.Trim(), SqlType, StringComparison.OrdinalIgnoreCase)
+        || string.Equals(type?.Trim(), ApiType, StringComparison.OrdinalIgnoreCase);
+
+    private async Task<DataTable> LoadFromApiAsync(
+        AdoS8DataSource dataSource,
+        string expression,
+        int timeoutSeconds,
+        CancellationToken cancellationToken)
+    {
+        if (string.IsNullOrWhiteSpace(dataSource.Endpoint))
+            throw new InvalidOperationException("API 数据源 endpoint 为空");
+
+        var url = dataSource.Endpoint.Trim();
+        if (!string.IsNullOrWhiteSpace(expression)
+            && !expression.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
+        {
+            if (expression.StartsWith('?'))
+                url = url.Contains('?') ? url + "&" + expression.TrimStart('?') : url + expression;
+            else if (expression.StartsWith('/'))
+                url = url.TrimEnd('/') + expression;
+        }
+
+        _logger.LogDebug("S8 API 取数 url={Url} authType={AuthType}", url, dataSource.AuthType);
+        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(Math.Max(5, timeoutSeconds)) };
+        using var resp = await http.GetAsync(url, cancellationToken);
+        resp.EnsureSuccessStatusCode();
+        var json = await resp.Content.ReadAsStringAsync(cancellationToken);
+        return JsonToDataTable(json);
+    }
+
+    /// <summary>支持 {data:{list:[...]}} / {list:[...]} / [...] 。</summary>
+    internal static DataTable JsonToDataTable(string json)
+    {
+        using var doc = JsonDocument.Parse(json);
+        JsonElement arr;
+        var root = doc.RootElement;
+        if (root.ValueKind == JsonValueKind.Array)
+            arr = root;
+        else if (root.TryGetProperty("data", out var data) && data.TryGetProperty("list", out var list) && list.ValueKind == JsonValueKind.Array)
+            arr = list;
+        else if (root.TryGetProperty("list", out var list2) && list2.ValueKind == JsonValueKind.Array)
+            arr = list2;
+        else
+            throw new InvalidOperationException("API 响应不是可识别的行数组(期望 data.list / list / [])");
+
+        var table = new DataTable();
+        foreach (var item in arr.EnumerateArray())
+        {
+            if (item.ValueKind != JsonValueKind.Object) continue;
+            foreach (var prop in item.EnumerateObject())
+            {
+                if (!table.Columns.Contains(prop.Name))
+                    table.Columns.Add(prop.Name, typeof(string));
+            }
+        }
+
+        foreach (var item in arr.EnumerateArray())
+        {
+            if (item.ValueKind != JsonValueKind.Object) continue;
+            var row = table.NewRow();
+            foreach (DataColumn col in table.Columns)
+            {
+                if (item.TryGetProperty(col.ColumnName, out var v))
+                    row[col.ColumnName] = v.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
+                        ? DBNull.Value
+                        : v.ToString();
+            }
+            table.Rows.Add(row);
+        }
+
+        return table;
+    }
+}

+ 11 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -22,7 +22,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
     public const string RuleTypeCode = "OUT_OF_RANGE";
     public string RuleType => RuleTypeCode;
 
-    private const string SqlDataSourceType = "SQL";
     private const string DefaultExceptionTypeCode = "EQUIP_FAULT";
 
     // S8-WATCH-EXPRESSION-COLUMN-CONTRACT-SHORTAGE-OUTOFRANGE-FIX-1:S8ConfigDraftService.BuildExpression
@@ -34,16 +33,16 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
     private const string CanonicalRelatedObjectCodeColumn = "related_object_code";
 
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
-    private readonly S8SqlSugarScopeFactory _scopeFactory;
+    private readonly S8DataSourceRowLoader _rowLoader;
     private readonly ILogger<S8OutOfRangeRuleEvaluator> _logger;
 
     public S8OutOfRangeRuleEvaluator(
         SqlSugarRepository<AdoS8DataSource> dataSourceRep,
-        S8SqlSugarScopeFactory scopeFactory,
+        S8DataSourceRowLoader rowLoader,
         ILogger<S8OutOfRangeRuleEvaluator> logger)
     {
         _dataSourceRep = dataSourceRep;
-        _scopeFactory = scopeFactory;
+        _rowLoader = rowLoader;
         _logger = logger;
     }
 
@@ -79,7 +78,7 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
             .FirstAsync();
         if (dataSource == null
             || string.IsNullOrWhiteSpace(dataSource.Endpoint)
-            || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
+            || !S8DataSourceRowLoader.IsSupportedType(dataSource.Type))
             throw new S8RuleEvaluatorException("data_source_unavailable", $"OUT_OF_RANGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
         // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
@@ -89,12 +88,16 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         DataTable table;
         try
         {
-            using var db = _scopeFactory.CreateScope(dataSource.Endpoint!, _dataSourceRep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
-            table = await db.Ado.GetDataTableAsync(rule.Expression!);
+            table = await _rowLoader.LoadAsync(
+                dataSource,
+                rule.Expression!,
+                _dataSourceRep.Context.CurrentConnectionConfig.DbType,
+                timeoutSeconds,
+                cancellationToken);
         }
         catch (Exception ex)
         {
-            throw new S8RuleEvaluatorException("query_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
+            throw new S8RuleEvaluatorException("query_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} 取数失败:{ex.Message}", ex);
         }
 
         // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。

+ 11 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -25,19 +25,17 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
     public const string RuleTypeCode = "SHORTAGE";
     public string RuleType => RuleTypeCode;
 
-    private const string SqlDataSourceType = "SQL";
-
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
-    private readonly S8SqlSugarScopeFactory _scopeFactory;
+    private readonly S8DataSourceRowLoader _rowLoader;
     private readonly ILogger<S8ShortageRuleEvaluator> _logger;
 
     public S8ShortageRuleEvaluator(
         SqlSugarRepository<AdoS8DataSource> dataSourceRep,
-        S8SqlSugarScopeFactory scopeFactory,
+        S8DataSourceRowLoader rowLoader,
         ILogger<S8ShortageRuleEvaluator> logger)
     {
         _dataSourceRep = dataSourceRep;
-        _scopeFactory = scopeFactory;
+        _rowLoader = rowLoader;
         _logger = logger;
     }
 
@@ -71,7 +69,7 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
             .FirstAsync();
         if (dataSource == null
             || string.IsNullOrWhiteSpace(dataSource.Endpoint)
-            || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
+            || !S8DataSourceRowLoader.IsSupportedType(dataSource.Type))
             throw new S8RuleEvaluatorException("data_source_unavailable", $"SHORTAGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
         // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
@@ -81,12 +79,16 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         DataTable table;
         try
         {
-            using var db = _scopeFactory.CreateScope(dataSource.Endpoint!, _dataSourceRep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
-            table = await db.Ado.GetDataTableAsync(rule.Expression!);
+            table = await _rowLoader.LoadAsync(
+                dataSource,
+                rule.Expression!,
+                _dataSourceRep.Context.CurrentConnectionConfig.DbType,
+                timeoutSeconds,
+                cancellationToken);
         }
         catch (Exception ex)
         {
-            throw new S8RuleEvaluatorException("query_failed", $"SHORTAGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
+            throw new S8RuleEvaluatorException("query_failed", $"SHORTAGE 规则 {rule.RuleCode} 取数失败:{ex.Message}", ex);
         }
 
         // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。

+ 11 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -20,8 +20,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     public const string RuleTypeCode = "TIMEOUT";
     public string RuleType => RuleTypeCode;
 
-    private const string SqlDataSourceType = "SQL";
-
     // S8-WATCH-EXPRESSION-COLUMN-CONTRACT-FIX-1:S8ConfigDraftService.BuildExpression 统一把结果列
     // 别名为以下 canonical 名(无论源表真实列名为何)。evaluator 优先按 canonical 读取,仅当结果集
     // 不含 canonical 列时才回退到 params_json 指定的真实列名(兼容历史未别名规则)。
@@ -31,16 +29,16 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     private const string CanonicalRelatedObjectCodeColumn = "related_object_code";
 
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
-    private readonly S8SqlSugarScopeFactory _scopeFactory;
+    private readonly S8DataSourceRowLoader _rowLoader;
     private readonly ILogger<S8TimeoutRuleEvaluator> _logger;
 
     public S8TimeoutRuleEvaluator(
         SqlSugarRepository<AdoS8DataSource> dataSourceRep,
-        S8SqlSugarScopeFactory scopeFactory,
+        S8DataSourceRowLoader rowLoader,
         ILogger<S8TimeoutRuleEvaluator> logger)
     {
         _dataSourceRep = dataSourceRep;
-        _scopeFactory = scopeFactory;
+        _rowLoader = rowLoader;
         _logger = logger;
     }
 
@@ -75,7 +73,7 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
             .FirstAsync();
         if (dataSource == null
             || string.IsNullOrWhiteSpace(dataSource.Endpoint)
-            || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
+            || !S8DataSourceRowLoader.IsSupportedType(dataSource.Type))
             throw new S8RuleEvaluatorException("data_source_unavailable", $"TIMEOUT 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
         // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
@@ -85,12 +83,16 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         DataTable table;
         try
         {
-            using var db = _scopeFactory.CreateScope(dataSource.Endpoint!, _dataSourceRep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
-            table = await db.Ado.GetDataTableAsync(rule.Expression!);
+            table = await _rowLoader.LoadAsync(
+                dataSource,
+                rule.Expression!,
+                _dataSourceRep.Context.CurrentConnectionConfig.DbType,
+                timeoutSeconds,
+                cancellationToken);
         }
         catch (Exception ex)
         {
-            throw new S8RuleEvaluatorException("query_failed", $"TIMEOUT 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
+            throw new S8RuleEvaluatorException("query_failed", $"TIMEOUT 规则 {rule.RuleCode} 取数失败:{ex.Message}", ex);
         }
 
         // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。

+ 22 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -28,6 +28,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
     private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
+    private readonly S8DataSourceRowLoader _rowLoader;
     private readonly ILogger<S8WatchSchedulerService> _logger;
     private readonly SqlSugarRepository<AdoS8DetectionLog> _detectionLogRep;
     private readonly SqlSugarRepository<AdoS8RuleDetectionState> _detectionStateRep;
@@ -66,6 +67,7 @@ public class S8WatchSchedulerService : ITransient
         S8TimeoutRuleEvaluator timeoutEvaluator,
         S8ShortageRuleEvaluator shortageEvaluator,
         S8OutOfRangeRuleEvaluator outOfRangeEvaluator,
+        S8DataSourceRowLoader rowLoader,
         ILogger<S8WatchSchedulerService> logger,
         SqlSugarRepository<AdoS8DetectionLog> detectionLogRep,
         SqlSugarRepository<AdoS8RuleDetectionState> detectionStateRep)
@@ -82,6 +84,7 @@ public class S8WatchSchedulerService : ITransient
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
         _outOfRangeEvaluator = outOfRangeEvaluator;
+        _rowLoader = rowLoader;
         _logger = logger;
         _detectionLogRep = detectionLogRep;
         _detectionStateRep = detectionStateRep;
@@ -181,16 +184,30 @@ public class S8WatchSchedulerService : ITransient
 
     public async Task<S8WatchDeviceQueryResult> QueryDeviceRowsAsync(S8WatchExecutionRule rule)
     {
-        if (!string.Equals(rule.DataSourceType, SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
-            return S8WatchDeviceQueryResult.Fail(rule, "数据源类型不是 SQL,已跳过");
+        if (!S8DataSourceRowLoader.IsSupportedType(rule.DataSourceType))
+            return S8WatchDeviceQueryResult.Fail(rule, "数据源类型不是 SQL/API,已跳过");
 
         if (string.IsNullOrWhiteSpace(rule.QueryExpression))
             return S8WatchDeviceQueryResult.Fail(rule, "查询表达式为空,已跳过");
 
         try
         {
-            using var db = CreateSqlQueryScope(rule.DataSourceConnection);
-            var table = await db.Ado.GetDataTableAsync(rule.QueryExpression);
+            DataTable table;
+            if (string.Equals(rule.DataSourceType, S8DataSourceRowLoader.ApiType, StringComparison.OrdinalIgnoreCase))
+            {
+                var ds = new AdoS8DataSource
+                {
+                    Type = S8DataSourceRowLoader.ApiType,
+                    Endpoint = rule.DataSourceConnection,
+                    Enabled = true
+                };
+                table = await _rowLoader.LoadAsync(ds, rule.QueryExpression, SqlSugar.DbType.MySql, 30);
+            }
+            else
+            {
+                using var db = CreateSqlQueryScope(rule.DataSourceConnection);
+                table = await db.Ado.GetDataTableAsync(rule.QueryExpression);
+            }
             if (!HasRequiredColumns(table))
                 return S8WatchDeviceQueryResult.Fail(rule, "查询结果缺少 required columns: related_object_code/current_value");
 
@@ -1016,7 +1033,7 @@ public class S8WatchSchedulerService : ITransient
 
     private static bool IsSupportedSqlDataSource(AdoS8DataSource dataSource) =>
         dataSource.Enabled
-        && string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase)
+        && S8DataSourceRowLoader.IsSupportedType(dataSource.Type)
         && !string.IsNullOrWhiteSpace(dataSource.Endpoint);
 
     private static bool IsDeviceWatchObjectType(string? watchObjectType)

+ 47 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestExternalPushService.cs

@@ -1,4 +1,6 @@
 using System.Globalization;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Microsoft.Extensions.Options;
 using Yitter.IdGenerator;
 
@@ -6,6 +8,7 @@ namespace Admin.NET.Plugin.AiDOP.Supply;
 
 /// <summary>
 /// 采购申请外部事务推送服务。对齐旧 PrSendSAP:写 QadTracking;真实 SAP/QAD 默认关闭。
+/// 同步写 mdp_outbox(S3_PR_PUSH)占位,供配置真实源后由 MdpOutboxPushJob 推送;不替代审批流白名单。
 /// </summary>
 public class PurchaseRequestExternalPushService : ITransient
 {
@@ -58,6 +61,7 @@ public class PurchaseRequestExternalPushService : ITransient
                 pr.UpdateByName = account;
                 pr.UpdateTime = DateTime.Now;
                 pushedIds.Add(pr.Id);
+                await TryEnqueuePrPushOutboxAsync(pr);
                 seqId++;
             }
         }
@@ -67,6 +71,49 @@ public class PurchaseRequestExternalPushService : ITransient
         return result;
     }
 
+    /// <summary>写 mdp_outbox(幂等);失败不阻断 QadTracking。真实 SAP 仍受 EnableRealSapPush/审批白名单约束。</summary>
+    private async Task TryEnqueuePrPushOutboxAsync(PurchaseRequestMain pr)
+    {
+        try
+        {
+            var tenantId = pr.TenantId > 0 ? pr.TenantId : 0;
+            var idem = string.IsNullOrWhiteSpace(pr.PrBillNo) ? pr.Id.ToString() : pr.PrBillNo!;
+            var exists = await _db.Ado.GetIntAsync(
+                "SELECT COUNT(1) FROM mdp_outbox WHERE tenant_id=@tid AND target_source_code='WMS_API' AND action_code='S3_PR_PUSH' AND idem_key=@idem",
+                new List<SugarParameter> { new("@tid", tenantId), new("@idem", idem) }) > 0;
+            if (exists) return;
+
+            var payload = JsonSerializer.Serialize(new
+            {
+                path = "/pr/push",
+                method = "POST",
+                body = new
+                {
+                    prId = pr.Id,
+                    prBillNo = pr.PrBillNo,
+                    action = "S3_PR_PUSH",
+                    note = "placeholder; real SAP/QAD requires EnableRealSapPush + approval whitelist"
+                }
+            });
+            await _db.Insertable(new MdpOutbox
+            {
+                TenantId = tenantId,
+                TargetSourceCode = "WMS_API",
+                ActionCode = "S3_PR_PUSH",
+                IdemKey = idem,
+                PayloadJson = payload,
+                Status = 0,
+                RetryCount = 0,
+                CreateTime = DateTime.Now,
+                UpdateTime = DateTime.Now
+            }).ExecuteCommandAsync();
+        }
+        catch
+        {
+            /* Outbox 失败不影响主流程 */
+        }
+    }
+
     public static List<PurchaseRequestMain> SelectPushCandidates(IEnumerable<PurchaseRequestMain> requests)
     {
         return requests