From 3ccba5f4609ab3dc29eded8bdb76ce19ccdee23c Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 16 Sep 2026 14:57:05 +0800 Subject: [PATCH] fix: show MKH BOM details in drill-down --- backend/src/services/mkhBomService.js | 325 ++++++++++++++++++++---- backend/test/mkhBomService.test.js | 95 +++++++ frontend/src/pages/MkhBomData/index.jsx | 24 +- 3 files changed, 387 insertions(+), 57 deletions(-) create mode 100644 backend/test/mkhBomService.test.js diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 4b383f6..3f3bd17 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -10,7 +10,12 @@ // rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品' (约 4436 条) // · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198 // -// ② 配件明细(BOM)—— 产品资料系统数据库(PostgreSQL / furniture3d_prod) +// ② 配件明细(BOM)—— 业务中台组合商品表(主来源) +// JSDataMiddlePlatform.dbo.jst_comboProductItem +// · combine_sku_id = MKH 成品编码,src_sku_id = 配件编码,qty = 配件数量 +// · 当前实测 1,406 个 MKH 成品、3,022 条配件关系、159 个不同配件编码 +// +// ③ 产品资料系统数据库(PostgreSQL / furniture3d_prod)作为补充来源 // public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails' // · 每一项形如 { code, name, role/assetType, barcode, quantity } // · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 }, @@ -25,10 +30,10 @@ // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ③ 配件仓位 —— 资料库 external.warehouse_locations_v1(库位主数据,当前仅 A-01); +// ④ 配件仓位 —— 业务库 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'); @@ -36,6 +41,10 @@ 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'成品'`; @@ -93,12 +102,129 @@ function parseComponents(raw) { .filter(Boolean); } +/** + * 将业务中台组合商品明细整理成按 MKH 成品编码查找的 BOM。 + * 同一成品、同一配件出现多行时合并数量,避免下钻重复展示。 + */ +function groupBusinessBomRows(rows = []) { + const grouped = new Map(); + + rows.forEach((row) => { + const skuCode = toText(row.comboSkuId); + 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 }; +} + +/** + * 从业务中台读取 MKH 组合商品 BOM。该表是 MKH 下钻明细的主数据源。 + */ +async function loadBusinessBom() { + const now = Date.now(); + if (businessBomCache?.value && businessBomCache.expiresAt > now) { + return businessBomCache.value; + } + if (businessBomCache?.pending) return businessBomCache.pending; + + const pending = Promise.all([ + cloudDatabase.query( + `SELECT + c.combine_sku_id AS comboSkuId, + c.src_sku_id AS componentSkuId, + c.qty AS qty, + s.name AS componentName + FROM JSDataMiddlePlatform.dbo.jst_comboProductItem c + LEFT JOIN dbo.jst_sku s ON s.sku_id = c.src_sku_id + WHERE c.combine_sku_id LIKE N'MKH%' + ORDER BY c.combine_sku_id, c.Id`, + {}, + { requestTimeout: 180000 } + ), + cloudDatabase.query( + `SELECT s.sku_id AS skuId, s.name AS productName + FROM dbo.jst_sku s + WHERE ${MKH_WHERE}`, + {}, + { requestTimeout: 180000 } + ), + ]).then(([rows, productRows]) => { + const bom = groupBusinessBomRows(rows); + const productNames = new Map(); + productRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) productNames.set(key, toText(row.productName)); + }); + bom.existingSkuKeys = new Set(productNames.keys()); + bom.entries.forEach((entry) => { + entry.productName = productNames.get(normalizeCode(entry.skuCode)) || entry.productName; + }); + return bom; + }); + + businessBomCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + return value; + } catch (error) { + businessBomCache = null; + throw error; + } +} + /** * 从产品资料系统数据库读取全部「有配件明细」的模块化商品。 * 返回 { bySku: Map, bySpuPrefix: Array, entries: Array } * 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。 */ async function loadPgBom() { + const now = Date.now(); + if (productBomCache?.value && productBomCache.expiresAt > now) { + return productBomCache.value; + } + if (productBomCache?.pending) return productBomCache.pending; + const empty = { bySku: new Map(), bySpuPrefix: [], entries: [] }; const sql = ` SELECT @@ -110,52 +236,64 @@ async function loadPgBom() { CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`; - let rows = []; - try { - rows = await productDb.query(sql); - } catch (error) { - console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message); - return empty; - } + const pending = (async () => { + let rows = []; + try { + rows = await productDb.query(sql); + } catch (error) { + console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message); + return empty; + } - // 商品名称(资料库档案表,取不到不影响主流程) - const nameMap = new Map(); - try { - const nameRows = await productDb.query( - `SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`); - nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName))); - } catch (error) { - console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message); - } + // 商品名称(资料库档案表,取不到不影响主流程) + const nameMap = new Map(); + try { + const nameRows = await productDb.query( + `SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`); + nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName))); + } catch (error) { + console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message); + } - const bySku = new Map(); - const bySpuPrefix = []; - const entries = []; + const bySku = new Map(); + const bySpuPrefix = []; + const entries = []; - rows.forEach((row) => { - const components = parseComponents(row.components); - if (components.length === 0) return; + rows.forEach((row) => { + const components = parseComponents(row.components); + if (components.length === 0) return; - const skuCode = toText(row.sku_code); - const spu = toText(row.spu); - const skuKey = normalizeCode(skuCode); - if (skuKey) bySku.set(skuKey, components); + const skuCode = toText(row.sku_code); + const spu = toText(row.spu); + const skuKey = normalizeCode(skuCode); + if (skuKey) bySku.set(skuKey, components); - const spuKey = normalizeCode(spu); - if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu }); + const spuKey = normalizeCode(spu); + if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu }); - entries.push({ - skuCode, - spu, - productName: nameMap.get(toText(row.product_id)) || spu || skuCode, - components, + entries.push({ + skuCode, + spu, + productName: nameMap.get(toText(row.product_id)) || spu || skuCode, + components, + }); }); - }); - // 前缀长优先,避免短前缀抢先命中 - bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length); + // 前缀长优先,避免短前缀抢先命中 + bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length); - return { bySku, bySpuPrefix, entries }; + return { bySku, bySpuPrefix, entries }; + })(); + + productBomCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + return value; + } catch (error) { + productBomCache = null; + throw error; + } } /** @@ -174,6 +312,27 @@ function matchComponents(bom, skuId) { return null; } +function matchBomSources(businessBom, productBom, skuId) { + const businessMatch = matchComponents(businessBom, skuId); + if (businessMatch) return { ...businessMatch, dataSource: 'business_bom' }; + + const productMatch = matchComponents(productBom, skuId); + if (productMatch) return { ...productMatch, dataSource: 'product_db' }; + + 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); + }); + }); + return keys.size; +} + const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0); /** @@ -228,6 +387,48 @@ async function buildExtraRows(bom) { })); } +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) */ @@ -245,8 +446,18 @@ 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 bom = await loadPgBom(); - const extraRows = await buildExtraRows(bom); + 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 个位置,业务库行顺延 @@ -282,7 +493,7 @@ async function getMkhBomList(query = {}) { const mapped = businessRows.map((row) => { const skuId = toText(row.skuId); - const matched = matchComponents(bom, skuId); + const matched = matchBomSources(businessBom, productBom, skuId); const components = matched?.components || []; return { @@ -294,6 +505,7 @@ async function getMkhBomList(query = {}) { accessoryQtySum: sumQty(components), bomConfigured: components.length > 0 ? '已配置' : '未配置', bomMatchType: matched?.matchType || null, + bomDataSource: matched?.dataSource || null, source: 'business_db', }; }); @@ -303,9 +515,11 @@ async function getMkhBomList(query = {}) { return { rows, total: Number(totalRow?.total || 0) + extraCount, - bomRowCount: bom.entries.length, + bomRowCount: countDistinctBomProducts(businessBom, productBom), + bomBusinessRowCount: businessBom.entries.length, + bomProductDbRowCount: productBom.entries.length, bomExtraCount: extraCount, - bomReady: bom.entries.length > 0, + bomReady: businessBom.entries.length > 0 || productBom.entries.length > 0, page, pageSize, configuredInPage: rows.filter((row) => row.accessoryCount > 0).length, @@ -338,13 +552,17 @@ async function getMkhBomDetail(mkhSkuId) { category: toText(comboRows[0].category), } : null; - const bom = await loadPgBom(); - const matched = matchComponents(bom, skuId); + const [businessBom, productBom] = await Promise.all([ + loadBusinessBom(), + loadPgBom(), + ]); + const matched = matchBomSources(businessBom, productBom, skuId); const components = matched?.components || []; // 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息 if (!combo) { - const entry = bom.entries.find((item) => normalizeCode(item.skuCode) === normalizeCode(skuId)); + 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; @@ -408,10 +626,21 @@ async function getMkhBomDetail(mkhSkuId) { items, bomReady: items.length > 0, bomMatchType: matched?.matchType || null, + bomDataSource: matched?.dataSource || null, }; } module.exports = { getMkhBomList, getMkhBomDetail, + _private: { + normalizeCode, + groupBusinessBomRows, + matchComponents, + matchBomSources, + countDistinctBomProducts, + buildBusinessBomExtraRows, + mergeExtraRows, + filterExtraRows, + }, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js new file mode 100644 index 0000000..603c7b6 --- /dev/null +++ b/backend/test/mkhBomService.test.js @@ -0,0 +1,95 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { _private } = require('../src/services/mkhBomService'); + +test('business MKH BOM rows are grouped for drill-down and match codes without separators', () => { + const bom = _private.groupBusinessBomRows([ + { + comboSkuId: 'MKH-DNY004624N1015V01-1-DNZ038569N1280V01-1', + componentSkuId: 'DNY004624N1015V01', + componentName: '配件 A', + qty: 1, + }, + { + comboSkuId: 'MKH-DNY004624N1015V01-1-DNZ038569N1280V01-1', + componentSkuId: 'DNZ038569N1280V01', + componentName: '配件 B', + qty: 2, + }, + ]); + + const matched = _private.matchComponents( + bom, + 'MKHDNY004624N1015V011DNZ038569N1280V011' + ); + + assert.equal(bom.entries.length, 1); + assert.equal(matched.matchType, 'sku'); + assert.deepEqual( + matched.components.map((item) => [item.barcode, item.name, item.qty]), + [ + ['DNY004624N1015V01', '配件 A', 1], + ['DNZ038569N1280V01', '配件 B', 2], + ] + ); +}); + +test('duplicate business BOM component rows are consolidated without losing quantity', () => { + const bom = _private.groupBusinessBomRows([ + { comboSkuId: 'MKH-A-1', componentSkuId: 'PART-1', qty: 1 }, + { comboSkuId: 'MKH-A-1', componentSkuId: 'PART-1', qty: 2 }, + ]); + + const matched = _private.matchComponents(bom, 'MKHA1'); + assert.equal(matched.components.length, 1); + assert.equal(matched.components[0].qty, 3); +}); + +test('business BOM takes precedence while product database BOM remains a fallback', () => { + 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(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 }, + ]); + bom.existingSkuKeys = new Set(['MKHA1']); + + 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']); +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 192d65a..8ca850e 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -83,7 +83,7 @@ const PrintPreview = ({ data, onClose }) => {
- MKH 商品编码取自业务库;配件明细与数量取自「产品资料系统数据库」(BOM); + MKH 商品编码与组合配件取自业务库;「产品资料系统数据库」中维护的配件 BOM 作为补充; 配件仓位取库位主数据,未维护时显示「未维护」。
@@ -218,6 +218,11 @@ const MkhBomData = () => { 资料库 ) : null} + {row.source === 'business_bom' ? ( + + 组合BOM + + ) : null} ), }, @@ -295,8 +300,8 @@ const MkhBomData = () => { {items.length === 0 ? (
- 该 MKH 商品在「产品资料系统数据库」中暂无配件 BOM 录入。 - 在产品资料系统里维护好该商品的配件明细后,此处会自动显示配件明细、数量与仓位。 + 该 MKH 商品在业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。 + 上游维护好该商品的组合配件后,此处会自动显示配件明细、数量与仓位。
) : null} @@ -347,8 +352,8 @@ const MkhBomData = () => { 数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条); - 配件明细与数量取自「产品资料系统数据库」(PostgreSQL / furniture3d_prod)中模块化商品维护的配件明细(配件条码 + 数量); - 资料库中已维护 BOM 但业务库无对应成品的编码会置顶并标注「资料库」;配件仓位取库位主数据,未维护时显示「未维护」。 + 配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充; + 已维护组合 BOM 但 jst_sku 无对应成品档案的编码会置顶并标注「组合BOM」,资料库补充编码标注「资料库」;配件仓位未维护时显示「未维护」。 @@ -375,13 +380,14 @@ const MkhBomData = () => { type={data.bomRowCount > 0 ? 'info' : 'warning'} showIcon message={data.bomRowCount > 0 - ? `产品资料系统数据库已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` - : '产品资料系统数据库中尚未录入模块化商品的配件 BOM'} + ? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` + : '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'} description={( MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku; - 配件明细与数量取自「产品资料系统数据库」中模块化商品维护的 BOM。 - 资料库中已维护 BOM、但业务库无对应成品的编码({data.bomExtraCount || 0} 条)会置顶并标注「资料库」。 + 配件明细与数量取自业务组合 BOM({data.bomBusinessRowCount || 0} 个), + 「产品资料系统数据库」维护的 BOM({data.bomProductDbRowCount || 0} 个)作为补充。 + 已维护 BOM、但 jst_sku 无对应成品档案的编码({data.bomExtraCount || 0} 条)会置顶并标注数据来源。 在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 )} -- 2.47.2