cangku/backend/src/services/mkhBomService.js
2026-09-17 11:42:50 +08:00

803 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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