diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 9da13ae..61d83f8 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -7,48 +7,58 @@ // ── 数据口径 ────────────────────────────────────────────────────── // // ① 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)—— 业务中台组合商品表(主来源) +// ② 配件明细(BOM)—— 聚水潭商品 BOM(主来源) +// rpt.jst_skuBomInfo / rpt.jst_skuBomItem +// · jst_skuBomInfo.sku_id = MKH 成品编码 +// · jst_skuBomItem.map_outer_sku_id = 配件编码,rm_qty = 配件数量 +// · 两表当前分别约 4,257 / 49,012 行;远端副本暂未提供 sku_id 索引, +// 因此只读取必要列并在进程内短时缓存,避免分页和下钻重复扫描。 +// +// ③ 业务中台组合商品表(降级来源) // JSDataMiddlePlatform.dbo.jst_comboProductItem // · combine_sku_id = MKH 成品编码,src_sku_id = 配件编码,qty = 配件数量 // · 当前实测 1,406 个 MKH 成品、3,022 条配件关系、159 个不同配件编码 // -// ③ 产品资料系统数据库(PostgreSQL / furniture3d_prod)作为补充来源 +// ④ 产品资料系统数据库(PostgreSQL / furniture3d_prod)作为补充来源 // public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails' // · 每一项形如 { code, name, role/assetType, barcode, quantity } // · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 }, // { 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 行,因此视图恒为空、不要用。 // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ④ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; +// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; // sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin, // 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。 // -// ⑤ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 +// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 const cloudDatabase = require('../config/cloudDatabase'); 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; +const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000; let businessBomCache = null; +let officialBomIndexCache = null; +const officialBomDetailCache = new Map(); 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; @@ -159,7 +169,158 @@ function groupBusinessBomRows(rows = []) { } /** - * 从业务中台读取 MKH 组合商品 BOM。该表是 MKH 下钻明细的主数据源。 + * 将聚水潭正式商品 BOM 明细整理成按成品编码查找的结构。 + * 同一 BOM 中重复的配件编码会合并数量;外部配件编码为空时忽略该行, + * 避免把聚水潭内部数字 ID 当成可展示、可查库存的配件编码。 + */ +function groupOfficialBomRows(rows = []) { + const grouped = new Map(); + + rows.forEach((row) => { + const skuCode = toText(row.skuId); + const skuKey = normalizeCode(skuCode); + const componentCode = toText(row.componentSkuId); + const componentKey = normalizeCode(componentCode); + if (!skuCode || !skuKey || !componentCode || !componentKey) return; + + if (!grouped.has(skuKey)) { + grouped.set(skuKey, { + skuCode, + components: new Map(), + }); + } + + const entry = grouped.get(skuKey); + const previous = entry.components.get(componentKey); + const qty = toNumber(row.qty) ?? 1; + if (previous) { + previous.qty += qty; + if (!previous.name) previous.name = toText(row.componentName) || componentCode; + return; + } + + entry.components.set(componentKey, { + barcode: componentCode, + code: componentCode, + name: toText(row.componentName) || componentCode, + role: null, + qty, + }); + }); + + const bySku = new Map(); + const entries = []; + grouped.forEach((entry, skuKey) => { + const components = Array.from(entry.components.values()); + bySku.set(skuKey, components); + entries.push({ + skuCode: entry.skuCode, + spu: null, + productName: entry.skuCode, + components, + }); + }); + + return { bySku, bySpuPrefix: [], entries }; +} + +/** + * 读取聚水潭正式 BOM 主表中的成品编码索引。 + * 主表只有约四千行,只传输 sku_id;用于全局统计与来源优先级判断。 + */ +async function loadOfficialBomIndex() { + const now = Date.now(); + if (officialBomIndexCache?.value && officialBomIndexCache.expiresAt > now) { + return officialBomIndexCache.value; + } + if (officialBomIndexCache?.pending) return officialBomIndexCache.pending; + + const pending = cloudDatabase.query( + `SELECT h.sku_id AS skuId + FROM rpt.jst_skuBomInfo h WITH (NOLOCK) + WHERE h.sku_id LIKE N'MKH%'`, + {}, + { requestTimeout: 180000 } + ).then((rows) => { + const bySku = new Map(); + rows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) bySku.set(key, []); + }); + return { bySku, bySpuPrefix: [], entries: [] }; + }); + + officialBomIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + return value; + } catch (error) { + officialBomIndexCache = null; + throw error; + } +} + +/** + * 按当前页面或单品所需 SKU 批量读取正式 BOM 明细。 + * 单次最多接收 MAX_PAGE_SIZE 个 SKU,并缓存每个 SKU 的结果(包括空结果), + * 避免把远端约 4.9 万条明细在每次缓存刷新时全部传回应用服务器。 + */ +async function loadOfficialBomForSkus(skuIds = []) { + const requested = Array.from(new Map( + skuIds + .map((skuId) => [normalizeCode(skuId), toText(skuId)]) + .filter(([key, value]) => key && value) + ).entries()).slice(0, MAX_PAGE_SIZE); + const now = Date.now(); + const missing = requested.filter(([key]) => { + const cached = officialBomDetailCache.get(key); + return !cached || cached.expiresAt <= now; + }); + + if (missing.length > 0) { + const params = {}; + const placeholders = missing.map(([, skuId], index) => { + params[`officialSku${index}`] = skuId; + return `@officialSku${index}`; + }); + const rows = await cloudDatabase.query( + `SELECT + i.sku_id AS skuId, + i.map_outer_sku_id AS componentSkuId, + i.map_name AS componentName, + i.rm_qty AS qty + FROM rpt.jst_skuBomItem i WITH (NOLOCK) + WHERE i.sku_id IN (${placeholders.join(', ')}) + AND NULLIF(LTRIM(RTRIM(i.map_outer_sku_id)), N'') IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM rpt.jst_skuBomInfo h WITH (NOLOCK) + WHERE h.sku_id = i.sku_id + )`, + params, + { requestTimeout: 180000 } + ); + const grouped = groupOfficialBomRows(rows); + const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS; + missing.forEach(([key]) => { + officialBomDetailCache.set(key, { + components: grouped.bySku.get(key) || [], + expiresAt, + }); + }); + } + + const bySku = new Map(); + requested.forEach(([key]) => { + const cached = officialBomDetailCache.get(key); + if (cached?.components?.length) bySku.set(key, cached.components); + }); + return { bySku, bySpuPrefix: [], entries: [] }; +} + +/** + * 从业务中台读取 MKH 组合商品 BOM,作为正式商品 BOM 缺失时的降级来源。 */ async function loadBusinessBom() { const now = Date.now(); @@ -313,7 +474,10 @@ function matchComponents(bom, skuId) { return null; } -function matchBomSources(businessBom, productBom, skuId) { +function matchBomSources(officialBom, businessBom, productBom, skuId) { + const officialMatch = matchComponents(officialBom, skuId); + if (officialMatch) return { ...officialMatch, dataSource: 'official_bom' }; + const businessMatch = matchComponents(businessBom, skuId); if (businessMatch) return { ...businessMatch, dataSource: 'business_bom' }; @@ -323,15 +487,25 @@ 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(officialBom, businessBom, productBom) { + const summary = { official: 0, business: 0, product: 0, total: 0 }; + (businessBom?.existingSkuKeys || new Set()).forEach((skuKey) => { + if (matchComponents(officialBom, skuKey)) { + summary.official += 1; + summary.total += 1; + return; + } + 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); @@ -377,101 +551,8 @@ function buildPackLocationMap(rows, componentCodes) { } /** - * 找出「资料库里已维护 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); @@ -487,32 +568,9 @@ async function getMkhBomList(query = {}) { const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`; - const [businessBom, productBom] = await Promise.all([ - loadBusinessBom(), - loadPgBom(), - ]); - const productExtraRows = await buildExtraRows(productBom); - const extraRows = filterExtraRows( - mergeExtraRows( - buildBusinessBomExtraRows(businessBom), - productExtraRows - ), - query - ); - const extraCount = extraRows.length; - - // 追加行占据全局前 extraCount 个位置,业务库行顺延 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( + const [businessRows, totalRows, officialBomIndex, businessBom, productBom] = await Promise.all([ + cloudDatabase.query( `SELECT s.sku_id AS skuId, s.name AS productName, @@ -520,21 +578,26 @@ async function getMkhBomList(query = {}) { s.other_10 AS material ${whereSql} ORDER BY ${orderSql} - OFFSET ${businessOffset} ROWS FETCH NEXT ${businessTake} ROWS ONLY`, + OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`, params, { requestTimeout: 180000 } - ); - } - - const [totalRow] = await cloudDatabase.query( - `SELECT COUNT_BIG(*) AS total ${whereSql}`, - params, - { requestTimeout: 180000 } - ); + ), + cloudDatabase.query( + `SELECT COUNT_BIG(*) AS total ${whereSql}`, + params, + { requestTimeout: 180000 } + ), + loadOfficialBomIndex(), + loadBusinessBom(), + loadPgBom(), + ]); + const officialBom = await loadOfficialBomForSkus(businessRows.map((row) => row.skuId)); + const bomSummary = summarizeConfiguredBoms(officialBomIndex, businessBom, productBom); + const [totalRow] = totalRows; const mapped = businessRows.map((row) => { const skuId = toText(row.skuId); - const matched = matchBomSources(businessBom, productBom, skuId); + const matched = matchBomSources(officialBom, businessBom, productBom, skuId); const components = matched?.components || []; return { @@ -551,19 +614,18 @@ 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, + bomOfficialRowCount: bomSummary.official, + 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, }; } @@ -582,36 +644,31 @@ 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; - const [businessBom, productBom] = await Promise.all([ + if (!combo) { + const error = new Error('未找到指定仓库的该 MKH 商品'); + error.statusCode = 404; + throw error; + } + + const [officialBom, businessBom, productBom] = await Promise.all([ + loadOfficialBomForSkus([skuId]), loadBusinessBom(), loadPgBom(), ]); - const matched = matchBomSources(businessBom, productBom, skuId); + const matched = matchBomSources(officialBom, 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(); @@ -690,13 +747,11 @@ module.exports = { getMkhBomDetail, _private: { normalizeCode, + groupOfficialBomRows, groupBusinessBomRows, matchComponents, matchBomSources, - countDistinctBomProducts, - buildBusinessBomExtraRows, - mergeExtraRows, - filterExtraRows, + summarizeConfiguredBoms, matchesPackSku, formatPackLocation, buildPackLocationMap, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index c553d1d..93e7010 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -5,6 +5,46 @@ const assert = require('node:assert/strict'); const { _private } = require('../src/services/mkhBomService'); +const emptyBom = () => ({ bySku: new Map(), bySpuPrefix: [], entries: [] }); + +test('official Jushuitan BOM rows use external component codes and consolidate duplicates', () => { + const bom = _private.groupOfficialBomRows([ + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: 'DNZ039804N1410V01', + componentName: '桌面', + qty: '1.000000', + }, + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: 'DNZ040928N1440V01', + componentName: 'K脚架', + qty: '1.000000', + }, + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: 'DNZ040928N1440V01', + componentName: 'K脚架', + qty: '1.000000', + }, + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: ' ', + componentName: '无外部编码的内部物料', + qty: 1, + }, + ]); + + const matched = _private.matchComponents(bom, 'MKHDNZ0409260600022'); + assert.deepEqual( + matched.components.map((item) => [item.barcode, item.name, item.qty]), + [ + ['DNZ039804N1410V01', '桌面', 1], + ['DNZ040928N1440V01', 'K脚架', 2], + ] + ); +}); + test('business MKH BOM rows are grouped for drill-down and match codes without separators', () => { const bom = _private.groupBusinessBomRows([ { @@ -58,40 +98,52 @@ test('business BOM takes precedence while product database BOM remains a fallbac entries: [], }; - const matched = _private.matchBomSources(businessBom, productBom, 'MKHA1'); + const matched = _private.matchBomSources(emptyBom(), businessBom, productBom, 'MKHA1'); assert.equal(matched.dataSource, 'business_bom'); 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('official Jushuitan BOM takes precedence over all fallback sources', () => { + const officialBom = _private.groupOfficialBomRows([ + { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 2 }, ]); - bom.existingSkuKeys = new Set(['MKHA1']); + const businessBom = _private.groupBusinessBomRows([ + { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, + ]); + const productBom = { + bySku: new Map([['MKHA1', [{ 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); + const matched = _private.matchBomSources(officialBom, businessBom, productBom, 'MKHA1'); + assert.equal(matched.dataSource, 'official_bom'); + assert.equal(matched.components[0].barcode, 'OFFICIAL-PART'); + assert.equal(matched.components[0].qty, 2); }); -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' }] - ); +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 }, + ]); + businessBom.existingSkuKeys = new Set(['MKHA1', 'MKHB1']); + const productBom = { + bySku: new Map([['MKHB1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]), + bySpuPrefix: [], + entries: [], + }; - assert.deepEqual(rows.map((row) => row.skuId), ['MKH-A-1', 'MKH-B-1']); -}); + const officialBom = _private.groupOfficialBomRows([ + { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 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(officialBom, businessBom, productBom), { + official: 1, + business: 0, + product: 1, + total: 2, + }); }); test('pack inventory locations match a BOM component and its package suffixes', () => { diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 8ca850e..c6f2b55 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -75,7 +75,7 @@ const PrintPreview = ({ data, onClose }) => { {items.length === 0 ? (