Преглед изворни кода

feat(bom-upload): 可编辑列改为双击触发的下拉/数值编辑器,对接货源清单API自动匹配供应商

- 决策采购: el-select filterable,候选项来自 SysUserApi 用户列表
- 供应商: 上传后逐行调 fetchSourceListPage API 自动匹配(并发限制5),双击远程搜索
- 供应商联系人: 选择供应商时自动带出 orderRectorName,双击可手动编辑
- 决策阶段/状态: el-select 固定枚举选项
- 含税单价/总价: el-input-number,单价变更自动计算总价
- 交互: 默认显示文本+hover高亮,双击进入编辑,blur/change/Esc退出
- 性能: 同时只渲染1个编辑器,用户列表一次加载,API失败静默跳过
- Web 2.4.416 -> 2.4.417
Pengxy пре 2 дана
родитељ
комит
ab1e0ea39f
2 измењених фајлова са 438 додато и 84 уклоњено
  1. 1 1
      Web/package.json
  2. 437 83
      Web/src/views/aidop/business/workOrderBomUpload.vue

+ 1 - 1
Web/package.json

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

+ 437 - 83
Web/src/views/aidop/business/workOrderBomUpload.vue

@@ -1,5 +1,5 @@
 <template>
-	<AidopDemoShell :title="pageTitle" subtitle="上传 Excel BOM 并按订单号+物料编号匹配未下达工单写入">
+	<AidopDemoShell :title="pageTitle" subtitle="上传 Excel BOM 并按订单号+物料编号匹配未下达工单写入(支持在线编辑)">
 		<!-- 顶部信息 + 返回 -->
 		<div class="bom-header">
 			<el-button text @click="goBack">
@@ -21,6 +21,7 @@
 			/>
 			<span v-if="fileName" class="file-name">{{ fileName }}</span>
 			<span v-if="parseError" class="parse-error">{{ parseError }}</span>
+			<span v-if="hasSavedData" class="saved-hint">已加载缓存数据({{ parsedRows.length }} 行)</span>
 		</div>
 
 		<!-- 统计摘要 -->
@@ -29,63 +30,170 @@
 			涉及 <b>{{ groupCount }}</b> 个工单(订单号+父物料号组合)
 		</div>
 
-		<!-- 物料预览表 -->
+		<!-- 物料预览表:点击采购列单元格进入编辑 -->
 		<el-table
-			v-if="parsedRows.length > 0"
 			:data="parsedRows"
 			border
 			stripe
 			style="width: 100%; margin-top: 8px"
-			max-height="480"
+			max-height="520"
+			size="small"
 		>
-			<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-column type="index" label="#" width="50" align="center" fixed />
+			<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, $index }">
+					<template v-if="col.editable">
+						<div @blur.capture="stopEditing">
+							<!-- 下拉选择列:决策采购 / 供应商 / 决策阶段 / 状态 -->
+							<el-select
+								v-if="['purchaser', 'supplier', 'decisionStage', 'status'].includes(col.key)"
+								v-model="row[col.key]"
+								filterable
+								: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)"
+								@keyup.esc="stopEditing"
+							>
+								<el-option v-if="col.key === 'purchaser'" v-for="u in purchaserOptions" :key="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" />
+								<el-option v-else-if="col.key === 'decisionStage'" v-for="o in DECISION_STAGE_OPTIONS" :key="o" :label="o" :value="o" />
+								<el-option v-else-if="col.key === 'status'" v-for="o in STATUS_OPTIONS" :key="o" :label="o" :value="o" />
+							</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>
+						<span v-if="!isEditing($index, col.key)" class="editable-cell" @dblclick="startEditing($index, col.key)">{{ formatCell(row[col.key], col.type) }}</span>
+					</template>
+					<span v-else>{{ formatCell(row[col.key], col.type) }}</span>
+				</template>
+			</el-table-column>
 		</el-table>
 
 		<!-- 操作按钮 -->
-		<div v-if="parsedRows.length > 0" class="bom-actions">
-			<el-button type="primary" :loading="writing" @click="onConfirmWrite">确认写入</el-button>
+		<div class="bom-actions">
+			<el-button type="warning" @click="onConfirmWrite" :loading="writing">确认写入工单</el-button>
+			<el-button type="danger" plain @click="onClearData">清空数据</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 { computed, onMounted, 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 { getAPI } from '/@/utils/axios-utils';
+import { SysUserApi } from '/@/api-services/api';
+import { fetchSourceListPage } from '../s3/api/sourceList';
 import XLSX from 'xlsx-js-style';
 
 const router = useRouter();
-
 const pageTitle = '工单 BOM 批量上传';
 
-// ── 文件解析 ──
+const STORAGE_KEY = 'aidop_bom_upload_data_v2';
+
+// ── 列定义:与 procurement-intransit-demo 的 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 = ['待决策', '已发资料', '集采再询价', '竞价或扩额', '替代采购', '已维护价格'];
+const STATUS_OPTIONS = ['待决策', '已下订单', '已通知送货', '已部分到货', '已完成', '已删除', '被替代'];
+
+// ── 数据状态 ──
+interface ParsedRow {
+	[key: string]: any;
+}
 const fileInputRef = ref<HTMLInputElement | null>(null);
 const fileName = ref('');
 const parseError = ref('');
 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);
 
-/** 解析后的行(前端预览 + 传后端) */
-interface ParsedRow extends BomItemDto {
-	contractNo?: string;
+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;
 }
-const parsedRows = ref<ParsedRow[]>([]);
 
 /** 按(订单号+父物料号)去重组数 */
 const groupCount = computed(() => {
@@ -97,15 +205,199 @@ const groupCount = computed(() => {
 	return keys.size;
 });
 
-/** MES BOM 18 列名称 → Excel 列索引 */
-const MES_COLUMN_ORDER = [
-	'销售订单号', '合同号', '合同名称', '产品部',
-	'父物料号', '父物料描述', '工厂', '投产人', '物料单号',
-	'子物料号', '子物料描述', '子件长描述', '需求数量', '单位', '项目文本',
-	'电气设计', '结构设计', '推送日期'
-];
-const HEADER_ROW = 1; // 第 2 行(index=1)为表头,第 1 行是标题
+// ── Excel 列映射 ──
+/** Excel 列名 → ParsedRow key */
+const EXCEL_COL_MAP: Record<string, string> = {
+	'销售订单号': 'salesOrderNo',
+	'合同号': 'contractNo',
+	'合同名称': 'contractName',
+	'产品部': 'productDept',
+	'父物料号': 'parentItemNum',
+	'父物料描述': 'parentDescr',
+	'工厂': 'factory',
+	'投产人': 'productionPerson',
+	'物料单号': 'materialBillNo',
+	'子物料号': 'itemNum',
+	'子物料描述': 'descr',
+	'子件长描述': 'descr1',
+	'需求数量': 'qtyRequired',
+	'单位': 'um',
+	'项目文本': 'projectText',
+	'电气设计': 'electricalDesigner',
+	'结构设计': 'structuralDesigner',
+	'推送日期': 'pushDate',
+	'采购负责人': 'purchaser',
+};
+const REQUIRED_EXCEL_COLS = ['销售订单号', '合同号', '父物料号', '子物料号', '需求数量'];
+const HEADER_ROW = 1; // 第 2 行(index=1)为表头
+
+/** 创建空行对象 */
+function createEmptyRow(): ParsedRow {
+	const row: ParsedRow = {};
+	for (const col of TABLE_COLUMNS) row[col.key] = null;
+	return row;
+}
+
+/** 按列类型转换 Excel 单元格值 */
+function coerceValue(key: string, raw: any): any {
+	if (raw == null || raw === '') return null;
+	const col = TABLE_COLUMNS.find((c) => c.key === key);
+	if (!col) return String(raw).trim() || null;
+	if (col.type === 'number') {
+		const n = typeof raw === 'number' ? raw : parseFloat(String(raw));
+		return isNaN(n) ? null : n;
+	}
+	if (col.type === 'date') {
+		if (raw instanceof Date && !isNaN(raw.getTime())) {
+			const d = raw;
+			const mm = String(d.getMonth() + 1).padStart(2, '0');
+			const dd = String(d.getDate()).padStart(2, '0');
+			return `${d.getFullYear()}.${mm}.${dd}`;
+		}
+		return String(raw).trim() || null;
+	}
+	return String(raw).trim() || null;
+}
 
+/** 格式化单元格显示 */
+function formatCell(val: any, type: string): string {
+	if (val == null || val === '') return '—';
+	if (type === 'number') return String(val);
+	return String(val);
+}
+
+// ── localStorage 持久化 ──
+function saveToStorage() {
+	try {
+		localStorage.setItem(STORAGE_KEY, JSON.stringify(parsedRows.value));
+	} catch (e) {
+		console.warn('保存 BOM 缓存失败', e);
+	}
+}
+function clearStorage() {
+	localStorage.removeItem(STORAGE_KEY);
+}
+
+// ── 货源清单规则(物料→决策采购→供应商 映射,localStorage 持久化)──
+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 saveAllSupplySourceRules() {
+	try { localStorage.setItem(RULES_KEY, JSON.stringify(supplySourceRules.value)); } catch { /* ignore */ }
+}
+/** 自动匹配供应商(本地货源清单规则,同步) */
+function autoMatchSupplierLocal(row: ParsedRow) {
+	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;
+	}
+}
+
+/** 自动匹配供应商(调货源清单 API) */
+async function autoMatchSourceList(row: ParsedRow) {
+	autoMatchSupplierLocal(row);
+	if (!row.itemNum) return;
+	if (row.supplier && row.unitPrice) return;
+	try {
+		const res = await fetchSourceListPage({ number: String(row.itemNum), page: 1, pageSize: 1 });
+		if (res?.list?.length) {
+			const item = res.list[0];
+			if (!row.supplier && item.supplierName) row.supplier = item.supplierName;
+			if (!row.supplierContact && item.orderRectorName) row.supplierContact = item.orderRectorName;
+			if (row.unitPrice == null && item.orderPrice != null) row.unitPrice = item.orderPrice;
+		}
+	} catch {
+		/* API 失败静默跳过,本地规则已回填 */
+	}
+}
+
+// ── 可编辑列变更处理 ──
+function onCellChange(row: ParsedRow, key: string) {
+	if (key === 'purchaser') {
+		autoMatchSupplierLocal(row);
+	}
+	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);
+	}
+	saveToStorage();
+}
+
+// ── 采购人员选项 ──
+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; }
+}
+
+// ── 供应商搜索选项 ──
+const supplierOptions = ref<{ value: string; label: string }[]>([]);
+const supplierLoading = ref(false);
+async function searchSuppliers(keyword: string) {
+	supplierLoading.value = true;
+	try {
+		const res = await fetchSourceListPage({ number: keyword || undefined, page: 1, pageSize: 20 });
+		supplierOptions.value = (res?.list ?? [])
+			.filter((s) => s.supplierName)
+			.map((s) => ({ value: s.supplierName!, label: `${s.supplierName}${s.supplierNumber ? ' | ' + s.supplierNumber : ''}` }));
+	} catch {
+		supplierOptions.value = [];
+	} finally { supplierLoading.value = false; }
+}
+
+// ── 页面加载:恢复货源清单规则 + 加载采购人员列表 ──
+onMounted(() => {
+	loadSupplySourceRules();
+	loadPurchaserOptions();
+});
+
+// ── 文件上传解析 ──
 function triggerFileInput() {
 	fileInputRef.value?.click();
 }
@@ -116,10 +408,12 @@ function onFileChange(e: Event) {
 	if (!file) return;
 	fileName.value = file.name;
 	parseError.value = '';
+	hasSavedData.value = false;
 	uploading.value = true;
+	clearStorage(); // 新文件上传清除旧缓存
 
 	const reader = new FileReader();
-	reader.onload = (ev) => {
+	reader.onload = async (ev) => {
 		try {
 			const data = new Uint8Array(ev.target!.result as ArrayBuffer);
 			const wb = XLSX.read(data, { type: 'array', cellDates: true });
@@ -131,53 +425,52 @@ function onFileChange(e: Event) {
 				return;
 			}
 
-			// 解析表头
+			// 1) 解析表头:按列名匹配
 			const headerRow = aoa[HEADER_ROW] || [];
-			const idx: Record<string, number> = {};
-			for (const name of MES_COLUMN_ORDER) {
-				idx[name] = -1;
+			const colIdx: Record<string, number> = {};
+			for (const [excelName, key] of Object.entries(EXCEL_COL_MAP)) {
+				colIdx[key] = -1;
 				for (let i = 0; i < headerRow.length; i++) {
-					if (headerRow[i] != null && String(headerRow[i]).trim() === name) {
-						idx[name] = i;
+					if (headerRow[i] != null && String(headerRow[i]).trim() === excelName) {
+						colIdx[key] = 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} 个`;
+			// 校验必需列
+			const missingRequired = REQUIRED_EXCEL_COLS.filter((n) => {
+				const key = EXCEL_COL_MAP[n];
+				return key && colIdx[key] < 0;
+			});
+			if (missingRequired.length > 0) {
+				parseError.value = `Excel 缺少必需字段:${missingRequired.join('、')}`;
 				return;
 			}
 
-			// 解析数据行
+			// 2) 解析数据行 → 38 列完整行对象
 			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,
-				});
+				// 跳过完全空行
+				const hasAny = Object.values(colIdx).some((idx) => idx >= 0 && arr[idx] != null && String(arr[idx]).trim() !== '');
+				if (!hasAny) continue;
+
+				const row = createEmptyRow();
+				for (const [_, key] of Object.entries(EXCEL_COL_MAP)) {
+					if (colIdx[key] >= 0) {
+						row[key] = coerceValue(key, arr[colIdx[key]]);
+					}
+				}
+
+				// 子物料号为空则跳过
+				if (!row.itemNum) continue;
+
+				// 默认状态
+				if (!row.status) row.status = '待决策';
+				// 本地规则预匹配(同步,快速回填已有规则)
+				autoMatchSupplierLocal(row);
+				rows.push(row);
 			}
 
 			if (rows.length === 0) {
@@ -185,6 +478,18 @@ function onFileChange(e: Event) {
 				return;
 			}
 			parsedRows.value = rows;
+			saveToStorage();
+			ElMessage.success(`解析完成:${rows.length} 行,正在从货源清单自动匹配供应商…`);
+
+			// 异步:逐行调货源清单 API 自动匹配供应商(并发限制 5)
+			const CONCURRENCY = 5;
+			let apiMatched = 0;
+			for (let i = 0; i < rows.length; i += CONCURRENCY) {
+				const batch = rows.slice(i, i + CONCURRENCY);
+				const results = await Promise.all(batch.map((r) => autoMatchSourceList(r).then(() => r)));
+				apiMatched += results.filter((r) => r.supplier).length;
+			}
+			ElMessage.success(`${rows.length} 行解析完成,${apiMatched} 行已匹配供应商`);
 		} catch (err: any) {
 			parseError.value = `解析失败:${err?.message || err}`;
 		} finally {
@@ -196,26 +501,41 @@ function onFileChange(e: Event) {
 		uploading.value = false;
 	};
 	reader.readAsArrayBuffer(file);
-	input.value = ''; // 允许重复上传同文件
+	input.value = '';
 }
 
-function cellStr(v: any): string {
-	if (v == null) return '';
-	return String(v).trim();
+// ── 清空数据 ──
+async function onClearData() {
+	try {
+		await ElMessageBox.confirm('确定清空所有已解析/编辑的数据?', '确认清空', {
+			confirmButtonText: '确定',
+			cancelButtonText: '取消',
+			type: 'warning',
+		});
+		parsedRows.value = [];
+		clearStorage();
+		hasSavedData.value = false;
+		editingCell.value = null;
+		ElMessage.success('数据已清空');
+	} catch {
+		/* 用户取消 */
+	}
 }
 
-// ── 确认写入 ──
+// ── 确认写入工单 ──
 async function onConfirmWrite() {
 	if (!parsedRows.value.length) return;
+
+	// 提取核心 BOM 字段传后端
 	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,
+		itemNum: String(r.itemNum ?? ''),
+		descr: String(r.descr ?? ''),
+		descr1: r.descr1 ? String(r.descr1) : undefined,
+		um: r.um ? String(r.um) : undefined,
+		qtyRequired: Number(r.qtyRequired) || 0,
+		op: Number(r.projectText) || 0,
+		salesOrderNo: r.salesOrderNo ? String(r.salesOrderNo) : undefined,
+		parentItemNum: r.parentItemNum ? String(r.parentItemNum) : undefined,
 	}));
 
 	writing.value = true;
@@ -234,7 +554,6 @@ async function onConfirmWrite() {
 				);
 				createMissing = true;
 			} catch {
-				// 用户点取消
 				ElMessage.info('已取消,未写入任何数据');
 				return;
 			}
@@ -243,7 +562,6 @@ async function onConfirmWrite() {
 		// 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} 组`);
@@ -260,6 +578,20 @@ async function onConfirmWrite() {
 				`新增 ${result.inserted} 行、更新 ${result.updated} 行` +
 				(result.newItems > 0 ? `、新建物料 ${result.newItems} 条` : '')
 		);
+		// 写入前:将所有有供应商关系的行保存到货源清单规则
+		for (const row of parsedRows.value) {
+			if (row.supplier && row.itemNum) {
+				saveSupplySourceRule(
+					String(row.itemNum),
+					String(row.purchaser ?? ''),
+					String(row.supplier),
+					row.supplierContact ? String(row.supplierContact) : undefined
+				);
+			}
+		}
+		saveAllSupplySourceRules();
+
+		clearStorage();
 		goBack();
 	} catch (err: any) {
 		ElMessage.error(err?.message || '写入失败');
@@ -288,6 +620,7 @@ function goBack() {
 	align-items: center;
 	gap: 12px;
 	margin-bottom: 8px;
+	flex-wrap: wrap;
 
 	.file-name {
 		font-size: 13px;
@@ -297,6 +630,13 @@ function goBack() {
 		font-size: 13px;
 		color: var(--el-color-danger);
 	}
+	.saved-hint {
+		font-size: 12px;
+		color: var(--el-color-success);
+		background: var(--el-color-success-light-9);
+		padding: 2px 8px;
+		border-radius: 4px;
+	}
 }
 
 .bom-summary {
@@ -310,5 +650,19 @@ function goBack() {
 	margin-top: 16px;
 	display: flex;
 	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>