diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js
index 3f3bd17..38329c9 100644
--- a/backend/src/services/mkhBomService.js
+++ b/backend/src/services/mkhBomService.js
@@ -7,7 +7,8 @@
// ── 数据口径 ──────────────────────────────────────────────────────
//
// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt)
-// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品' (约 4436 条)
+// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品'
+// AND other_3 = N'简尚-崔桥工厂品牌仓' (当前 4408 条)
// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198
//
// ② 配件明细(BOM)—— 业务中台组合商品表(主来源)
@@ -22,8 +23,7 @@
// { barcode: DNZ038401N1440V01, quantity: 1, role: 组件 }]
// · 匹配:编码去横线/空格转大写后比对;精确 SKU 匹配不到时退化到 SPU 前缀匹配
// (业务库 MKHDNZ0384260600002 ↔ 资料库 MKH-DNZ0384260600001)
-// · 资料库里已维护 BOM、但业务库 jst_sku 里没有对应成品的模块化商品,
-// 会以 source='product_db' 追加到列表最前,保证已维护的数据一定可见。
+// · BOM 只用于补充指定仓库商品的配件明细,不能把无 jst_sku 仓库档案的编码追加为商品。
//
// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU /
// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。
@@ -40,14 +40,14 @@ const productDb = require('../config/productInformationDatabase');
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
-const MAX_EXTRA_LOOKUP = 300;
const BOM_CACHE_TTL_MS = 2 * 60 * 1000;
let businessBomCache = null;
let productBomCache = null;
// MKH 商品编码 = 模块化成品
-const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品'`;
+const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
+const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品' AND s.other_3 = N'${DESIGNATED_WAREHOUSE}'`;
const toText = (value) => {
if (value === null || value === undefined) return null;
@@ -322,115 +322,27 @@ function matchBomSources(businessBom, productBom, skuId) {
return null;
}
-function countDistinctBomProducts(...boms) {
- const keys = new Set();
- boms.forEach((bom) => {
- (bom?.entries || []).forEach((entry) => {
- const key = normalizeCode(entry.skuCode || entry.spu);
- if (key) keys.add(key);
- });
+function summarizeConfiguredBoms(businessBom, productBom) {
+ const summary = { business: 0, product: 0, total: 0 };
+ (businessBom?.existingSkuKeys || new Set()).forEach((skuKey) => {
+ if (matchComponents(businessBom, skuKey)) {
+ summary.business += 1;
+ summary.total += 1;
+ return;
+ }
+ if (matchComponents(productBom, skuKey)) {
+ summary.product += 1;
+ summary.total += 1;
+ }
});
- return keys.size;
+ return summary;
}
const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0);
/**
- * 找出「资料库里已维护 BOM、但业务库列表里没有」的模块化商品,
- * 作为追加行放在列表最前面,保证已维护的数据一定能在页面上看到。
- */
-async function buildExtraRows(bom) {
- if (!bom.entries.length) return [];
- if (bom.entries.length > MAX_EXTRA_LOOKUP) return [];
-
- const candidates = [];
- bom.entries.forEach((entry) => {
- [entry.skuCode, normalizeCode(entry.skuCode)].forEach((code) => {
- if (code && !candidates.includes(code)) candidates.push(code);
- });
- });
- if (!candidates.length) return [];
-
- const params = {};
- const placeholders = candidates.map((code, index) => {
- params[`c${index}`] = code;
- return `@c${index}`;
- });
-
- let existing = new Set();
- try {
- const rows = await cloudDatabase.query(
- `SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.sku_id IN (${placeholders.join(', ')})`,
- params,
- { requestTimeout: 120000 }
- );
- existing = new Set(rows.map((row) => normalizeCode(row.skuId)).filter(Boolean));
- } catch (error) {
- console.warn('[MkhBom] 追加行去重查询失败,按全部追加处理:', error.message);
- }
-
- return bom.entries
- .filter((entry) => {
- const key = normalizeCode(entry.skuCode);
- return key && !existing.has(key);
- })
- .map((entry) => ({
- skuId: entry.skuCode || entry.spu,
- productName: entry.productName,
- category: null,
- material: null,
- accessoryCount: entry.components.length,
- accessoryQtySum: sumQty(entry.components),
- bomConfigured: '已配置',
- bomMatchType: 'sku',
- source: 'product_db',
- }));
-}
-
-function buildBusinessBomExtraRows(bom) {
- const existing = bom?.existingSkuKeys || new Set();
- return (bom?.entries || [])
- .filter((entry) => {
- const key = normalizeCode(entry.skuCode);
- return key && !existing.has(key);
- })
- .map((entry) => ({
- skuId: entry.skuCode,
- productName: entry.productName || entry.skuCode,
- category: null,
- material: null,
- accessoryCount: entry.components.length,
- accessoryQtySum: sumQty(entry.components),
- bomConfigured: '已配置',
- bomMatchType: 'sku',
- bomDataSource: 'business_bom',
- source: 'business_bom',
- }));
-}
-
-function mergeExtraRows(...groups) {
- const seen = new Set();
- const rows = [];
- groups.flat().forEach((row) => {
- const key = normalizeCode(row?.skuId);
- if (!key || seen.has(key)) return;
- seen.add(key);
- rows.push(row);
- });
- return rows;
-}
-
-function filterExtraRows(rows, query = {}) {
- const keyword = toText(query.keyword)?.toUpperCase();
- if (!keyword) return rows;
- return rows.filter((row) => (
- String(row.skuId || '').toUpperCase().includes(keyword)
- || String(row.productName || '').toUpperCase().includes(keyword)
- ));
-}
-
-/**
- * 列表:MKH 模块化成品(业务库)+ 配件种类/数量(资料库 BOM)
+ * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。
+ * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。
*/
async function getMkhBomList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
@@ -450,40 +362,20 @@ async function getMkhBomList(query = {}) {
loadBusinessBom(),
loadPgBom(),
]);
- const productExtraRows = await buildExtraRows(productBom);
- const extraRows = filterExtraRows(
- mergeExtraRows(
- buildBusinessBomExtraRows(businessBom),
- productExtraRows
- ),
- query
- );
- const extraCount = extraRows.length;
-
- // 追加行占据全局前 extraCount 个位置,业务库行顺延
+ const bomSummary = summarizeConfiguredBoms(businessBom, productBom);
const offset = (page - 1) * pageSize;
- const extraStart = Math.min(offset, extraCount);
- const extraTake = Math.max(0, Math.min(pageSize, extraCount - extraStart));
- const businessOffset = Math.max(0, offset - extraCount);
- const businessTake = pageSize - extraTake;
-
- const pageExtras = extraRows.slice(extraStart, extraStart + extraTake);
-
- let businessRows = [];
- if (businessTake > 0) {
- businessRows = await cloudDatabase.query(
- `SELECT
- s.sku_id AS skuId,
- s.name AS productName,
- s.category AS category,
- s.other_10 AS material
- ${whereSql}
- ORDER BY ${orderSql}
- OFFSET ${businessOffset} ROWS FETCH NEXT ${businessTake} ROWS ONLY`,
- params,
- { requestTimeout: 180000 }
- );
- }
+ const businessRows = await cloudDatabase.query(
+ `SELECT
+ s.sku_id AS skuId,
+ s.name AS productName,
+ s.category AS category,
+ s.other_10 AS material
+ ${whereSql}
+ ORDER BY ${orderSql}
+ OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
+ params,
+ { requestTimeout: 180000 }
+ );
const [totalRow] = await cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
@@ -510,19 +402,17 @@ async function getMkhBomList(query = {}) {
};
});
- const rows = [...pageExtras, ...mapped];
-
return {
- rows,
- total: Number(totalRow?.total || 0) + extraCount,
- bomRowCount: countDistinctBomProducts(businessBom, productBom),
- bomBusinessRowCount: businessBom.entries.length,
- bomProductDbRowCount: productBom.entries.length,
- bomExtraCount: extraCount,
- bomReady: businessBom.entries.length > 0 || productBom.entries.length > 0,
+ rows: mapped,
+ total: Number(totalRow?.total || 0),
+ bomRowCount: bomSummary.total,
+ bomBusinessRowCount: bomSummary.business,
+ bomProductDbRowCount: bomSummary.product,
+ bomExtraCount: 0,
+ bomReady: bomSummary.total > 0,
page,
pageSize,
- configuredInPage: rows.filter((row) => row.accessoryCount > 0).length,
+ configuredInPage: mapped.filter((row) => row.accessoryCount > 0).length,
};
}
@@ -541,17 +431,23 @@ async function getMkhBomDetail(mkhSkuId) {
const comboRows = await cloudDatabase.query(
`SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category
FROM dbo.jst_sku s
- WHERE s.sku_id = @mkhSkuId`,
+ WHERE s.sku_id = @mkhSkuId AND ${MKH_WHERE}`,
{ mkhSkuId: skuId },
{ requestTimeout: 120000 }
);
- let combo = comboRows[0] ? {
+ const combo = comboRows[0] ? {
skuId: toText(comboRows[0].skuId),
productName: toText(comboRows[0].productName),
category: toText(comboRows[0].category),
} : null;
+ if (!combo) {
+ const error = new Error('未找到指定仓库的该 MKH 商品');
+ error.statusCode = 404;
+ throw error;
+ }
+
const [businessBom, productBom] = await Promise.all([
loadBusinessBom(),
loadPgBom(),
@@ -559,18 +455,6 @@ async function getMkhBomDetail(mkhSkuId) {
const matched = matchBomSources(businessBom, productBom, skuId);
const components = matched?.components || [];
- // 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息
- if (!combo) {
- const entry = businessBom.entries.find((item) => normalizeCode(item.skuCode) === normalizeCode(skuId))
- || productBom.entries.find((item) => normalizeCode(item.skuCode) === normalizeCode(skuId));
- if (!entry) {
- const error = new Error('未找到该 MKH 商品');
- error.statusCode = 404;
- throw error;
- }
- combo = { skuId, productName: entry.productName, category: null };
- }
-
const codes = components.map((item) => item.barcode).filter(Boolean);
const stockMap = new Map();
const locationMap = new Map();
@@ -638,9 +522,6 @@ module.exports = {
groupBusinessBomRows,
matchComponents,
matchBomSources,
- countDistinctBomProducts,
- buildBusinessBomExtraRows,
- mergeExtraRows,
- filterExtraRows,
+ summarizeConfiguredBoms,
},
};
diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js
index 603c7b6..90b4ca6 100644
--- a/backend/test/mkhBomService.test.js
+++ b/backend/test/mkhBomService.test.js
@@ -63,33 +63,21 @@ test('business BOM takes precedence while product database BOM remains a fallbac
assert.equal(matched.components[0].barcode, 'BUSINESS-PART');
});
-test('BOM-only MKH products remain visible as drill-down rows without a jst_sku record', () => {
- const bom = _private.groupBusinessBomRows([
- { comboSkuId: 'MKH-A-1', componentSkuId: 'PART-1', qty: 2 },
- { comboSkuId: 'MKH-B-1', componentSkuId: 'PART-2', qty: 1 },
+test('BOM summary excludes combinations outside the designated warehouse SKU set', () => {
+ const businessBom = _private.groupBusinessBomRows([
+ { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 },
+ { comboSkuId: 'MKH-OUTSIDE-1', componentSkuId: 'OUTSIDE-PART', qty: 1 },
]);
- bom.existingSkuKeys = new Set(['MKHA1']);
+ businessBom.existingSkuKeys = new Set(['MKHA1', 'MKHB1']);
+ const productBom = {
+ bySku: new Map([['MKHB1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]),
+ bySpuPrefix: [],
+ entries: [],
+ };
- const extras = _private.buildBusinessBomExtraRows(bom);
- assert.deepEqual(extras.map((row) => row.skuId), ['MKH-B-1']);
- assert.equal(extras[0].source, 'business_bom');
- assert.equal(extras[0].accessoryQtySum, 1);
-});
-
-test('extra rows from multiple BOM sources are de-duplicated by normalized MKH code', () => {
- const rows = _private.mergeExtraRows(
- [{ skuId: 'MKH-A-1', source: 'business_bom' }],
- [{ skuId: 'MKHA1', source: 'product_db' }, { skuId: 'MKH-B-1', source: 'product_db' }]
- );
-
- assert.deepEqual(rows.map((row) => row.skuId), ['MKH-A-1', 'MKH-B-1']);
-});
-
-test('BOM-only rows honor the same keyword filter as jst_sku rows', () => {
- const rows = _private.filterExtraRows([
- { skuId: 'MKH-A-1', productName: '组合 A' },
- { skuId: 'MKH-B-1', productName: '组合 B' },
- ], { keyword: 'b-1' });
-
- assert.deepEqual(rows.map((row) => row.skuId), ['MKH-B-1']);
+ assert.deepEqual(_private.summarizeConfiguredBoms(businessBom, productBom), {
+ business: 1,
+ product: 1,
+ total: 2,
+ });
});
diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx
index 8ca850e..de77b93 100644
--- a/frontend/src/pages/MkhBomData/index.jsx
+++ b/frontend/src/pages/MkhBomData/index.jsx
@@ -210,21 +210,7 @@ const MkhBomData = () => {
width: 240,
fixed: 'left',
sorter: true,
- render: (value, row) => (
-
- {text(value)}
- {row.source === 'product_db' ? (
-
- 资料库
-
- ) : null}
- {row.source === 'business_bom' ? (
-
- 组合BOM
-
- ) : null}
-
- ),
+ render: (value) => {text(value)},
},
{
title: '商品名称',
@@ -351,9 +337,9 @@ const MkhBomData = () => {
模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位
- 数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条);
+ 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条);
配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充;
- 已维护组合 BOM 但 jst_sku 无对应成品档案的编码会置顶并标注「组合BOM」,资料库补充编码标注「资料库」;配件仓位未维护时显示「未维护」。
+ 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。
@@ -384,10 +370,10 @@ const MkhBomData = () => {
: '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'}
description={(
- MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku;
+ MKH 商品编码({data.total || 0} 条)仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”;
配件明细与数量取自业务组合 BOM({data.bomBusinessRowCount || 0} 个),
「产品资料系统数据库」维护的 BOM({data.bomProductDbRowCount || 0} 个)作为补充。
- 已维护 BOM、但 jst_sku 无对应成品档案的编码({data.bomExtraCount || 0} 条)会置顶并标注数据来源。
+ BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。
在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
)}