fix: show MKH BOM details in drill-down #7

Merged
lunanting merged 1 commits from codex/fix-mkh-bom-drilldown into main 2026-09-16 16:58:55 +08:00
3 changed files with 387 additions and 57 deletions

View File

@ -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,
},
};

View File

@ -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']);
});

View File

@ -83,7 +83,7 @@ const PrintPreview = ({ data, onClose }) => {
<div className="mkh-print-foot">
<span>
MKH 商品编码取自业务库;配件明细与数量取自「产品资料系统数据库」(BOM);
MKH 商品编码与组合配件取自业务库;「产品资料系统数据库」中维护的配件 BOM 作为补充;
配件仓位取库位主数据,未维护时显示「未维护」。
</span>
</div>
@ -218,6 +218,11 @@ const MkhBomData = () => {
<span className="mkh-tag mkh-tag--src">资料库</span>
</Tooltip>
) : null}
{row.source === 'business_bom' ? (
<Tooltip title="该编码存在于业务组合 BOM,业务库 jst_sku 暂无对应成品档案">
<span className="mkh-tag mkh-tag--src">组合BOM</span>
</Tooltip>
) : null}
</Space>
),
},
@ -295,8 +300,8 @@ const MkhBomData = () => {
{items.length === 0 ? (
<div className="mkh-detail-tip">
<Text type="secondary">
该 MKH 商品在「产品资料系统数据库」中暂无配件 BOM 录入。
在产品资料系统里维护好该商品的配件明细后,此处会自动显示配件明细、数量与仓位。
该 MKH 商品在业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。
上游维护好该商品的组合配件后,此处会自动显示配件明细、数量与仓位。
</Text>
</div>
) : null}
@ -347,8 +352,8 @@ const MkhBomData = () => {
</Text>
<Text type="secondary" className="mkh-note">
数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条);
配件明细与数量取自「产品资料系统数据库」(PostgreSQL / furniture3d_prod)中模块化商品维护的配件明细(配件条码 + 数量);
资料库中已维护 BOM 但业务库无对应成品的编码会置顶并标注「资料库」;配件仓位取库位主数据,未维护时显示「未维护」。
配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充;
已维护组合 BOM 但 jst_sku 无对应成品档案的编码会置顶并标注「组合BOM」,资料库补充编码标注「资料库」;配件仓位未维护时显示「未维护」。
</Text>
</div>
</header>
@ -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={(
<span>
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 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
</span>
)}