| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751 |
- <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>
- <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="filteredRows.length > 0" class="bom-summary">
- 共 <b>{{ filteredRows.length }}</b> 行,
- 涉及 <b>{{ groupCount }}</b> 个工单(订单号+父物料号组合)
- </div>
- <!-- 物料预览表:点击采购列单元格进入编辑 -->
- <el-table
- :data="pageRows"
- border
- stripe
- style="width: 100%; margin-top: 8px"
- max-height="520"
- size="small"
- >
- <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, $index }">
- <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.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 v-if="matchingProgress > 0 && matchingProgress < 100" style="margin-top: 4px">
- <el-progress :percentage="matchingProgress" :stroke-width="10" :text-inside="true" />
- <el-button size="small" text type="danger" @click="matchingCancelled = true" style="margin-top: 4px">取消匹配</el-button>
- </div>
- <!-- 操作按钮 -->
- <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>
- </AidopDemoShell>
- </template>
- <script setup lang="ts" name="aidopBusinessWorkOrderBomUpload">
- 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 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';
- 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 = ref(['待决策', '已发资料', '集采再询价', '竞价或扩额', '替代采购', '已维护价格']);
- const STATUS_OPTIONS = ref(['待决策', '已下订单', '已通知送货', '已部分到货', '已完成', '已删除', '被替代']);
- // ── 数据状态 ──
- 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 matchingCancelled = ref(false);
- const matchingProgress = ref(0);
- // ── 分页 ──
- const PAGE_SIZE = 200;
- const currentPage = ref(1);
- /** 筛选后的行(由条件筛选区驱动;写入工单仍用全量 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 filteredRows.value.slice(start, start + PAGE_SIZE);
- });
- function pageIndex(i: number): number {
- return (currentPage.value - 1) * PAGE_SIZE + i + 1;
- }
- function onFilterChange(list: ParsedRow[]) {
- filteredRows.value = list;
- currentPage.value = 1;
- }
- /** 按(订单号+父物料号)去重组数(基于筛选结果) */
- const groupCount = computed(() => {
- const keys = new Set(
- filteredRows.value
- .filter((r) => r.salesOrderNo && r.parentItemNum)
- .map((r) => `${r.salesOrderNo}|${r.parentItemNum}`)
- );
- return keys.size;
- });
- // ── Excel 列映射 ──
- /** Excel 列名 → ParsedRow key */
- const EXCEL_COL_MAP: Record<string, string> = {
- '销售订单号': 'salesOrderNo',
- '合同号': 'contractNo',
- '合同名称': 'contractName',
- '合同批次': 'contractBatch',
- '产品部': 'productDept',
- '行项目号': 'lineItemNo',
- '父物料号': 'parentItemNum',
- '父物料描述': 'parentDescr',
- '工厂': 'factory',
- '投产批次': 'productionBatch',
- '投产人': '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') {
- 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);
- }
- 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();
- }
- function onFileChange(e: Event) {
- const input = e.target as HTMLInputElement;
- const file = input.files?.[0];
- if (!file) return;
- fileName.value = file.name;
- parseError.value = '';
- hasSavedData.value = false;
- uploading.value = true;
- clearStorage(); // 新文件上传清除旧缓存
- const reader = new FileReader();
- reader.onload = async (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;
- }
- // 1) 解析表头:按列名匹配
- const headerRow = aoa[HEADER_ROW] || [];
- 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() === excelName) {
- colIdx[key] = i;
- break;
- }
- }
- }
- // 校验必需列
- 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 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) {
- parseError.value = '未发现有效数据行(子物料号为空则跳过)';
- return;
- }
- parsedRows.value = rows;
- saveToStorage();
- ElMessage.success(`解析完成:${rows.length} 行,正在从货源清单自动匹配供应商…`);
- // 异步:按唯一物料号去重调货源清单 API 自动匹配供应商
- matchingCancelled.value = false;
- const uniqueItemNums = [...new Set(rows.map((r) => String(r.itemNum)).filter(Boolean))];
- const total = uniqueItemNums.length;
- const CONCURRENCY = 3;
- let processed = 0;
- let apiMatched = 0;
- for (let i = 0; i < total; i += CONCURRENCY) {
- if (matchingCancelled.value) {
- ElMessage.warning('已取消自动匹配');
- break;
- }
- const batch = uniqueItemNums.slice(i, i + CONCURRENCY);
- const results = await Promise.all(
- batch.map(async (itemNum) => {
- try {
- const res = await fetchSourceListPage({ number: itemNum, page: 1, pageSize: 1 });
- if (!res?.list?.length) return false;
- const item = res.list[0];
- let matched = false;
- for (const row of rows) {
- if (String(row.itemNum) !== itemNum) continue;
- 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;
- if (row.supplier) matched = true;
- }
- return matched;
- } catch { return false; }
- })
- );
- apiMatched += results.filter(Boolean).length;
- processed += batch.length;
- matchingProgress.value = Math.round((processed / total) * 100);
- if (i + CONCURRENCY < total) await new Promise((r) => setTimeout(r, 200));
- }
- matchingProgress.value = 0;
- ElMessage.success(`${rows.length} 行解析完成,${apiMatched} 种物料已匹配供应商`);
- } catch (err: any) {
- parseError.value = `解析失败:${err?.message || err}`;
- } finally {
- uploading.value = false;
- }
- };
- reader.onerror = () => {
- parseError.value = '读取文件失败';
- uploading.value = false;
- };
- reader.readAsArrayBuffer(file);
- input.value = '';
- }
- // ── 清空数据 ──
- async function onClearData() {
- try {
- await ElMessageBox.confirm('确定清空所有已解析/编辑的数据?', '确认清空', {
- confirmButtonText: '确定',
- cancelButtonText: '取消',
- type: 'warning',
- });
- parsedRows.value = [];
- filteredRows.value = [];
- clearStorage();
- hasSavedData.value = false;
- ElMessage.success('数据已清空');
- } catch {
- /* 用户取消 */
- }
- }
- // ── 确认写入工单 ──
- async function onConfirmWrite() {
- if (!parsedRows.value.length) return;
- // 提取核心 BOM 字段传后端
- const items: BomItemDto[] = parsedRows.value.map((r) => ({
- 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: 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;
- 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} 条` : '') +
- (result.createdOrders > 0 ? `、自动新建订单 ${result.createdOrders} 个` : '') +
- (result.createdWorkOrders > 0 ? `、自动新建工单 ${result.createdWorkOrders} 个` : '')
- );
- // 写入前:将所有有供应商关系的行保存到货源清单规则
- 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 || '写入失败');
- } 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;
- flex-wrap: wrap;
- .file-name {
- font-size: 13px;
- color: var(--el-text-color-secondary);
- }
- .parse-error {
- 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 {
- 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>
|