Pengxy 3 дней назад
Родитель
Сommit
eec9334f13

+ 1 - 1
Web/package.json

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

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

@@ -84,6 +84,18 @@ export const dynamicRoutes: Array<RouteRecordRaw> = [
 			isAffix: false,
 		},
 	},
+	// 工单BOM批量上传(隐藏详情页,从工单下达列表跳转)
+	{
+		path: '/aidop/s1/work-order-bom-upload',
+		name: 'aidopBusinessWorkOrderBomUpload',
+		component: () => import('/@/views/aidop/business/workOrderBomUpload.vue'),
+		meta: {
+			title: '工单BOM上传',
+			isHide: true,
+			isKeepAlive: false,
+			isAffix: false,
+		},
+	},
 	{
 		path: '/develop/database/visual',
 		name: 'databaseVisual',

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

@@ -100,6 +100,47 @@ export function fetchNextLotSerial(payload: { workOrd: string }) {
 		});
 }
 
+// ── BOM 上传 ──
+
+export interface BomItemDto {
+	itemNum: string;
+	descr: string;
+	descr1?: string;
+	um?: string;
+	qtyRequired: number;
+	op: number;
+	salesOrderNo?: string;
+	parentItemNum?: string;
+}
+
+export interface BomCheckResult {
+	existing: string[];
+	missing: BomItemDto[];
+}
+
+export interface BomUploadResult {
+	inserted: number;
+	updated: number;
+	newItems: number;
+	total: number;
+	matchedWorkOrders: number;
+	unmatched: string[];
+}
+
+/** BOM 物料存在性校验 */
+export function checkBomMaterials(items: BomItemDto[]) {
+	return service
+		.post<BomCheckResult>('/api/WorkOrder/dispatch/bom/check-materials', { items })
+		.then((r) => r.data);
+}
+
+/** BOM 写入工单物料明细(按订单号+父物料号自动匹配未下达工单) */
+export function uploadBom(createMissingItems: boolean, items: BomItemDto[]) {
+	return service
+		.post<BomUploadResult>('/api/WorkOrder/dispatch/bom/upload', { createMissingItems, items })
+		.then((r) => r.data);
+}
+
 export function fetchWorkOrderDispatchList(params: Record<string, unknown>) {
 	return service
 		.get<Paged<WorkOrderDispatchRow>>('/api/WorkOrder/dispatch/list', { params: withAidopTenantParams(params) })

+ 314 - 0
Web/src/views/aidop/business/workOrderBomUpload.vue

@@ -0,0 +1,314 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="上传 Excel BOM 并按订单号+物料编号匹配未下达工单写入">
+		<!-- 顶部信息 + 返回 -->
+		<div class="bom-header">
+			<el-button text @click="goBack">
+				<el-icon><ArrowLeft /></el-icon> 返回工单下达列表
+			</el-button>
+		</div>
+
+		<!-- 上传区 -->
+		<div class="bom-upload-area">
+			<el-button type="primary" @click="triggerFileInput" :disabled="uploading">
+				上传 BOM 文件
+			</el-button>
+			<input
+				ref="fileInputRef"
+				type="file"
+				accept=".xlsx,.xls"
+				style="display: none"
+				@change="onFileChange"
+			/>
+			<span v-if="fileName" class="file-name">{{ fileName }}</span>
+			<span v-if="parseError" class="parse-error">{{ parseError }}</span>
+		</div>
+
+		<!-- 统计摘要 -->
+		<div v-if="parsedRows.length > 0" class="bom-summary">
+			共 <b>{{ parsedRows.length }}</b> 行,
+			涉及 <b>{{ groupCount }}</b> 个工单(订单号+父物料号组合)
+		</div>
+
+		<!-- 物料预览表 -->
+		<el-table
+			v-if="parsedRows.length > 0"
+			:data="parsedRows"
+			border
+			stripe
+			style="width: 100%; margin-top: 8px"
+			max-height="480"
+		>
+			<el-table-column type="index" label="#" width="50" align="center" />
+			<el-table-column prop="salesOrderNo" label="销售订单号" width="130" show-overflow-tooltip />
+			<el-table-column prop="parentItemNum" label="父物料号" width="120" show-overflow-tooltip />
+			<el-table-column prop="itemNum" label="子物料号" width="140" show-overflow-tooltip />
+			<el-table-column prop="descr" label="子物料描述" min-width="150" show-overflow-tooltip />
+			<el-table-column prop="descr1" label="规格型号" min-width="120" show-overflow-tooltip />
+			<el-table-column prop="qtyRequired" label="需求数量" width="90" align="right" />
+			<el-table-column prop="um" label="单位" width="70" align="center" />
+			<el-table-column prop="op" label="工序" width="70" align="center" />
+			<el-table-column prop="contractNo" label="合同号" width="120" show-overflow-tooltip />
+		</el-table>
+
+		<!-- 操作按钮 -->
+		<div v-if="parsedRows.length > 0" class="bom-actions">
+			<el-button type="primary" :loading="writing" @click="onConfirmWrite">确认写入</el-button>
+			<el-button @click="goBack">取消</el-button>
+		</div>
+
+		<el-empty v-if="!parsedRows.length && !uploading" description="请先上传 BOM Excel 文件(支持含多工单 BOM 的文件)" />
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopBusinessWorkOrderBomUpload">
+import { computed, ref } from 'vue';
+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 { checkBomMaterials, uploadBom, type BomItemDto } from '../api/workOrderDispatch';
+// xlsx-js-style 是 SheetJS 的样式增强 fork,API 兼容
+import XLSX from 'xlsx-js-style';
+
+const router = useRouter();
+
+const pageTitle = '工单 BOM 批量上传';
+
+// ── 文件解析 ──
+const fileInputRef = ref<HTMLInputElement | null>(null);
+const fileName = ref('');
+const parseError = ref('');
+const uploading = ref(false);
+const writing = ref(false);
+
+/** 解析后的行(前端预览 + 传后端) */
+interface ParsedRow extends BomItemDto {
+	contractNo?: string;
+}
+const parsedRows = ref<ParsedRow[]>([]);
+
+/** 按(订单号+父物料号)去重组数 */
+const groupCount = computed(() => {
+	const keys = new Set(
+		parsedRows.value
+			.filter((r) => r.salesOrderNo && r.parentItemNum)
+			.map((r) => `${r.salesOrderNo}|${r.parentItemNum}`)
+	);
+	return keys.size;
+});
+
+/** MES BOM 18 列名称 → Excel 列索引 */
+const MES_COLUMN_ORDER = [
+	'销售订单号', '合同号', '合同名称', '产品部',
+	'父物料号', '父物料描述', '工厂', '投产人', '物料单号',
+	'子物料号', '子物料描述', '子件长描述', '需求数量', '单位', '项目文本',
+	'电气设计', '结构设计', '推送日期'
+];
+const HEADER_ROW = 1; // 第 2 行(index=1)为表头,第 1 行是标题
+
+function triggerFileInput() {
+	fileInputRef.value?.click();
+}
+
+function onFileChange(e: Event) {
+	const input = e.target as HTMLInputElement;
+	const file = input.files?.[0];
+	if (!file) return;
+	fileName.value = file.name;
+	parseError.value = '';
+	uploading.value = true;
+
+	const reader = new FileReader();
+	reader.onload = (ev) => {
+		try {
+			const data = new Uint8Array(ev.target!.result as ArrayBuffer);
+			const wb = XLSX.read(data, { type: 'array', cellDates: true });
+			const ws = wb.Sheets[wb.SheetNames[0]];
+			const aoa: any[][] = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
+
+			if (!aoa || aoa.length < HEADER_ROW + 1) {
+				parseError.value = '文件内容为空或行数不足';
+				return;
+			}
+
+			// 解析表头
+			const headerRow = aoa[HEADER_ROW] || [];
+			const idx: Record<string, number> = {};
+			for (const name of MES_COLUMN_ORDER) {
+				idx[name] = -1;
+				for (let i = 0; i < headerRow.length; i++) {
+					if (headerRow[i] != null && String(headerRow[i]).trim() === name) {
+						idx[name] = i;
+						break;
+					}
+				}
+			}
+
+			const missing = MES_COLUMN_ORDER.filter((n) => idx[n] < 0);
+			if (missing.length > 3) {
+				parseError.value = `Excel 缺少字段:${missing.slice(0, 5).join('、')} 等 ${missing.length} 个`;
+				return;
+			}
+
+			// 解析数据行
+			const rows: ParsedRow[] = [];
+			for (let r = HEADER_ROW + 1; r < aoa.length; r++) {
+				const arr = aoa[r] || [];
+				const itemNum = cellStr(arr[idx['子物料号']]);
+				if (!itemNum) continue; // 跳过空行
+
+				const salesOrderNo = cellStr(arr[idx['销售订单号']]);
+				const parentItemNum = cellStr(arr[idx['父物料号']]);
+
+				const qtyRaw = arr[idx['需求数量']];
+				const qty = typeof qtyRaw === 'number' ? qtyRaw : parseFloat(String(qtyRaw ?? ''));
+
+				const opRaw = cellStr(arr[idx['项目文本']]);
+				const opParsed = parseInt(opRaw, 10);
+
+				rows.push({
+					itemNum,
+					descr: cellStr(arr[idx['子物料描述']]) || '',
+					descr1: cellStr(arr[idx['子件长描述']]) || undefined,
+					um: cellStr(arr[idx['单位']]) || undefined,
+					qtyRequired: isNaN(qty) ? 0 : qty,
+					op: isNaN(opParsed) ? 0 : opParsed,
+					salesOrderNo: salesOrderNo || undefined,
+					parentItemNum: parentItemNum || undefined,
+					// 预览列
+					contractNo: cellStr(arr[idx['合同号']]) || undefined,
+				});
+			}
+
+			if (rows.length === 0) {
+				parseError.value = '未发现有效数据行(子物料号为空则跳过)';
+				return;
+			}
+			parsedRows.value = rows;
+		} catch (err: any) {
+			parseError.value = `解析失败:${err?.message || err}`;
+		} finally {
+			uploading.value = false;
+		}
+	};
+	reader.onerror = () => {
+		parseError.value = '读取文件失败';
+		uploading.value = false;
+	};
+	reader.readAsArrayBuffer(file);
+	input.value = ''; // 允许重复上传同文件
+}
+
+function cellStr(v: any): string {
+	if (v == null) return '';
+	return String(v).trim();
+}
+
+// ── 确认写入 ──
+async function onConfirmWrite() {
+	if (!parsedRows.value.length) return;
+	const items: BomItemDto[] = parsedRows.value.map((r) => ({
+		itemNum: r.itemNum,
+		descr: r.descr,
+		descr1: r.descr1,
+		um: r.um,
+		qtyRequired: r.qtyRequired,
+		op: r.op,
+		salesOrderNo: r.salesOrderNo,
+		parentItemNum: r.parentItemNum,
+	}));
+
+	writing.value = true;
+	try {
+		// 1) 校验物料存在性
+		const check = await checkBomMaterials(items);
+
+		let createMissing = false;
+		if (check.missing.length > 0) {
+			const missingList = check.missing.map((m) => `${m.itemNum}(${m.descr || '—'})`).join('\n');
+			try {
+				await ElMessageBox.confirm(
+					`以下 ${check.missing.length} 个物料在系统中不存在:\n${missingList}\n\n是否记录基础数据并写入?`,
+					'表格中有物料未记录',
+					{ confirmButtonText: '确认记录并写入', cancelButtonText: '取消', type: 'warning' }
+				);
+				createMissing = true;
+			} catch {
+				// 用户点取消
+				ElMessage.info('已取消,未写入任何数据');
+				return;
+			}
+		}
+
+		// 2) 写入
+		const result = await uploadBom(createMissing, items);
+
+		// 未匹配到对应销售订单 → 展示错误提示,不跳转
+		if (result.unmatched.length > 0) {
+			const lines = result.unmatched.slice(0, 10).map((u) => `· ${u}`);
+			if (result.unmatched.length > 10) lines.push(`…还有 ${result.unmatched.length - 10} 组`);
+			ElMessage({
+				type: 'error',
+				message: `未匹配到对应销售订单,未写入任何数据\n${lines.join('\n')}`,
+				duration: 8000,
+			});
+			return;
+		}
+
+		ElMessage.success(
+			`写入成功:匹配工单 ${result.matchedWorkOrders} 个,` +
+				`新增 ${result.inserted} 行、更新 ${result.updated} 行` +
+				(result.newItems > 0 ? `、新建物料 ${result.newItems} 条` : '')
+		);
+		goBack();
+	} catch (err: any) {
+		ElMessage.error(err?.message || '写入失败');
+	} finally {
+		writing.value = false;
+	}
+}
+
+function goBack() {
+	router.push({ path: '/aidop/s1/workorder-mgmt/dispatch' });
+}
+</script>
+
+<style scoped lang="scss">
+@import '../styles/aidop-demo.scss';
+
+.bom-header {
+	display: flex;
+	align-items: center;
+	gap: 16px;
+	margin-bottom: 16px;
+}
+
+.bom-upload-area {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	margin-bottom: 8px;
+
+	.file-name {
+		font-size: 13px;
+		color: var(--el-text-color-secondary);
+	}
+	.parse-error {
+		font-size: 13px;
+		color: var(--el-color-danger);
+	}
+}
+
+.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;
+}
+</style>

+ 8 - 1
Web/src/views/aidop/business/workOrderDispatchList.vue

@@ -43,6 +43,7 @@
 		<div class="toolbar">
 			<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>
 		</div>
 
 		<!-- 列表 -->
@@ -118,7 +119,7 @@
 
 <script setup lang="ts" name="aidopBusinessWorkOrderDispatchList">
 import { computed, onMounted, reactive, ref } from 'vue';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
 import { ElMessage } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
 import WorkOrderDispatchForm from './workOrderDispatchForm.vue';
@@ -134,6 +135,7 @@ import {
 const userInfo = useUserInfo();
 
 const route = useRoute();
+const router = useRouter();
 const pageTitle = computed(() => (route.meta?.title as string) || '工单池下达');
 
 /** 默认仅显示初始(p)状态工单 */
@@ -187,6 +189,11 @@ function onSelectionChange(sel: WorkOrderDispatchRow[]) {
 	selectedRows.value = sel;
 }
 
+// ── 上传BOM ──
+function openBomUpload() {
+	router.push({ path: '/aidop/s1/work-order-bom-upload' });
+}
+
 /** 外部 resource-examine 的 `domain` 参数:传当前用户租户 ID(如 AidopAdmin 对应 797403760988229) */
 function domainParamFromCurrentTenant(): string | null {
 	const u = userInfo.userInfos;

+ 4 - 4
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -10,10 +10,10 @@
     <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
-    <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.590</AssemblyVersion>
-    <FileVersion>1.0.590</FileVersion>
-    <Version>1.0.590</Version>
+    <Description>Admin.NET 通锟斤拷权锟睫匡拷锟斤拷平台</Description>
+    <AssemblyVersion>1.0.592</AssemblyVersion>
+    <FileVersion>1.0.592</FileVersion>
+    <Version>1.0.592</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

@@ -0,0 +1,76 @@
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>BOM 单行物料(前端 Excel 解析后传入)。</summary>
+public class BomItemDto
+{
+    /// <summary>子物料号(必填)。</summary>
+    public string ItemNum { get; set; } = string.Empty;
+
+    /// <summary>子物料描述。</summary>
+    public string Descr { get; set; } = string.Empty;
+
+    /// <summary>规格型号(子件长描述)。</summary>
+    public string? Descr1 { get; set; }
+
+    /// <summary>单位。</summary>
+    public string? UM { get; set; }
+
+    /// <summary>需求数量。</summary>
+    public decimal QtyRequired { get; set; }
+
+    /// <summary>工序号(项目文本解析;非数字则为 0)。</summary>
+    public int Op { get; set; }
+
+    /// <summary>销售订单号(用于匹配工单:对应 WorkOrdMaster.SalesJob)。</summary>
+    public string? SalesOrderNo { get; set; }
+
+    /// <summary>父物料号(用于匹配工单:对应 WorkOrdMaster.ItemNum)。</summary>
+    public string? ParentItemNum { get; set; }
+}
+
+/// <summary>物料存在性校验请求。</summary>
+public class BomCheckMaterialsInput
+{
+    public List<BomItemDto> Items { get; set; } = new();
+}
+
+/// <summary>物料存在性校验结果。</summary>
+public class BomCheckResult
+{
+    /// <summary>已在 ItemMaster 中存在的物料编码。</summary>
+    public List<string> Existing { get; set; } = new();
+
+    /// <summary>不存在、需要新建的物料。</summary>
+    public List<BomItemDto> Missing { get; set; } = new();
+}
+
+/// <summary>BOM 写入请求。</summary>
+public class BomUploadInput
+{
+    /// <summary>是否同时创建 ItemMaster 中不存在的物料。</summary>
+    public bool CreateMissingItems { get; set; }
+
+    public List<BomItemDto> Items { get; set; } = new();
+}
+
+/// <summary>BOM 写入结果。</summary>
+public class BomUploadResult
+{
+    /// <summary>WorkOrdDetail 新增行数。</summary>
+    public int Inserted { get; set; }
+
+    /// <summary>WorkOrdDetail 更新行数。</summary>
+    public int Updated { get; set; }
+
+    /// <summary>ItemMaster 新建行数。</summary>
+    public int NewItems { get; set; }
+
+    /// <summary>本次处理的 BOM 总行数。</summary>
+    public int Total { get; set; }
+
+    /// <summary>成功匹配写入的工单数量。</summary>
+    public int MatchedWorkOrders { get; set; }
+
+    /// <summary>未能匹配到未下达工单的行(格式:订单号+父物料号)。</summary>
+    public List<string> Unmatched { get; set; } = new();
+}

+ 363 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderBomUploadService.cs

@@ -0,0 +1,363 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>
+/// 工单 BOM 上传服务 — 从 Excel 解析结果按(订单号+父物料号)匹配未下达工单,
+/// 批量写入 ItemMaster + WorkOrdDetail。
+/// 路由前缀:/api/WorkOrder/dispatch/bom/...
+///
+/// 【租户安全边界】需认证访问;租户一律经
+/// <c>AidopTenantScope.ResolveOrThrow</c> 从认证后 JWT 解析。
+/// </summary>
+[ApiDescriptionSettings(Order = 258, Description = "工单BOM上传")]
+[Route("api/WorkOrder")]
+[NonUnify]
+public class WorkOrderBomUploadService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+    private readonly ILogger<WorkOrderBomUploadService> _logger;
+
+    public WorkOrderBomUploadService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        ILogger<WorkOrderBomUploadService> logger)
+    {
+        _db = db;
+        _userManager = userManager;
+        _logger = logger;
+    }
+
+    private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
+
+    // ══════════════════════════════════════════════════════════════
+    // POST /api/WorkOrder/dispatch/bom/check-materials
+    // ══════════════════════════════════════════════════════════════
+    /// <summary>校验 BOM 子物料是否在 ItemMaster 中存在。</summary>
+    [DisplayName("BOM物料存在性校验")]
+    [ApiDescriptionSettings(Name = "BomCheckMaterials"), HttpPost("dispatch/bom/check-materials")]
+    public async Task<BomCheckResult> CheckMaterials([FromBody] BomCheckMaterialsInput input)
+    {
+        var tid = ResolveTenantId();
+        if (input.Items == null || input.Items.Count == 0)
+            throw Oops.Oh("BOM 物料列表为空");
+
+        var itemNums = input.Items
+            .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum))
+            .Select(x => x.ItemNum.Trim())
+            .Distinct()
+            .ToList();
+
+        if (itemNums.Count == 0)
+            throw Oops.Oh("BOM 中无有效的物料编码");
+
+        // 查询本租户下已存在的物料编码
+        var existingSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var pars = new List<SugarParameter> { new("@TenantId", tid) };
+        for (var i = 0; i < itemNums.Count; i++)
+            pars.Add(new SugarParameter($"@IN{i}", itemNums[i]));
+        var inClause = string.Join(", ", Enumerable.Range(0, itemNums.Count).Select(i => $"@IN{i}"));
+
+        var existing = await _db.Ado.SqlQueryAsync<string>(
+            $"""
+            SELECT DISTINCT ItemNum FROM ItemMaster
+            WHERE tenant_id = @TenantId AND ItemNum IN ({inClause})
+            """,
+            pars);
+        foreach (var e in existing)
+            existingSet.Add(e);
+
+        return new BomCheckResult
+        {
+            Existing = existingSet.ToList(),
+            Missing = input.Items
+                .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum) && !existingSet.Contains(x.ItemNum.Trim()))
+                .GroupBy(x => x.ItemNum.Trim())
+                .Select(g => g.First())
+                .ToList()
+        };
+    }
+
+    // ══════════════════════════════════════════════════════════════
+    // POST /api/WorkOrder/dispatch/bom/upload
+    // ══════════════════════════════════════════════════════════════
+    /// <summary>
+    /// 写入 BOM:按(订单号+父物料号)匹配未下达工单,可选新建 ItemMaster 缺失物料,
+    /// 然后 Upsert WorkOrdDetail。
+    /// </summary>
+    [DisplayName("BOM写入工单物料明细")]
+    [ApiDescriptionSettings(Name = "BomUpload"), HttpPost("dispatch/bom/upload")]
+    public async Task<BomUploadResult> Upload([FromBody] BomUploadInput input)
+    {
+        var tid = ResolveTenantId();
+        if (input.Items == null || input.Items.Count == 0)
+            throw Oops.Oh("BOM 物料列表为空");
+
+        var account = _userManager.Account ?? "system";
+        var factoryRefId = await ResolveFactoryRefIdAsync(tid);
+
+        // 1) 查找哪些子物料需要新建(ItemMaster 存在性检查)
+        var itemNums = input.Items
+            .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum))
+            .Select(x => x.ItemNum.Trim())
+            .Distinct()
+            .ToList();
+
+        var existingSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        if (itemNums.Count > 0)
+        {
+            var pars = new List<SugarParameter> { new("@TenantId", tid) };
+            for (var i = 0; i < itemNums.Count; i++)
+                pars.Add(new SugarParameter($"@IN{i}", itemNums[i]));
+            var inClause = string.Join(", ", Enumerable.Range(0, itemNums.Count).Select(i => $"@IN{i}"));
+            var existing = await _db.Ado.SqlQueryAsync<string>(
+                $"SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id = @TenantId AND ItemNum IN ({inClause})",
+                pars);
+            foreach (var e in existing)
+                existingSet.Add(e);
+        }
+
+        var newItemsCount = 0;
+
+        // 2) 按(SalesOrderNo, ParentItemNum)分组 → 每组对应一个工单
+        var groups = input.Items
+            .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum)
+                        && !string.IsNullOrWhiteSpace(x.SalesOrderNo)
+                        && !string.IsNullOrWhiteSpace(x.ParentItemNum))
+            .GroupBy(x => (SalesOrderNo: x.SalesOrderNo!.Trim(), ParentItemNum: x.ParentItemNum!.Trim()))
+            .ToList();
+
+        // 没有有效分组 key 的行视为无法匹配
+        var unmatched = new List<string>();
+        var noKeyItems = input.Items
+            .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum)
+                        && (string.IsNullOrWhiteSpace(x.SalesOrderNo) || string.IsNullOrWhiteSpace(x.ParentItemNum)))
+            .ToList();
+        if (noKeyItems.Count > 0)
+            unmatched.Add($"缺少订单号或父物料号:{noKeyItems.Count} 行");
+
+        // 3) 预匹配:在写入之前先将所有分组匹配到工单,有未匹配则整体中止
+        var matchedGroups = new List<(IGrouping<(string SalesOrderNo, string ParentItemNum), BomItemDto> Grp, WoInfo Wo)>();
+        foreach (var grp in groups)
+        {
+            var woRow = await _db.Ado.SqlQuerySingleAsync<WoInfo>(
+                """
+                SELECT RecID AS RecId, WorkOrd, `Domain`, LOWER(TRIM(IFNULL(Status,''))) AS Status
+                FROM WorkOrdMaster
+                WHERE tenant_id = @TenantId
+                  AND TRIM(IFNULL(SalesJob,'')) = @SalesOrderNo
+                  AND TRIM(IFNULL(ItemNum,'')) = @ParentItemNum
+                  AND LOWER(TRIM(IFNULL(Status,''))) = 'p'
+                LIMIT 1
+                """,
+                new SugarParameter("@TenantId", tid),
+                new SugarParameter("@SalesOrderNo", grp.Key.SalesOrderNo),
+                new SugarParameter("@ParentItemNum", grp.Key.ParentItemNum));
+
+            if (woRow == null)
+                unmatched.Add($"订单[{grp.Key.SalesOrderNo}] + 物料[{grp.Key.ParentItemNum}]");
+            else
+                matchedGroups.Add((grp, woRow));
+        }
+
+        // 有未匹配组 → 整体中止,不写入任何数据
+        if (unmatched.Count > 0)
+        {
+            return new BomUploadResult
+            {
+                Inserted = 0, Updated = 0, NewItems = 0, Total = input.Items.Count,
+                MatchedWorkOrders = 0, Unmatched = unmatched
+            };
+        }
+
+        await _db.Ado.BeginTranAsync();
+        try
+        {
+            // 4) 创建缺失物料到 ItemMaster
+            if (input.CreateMissingItems)
+            {
+                var missingItems = input.Items
+                    .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum) && !existingSet.Contains(x.ItemNum.Trim()))
+                    .GroupBy(x => x.ItemNum.Trim())
+                    .Select(g => g.First())
+                    .ToList();
+
+                var now = DateTime.Now;
+                // 获取一个默认 Domain(取第一个匹配工单的,或兜底 8010)
+                var defaultDomain = "8010";
+                foreach (var item in missingItems)
+                {
+                    var itemNum = item.ItemNum.Trim();
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        INSERT IGNORE INTO ItemMaster (
+                            tenant_id, ItemNum, Descr, Descr1, UM,
+                            Status, IsActive, Domain, factory_ref_id,
+                            CreateTime, CreateUser, UpdateTime, UpdateUser
+                        ) VALUES (
+                            @TenantId, @ItemNum, @Descr, @Descr1, @UM,
+                            '', 1, @Domain, @FactoryRefId,
+                            @Now, @User, @Now, @User
+                        )
+                        """,
+                        new SugarParameter("@TenantId", tid),
+                        new SugarParameter("@ItemNum", itemNum),
+                        new SugarParameter("@Descr", item.Descr ?? string.Empty),
+                        new SugarParameter("@Descr1", item.Descr1 ?? (object)DBNull.Value),
+                        new SugarParameter("@UM", item.UM ?? (object)DBNull.Value),
+                        new SugarParameter("@Domain", defaultDomain),
+                        new SugarParameter("@FactoryRefId", factoryRefId),
+                        new SugarParameter("@Now", now),
+                        new SugarParameter("@User", account));
+                    newItemsCount++;
+                    existingSet.Add(itemNum);
+                }
+            }
+
+            // 5) 逐组写入 WorkOrdDetail(已预匹配,无需再查)
+            var inserted = 0;
+            var updated = 0;
+            var matchedWorkOrders = 0;
+
+            foreach (var (grp, woRow) in matchedGroups)
+            {
+                var domain = string.IsNullOrWhiteSpace(woRow.Domain) ? "8010" : woRow.Domain;
+
+                // 加载该工单已有的 WorkOrdDetail
+                var existingDetails = await _db.Ado.SqlQueryAsync<ExistingDetailRow>(
+                    """
+                    SELECT RecID, ItemNum, QtyRequired
+                    FROM WorkOrdDetail
+                    WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
+                    """,
+                    new SugarParameter("@TenantId", tid),
+                    new SugarParameter("@WorkOrd", woRow.WorkOrd));
+                var existingMap = existingDetails.ToDictionary(x => x.ItemNum, x => x, StringComparer.OrdinalIgnoreCase);
+
+                var line = existingDetails.Count + 1;
+                var now2 = DateTime.Now;
+
+                // 按子物料 ItemNum 去重(同一物料累加需求数量)
+                var groupedItems = grp
+                    .GroupBy(x => x.ItemNum.Trim())
+                    .Select(g => new
+                    {
+                        ItemNum = g.Key,
+                        Descr = g.First().Descr,
+                        Descr1 = g.First().Descr1,
+                        UM = g.First().UM,
+                        QtyRequired = g.Sum(x => x.QtyRequired),
+                        Op = g.First().Op
+                    })
+                    .ToList();
+
+                foreach (var item in groupedItems)
+                {
+                    var itemNum = item.ItemNum.Trim();
+                    if (existingMap.TryGetValue(itemNum, out var ex))
+                    {
+                        // 已有 → UPDATE QtyRequired
+                        await _db.Ado.ExecuteCommandAsync(
+                            """
+                            UPDATE WorkOrdDetail
+                            SET QtyRequired = @QtyRequired, UpdateUser = @User, UpdateTime = @Now
+                            WHERE RecID = @RecID AND tenant_id = @TenantId
+                            """,
+                            new SugarParameter("@QtyRequired", item.QtyRequired),
+                            new SugarParameter("@User", account),
+                            new SugarParameter("@Now", now2),
+                            new SugarParameter("@RecID", ex.RecID),
+                            new SugarParameter("@TenantId", tid));
+                        updated++;
+                    }
+                    else
+                    {
+                        // 新增 → INSERT
+                        await _db.Ado.ExecuteCommandAsync(
+                            """
+                            INSERT INTO WorkOrdDetail (
+                                WorkOrdMasterRecID, `Domain`, WorkOrd, LineNum, ItemNum, Op,
+                                Location, QtyRequired, QtyPosted, UM, IsActive, IsConfirm,
+                                CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
+                            ) VALUES (
+                                @MasterRecId, @Domain, @WorkOrd, @LineNum, @ItemNum, @Op,
+                                @Location, @QtyRequired, 0, @UM, 1, 0,
+                                @User, @Now, @User, @Now, @TenantId
+                            )
+                            """,
+                            new SugarParameter("@MasterRecId", woRow.RecId),
+                            new SugarParameter("@Domain", domain),
+                            new SugarParameter("@WorkOrd", woRow.WorkOrd),
+                            new SugarParameter("@LineNum", line),
+                            new SugarParameter("@ItemNum", itemNum),
+                            new SugarParameter("@Op", item.Op),
+                            new SugarParameter("@Location", DBNull.Value),
+                            new SugarParameter("@QtyRequired", item.QtyRequired),
+                            new SugarParameter("@UM", item.UM ?? (object)DBNull.Value),
+                            new SugarParameter("@User", account),
+                            new SugarParameter("@Now", now2),
+                            new SugarParameter("@TenantId", tid));
+                        inserted++;
+                        line++;
+                    }
+                }
+
+                matchedWorkOrders++;
+                _logger.LogInformation(
+                    "[BomUpload] workOrd={WorkOrd} salesOrderNo={SalesOrderNo} parentItem={ParentItem} items={Items}",
+                    woRow.WorkOrd, grp.Key.SalesOrderNo, grp.Key.ParentItemNum, groupedItems.Count);
+            }
+
+            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);
+
+            return new BomUploadResult
+            {
+                Inserted = inserted,
+                Updated = updated,
+                NewItems = newItemsCount,
+                Total = input.Items.Count,
+                MatchedWorkOrders = matchedWorkOrders,
+                Unmatched = unmatched
+            };
+        }
+        catch
+        {
+            await _db.Ado.RollbackTranAsync();
+            throw;
+        }
+    }
+
+    /// <summary>获取租户的 factory_ref_id(取 ItemMaster 中已有的最小 factory_ref_id,缺省 1)。</summary>
+    private async Task<long> ResolveFactoryRefIdAsync(long tenantId)
+    {
+        var val = await _db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(MIN(factory_ref_id), 1) FROM ItemMaster
+            WHERE tenant_id = @TenantId AND factory_ref_id > 0
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId));
+        return val <= 0 ? 1 : val;
+    }
+
+    private sealed class WoInfo
+    {
+        public long RecId { get; set; }
+        public string WorkOrd { get; set; } = string.Empty;
+        public string? Domain { get; set; }
+        public string? Status { get; set; }
+    }
+
+    private sealed class ExistingDetailRow
+    {
+        public long RecID { get; set; }
+        public string ItemNum { get; set; } = string.Empty;
+        public decimal QtyRequired { get; set; }
+    }
+}