Bläddra i källkod

fix:984【企业运营】营销管理的报价单支持新建导入bom清单查询物料价格

mlchen 4 dagar sedan
förälder
incheckning
b654b5c8b6

BIN
public/importTemplate/BOM导入模板.xls


+ 26 - 1
src/BIZComponents/inventoryTable.vue

@@ -590,6 +590,8 @@
 
       <!-- 操作列 -->
       <template v-slot:action="scope">
+        <!-- 行内自定义操作:父组件传了 rowActions 插槽才渲染(报价单审批用 BOM导入/BOM详情) -->
+        <slot name="rowActions" :row="scope.row" :index="scope.$index"></slot>
         <el-popconfirm
           class="ele-action"
           title="确定要删除吗?"
@@ -649,6 +651,11 @@
       ProductionVersion
     },
     props: {
+      // 操作列宽度:传了 rowActions 插槽(如报价单审批的 BOM导入/BOM详情)时可放宽,不传沿用 120
+      rowActionWidth: {
+        type: Number,
+        default: 120
+      },
       pageName: {
         default: '',
         type: String
@@ -1155,7 +1162,7 @@
           {
             columnKey: 'action',
             label: '操作',
-            width: 120,
+            width: this.rowActionWidth,
             align: 'center',
             resizable: false,
             slot: 'action',
@@ -1437,12 +1444,26 @@
       },
       //选择产品回调
       changeParent(obj, idx) {
+        let bomCleared = false;
         obj.forEach((item, index) => {
           let i = idx == -1 ? index : idx;
           let row = JSON.parse(JSON.stringify(this.defaultForm));
           row.key = this.form.datasource.length + 1;
           let parasm = idx == -1 ? row : this.form.datasource[i];
 
+          // 切换产品:原 BOM 属于已换掉的产品,必须清空;
+          // 单价若是由 BOM导入回填的(该行有 bomList),一并归零,避免新产品沿用旧成本价
+          const hadBom =
+            Array.isArray(parasm.bomList) && parasm.bomList.length > 0;
+          if (hadBom) {
+            bomCleared = true;
+            this.$set(parasm, 'singlePrice', 0);
+            this.$set(parasm, 'notaxSinglePrice', 0);
+            this.$set(parasm, 'totalPrice', 0);
+          }
+          this.$set(parasm, 'bomList', []);
+          this.$set(parasm, 'rowKey', '');
+
           this.$set(parasm, 'productId', item.id);
           this.$set(parasm, 'categoryName', item.name);
           this.$set(parasm, 'productCategoryId', item.categoryLevelId);
@@ -1497,6 +1518,10 @@
             this.form.datasource.push(row);
           }
         });
+        // 清过 BOM 的行单价被归零,总计要跟着重算
+        if (bomCleared) {
+          this.changeAll();
+        }
       },
       remove(index) {
         this.form.datasource.splice(index, 1);

+ 78 - 2
src/views/bpm/handleTask/components/quotation/addDialog.vue

@@ -260,7 +260,30 @@
         :isGuaranteePeriod="false"
         :isDiscount="false"
         :is-produce-delivery-deadline="isProduceDeliveryDeadlineRequired"
-      ></inventoryTable>
+        :rowActionWidth="270"
+      >
+        <!-- 行内操作:BOM导入 / BOM详情(仅报价单审批传,其它单据不受影响) -->
+        <template v-slot:rowActions="{ row, index }">
+          <el-link
+            type="primary"
+            :underline="false"
+            icon="el-icon-upload2"
+            @click.native="openBomImport(row, index)"
+          >
+            BOM导入
+          </el-link>
+          <el-link
+            type="primary"
+            :underline="false"
+            icon="el-icon-document"
+            @click.native="openBomDetail(row)"
+          >
+            BOM详情<span v-if="bomCount(row)">({{ bomCount(row) }})</span>
+          </el-link>
+        </template>
+      </inventoryTable>
+      <bomImportDialog ref="bomImportDialogRef" @success="handleBomImported"></bomImportDialog>
+      <bomDetailDialog ref="bomDetailDialogRef"></bomDetailDialog>
       <!-- <el-row style="margin-top: 20px">
         <el-col :span="6">
           <el-form-item prop="taxRate" label="税率">
@@ -345,6 +368,9 @@
   } from '@/api/bpm/components/saleManage/businessOpportunity';
   import { copyObj } from '@/utils/util';
   import { getFile } from '@/api/system/file';
+  import bomImportDialog from './bomImportDialog.vue';
+  import bomDetailDialog from './bomDetailDialog.vue';
+  import { buildRowKey } from './bom';
   // import fileMain from "@/components/addDoc/index.vue";
 
   export default {
@@ -354,7 +380,9 @@
       fileUpload,
       inventoryTable,
       parentList,
-      headList
+      headList,
+      bomImportDialog,
+      bomDetailDialog
     },
     props: {
       businessId: {
@@ -498,6 +526,54 @@
       this.getDetailData(this.businessId);
     },
     methods: {
+      /* ===== BOM导入 / BOM详情(销售员补充、技术员等可编辑节点) ===== */
+      bomCount(row) {
+        return row && Array.isArray(row.bomList) ? row.bomList.length : 0;
+      },
+      // 物品清单行稳定键:已落库行用明细行id,新增行生成临时键(后端按 row_key 记录,仅排查用)
+      bomRowKey(row, index) {
+        if (!row.rowKey) {
+          this.$set(row, 'rowKey', buildRowKey(row, index));
+        }
+        return row.rowKey;
+      },
+      openBomImport(row, index) {
+        this.$refs.bomImportDialogRef.open({
+          row: row,
+          index: index,
+          quoteId: this.form.id,
+          rowKey: this.bomRowKey(row, index)
+        });
+      },
+      openBomDetail(row) {
+        this.$refs.bomDetailDialogRef.open({ row: row });
+      },
+      // 导入成功:BOM 挂到清单行随报价单一起提交,累计价格回填到该行单价并重算总计
+      handleBomImported(payload) {
+        const row = (payload && payload.row) || {};
+        const price = (payload && payload.price) || {};
+        const bomList = (payload && payload.bomList) || [];
+        this.$set(row, 'bomList', bomList);
+        this.$set(row, 'singlePrice', price.singlePrice);
+        this.$set(row, 'notaxSinglePrice', price.notaxSinglePrice);
+        if (price.taxRate !== null && price.taxRate !== undefined) {
+          this.$set(row, 'taxRate', price.taxRate);
+        }
+        const count = Number(row.totalCount) || 0;
+        const weight = Number(row.singleWeight) || 0;
+        // 与清单表口径一致:按数量计价 单价×数量;按重量计价 单价×数量×单重
+        const rowAmount =
+          Number(row.pricingWay) === 2
+            ? price.singlePrice * count * weight
+            : price.singlePrice * count;
+        this.$set(row, 'totalPrice', Number(rowAmount.toFixed(4)));
+        this.$nextTick(() => {
+          const table = this.$refs.inventoryTable;
+          if (table && typeof table.changeAll === 'function') {
+            table.changeAll();
+          }
+        });
+      },
       opportunityTypeChange() {
         if (this.form.opportunityType === 0) {
           this.form.opportunityId = '';

+ 362 - 0
src/views/bpm/handleTask/components/quotation/bom.js

@@ -0,0 +1,362 @@
+/**
+ * 报价单「BOM导入」纯逻辑:模板列定义、模版解析、取价规则、价格计算。
+ * 不依赖组件上下文(无 $/axios/window),便于单测与复用。
+ *
+ * 取价规则(需求原文):
+ *  1. 按物料编码(物品编码)调用 eom/goods/page 查出该物品的价格;
+ *  2. 价格查不到按 0;
+ *  3. 多个价格取接口返回的第一条;没有上架(t_eom_goods.goods_status != 1)
+ *     或该商品没有设置任何价格,一律按 0。
+ */
+
+/* 模版文件(放在 public/importTemplate 下,与项目其它导入模版同目录) */
+export const BOM_TEMPLATE_NAME = 'BOM导入模板';
+export const BOM_TEMPLATE_URL =
+  (process.env.BASE_URL || '/') + 'importTemplate/BOM导入模板.xls';
+
+/* 价格与金额统一保留 4 位小数,与后端 decimal(15,4)/decimal(18,4) 对齐 */
+export const BOM_PRICE_SCALE = 4;
+
+/*
+ * 与「BOM导入模板.xls」第 2 行表头一一对应;第 1 行是说明行,解析时自动跳过。
+ * 文字列用 minWidth(宽度不足时由表格均分剩余空间,避免右侧留白),
+ * 数字/标签列用固定 width;全部对齐方式与系统其它表格一致(居中 + 超出省略提示)。
+ */
+const textColumn = (prop, label, minWidth) => ({
+  prop: prop,
+  label: label,
+  minWidth: minWidth,
+  align: 'center',
+  showOverflowTooltip: true
+});
+
+const numberColumn = (prop, label, width) => ({
+  prop: prop,
+  label: label,
+  width: width,
+  align: 'center',
+  showOverflowTooltip: true
+});
+
+export const BOM_TEMPLATE_COLUMNS = [
+  textColumn('bomCategory', '物料分类', 180),
+  numberColumn('bomLevel', '级次', 70),
+  textColumn('parentMaterialCode', '父级物料编码', 140),
+  textColumn('materialCode', '物料编码', 150),
+  numberColumn('dosage', '用量', 80),
+  textColumn('name', '名称', 160),
+  textColumn('specification', '规格', 160),
+  textColumn('supplierInfo', '供应商信息', 130)
+];
+
+/* 详情弹窗展示列 = 模版列 + 价格列 */
+export const BOM_DETAIL_COLUMNS = BOM_TEMPLATE_COLUMNS.concat([
+  numberColumn('quantity', '数量', 90),
+  textColumn('taxPrice', '含税单价', 110),
+  numberColumn('taxRate', '税率(%)', 90),
+  textColumn('notaxPrice', '不含税单价', 120),
+  textColumn('totalPrice', '总价', 110)
+]);
+
+/* 模版必填列(带 * 的列),供应商信息非必填 */
+const REQUIRED_PROPS = [
+  'bomCategory',
+  'bomLevel',
+  'parentMaterialCode',
+  'materialCode',
+  'dosage',
+  'name',
+  'specification'
+];
+
+/*
+ * 模版自带的示例 BOM(示例树:产品-工程机械行业-顺新产品 / 物料-顺新物料)。
+ * 只是填写示例,不属于真实业务数据,导入时必须过滤掉。
+ * 判定要求「分类与编码同时命中」,避免误伤真实数据里同编码的物料。
+ */
+export const BOM_TEMPLATE_DEMO_CATEGORIES = ['产品-工程机械行业-顺新产品', '物料-顺新物料'];
+export const BOM_TEMPLATE_DEMO_CODES = [
+  'TQWV5-00-00',
+  'PZM-1500-00',
+  'PZM-1500-01',
+  'PZM-1500-01-03',
+  'PZM-1500-01-01',
+  'PZM-1500-01-04',
+  'PZM-1500-01-05'
+];
+
+/** 是否为模版自带的示例行 */
+export function isTemplateDemoLine(line) {
+  if (!line) return false;
+  const category = normalizeCell(line.bomCategory);
+  const code = normalizeCell(line.materialCode);
+  if (BOM_TEMPLATE_DEMO_CODES.indexOf(code) < 0) return false;
+  return BOM_TEMPLATE_DEMO_CATEGORIES.some(
+    (demoCategory) => category === demoCategory || category.indexOf(demoCategory + '-') === 0
+  );
+}
+
+/* 空价格:查不到商品 / 商品未上架 / 商品无价格,一律按 0 参与计算 */
+export const EMPTY_PRICE = {
+  goodsId: null,
+  goodsPriceId: null,
+  priceType: '',
+  taxPrice: 0,
+  taxRate: null,
+  notaxPrice: 0,
+  shelfStatus: 0
+};
+
+export function toNumber(value, fallback) {
+  if (value === null || value === undefined || value === '') {
+    return fallback === undefined ? 0 : fallback;
+  }
+  const num = Number(value);
+  return Number.isFinite(num) ? num : fallback === undefined ? 0 : fallback;
+}
+
+export function round(value, digits) {
+  const scale = digits === undefined ? BOM_PRICE_SCALE : digits;
+  const num = toNumber(value, 0);
+  const factor = Math.pow(10, scale);
+  return Math.round((num + Number.EPSILON) * factor) / factor;
+}
+
+function normalizeCell(cell) {
+  return String(cell === null || cell === undefined ? '' : cell).trim();
+}
+
+/**
+ * 解析模版二维数组。
+ * @param {Array<Array>} matrix xlsx 读出的二维数组(header:1)
+ * @returns {{lines: Array, errors: Array<string>}}
+ */
+export function parseBomRows(matrix) {
+  const rows = Array.isArray(matrix) ? matrix : [];
+  const headerIndex = rows.findIndex((row) =>
+    (Array.isArray(row) ? row : []).some((cell) => normalizeCell(cell).indexOf('物料编码') > -1)
+  );
+  if (headerIndex < 0) {
+    return { lines: [], errors: ['未识别到表头行,请使用标准「BOM导入模板」'] };
+  }
+  const header = (Array.isArray(rows[headerIndex]) ? rows[headerIndex] : []).map((cell) =>
+    normalizeCell(cell).replace(/^\*+/, '')
+  );
+  const columns = BOM_TEMPLATE_COLUMNS.map((column) => ({
+    prop: column.prop,
+    label: column.label,
+    index: header.indexOf(column.label)
+  }));
+  const missing = columns
+    .filter((column) => REQUIRED_PROPS.indexOf(column.prop) > -1 && column.index < 0)
+    .map((column) => column.label);
+  if (missing.length) {
+    return { lines: [], errors: ['模版缺少必要列:' + missing.join('、')] };
+  }
+
+  const lines = [];
+  const errors = [];
+  const skipped = [];
+  for (let i = headerIndex + 1; i < rows.length; i++) {
+    const row = Array.isArray(rows[i]) ? rows[i] : [];
+    const rowNo = i + 1;
+    if (row.every((cell) => !normalizeCell(cell))) {
+      continue;
+    }
+    const cellOf = (prop) => {
+      const column = columns.find((item) => item.prop === prop);
+      return column && column.index > -1 ? row[column.index] : '';
+    };
+    const materialCode = normalizeCell(cellOf('materialCode'));
+    if (!materialCode) {
+      // 有内容却缺物料编码:属填写问题,明确指出是哪一行
+      errors.push('第' + rowNo + '行:物料编码不能为空');
+      continue;
+    }
+    const line = {
+      sort: lines.length + 1,
+      bomCategory: normalizeCell(cellOf('bomCategory')),
+      bomLevel: toNumber(cellOf('bomLevel'), 1),
+      parentMaterialCode: normalizeCell(cellOf('parentMaterialCode')),
+      materialCode: materialCode,
+      dosage: toNumber(cellOf('dosage'), 0),
+      name: normalizeCell(cellOf('name')),
+      specification: normalizeCell(cellOf('specification')),
+      supplierInfo: normalizeCell(cellOf('supplierInfo'))
+    };
+    if (isTemplateDemoLine(line)) {
+      // 模版自带的填写示例(如 产品-工程机械行业-顺新产品 / TQWV5-00-00 多功能提取罐)
+      // 静默丢弃,不打扰用户
+      skipped.push({ rowNo: rowNo, reason: '模版示例行' });
+      continue;
+    }
+    if (!line.name) {
+      errors.push('第' + rowNo + '行:名称不能为空');
+      continue;
+    }
+    lines.push(line);
+  }
+  if (!lines.length && !errors.length) {
+    errors.push('模版中没有可导入的数据行');
+  }
+  return { lines: lines, errors: errors, skipped: skipped };
+}
+
+/* 物料编码与商品编码 / 物品编码(categoryInfo.code)任一相等即命中 */
+function goodsMatchesCode(goods, code) {
+  if (!goods) return false;
+  if (normalizeCell(goods.goodsCode) === code) return true;
+  const categoryInfo = goods.categoryInfo;
+  return !!categoryInfo && normalizeCell(categoryInfo.code) === code;
+}
+
+/**
+ * 用一个物品编码取出单价。
+ * 候选 = 命中该编码且已上架(goodsStatus === 1)的商品下的全部价格;
+ * 多个价格按接口返回顺序取第一条(需求:有多个价格就取第一条)。
+ * 未命中 / 未上架 / 未设置价格 -> EMPTY_PRICE(各项 0),流程不报错。
+ */
+export function pickGoodsPrice(goodsList, materialCode) {
+  const code = normalizeCell(materialCode);
+  if (!code || !Array.isArray(goodsList) || !goodsList.length) {
+    return Object.assign({}, EMPTY_PRICE);
+  }
+  const matched = goodsList.filter((goods) => goodsMatchesCode(goods, code));
+  if (!matched.length) return Object.assign({}, EMPTY_PRICE);
+  const onShelf = matched.filter((goods) => Number(goods.goodsStatus) === 1);
+  if (!onShelf.length) return Object.assign({}, EMPTY_PRICE);
+
+  const candidates = [];
+  onShelf.forEach((goods) => {
+    (goods.goodsPriceList || []).forEach((price) => {
+      candidates.push({ goods: goods, price: price });
+    });
+  });
+  if (!candidates.length) return Object.assign({}, EMPTY_PRICE);
+
+  const picked = candidates[0];
+  const taxPrice = round(toNumber(picked.price.unitPrice, 0));
+  const taxRate =
+    picked.price.taxRate === null || picked.price.taxRate === undefined
+      ? null
+      : round(picked.price.taxRate, 2);
+  const notaxFromPrice =
+    picked.price.excludeTaxPrice === null || picked.price.excludeTaxPrice === undefined
+      ? null
+      : toNumber(picked.price.excludeTaxPrice, 0);
+  return {
+    goodsId: picked.goods.id === undefined ? null : picked.goods.id,
+    goodsPriceId: picked.price.id === undefined ? null : picked.price.id,
+    priceType: picked.price.priceType || '',
+    taxPrice: taxPrice,
+    taxRate: taxRate,
+    notaxPrice: round(
+      notaxFromPrice === null ? taxPrice / (1 + toNumber(taxRate, 0) / 100) : notaxFromPrice
+    ),
+    shelfStatus: 1
+  };
+}
+
+/**
+ * 用量 -> 数量:数量 = 用量 × 物品清单行数量(明细数量为空或 0 时按 1 计)。
+ */
+export function calcBomQuantity(dosage, lineCount) {
+  const count = toNumber(lineCount, 0);
+  const base = count > 0 ? count : 1;
+  return round(toNumber(dosage, 0) * base);
+}
+
+/**
+ * 生成入库用的 BOM 明细行(模版字段 + 价格字段 + 关联字段)。
+ * @param {Array} templateLines parseBomRows().lines
+ * @param {{quantity: number, priceMap: Object, quoteId: (string|number), quoteProductId: (string|number), rowKey: string}} ctx
+ */
+export function buildBomLines(templateLines, ctx) {
+  const options = ctx || {};
+  const priceMap = options.priceMap || {};
+  return (templateLines || []).map((line) => {
+    const price = priceMap[line.materialCode] || EMPTY_PRICE;
+    const quantity = calcBomQuantity(line.dosage, options.quantity);
+    const taxPrice = round(toNumber(price.taxPrice, 0));
+    return Object.assign({}, line, {
+      quoteId: options.quoteId || 0,
+      quoteProductId: options.quoteProductId || 0,
+      rowKey: options.rowKey || '',
+      quantity: quantity,
+      taxPrice: taxPrice,
+      taxRate: price.taxRate === undefined ? null : price.taxRate,
+      notaxPrice: round(toNumber(price.notaxPrice, 0)),
+      totalPrice: round(taxPrice * quantity),
+      goodsId: price.goodsId === undefined ? null : price.goodsId,
+      goodsPriceId: price.goodsPriceId === undefined ? null : price.goodsPriceId,
+      shelfStatus: toNumber(price.shelfStatus, 0)
+    });
+  });
+}
+
+/* BOM 合计总价 = Σ(含税单价 × 数量) */
+export function sumBomTotalPrice(bomList) {
+  return round(
+    (bomList || []).reduce((total, item) => total + toNumber(item.totalPrice, 0), 0)
+  );
+}
+
+/**
+ * 回填到物品清单行的单价:单位成本 = Σ(含税单价 × 用量)。
+ * 与 BOM 行 quantity/totalPrice 同源,保证「单价 × 明细数量」等于「Σ BOM 总价」。
+ */
+export function sumBomUnitPrice(bomList) {
+  return round(
+    (bomList || []).reduce(
+      (total, item) => total + toNumber(item.taxPrice, 0) * toNumber(item.dosage, 0),
+      0
+    )
+  );
+}
+
+/* 从 BOM 行中取一个税率回填明细行(多个时取第一个非空) */
+export function pickBomTaxRate(bomList) {
+  const found = (bomList || []).find(
+    (item) => item.taxRate !== null && item.taxRate !== undefined
+  );
+  return found ? found.taxRate : null;
+}
+
+/**
+ * 物品清单行的回填价格:含税单价、不含税单价、税率。
+ */
+export function buildRowPrice(bomList) {
+  const singlePrice = sumBomUnitPrice(bomList);
+  const taxRate = pickBomTaxRate(bomList);
+  const notaxSinglePrice =
+    taxRate === null ? singlePrice : round(singlePrice / (1 + toNumber(taxRate, 0) / 100), 2);
+  return {
+    singlePrice: singlePrice,
+    notaxSinglePrice: notaxSinglePrice,
+    taxRate: taxRate
+  };
+}
+
+/**
+ * 物品清单行的稳定键:已落库行用明细行 id,新增行用前端生成的 uuid。
+ * 服务端以 row_key 做「同一明细数据」的判重与覆盖依据。
+ */
+export function buildRowKey(row, index) {
+  if (row && row.id !== undefined && row.id !== null && row.id !== '') {
+    return String(row.id);
+  }
+  const seed = [Date.now(), index, Math.random()].join('-');
+  return 'tmp-' + seed.replace(/[^0-9a-zA-Z-]/g, '').slice(0, 48);
+}
+
+/**
+ * 分片:按物料编码查价时控制并发,避免一次性打爆 eom/goods/page。
+ */
+export function chunkList(list, size) {
+  const step = size > 0 ? size : 1;
+  const chunks = [];
+  for (let i = 0; i < (list || []).length; i += step) {
+    chunks.push(list.slice(i, i + step));
+  }
+  return chunks;
+}

+ 143 - 0
src/views/bpm/handleTask/components/quotation/bomDetailDialog.vue

@@ -0,0 +1,143 @@
+<template>
+  <el-dialog
+    :title="dialogTitle"
+    :visible.sync="dialogVisible"
+    :close-on-click-modal="false"
+    append-to-body
+    width="80%"
+    top="5vh"
+    custom-class="ele-dialog-form"
+    @close="close"
+  >
+    <el-card shadow="never">
+      <el-alert
+        v-if="!bomList.length"
+        type="info"
+        :closable="false"
+        show-icon
+        title="该明细行暂无 BOM 数据"
+      />
+
+      <ele-pro-table
+        v-else
+        ref="table"
+        :need-page="false"
+        :columns="columns"
+        :datasource="bomList"
+        row-key="materialCode"
+        max-height="420px"
+      >
+        <template v-slot:shelf="{ row: item }">
+          <el-tag v-if="Number(item.shelfStatus) === 1" type="success" size="small">
+            已上架
+          </el-tag>
+          <el-tag v-else type="info" size="small">按0</el-tag>
+        </template>
+      </ele-pro-table>
+
+      <div class="bom-summary" v-if="bomList.length">
+        <span class="amount">数量:{{ toNumber(row.totalCount, 0) }}</span>
+        <span class="amount">单价:{{ formatAmount(row.singlePrice) }}元</span>
+        <span class="amount">BOM条数:{{ bomList.length }}</span>
+        <span class="amount">总计:{{ formatAmount(sumTotal) }}元</span>
+      </div>
+    </el-card>
+
+    <div slot="footer" class="dialog-footer">
+      <el-button size="small" @click="close">关 闭</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+  /* 报价单审批页:查看某条物品清单行已导入的 BOM 详情(只读,与 eom 报价单 BOM详情 同版式) */
+  const textColumn = (prop, label, minWidth) => ({
+    prop: prop,
+    label: label,
+    minWidth: minWidth,
+    align: 'center',
+    showOverflowTooltip: true
+  });
+
+  const numberColumn = (prop, label, width) => ({
+    prop: prop,
+    label: label,
+    width: width,
+    align: 'center',
+    showOverflowTooltip: true
+  });
+
+  export default {
+    name: 'QuoteBomDetailDialog',
+    data() {
+      return {
+        dialogVisible: false,
+        row: {},
+        bomList: [],
+        columns: [
+          textColumn('bomCategory', '物料分类', 180),
+          numberColumn('bomLevel', '级次', 70),
+          textColumn('parentMaterialCode', '父级物料编码', 140),
+          textColumn('materialCode', '物料编码', 150),
+          numberColumn('dosage', '用量', 80),
+          textColumn('name', '名称', 160),
+          textColumn('specification', '规格', 160),
+          textColumn('supplierInfo', '供应商信息', 130),
+          numberColumn('quantity', '数量', 90),
+          textColumn('taxPrice', '含税单价', 110),
+          numberColumn('taxRate', '税率(%)', 90),
+          textColumn('notaxPrice', '不含税单价', 120),
+          textColumn('totalPrice', '总价', 110),
+          { columnKey: 'shelf', label: '状态', width: 90, align: 'center', slot: 'shelf' }
+        ]
+      };
+    },
+    computed: {
+      dialogTitle() {
+        const name = this.row && this.row.productName ? this.row.productName : '';
+        return name ? 'BOM详情 - ' + name : 'BOM详情';
+      },
+      sumTotal() {
+        return this.bomList.reduce(
+          (total, item) => total + this.toNumber(item.totalPrice, 0),
+          0
+        );
+      }
+    },
+    methods: {
+      toNumber(value, fallback) {
+        const num = Number(value);
+        if (value === null || value === undefined || value === '' || !isFinite(num)) {
+          return fallback === undefined ? 0 : fallback;
+        }
+        return num;
+      },
+      formatAmount(value) {
+        return this.toNumber(value, 0).toFixed(4);
+      },
+      // BOM 数据随报价单详情返回在 quoteProductList[].bomList 上,这里只做展示
+      open(payload) {
+        const params = payload || {};
+        this.row = params.row || {};
+        this.bomList = Array.isArray(this.row.bomList) ? this.row.bomList : [];
+        this.dialogVisible = true;
+      },
+      close() {
+        this.dialogVisible = false;
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .bom-summary {
+    margin-top: 8px;
+    text-align: right;
+
+    .amount {
+      margin-left: 20px;
+      font-size: 13px;
+      color: #606266;
+    }
+  }
+</style>

+ 336 - 0
src/views/bpm/handleTask/components/quotation/bomImportDialog.vue

@@ -0,0 +1,336 @@
+<template>
+  <el-dialog
+    :title="dialogTitle"
+    :visible.sync="dialogVisible"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    width="80%"
+    top="5vh"
+    custom-class="ele-dialog-form"
+    @close="close"
+  >
+    <el-card shadow="never">
+      <el-form size="small" label-width="80px" class="zw-criterion" @submit.native.prevent>
+        <el-form-item label="选择文件">
+          <div class="bom-upload">
+            <el-upload
+              action="#"
+              :show-file-list="false"
+              :http-request="handleUpload"
+              accept=".xls,.xlsx"
+              :disabled="loading"
+            >
+              <el-button size="small" type="primary" icon="el-icon-upload2" :loading="loading">
+                文件上传
+              </el-button>
+            </el-upload>
+            <span class="bom-upload-tip">
+              只能上传 excel 文件(.xls/.xlsx),点击
+              <el-link type="primary" :underline="false" @click="downloadTemplate">
+                下载《BOM导入模板》
+              </el-link>
+            </span>
+            <span class="bom-upload-file" v-if="fileName">{{ fileName }}</span>
+          </div>
+        </el-form-item>
+      </el-form>
+
+      <el-alert
+        v-for="(item, index) in errors"
+        :key="index"
+        type="error"
+        :closable="false"
+        show-icon
+        :title="item"
+        class="bom-alert"
+      />
+
+      <ele-pro-table
+        ref="table"
+        :need-page="false"
+        :columns="columns"
+        :datasource="bomList"
+        row-key="materialCode"
+        v-loading="loading"
+        max-height="320px"
+        class="dict-table"
+      >
+        <template v-slot:shelf="{ row: item }">
+          <el-tag v-if="toNumber(item.shelfStatus, 0) === 1" type="success" size="small">
+            已上架
+          </el-tag>
+          <el-tag v-else type="info" size="small">按0</el-tag>
+        </template>
+      </ele-pro-table>
+
+      <div class="bom-summary" v-if="bomList.length">
+        <span class="amount">BOM条数:{{ bomList.length }}</span>
+        <span class="amount">总计:{{ formatAmount(sumTotal) }}元</span>
+        <span class="amount">回填单价:{{ formatAmount(rowPrice.singlePrice) }}元</span>
+      </div>
+    </el-card>
+
+    <div slot="footer" class="dialog-footer">
+      <el-button size="small" @click="close">关 闭</el-button>
+      <el-button
+        size="small"
+        type="primary"
+        :disabled="loading || !bomList.length"
+        v-click-once
+        @click="confirm"
+      >
+        确定导入
+      </el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+  import * as XLSX from 'xlsx';
+  import { download1 } from '@/utils/file';
+  import { getList as getGoodsList } from '@/api/bpm/components/goodsManage';
+  import {
+    BOM_DETAIL_COLUMNS,
+    BOM_TEMPLATE_NAME,
+    BOM_TEMPLATE_URL,
+    chunkList,
+    parseBomRows,
+    pickGoodsPrice,
+    buildBomLines,
+    buildRowPrice,
+    sumBomTotalPrice,
+    toNumber
+  } from './bom';
+
+  const PRICE_QUERY_CHUNK = 5;
+
+  export default {
+    name: 'BomImportDialog',
+    data() {
+      return {
+        dialogVisible: false,
+        loading: false,
+        fileName: '',
+        row: {},
+        rowIndex: -1,
+        quoteId: '',
+        rowKey: '',
+        bomList: [],
+        errors: [],
+        rowPrice: { singlePrice: 0, notaxSinglePrice: 0, taxRate: null },
+        columns: BOM_DETAIL_COLUMNS.concat([
+          { columnKey: 'shelf', label: '状态', width: 90, align: 'center', slot: 'shelf' }
+        ])
+      };
+    },
+    computed: {
+      dialogTitle() {
+        const name = this.row && this.row.productName ? this.row.productName : '';
+        return name ? 'BOM导入 - ' + name : 'BOM导入';
+      },
+      sumTotal() {
+        return sumBomTotalPrice(this.bomList);
+      }
+    },
+    methods: {
+      toNumber(value, fallback) {
+        return toNumber(value, fallback);
+      },
+      formatAmount(value) {
+        return toNumber(value, 0).toFixed(4);
+      },
+      /* 父组件调用:row 为物品清单行对象,index 为行下标 */
+      open(payload) {
+        const params = payload || {};
+        this.dialogVisible = true;
+        this.row = params.row || {};
+        this.rowIndex = params.index === undefined ? -1 : params.index;
+        this.quoteId = params.quoteId || '';
+        this.rowKey = params.rowKey || '';
+        this.fileName = '';
+        this.errors = [];
+        this.bomList = [];
+        this.rowPrice = { singlePrice: 0, notaxSinglePrice: 0, taxRate: null };
+      },
+      close() {
+        this.dialogVisible = false;
+        this.loading = false;
+      },
+      downloadTemplate() {
+        download1(window.location.origin + BOM_TEMPLATE_URL, BOM_TEMPLATE_NAME);
+      },
+      /* 读文件 -> 解析模版 -> 按物料编码取价 -> 计算数量与价格 */
+      async handleUpload(param) {
+        const file = param && param.file;
+        if (!file) return;
+        this.loading = true;
+        this.fileName = file.name;
+        this.errors = [];
+        try {
+          const matrix = await this.readSheet(file);
+          const parsed = parseBomRows(matrix);
+          if (parsed.errors.length) {
+            // 只回报有问题的行(第N行 + 原因),示例行/空行静默丢弃;其余行照常导入
+            this.errors = parsed.errors;
+          }
+          if (!parsed.lines.length) {
+            this.bomList = [];
+            this.rowPrice = { singlePrice: 0, notaxSinglePrice: 0, taxRate: null };
+            return;
+          }
+          const priceMap = await this.loadPriceMap(parsed.lines);
+          const bomList = buildBomLines(parsed.lines, {
+            quantity: this.row.totalCount,
+            priceMap: priceMap,
+            quoteId: this.quoteId,
+            quoteProductId: this.row.id,
+            rowKey: this.rowKey
+          });
+          this.bomList = bomList;
+          this.rowPrice = buildRowPrice(bomList);
+          const missCodes = bomList
+            .filter((item) => toNumber(item.shelfStatus, 0) !== 1)
+            .map((item) => item.materialCode);
+          if (missCodes.length) {
+            this.errors = this.errors.concat([
+              '以下物料编码未取到上架价格,已按 0 计算:' + missCodes.join('、')
+            ]);
+          }
+        } catch (error) {
+          this.errors = ['文件解析失败:' + (error && error.message ? error.message : error)];
+          this.bomList = [];
+        } finally {
+          this.loading = false;
+        }
+      },
+      readSheet(file) {
+        return new Promise((resolve, reject) => {
+          const reader = new FileReader();
+          reader.onload = (event) => {
+            try {
+              const workbook = XLSX.read(new Uint8Array(event.target.result), {
+                type: 'array'
+              });
+              const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
+              resolve(
+                XLSX.utils.sheet_to_json(firstSheet, { header: 1, raw: false, defval: '' })
+              );
+            } catch (error) {
+              reject(error);
+            }
+          };
+          reader.onerror = reject;
+          reader.readAsArrayBuffer(file);
+        });
+      },
+      /* 按物料编码调 eom/goods/page,命中后按「优先上架、多价格取第一条」取价;查不到就是 0 */
+      async loadPriceMap(lines) {
+        const codes = [];
+        (lines || []).forEach((line) => {
+          if (line.materialCode && codes.indexOf(line.materialCode) < 0) {
+            codes.push(line.materialCode);
+          }
+        });
+        const priceMap = {};
+        const groups = chunkList(codes, PRICE_QUERY_CHUNK);
+        for (let i = 0; i < groups.length; i++) {
+          const results = await Promise.all(
+            groups[i].map((code) => this.queryGoodsByCode(code))
+          );
+          groups[i].forEach((code, index) => {
+            priceMap[code] = pickGoodsPrice(results[index], code);
+          });
+        }
+        return priceMap;
+      },
+      async queryGoodsByCode(code) {
+        try {
+          // pageNum/size 是后端 PaginationPO 的分页字段(不是 pageSize)
+          const res = await getGoodsList({
+            searchName: code,
+            pageNum: 1,
+            size: 50
+          });
+          if (Array.isArray(res)) return res;
+          if (res && Array.isArray(res.list)) return res.list;
+          if (res && Array.isArray(res.records)) return res.records;
+          return [];
+        } catch (error) {
+          // 单个编码查询失败不影响整单导入,按 0 处理
+          return [];
+        }
+      },
+      confirm() {
+        if (!this.bomList.length) {
+          this.$message.warning('请先选择并解析模版文件');
+          return;
+        }
+        const existed = (this.row.bomList || []).length;
+        const tip = existed
+          ? '该物品清单行已有 ' +
+            existed +
+            ' 条 BOM 数据,确定覆盖导入 ' +
+            this.bomList.length +
+            ' 条吗?'
+          : '确定导入 ' + this.bomList.length + ' 条 BOM 数据吗?';
+        this.$confirm(tip, '提示', {
+          type: 'warning',
+          confirmButtonText: '确定',
+          cancelButtonText: '取消'
+        })
+          .then(() => this.doImport())
+          .catch(() => {});
+      },
+      doImport() {
+        // BOM 挂在物品清单行上,随报价单 save/update 一起提交,
+        // 由后端 QuoteProductServiceImpl#saveBomBatch 按产品整行覆盖落库(新增与修改同一套逻辑)。
+        this.$emit('success', {
+          bomList: this.bomList,
+          price: this.rowPrice,
+          row: this.row,
+          index: this.rowIndex,
+          rowKey: this.rowKey
+        });
+        this.$message.success('成功导入 ' + this.bomList.length + ' 条 BOM 数据');
+        this.close();
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .bom-upload {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+  }
+
+  .bom-upload-tip {
+    margin-left: 12px;
+    font-size: 12px;
+    color: #909399;
+  }
+
+  .bom-upload-file {
+    margin-left: 12px;
+    font-size: 12px;
+    color: #606266;
+  }
+
+  .bom-alert {
+    margin-bottom: 8px;
+  }
+
+
+  .bom-summary {
+    margin-top: 8px;
+    text-align: right;
+  }
+
+  .bom-summary .amount {
+    margin-left: 20px;
+    font-size: 13px;
+    color: #606266;
+  }
+</style>

+ 2 - 2
vue.config.js

@@ -38,13 +38,13 @@ module.exports = {
         // target: 'http://124.71.68.31:50001', // 测试环境
         // target: 'http://124.71.68.31:50001',
         // target: 'http://192.168.1.105:18086',
-        // target: 'http://localhost:18086',
+        target: 'http://localhost:18086',
         // target: 'http://192.168.1.116:18086',
         // target: 'http://192.168.1.3:18086',
         // target: 'http://192.168.1.251:18186',
         // target: 'http://192.168.1.251:18086',
         // target: 'http://192.168.1.3:18086',
-        target: 'http://aiot.zoomwin.com.cn:51007/api',
+        // target: 'http://aiot.zoomwin.com.cn:51007/api',
         // target: 'http://192.168.1.3:18086',
         // target: 'http://192.168.1.147:18086', // 陈兵
         // target: 'http://192.168.1.116:18086', // 赵沙金