workOrderBomUpload.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. <template>
  2. <AidopDemoShell :title="pageTitle" subtitle="上传 Excel BOM 并按订单号+物料编号匹配未下达工单写入(支持在线编辑)">
  3. <!-- 顶部信息 + 返回 -->
  4. <div class="bom-header">
  5. <el-button text @click="goBack">
  6. <el-icon><ArrowLeft /></el-icon> 返回工单下达列表
  7. </el-button>
  8. </div>
  9. <!-- 上传区 -->
  10. <div class="bom-upload-area">
  11. <el-button type="primary" @click="triggerFileInput" :disabled="uploading">
  12. 上传 BOM 文件
  13. </el-button>
  14. <input
  15. ref="fileInputRef"
  16. type="file"
  17. accept=".xlsx,.xls"
  18. style="display: none"
  19. @change="onFileChange"
  20. />
  21. <span v-if="fileName" class="file-name">{{ fileName }}</span>
  22. <span v-if="parseError" class="parse-error">{{ parseError }}</span>
  23. <span v-if="hasSavedData" class="saved-hint">已加载缓存数据({{ parsedRows.length }} 行)</span>
  24. </div>
  25. <!-- 条件筛选区(参照采购在途演示工程) -->
  26. <AidopFilterPane
  27. v-if="parsedRows.length > 0"
  28. :rows="parsedRows"
  29. :fields="AIDOP_FILTER_FIELDS"
  30. @filter-change="onFilterChange"
  31. />
  32. <!-- 统计摘要 -->
  33. <div v-if="filteredRows.length > 0" class="bom-summary">
  34. 共 <b>{{ filteredRows.length }}</b> 行,
  35. 涉及 <b>{{ groupCount }}</b> 个工单(订单号+父物料号组合)
  36. </div>
  37. <!-- 物料预览表:点击采购列单元格进入编辑 -->
  38. <el-table
  39. :data="pageRows"
  40. border
  41. stripe
  42. style="width: 100%; margin-top: 8px"
  43. max-height="520"
  44. size="small"
  45. >
  46. <el-table-column type="index" label="#" width="60" align="center" fixed :index="pageIndex" />
  47. <el-table-column
  48. v-for="col in TABLE_COLUMNS"
  49. :key="col.key"
  50. :prop="col.key"
  51. :label="col.label"
  52. :width="col.width"
  53. :min-width="col.minWidth"
  54. show-overflow-tooltip
  55. >
  56. <template #default="{ row, $index }">
  57. <template v-if="col.editable">
  58. <div>
  59. <!-- 下拉选择列:决策采购 / 供应商 / 决策阶段 / 状态 -->
  60. <el-select
  61. v-if="['purchaser', 'supplier', 'decisionStage', 'status'].includes(col.key)"
  62. v-model="row[col.key]"
  63. filterable
  64. allow-create
  65. default-first-option
  66. :remote="col.key === 'supplier'"
  67. :remote-method="col.key === 'supplier' ? searchSuppliers : undefined"
  68. :loading="col.key === 'purchaser' ? purchaserLoading : col.key === 'supplier' ? supplierLoading : false"
  69. placeholder=""
  70. size="small"
  71. style="width: 100%"
  72. @change="onCellChange(row, col.key)"
  73. >
  74. <el-option v-if="col.key === 'purchaser'" v-for="u in purchaserOptions" :key="'p-' + u" :label="u" :value="u" />
  75. <el-option v-else-if="col.key === 'supplier'" v-for="s in supplierOptions" :key="s.value" :label="s.label" :value="s.value" />
  76. <el-option v-else-if="col.key === 'decisionStage'" v-for="ds in DECISION_STAGE_OPTIONS" :key="'ds-' + ds" :label="ds" :value="ds" />
  77. <el-option v-else-if="col.key === 'status'" v-for="st in STATUS_OPTIONS" :key="'st-' + st" :label="st" :value="st" />
  78. </el-select>
  79. <!-- 数值列:含税单价 / 总价 -->
  80. <el-input-number
  81. v-else-if="['unitPrice', 'totalPrice'].includes(col.key)"
  82. v-model="row[col.key]"
  83. size="small"
  84. controls-position="right"
  85. :precision="2"
  86. style="width: 100%"
  87. @change="onCellChange(row, col.key)"
  88. />
  89. <!-- 文本输入:供应商联系人 -->
  90. <el-input v-else v-model="row[col.key]" size="small" @change="onCellChange(row, col.key)" />
  91. </div>
  92. </template>
  93. <span v-else>{{ formatCell(row[col.key], col.type) }}</span>
  94. </template>
  95. </el-table-column>
  96. </el-table>
  97. <!-- 分页 -->
  98. <div v-if="filteredRows.length > PAGE_SIZE" style="margin-top: 8px; display: flex; justify-content: flex-end">
  99. <el-pagination
  100. v-model:current-page="currentPage"
  101. :page-size="PAGE_SIZE"
  102. :total="filteredRows.length"
  103. layout="total, prev, pager, next, jumper"
  104. background
  105. small
  106. />
  107. </div>
  108. <!-- 匹配进度条 -->
  109. <div v-if="matchingProgress > 0 && matchingProgress < 100" style="margin-top: 4px">
  110. <el-progress :percentage="matchingProgress" :stroke-width="10" :text-inside="true" />
  111. <el-button size="small" text type="danger" @click="matchingCancelled = true" style="margin-top: 4px">取消匹配</el-button>
  112. </div>
  113. <!-- 操作按钮 -->
  114. <div class="bom-actions">
  115. <el-button type="warning" @click="onConfirmWrite" :loading="writing">确认写入工单</el-button>
  116. <el-button type="danger" plain @click="onClearData">清空数据</el-button>
  117. <el-button @click="goBack">取消</el-button>
  118. </div>
  119. </AidopDemoShell>
  120. </template>
  121. <script setup lang="ts" name="aidopBusinessWorkOrderBomUpload">
  122. import { computed, onMounted, ref } from 'vue';
  123. import { useRouter } from 'vue-router';
  124. import { ElMessage, ElMessageBox } from 'element-plus';
  125. import { ArrowLeft } from '@element-plus/icons-vue';
  126. import AidopDemoShell from '../components/AidopDemoShell.vue';
  127. import AidopFilterPane from '../components/AidopFilterPane.vue';
  128. import { AIDOP_FILTER_FIELDS } from '../components/aidopFilterFields';
  129. import { checkBomMaterials, uploadBom, type BomItemDto } from '../api/workOrderDispatch';
  130. import { getAPI } from '/@/utils/axios-utils';
  131. import { SysUserApi } from '/@/api-services/api';
  132. import { fetchSourceListPage } from '../s3/api/sourceList';
  133. import XLSX from 'xlsx-js-style';
  134. const router = useRouter();
  135. const pageTitle = '工单 BOM 批量上传';
  136. const STORAGE_KEY = 'aidop_bom_upload_data_v2';
  137. // ── 列定义:与 procurement-intransit-demo 的 38 列完全一致 ──
  138. interface ColDef {
  139. key: string;
  140. label: string;
  141. width?: number;
  142. minWidth?: number;
  143. type: 'id' | 'text' | 'enum' | 'number' | 'date';
  144. editable?: boolean;
  145. }
  146. const TABLE_COLUMNS: ColDef[] = [
  147. { key: 'salesOrderNo', label: '销售订单号', width: 130, type: 'id' },
  148. { key: 'contractNo', label: '合同号', width: 130, type: 'id' },
  149. { key: 'contractName', label: '合同名称', minWidth: 150, type: 'text' },
  150. { key: 'productDept', label: '产品部', width: 110, type: 'enum' },
  151. { key: 'parentItemNum', label: '父物料号', width: 120, type: 'id' },
  152. { key: 'parentDescr', label: '父物料描述', minWidth: 140, type: 'text' },
  153. { key: 'factory', label: '工厂', width: 70, type: 'enum' },
  154. { key: 'productionPerson', label: '投产人', width: 80, type: 'enum' },
  155. { key: 'materialBillNo', label: '物料单号', width: 100, type: 'id' },
  156. { key: 'itemNum', label: '子物料号', width: 140, type: 'id' },
  157. { key: 'descr', label: '子物料描述', minWidth: 150, type: 'text' },
  158. { key: 'descr1', label: '子件长描述', minWidth: 120, type: 'text' },
  159. { key: 'qtyRequired', label: '需求数量', width: 90, type: 'number' },
  160. { key: 'um', label: '单位', width: 60, type: 'enum' },
  161. { key: 'projectText', label: '项目文本', width: 100, type: 'text' },
  162. { key: 'electricalDesigner', label: '电气设计', width: 90, type: 'enum' },
  163. { key: 'structuralDesigner', label: '结构设计', width: 90, type: 'enum' },
  164. { key: 'pushDate', label: '推送日期', width: 100, type: 'date' },
  165. { key: 'purchaser', label: '决策采购', width: 100, type: 'enum', editable: true },
  166. { key: 'supplier', label: '供应商', minWidth: 160, type: 'enum', editable: true },
  167. { key: 'supplierContact', label: '供应商联系人', width: 120, type: 'enum', editable: true },
  168. { key: 'decisionStage', label: '决策阶段', width: 110, type: 'enum', editable: true },
  169. { key: 'unitPrice', label: '含税单价', width: 90, type: 'number', editable: true },
  170. { key: 'totalPrice', label: '总价', width: 90, type: 'number', editable: true },
  171. { key: 'sapInfoNo', label: 'SAP信息记录编号', width: 150, type: 'id' },
  172. { key: 'decisionDoneDate', label: '决策完成日期', width: 110, type: 'date' },
  173. { key: 'execPurchaser', label: '执行采购', width: 90, type: 'enum' },
  174. { key: 'purchaseReqNo', label: '采购申请号', width: 110, type: 'number' },
  175. { key: 'purchaseOrderNo', label: '采购订单号', width: 110, type: 'number' },
  176. { key: 'orderQty', label: '订单数量', width: 90, type: 'number' },
  177. { key: 'orderDate', label: '订单日期', width: 100, type: 'date' },
  178. { key: 'deliveryDate', label: '交货日期', width: 100, type: 'date' },
  179. { key: 'requiredDeliveryDate', label: '要求到货日期', width: 110, type: 'date' },
  180. { key: 'lastReceivedQty', label: '最近到货数量', width: 110, type: 'number' },
  181. { key: 'lastReceivedDate', label: '最近到货日期', width: 110, type: 'date' },
  182. { key: 'status', label: '状态', width: 100, type: 'enum', editable: true },
  183. { key: 'cumulativeReceivedQty', label: '累计入库数量', width: 110, type: 'number' },
  184. { key: 'issue', label: '异常问题', minWidth: 120, type: 'text' },
  185. ];
  186. const DECISION_STAGE_OPTIONS = ref(['待决策', '已发资料', '集采再询价', '竞价或扩额', '替代采购', '已维护价格']);
  187. const STATUS_OPTIONS = ref(['待决策', '已下订单', '已通知送货', '已部分到货', '已完成', '已删除', '被替代']);
  188. // ── 数据状态 ──
  189. interface ParsedRow {
  190. [key: string]: any;
  191. }
  192. const fileInputRef = ref<HTMLInputElement | null>(null);
  193. const fileName = ref('');
  194. const parseError = ref('');
  195. const uploading = ref(false);
  196. const writing = ref(false);
  197. const hasSavedData = ref(false);
  198. const parsedRows = ref<ParsedRow[]>([]);
  199. const matchingCancelled = ref(false);
  200. const matchingProgress = ref(0);
  201. // ── 分页 ──
  202. const PAGE_SIZE = 200;
  203. const currentPage = ref(1);
  204. /** 筛选后的行(由条件筛选区驱动;写入工单仍用全量 parsedRows) */
  205. const filteredRows = ref<ParsedRow[]>([]);
  206. const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / PAGE_SIZE)));
  207. const pageRows = computed(() => {
  208. const start = (currentPage.value - 1) * PAGE_SIZE;
  209. return filteredRows.value.slice(start, start + PAGE_SIZE);
  210. });
  211. function pageIndex(i: number): number {
  212. return (currentPage.value - 1) * PAGE_SIZE + i + 1;
  213. }
  214. function onFilterChange(list: ParsedRow[]) {
  215. filteredRows.value = list;
  216. currentPage.value = 1;
  217. }
  218. /** 按(订单号+父物料号)去重组数(基于筛选结果) */
  219. const groupCount = computed(() => {
  220. const keys = new Set(
  221. filteredRows.value
  222. .filter((r) => r.salesOrderNo && r.parentItemNum)
  223. .map((r) => `${r.salesOrderNo}|${r.parentItemNum}`)
  224. );
  225. return keys.size;
  226. });
  227. // ── Excel 列映射 ──
  228. /** Excel 列名 → ParsedRow key */
  229. const EXCEL_COL_MAP: Record<string, string> = {
  230. '销售订单号': 'salesOrderNo',
  231. '合同号': 'contractNo',
  232. '合同名称': 'contractName',
  233. '合同批次': 'contractBatch',
  234. '产品部': 'productDept',
  235. '行项目号': 'lineItemNo',
  236. '父物料号': 'parentItemNum',
  237. '父物料描述': 'parentDescr',
  238. '工厂': 'factory',
  239. '投产批次': 'productionBatch',
  240. '投产人': 'productionPerson',
  241. '物料单号': 'materialBillNo',
  242. '子物料号': 'itemNum',
  243. '子物料描述': 'descr',
  244. '子件长描述': 'descr1',
  245. '需求数量': 'qtyRequired',
  246. '单位': 'um',
  247. '项目文本': 'projectText',
  248. '电气设计': 'electricalDesigner',
  249. '结构设计': 'structuralDesigner',
  250. '推送日期': 'pushDate',
  251. '采购负责人': 'purchaser',
  252. };
  253. const REQUIRED_EXCEL_COLS = ['销售订单号', '合同号', '父物料号', '子物料号', '需求数量'];
  254. const HEADER_ROW = 1; // 第 2 行(index=1)为表头
  255. /** 创建空行对象 */
  256. function createEmptyRow(): ParsedRow {
  257. const row: ParsedRow = {};
  258. for (const col of TABLE_COLUMNS) row[col.key] = null;
  259. return row;
  260. }
  261. /** 按列类型转换 Excel 单元格值 */
  262. function coerceValue(key: string, raw: any): any {
  263. if (raw == null || raw === '') return null;
  264. const col = TABLE_COLUMNS.find((c) => c.key === key);
  265. if (!col) return String(raw).trim() || null;
  266. if (col.type === 'number') {
  267. const n = typeof raw === 'number' ? raw : parseFloat(String(raw));
  268. return isNaN(n) ? null : n;
  269. }
  270. if (col.type === 'date') {
  271. if (raw instanceof Date && !isNaN(raw.getTime())) {
  272. const d = raw;
  273. const mm = String(d.getMonth() + 1).padStart(2, '0');
  274. const dd = String(d.getDate()).padStart(2, '0');
  275. return `${d.getFullYear()}.${mm}.${dd}`;
  276. }
  277. return String(raw).trim() || null;
  278. }
  279. return String(raw).trim() || null;
  280. }
  281. /** 格式化单元格显示 */
  282. function formatCell(val: any, type: string): string {
  283. if (val == null || val === '') return '—';
  284. if (type === 'number') return String(val);
  285. return String(val);
  286. }
  287. // ── localStorage 持久化 ──
  288. function saveToStorage() {
  289. try {
  290. localStorage.setItem(STORAGE_KEY, JSON.stringify(parsedRows.value));
  291. } catch (e) {
  292. console.warn('保存 BOM 缓存失败', e);
  293. }
  294. }
  295. function clearStorage() {
  296. localStorage.removeItem(STORAGE_KEY);
  297. }
  298. // ── 货源清单规则(物料→决策采购→供应商 映射,localStorage 持久化)──
  299. const RULES_KEY = 'aidop_supply_source_rules_v1';
  300. interface SupplySourceRule {
  301. itemNum: string;
  302. purchaser: string;
  303. supplier: string;
  304. supplierContact?: string;
  305. updatedAt: string;
  306. }
  307. const supplySourceRules = ref<SupplySourceRule[]>([]);
  308. function loadSupplySourceRules() {
  309. try {
  310. const raw = localStorage.getItem(RULES_KEY);
  311. if (raw) supplySourceRules.value = JSON.parse(raw);
  312. } catch { /* ignore */ }
  313. }
  314. function saveSupplySourceRule(itemNum: string, purchaser: string, supplier: string, supplierContact?: string) {
  315. if (!itemNum || !supplier) return;
  316. const rules = supplySourceRules.value;
  317. const existIdx = rules.findIndex((r) => r.itemNum === itemNum && r.purchaser === purchaser);
  318. const rule: SupplySourceRule = { itemNum, purchaser, supplier, supplierContact, updatedAt: new Date().toISOString() };
  319. if (existIdx >= 0) rules[existIdx] = rule;
  320. else rules.push(rule);
  321. supplySourceRules.value = rules;
  322. try { localStorage.setItem(RULES_KEY, JSON.stringify(rules)); } catch { /* ignore */ }
  323. }
  324. function saveAllSupplySourceRules() {
  325. try { localStorage.setItem(RULES_KEY, JSON.stringify(supplySourceRules.value)); } catch { /* ignore */ }
  326. }
  327. /** 自动匹配供应商(本地货源清单规则,同步) */
  328. function autoMatchSupplierLocal(row: ParsedRow) {
  329. if (!row.itemNum) return;
  330. const rules = supplySourceRules.value;
  331. const exact = row.purchaser ? rules.find((r) => r.itemNum === row.itemNum && r.purchaser === row.purchaser) : null;
  332. if (exact) {
  333. if (!row.supplier) row.supplier = exact.supplier;
  334. if (!row.supplierContact && exact.supplierContact) row.supplierContact = exact.supplierContact;
  335. return;
  336. }
  337. const fallback = rules.find((r) => r.itemNum === row.itemNum);
  338. if (fallback && !row.supplier) {
  339. row.supplier = fallback.supplier;
  340. if (!row.supplierContact && fallback.supplierContact) row.supplierContact = fallback.supplierContact;
  341. }
  342. }
  343. /** 自动匹配供应商(调货源清单 API) */
  344. async function autoMatchSourceList(row: ParsedRow) {
  345. autoMatchSupplierLocal(row);
  346. if (!row.itemNum) return;
  347. if (row.supplier && row.unitPrice) return;
  348. try {
  349. const res = await fetchSourceListPage({ number: String(row.itemNum), page: 1, pageSize: 1 });
  350. if (res?.list?.length) {
  351. const item = res.list[0];
  352. if (!row.supplier && item.supplierName) row.supplier = item.supplierName;
  353. if (!row.supplierContact && item.orderRectorName) row.supplierContact = item.orderRectorName;
  354. if (row.unitPrice == null && item.orderPrice != null) row.unitPrice = item.orderPrice;
  355. }
  356. } catch {
  357. /* API 失败静默跳过,本地规则已回填 */
  358. }
  359. }
  360. // ── 可编辑列变更处理 ──
  361. function onCellChange(row: ParsedRow, key: string) {
  362. if (key === 'purchaser') {
  363. const val = String(row.purchaser ?? '').trim();
  364. if (val && !purchaserOptions.value.includes(val)) {
  365. purchaserOptions.value.push(val);
  366. }
  367. autoMatchSupplierLocal(row);
  368. }
  369. if (key === 'decisionStage') {
  370. const val = String(row.decisionStage ?? '').trim();
  371. if (val && !DECISION_STAGE_OPTIONS.value.includes(val)) {
  372. DECISION_STAGE_OPTIONS.value.push(val);
  373. }
  374. }
  375. if (key === 'status') {
  376. const val = String(row.status ?? '').trim();
  377. if (val && !STATUS_OPTIONS.value.includes(val)) {
  378. STATUS_OPTIONS.value.push(val);
  379. }
  380. }
  381. if (key === 'supplier') {
  382. const val = String(row.supplier ?? '').trim();
  383. if (val && !supplierOptions.value.some((s) => s.value === val)) {
  384. supplierOptions.value.unshift({ value: val, label: val });
  385. }
  386. }
  387. if (key === 'supplier' && row.supplier && row.itemNum) {
  388. saveSupplySourceRule(
  389. String(row.itemNum),
  390. String(row.purchaser ?? ''),
  391. String(row.supplier),
  392. row.supplierContact ? String(row.supplierContact) : undefined
  393. );
  394. }
  395. if (key === 'unitPrice' && row.unitPrice != null && row.qtyRequired != null) {
  396. row.totalPrice = Number(row.unitPrice) * Number(row.qtyRequired);
  397. }
  398. saveToStorage();
  399. }
  400. // ── 采购人员选项 ──
  401. const purchaserOptions = ref<string[]>([]);
  402. const purchaserLoading = ref(false);
  403. async function loadPurchaserOptions() {
  404. purchaserLoading.value = true;
  405. try {
  406. const res = await getAPI(SysUserApi).apiSysUserPagePost({ page: 1, pageSize: 500 } as any);
  407. purchaserOptions.value = (res.data.result?.items ?? [])
  408. .map((u: any) => u.realName)
  409. .filter(Boolean);
  410. } catch { /* 静默失败 */ }
  411. finally { purchaserLoading.value = false; }
  412. }
  413. // ── 供应商搜索选项 ──
  414. const supplierOptions = ref<{ value: string; label: string }[]>([]);
  415. const supplierLoading = ref(false);
  416. async function searchSuppliers(keyword: string) {
  417. supplierLoading.value = true;
  418. try {
  419. const res = await fetchSourceListPage({ number: keyword || undefined, page: 1, pageSize: 20 });
  420. supplierOptions.value = (res?.list ?? [])
  421. .filter((s) => s.supplierName)
  422. .map((s) => ({ value: s.supplierName!, label: `${s.supplierName}${s.supplierNumber ? ' | ' + s.supplierNumber : ''}` }));
  423. } catch {
  424. supplierOptions.value = [];
  425. } finally { supplierLoading.value = false; }
  426. }
  427. // ── 页面加载:恢复货源清单规则 + 加载采购人员列表 ──
  428. onMounted(() => {
  429. loadSupplySourceRules();
  430. loadPurchaserOptions();
  431. });
  432. // ── 文件上传解析 ──
  433. function triggerFileInput() {
  434. fileInputRef.value?.click();
  435. }
  436. function onFileChange(e: Event) {
  437. const input = e.target as HTMLInputElement;
  438. const file = input.files?.[0];
  439. if (!file) return;
  440. fileName.value = file.name;
  441. parseError.value = '';
  442. hasSavedData.value = false;
  443. uploading.value = true;
  444. clearStorage(); // 新文件上传清除旧缓存
  445. const reader = new FileReader();
  446. reader.onload = async (ev) => {
  447. try {
  448. const data = new Uint8Array(ev.target!.result as ArrayBuffer);
  449. const wb = XLSX.read(data, { type: 'array', cellDates: true });
  450. const ws = wb.Sheets[wb.SheetNames[0]];
  451. const aoa: any[][] = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
  452. if (!aoa || aoa.length < HEADER_ROW + 1) {
  453. parseError.value = '文件内容为空或行数不足';
  454. return;
  455. }
  456. // 1) 解析表头:按列名匹配
  457. const headerRow = aoa[HEADER_ROW] || [];
  458. const colIdx: Record<string, number> = {};
  459. for (const [excelName, key] of Object.entries(EXCEL_COL_MAP)) {
  460. colIdx[key] = -1;
  461. for (let i = 0; i < headerRow.length; i++) {
  462. if (headerRow[i] != null && String(headerRow[i]).trim() === excelName) {
  463. colIdx[key] = i;
  464. break;
  465. }
  466. }
  467. }
  468. // 校验必需列
  469. const missingRequired = REQUIRED_EXCEL_COLS.filter((n) => {
  470. const key = EXCEL_COL_MAP[n];
  471. return key && colIdx[key] < 0;
  472. });
  473. if (missingRequired.length > 0) {
  474. parseError.value = `Excel 缺少必需字段:${missingRequired.join('、')}`;
  475. return;
  476. }
  477. // 2) 解析数据行 → 38 列完整行对象
  478. const rows: ParsedRow[] = [];
  479. for (let r = HEADER_ROW + 1; r < aoa.length; r++) {
  480. const arr = aoa[r] || [];
  481. // 跳过完全空行
  482. const hasAny = Object.values(colIdx).some((idx) => idx >= 0 && arr[idx] != null && String(arr[idx]).trim() !== '');
  483. if (!hasAny) continue;
  484. const row = createEmptyRow();
  485. for (const [_, key] of Object.entries(EXCEL_COL_MAP)) {
  486. if (colIdx[key] >= 0) {
  487. row[key] = coerceValue(key, arr[colIdx[key]]);
  488. }
  489. }
  490. // 子物料号为空则跳过
  491. if (!row.itemNum) continue;
  492. // 默认状态
  493. if (!row.status) row.status = '待决策';
  494. // 本地规则预匹配(同步,快速回填已有规则)
  495. autoMatchSupplierLocal(row);
  496. rows.push(row);
  497. }
  498. if (rows.length === 0) {
  499. parseError.value = '未发现有效数据行(子物料号为空则跳过)';
  500. return;
  501. }
  502. parsedRows.value = rows;
  503. saveToStorage();
  504. ElMessage.success(`解析完成:${rows.length} 行,正在从货源清单自动匹配供应商…`);
  505. // 异步:按唯一物料号去重调货源清单 API 自动匹配供应商
  506. matchingCancelled.value = false;
  507. const uniqueItemNums = [...new Set(rows.map((r) => String(r.itemNum)).filter(Boolean))];
  508. const total = uniqueItemNums.length;
  509. const CONCURRENCY = 3;
  510. let processed = 0;
  511. let apiMatched = 0;
  512. for (let i = 0; i < total; i += CONCURRENCY) {
  513. if (matchingCancelled.value) {
  514. ElMessage.warning('已取消自动匹配');
  515. break;
  516. }
  517. const batch = uniqueItemNums.slice(i, i + CONCURRENCY);
  518. const results = await Promise.all(
  519. batch.map(async (itemNum) => {
  520. try {
  521. const res = await fetchSourceListPage({ number: itemNum, page: 1, pageSize: 1 });
  522. if (!res?.list?.length) return false;
  523. const item = res.list[0];
  524. let matched = false;
  525. for (const row of rows) {
  526. if (String(row.itemNum) !== itemNum) continue;
  527. if (!row.supplier && item.supplierName) row.supplier = item.supplierName;
  528. if (!row.supplierContact && item.orderRectorName) row.supplierContact = item.orderRectorName;
  529. if (row.unitPrice == null && item.orderPrice != null) row.unitPrice = item.orderPrice;
  530. if (row.supplier) matched = true;
  531. }
  532. return matched;
  533. } catch { return false; }
  534. })
  535. );
  536. apiMatched += results.filter(Boolean).length;
  537. processed += batch.length;
  538. matchingProgress.value = Math.round((processed / total) * 100);
  539. if (i + CONCURRENCY < total) await new Promise((r) => setTimeout(r, 200));
  540. }
  541. matchingProgress.value = 0;
  542. ElMessage.success(`${rows.length} 行解析完成,${apiMatched} 种物料已匹配供应商`);
  543. } catch (err: any) {
  544. parseError.value = `解析失败:${err?.message || err}`;
  545. } finally {
  546. uploading.value = false;
  547. }
  548. };
  549. reader.onerror = () => {
  550. parseError.value = '读取文件失败';
  551. uploading.value = false;
  552. };
  553. reader.readAsArrayBuffer(file);
  554. input.value = '';
  555. }
  556. // ── 清空数据 ──
  557. async function onClearData() {
  558. try {
  559. await ElMessageBox.confirm('确定清空所有已解析/编辑的数据?', '确认清空', {
  560. confirmButtonText: '确定',
  561. cancelButtonText: '取消',
  562. type: 'warning',
  563. });
  564. parsedRows.value = [];
  565. filteredRows.value = [];
  566. clearStorage();
  567. hasSavedData.value = false;
  568. ElMessage.success('数据已清空');
  569. } catch {
  570. /* 用户取消 */
  571. }
  572. }
  573. // ── 确认写入工单 ──
  574. async function onConfirmWrite() {
  575. if (!parsedRows.value.length) return;
  576. // 提取核心 BOM 字段传后端
  577. const items: BomItemDto[] = parsedRows.value.map((r) => ({
  578. itemNum: String(r.itemNum ?? ''),
  579. descr: String(r.descr ?? ''),
  580. descr1: r.descr1 ? String(r.descr1) : undefined,
  581. um: r.um ? String(r.um) : undefined,
  582. qtyRequired: Number(r.qtyRequired) || 0,
  583. op: Math.trunc(Number(r.projectText)) || 0,
  584. salesOrderNo: r.salesOrderNo ? String(r.salesOrderNo) : undefined,
  585. parentItemNum: r.parentItemNum ? String(r.parentItemNum) : undefined,
  586. contractNo: r.contractNo ? String(r.contractNo) : undefined,
  587. parentDescr: r.parentDescr ? String(r.parentDescr) : undefined,
  588. }));
  589. writing.value = true;
  590. try {
  591. // 1) 校验物料存在性
  592. const check = await checkBomMaterials(items);
  593. let createMissing = false;
  594. if (check.missing.length > 0) {
  595. const missingList = check.missing.map((m) => `${m.itemNum}(${m.descr || '—'})`).join('\n');
  596. try {
  597. await ElMessageBox.confirm(
  598. `以下 ${check.missing.length} 个物料在系统中不存在:\n${missingList}\n\n是否记录基础数据并写入?`,
  599. '表格中有物料未记录',
  600. { confirmButtonText: '确认记录并写入', cancelButtonText: '取消', type: 'warning' }
  601. );
  602. createMissing = true;
  603. } catch {
  604. ElMessage.info('已取消,未写入任何数据');
  605. return;
  606. }
  607. }
  608. // 2) 写入
  609. const result = await uploadBom(createMissing, items);
  610. if (result.unmatched.length > 0) {
  611. const lines = result.unmatched.slice(0, 10).map((u) => `· ${u}`);
  612. if (result.unmatched.length > 10) lines.push(`…还有 ${result.unmatched.length - 10} 组`);
  613. ElMessage({
  614. type: 'error',
  615. message: `未匹配到对应销售订单,未写入任何数据\n${lines.join('\n')}`,
  616. duration: 8000,
  617. });
  618. return;
  619. }
  620. ElMessage.success(
  621. `写入成功:匹配工单 ${result.matchedWorkOrders} 个,` +
  622. `新增 ${result.inserted} 行、更新 ${result.updated} 行` +
  623. (result.newItems > 0 ? `、新建物料 ${result.newItems} 条` : '') +
  624. (result.createdOrders > 0 ? `、自动新建订单 ${result.createdOrders} 个` : '') +
  625. (result.createdWorkOrders > 0 ? `、自动新建工单 ${result.createdWorkOrders} 个` : '')
  626. );
  627. // 写入前:将所有有供应商关系的行保存到货源清单规则
  628. for (const row of parsedRows.value) {
  629. if (row.supplier && row.itemNum) {
  630. saveSupplySourceRule(
  631. String(row.itemNum),
  632. String(row.purchaser ?? ''),
  633. String(row.supplier),
  634. row.supplierContact ? String(row.supplierContact) : undefined
  635. );
  636. }
  637. }
  638. saveAllSupplySourceRules();
  639. clearStorage();
  640. goBack();
  641. } catch (err: any) {
  642. ElMessage.error(err?.message || '写入失败');
  643. } finally {
  644. writing.value = false;
  645. }
  646. }
  647. function goBack() {
  648. router.push({ path: '/aidop/s1/workorder-mgmt/dispatch' });
  649. }
  650. </script>
  651. <style scoped lang="scss">
  652. @import '../styles/aidop-demo.scss';
  653. .bom-header {
  654. display: flex;
  655. align-items: center;
  656. gap: 16px;
  657. margin-bottom: 16px;
  658. }
  659. .bom-upload-area {
  660. display: flex;
  661. align-items: center;
  662. gap: 12px;
  663. margin-bottom: 8px;
  664. flex-wrap: wrap;
  665. .file-name {
  666. font-size: 13px;
  667. color: var(--el-text-color-secondary);
  668. }
  669. .parse-error {
  670. font-size: 13px;
  671. color: var(--el-color-danger);
  672. }
  673. .saved-hint {
  674. font-size: 12px;
  675. color: var(--el-color-success);
  676. background: var(--el-color-success-light-9);
  677. padding: 2px 8px;
  678. border-radius: 4px;
  679. }
  680. }
  681. .bom-summary {
  682. font-size: 13px;
  683. color: var(--el-text-color-regular);
  684. margin-bottom: 4px;
  685. b { color: var(--el-color-primary); }
  686. }
  687. .bom-actions {
  688. margin-top: 16px;
  689. display: flex;
  690. gap: 8px;
  691. flex-wrap: wrap;
  692. }
  693. </style>