fix: read MKH details from official BOM tables

This commit is contained in:
lunanting 2026-09-16 20:56:26 +08:00
parent 3bf7b9f208
commit 8e5d427b3a
3 changed files with 282 additions and 44 deletions

View File

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

View File

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

View File

@ -75,7 +75,7 @@ const PrintPreview = ({ data, onClose }) => {
{items.length === 0 ? (
<tr>
<td className="mkh-print-sku">{text(data.skuId)}</td>
<td colSpan={3} className="mkh-muted">该 MKH 商品的配件明细尚未录入「产品资料系统数据库」</td>
<td colSpan={3} className="mkh-muted">该 MKH 商品尚无可用的聚水潭商品 BOM 明细</td>
</tr>
) : null}
</tbody>
@ -83,7 +83,7 @@ const PrintPreview = ({ data, onClose }) => {
<div className="mkh-print-foot">
<span>
MKH 商品编码与组合配件取自业务库;「产品资料系统数据库」中维护的配件 BOM 作为补充;
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充;
配件仓位取库位主数据,未维护时显示「未维护」。
</span>
</div>
@ -286,8 +286,8 @@ const MkhBomData = () => {
{items.length === 0 ? (
<div className="mkh-detail-tip">
<Text type="secondary">
该 MKH 商品在业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。
上游维护好该商品的组合配件后,此处会自动显示配件明细、数量与仓位。
该 MKH 商品在聚水潭商品 BOM、业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。
上游维护好该商品的正式 BOM 后,此处会自动显示配件明细、数量与仓位。
</Text>
</div>
) : null}
@ -338,7 +338,7 @@ const MkhBomData = () => {
</Text>
<Text type="secondary" className="mkh-note">
数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条);
配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充;
配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充;
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。
</Text>
</div>
@ -367,14 +367,14 @@ const MkhBomData = () => {
showIcon
message={data.bomRowCount > 0
? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
: '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'}
: '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'}
description={(
<span>
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 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
</span>
)}
/>