|
@@ -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;
|
|
|
|
|
+}
|