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 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 )}