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