fix: show MKH BOM details in drill-down
This commit is contained in:
parent
4c2ebef988
commit
3ccba5f460
@ -10,7 +10,12 @@
|
|||||||
// 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'成品' (约 4436 条)
|
||||||
// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198
|
// · 形态 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'
|
// public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails'
|
||||||
// · 每一项形如 { code, name, role/assetType, barcode, quantity }
|
// · 每一项形如 { code, name, role/assetType, barcode, quantity }
|
||||||
// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 },
|
// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 },
|
||||||
@ -25,10 +30,10 @@
|
|||||||
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
|
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
|
||||||
// (无 SKU 条码),也不能直接当配件 BOM。
|
// (无 SKU 条码),也不能直接当配件 BOM。
|
||||||
//
|
//
|
||||||
// ③ 配件仓位 —— 资料库 external.warehouse_locations_v1(库位主数据,当前仅 A-01);
|
// ④ 配件仓位 —— 业务库 SKU 档案中的库位字段;
|
||||||
// 业务库 jst_sku.bin / other_bin 全库为空,故多数配件显示「未维护」。
|
// 业务库 jst_sku.bin / other_bin 全库为空,故多数配件显示「未维护」。
|
||||||
//
|
//
|
||||||
// ④ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
|
// ⑤ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
|
||||||
|
|
||||||
const cloudDatabase = require('../config/cloudDatabase');
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
const productDb = require('../config/productInformationDatabase');
|
const productDb = require('../config/productInformationDatabase');
|
||||||
@ -36,6 +41,10 @@ const productDb = require('../config/productInformationDatabase');
|
|||||||
const DEFAULT_PAGE_SIZE = 50;
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
const MAX_PAGE_SIZE = 200;
|
const MAX_PAGE_SIZE = 200;
|
||||||
const MAX_EXTRA_LOOKUP = 300;
|
const MAX_EXTRA_LOOKUP = 300;
|
||||||
|
const BOM_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
let businessBomCache = null;
|
||||||
|
let productBomCache = null;
|
||||||
|
|
||||||
// MKH 商品编码 = 模块化成品
|
// MKH 商品编码 = 模块化成品
|
||||||
const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品'`;
|
const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品'`;
|
||||||
@ -93,12 +102,129 @@ function parseComponents(raw) {
|
|||||||
.filter(Boolean);
|
.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 }
|
* 返回 { bySku: Map, bySpuPrefix: Array, entries: Array }
|
||||||
* 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。
|
* 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。
|
||||||
*/
|
*/
|
||||||
async function loadPgBom() {
|
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 empty = { bySku: new Map(), bySpuPrefix: [], entries: [] };
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
@ -110,52 +236,64 @@ async function loadPgBom() {
|
|||||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s
|
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s
|
||||||
WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`;
|
WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`;
|
||||||
|
|
||||||
let rows = [];
|
const pending = (async () => {
|
||||||
try {
|
let rows = [];
|
||||||
rows = await productDb.query(sql);
|
try {
|
||||||
} catch (error) {
|
rows = await productDb.query(sql);
|
||||||
console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message);
|
} catch (error) {
|
||||||
return empty;
|
console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message);
|
||||||
}
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
// 商品名称(资料库档案表,取不到不影响主流程)
|
// 商品名称(资料库档案表,取不到不影响主流程)
|
||||||
const nameMap = new Map();
|
const nameMap = new Map();
|
||||||
try {
|
try {
|
||||||
const nameRows = await productDb.query(
|
const nameRows = await productDb.query(
|
||||||
`SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`);
|
`SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`);
|
||||||
nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName)));
|
nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message);
|
console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bySku = new Map();
|
const bySku = new Map();
|
||||||
const bySpuPrefix = [];
|
const bySpuPrefix = [];
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const components = parseComponents(row.components);
|
const components = parseComponents(row.components);
|
||||||
if (components.length === 0) return;
|
if (components.length === 0) return;
|
||||||
|
|
||||||
const skuCode = toText(row.sku_code);
|
const skuCode = toText(row.sku_code);
|
||||||
const spu = toText(row.spu);
|
const spu = toText(row.spu);
|
||||||
const skuKey = normalizeCode(skuCode);
|
const skuKey = normalizeCode(skuCode);
|
||||||
if (skuKey) bySku.set(skuKey, components);
|
if (skuKey) bySku.set(skuKey, components);
|
||||||
|
|
||||||
const spuKey = normalizeCode(spu);
|
const spuKey = normalizeCode(spu);
|
||||||
if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu });
|
if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu });
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
skuCode,
|
skuCode,
|
||||||
spu,
|
spu,
|
||||||
productName: nameMap.get(toText(row.product_id)) || spu || skuCode,
|
productName: nameMap.get(toText(row.product_id)) || spu || skuCode,
|
||||||
components,
|
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;
|
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);
|
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)
|
* 列表:MKH 模块化成品(业务库)+ 配件种类/数量(资料库 BOM)
|
||||||
*/
|
*/
|
||||||
@ -245,8 +446,18 @@ async function getMkhBomList(query = {}) {
|
|||||||
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`;
|
const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`;
|
||||||
|
|
||||||
const bom = await loadPgBom();
|
const [businessBom, productBom] = await Promise.all([
|
||||||
const extraRows = await buildExtraRows(bom);
|
loadBusinessBom(),
|
||||||
|
loadPgBom(),
|
||||||
|
]);
|
||||||
|
const productExtraRows = await buildExtraRows(productBom);
|
||||||
|
const extraRows = filterExtraRows(
|
||||||
|
mergeExtraRows(
|
||||||
|
buildBusinessBomExtraRows(businessBom),
|
||||||
|
productExtraRows
|
||||||
|
),
|
||||||
|
query
|
||||||
|
);
|
||||||
const extraCount = extraRows.length;
|
const extraCount = extraRows.length;
|
||||||
|
|
||||||
// 追加行占据全局前 extraCount 个位置,业务库行顺延
|
// 追加行占据全局前 extraCount 个位置,业务库行顺延
|
||||||
@ -282,7 +493,7 @@ async function getMkhBomList(query = {}) {
|
|||||||
|
|
||||||
const mapped = businessRows.map((row) => {
|
const mapped = businessRows.map((row) => {
|
||||||
const skuId = toText(row.skuId);
|
const skuId = toText(row.skuId);
|
||||||
const matched = matchComponents(bom, skuId);
|
const matched = matchBomSources(businessBom, productBom, skuId);
|
||||||
const components = matched?.components || [];
|
const components = matched?.components || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -294,6 +505,7 @@ async function getMkhBomList(query = {}) {
|
|||||||
accessoryQtySum: sumQty(components),
|
accessoryQtySum: sumQty(components),
|
||||||
bomConfigured: components.length > 0 ? '已配置' : '未配置',
|
bomConfigured: components.length > 0 ? '已配置' : '未配置',
|
||||||
bomMatchType: matched?.matchType || null,
|
bomMatchType: matched?.matchType || null,
|
||||||
|
bomDataSource: matched?.dataSource || null,
|
||||||
source: 'business_db',
|
source: 'business_db',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -303,9 +515,11 @@ async function getMkhBomList(query = {}) {
|
|||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
total: Number(totalRow?.total || 0) + extraCount,
|
total: Number(totalRow?.total || 0) + extraCount,
|
||||||
bomRowCount: bom.entries.length,
|
bomRowCount: countDistinctBomProducts(businessBom, productBom),
|
||||||
|
bomBusinessRowCount: businessBom.entries.length,
|
||||||
|
bomProductDbRowCount: productBom.entries.length,
|
||||||
bomExtraCount: extraCount,
|
bomExtraCount: extraCount,
|
||||||
bomReady: bom.entries.length > 0,
|
bomReady: businessBom.entries.length > 0 || productBom.entries.length > 0,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
configuredInPage: rows.filter((row) => row.accessoryCount > 0).length,
|
configuredInPage: rows.filter((row) => row.accessoryCount > 0).length,
|
||||||
@ -338,13 +552,17 @@ async function getMkhBomDetail(mkhSkuId) {
|
|||||||
category: toText(comboRows[0].category),
|
category: toText(comboRows[0].category),
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
const bom = await loadPgBom();
|
const [businessBom, productBom] = await Promise.all([
|
||||||
const matched = matchComponents(bom, skuId);
|
loadBusinessBom(),
|
||||||
|
loadPgBom(),
|
||||||
|
]);
|
||||||
|
const matched = matchBomSources(businessBom, productBom, skuId);
|
||||||
const components = matched?.components || [];
|
const components = matched?.components || [];
|
||||||
|
|
||||||
// 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息
|
// 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息
|
||||||
if (!combo) {
|
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) {
|
if (!entry) {
|
||||||
const error = new Error('未找到该 MKH 商品');
|
const error = new Error('未找到该 MKH 商品');
|
||||||
error.statusCode = 404;
|
error.statusCode = 404;
|
||||||
@ -408,10 +626,21 @@ async function getMkhBomDetail(mkhSkuId) {
|
|||||||
items,
|
items,
|
||||||
bomReady: items.length > 0,
|
bomReady: items.length > 0,
|
||||||
bomMatchType: matched?.matchType || null,
|
bomMatchType: matched?.matchType || null,
|
||||||
|
bomDataSource: matched?.dataSource || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getMkhBomList,
|
getMkhBomList,
|
||||||
getMkhBomDetail,
|
getMkhBomDetail,
|
||||||
|
_private: {
|
||||||
|
normalizeCode,
|
||||||
|
groupBusinessBomRows,
|
||||||
|
matchComponents,
|
||||||
|
matchBomSources,
|
||||||
|
countDistinctBomProducts,
|
||||||
|
buildBusinessBomExtraRows,
|
||||||
|
mergeExtraRows,
|
||||||
|
filterExtraRows,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
95
backend/test/mkhBomService.test.js
Normal file
95
backend/test/mkhBomService.test.js
Normal 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']);
|
||||||
|
});
|
||||||
@ -83,7 +83,7 @@ const PrintPreview = ({ data, onClose }) => {
|
|||||||
|
|
||||||
<div className="mkh-print-foot">
|
<div className="mkh-print-foot">
|
||||||
<span>
|
<span>
|
||||||
MKH 商品编码取自业务库;配件明细与数量取自「产品资料系统数据库」(BOM);
|
MKH 商品编码与组合配件取自业务库;「产品资料系统数据库」中维护的配件 BOM 作为补充;
|
||||||
配件仓位取库位主数据,未维护时显示「未维护」。
|
配件仓位取库位主数据,未维护时显示「未维护」。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -218,6 +218,11 @@ const MkhBomData = () => {
|
|||||||
<span className="mkh-tag mkh-tag--src">资料库</span>
|
<span className="mkh-tag mkh-tag--src">资料库</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{row.source === 'business_bom' ? (
|
||||||
|
<Tooltip title="该编码存在于业务组合 BOM,业务库 jst_sku 暂无对应成品档案">
|
||||||
|
<span className="mkh-tag mkh-tag--src">组合BOM</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -295,8 +300,8 @@ const MkhBomData = () => {
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="mkh-detail-tip">
|
<div className="mkh-detail-tip">
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
该 MKH 商品在「产品资料系统数据库」中暂无配件 BOM 录入。
|
该 MKH 商品在业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。
|
||||||
在产品资料系统里维护好该商品的配件明细后,此处会自动显示配件明细、数量与仓位。
|
上游维护好该商品的组合配件后,此处会自动显示配件明细、数量与仓位。
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -347,8 +352,8 @@ const MkhBomData = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary" className="mkh-note">
|
<Text type="secondary" className="mkh-note">
|
||||||
数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条);
|
数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条);
|
||||||
配件明细与数量取自「产品资料系统数据库」(PostgreSQL / furniture3d_prod)中模块化商品维护的配件明细(配件条码 + 数量);
|
配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充;
|
||||||
资料库中已维护 BOM 但业务库无对应成品的编码会置顶并标注「资料库」;配件仓位取库位主数据,未维护时显示「未维护」。
|
已维护组合 BOM 但 jst_sku 无对应成品档案的编码会置顶并标注「组合BOM」,资料库补充编码标注「资料库」;配件仓位未维护时显示「未维护」。
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -375,13 +380,14 @@ const MkhBomData = () => {
|
|||||||
type={data.bomRowCount > 0 ? 'info' : 'warning'}
|
type={data.bomRowCount > 0 ? 'info' : 'warning'}
|
||||||
showIcon
|
showIcon
|
||||||
message={data.bomRowCount > 0
|
message={data.bomRowCount > 0
|
||||||
? `产品资料系统数据库已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
|
? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
|
||||||
: '产品资料系统数据库中尚未录入模块化商品的配件 BOM'}
|
: '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'}
|
||||||
description={(
|
description={(
|
||||||
<span>
|
<span>
|
||||||
MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku;
|
MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku;
|
||||||
配件明细与数量取自「产品资料系统数据库」中模块化商品维护的 BOM。
|
配件明细与数量取自业务组合 BOM({data.bomBusinessRowCount || 0} 个),
|
||||||
资料库中已维护 BOM、但业务库无对应成品的编码({data.bomExtraCount || 0} 条)会置顶并标注「资料库」。
|
「产品资料系统数据库」维护的 BOM({data.bomProductDbRowCount || 0} 个)作为补充。
|
||||||
|
已维护 BOM、但 jst_sku 无对应成品档案的编码({data.bomExtraCount || 0} 条)会置顶并标注数据来源。
|
||||||
在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
|
在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user