Переглянути джерело

feat(s5): 委外发料单数据中台只读链路 (S5-OUTSOURCE-ISSUE-MDP-PIPELINE-1)

- 新增 1.0.206.sql:建 mdp_stg/mdp_std_outsource_issue(_detail) 4 表(贴源 raw_data JSON + 标准层 typed;
  std 明细仅已确认 9 列,发料数量/已发数/批次号 3 候选列后置)
- 新增 OutsourceIssueMdpSyncService:只读 NbrMaster/NbrDetail Type='CA' → stg → std(不写源表,CA 0 行成功)
- AidopKanbanController:新增 POST s5-outsource-issue-mdp/refresh(独立于 KPI 管线)
- 新增 OutsourceIssueService + DTO:只读 list/detail(读 mdp_std,排序白名单,空态 total=0)
- 前端 outsourceIssue.ts + outsourceIssueList.vue:接真实接口,空态可用,无 mock、无写路径
- chore: bump version Web 2.4.202 / server 1.0.206
YY968XX 5 днів тому
батько
коміт
44ba6b46d7

+ 75 - 0
Web/src/views/aidop/s5/api/outsourceIssue.ts

@@ -0,0 +1,75 @@
+import service from '/@/utils/request';
+import { withAidopTenantParams } from '../../api/aidopTenant';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+// ── S5 委外发料单(只读,数据中台标准层 mdp_std_outsource_issue)──
+export interface OutsourceIssueRow {
+	id?: number;
+	/** 发料单号 bill_no */
+	nbr?: string;
+	/** 发料日期 issue_date */
+	date?: string;
+	/** 状态中文 status_desc */
+	statusDescr?: string;
+	/** 委外单号 outsource_no */
+	address?: string;
+	/** 工单号 work_order(默认隐藏列) */
+	workOrd?: string;
+	/** 部门 department_code + department_name */
+	departmentDescr?: string;
+	/** 发料人 issuer */
+	user1?: string;
+	/** 备注 remark */
+	remark?: string;
+	/** 创建用户 create_user */
+	createUser?: string;
+	/** 创建时间 source_create_time */
+	createTime?: string;
+}
+
+export interface OutsourceIssueDetailLine {
+	id?: number;
+	line?: number;
+	itemNum?: string;
+	itemName?: string;
+	um?: string;
+	qtyOrd?: number;
+	locationFrom?: string;
+	locationTo?: string;
+	status?: string;
+	remark?: string;
+}
+
+export interface OutsourceIssueDetail {
+	id?: number;
+	nbr?: string;
+	date?: string;
+	address?: string;
+	workOrd?: string;
+	departmentDescr?: string;
+	user1?: string;
+	statusDescr?: string;
+	remark?: string;
+	createUser?: string;
+	createTime?: string;
+	/** 明细行(已确认 9 列;发料数量/已发数/批次号 3 候选列后置不返回) */
+	lines?: OutsourceIssueDetailLine[];
+}
+
+export function fetchOutsourceIssueList(params: any) {
+	return service
+		.get<Paged<OutsourceIssueRow>>('/api/OutsourceIssue/list', { params: withAidopTenantParams(params) })
+		.then((r) => r.data);
+}
+
+export function fetchOutsourceIssueDetail(id: number) {
+	return service
+		.get<OutsourceIssueDetail>('/api/OutsourceIssue/detail', { params: withAidopTenantParams({ id }) })
+		.then((r) => r.data);
+}

+ 120 - 61
Web/src/views/aidop/s5/warehouse/outsourceIssueList.vue

@@ -1,10 +1,9 @@
 <template>
 	<AidopDemoShell :title="pageTitle" subtitle="只读列表">
 		<!--
-			S5 委外发料单 · 只读骨架页(S5-OUTSOURCE-ISSUE-PROTOTYPE-FIELD-MAP-1)。
-			列名/查询项/详情结构均来自旧系统截图与 sql.txt(NbrMaster / NbrDetail),
-			真实业务数据源尚未定位(见字段对接表草案),本页不接真实接口、不写任何数据,
-			列表恒为空数据结构,仅承接页面与字段结构。
+			S5 委外发料单 · 只读页(S5-OUTSOURCE-ISSUE-MDP-PIPELINE-1)。
+			数据源:DOP 数据中台标准层 mdp_std_outsource_issue(由 NbrMaster/NbrDetail Type='CA' 同步标准化)。
+			只读:无新增/编辑/保存/库位转移/物料卡写入。上游未产 CA 单据时列表为真实接口空态,无 mock。
 		-->
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="发料单号">
@@ -31,17 +30,21 @@
 			</el-form-item>
 		</el-form>
 
-		<el-table :data="rows" row-key="id" border stripe>
+		<el-table :data="rows" row-key="id" v-loading="loading" border stripe @sort-change="onSortChange">
 			<el-table-column type="index" label="#" width="60" align="center" />
-			<el-table-column prop="nbr" label="发料单号" min-width="180" show-overflow-tooltip resizable />
-			<el-table-column prop="date" label="发料日期" width="140" resizable />
+			<el-table-column prop="nbr" label="发料单号" min-width="180" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column prop="date" label="发料日期" width="140" sortable="custom" resizable>
+				<template #default="{ row }">{{ fmtDate(row.date) }}</template>
+			</el-table-column>
 			<el-table-column prop="statusDescr" label="状态" width="90" resizable />
 			<el-table-column prop="address" label="委外单号" min-width="180" show-overflow-tooltip resizable />
 			<el-table-column prop="departmentDescr" label="部门" min-width="140" show-overflow-tooltip resizable />
 			<el-table-column prop="user1" label="发料人" width="120" resizable />
 			<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip resizable />
 			<el-table-column prop="createUser" label="创建用户" width="120" resizable />
-			<el-table-column prop="createTime" label="创建时间" width="160" resizable />
+			<el-table-column prop="createTime" label="创建时间" width="160" resizable>
+				<template #default="{ row }">{{ fmtDateTime(row.createTime) }}</template>
+			</el-table-column>
 			<el-table-column label="操作" width="220" fixed="right">
 				<template #default="{ row }">
 					<el-button link type="primary" @click="viewRow(row)">查看</el-button>
@@ -61,61 +64,55 @@
 				:total="total"
 				:page-sizes="[10, 20, 50]"
 				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
 			/>
 		</div>
 
-		<!-- 查看详情骨架:基础信息 + 发料单明细结构(来自 4.png / 5.png),只读、空结构 -->
+		<!-- 查看详情:基础信息 + 发料单明细(已确认 9 列),只读,数据来自真实 detail 接口 -->
 		<el-dialog v-model="detailVisible" title="委外发料单" width="900px" append-to-body>
-			<el-descriptions :column="3" border title="基础信息">
-				<el-descriptions-item label="领料单号">{{ detail.nbr }}</el-descriptions-item>
-				<el-descriptions-item label="委外采购单">{{ detail.address }}</el-descriptions-item>
-				<el-descriptions-item label="发料日期">{{ detail.date }}</el-descriptions-item>
-				<el-descriptions-item label="部门">{{ detail.departmentDescr }}</el-descriptions-item>
-				<el-descriptions-item label="申请人">{{ detail.createUser }}</el-descriptions-item>
-				<el-descriptions-item label="发料人">{{ detail.user1 }}</el-descriptions-item>
-				<el-descriptions-item label="备注" :span="3">{{ detail.remark }}</el-descriptions-item>
-			</el-descriptions>
-
-			<div class="detail-section-title">发料单明细</div>
-			<el-table :data="detailLines" border stripe size="small">
-				<el-table-column type="index" label="项次" width="64" align="center" />
-				<el-table-column prop="itemNum" label="物料编码" min-width="140" />
-				<el-table-column prop="um" label="单位" width="80" />
-				<el-table-column prop="qtyOrd" label="工单需求数" width="110" />
-				<!-- 发料数量 / 已发数 / 批次号 的真实 NbrDetail 列名未确认(见待补字段表),仅承接表头结构,不绑定推测 key -->
-				<el-table-column label="发料数量" width="100" />
-				<el-table-column label="已发数" width="90" />
-				<el-table-column prop="locationFrom" label="转出库位" width="120" />
-				<el-table-column prop="locationTo" label="转入库位" width="120" />
-				<el-table-column label="批次号" width="120" />
-				<el-table-column prop="status" label="状态" width="80" />
-				<el-table-column prop="remark" label="备注" min-width="120" />
-				<template #empty>
-					<el-empty description="暂无数据" />
-				</template>
-			</el-table>
+			<div v-loading="detailLoading">
+				<el-descriptions :column="3" border title="基础信息">
+					<el-descriptions-item label="领料单号">{{ detail.nbr }}</el-descriptions-item>
+					<el-descriptions-item label="委外采购单">{{ detail.address }}</el-descriptions-item>
+					<el-descriptions-item label="发料日期">{{ fmtDate(detail.date) }}</el-descriptions-item>
+					<el-descriptions-item label="部门">{{ detail.departmentDescr }}</el-descriptions-item>
+					<el-descriptions-item label="申请人">{{ detail.createUser }}</el-descriptions-item>
+					<el-descriptions-item label="发料人">{{ detail.user1 }}</el-descriptions-item>
+					<el-descriptions-item label="备注" :span="3">{{ detail.remark }}</el-descriptions-item>
+				</el-descriptions>
+
+				<div class="detail-section-title">发料单明细</div>
+				<el-table :data="detailLines" border stripe size="small">
+					<el-table-column type="index" label="项次" width="64" align="center" />
+					<el-table-column prop="itemNum" label="物料编码" min-width="140" />
+					<el-table-column prop="itemName" label="物料名称" min-width="160" show-overflow-tooltip />
+					<el-table-column prop="um" label="单位" width="80" />
+					<el-table-column prop="qtyOrd" label="工单需求数" width="110" />
+					<el-table-column prop="locationFrom" label="转出库位" width="120" />
+					<el-table-column prop="locationTo" label="转入库位" width="120" />
+					<el-table-column prop="status" label="状态" width="80" />
+					<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
+					<template #empty>
+						<el-empty description="暂无数据" />
+					</template>
+				</el-table>
+			</div>
 		</el-dialog>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS5WarehouseOutsourceIssue">
-import { computed, reactive, ref } from 'vue';
+import { computed, onActivated, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
+import { ElMessage } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
-
-// 列表行结构(key 来自 sql.txt SELECT 列;本页不接真实接口,rows 恒为空)
-interface OutsourceIssueRow {
-	id?: number;
-	nbr?: string;
-	date?: string;
-	statusDescr?: string;
-	address?: string;
-	departmentDescr?: string;
-	user1?: string;
-	remark?: string;
-	createUser?: string;
-	createTime?: string;
-}
+import {
+	fetchOutsourceIssueList,
+	fetchOutsourceIssueDetail,
+	type OutsourceIssueRow,
+	type OutsourceIssueDetailLine,
+} from '../api/outsourceIssue';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '委外发料单');
@@ -127,22 +124,65 @@ const query = reactive({
 	dateEnd: '',
 	page: 1,
 	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
 });
 const dateRange = ref<[string, string] | null>(null);
 
-// 真实数据源待补证(见字段对接表草案):本页不发起请求,列表保持空数据结构。
+const loading = ref(false);
 const rows = ref<OutsourceIssueRow[]>([]);
 const total = ref(0);
 
 const detailVisible = ref(false);
-const detail = reactive<OutsourceIssueRow>({});
-const detailLines = ref<Record<string, unknown>[]>([]);
+const detailLoading = ref(false);
+const detail = reactive<Record<string, any>>({});
+const detailLines = ref<OutsourceIssueDetailLine[]>([]);
+
+function fmtDate(v?: string | null) {
+	if (!v) return '';
+	return String(v).slice(0, 10);
+}
+function fmtDateTime(v?: string | null) {
+	if (!v) return '';
+	const s = String(v);
+	return s.length > 19 ? s.slice(0, 19).replace('T', ' ') : s.replace('T', ' ');
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		query.dateStart = dateRange.value?.[0] || '';
+		query.dateEnd = dateRange.value?.[1] || '';
+		const data = await fetchOutsourceIssueList({
+			billNo: query.nbr,
+			outsourceNo: query.address,
+			issueDateStart: query.dateStart,
+			issueDateEnd: query.dateEnd,
+			page: query.page,
+			pageSize: query.pageSize,
+			sortField: query.sortField,
+			sortOrder: query.sortOrder,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} catch (e: any) {
+		rows.value = [];
+		total.value = 0;
+		ElMessage.error(e?.message || '加载委外发料单列表失败');
+	} finally {
+		loading.value = false;
+	}
+}
 
 function doSearch() {
-	query.dateStart = dateRange.value?.[0] || '';
-	query.dateEnd = dateRange.value?.[1] || '';
 	query.page = 1;
-	// 真实查询接口未就绪,仅维持空结构,不构造任何假数据。
+	loadList();
 }
 
 function resetQuery() {
@@ -152,13 +192,32 @@ function resetQuery() {
 	query.dateStart = '';
 	query.dateEnd = '';
 	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
 }
 
-function viewRow(row: OutsourceIssueRow) {
-	Object.assign(detail, row);
-	detailLines.value = [];
+async function viewRow(row: OutsourceIssueRow) {
+	if (!row?.id) return;
 	detailVisible.value = true;
+	detailLoading.value = true;
+	Object.keys(detail).forEach((k) => delete detail[k]);
+	detailLines.value = [];
+	try {
+		const data = await fetchOutsourceIssueDetail(row.id);
+		if (data) {
+			Object.assign(detail, data);
+			detailLines.value = data.lines || [];
+		}
+	} catch (e: any) {
+		ElMessage.error(e?.message || '加载委外发料单详情失败');
+	} finally {
+		detailLoading.value = false;
+	}
 }
+
+onMounted(() => loadList());
+onActivated(() => loadList());
 </script>
 
 <style scoped lang="scss">

+ 3 - 0
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -190,6 +190,9 @@
     <None Update="UpdateScripts\1.0.205.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.206.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 97 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.206.sql

@@ -0,0 +1,97 @@
+-- ============================================================
+-- 1.0.206.sql
+-- S5-OUTSOURCE-ISSUE-MDP-PIPELINE-1
+--
+-- 委外发料单 DOP 内部数据中台只读链路建表。
+-- 源:aidopdev.NbrMaster / NbrDetail,业务类型 Type='CA'(头明细关联 NbrDetail.NbrRecID -> NbrMaster.RecID)。
+--   贴源层:mdp_stg_outsource_issue / mdp_stg_outsource_issue_detail(raw_data JSON 信封)
+--   标准层:mdp_std_outsource_issue(头·已确认字段) / mdp_std_outsource_issue_detail(明细·已确认 9 列)
+--
+-- 边界:仅 CREATE TABLE IF NOT EXISTS;不 INSERT/UPDATE/DELETE;不动 NbrMaster/NbrDetail;
+--       明细「发料数量/已发数/批次号」3 候选列本批后置,不落 typed 字段。
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mdp_stg_outsource_issue (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id BIGINT NULL DEFAULT 1,
+    source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+    source_table VARCHAR(100) NOT NULL,
+    source_row_id VARCHAR(100) NOT NULL,
+    source_biz_key VARCHAR(200) NULL,
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+    raw_data JSON NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_mdp_stg_outsrc_issue (tenant_id, source_table, source_row_id),
+    KEY idx_mdp_stg_outsrc_issue_batch (sync_batch_id),
+    KEY idx_mdp_stg_outsrc_issue_biz (source_biz_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单头贴源层';
+
+CREATE TABLE IF NOT EXISTS mdp_stg_outsource_issue_detail (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id BIGINT NULL DEFAULT 1,
+    source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+    source_table VARCHAR(100) NOT NULL,
+    source_row_id VARCHAR(100) NOT NULL,
+    source_biz_key VARCHAR(200) NULL,
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+    raw_data JSON NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_mdp_stg_outsrc_issue_dtl (tenant_id, source_table, source_row_id),
+    KEY idx_mdp_stg_outsrc_issue_dtl_batch (sync_batch_id),
+    KEY idx_mdp_stg_outsrc_issue_dtl_biz (source_biz_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单明细贴源层';
+
+CREATE TABLE IF NOT EXISTS mdp_std_outsource_issue (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id BIGINT NULL DEFAULT 1,
+    source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+    bill_no VARCHAR(24) NOT NULL,
+    issue_date DATETIME NULL,
+    outsource_no VARCHAR(60) NULL,
+    work_order VARCHAR(64) NULL,
+    department_code VARCHAR(20) NULL,
+    department_name VARCHAR(255) NULL,
+    issuer VARCHAR(255) NULL,
+    status VARCHAR(8) NULL,
+    status_desc VARCHAR(20) NULL,
+    remark VARCHAR(200) NULL,
+    create_user VARCHAR(24) NULL,
+    source_create_time DATETIME NULL,
+    eff_date DATETIME NULL,
+    source_biz_key VARCHAR(200) NULL,
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_mdp_std_outsrc_issue (tenant_id, bill_no),
+    KEY idx_mdp_std_outsrc_issue_date (tenant_id, issue_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单头标准层';
+
+CREATE TABLE IF NOT EXISTS mdp_std_outsource_issue_detail (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    std_head_id BIGINT NULL,
+    bill_no VARCHAR(24) NOT NULL,
+    line SMALLINT NOT NULL DEFAULT 0,
+    item_num VARCHAR(24) NULL,
+    item_name TEXT NULL,
+    um VARCHAR(8) NULL,
+    qty_ord DECIMAL(18,6) NULL DEFAULT 0,
+    location_from VARCHAR(8) NULL,
+    location_to VARCHAR(8) NULL,
+    status VARCHAR(8) NULL,
+    remark TEXT NULL,
+    source_biz_key VARCHAR(200) NULL,
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_mdp_std_outsrc_issue_dtl (tenant_id, source_biz_key),
+    KEY idx_mdp_std_outsrc_issue_dtl_head (std_head_id),
+    KEY idx_mdp_std_outsrc_issue_dtl_bill (tenant_id, bill_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单明细标准层(发料数量/已发数/批次号 3 候选列后置)';

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

@@ -28,6 +28,7 @@ public partial class AidopKanbanController : ControllerBase
     private readonly S6MdpSyncTransformService _s6MdpSyncTransformService;
     private readonly S7MdpSyncTransformService _s7MdpSyncTransformService;
     private readonly SmartOpsKpiAtomicQueryService _atomicQuery;
+    private readonly OutsourceIssueMdpSyncService _outsourceIssueMdpSyncService;
 
     public AidopKanbanController(
         ISqlSugarClient db,
@@ -38,7 +39,8 @@ public partial class AidopKanbanController : ControllerBase
         S5MdpSyncTransformService s5MdpSyncTransformService,
         S6MdpSyncTransformService s6MdpSyncTransformService,
         S7MdpSyncTransformService s7MdpSyncTransformService,
-        SmartOpsKpiAtomicQueryService atomicQuery)
+        SmartOpsKpiAtomicQueryService atomicQuery,
+        OutsourceIssueMdpSyncService outsourceIssueMdpSyncService)
     {
         _db = db;
         _s1MdpSyncTransformService = s1MdpSyncTransformService;
@@ -49,6 +51,7 @@ public partial class AidopKanbanController : ControllerBase
         _s6MdpSyncTransformService = s6MdpSyncTransformService;
         _s7MdpSyncTransformService = s7MdpSyncTransformService;
         _atomicQuery = atomicQuery;
+        _outsourceIssueMdpSyncService = outsourceIssueMdpSyncService;
     }
 
     [HttpGet("home-l1")]
@@ -430,6 +433,25 @@ LIMIT 60
         });
     }
 
+    /// <summary>
+    /// S5 委外发料单 数据中台只读链路手动刷新(DOP 内部:NbrMaster/NbrDetail Type='CA' → mdp_stg → mdp_std)。
+    /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp 表;不碰 T8;CA 为 0 行时成功、处理数 0。
+    /// </summary>
+    [HttpPost("s5-outsource-issue-mdp/refresh")]
+    public async Task<IActionResult> RefreshS5OutsourceIssueMdp(CancellationToken cancellationToken)
+    {
+        var result = await _outsourceIssueMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            result.BatchId,
+            result.HeadStageRows,
+            result.DetailStageRows,
+            result.HeadStandardRows,
+            result.DetailStandardRows
+        });
+    }
+
     /// <summary>
     /// S6 生产执行 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
     /// 路径 A:方老师 v5.4 KPI #22/#23 原 SQL 直发 T8。

+ 111 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/OutsourceIssueDto.cs

@@ -0,0 +1,111 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+/// <summary>
+/// 委外发料单列表 查询入参(只读)。读 mdp_std_outsource_issue。
+/// </summary>
+public class OutsourceIssueListInput
+{
+    /// <summary>页码(从 1 开始)</summary>
+    public int Page { get; set; } = 1;
+
+    /// <summary>每页条数</summary>
+    public int PageSize { get; set; } = 10;
+
+    /// <summary>发料单号(bill_no,模糊匹配)</summary>
+    public string? BillNo { get; set; }
+
+    /// <summary>委外单号(outsource_no,模糊匹配)</summary>
+    public string? OutsourceNo { get; set; }
+
+    /// <summary>发料日期起(issue_date,含当日,yyyy-MM-dd)</summary>
+    public string? IssueDateStart { get; set; }
+
+    /// <summary>发料日期止(issue_date,含当日,yyyy-MM-dd)</summary>
+    public string? IssueDateEnd { get; set; }
+
+    /// <summary>排序字段(前端列 prop:billNo / issueDate / sourceCreateTime)</summary>
+    public string? SortField { get; set; }
+
+    /// <summary>排序方向(asc / desc)</summary>
+    public string? SortOrder { get; set; }
+
+    /// <summary>租户 ID(前端 withAidopTenantParams 注入;为空则不按租户过滤)</summary>
+    public long? TenantId { get; set; }
+}
+
+/// <summary>
+/// 委外发料单列表 行(只读)。字段对齐前端骨架列 prop(camelCase)。
+/// </summary>
+public class OutsourceIssueListRow
+{
+    /// <summary>主键 id(mdp_std_outsource_issue.id)</summary>
+    public long Id { get; set; }
+
+    /// <summary>发料单号(bill_no)</summary>
+    public string? Nbr { get; set; }
+
+    /// <summary>发料日期(issue_date)</summary>
+    public DateTime? Date { get; set; }
+
+    /// <summary>状态中文(status_desc)</summary>
+    public string? StatusDescr { get; set; }
+
+    /// <summary>委外单号(outsource_no)</summary>
+    public string? Address { get; set; }
+
+    /// <summary>工单号(work_order,默认隐藏列)</summary>
+    public string? WorkOrd { get; set; }
+
+    /// <summary>部门(department_code + department_name)</summary>
+    public string? DepartmentDescr { get; set; }
+
+    /// <summary>发料人(issuer)</summary>
+    public string? User1 { get; set; }
+
+    /// <summary>备注(remark)</summary>
+    public string? Remark { get; set; }
+
+    /// <summary>创建用户(create_user)</summary>
+    public string? CreateUser { get; set; }
+
+    /// <summary>创建时间(source_create_time)</summary>
+    public DateTime? CreateTime { get; set; }
+}
+
+/// <summary>
+/// 委外发料单详情(只读):头 + 明细(已确认 9 列;发料数量/已发数/批次号 3 候选列后置不返回)。
+/// </summary>
+public class OutsourceIssueDetailDto
+{
+    public long Id { get; set; }
+    public string? Nbr { get; set; }
+    public DateTime? Date { get; set; }
+    public string? Address { get; set; }
+    public string? WorkOrd { get; set; }
+    public string? DepartmentDescr { get; set; }
+    public string? User1 { get; set; }
+    public string? StatusDescr { get; set; }
+    public string? Remark { get; set; }
+    public string? CreateUser { get; set; }
+    public DateTime? CreateTime { get; set; }
+
+    /// <summary>明细行(已确认 9 列)</summary>
+    public List<OutsourceIssueDetailLineDto> Lines { get; set; } = new();
+}
+
+/// <summary>
+/// 委外发料单明细行(只读,已确认 9 列)。
+/// </summary>
+public class OutsourceIssueDetailLineDto
+{
+    public long Id { get; set; }
+    public short Line { get; set; }
+    public string? ItemNum { get; set; }
+    public string? ItemName { get; set; }
+    public string? Um { get; set; }
+    public decimal? QtyOrd { get; set; }
+    public string? LocationFrom { get; set; }
+    public string? LocationTo { get; set; }
+    public string? Status { get; set; }
+    public string? Remark { get; set; }
+}

+ 340 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/OutsourceIssueMdpSyncService.cs

@@ -0,0 +1,340 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 委外发料单 数据中台只读同步转换服务(DOP 内部,独立于 S5MdpSyncTransformService 的 KPI 管线)。
+///
+/// 源:aidopdev NbrMaster / NbrDetail,业务类型 Type='CA';头明细关联 NbrDetail.NbrRecID -> NbrMaster.RecID。
+/// 链路:NbrMaster/NbrDetail(CA) 只读 → mdp_stg_outsource_issue(_detail)(raw_data JSON) → mdp_std_outsource_issue(_detail)(typed)。
+///
+/// 约束:
+///   - 只读源表,仅写 mdp_stg_*/mdp_std_*;绝不 INSERT/UPDATE/DELETE NbrMaster/NbrDetail。
+///   - 过滤 NbrMaster.Type='CA' AND IsActive=1;NbrDetail.Type='CA'。
+///   - status_desc 规则本批仅:Status='C' -> '关闭',其他 -> ''。
+///   - department_name 经 NbrMaster.Domain + NbrMaster.Department 关联 DepartmentMaster.Descr。
+///   - 明细只转换已确认 9 列(ItemNum/ItemName/UM/QtyOrd/LocationFrom/LocationTo/Line/Status/Remark);
+///     「发料数量/已发数/批次号」3 候选列本批后置,不转换、不落 typed 字段。
+///   - CA 为 0 行时转换成功完成、处理数为 0,不报错。
+/// </summary>
+public class OutsourceIssueMdpSyncService : ITransient
+{
+    private const string JobCode = "S5_OUTSOURCE_ISSUE_MDP_SYNC";
+
+    private readonly ISqlSugarClient _db;
+
+    public OutsourceIssueMdpSyncService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>
+    /// 全量同步转换:贴源(头/明细) → 标准化(头/明细)。
+    /// </summary>
+    public async Task<OutsourceIssueMdpSyncResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+
+        var now = DateTime.Now;
+        var batchId = $"S5_OUTSRC_ISSUE_FULL_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
+        var result = new OutsourceIssueMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            result.HeadStageRows = await SyncHeadStagingAsync(batchId, now);
+            result.DetailStageRows = await SyncDetailStagingAsync(batchId, now);
+            result.HeadStandardRows = await TransformHeadStandardAsync(batchId, now);
+            result.DetailStandardRows = await TransformDetailStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    /// <summary>防御式建表(与 UpdateScripts/1.0.206.sql 同构,幂等)。</summary>
+    private async Task EnsureTablesAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_stg_outsource_issue (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                factory_id BIGINT NULL DEFAULT 1,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                source_table VARCHAR(100) NOT NULL,
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+                raw_data JSON NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_stg_outsrc_issue (tenant_id, source_table, source_row_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单头贴源层'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_stg_outsource_issue_detail (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                factory_id BIGINT NULL DEFAULT 1,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                source_table VARCHAR(100) NOT NULL,
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+                raw_data JSON NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_stg_outsrc_issue_dtl (tenant_id, source_table, source_row_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单明细贴源层'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_std_outsource_issue (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                factory_id BIGINT NULL DEFAULT 1,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                bill_no VARCHAR(24) NOT NULL,
+                issue_date DATETIME NULL,
+                outsource_no VARCHAR(60) NULL,
+                work_order VARCHAR(64) NULL,
+                department_code VARCHAR(20) NULL,
+                department_name VARCHAR(255) NULL,
+                issuer VARCHAR(255) NULL,
+                status VARCHAR(8) NULL,
+                status_desc VARCHAR(20) NULL,
+                remark VARCHAR(200) NULL,
+                create_user VARCHAR(24) NULL,
+                source_create_time DATETIME NULL,
+                eff_date DATETIME NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_outsrc_issue (tenant_id, bill_no),
+                KEY idx_mdp_std_outsrc_issue_date (tenant_id, issue_date)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单头标准层'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_std_outsource_issue_detail (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                std_head_id BIGINT NULL,
+                bill_no VARCHAR(24) NOT NULL,
+                line SMALLINT NOT NULL DEFAULT 0,
+                item_num VARCHAR(24) NULL,
+                item_name TEXT NULL,
+                um VARCHAR(8) NULL,
+                qty_ord DECIMAL(18,6) NULL DEFAULT 0,
+                location_from VARCHAR(8) NULL,
+                location_to VARCHAR(8) NULL,
+                status VARCHAR(8) NULL,
+                remark TEXT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_outsrc_issue_dtl (tenant_id, source_biz_key),
+                KEY idx_mdp_std_outsrc_issue_dtl_head (std_head_id),
+                KEY idx_mdp_std_outsrc_issue_dtl_bill (tenant_id, bill_no)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5委外发料单明细标准层'
+            """);
+    }
+
+    /// <summary>贴源头:NbrMaster(CA) -> mdp_stg_outsource_issue(raw_data JSON 快照)。返回源 CA 头行数。</summary>
+    private async Task<int> SyncHeadStagingAsync(string batchId, DateTime now)
+    {
+        var rows = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM NbrMaster WHERE Type='CA' AND IsActive=1");
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_stg_outsource_issue
+            (tenant_id, factory_id, source_system, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time, process_status, raw_data)
+            SELECT
+                IFNULL(m.tenant_id, 0), 1, 'AIDOP', 'NbrMaster',
+                CAST(m.RecID AS CHAR), CAST(m.Nbr AS CHAR), @BatchId, @Now, 'PENDING',
+                JSON_OBJECT(
+                    'RecID', m.RecID, 'Nbr', m.Nbr, 'Date', m.Date, 'Address', m.Address,
+                    'WorkOrd', m.WorkOrd, 'Remark', m.Remark, 'CreateUser', m.CreateUser, 'CreateTime', m.CreateTime,
+                    'Status', m.Status, 'Domain', m.Domain, 'Department', m.Department,
+                    'User1', CAST(m.User1 AS CHAR), 'EffDate', m.EffDate, 'Ufld1', m.Ufld1
+                )
+            FROM NbrMaster m
+            WHERE m.Type='CA' AND m.IsActive=1
+            ON DUPLICATE KEY UPDATE
+                source_biz_key=VALUES(source_biz_key), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
+                process_status=VALUES(process_status), raw_data=VALUES(raw_data), update_time=CURRENT_TIMESTAMP
+            """,
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@Now", now));
+        return rows;
+    }
+
+    /// <summary>贴源明细:NbrDetail(CA) -> mdp_stg_outsource_issue_detail。返回源 CA 明细行数。</summary>
+    private async Task<int> SyncDetailStagingAsync(string batchId, DateTime now)
+    {
+        var rows = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM NbrDetail dt
+            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
+            WHERE dt.Type='CA'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_stg_outsource_issue_detail
+            (tenant_id, factory_id, source_system, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time, process_status, raw_data)
+            SELECT
+                IFNULL(dt.tenant_id, 0), 1, 'AIDOP', 'NbrDetail',
+                CAST(dt.RecID AS CHAR), CONCAT(CAST(dt.Nbr AS CHAR), '#', dt.Line), @BatchId, @Now, 'PENDING',
+                JSON_OBJECT(
+                    'RecID', dt.RecID, 'NbrRecID', dt.NbrRecID, 'Nbr', dt.Nbr, 'Line', dt.Line,
+                    'ItemNum', dt.ItemNum, 'ItemName', CAST(dt.ItemName AS CHAR), 'UM', dt.UM, 'QtyOrd', dt.QtyOrd,
+                    'LocationFrom', dt.LocationFrom, 'LocationTo', dt.LocationTo, 'Status', dt.Status, 'Remark', CAST(dt.Remark AS CHAR)
+                )
+            FROM NbrDetail dt
+            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
+            WHERE dt.Type='CA'
+            ON DUPLICATE KEY UPDATE
+                source_biz_key=VALUES(source_biz_key), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
+                process_status=VALUES(process_status), raw_data=VALUES(raw_data), update_time=CURRENT_TIMESTAMP
+            """,
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@Now", now));
+        return rows;
+    }
+
+    /// <summary>标准化头:NbrMaster(CA) + DepartmentMaster -> mdp_std_outsource_issue。返回处理头行数。</summary>
+    private async Task<int> TransformHeadStandardAsync(string batchId, DateTime now)
+    {
+        var rows = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM NbrMaster WHERE Type='CA' AND IsActive=1");
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_std_outsource_issue
+            (tenant_id, factory_id, source_system, bill_no, issue_date, outsource_no, work_order,
+             department_code, department_name, issuer, status, status_desc, remark, create_user,
+             source_create_time, eff_date, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                IFNULL(m.tenant_id, 0), 1, 'AIDOP', m.Nbr, m.Date, m.Address, m.WorkOrd,
+                m.Department, d.Descr, CAST(m.User1 AS CHAR), m.Status,
+                CASE WHEN m.Status='C' THEN '关闭' ELSE '' END,
+                m.Remark, m.CreateUser, m.CreateTime, m.EffDate,
+                CAST(m.Nbr AS CHAR), @BatchId, @Now
+            FROM NbrMaster m
+            LEFT JOIN DepartmentMaster d ON d.Domain = m.Domain AND d.Department = m.Department
+            WHERE m.Type='CA' AND m.IsActive=1
+            ON DUPLICATE KEY UPDATE
+                issue_date=VALUES(issue_date), outsource_no=VALUES(outsource_no), work_order=VALUES(work_order),
+                department_code=VALUES(department_code), department_name=VALUES(department_name), issuer=VALUES(issuer),
+                status=VALUES(status), status_desc=VALUES(status_desc), remark=VALUES(remark), create_user=VALUES(create_user),
+                source_create_time=VALUES(source_create_time), eff_date=VALUES(eff_date),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """,
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@Now", now));
+        return rows;
+    }
+
+    /// <summary>标准化明细:NbrDetail(CA) -> mdp_std_outsource_issue_detail(仅已确认 9 列;回填 std_head_id)。返回处理明细行数。</summary>
+    private async Task<int> TransformDetailStandardAsync(string batchId, DateTime now)
+    {
+        var rows = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM NbrDetail dt
+            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
+            WHERE dt.Type='CA'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_std_outsource_issue_detail
+            (tenant_id, std_head_id, bill_no, line, item_num, item_name, um, qty_ord,
+             location_from, location_to, status, remark, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                IFNULL(dt.tenant_id, 0), h.id, dt.Nbr, dt.Line, dt.ItemNum, CAST(dt.ItemName AS CHAR), dt.UM, dt.QtyOrd,
+                dt.LocationFrom, dt.LocationTo, dt.Status, CAST(dt.Remark AS CHAR),
+                CONCAT(CAST(dt.Nbr AS CHAR), '#', dt.Line), @BatchId, @Now
+            FROM NbrDetail dt
+            JOIN NbrMaster m ON m.RecID = dt.NbrRecID AND m.Type='CA' AND m.IsActive=1
+            LEFT JOIN mdp_std_outsource_issue h ON h.tenant_id = IFNULL(dt.tenant_id, 0) AND h.bill_no = dt.Nbr
+            WHERE dt.Type='CA'
+            ON DUPLICATE KEY UPDATE
+                std_head_id=VALUES(std_head_id), line=VALUES(line), item_num=VALUES(item_num), item_name=VALUES(item_name),
+                um=VALUES(um), qty_ord=VALUES(qty_ord), location_from=VALUES(location_from), location_to=VALUES(location_to),
+                status=VALUES(status), remark=VALUES(remark), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
+                update_time=CURRENT_TIMESTAMP
+            """,
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@Now", now));
+        return rows;
+    }
+
+    private async Task<long> InsertRunLogAsync(string batchId, DateTime startedAt, string triggerType)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_transform_run_log
+            (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time)
+            VALUES (0, @JobCode, 'S5委外发料单MDP同步与标准化转换', @TriggerType, @BatchId, 'RUNNING', @StartTime)
+            """,
+            new SugarParameter("@JobCode", JobCode),
+            new SugarParameter("@TriggerType", NormalizeTriggerType(triggerType)),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StartTime", startedAt));
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    private async Task MarkRunSuccessAsync(long runLogId, DateTime startedAt, OutsourceIssueMdpSyncResult result)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
+                stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=0, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@StageRows", result.HeadStageRows + result.DetailStageRows),
+            new SugarParameter("@StandardRows", result.HeadStandardRows + result.DetailStandardRows),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
+                error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@ErrorMessage", message.Length > 2000 ? message[..2000] : message),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private static string NormalizeTriggerType(string? triggerType)
+        => string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
+}
+
+/// <summary>委外发料单 MDP 同步转换结果。</summary>
+public sealed class OutsourceIssueMdpSyncResult
+{
+    public long RunLogId { get; set; }
+    public string BatchId { get; set; } = string.Empty;
+    public int HeadStageRows { get; set; }
+    public int DetailStageRows { get; set; }
+    public int HeadStandardRows { get; set; }
+    public int DetailStandardRows { get; set; }
+}

+ 172 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/OutsourceIssueService.cs

@@ -0,0 +1,172 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 委外发料单 只读 list/detail 服务。
+///
+/// 数据源:DOP 数据中台标准层 mdp_std_outsource_issue(头)/ mdp_std_outsource_issue_detail(明细)。
+/// 由 OutsourceIssueMdpSyncService 从 aidopdev.NbrMaster/NbrDetail Type='CA' 同步标准化而来。
+///
+/// 本服务仅 SELECT:无新增/编辑/删除/写入;明细只暴露已确认 9 列(发料数量/已发数/批次号 3 候选列后置)。
+/// 当前上游未产 CA 单据,标准层为空,list 返回空数组、detail 返回 null(前端空态)。
+/// </summary>
+[ApiDescriptionSettings(Order = 304, Description = "委外发料单")]
+[Route("api/OutsourceIssue")]
+[AllowAnonymous]
+[NonUnify]
+public class OutsourceIssueService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public OutsourceIssueService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>
+    /// 委外发料单列表(只读分页查询)。
+    /// </summary>
+    [DisplayName("委外发料单列表")]
+    [HttpGet("list")]
+    public async Task<object> GetList([FromQuery] OutsourceIssueListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var where = new List<string> { "1=1" };
+        var pars = new List<SugarParameter>();
+
+        if (input.TenantId is > 0)
+        {
+            where.Add("m.tenant_id = @TenantId");
+            pars.Add(new SugarParameter("@TenantId", input.TenantId));
+        }
+        if (!string.IsNullOrWhiteSpace(input.BillNo))
+        {
+            where.Add("m.bill_no LIKE @BillNo");
+            pars.Add(new SugarParameter("@BillNo", $"%{input.BillNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.OutsourceNo))
+        {
+            where.Add("m.outsource_no LIKE @OutsourceNo");
+            pars.Add(new SugarParameter("@OutsourceNo", $"%{input.OutsourceNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.IssueDateStart))
+        {
+            where.Add("m.issue_date >= @IssueDateStart");
+            pars.Add(new SugarParameter("@IssueDateStart", $"{input.IssueDateStart.Trim()} 00:00:00"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.IssueDateEnd))
+        {
+            where.Add("m.issue_date <= @IssueDateEnd");
+            pars.Add(new SugarParameter("@IssueDateEnd", $"{input.IssueDateEnd.Trim()} 23:59:59"));
+        }
+
+        var whereSql = string.Join(" AND ", where);
+
+        var total = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM mdp_std_outsource_issue m WHERE {whereSql}", pars);
+
+        var list = await _db.Ado.SqlQueryAsync<OutsourceIssueListRow>(
+            $"""
+            SELECT
+                m.id            AS Id,
+                m.bill_no       AS Nbr,
+                m.issue_date    AS Date,
+                m.status_desc   AS StatusDescr,
+                m.outsource_no  AS Address,
+                m.work_order    AS WorkOrd,
+                TRIM(CONCAT(IFNULL(m.department_code,''), ' ', IFNULL(m.department_name,''))) AS DepartmentDescr,
+                m.issuer        AS User1,
+                m.remark        AS Remark,
+                m.create_user   AS CreateUser,
+                m.source_create_time AS CreateTime
+            FROM mdp_std_outsource_issue m
+            WHERE {whereSql}
+            ORDER BY {BuildOrderBy(input.SortField, input.SortOrder)}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    /// <summary>
+    /// 委外发料单详情(只读:头 + 明细)。查不到返回 null。
+    /// </summary>
+    [DisplayName("委外发料单详情")]
+    [HttpGet("detail")]
+    public async Task<OutsourceIssueDetailDto?> GetDetail([FromQuery] long id, [FromQuery] long? tenantId)
+    {
+        if (id <= 0) return null;
+
+        var headPars = new List<SugarParameter> { new("@Id", id) };
+        var headWhere = "m.id = @Id";
+        if (tenantId is > 0)
+        {
+            headWhere += " AND m.tenant_id = @TenantId";
+            headPars.Add(new SugarParameter("@TenantId", tenantId));
+        }
+
+        var head = await _db.Ado.SqlQuerySingleAsync<OutsourceIssueDetailDto>(
+            $"""
+            SELECT
+                m.id            AS Id,
+                m.bill_no       AS Nbr,
+                m.issue_date    AS Date,
+                m.outsource_no  AS Address,
+                m.work_order    AS WorkOrd,
+                TRIM(CONCAT(IFNULL(m.department_code,''), ' ', IFNULL(m.department_name,''))) AS DepartmentDescr,
+                m.issuer        AS User1,
+                m.status_desc   AS StatusDescr,
+                m.remark        AS Remark,
+                m.create_user   AS CreateUser,
+                m.source_create_time AS CreateTime
+            FROM mdp_std_outsource_issue m
+            WHERE {headWhere}
+            LIMIT 1
+            """,
+            headPars);
+
+        if (head == null || head.Id <= 0) return null;
+
+        head.Lines = await _db.Ado.SqlQueryAsync<OutsourceIssueDetailLineDto>(
+            """
+            SELECT
+                d.id            AS Id,
+                d.line          AS Line,
+                d.item_num      AS ItemNum,
+                d.item_name     AS ItemName,
+                d.um            AS Um,
+                d.qty_ord       AS QtyOrd,
+                d.location_from AS LocationFrom,
+                d.location_to   AS LocationTo,
+                d.status        AS Status,
+                d.remark        AS Remark
+            FROM mdp_std_outsource_issue_detail d
+            WHERE d.std_head_id = @HeadId
+            ORDER BY d.line ASC, d.id ASC
+            """,
+            new List<SugarParameter> { new("@HeadId", head.Id) });
+
+        return head;
+    }
+
+    /// <summary>
+    /// 排序白名单:仅允许按已展示列排序,杜绝 SQL 注入。
+    /// </summary>
+    private static string BuildOrderBy(string? sortField, string? sortOrder)
+    {
+        var column = sortField switch
+        {
+            "billNo" => "m.bill_no",
+            "issueDate" => "m.issue_date",
+            "sourceCreateTime" => "m.source_create_time",
+            _ => "m.issue_date",
+        };
+        var direction = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return $"{column} {direction}, m.id DESC";
+    }
+}