803 lines
27 KiB
JavaScript
803 lines
27 KiB
JavaScript
'use strict';
|
||
|
||
// 模块化库存监控 —— MKH-BOM 资料
|
||
//
|
||
// 页面结构(与业务确认的模板):MKH商品编码 | 配件明细 | 数量 | 配件仓位
|
||
//
|
||
// ── 数据口径 ──────────────────────────────────────────────────────
|
||
//
|
||
// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt)
|
||
// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品'
|
||
// AND other_3 = N'简尚-崔桥工厂品牌仓'
|
||
// AND 该编码存在正式 BOM 配件明细 (当前 4223 条)
|
||
// · 商品档案里的 item_type/labels 不能单独作为成品依据:上游也会把部分配件
|
||
// 标成“成品/模块化成品”。只有 jst_skuBomItem 的父项才进入本页面。
|
||
// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198
|
||
//
|
||
// ② 配件明细(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)作为补充来源
|
||
// 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 仓库档案的编码追加为商品。
|
||
//
|
||
// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU /
|
||
// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。
|
||
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
|
||
// (无 SKU 条码),也不能直接当配件 BOM。
|
||
//
|
||
// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo;
|
||
// sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin,
|
||
// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_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 BOM_CACHE_TTL_MS = 2 * 60 * 1000;
|
||
const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000;
|
||
const COMPONENT_LOCATION_CACHE_TTL_MS = 2 * 60 * 1000;
|
||
|
||
let businessBomCache = null;
|
||
let officialBomIndexCache = null;
|
||
const officialBomDetailCache = new Map();
|
||
const componentLocationCache = new Map();
|
||
let productBomCache = null;
|
||
|
||
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
|
||
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
|
||
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
|
||
const MKH_WHERE = `s.sku_id LIKE N'MKH%'
|
||
AND s.item_type = N'成品'
|
||
AND s.other_3 = N'${DESIGNATED_WAREHOUSE}'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM rpt.jst_skuBomItem parent_bom WITH (NOLOCK)
|
||
WHERE parent_bom.sku_id = s.sku_id
|
||
AND NULLIF(LTRIM(RTRIM(parent_bom.map_outer_sku_id)), N'') IS NOT NULL
|
||
)`;
|
||
|
||
const toText = (value) => {
|
||
if (value === null || value === undefined) return null;
|
||
const text = String(value).trim();
|
||
return text === '' ? null : text;
|
||
};
|
||
|
||
const toNumber = (value) => {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
|
||
// 编码归一化:去横线/空格 + 大写,用于跨库匹配
|
||
const normalizeCode = (value) => {
|
||
const text = toText(value);
|
||
return text ? text.toUpperCase().replace(/[-\s]/g, '') : null;
|
||
};
|
||
|
||
const buildFilters = (query = {}) => {
|
||
const conditions = [];
|
||
const params = {};
|
||
|
||
const keyword = toText(query.keyword);
|
||
if (keyword) {
|
||
params.keyword = `%${keyword}%`;
|
||
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
|
||
}
|
||
|
||
return { conditions, params };
|
||
};
|
||
|
||
/**
|
||
* 把 componentDetails 原始 JSON 解析成统一结构。
|
||
*/
|
||
function parseComponents(raw) {
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw
|
||
.map((item) => {
|
||
if (!item || typeof item !== 'object') return null;
|
||
const barcode = toText(item.barcode);
|
||
const code = toText(item.code);
|
||
const name = toText(item.name) || barcode || code;
|
||
return {
|
||
barcode: barcode || code,
|
||
code,
|
||
name,
|
||
role: toText(item.role) || toText(item.assetType),
|
||
qty: toNumber(item.quantity) ?? 1,
|
||
};
|
||
})
|
||
.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 };
|
||
}
|
||
|
||
/**
|
||
* 将聚水潭正式商品 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();
|
||
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
|
||
p.id AS product_id,
|
||
p.spu AS spu,
|
||
COALESCE(s->>'skuCode', s->>'barcode') AS sku_code,
|
||
s->'componentDetails' AS components
|
||
FROM public."IntakeNonIntegratedProduct" p
|
||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s
|
||
WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`;
|
||
|
||
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 bySku = new Map();
|
||
const bySpuPrefix = [];
|
||
const entries = [];
|
||
|
||
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 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,
|
||
});
|
||
});
|
||
|
||
// 前缀长优先,避免短前缀抢先命中
|
||
bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length);
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 按编码匹配配件:先精确 SKU,再退化到 SPU 前缀。
|
||
*/
|
||
function matchComponents(bom, skuId) {
|
||
const key = normalizeCode(skuId);
|
||
if (!key) return null;
|
||
|
||
const exact = bom.bySku.get(key);
|
||
if (exact) return { components: exact, matchType: 'sku' };
|
||
|
||
const hit = bom.bySpuPrefix.find((entry) => key.startsWith(entry.prefix));
|
||
if (hit) return { components: hit.components, matchType: 'spu' };
|
||
|
||
return null;
|
||
}
|
||
|
||
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' };
|
||
|
||
const productMatch = matchComponents(productBom, skuId);
|
||
if (productMatch) return { ...productMatch, dataSource: 'product_db' };
|
||
|
||
return null;
|
||
}
|
||
|
||
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 summary;
|
||
}
|
||
|
||
const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0);
|
||
|
||
function matchesPackSku(componentCode, packSkuCode) {
|
||
const component = toText(componentCode)?.toUpperCase();
|
||
const packSku = toText(packSkuCode)?.toUpperCase();
|
||
if (!component || !packSku) return false;
|
||
return packSku === component || packSku.startsWith(`${component}-`);
|
||
}
|
||
|
||
function formatPackLocation(row) {
|
||
const warehouseName = toText(row.warehouseName);
|
||
const warehouseId = toText(row.warehouseId);
|
||
const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : '');
|
||
const binGroup = toText(row.binGroup);
|
||
const bin = toText(row.bin);
|
||
const packId = toText(row.packId);
|
||
const position = [binGroup, bin].filter(Boolean).join(' / ')
|
||
|| (packId ? `箱号 ${packId}` : '');
|
||
return [warehouse, position].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
function buildPackLocationMap(rows, componentCodes) {
|
||
const locations = new Map(componentCodes.map((code) => [code, new Set()]));
|
||
|
||
rows.forEach((row) => {
|
||
const componentCode = componentCodes.find((code) => matchesPackSku(code, row.skuId));
|
||
if (!componentCode) return;
|
||
const location = formatPackLocation(row);
|
||
if (location) locations.get(componentCode).add(location);
|
||
});
|
||
|
||
const result = new Map();
|
||
locations.forEach((values, code) => {
|
||
const all = [...values];
|
||
if (!all.length) return;
|
||
const visible = all.slice(0, 10);
|
||
result.set(code, all.length > visible.length
|
||
? `${visible.join('、')} 等 ${all.length} 个位置`
|
||
: visible.join('、'));
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。
|
||
* BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。
|
||
*/
|
||
async function getMkhBomList(query = {}) {
|
||
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
|
||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||
const { conditions, params } = buildFilters(query);
|
||
|
||
const whereSql = conditions.length
|
||
? `FROM dbo.jst_sku s WHERE ${MKH_WHERE} AND ${conditions.join(' AND ')}`
|
||
: `FROM dbo.jst_sku s WHERE ${MKH_WHERE}`;
|
||
|
||
const sortField = String(query.sortField || 'skuId');
|
||
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||
const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`;
|
||
|
||
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 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(officialBom, businessBom, productBom, skuId);
|
||
const components = matched?.components || [];
|
||
|
||
return {
|
||
skuId,
|
||
productName: toText(row.productName),
|
||
category: toText(row.category),
|
||
material: toText(row.material),
|
||
accessoryCount: components.length,
|
||
accessoryQtySum: sumQty(components),
|
||
bomConfigured: components.length > 0 ? '已配置' : '未配置',
|
||
bomMatchType: matched?.matchType || null,
|
||
bomDataSource: matched?.dataSource || null,
|
||
source: 'business_db',
|
||
};
|
||
});
|
||
|
||
return {
|
||
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: mapped.filter((row) => row.accessoryCount > 0).length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 明细:单个 MKH 成品的配件清单
|
||
* 结构按业务模板:配件明细 | 数量 | 配件仓位
|
||
*/
|
||
async function getMkhBomDetail(mkhSkuId) {
|
||
const skuId = toText(mkhSkuId);
|
||
if (!skuId) {
|
||
const error = new Error('缺少 MKH 商品编码');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
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 AND ${MKH_WHERE}`,
|
||
{ mkhSkuId: skuId },
|
||
{ requestTimeout: 120000 }
|
||
);
|
||
|
||
const combo = comboRows[0] ? {
|
||
skuId: toText(comboRows[0].skuId),
|
||
productName: toText(comboRows[0].productName),
|
||
category: toText(comboRows[0].category),
|
||
} : null;
|
||
|
||
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(officialBom, businessBom, productBom, skuId);
|
||
const components = matched?.components || [];
|
||
|
||
const codes = components.map((item) => item.barcode).filter(Boolean);
|
||
const stockMap = new Map();
|
||
const locationMap = new Map();
|
||
const now = Date.now();
|
||
const uncachedCodes = codes.filter((code) => {
|
||
const cached = componentLocationCache.get(normalizeCode(code));
|
||
if (!cached || cached.expiresAt <= now) return true;
|
||
stockMap.set(code, cached.stock);
|
||
if (cached.location) locationMap.set(code, cached.location);
|
||
return false;
|
||
});
|
||
|
||
if (uncachedCodes.length > 0) {
|
||
const placeholders = uncachedCodes.map((_, index) => `@c${index}`).join(', ');
|
||
const codeParams = Object.fromEntries(uncachedCodes.map((code, index) => [`c${index}`, code]));
|
||
let stockQuerySucceeded = false;
|
||
let packQuerySucceeded = false;
|
||
|
||
try {
|
||
const stockRows = await cloudDatabase.query(
|
||
`SELECT i.sku_id AS skuId, SUM(i.qty) AS qty
|
||
FROM rpt.jst_inventoryInfo i WITH (NOLOCK)
|
||
WHERE i.sku_id IN (${placeholders})
|
||
GROUP BY i.sku_id`,
|
||
codeParams,
|
||
{ requestTimeout: 180000 }
|
||
);
|
||
stockRows.forEach((row) => {
|
||
const code = uncachedCodes.find((candidate) => normalizeCode(candidate) === normalizeCode(row.skuId));
|
||
if (!code) return;
|
||
stockMap.set(code, (stockMap.get(code) || 0) + (toNumber(row.qty) ?? 0));
|
||
});
|
||
stockQuerySucceeded = true;
|
||
} catch (error) {
|
||
console.warn('[MkhBom] 配件库存查询降级:', error.message);
|
||
}
|
||
|
||
try {
|
||
const locationParams = {};
|
||
const locationConditions = uncachedCodes.map((code, index) => {
|
||
locationParams[`lc${index}`] = code;
|
||
locationParams[`lp${index}`] = `${code}-%`;
|
||
return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`;
|
||
});
|
||
const locRows = await cloudDatabase.query(
|
||
`SELECT p.sku_id AS skuId,
|
||
p.pack_id AS packId,
|
||
p.wh_id AS warehouseId,
|
||
p.bin AS bin,
|
||
p.bin_group AS binGroup,
|
||
SUM(p.qty) AS qty
|
||
FROM rpt.jst_packInventoryInfo p WITH (NOLOCK)
|
||
WHERE ${locationConditions.join(' OR ')}
|
||
GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group
|
||
HAVING SUM(p.qty) > 0`,
|
||
locationParams,
|
||
{ requestTimeout: 120000 }
|
||
);
|
||
buildPackLocationMap(locRows, uncachedCodes).forEach((location, code) => {
|
||
locationMap.set(code, location);
|
||
});
|
||
packQuerySucceeded = true;
|
||
} catch (error) {
|
||
console.warn('[MkhBom] 配件箱号/仓位查询降级:', error.message);
|
||
}
|
||
|
||
if (stockQuerySucceeded && packQuerySucceeded) {
|
||
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
|
||
uncachedCodes.forEach((code) => {
|
||
componentLocationCache.set(normalizeCode(code), {
|
||
stock: stockMap.get(code) ?? 0,
|
||
location: locationMap.get(code) || null,
|
||
expiresAt,
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
const items = components.map((component, index) => ({
|
||
index: index + 1,
|
||
skuId: component.barcode,
|
||
barcode: component.barcode,
|
||
skuName: component.name,
|
||
role: component.role,
|
||
qty: toNumber(component.qty) ?? 0,
|
||
location: locationMap.get(component.barcode) || null,
|
||
stock: stockMap.get(component.barcode) ?? 0,
|
||
}));
|
||
|
||
return {
|
||
skuId: toText(combo.skuId),
|
||
productName: toText(combo.productName),
|
||
category: toText(combo.category),
|
||
items,
|
||
bomReady: items.length > 0,
|
||
bomMatchType: matched?.matchType || null,
|
||
bomDataSource: matched?.dataSource || null,
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
getMkhBomList,
|
||
getMkhBomDetail,
|
||
_private: {
|
||
normalizeCode,
|
||
groupOfficialBomRows,
|
||
groupBusinessBomRows,
|
||
matchComponents,
|
||
matchBomSources,
|
||
summarizeConfiguredBoms,
|
||
matchesPackSku,
|
||
formatPackLocation,
|
||
buildPackLocationMap,
|
||
},
|
||
};
|