diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 61d83f8..3f992de 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -39,7 +39,9 @@ // // ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; // sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin, -// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。 +// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与实际仓库名称。 +// 尚未分箱时使用 rpt.jst_inventoryInfo 的正库存仓库;连库存记录也没有时, +// 回退到 jst_sku.other_3 的商品档案仓库,并明确标注没有箱号,避免误报具体仓位。 // // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 @@ -50,10 +52,12 @@ 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 商品编码 = 模块化成品 @@ -518,11 +522,12 @@ function matchesPackSku(componentCode, packSkuCode) { } 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 warehouse = warehouseId ? `仓库 ${warehouseId}` : ''; const position = [binGroup, bin].filter(Boolean).join(' / ') || (packId ? `箱号 ${packId}` : ''); return [warehouse, position].filter(Boolean).join(' · '); @@ -550,6 +555,37 @@ function buildPackLocationMap(rows, componentCodes) { return result; } +function buildInventoryWarehouseMap(rows, componentCodes) { + const positiveWarehouses = new Map(componentCodes.map((code) => [code, new Set()])); + const defaultWarehouses = new Map(); + + rows.forEach((row) => { + const componentCode = componentCodes.find((code) => normalizeCode(code) === normalizeCode(row.skuId)); + if (!componentCode) return; + + const defaultWarehouse = toText(row.defaultWarehouse); + if (defaultWarehouse) defaultWarehouses.set(componentCode, defaultWarehouse); + + const warehouseName = toText(row.warehouseName); + if (warehouseName && (toNumber(row.availableQty) ?? 0) > 0) { + positiveWarehouses.get(componentCode).add(warehouseName); + } + }); + + const result = new Map(); + componentCodes.forEach((code) => { + const warehouses = [...positiveWarehouses.get(code)]; + if (warehouses.length > 0) { + result.set(code, `${warehouses.join('、')}(未分箱)`); + return; + } + + const defaultWarehouse = defaultWarehouses.get(code); + if (defaultWarehouse) result.set(code, `${defaultWarehouse}(暂无箱号)`); + }); + return result; +} + /** * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。 * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。 @@ -672,28 +708,54 @@ async function getMkhBomDetail(mkhSkuId) { 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 (codes.length > 0) { - const placeholders = codes.map((_, index) => `@c${index}`).join(', '); - const codeParams = Object.fromEntries(codes.map((code, index) => [`c${index}`, code])); + 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 dbo.jst_inventoryInfo i - WHERE i.sku_id IN (${placeholders}) - GROUP BY i.sku_id`, + `SELECT s.sku_id AS skuId, + s.other_3 AS defaultWarehouse, + w.name AS warehouseName, + SUM(COALESCE(i.qty, 0)) AS qty, + SUM(COALESCE(i.qty, 0) + - COALESCE(i.order_lock, 0) + - COALESCE(i.pick_lock, 0)) AS availableQty + FROM dbo.jst_sku s WITH (NOLOCK) + LEFT JOIN rpt.jst_inventoryInfo i WITH (NOLOCK) ON i.sku_id = s.sku_id + LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = i.wms_co_id + WHERE s.sku_id IN (${placeholders}) + GROUP BY s.sku_id, s.other_3, i.wms_co_id, w.name`, codeParams, { requestTimeout: 180000 } ); - stockRows.forEach((row) => stockMap.set(toText(row.skuId), toNumber(row.qty) ?? 0)); + 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)); + }); + buildInventoryWarehouseMap(stockRows, uncachedCodes).forEach((location, code) => { + locationMap.set(code, location); + }); + stockQuerySucceeded = true; } catch (error) { console.warn('[MkhBom] 配件库存查询降级:', error.message); } try { const locationParams = {}; - const locationConditions = codes.map((code, index) => { + 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})`; @@ -701,23 +763,37 @@ async function getMkhBomDetail(mkhSkuId) { const locRows = await cloudDatabase.query( `SELECT p.sku_id AS skuId, p.pack_id AS packId, - p.wh_id AS warehouseId, + p.wms_co_id AS warehouseId, + w.name AS warehouseName, p.bin AS bin, p.bin_group AS binGroup, SUM(p.qty) AS qty - FROM rpt.jst_packInventoryInfo p + FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) + LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = p.wms_co_id WHERE ${locationConditions.join(' OR ')} - GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group + GROUP BY p.sku_id, p.pack_id, p.wms_co_id, w.name, p.bin, p.bin_group HAVING SUM(p.qty) > 0`, locationParams, { requestTimeout: 120000 } ); - buildPackLocationMap(locRows, codes).forEach((location, code) => { + 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) => ({ @@ -755,5 +831,6 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, + buildInventoryWarehouseMap, }, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 93e7010..2b8d6d8 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -155,12 +155,27 @@ test('pack inventory locations match a BOM component and its package suffixes', test('pack inventory location prefers bins and falls back to positive-stock box ids', () => { const locations = _private.buildPackLocationMap([ { - skuId: 'PART-1-1', warehouseId: 1, binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, + skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, }, { skuId: 'PART-1-2', warehouseId: 1, binGroup: null, bin: '', packId: 'BOX-2', qty: 1, }, ], ['PART-1']); - assert.equal(locations.get('PART-1'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); + assert.equal(locations.get('PART-1'), '半成品仓 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); +}); + +test('inventory warehouse fallback distinguishes unboxed stock from a warehouse with no box record', () => { + const locations = _private.buildInventoryWarehouseMap([ + { + skuId: 'PART-1', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 12, + }, + { + skuId: 'PART-2', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 0, + }, + ], ['PART-1', 'PART-2', 'PART-3']); + + assert.equal(locations.get('PART-1'), '实际库存仓(未分箱)'); + assert.equal(locations.get('PART-2'), '默认半成品仓(暂无箱号)'); + assert.equal(locations.has('PART-3'), false); }); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index c6f2b55..ee278b3 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -84,7 +84,7 @@ const PrintPreview = ({ data, onClose }) => {
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; - 配件仓位取库位主数据,未维护时显示「未维护」。 + 配件仓位优先取包装箱与具体仓位,尚未分箱时显示库存仓库或商品档案仓库。
@@ -339,7 +339,8 @@ const MkhBomData = () => { 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; - 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。 + 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位优先显示箱号或具体仓位, + 尚未分箱时显示实际库存仓库,没有库存记录时显示商品档案仓库。