浏览代码

feat(purchase-confirm): 工单批量采购确认写入货源清单+BOM自动建单开关+两列表条件筛选区

Pengxy 1 天之前
父节点
当前提交
e334b8f180

+ 1 - 1
Web/package.json

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

+ 12 - 0
Web/src/router/route.ts

@@ -96,6 +96,18 @@ export const dynamicRoutes: Array<RouteRecordRaw> = [
 			isAffix: false,
 		},
 	},
+	// 批量采购确认(隐藏详情页,从工单下达列表跳转)
+	{
+		path: '/aidop/s1/work-order-purchase-confirm',
+		name: 'aidopBusinessWorkOrderPurchaseConfirm',
+		component: () => import('/@/views/aidop/business/workOrderPurchaseConfirm.vue'),
+		meta: {
+			title: '批量采购确认',
+			isHide: true,
+			isKeepAlive: false,
+			isAffix: false,
+		},
+	},
 	{
 		path: '/develop/database/visual',
 		name: 'databaseVisual',

+ 61 - 0
Web/src/views/aidop/api/workOrderDispatch.ts

@@ -111,6 +111,10 @@ export interface BomItemDto {
 	op: number;
 	salesOrderNo?: string;
 	parentItemNum?: string;
+	/** 合同号(自动建单时写入 crm_seorder.bill_from) */
+	contractNo?: string;
+	/** 父物料描述(自动建单时写入 WorkOrdMaster.ItemName) */
+	parentDescr?: string;
 }
 
 export interface BomCheckResult {
@@ -125,6 +129,10 @@ export interface BomUploadResult {
 	total: number;
 	matchedWorkOrders: number;
 	unmatched: string[];
+	/** 自动建单开关打开时新建的销售订单数量 */
+	createdOrders: number;
+	/** 自动建单开关打开时新建的工单数量 */
+	createdWorkOrders: number;
 }
 
 /** BOM 物料存在性校验 */
@@ -147,6 +155,59 @@ export function fetchWorkOrderDispatchList(params: Record<string, unknown>) {
 		.then((r) => r.data);
 }
 
+// ── 批量采购确认 ──
+
+/** 未匹配到供应商的工单物料明细行 */
+export interface UnmatchedPurchaseRow {
+	detailRecId: number;
+	workOrd?: string | null;
+	salesOrderNo?: string | null;
+	contractNo?: string | null;
+	parentItemNum?: string | null;
+	parentDescr?: string | null;
+	factory?: string | null;
+	itemNum?: string | null;
+	descr?: string | null;
+	descr1?: string | null;
+	qtyRequired?: number | null;
+	um?: string | null;
+	projectText?: string | null;
+	/** 物料主数据 Buyer(采购员预填) */
+	purchaser?: string | null;
+}
+
+/** 批量采购确认明细行 */
+export interface PurchaseConfirmItem {
+	itemNum?: string;
+	descr?: string;
+	um?: string;
+	purchaser?: string;
+	supplier?: string;
+	supplierContact?: string;
+	unitPrice?: number | null;
+}
+
+export interface PurchaseConfirmResult {
+	inserted: number;
+	updated: number;
+	skipped: number;
+	total: number;
+}
+
+/** 获取未匹配到供应商的工单物料明细(仅初始 p 状态工单) */
+export function fetchUnmatchedPurchaseList() {
+	return service
+		.get<{ total: number; list: UnmatchedPurchaseRow[] }>('/api/WorkOrder/purchase-confirm/unmatched-list')
+		.then((r) => r.data);
+}
+
+/** 批量采购确认:写入货源清单基础数据 */
+export function confirmBatchPurchase(items: PurchaseConfirmItem[]) {
+	return service
+		.post<PurchaseConfirmResult>('/api/WorkOrder/purchase-confirm/confirm', { items })
+		.then((r) => r.data);
+}
+
 /** 下达成功后的响应体(统一包装或非统一 JSON) */
 export type WorkOrderReleaseResponse = Record<string, unknown>;
 

+ 32 - 42
Web/src/views/aidop/business/workOrderBomUpload.vue

@@ -24,9 +24,17 @@
 			<span v-if="hasSavedData" class="saved-hint">已加载缓存数据({{ parsedRows.length }} 行)</span>
 		</div>
 
+		<!-- 条件筛选区(参照采购在途演示工程) -->
+		<AidopFilterPane
+			v-if="parsedRows.length > 0"
+			:rows="parsedRows"
+			:fields="AIDOP_FILTER_FIELDS"
+			@filter-change="onFilterChange"
+		/>
+
 		<!-- 统计摘要 -->
-		<div v-if="parsedRows.length > 0" class="bom-summary">
-			共 <b>{{ parsedRows.length }}</b> 行,
+		<div v-if="filteredRows.length > 0" class="bom-summary">
+			共 <b>{{ filteredRows.length }}</b> 行,
 			涉及 <b>{{ groupCount }}</b> 个工单(订单号+父物料号组合)
 		</div>
 
@@ -51,7 +59,7 @@
 			>
 				<template #default="{ row, $index }">
 					<template v-if="col.editable">
-						<div @blur.capture="stopEditing">
+						<div>
 							<!-- 下拉选择列:决策采购 / 供应商 / 决策阶段 / 状态 -->
 							<el-select
 								v-if="['purchaser', 'supplier', 'decisionStage', 'status'].includes(col.key)"
@@ -66,7 +74,6 @@
 								size="small"
 								style="width: 100%"
 								@change="onCellChange(row, col.key)"
-								@keyup.esc="stopEditing"
 							>
 								<el-option v-if="col.key === 'purchaser'" v-for="u in purchaserOptions" :key="'p-' + u" :label="u" :value="u" />
 								<el-option v-else-if="col.key === 'supplier'" v-for="s in supplierOptions" :key="s.value" :label="s.label" :value="s.value" />
@@ -86,7 +93,6 @@
 							<!-- 文本输入:供应商联系人 -->
 							<el-input v-else v-model="row[col.key]" size="small" @change="onCellChange(row, col.key)" />
 						</div>
-						<span v-if="!isEditing(row._globalIdx, col.key)" class="editable-cell" @dblclick="startEditing(row._globalIdx, col.key)">{{ formatCell(row[col.key], col.type) }}</span>
 					</template>
 					<span v-else>{{ formatCell(row[col.key], col.type) }}</span>
 				</template>
@@ -94,11 +100,11 @@
 		</el-table>
 
 		<!-- 分页 -->
-		<div v-if="parsedRows.length > PAGE_SIZE" style="margin-top: 8px; display: flex; justify-content: flex-end">
+		<div v-if="filteredRows.length > PAGE_SIZE" style="margin-top: 8px; display: flex; justify-content: flex-end">
 			<el-pagination
 				v-model:current-page="currentPage"
 				:page-size="PAGE_SIZE"
-				:total="parsedRows.length"
+				:total="filteredRows.length"
 				layout="total, prev, pager, next, jumper"
 				background
 				small
@@ -128,6 +134,8 @@ import { useRouter } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { ArrowLeft } from '@element-plus/icons-vue';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
+import AidopFilterPane from '../components/AidopFilterPane.vue';
+import { AIDOP_FILTER_FIELDS } from '../components/aidopFilterFields';
 import { checkBomMaterials, uploadBom, type BomItemDto } from '../api/workOrderDispatch';
 import { getAPI } from '/@/utils/axios-utils';
 import { SysUserApi } from '/@/api-services/api';
@@ -203,39 +211,31 @@ const uploading = ref(false);
 const writing = ref(false);
 const hasSavedData = ref(false);
 const parsedRows = ref<ParsedRow[]>([]);
-const editingCell = ref<{ rowIdx: number; colKey: string } | null>(null);
 const matchingCancelled = ref(false);
 const matchingProgress = ref(0);
 
 // ── 分页 ──
 const PAGE_SIZE = 200;
 const currentPage = ref(1);
-const totalPages = computed(() => Math.max(1, Math.ceil(parsedRows.value.length / PAGE_SIZE)));
+/** 筛选后的行(由条件筛选区驱动;写入工单仍用全量 parsedRows) */
+const filteredRows = ref<ParsedRow[]>([]);
+const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / PAGE_SIZE)));
 const pageRows = computed(() => {
 	const start = (currentPage.value - 1) * PAGE_SIZE;
-	return parsedRows.value.slice(start, start + PAGE_SIZE).map((r, i) => {
-		Object.defineProperty(r, '_globalIdx', { value: start + i, configurable: true, enumerable: false });
-		return r;
-	});
+	return filteredRows.value.slice(start, start + PAGE_SIZE);
 });
 function pageIndex(i: number): number {
 	return (currentPage.value - 1) * PAGE_SIZE + i + 1;
 }
-
-function isEditing(rowIdx: number, colKey: string): boolean {
-	return editingCell.value?.rowIdx === rowIdx && editingCell.value?.colKey === colKey;
-}
-function startEditing(rowIdx: number, colKey: string) {
-	editingCell.value = { rowIdx, colKey };
-}
-function stopEditing() {
-	editingCell.value = null;
+function onFilterChange(list: ParsedRow[]) {
+	filteredRows.value = list;
+	currentPage.value = 1;
 }
 
-/** 按(订单号+父物料号)去重组数 */
+/** 按(订单号+父物料号)去重组数(基于筛选结果) */
 const groupCount = computed(() => {
 	const keys = new Set(
-		parsedRows.value
+		filteredRows.value
 			.filter((r) => r.salesOrderNo && r.parentItemNum)
 			.map((r) => `${r.salesOrderNo}|${r.parentItemNum}`)
 	);
@@ -527,13 +527,12 @@ function onFileChange(e: Event) {
 
 				// 子物料号为空则跳过
 				if (!row.itemNum) continue;
-				// 无BOM关键字段的行跳过(仅有采购负责人但无订单/合同/父物料号的无效行)
-				if (!row.salesOrderNo && !row.contractNo && !row.parentItemNum) continue;
 
 				// 默认状态
 				if (!row.status) row.status = '待决策';
 				// 本地规则预匹配(同步,快速回填已有规则)
 				autoMatchSupplierLocal(row);
+
 				rows.push(row);
 			}
 
@@ -606,9 +605,9 @@ async function onClearData() {
 			type: 'warning',
 		});
 		parsedRows.value = [];
+		filteredRows.value = [];
 		clearStorage();
 		hasSavedData.value = false;
-		editingCell.value = null;
 		ElMessage.success('数据已清空');
 	} catch {
 		/* 用户取消 */
@@ -626,9 +625,11 @@ async function onConfirmWrite() {
 		descr1: r.descr1 ? String(r.descr1) : undefined,
 		um: r.um ? String(r.um) : undefined,
 		qtyRequired: Number(r.qtyRequired) || 0,
-		op: Number(r.projectText) || 0,
+		op: Math.trunc(Number(r.projectText)) || 0,
 		salesOrderNo: r.salesOrderNo ? String(r.salesOrderNo) : undefined,
 		parentItemNum: r.parentItemNum ? String(r.parentItemNum) : undefined,
+		contractNo: r.contractNo ? String(r.contractNo) : undefined,
+		parentDescr: r.parentDescr ? String(r.parentDescr) : undefined,
 	}));
 
 	writing.value = true;
@@ -669,7 +670,9 @@ async function onConfirmWrite() {
 		ElMessage.success(
 			`写入成功:匹配工单 ${result.matchedWorkOrders} 个,` +
 				`新增 ${result.inserted} 行、更新 ${result.updated} 行` +
-				(result.newItems > 0 ? `、新建物料 ${result.newItems} 条` : '')
+				(result.newItems > 0 ? `、新建物料 ${result.newItems} 条` : '') +
+				(result.createdOrders > 0 ? `、自动新建订单 ${result.createdOrders} 个` : '') +
+				(result.createdWorkOrders > 0 ? `、自动新建工单 ${result.createdWorkOrders} 个` : '')
 		);
 		// 写入前:将所有有供应商关系的行保存到货源清单规则
 		for (const row of parsedRows.value) {
@@ -745,17 +748,4 @@ function goBack() {
 	gap: 8px;
 	flex-wrap: wrap;
 }
-
-.editable-cell {
-	cursor: pointer;
-	display: block;
-	min-height: 24px;
-	padding: 2px 4px;
-	border-radius: 3px;
-	transition: background 0.15s;
-
-	&:hover {
-		background: var(--el-color-primary-light-9);
-	}
-}
 </style>

+ 6 - 0
Web/src/views/aidop/business/workOrderDispatchList.vue

@@ -44,6 +44,7 @@
 			<el-button type="warning" :loading="kitChecking" @click="onKitCheck">物料齐套检查</el-button>
 			<el-button type="success" :loading="mrpGenerating" @click="onMrpGenerate">生成物料需求</el-button>
 			<el-button type="primary" @click="openBomUpload">上传BOM</el-button>
+			<el-button type="primary" plain @click="openPurchaseConfirm">批量采购确认</el-button>
 		</div>
 
 		<!-- 列表 -->
@@ -194,6 +195,11 @@ function openBomUpload() {
 	router.push({ path: '/aidop/s1/work-order-bom-upload' });
 }
 
+// ── 批量采购确认 ──
+function openPurchaseConfirm() {
+	router.push({ path: '/aidop/s1/work-order-purchase-confirm' });
+}
+
 /** 外部 resource-examine 的 `domain` 参数:传当前用户租户 ID(如 AidopAdmin 对应 797403760988229) */
 function domainParamFromCurrentTenant(): string | null {
 	const u = userInfo.userInfos;

+ 449 - 0
Web/src/views/aidop/business/workOrderPurchaseConfirm.vue

@@ -0,0 +1,449 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="未匹配到供应商的初始(p)工单物料明细,确认后写入货源清单基础数据">
+		<!-- 顶部信息 + 返回 -->
+		<div class="bom-header">
+			<el-button text @click="goBack">
+				<el-icon><ArrowLeft /></el-icon> 返回工单下达列表
+			</el-button>
+			<el-button text type="primary" :loading="loading" @click="loadList">
+				<el-icon><Refresh /></el-icon> 刷新
+			</el-button>
+		</div>
+
+		<!-- 条件筛选区(参照采购在途演示工程) -->
+		<AidopFilterPane
+			v-if="rows.length > 0"
+			:rows="rows"
+			:fields="AIDOP_FILTER_FIELDS"
+			@filter-change="onFilterChange"
+		/>
+
+		<!-- 统计摘要 -->
+		<div class="bom-summary">
+			共 <b>{{ filteredRows.length }}</b> 行未匹配供应商,涉及 <b>{{ itemKindCount }}</b> 种物料
+		</div>
+
+		<!-- 物料明细表:可编辑列与上传BOM一致 -->
+		<el-table
+			:data="pageRows"
+			border
+			stripe
+			style="width: 100%; margin-top: 8px"
+			max-height="520"
+			size="small"
+			v-loading="loading"
+		>
+			<el-table-column type="index" label="#" width="60" align="center" fixed :index="pageIndex" />
+			<el-table-column
+				v-for="col in TABLE_COLUMNS"
+				:key="col.key"
+				:prop="col.key"
+				:label="col.label"
+				:width="col.width"
+				:min-width="col.minWidth"
+				show-overflow-tooltip
+			>
+				<template #default="{ row }">
+					<template v-if="col.editable">
+						<div>
+							<!-- 下拉选择列:决策采购 / 供应商 / 决策阶段 / 状态 -->
+							<el-select
+								v-if="['purchaser', 'supplier', 'decisionStage', 'status'].includes(col.key)"
+								v-model="row[col.key]"
+								filterable
+								allow-create
+								default-first-option
+								:remote="col.key === 'supplier'"
+								:remote-method="col.key === 'supplier' ? searchSuppliers : undefined"
+								:loading="col.key === 'purchaser' ? purchaserLoading : col.key === 'supplier' ? supplierLoading : false"
+								placeholder=""
+								size="small"
+								style="width: 100%"
+								@change="onCellChange(row, col.key)"
+							>
+								<el-option v-if="col.key === 'purchaser'" v-for="u in purchaserOptions" :key="'p-' + u" :label="u" :value="u" />
+								<el-option v-else-if="col.key === 'supplier'" v-for="s in supplierOptions" :key="'s-' + s.value" :label="s.label" :value="s.value" />
+								<el-option v-else-if="col.key === 'decisionStage'" v-for="ds in DECISION_STAGE_OPTIONS" :key="'ds-' + ds" :label="ds" :value="ds" />
+								<el-option v-else-if="col.key === 'status'" v-for="st in STATUS_OPTIONS" :key="'st-' + st" :label="st" :value="st" />
+							</el-select>
+							<!-- 数值列:含税单价 / 总价 -->
+							<el-input-number
+								v-else-if="['unitPrice', 'totalPrice'].includes(col.key)"
+								v-model="row[col.key]"
+								size="small"
+								controls-position="right"
+								:precision="2"
+								style="width: 100%"
+								@change="onCellChange(row, col.key)"
+							/>
+							<!-- 文本输入:供应商联系人 -->
+							<el-input v-else v-model="row[col.key]" size="small" @change="onCellChange(row, col.key)" />
+						</div>
+					</template>
+					<span v-else>{{ formatCell(row[col.key], col.type) }}</span>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<!-- 分页 -->
+		<div v-if="filteredRows.length > PAGE_SIZE" style="margin-top: 8px; display: flex; justify-content: flex-end">
+			<el-pagination
+				v-model:current-page="currentPage"
+				:page-size="PAGE_SIZE"
+				:total="filteredRows.length"
+				layout="total, prev, pager, next, jumper"
+				background
+				small
+			/>
+		</div>
+
+		<!-- 操作按钮 -->
+		<div class="bom-actions">
+			<el-button type="primary" @click="onConfirm" :loading="confirming">确认</el-button>
+			<el-button @click="goBack">取消</el-button>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopBusinessWorkOrderPurchaseConfirm">
+import { computed, onMounted, ref } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { ArrowLeft, Refresh } from '@element-plus/icons-vue';
+import AidopDemoShell from '../components/AidopDemoShell.vue';
+import AidopFilterPane from '../components/AidopFilterPane.vue';
+import { AIDOP_FILTER_FIELDS } from '../components/aidopFilterFields';
+import {
+	confirmBatchPurchase,
+	fetchUnmatchedPurchaseList,
+	type PurchaseConfirmItem,
+	type UnmatchedPurchaseRow,
+} from '../api/workOrderDispatch';
+import { getAPI } from '/@/utils/axios-utils';
+import { SysUserApi } from '/@/api-services/api';
+import { s0SuppliersApi } from '../s0/api/s0SupplyApi';
+
+const router = useRouter();
+const pageTitle = '批量采购确认';
+
+// ── 列定义:与上传BOM页 38 列完全一致 ──
+interface ColDef {
+	key: string;
+	label: string;
+	width?: number;
+	minWidth?: number;
+	type: 'id' | 'text' | 'enum' | 'number' | 'date';
+	editable?: boolean;
+}
+const TABLE_COLUMNS: ColDef[] = [
+	{ key: 'salesOrderNo', label: '销售订单号', width: 130, type: 'id' },
+	{ key: 'contractNo', label: '合同号', width: 130, type: 'id' },
+	{ key: 'contractName', label: '合同名称', minWidth: 150, type: 'text' },
+	{ key: 'productDept', label: '产品部', width: 110, type: 'enum' },
+	{ key: 'parentItemNum', label: '父物料号', width: 120, type: 'id' },
+	{ key: 'parentDescr', label: '父物料描述', minWidth: 140, type: 'text' },
+	{ key: 'factory', label: '工厂', width: 70, type: 'enum' },
+	{ key: 'productionPerson', label: '投产人', width: 80, type: 'enum' },
+	{ key: 'materialBillNo', label: '物料单号', width: 100, type: 'id' },
+	{ key: 'itemNum', label: '子物料号', width: 140, type: 'id' },
+	{ key: 'descr', label: '子物料描述', minWidth: 150, type: 'text' },
+	{ key: 'descr1', label: '子件长描述', minWidth: 120, type: 'text' },
+	{ key: 'qtyRequired', label: '需求数量', width: 90, type: 'number' },
+	{ key: 'um', label: '单位', width: 60, type: 'enum' },
+	{ key: 'projectText', label: '项目文本', width: 100, type: 'text' },
+	{ key: 'electricalDesigner', label: '电气设计', width: 90, type: 'enum' },
+	{ key: 'structuralDesigner', label: '结构设计', width: 90, type: 'enum' },
+	{ key: 'pushDate', label: '推送日期', width: 100, type: 'date' },
+	{ key: 'purchaser', label: '决策采购', width: 100, type: 'enum', editable: true },
+	{ key: 'supplier', label: '供应商', minWidth: 160, type: 'enum', editable: true },
+	{ key: 'supplierContact', label: '供应商联系人', width: 120, type: 'enum', editable: true },
+	{ key: 'decisionStage', label: '决策阶段', width: 110, type: 'enum', editable: true },
+	{ key: 'unitPrice', label: '含税单价', width: 90, type: 'number', editable: true },
+	{ key: 'totalPrice', label: '总价', width: 90, type: 'number', editable: true },
+	{ key: 'sapInfoNo', label: 'SAP信息记录编号', width: 150, type: 'id' },
+	{ key: 'decisionDoneDate', label: '决策完成日期', width: 110, type: 'date' },
+	{ key: 'execPurchaser', label: '执行采购', width: 90, type: 'enum' },
+	{ key: 'purchaseReqNo', label: '采购申请号', width: 110, type: 'number' },
+	{ key: 'purchaseOrderNo', label: '采购订单号', width: 110, type: 'number' },
+	{ key: 'orderQty', label: '订单数量', width: 90, type: 'number' },
+	{ key: 'orderDate', label: '订单日期', width: 100, type: 'date' },
+	{ key: 'deliveryDate', label: '交货日期', width: 100, type: 'date' },
+	{ key: 'requiredDeliveryDate', label: '要求到货日期', width: 110, type: 'date' },
+	{ key: 'lastReceivedQty', label: '最近到货数量', width: 110, type: 'number' },
+	{ key: 'lastReceivedDate', label: '最近到货日期', width: 110, type: 'date' },
+	{ key: 'status', label: '状态', width: 100, type: 'enum', editable: true },
+	{ key: 'cumulativeReceivedQty', label: '累计入库数量', width: 110, type: 'number' },
+	{ key: 'issue', label: '异常问题', minWidth: 120, type: 'text' },
+];
+
+const DECISION_STAGE_OPTIONS = ref(['待决策', '已发资料', '集采再询价', '竞价或扩额', '替代采购', '已维护价格']);
+const STATUS_OPTIONS = ref(['待决策', '已下订单', '已通知送货', '已部分到货', '已完成', '已删除', '被替代']);
+
+// ── 数据状态 ──
+interface RowItem {
+	[key: string]: any;
+}
+const loading = ref(false);
+const confirming = ref(false);
+const rows = ref<RowItem[]>([]);
+/** 筛选后的行(由条件筛选区驱动;确认写入仍用全量 rows) */
+const filteredRows = ref<RowItem[]>([]);
+
+const itemKindCount = computed(() => new Set(filteredRows.value.map((r) => String(r.itemNum ?? '')).filter(Boolean)).size);
+
+// ── 分页 ──
+const PAGE_SIZE = 200;
+const currentPage = ref(1);
+const pageRows = computed(() => {
+	const start = (currentPage.value - 1) * PAGE_SIZE;
+	return filteredRows.value.slice(start, start + PAGE_SIZE);
+});
+function pageIndex(i: number): number {
+	return (currentPage.value - 1) * PAGE_SIZE + i + 1;
+}
+function onFilterChange(list: RowItem[]) {
+	filteredRows.value = list;
+	currentPage.value = 1;
+}
+
+/** 格式化单元格显示 */
+function formatCell(val: any, type: string): string {
+	if (val == null || val === '') return '—';
+	return String(val);
+}
+
+// ── 货源清单规则(物料→决策采购→供应商 映射,localStorage 持久化,与上传BOM页共享)──
+const RULES_KEY = 'aidop_supply_source_rules_v1';
+interface SupplySourceRule {
+	itemNum: string;
+	purchaser: string;
+	supplier: string;
+	supplierContact?: string;
+	updatedAt: string;
+}
+const supplySourceRules = ref<SupplySourceRule[]>([]);
+
+function loadSupplySourceRules() {
+	try {
+		const raw = localStorage.getItem(RULES_KEY);
+		if (raw) supplySourceRules.value = JSON.parse(raw);
+	} catch { /* ignore */ }
+}
+function saveSupplySourceRule(itemNum: string, purchaser: string, supplier: string, supplierContact?: string) {
+	if (!itemNum || !supplier) return;
+	const rules = supplySourceRules.value;
+	const existIdx = rules.findIndex((r) => r.itemNum === itemNum && r.purchaser === purchaser);
+	const rule: SupplySourceRule = { itemNum, purchaser, supplier, supplierContact, updatedAt: new Date().toISOString() };
+	if (existIdx >= 0) rules[existIdx] = rule;
+	else rules.push(rule);
+	supplySourceRules.value = rules;
+	try { localStorage.setItem(RULES_KEY, JSON.stringify(rules)); } catch { /* ignore */ }
+}
+/** 自动匹配供应商(本地货源清单规则,同步) */
+function autoMatchSupplierLocal(row: RowItem) {
+	if (!row.itemNum) return;
+	const rules = supplySourceRules.value;
+	const exact = row.purchaser ? rules.find((r) => r.itemNum === row.itemNum && r.purchaser === row.purchaser) : null;
+	if (exact) {
+		if (!row.supplier) row.supplier = exact.supplier;
+		if (!row.supplierContact && exact.supplierContact) row.supplierContact = exact.supplierContact;
+		return;
+	}
+	const fallback = rules.find((r) => r.itemNum === row.itemNum);
+	if (fallback && !row.supplier) {
+		row.supplier = fallback.supplier;
+		if (!row.supplierContact && fallback.supplierContact) row.supplierContact = fallback.supplierContact;
+	}
+}
+
+// ── 可编辑列变更处理 ──
+function onCellChange(row: RowItem, key: string) {
+	if (key === 'purchaser') {
+		const val = String(row.purchaser ?? '').trim();
+		if (val && !purchaserOptions.value.includes(val)) {
+			purchaserOptions.value.push(val);
+		}
+		autoMatchSupplierLocal(row);
+	}
+	if (key === 'decisionStage') {
+		const val = String(row.decisionStage ?? '').trim();
+		if (val && !DECISION_STAGE_OPTIONS.value.includes(val)) {
+			DECISION_STAGE_OPTIONS.value.push(val);
+		}
+	}
+	if (key === 'status') {
+		const val = String(row.status ?? '').trim();
+		if (val && !STATUS_OPTIONS.value.includes(val)) {
+			STATUS_OPTIONS.value.push(val);
+		}
+	}
+	if (key === 'supplier') {
+		const val = String(row.supplier ?? '').trim();
+		if (val && !supplierOptions.value.some((s) => s.value === val)) {
+			supplierOptions.value.unshift({ value: val, label: val });
+		}
+	}
+	if (key === 'supplier' && row.supplier && row.itemNum) {
+		saveSupplySourceRule(
+			String(row.itemNum),
+			String(row.purchaser ?? ''),
+			String(row.supplier),
+			row.supplierContact ? String(row.supplierContact) : undefined
+		);
+	}
+	if (key === 'unitPrice' && row.unitPrice != null && row.qtyRequired != null) {
+		row.totalPrice = Number(row.unitPrice) * Number(row.qtyRequired);
+	}
+}
+
+// ── 采购人员选项 ──
+const purchaserOptions = ref<string[]>([]);
+const purchaserLoading = ref(false);
+async function loadPurchaserOptions() {
+	purchaserLoading.value = true;
+	try {
+		const res = await getAPI(SysUserApi).apiSysUserPagePost({ page: 1, pageSize: 500 } as any);
+		purchaserOptions.value = (res.data.result?.items ?? [])
+			.map((u: any) => u.realName)
+			.filter(Boolean);
+	} catch { /* 静默失败 */ }
+	finally { purchaserLoading.value = false; }
+}
+
+// ── 供应商搜索选项(供应商主数据 SuppMaster)──
+const supplierOptions = ref<{ value: string; label: string }[]>([]);
+const supplierLoading = ref(false);
+async function searchSuppliers(keyword: string) {
+	supplierLoading.value = true;
+	try {
+		const res = await s0SuppliersApi.list({ keyword: keyword || undefined, page: 1, pageSize: 20 });
+		supplierOptions.value = (res?.list ?? [])
+			.filter((s) => s.sortName)
+			.map((s) => ({ value: s.sortName!, label: `${s.sortName}${s.supp ? ' | ' + s.supp : ''}` }));
+	} catch {
+		supplierOptions.value = [];
+	} finally { supplierLoading.value = false; }
+}
+
+// ── 列表加载 ──
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchUnmatchedPurchaseList();
+		rows.value = (data.list ?? []).map((r: UnmatchedPurchaseRow) => {
+			const row: RowItem = {
+				workOrd: r.workOrd ?? null,
+				salesOrderNo: r.salesOrderNo ?? null,
+				contractNo: r.contractNo ?? null,
+				parentItemNum: r.parentItemNum ?? null,
+				parentDescr: r.parentDescr ?? null,
+				factory: r.factory ?? null,
+				itemNum: r.itemNum ?? null,
+				descr: r.descr ?? null,
+				descr1: r.descr1 ?? null,
+				qtyRequired: r.qtyRequired ?? null,
+				um: r.um ?? null,
+				projectText: r.projectText ?? null,
+				purchaser: r.purchaser ?? '',
+				supplier: null,
+				supplierContact: null,
+				decisionStage: '待决策',
+				unitPrice: null,
+				totalPrice: null,
+				status: '待决策',
+			};
+			autoMatchSupplierLocal(row);
+			return row;
+		});
+		currentPage.value = 1;
+	} catch (e: any) {
+		ElMessage.error(e?.message || '加载未匹配列表失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+// ── 确认写入货源清单 ──
+async function onConfirm() {
+	if (!rows.value.length) return;
+
+	const items: PurchaseConfirmItem[] = rows.value
+		.filter((r) => r.supplier && r.itemNum)
+		.map((r) => ({
+			itemNum: String(r.itemNum),
+			descr: r.descr ? String(r.descr) : undefined,
+			um: r.um ? String(r.um) : undefined,
+			purchaser: r.purchaser ? String(r.purchaser) : undefined,
+			supplier: String(r.supplier),
+			supplierContact: r.supplierContact ? String(r.supplierContact) : undefined,
+			unitPrice: r.unitPrice != null ? Number(r.unitPrice) : null,
+		}));
+
+	if (items.length === 0) {
+		ElMessage.warning('请先为明细填写供应商');
+		return;
+	}
+
+	const emptyCount = rows.value.length - items.length;
+	if (emptyCount > 0) {
+		try {
+			await ElMessageBox.confirm(
+				`有 ${emptyCount} 行未填写供应商,将跳过不写入。是否继续?`,
+				'批量采购确认',
+				{ confirmButtonText: '继续', cancelButtonText: '取消', type: 'warning' }
+			);
+		} catch {
+			return;
+		}
+	}
+
+	confirming.value = true;
+	try {
+		const result = await confirmBatchPurchase(items);
+		const msg =
+			`已写入货源清单:新增 ${result.inserted} 条、更新 ${result.updated} 条` +
+			(result.skipped > 0 ? `、跳过 ${result.skipped} 行` : '');
+		ElMessage.success(msg);
+		await loadList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '确认写入失败');
+	} finally {
+		confirming.value = false;
+	}
+}
+
+function goBack() {
+	router.push({ path: '/aidop/s1/workorder-mgmt/dispatch' });
+}
+
+onMounted(() => {
+	loadSupplySourceRules();
+	loadPurchaserOptions();
+	loadList();
+});
+</script>
+
+<style scoped lang="scss">
+@import '../styles/aidop-demo.scss';
+
+.bom-header {
+	display: flex;
+	align-items: center;
+	gap: 16px;
+	margin-bottom: 16px;
+}
+
+.bom-summary {
+	font-size: 13px;
+	color: var(--el-text-color-regular);
+	margin-bottom: 4px;
+	b { color: var(--el-color-primary); }
+}
+
+.bom-actions {
+	margin-top: 16px;
+	display: flex;
+	gap: 8px;
+	flex-wrap: wrap;
+}
+</style>

+ 213 - 0
Web/src/views/aidop/components/AidopFilterPane.vue

@@ -0,0 +1,213 @@
+<template>
+	<div class="filter-pane">
+		<div class="filter-header">
+			<span class="title">条件筛选区</span>
+			<span class="hint">(下拉可选择或手输;留空 = 不过滤)</span>
+			<el-button link type="primary" size="small" @click="clearAll">清空筛选</el-button>
+		</div>
+		<div class="filter-grid">
+			<div v-for="f in fields" :key="f.key" class="filter-card">
+				<span class="label">{{ f.label }}</span>
+				<!-- id / enum / text:可手输下拉(含空值模式) -->
+				<el-select
+					v-if="f.type === 'enum' || f.type === 'id' || f.type === 'text'"
+					v-model="filters[f.key]"
+					clearable
+					filterable
+					allow-create
+					default-first-option
+					placeholder=""
+					size="small"
+					class="filter-ctl"
+				>
+					<el-option value="__IS_EMPTY__" label="(空值)" />
+					<el-option v-for="o in optionsFor(f)" :key="'opt-' + f.key + '-' + String(o)" :label="String(o)" :value="o" />
+				</el-select>
+				<!-- number:≥ 最小 / ≤ 最大 区间 -->
+				<div v-else-if="f.type === 'number'" class="range-row">
+					<span class="sym">≥</span>
+					<el-input-number v-model="filters[f.key + '__min']" size="small" :controls="false" placeholder="最小" class="range-ctl" />
+					<span class="sym">≤</span>
+					<el-input-number v-model="filters[f.key + '__max']" size="small" :controls="false" placeholder="最大" class="range-ctl" />
+				</div>
+				<!-- date:从 / 至 日期区间 -->
+				<div v-else-if="f.type === 'date'" class="range-row">
+					<span class="sym">从</span>
+					<el-date-picker
+						v-model="filters[f.key + '__start']"
+						type="date"
+						value-format="YYYY-MM-DD"
+						placeholder="起始"
+						size="small"
+						class="range-ctl"
+					/>
+					<span class="sym">至</span>
+					<el-date-picker
+						v-model="filters[f.key + '__end']"
+						type="date"
+						value-format="YYYY-MM-DD"
+						placeholder="结束"
+						size="small"
+						class="range-ctl"
+					/>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopFilterPane">
+import { computed, onMounted, reactive, watch } from 'vue';
+import type { AidopFilterFieldDef } from './aidopFilterFields';
+
+const props = defineProps<{
+	rows: any[];
+	fields: AidopFilterFieldDef[];
+}>();
+
+const emit = defineEmits<{
+	(e: 'filter-change', list: any[]): void;
+}>();
+
+/** 空值匹配模式专用哨兵值(与演示工程「键入空格 = 空值」语义一致) */
+const EMPTY_VALUE = '__IS_EMPTY__';
+
+const filters = reactive<Record<string, any>>({});
+
+/** enum/id 列选项:数据内去重值(text 列仅手输,无选项) */
+function optionsFor(f: AidopFilterFieldDef): (string | number)[] {
+	if (f.type === 'text') return [];
+	const set = new Set<string>();
+	for (const row of props.rows) {
+		const v = row?.[f.key];
+		if (v == null || v === '') continue;
+		set.add(String(v));
+	}
+	return Array.from(set).sort();
+}
+
+/** yyyy-MM-dd → yyyy.MM.dd(与数据存储格式一致,便于字典序比较) */
+function normDate(s: any): string {
+	return s == null ? '' : String(s).replace(/-/g, '.');
+}
+
+function matches(row: any): boolean {
+	for (const f of props.fields) {
+		if (f.type === 'enum' || f.type === 'id' || f.type === 'text') {
+			const fv = filters[f.key];
+			if (fv == null || fv === '') continue;
+			const val = row?.[f.key];
+			if (fv === EMPTY_VALUE) {
+				// 空值匹配:仅当该字段为空(null / '' / 纯空白)时保留
+				if (!(val == null || val === '' || (typeof val === 'string' && val.trim() === ''))) return false;
+			} else if (String(val ?? '') !== String(fv)) {
+				return false;
+			}
+		} else if (f.type === 'number') {
+			const min = filters[f.key + '__min'];
+			const max = filters[f.key + '__max'];
+			const raw = row?.[f.key];
+			const n = typeof raw === 'number' ? raw : raw == null || raw === '' ? null : Number(raw);
+			if (min != null && min !== '' && (n == null || Number.isNaN(n) || n < Number(min))) return false;
+			if (max != null && max !== '' && (n == null || Number.isNaN(n) || n > Number(max))) return false;
+		} else if (f.type === 'date') {
+			const start = normDate(filters[f.key + '__start']);
+			const end = normDate(filters[f.key + '__end']);
+			const sv = normDate(row?.[f.key]);
+			if (start && sv && sv < start) return false;
+			if (end && sv && sv > end) return false;
+		}
+	}
+	return true;
+}
+
+const filtered = computed(() => props.rows.filter((r) => matches(r)));
+
+watch(filtered, (list) => emit('filter-change', list));
+
+// v-if 场景下组件从无到有挂载时(如首次加载数据),主动上报初始筛选结果
+onMounted(() => emit('filter-change', filtered.value));
+
+function clearAll() {
+	for (const f of props.fields) {
+		if (f.type === 'enum' || f.type === 'id' || f.type === 'text') {
+			filters[f.key] = '';
+		} else if (f.type === 'number') {
+			filters[f.key + '__min'] = null;
+			filters[f.key + '__max'] = null;
+		} else if (f.type === 'date') {
+			filters[f.key + '__start'] = '';
+			filters[f.key + '__end'] = '';
+		}
+	}
+}
+</script>
+
+<style scoped lang="scss">
+.filter-pane {
+	border: 1px solid var(--el-border-color-lighter);
+	border-radius: 4px;
+	padding: 8px 12px;
+	margin: 8px 0;
+	background: var(--el-bg-color);
+}
+
+.filter-header {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 6px;
+
+	.title {
+		font-size: 13px;
+		font-weight: 600;
+	}
+	.hint {
+		font-size: 11px;
+		color: var(--el-text-color-secondary);
+		flex: 1;
+	}
+}
+
+.filter-grid {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+	gap: 6px 10px;
+}
+
+.filter-card {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+
+	.label {
+		flex: 0 0 auto;
+		font-size: 12px;
+		color: var(--el-text-color-secondary);
+		white-space: nowrap;
+	}
+}
+
+.filter-ctl {
+	flex: 1;
+	min-width: 0;
+}
+
+.range-row {
+	display: flex;
+	align-items: center;
+	gap: 4px;
+	flex: 1;
+	min-width: 0;
+
+	.sym {
+		font-size: 12px;
+		color: var(--el-text-color-secondary);
+		flex: 0 0 auto;
+	}
+	.range-ctl {
+		flex: 1;
+		min-width: 0;
+	}
+}
+</style>

+ 41 - 0
Web/src/views/aidop/components/aidopFilterFields.ts

@@ -0,0 +1,41 @@
+/**
+ * 工单 BOM 上传 / 批量采购确认 两页共用的「条件筛选区」字段定义。
+ * 与采购在途演示工程(procurement-intransit-demo/index.html)一致:
+ * 默认隐藏 单位/子物料描述/子件长描述/项目文本/SAP信息记录编号/供应商联系人/
+ * 采购申请号/异常问题/父物料描述/合同名称/子物料号 共 11 个字段,其余 27 个默认展示。
+ */
+export interface AidopFilterFieldDef {
+	key: string;
+	label: string;
+	type: 'id' | 'text' | 'enum' | 'number' | 'date';
+}
+
+export const AIDOP_FILTER_FIELDS: AidopFilterFieldDef[] = [
+	{ key: 'salesOrderNo', label: '销售订单号', type: 'id' },
+	{ key: 'contractNo', label: '合同号', type: 'id' },
+	{ key: 'productDept', label: '产品部', type: 'enum' },
+	{ key: 'parentItemNum', label: '父物料号', type: 'id' },
+	{ key: 'factory', label: '工厂', type: 'enum' },
+	{ key: 'productionPerson', label: '投产人', type: 'enum' },
+	{ key: 'materialBillNo', label: '物料单号', type: 'id' },
+	{ key: 'qtyRequired', label: '需求数量', type: 'number' },
+	{ key: 'electricalDesigner', label: '电气设计', type: 'enum' },
+	{ key: 'structuralDesigner', label: '结构设计', type: 'enum' },
+	{ key: 'pushDate', label: '推送日期', type: 'date' },
+	{ key: 'purchaser', label: '决策采购', type: 'enum' },
+	{ key: 'supplier', label: '供应商', type: 'enum' },
+	{ key: 'decisionStage', label: '决策阶段', type: 'enum' },
+	{ key: 'unitPrice', label: '含税单价', type: 'number' },
+	{ key: 'totalPrice', label: '总价', type: 'number' },
+	{ key: 'decisionDoneDate', label: '决策完成日期', type: 'date' },
+	{ key: 'execPurchaser', label: '执行采购', type: 'enum' },
+	{ key: 'purchaseOrderNo', label: '采购订单号', type: 'number' },
+	{ key: 'orderQty', label: '订单数量', type: 'number' },
+	{ key: 'orderDate', label: '订单日期', type: 'date' },
+	{ key: 'deliveryDate', label: '交货日期', type: 'date' },
+	{ key: 'requiredDeliveryDate', label: '要求到货日期', type: 'date' },
+	{ key: 'lastReceivedQty', label: '最近到货数量', type: 'number' },
+	{ key: 'lastReceivedDate', label: '最近到货日期', type: 'date' },
+	{ key: 'status', label: '状态', type: 'enum' },
+	{ key: 'cumulativeReceivedQty', label: '累计入库数量', type: 'number' },
+];

+ 6 - 0
server/Admin.NET.Application/Configuration/App.json

@@ -91,6 +91,12 @@
     "Outbound": {
       "Enabled": false // 开发库总开关:false=只入队不写 165。正式环境删除本段或置 true(缺省即为 true)
     },
+    "BomUpload": {
+      // BOM 上传未匹配自动建单开关:true=匹配不到订单/工单时提取上传信息自动创建
+      // 销售订单(crm_seorder)与工单(WorkOrdMaster+mes_morder)后继续写入;
+      // false=只返回未匹配提示不写入(生产阶段置 false 或删除本段,缺省即为 false)。
+      "AutoCreateWhenUnmatched": true
+    },
     "Jobs": {
       // ETL 执行机开关:false=本实例不跑任何 AiDOP 定时作业与轮询后台(页面手工重算不受影响)。
       // 开发库这里必须是 false —— 每台开发机启动后端都会变成一台完整的 ETL 引擎,

+ 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.598</AssemblyVersion>
-    <FileVersion>1.0.598</FileVersion>
-    <Version>1.0.598</Version>
+    <AssemblyVersion>1.0.599</AssemblyVersion>
+    <FileVersion>1.0.599</FileVersion>
+    <Version>1.0.599</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 19 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Option/AidopBomUploadOptions.cs

@@ -0,0 +1,19 @@
+using Furion.ConfigurableOptions;
+
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// BOM 上传行为开关。配置节 <c>AiDOP:BomUpload</c>。
+/// 缺省 <see cref="AutoCreateWhenUnmatched"/> = false(生产阶段保持缺省即可):
+/// 匹配不到订单/工单时只提示不写入。
+/// 测试阶段置 true:匹配不到时提取上传信息自动创建销售订单与工单后继续写入。
+/// </summary>
+[OptionsSettings("AiDOP:BomUpload")]
+public sealed class AidopBomUploadOptions : IConfigurableOptions
+{
+    /// <summary>
+    /// true=匹配不到订单/工单时自动创建(crm_seorder + WorkOrdMaster + mes_morder)并继续写入;
+    /// false=只返回未匹配提示,不写入任何数据。
+    /// </summary>
+    public bool AutoCreateWhenUnmatched { get; set; } = false;
+}

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

@@ -44,6 +44,7 @@ public class Startup : AppStartup
         services.AddConfigurableOptions<AidopInventoryOptions>();
         services.AddConfigurableOptions<AidopPoCompletionOptions>();
         services.AddConfigurableOptions<AidopOutboundOptions>();
+        services.AddConfigurableOptions<AidopBomUploadOptions>();
         services.AddTransient<IInventoryBalanceReader, InventoryBalanceReader>();
         services.AddTransient<IInventoryTransactionReader, StdInventoryTransactionReader>();
         // WP9:IPickBillCreator 唯一实现为 Facade(避免 Local/Mes 多实现竞注)

+ 12 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/Dto/WorkOrderBomDtos.cs

@@ -26,6 +26,12 @@ public class BomItemDto
 
     /// <summary>父物料号(用于匹配工单:对应 WorkOrdMaster.ItemNum)。</summary>
     public string? ParentItemNum { get; set; }
+
+    /// <summary>合同号(自动建单时写入 crm_seorder.bill_from)。</summary>
+    public string? ContractNo { get; set; }
+
+    /// <summary>父物料描述(自动建单时写入 WorkOrdMaster.ItemName)。</summary>
+    public string? ParentDescr { get; set; }
 }
 
 /// <summary>物料存在性校验请求。</summary>
@@ -73,4 +79,10 @@ public class BomUploadResult
 
     /// <summary>未能匹配到未下达工单的行(格式:订单号+父物料号)。</summary>
     public List<string> Unmatched { get; set; } = new();
+
+    /// <summary>自动建单开关打开时新建的销售订单数量(crm_seorder)。</summary>
+    public int CreatedOrders { get; set; }
+
+    /// <summary>自动建单开关打开时新建的工单数量(WorkOrdMaster)。</summary>
+    public int CreatedWorkOrders { get; set; }
 }

+ 211 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderBomUploadService.cs

@@ -1,5 +1,8 @@
+using System.Globalization;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Yitter.IdGenerator;
 
 namespace Admin.NET.Plugin.AiDOP.WorkOrder;
 
@@ -8,6 +11,10 @@ namespace Admin.NET.Plugin.AiDOP.WorkOrder;
 /// 批量写入 ItemMaster + WorkOrdDetail。
 /// 路由前缀:/api/WorkOrder/dispatch/bom/...
 ///
+/// 【自动建单开关】当 <see cref="AidopBomUploadOptions.AutoCreateWhenUnmatched"/> = true(测试阶段)时,
+/// 匹配不到订单/工单的组会自动创建销售订单(crm_seorder)与工单(WorkOrdMaster+mes_morder)后继续写入;
+/// false(生产阶段,缺省)时只返回未匹配提示,不写入任何数据。
+///
 /// 【租户安全边界】需认证访问;租户一律经
 /// <c>AidopTenantScope.ResolveOrThrow</c> 从认证后 JWT 解析。
 /// </summary>
@@ -19,15 +26,18 @@ public class WorkOrderBomUploadService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
     private readonly ILogger<WorkOrderBomUploadService> _logger;
+    private readonly IOptionsMonitor<AidopBomUploadOptions> _bomUploadOpt;
 
     public WorkOrderBomUploadService(
         ISqlSugarClient db,
         UserManager userManager,
-        ILogger<WorkOrderBomUploadService> logger)
+        ILogger<WorkOrderBomUploadService> logger,
+        IOptionsMonitor<AidopBomUploadOptions> bomUploadOpt)
     {
         _db = db;
         _userManager = userManager;
         _logger = logger;
+        _bomUploadOpt = bomUploadOpt;
     }
 
     private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
@@ -139,7 +149,10 @@ public class WorkOrderBomUploadService : IDynamicApiController, ITransient
             unmatched.Add($"缺少订单号或父物料号:{noKeyItems.Count} 行");
 
         // 3) 预匹配:在写入之前先将所有分组匹配到工单,有未匹配则整体中止
+        //    开关打开时,未匹配组转入 pendingCreate(事务内自动创建订单+工单后继续写入)
+        var autoCreateWhenUnmatched = _bomUploadOpt.CurrentValue.AutoCreateWhenUnmatched;
         var matchedGroups = new List<(IGrouping<(string SalesOrderNo, string ParentItemNum), BomItemDto> Grp, WoInfo Wo)>();
+        var pendingCreate = new List<(IGrouping<(string SalesOrderNo, string ParentItemNum), BomItemDto> Grp, string ContractNo, string ParentDescr)>();
         foreach (var grp in groups)
         {
             var woRow = await _db.Ado.SqlQuerySingleAsync<WoInfo>(
@@ -157,12 +170,26 @@ public class WorkOrderBomUploadService : IDynamicApiController, ITransient
                 new SugarParameter("@ParentItemNum", grp.Key.ParentItemNum));
 
             if (woRow == null)
-                unmatched.Add($"订单[{grp.Key.SalesOrderNo}] + 物料[{grp.Key.ParentItemNum}]");
+            {
+                if (autoCreateWhenUnmatched)
+                {
+                    // 提取组内首个非空的合同号/父物料描述,用于自动建单
+                    var contractNo = grp.Select(x => x.ContractNo).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? string.Empty;
+                    var parentDescr = grp.Select(x => x.ParentDescr).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? string.Empty;
+                    pendingCreate.Add((grp, contractNo, parentDescr));
+                }
+                else
+                {
+                    unmatched.Add($"订单[{grp.Key.SalesOrderNo}] + 物料[{grp.Key.ParentItemNum}]");
+                }
+            }
             else
+            {
                 matchedGroups.Add((grp, woRow));
+            }
         }
 
-        // 有未匹配组 → 整体中止,不写入任何数据
+        // 有未匹配组(且开关关闭无法自动建单)→ 整体中止,不写入任何数据
         if (unmatched.Count > 0)
         {
             return new BomUploadResult
@@ -175,6 +202,18 @@ public class WorkOrderBomUploadService : IDynamicApiController, ITransient
         await _db.Ado.BeginTranAsync();
         try
         {
+            // 3.5) 自动建单:开关打开时,为未匹配组创建销售订单+工单,纳入写入队列
+            var createdOrders = 0;
+            var createdWorkOrders = 0;
+            foreach (var pc in pendingCreate)
+            {
+                var (woRow, orderCreated) = await EnsureOrderAndWorkOrderAsync(
+                    tid, pc.Grp.Key.SalesOrderNo, pc.Grp.Key.ParentItemNum,
+                    pc.ContractNo, pc.ParentDescr, factoryRefId, account);
+                if (orderCreated) createdOrders++;
+                createdWorkOrders++;
+                matchedGroups.Add((pc.Grp, woRow));
+            }
             // 4) 创建缺失物料到 ItemMaster
             if (input.CreateMissingItems)
             {
@@ -313,8 +352,8 @@ public class WorkOrderBomUploadService : IDynamicApiController, ITransient
             await _db.Ado.CommitTranAsync();
 
             _logger.LogInformation(
-                "[BomUpload] tenantId={TenantId} matched={Matched} inserted={Inserted} updated={Updated} newItems={NewItems} unmatched={Unmatched}",
-                tid, matchedWorkOrders, inserted, updated, newItemsCount, unmatched.Count);
+                "[BomUpload] tenantId={TenantId} matched={Matched} inserted={Inserted} updated={Updated} newItems={NewItems} createdOrders={CreatedOrders} createdWorkOrders={CreatedWorkOrders} unmatched={Unmatched}",
+                tid, matchedWorkOrders, inserted, updated, newItemsCount, createdOrders, createdWorkOrders, unmatched.Count);
 
             return new BomUploadResult
             {
@@ -323,7 +362,9 @@ public class WorkOrderBomUploadService : IDynamicApiController, ITransient
                 NewItems = newItemsCount,
                 Total = input.Items.Count,
                 MatchedWorkOrders = matchedWorkOrders,
-                Unmatched = unmatched
+                Unmatched = unmatched,
+                CreatedOrders = createdOrders,
+                CreatedWorkOrders = createdWorkOrders
             };
         }
         catch
@@ -346,6 +387,170 @@ public class WorkOrderBomUploadService : IDynamicApiController, ITransient
         return val <= 0 ? 1 : val;
     }
 
+    /// <summary>
+    /// 自动建单(测试阶段开关):销售订单不存在则创建 crm_seorder,
+    /// 随后创建未下达工单(WorkOrdMaster Status='p' + mes_morder),返回新工单信息。
+    /// </summary>
+    private async Task<(WoInfo Wo, bool OrderCreated)> EnsureOrderAndWorkOrderAsync(
+        long tenantId,
+        string salesOrderNo,
+        string parentItemNum,
+        string contractNo,
+        string parentDescr,
+        long factoryRefId,
+        string account)
+    {
+        var now = DateTime.Now;
+
+        // 1) 销售订单存在性检查(已存在只建工单,不重建订单)
+        var orderId = await _db.Ado.GetLongAsync(
+            """
+            SELECT Id FROM crm_seorder
+            WHERE tenant_id = @TenantId
+              AND TRIM(IFNULL(bill_no,'')) = @BillNo
+              AND IFNULL(IsDeleted,0) = 0
+            LIMIT 1
+            """,
+            new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@BillNo", salesOrderNo)
+            });
+
+        var orderCreated = false;
+        if (orderId <= 0)
+        {
+            orderId = YitIdHelper.NextId();
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO crm_seorder (
+                    Id, bill_no, order_type, urgent, bill_from, flowstate,
+                    create_by_name, create_time, update_by_name, update_time,
+                    tenant_id, factory_id, IsDeleted, closed
+                ) VALUES (
+                    @Id, @BillNo, 1, 0, @BillFrom, 'BOM导入',
+                    @User, @Now, @User, @Now,
+                    @TenantId, @FactoryId, 0, 0
+                )
+                """,
+                new SugarParameter("@Id", orderId),
+                new SugarParameter("@BillNo", salesOrderNo),
+                new SugarParameter("@BillFrom", string.IsNullOrWhiteSpace(contractNo) ? (object)DBNull.Value : contractNo.Trim()),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@FactoryId", factoryRefId));
+            orderCreated = true;
+        }
+
+        // 2) 生成工单号并创建未下达工单(Status='p',与 BOM 匹配口径一致)
+        var workOrd = await GenerateWorkOrdNoAsync(tenantId);
+        var domain = await ResolveDomainAsync(tenantId);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO WorkOrdMaster (
+                `Domain`, WorkOrd, ItemNum, ItemName, Status, Typed,
+                QtyOrded, QtyCompleted, LbrVar, OrdDate, Priority, Urgent,
+                SalesJob, BusinessID, CreateUser, CreateTime, UpdateUser, UpdateTime,
+                IsActive, IsConfirm, tenant_id, Remark
+            ) VALUES (
+                @Domain, @WorkOrd, @ItemNum, @ItemName, 'p', 'M',
+                1, 0, 0, @OrdDate, 0, 0,
+                @SalesJob, @BusinessId, @User, @Now, @User, @Now,
+                1, 0, @TenantId, @Remark
+            )
+            """,
+            new SugarParameter("@Domain", domain),
+            new SugarParameter("@WorkOrd", workOrd),
+            new SugarParameter("@ItemNum", parentItemNum),
+            new SugarParameter("@ItemName", string.IsNullOrWhiteSpace(parentDescr) ? string.Empty : parentDescr.Trim()),
+            new SugarParameter("@OrdDate", now.Date),
+            new SugarParameter("@SalesJob", salesOrderNo),
+            new SugarParameter("@BusinessId", orderId),
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@Remark", "BOM上传自动建单"));
+
+        var recId = await _db.Ado.GetLongAsync(
+            "SELECT RecID FROM WorkOrdMaster WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1",
+            new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@WorkOrd", workOrd)
+            });
+
+        // 3) 同步创建 mes_morder(齐套检查/下达流程依赖)
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mes_morder (
+                Id, morder_no, morder_type, morder_date, morder_state,
+                product_code, product_name, need_number, morder_production_number,
+                MaterialSituation, urgent, create_by_name, create_time, update_by_name, update_time,
+                tenant_id, factory_id, company_id, IsDeleted
+            ) VALUES (
+                @Id, @MorderNo, '生产工单', @Now, '新建',
+                @ProductCode, @ProductName, 1, 1,
+                '待检查', 0, @User, @Now, @User, @Now,
+                @TenantId, @FactoryId, @FactoryId, 0
+            )
+            """,
+            new SugarParameter("@Id", YitIdHelper.NextId()),
+            new SugarParameter("@MorderNo", workOrd),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@ProductCode", parentItemNum),
+            new SugarParameter("@ProductName", string.IsNullOrWhiteSpace(parentDescr) ? string.Empty : parentDescr.Trim()),
+            new SugarParameter("@User", account),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@FactoryId", factoryRefId));
+
+        return (new WoInfo { RecId = recId, WorkOrd = workOrd, Domain = domain, Status = "p" }, orderCreated);
+    }
+
+    /// <summary>生成 M5 流水工单号(与 OrderWorkOrderGenerationService 同规则)。</summary>
+    private async Task<string> GenerateWorkOrdNoAsync(long tenantId)
+    {
+        const string sql = """
+            SELECT IFNULL(MAX(CAST(SUBSTRING(TRIM(WorkOrd), 3) AS UNSIGNED)), 0)
+            FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId
+              AND TRIM(WorkOrd) REGEXP '^M5[0-9]+$'
+            """;
+        var maxSeq = await _db.Ado.GetIntAsync(sql, new SugarParameter("@TenantId", tenantId));
+        for (var attempt = 0; attempt < 5; attempt++)
+        {
+            var next = maxSeq + 1 + attempt;
+            var candidate = "M5" + next.ToString("D8", CultureInfo.InvariantCulture);
+            var dup = await _db.Ado.GetIntAsync(
+                "SELECT COUNT(*) FROM WorkOrdMaster WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd",
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@WorkOrd", candidate));
+            if (dup == 0)
+                return candidate;
+        }
+
+        throw Oops.Oh("生成工单号失败:流水号冲突,请重试");
+    }
+
+    /// <summary>解析 Domain:取本租户现有工单中出现次数最多的 Domain,缺省 8010。</summary>
+    private async Task<string> ResolveDomainAsync(long tenantId)
+    {
+        var fallback = await _db.Ado.GetStringAsync(
+            """
+            SELECT `Domain` FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId
+              AND `Domain` IS NOT NULL AND TRIM(`Domain`) <> ''
+            GROUP BY `Domain`
+            ORDER BY COUNT(*) DESC
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId));
+        if (!string.IsNullOrWhiteSpace(fallback))
+            return fallback.Trim();
+
+        return "8010";
+    }
+
     private sealed class WoInfo
     {
         public long RecId { get; set; }

+ 59 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderPurchaseConfirmDtos.cs

@@ -0,0 +1,59 @@
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>
+/// 批量采购确认 - 未匹配到供应商的工单物料明细行(仅初始 p 状态工单)。
+/// </summary>
+public class UnmatchedPurchaseRow
+{
+    public long DetailRecId { get; set; }
+    public string? WorkOrd { get; set; }
+    public string? SalesOrderNo { get; set; }
+    public string? ContractNo { get; set; }
+    public string? ParentItemNum { get; set; }
+    public string? ParentDescr { get; set; }
+    public string? Factory { get; set; }
+    public string? ItemNum { get; set; }
+    public string? Descr { get; set; }
+    public string? Descr1 { get; set; }
+    public decimal? QtyRequired { get; set; }
+    public string? Um { get; set; }
+    public string? ProjectText { get; set; }
+    /// <summary>物料主数据 Buyer(采购员预填)。</summary>
+    public string? Purchaser { get; set; }
+}
+
+/// <summary>批量采购确认请求体。</summary>
+public class PurchaseConfirmInput
+{
+    public List<PurchaseConfirmItem> Items { get; set; } = new();
+}
+
+/// <summary>批量采购确认明细行。</summary>
+public class PurchaseConfirmItem
+{
+    public string? ItemNum { get; set; }
+    public string? Descr { get; set; }
+    public string? Um { get; set; }
+    /// <summary>决策采购(写入 order_rector_name / purcher)。</summary>
+    public string? Purchaser { get; set; }
+    /// <summary>供应商名称(写入 supplier_name,编码/ID 从 SuppMaster 回填)。</summary>
+    public string? Supplier { get; set; }
+    public string? SupplierContact { get; set; }
+    public decimal? UnitPrice { get; set; }
+}
+
+/// <summary>批量采购确认结果。</summary>
+public class PurchaseConfirmResult
+{
+    /// <summary>新增货源清单记录数。</summary>
+    public int Inserted { get; set; }
+
+    /// <summary>更新已有货源清单记录数。</summary>
+    public int Updated { get; set; }
+
+    /// <summary>因供应商为空而跳过的行数。</summary>
+    public int Skipped { get; set; }
+
+    /// <summary>提交总行数。</summary>
+    public int Total { get; set; }
+}

+ 288 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderPurchaseConfirmService.cs

@@ -0,0 +1,288 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>
+/// 批量采购确认服务 — 列出初始(p)状态工单中未匹配到供应商的物料明细,
+/// 确认后把(物料 → 采购员 → 供应商)关系写入货源清单基础数据 srm_purchase。
+/// 路由前缀:/api/WorkOrder/purchase-confirm/...
+///
+/// 【未匹配口径】物料号在 srm_purchase 中不存在「未删除且有供应商编码」的记录。
+/// 【写入约定】供应商编码/ID 从 SuppMaster 按名称回填(查不到编码为空);
+/// 物料 ID/工厂/公司从 ItemMaster 回填;同一(物料号+供应商)已存在则更新,否则新增。
+///
+/// 【租户安全边界】需认证访问;租户一律经
+/// <c>AidopTenantScope.ResolveOrThrow</c> 从认证后 JWT 解析。
+/// </summary>
+[ApiDescriptionSettings(Order = 259, Description = "批量采购确认")]
+[Route("api/WorkOrder")]
+[NonUnify]
+public class WorkOrderPurchaseConfirmService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+    private readonly ILogger<WorkOrderPurchaseConfirmService> _logger;
+
+    public WorkOrderPurchaseConfirmService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        ILogger<WorkOrderPurchaseConfirmService> logger)
+    {
+        _db = db;
+        _userManager = userManager;
+        _logger = logger;
+    }
+
+    private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
+
+    // ══════════════════════════════════════════════════════════════
+    // GET /api/WorkOrder/purchase-confirm/unmatched-list
+    // ══════════════════════════════════════════════════════════════
+    /// <summary>
+    /// 获取未匹配到供应商的工单物料明细(仅初始 p 状态工单;
+    /// 物料号在 srm_purchase 中无「未删除且有供应商编码」的有效货源记录)。
+    /// </summary>
+    [DisplayName("批量采购确认未匹配列表")]
+    [HttpGet("purchase-confirm/unmatched-list")]
+    public async Task<object> GetUnmatchedList()
+    {
+        var tid = ResolveTenantId();
+
+        var list = await _db.Ado.SqlQueryAsync<UnmatchedPurchaseRow>(
+            """
+            SELECT
+                wd.RecID            AS DetailRecId,
+                wm.WorkOrd,
+                wm.SalesJob         AS SalesOrderNo,
+                co.bill_from        AS ContractNo,
+                wm.ItemNum          AS ParentItemNum,
+                wm.ItemName         AS ParentDescr,
+                wm.Domain           AS Factory,
+                wd.ItemNum,
+                im.Descr,
+                im.Descr1,
+                wd.QtyRequired,
+                wd.UM               AS Um,
+                CAST(wd.Op AS CHAR(20)) AS ProjectText,
+                IFNULL(TRIM(im.Buyer), '') AS Purchaser
+            FROM WorkOrdDetail wd
+            JOIN WorkOrdMaster wm
+                ON wm.WorkOrd = wd.WorkOrd AND wm.tenant_id = wd.tenant_id
+            LEFT JOIN ItemMaster im
+                ON im.ItemNum = wd.ItemNum AND (im.tenant_id = wd.tenant_id OR im.tenant_id IS NULL)
+            LEFT JOIN crm_seorder co
+                ON co.Id = wm.BusinessID AND co.tenant_id = wm.tenant_id
+            LEFT JOIN srm_purchase sp
+                ON sp.number = wd.ItemNum AND sp.tenant_id = wd.tenant_id
+                AND IFNULL(sp.IsDeleted, 0) = 0
+                AND IFNULL(TRIM(sp.supplier_number), '') <> ''
+            WHERE wd.tenant_id = @TenantId
+              AND IFNULL(wd.IsActive, 0) = 1
+              AND IFNULL(TRIM(wd.ItemNum), '') <> ''
+              AND LOWER(TRIM(IFNULL(wm.Status, ''))) = 'p'
+              AND sp.Id IS NULL
+            ORDER BY wm.WorkOrd, wd.LineNum
+            """,
+            new SugarParameter("@TenantId", tid));
+
+        return new { total = list.Count, list };
+    }
+
+    // ══════════════════════════════════════════════════════════════
+    // POST /api/WorkOrder/purchase-confirm/confirm
+    // ══════════════════════════════════════════════════════════════
+    /// <summary>
+    /// 批量确认:把(物料 → 采购员 → 供应商)关系写入货源清单 srm_purchase。
+    /// 供应商为空的行跳过;按(物料号+供应商)去重合并。
+    /// </summary>
+    [DisplayName("批量采购确认写入货源清单")]
+    [ApiDescriptionSettings(Name = "PurchaseConfirm"), HttpPost("purchase-confirm/confirm")]
+    public async Task<PurchaseConfirmResult> Confirm([FromBody] PurchaseConfirmInput input)
+    {
+        var tid = ResolveTenantId();
+        if (input.Items == null || input.Items.Count == 0)
+            throw Oops.Oh("确认列表为空");
+
+        var account = _userManager.Account ?? "system";
+        var now = DateTime.Now;
+
+        // 1) 供应商为空的行跳过;其余按(物料号+供应商)去重合并
+        var groups = input.Items
+            .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum) && !string.IsNullOrWhiteSpace(x.Supplier))
+            .GroupBy(x => (ItemNum: x.ItemNum!.Trim(), Supplier: x.Supplier!.Trim()))
+            .ToList();
+        var skipped = input.Items.Count - input.Items.Count(x =>
+            !string.IsNullOrWhiteSpace(x.ItemNum) && !string.IsNullOrWhiteSpace(x.Supplier));
+
+        var inserted = 0;
+        var updated = 0;
+
+        await _db.Ado.BeginTranAsync();
+        try
+        {
+            foreach (var g in groups)
+            {
+                var itemNum = g.Key.ItemNum;
+                var supplierName = g.Key.Supplier;
+                var first = g.First();
+                var purchaser = first.Purchaser?.Trim();
+                var unitPrice = g.Select(x => x.UnitPrice).FirstOrDefault(x => x.HasValue);
+
+                // 物料主数据回填(ID/工厂/公司/名称/单位)
+                var item = await _db.Ado.SqlQuerySingleAsync<ItemRefRow>(
+                    """
+                    SELECT RecID AS Id, company_ref_id AS CompanyRefId, factory_ref_id AS FactoryRefId,
+                           Descr, UM
+                    FROM ItemMaster
+                    WHERE tenant_id = @TenantId AND TRIM(ItemNum) = @ItemNum
+                    LIMIT 1
+                    """,
+                    new SugarParameter("@TenantId", tid),
+                    new SugarParameter("@ItemNum", itemNum));
+                var icitemId = item?.Id ?? 0;
+                var companyRefId = item?.CompanyRefId ?? 0;
+                var factoryRefId = item?.FactoryRefId ?? 0;
+                var descr = first.Descr?.Trim() ?? item?.Descr ?? string.Empty;
+                var um = first.Um?.Trim() ?? item?.UM ?? string.Empty;
+
+                // 供应商主数据回填(ID/编码),按简称或编码匹配
+                var supp = await _db.Ado.SqlQuerySingleAsync<SuppRefRow>(
+                    """
+                    SELECT RecID AS Id, Supp
+                    FROM SuppMaster
+                    WHERE tenant_id = @TenantId
+                      AND (TRIM(IFNULL(SortName, '')) = @SupplierName OR TRIM(IFNULL(Supp, '')) = @SupplierName)
+                    ORDER BY IFNULL(IsActive, 0) DESC
+                    LIMIT 1
+                    """,
+                    new SugarParameter("@TenantId", tid),
+                    new SugarParameter("@SupplierName", supplierName));
+                var supplierId = supp?.Id ?? 0;
+                var supplierNumber = supp?.Supp ?? string.Empty;
+
+                // 已有货源记录?(同物料+同供应商)→ 更新,否则新增
+                var existing = await _db.Ado.SqlQuerySingleAsync<ExistingSourceRow>(
+                    """
+                    SELECT Id, supplier_number AS SupplierNumber
+                    FROM srm_purchase
+                    WHERE tenant_id = @TenantId
+                      AND TRIM(IFNULL(number, '')) = @ItemNum
+                      AND TRIM(IFNULL(supplier_name, '')) = @SupplierName
+                      AND IFNULL(IsDeleted, 0) = 0
+                    LIMIT 1
+                    """,
+                    new SugarParameter("@TenantId", tid),
+                    new SugarParameter("@ItemNum", itemNum),
+                    new SugarParameter("@SupplierName", supplierName));
+
+                if (existing != null)
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE srm_purchase
+                        SET order_rector_name = @Purchaser,
+                            purcher           = @Purchaser,
+                            order_price       = @UnitPrice,
+                            supplier_number   = CASE
+                                WHEN IFNULL(TRIM(supplier_number), '') = '' AND TRIM(@SupplierNumber) <> '' THEN @SupplierNumber
+                                ELSE supplier_number
+                            END,
+                            supplier_id       = CASE
+                                WHEN IFNULL(supplier_id, 0) = 0 AND @SupplierId > 0 THEN @SupplierId
+                                ELSE supplier_id
+                            END,
+                            update_user       = @User,
+                            update_time       = @Now
+                        WHERE Id = @Id AND tenant_id = @TenantId
+                        """,
+                        new SugarParameter("@Purchaser", string.IsNullOrEmpty(purchaser) ? (object)DBNull.Value : purchaser),
+                        new SugarParameter("@UnitPrice", unitPrice ?? (object)DBNull.Value),
+                        new SugarParameter("@SupplierNumber", supplierNumber),
+                        new SugarParameter("@SupplierId", supplierId),
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", now),
+                        new SugarParameter("@Id", existing.Id),
+                        new SugarParameter("@TenantId", tid));
+                    updated++;
+                }
+                else
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        INSERT INTO srm_purchase (
+                            company_ref_id, factory_ref_id, icitem_id, icitem_name,
+                            is_active, supplier_id, supplier_name, supplier_number,
+                            order_price, quota_rate, order_rector_name, purcher,
+                            purchase_unit, IsRequireGoods, quota_priority,
+                            create_user, create_time, update_user, update_time,
+                            number, IsDeleted, tenant_id
+                        ) VALUES (
+                            @CompanyRefId, @FactoryRefId, @IcitemId, @IcitemName,
+                            '是', @SupplierId, @SupplierName, @SupplierNumber,
+                            @UnitPrice, 100, @Purchaser, @Purchaser,
+                            @PurchaseUnit, 0, 0,
+                            @User, @Now, @User, @Now,
+                            @ItemNum, 0, @TenantId
+                        )
+                        """,
+                        new SugarParameter("@CompanyRefId", companyRefId),
+                        new SugarParameter("@FactoryRefId", factoryRefId),
+                        new SugarParameter("@IcitemId", icitemId),
+                        new SugarParameter("@IcitemName", string.IsNullOrEmpty(descr) ? (object)DBNull.Value : descr),
+                        new SugarParameter("@SupplierId", supplierId),
+                        new SugarParameter("@SupplierName", supplierName),
+                        new SugarParameter("@SupplierNumber", string.IsNullOrEmpty(supplierNumber) ? string.Empty : supplierNumber),
+                        new SugarParameter("@UnitPrice", unitPrice ?? (object)DBNull.Value),
+                        new SugarParameter("@Purchaser", string.IsNullOrEmpty(purchaser) ? (object)DBNull.Value : purchaser),
+                        new SugarParameter("@PurchaseUnit", string.IsNullOrEmpty(um) ? (object)DBNull.Value : um),
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", now),
+                        new SugarParameter("@ItemNum", itemNum),
+                        new SugarParameter("@TenantId", tid));
+                    inserted++;
+                }
+            }
+
+            await _db.Ado.CommitTranAsync();
+
+            _logger.LogInformation(
+                "[PurchaseConfirm] tenantId={TenantId} total={Total} inserted={Inserted} updated={Updated} skipped={Skipped}",
+                tid, input.Items.Count, inserted, updated, skipped);
+
+            return new PurchaseConfirmResult
+            {
+                Inserted = inserted,
+                Updated = updated,
+                Skipped = skipped,
+                Total = input.Items.Count
+            };
+        }
+        catch
+        {
+            await _db.Ado.RollbackTranAsync();
+            throw;
+        }
+    }
+
+    private sealed class ItemRefRow
+    {
+        public long Id { get; set; }
+        public long CompanyRefId { get; set; }
+        public long FactoryRefId { get; set; }
+        public string? Descr { get; set; }
+        public string? UM { get; set; }
+    }
+
+    private sealed class SuppRefRow
+    {
+        public long Id { get; set; }
+        public string? Supp { get; set; }
+    }
+
+    private sealed class ExistingSourceRow
+    {
+        public long Id { get; set; }
+        public string? SupplierNumber { get; set; }
+    }
+}