From 3bf7b9f2085f333286ba6be59c3a1df16f03ef6b Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 16 Sep 2026 18:02:28 +0800 Subject: [PATCH 1/2] fix: restrict MKH BOM list to designated warehouse --- backend/src/services/mkhBomService.js | 219 ++++++------------------ backend/test/mkhBomService.test.js | 42 ++--- frontend/src/pages/MkhBomData/index.jsx | 24 +-- 3 files changed, 70 insertions(+), 215 deletions(-) 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 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 )} From 8e5d427b3ae109f2f40083ae84d55b1f98f2e263 Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 16 Sep 2026 20:56:26 +0800 Subject: [PATCH 2/2] fix: read MKH details from official BOM tables --- backend/src/services/mkhBomService.js | 238 ++++++++++++++++++++---- backend/test/mkhBomService.test.js | 70 ++++++- frontend/src/pages/MkhBomData/index.jsx | 18 +- 3 files changed, 282 insertions(+), 44 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 38329c9..30aa431 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -11,12 +11,19 @@ // 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: 板材 }, @@ -30,10 +37,10 @@ // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ④ 配件仓位 —— 业务库 SKU 档案中的库位字段; +// ⑤ 配件仓位 —— 业务库 SKU 档案中的库位字段; // 业务库 jst_sku.bin / other_bin 全库为空,故多数配件显示「未维护」。 // -// ⑤ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 +// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -41,8 +48,11 @@ const productDb = require('../config/productInformationDatabase'); const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; 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 商品编码 = 模块化成品 @@ -158,7 +168,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(); @@ -312,7 +473,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' }; @@ -322,9 +486,14 @@ function matchBomSources(businessBom, productBom, skuId) { return null; } -function summarizeConfiguredBoms(businessBom, productBom) { - const summary = { business: 0, product: 0, total: 0 }; +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; @@ -358,34 +527,36 @@ 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([ + const offset = (page - 1) * pageSize; + const [businessRows, totalRows, officialBomIndex, businessBom, productBom] = await Promise.all([ + 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 } + ), + cloudDatabase.query( + `SELECT COUNT_BIG(*) AS total ${whereSql}`, + params, + { requestTimeout: 180000 } + ), + loadOfficialBomIndex(), loadBusinessBom(), loadPgBom(), ]); - const bomSummary = summarizeConfiguredBoms(businessBom, productBom); - const offset = (page - 1) * pageSize; - 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}`, - params, - { requestTimeout: 180000 } - ); + 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 { @@ -406,6 +577,7 @@ async function getMkhBomList(query = {}) { rows: mapped, total: Number(totalRow?.total || 0), bomRowCount: bomSummary.total, + bomOfficialRowCount: bomSummary.official, bomBusinessRowCount: bomSummary.business, bomProductDbRowCount: bomSummary.product, bomExtraCount: 0, @@ -448,11 +620,12 @@ async function getMkhBomDetail(mkhSkuId) { throw error; } - const [businessBom, productBom] = await Promise.all([ + 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 || []; const codes = components.map((item) => item.barcode).filter(Boolean); @@ -519,6 +692,7 @@ module.exports = { getMkhBomDetail, _private: { normalizeCode, + groupOfficialBomRows, groupBusinessBomRows, matchComponents, matchBomSources, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 90b4ca6..3d1ea20 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,11 +98,30 @@ 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('official Jushuitan BOM takes precedence over all fallback sources', () => { + const officialBom = _private.groupOfficialBomRows([ + { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 2 }, + ]); + 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 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('BOM summary excludes combinations outside the designated warehouse SKU set', () => { const businessBom = _private.groupBusinessBomRows([ { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, @@ -75,8 +134,13 @@ test('BOM summary excludes combinations outside the designated warehouse SKU set entries: [], }; - assert.deepEqual(_private.summarizeConfiguredBoms(businessBom, productBom), { - business: 1, + const officialBom = _private.groupOfficialBomRows([ + { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 1 }, + ]); + + assert.deepEqual(_private.summarizeConfiguredBoms(officialBom, businessBom, productBom), { + official: 1, + business: 0, product: 1, total: 2, }); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index de77b93..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 ? ( {text(data.skuId)} - 该 MKH 商品的配件明细尚未录入「产品资料系统数据库」 + 该 MKH 商品尚无可用的聚水潭商品 BOM 明细 ) : null} @@ -83,7 +83,7 @@ const PrintPreview = ({ data, onClose }) => {
- MKH 商品编码与组合配件取自业务库;「产品资料系统数据库」中维护的配件 BOM 作为补充; + MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; 配件仓位取库位主数据,未维护时显示「未维护」。
@@ -286,8 +286,8 @@ const MkhBomData = () => { {items.length === 0 ? (
- 该 MKH 商品在业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。 - 上游维护好该商品的组合配件后,此处会自动显示配件明细、数量与仓位。 + 该 MKH 商品在聚水潭商品 BOM、业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。 + 上游维护好该商品的正式 BOM 后,此处会自动显示配件明细、数量与仓位。
) : null} @@ -338,7 +338,7 @@ const MkhBomData = () => { 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); - 配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充; + 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。 @@ -367,14 +367,14 @@ const MkhBomData = () => { showIcon message={data.bomRowCount > 0 ? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` - : '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'} + : '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'} description={( MKH 商品编码({data.total || 0} 条)仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”; - 配件明细与数量取自业务组合 BOM({data.bomBusinessRowCount || 0} 个), - 「产品资料系统数据库」维护的 BOM({data.bomProductDbRowCount || 0} 个)作为补充。 + 配件明细优先取自聚水潭商品 BOM({data.bomOfficialRowCount || 0} 个), + 业务组合 BOM({data.bomBusinessRowCount || 0} 个)与产品资料系统({data.bomProductDbRowCount || 0} 个)作为补充。 BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。 - 在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 + 上游补充正式 BOM 后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 )} />