Merge pull request 'fix: restrict MKH products and use official BOM tables' (#9) from codex/bom-warehouse-filter-0916 into main
This commit is contained in:
commit
50202557d9
@ -7,48 +7,58 @@
|
||||
// ── 数据口径 ──────────────────────────────────────────────────────
|
||||
//
|
||||
// ① 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)—— 业务中台组合商品表(主来源)
|
||||
// ② 配件明细(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: 板材 },
|
||||
// { 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 行,因此视图恒为空、不要用。
|
||||
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
|
||||
// (无 SKU 条码),也不能直接当配件 BOM。
|
||||
//
|
||||
// ④ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo;
|
||||
// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo;
|
||||
// sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin,
|
||||
// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。
|
||||
//
|
||||
// ⑤ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
|
||||
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
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;
|
||||
const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
let businessBomCache = null;
|
||||
let officialBomIndexCache = null;
|
||||
const officialBomDetailCache = new Map();
|
||||
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;
|
||||
@ -159,7 +169,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();
|
||||
@ -313,7 +474,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' };
|
||||
|
||||
@ -323,15 +487,25 @@ 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(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;
|
||||
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);
|
||||
@ -377,101 +551,8 @@ function buildPackLocationMap(rows, componentCodes) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 找出「资料库里已维护 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);
|
||||
@ -487,32 +568,9 @@ 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([
|
||||
loadBusinessBom(),
|
||||
loadPgBom(),
|
||||
]);
|
||||
const productExtraRows = await buildExtraRows(productBom);
|
||||
const extraRows = filterExtraRows(
|
||||
mergeExtraRows(
|
||||
buildBusinessBomExtraRows(businessBom),
|
||||
productExtraRows
|
||||
),
|
||||
query
|
||||
);
|
||||
const extraCount = extraRows.length;
|
||||
|
||||
// 追加行占据全局前 extraCount 个位置,业务库行顺延
|
||||
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(
|
||||
const [businessRows, totalRows, officialBomIndex, businessBom, productBom] = await Promise.all([
|
||||
cloudDatabase.query(
|
||||
`SELECT
|
||||
s.sku_id AS skuId,
|
||||
s.name AS productName,
|
||||
@ -520,21 +578,26 @@ async function getMkhBomList(query = {}) {
|
||||
s.other_10 AS material
|
||||
${whereSql}
|
||||
ORDER BY ${orderSql}
|
||||
OFFSET ${businessOffset} ROWS FETCH NEXT ${businessTake} ROWS ONLY`,
|
||||
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 }
|
||||
);
|
||||
),
|
||||
cloudDatabase.query(
|
||||
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
loadOfficialBomIndex(),
|
||||
loadBusinessBom(),
|
||||
loadPgBom(),
|
||||
]);
|
||||
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 {
|
||||
@ -551,19 +614,18 @@ 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,
|
||||
bomOfficialRowCount: bomSummary.official,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -582,36 +644,31 @@ 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;
|
||||
|
||||
const [businessBom, productBom] = await Promise.all([
|
||||
if (!combo) {
|
||||
const error = new Error('未找到指定仓库的该 MKH 商品');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
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 || [];
|
||||
|
||||
// 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息
|
||||
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();
|
||||
@ -690,13 +747,11 @@ module.exports = {
|
||||
getMkhBomDetail,
|
||||
_private: {
|
||||
normalizeCode,
|
||||
groupOfficialBomRows,
|
||||
groupBusinessBomRows,
|
||||
matchComponents,
|
||||
matchBomSources,
|
||||
countDistinctBomProducts,
|
||||
buildBusinessBomExtraRows,
|
||||
mergeExtraRows,
|
||||
filterExtraRows,
|
||||
summarizeConfiguredBoms,
|
||||
matchesPackSku,
|
||||
formatPackLocation,
|
||||
buildPackLocationMap,
|
||||
|
||||
@ -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,40 +98,52 @@ 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('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('official Jushuitan BOM takes precedence over all fallback sources', () => {
|
||||
const officialBom = _private.groupOfficialBomRows([
|
||||
{ skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 2 },
|
||||
]);
|
||||
bom.existingSkuKeys = new Set(['MKHA1']);
|
||||
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 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);
|
||||
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('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' }]
|
||||
);
|
||||
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 },
|
||||
]);
|
||||
businessBom.existingSkuKeys = new Set(['MKHA1', 'MKHB1']);
|
||||
const productBom = {
|
||||
bySku: new Map([['MKHB1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]),
|
||||
bySpuPrefix: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
assert.deepEqual(rows.map((row) => row.skuId), ['MKH-A-1', 'MKH-B-1']);
|
||||
});
|
||||
const officialBom = _private.groupOfficialBomRows([
|
||||
{ skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 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(officialBom, businessBom, productBom), {
|
||||
official: 1,
|
||||
business: 0,
|
||||
product: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('pack inventory locations match a BOM component and its package suffixes', () => {
|
||||
|
||||
@ -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>
|
||||
@ -210,21 +210,7 @@ const MkhBomData = () => {
|
||||
width: 240,
|
||||
fixed: 'left',
|
||||
sorter: true,
|
||||
render: (value, row) => (
|
||||
<Space size={6}>
|
||||
<span className="mkh-sku">{text(value)}</span>
|
||||
{row.source === 'product_db' ? (
|
||||
<Tooltip title="该编码仅在「产品资料系统数据库」中维护,业务库 jst_sku 暂无对应成品">
|
||||
<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>
|
||||
),
|
||||
render: (value) => <span className="mkh-sku">{text(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
@ -300,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}
|
||||
@ -351,9 +337,9 @@ const MkhBomData = () => {
|
||||
模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位
|
||||
</Text>
|
||||
<Text type="secondary" className="mkh-note">
|
||||
数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条);
|
||||
配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充;
|
||||
已维护组合 BOM 但 jst_sku 无对应成品档案的编码会置顶并标注「组合BOM」,资料库补充编码标注「资料库」;配件仓位未维护时显示「未维护」。
|
||||
数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条);
|
||||
配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充;
|
||||
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。
|
||||
</Text>
|
||||
</div>
|
||||
</header>
|
||||
@ -381,14 +367,14 @@ const MkhBomData = () => {
|
||||
showIcon
|
||||
message={data.bomRowCount > 0
|
||||
? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
|
||||
: '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'}
|
||||
: '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'}
|
||||
description={(
|
||||
<span>
|
||||
MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku;
|
||||
配件明细与数量取自业务组合 BOM({data.bomBusinessRowCount || 0} 个),
|
||||
「产品资料系统数据库」维护的 BOM({data.bomProductDbRowCount || 0} 个)作为补充。
|
||||
已维护 BOM、但 jst_sku 无对应成品档案的编码({data.bomExtraCount || 0} 条)会置顶并标注数据来源。
|
||||
在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
|
||||
MKH 商品编码({data.total || 0} 条)仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”;
|
||||
配件明细优先取自聚水潭商品 BOM({data.bomOfficialRowCount || 0} 个),
|
||||
业务组合 BOM({data.bomBusinessRowCount || 0} 个)与产品资料系统({data.bomProductDbRowCount || 0} 个)作为补充。
|
||||
BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。
|
||||
上游补充正式 BOM 后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user