|
@@ -1,5 +1,5 @@
|
|
|
<template>
|
|
<template>
|
|
|
- <AidopDemoShell :title="pageTitle" subtitle="上传 Excel BOM 并按订单号+物料编号匹配未下达工单写入">
|
|
|
|
|
|
|
+ <AidopDemoShell :title="pageTitle" subtitle="上传 Excel BOM 并按订单号+物料编号匹配未下达工单写入(支持在线编辑)">
|
|
|
<!-- 顶部信息 + 返回 -->
|
|
<!-- 顶部信息 + 返回 -->
|
|
|
<div class="bom-header">
|
|
<div class="bom-header">
|
|
|
<el-button text @click="goBack">
|
|
<el-button text @click="goBack">
|
|
@@ -21,6 +21,7 @@
|
|
|
/>
|
|
/>
|
|
|
<span v-if="fileName" class="file-name">{{ fileName }}</span>
|
|
<span v-if="fileName" class="file-name">{{ fileName }}</span>
|
|
|
<span v-if="parseError" class="parse-error">{{ parseError }}</span>
|
|
<span v-if="parseError" class="parse-error">{{ parseError }}</span>
|
|
|
|
|
+ <span v-if="hasSavedData" class="saved-hint">已加载缓存数据({{ parsedRows.length }} 行)</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- 统计摘要 -->
|
|
<!-- 统计摘要 -->
|
|
@@ -29,63 +30,170 @@
|
|
|
涉及 <b>{{ groupCount }}</b> 个工单(订单号+父物料号组合)
|
|
涉及 <b>{{ groupCount }}</b> 个工单(订单号+父物料号组合)
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- 物料预览表 -->
|
|
|
|
|
|
|
+ <!-- 物料预览表:点击采购列单元格进入编辑 -->
|
|
|
<el-table
|
|
<el-table
|
|
|
- v-if="parsedRows.length > 0"
|
|
|
|
|
:data="parsedRows"
|
|
:data="parsedRows"
|
|
|
border
|
|
border
|
|
|
stripe
|
|
stripe
|
|
|
style="width: 100%; margin-top: 8px"
|
|
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>
|
|
</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>
|
|
<el-button @click="goBack">取消</el-button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <el-empty v-if="!parsedRows.length && !uploading" description="请先上传 BOM Excel 文件(支持含多工单 BOM 的文件)" />
|
|
|
|
|
|
|
+
|
|
|
</AidopDemoShell>
|
|
</AidopDemoShell>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts" name="aidopBusinessWorkOrderBomUpload">
|
|
<script setup lang="ts" name="aidopBusinessWorkOrderBomUpload">
|
|
|
-import { computed, ref } from 'vue';
|
|
|
|
|
|
|
+import { computed, onMounted, ref } from 'vue';
|
|
|
import { useRouter } from 'vue-router';
|
|
import { useRouter } from 'vue-router';
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
|
import { ArrowLeft } from '@element-plus/icons-vue';
|
|
import { ArrowLeft } from '@element-plus/icons-vue';
|
|
|
import AidopDemoShell from '../components/AidopDemoShell.vue';
|
|
import AidopDemoShell from '../components/AidopDemoShell.vue';
|
|
|
import { checkBomMaterials, uploadBom, type BomItemDto } from '../api/workOrderDispatch';
|
|
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';
|
|
import XLSX from 'xlsx-js-style';
|
|
|
|
|
|
|
|
const router = useRouter();
|
|
const router = useRouter();
|
|
|
-
|
|
|
|
|
const pageTitle = '工单 BOM 批量上传';
|
|
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 fileInputRef = ref<HTMLInputElement | null>(null);
|
|
|
const fileName = ref('');
|
|
const fileName = ref('');
|
|
|
const parseError = ref('');
|
|
const parseError = ref('');
|
|
|
const uploading = ref(false);
|
|
const uploading = ref(false);
|
|
|
const writing = 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(() => {
|
|
const groupCount = computed(() => {
|
|
@@ -97,15 +205,199 @@ const groupCount = computed(() => {
|
|
|
return keys.size;
|
|
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() {
|
|
function triggerFileInput() {
|
|
|
fileInputRef.value?.click();
|
|
fileInputRef.value?.click();
|
|
|
}
|
|
}
|
|
@@ -116,10 +408,12 @@ function onFileChange(e: Event) {
|
|
|
if (!file) return;
|
|
if (!file) return;
|
|
|
fileName.value = file.name;
|
|
fileName.value = file.name;
|
|
|
parseError.value = '';
|
|
parseError.value = '';
|
|
|
|
|
+ hasSavedData.value = false;
|
|
|
uploading.value = true;
|
|
uploading.value = true;
|
|
|
|
|
+ clearStorage(); // 新文件上传清除旧缓存
|
|
|
|
|
|
|
|
const reader = new FileReader();
|
|
const reader = new FileReader();
|
|
|
- reader.onload = (ev) => {
|
|
|
|
|
|
|
+ reader.onload = async (ev) => {
|
|
|
try {
|
|
try {
|
|
|
const data = new Uint8Array(ev.target!.result as ArrayBuffer);
|
|
const data = new Uint8Array(ev.target!.result as ArrayBuffer);
|
|
|
const wb = XLSX.read(data, { type: 'array', cellDates: true });
|
|
const wb = XLSX.read(data, { type: 'array', cellDates: true });
|
|
@@ -131,53 +425,52 @@ function onFileChange(e: Event) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 解析表头
|
|
|
|
|
|
|
+ // 1) 解析表头:按列名匹配
|
|
|
const headerRow = aoa[HEADER_ROW] || [];
|
|
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++) {
|
|
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;
|
|
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;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 解析数据行
|
|
|
|
|
|
|
+ // 2) 解析数据行 → 38 列完整行对象
|
|
|
const rows: ParsedRow[] = [];
|
|
const rows: ParsedRow[] = [];
|
|
|
for (let r = HEADER_ROW + 1; r < aoa.length; r++) {
|
|
for (let r = HEADER_ROW + 1; r < aoa.length; r++) {
|
|
|
const arr = aoa[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) {
|
|
if (rows.length === 0) {
|
|
@@ -185,6 +478,18 @@ function onFileChange(e: Event) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
parsedRows.value = rows;
|
|
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) {
|
|
} catch (err: any) {
|
|
|
parseError.value = `解析失败:${err?.message || err}`;
|
|
parseError.value = `解析失败:${err?.message || err}`;
|
|
|
} finally {
|
|
} finally {
|
|
@@ -196,26 +501,41 @@ function onFileChange(e: Event) {
|
|
|
uploading.value = false;
|
|
uploading.value = false;
|
|
|
};
|
|
};
|
|
|
reader.readAsArrayBuffer(file);
|
|
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() {
|
|
async function onConfirmWrite() {
|
|
|
if (!parsedRows.value.length) return;
|
|
if (!parsedRows.value.length) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 提取核心 BOM 字段传后端
|
|
|
const items: BomItemDto[] = parsedRows.value.map((r) => ({
|
|
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;
|
|
writing.value = true;
|
|
@@ -234,7 +554,6 @@ async function onConfirmWrite() {
|
|
|
);
|
|
);
|
|
|
createMissing = true;
|
|
createMissing = true;
|
|
|
} catch {
|
|
} catch {
|
|
|
- // 用户点取消
|
|
|
|
|
ElMessage.info('已取消,未写入任何数据');
|
|
ElMessage.info('已取消,未写入任何数据');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
@@ -243,7 +562,6 @@ async function onConfirmWrite() {
|
|
|
// 2) 写入
|
|
// 2) 写入
|
|
|
const result = await uploadBom(createMissing, items);
|
|
const result = await uploadBom(createMissing, items);
|
|
|
|
|
|
|
|
- // 未匹配到对应销售订单 → 展示错误提示,不跳转
|
|
|
|
|
if (result.unmatched.length > 0) {
|
|
if (result.unmatched.length > 0) {
|
|
|
const lines = result.unmatched.slice(0, 10).map((u) => `· ${u}`);
|
|
const lines = result.unmatched.slice(0, 10).map((u) => `· ${u}`);
|
|
|
if (result.unmatched.length > 10) lines.push(`…还有 ${result.unmatched.length - 10} 组`);
|
|
if (result.unmatched.length > 10) lines.push(`…还有 ${result.unmatched.length - 10} 组`);
|
|
@@ -260,6 +578,20 @@ async function onConfirmWrite() {
|
|
|
`新增 ${result.inserted} 行、更新 ${result.updated} 行` +
|
|
`新增 ${result.inserted} 行、更新 ${result.updated} 行` +
|
|
|
(result.newItems > 0 ? `、新建物料 ${result.newItems} 条` : '')
|
|
(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();
|
|
goBack();
|
|
|
} catch (err: any) {
|
|
} catch (err: any) {
|
|
|
ElMessage.error(err?.message || '写入失败');
|
|
ElMessage.error(err?.message || '写入失败');
|
|
@@ -288,6 +620,7 @@ function goBack() {
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
gap: 12px;
|
|
gap: 12px;
|
|
|
margin-bottom: 8px;
|
|
margin-bottom: 8px;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
|
|
|
.file-name {
|
|
.file-name {
|
|
|
font-size: 13px;
|
|
font-size: 13px;
|
|
@@ -297,6 +630,13 @@ function goBack() {
|
|
|
font-size: 13px;
|
|
font-size: 13px;
|
|
|
color: var(--el-color-danger);
|
|
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 {
|
|
.bom-summary {
|
|
@@ -310,5 +650,19 @@ function goBack() {
|
|
|
margin-top: 16px;
|
|
margin-top: 16px;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
gap: 8px;
|
|
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>
|
|
</style>
|