From a93691f5d8e7270bdbaaa10655c0cf5f602e59d3 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Wed, 30 Sep 2026 17:43:30 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20MKH-BOM=20=E9=85=8D=E4=BB=B6=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E5=A2=9E=E5=8A=A0=E7=AE=B1=E8=A1=A8=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=AD=A3=E3=80=8C=E9=85=8D=E4=BB=B6=E6=9C=89?= =?UTF-8?q?=E8=B4=A7=E5=8D=B4=E5=88=A4=E9=85=8D=E4=BB=B6=E4=B8=8D=E8=B6=B3?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因 - 配件监控页取实时库存是三级兜底(inventoryInfo → 同仓箱表 packInventoryInfo → 监控快照 jsStock),而 MKH-BOM 的 loadComponentStock **只查 jst_inventoryInfo**, 一级来源到底就结束。 - 上游 jst_inventoryInfo 存在滞后:2026-09-30 该行停在 09-28 14:51 的值 0, 而 jst_packInventoryInfo 当日 10:30 已记 bin=ZT-30 qty=200。 - 结果:同一个仓(15718196)、同一个配件,配件监控页显示 200(判「正常」), MKH-BOM 判「配件不足」、可组装数 0。 案例 MKHDNZ0409260600849 的配件 DNZ040947N1440V01(瓶颈唯一)。 改动 - backend/src/services/mkhBomService.js · loadComponentStock():一条语句同时取两张表(UNION ALL + src 标记), 库存表有正库存以它为准;为 0 / 缺行时用同仓 jst_packInventoryInfo 的 bin 非空行合计兜底。**只补 0、不覆盖正库存**,与配件监控页 buildRealtimeStockSql 同口径;查询失败仍整体 degraded(不做判定)。 · 文件头 ⑪:修正「不能用 jst_packInventoryInfo 当配件库存 —— 与 MKH 配件 零交集」这条结论 —— 它是 09-17 的表状态(当时该表 bin 全空), 09-18 上游整表重建后已失效。 · 文件头 ⑤:同理修正该表的关联列与数据现状结论。PR #22 的提交信息声称 已更新 ⑤,但实际 diff 未改,此处补齐(该表 wms_co_id 关联、目标仓 727/794 命中、配件仓 743 个 SKU 有箱记录)。 - backend/test/mkhBomService.test.js · 新增 3 个用例:箱表兜底生效且不覆盖正库存 / SQL 形状防腐化(两张表 + bin 非空 + 限定仓)/ 查询失败仍降级不做判定。 对照组已验证:在修复前的实现上前两个用例失败(expected 200、actual 0)。 影响与验证 - 仅影响 MKH-BOM 的「配件不足」标记与可组装数;不改预警级别、排序与预警总数。 - 同一数据快照下的新旧口径对照(监控日 2026-09-29): 配件不足 26 → 24(2 行翻盘)、预警总数 29、缺货 23 两边一致, 0 行反向变化、0 处其他字段变化、0 处级别变化。 - 真库实测:DNZ040947N1440V01 0 → 200、DNZ040959N1440V01 0 → 200; 库存表本就有正数的对照配件(60 / 87)未被箱表覆盖。 - 负载(SET STATISTICS IO,300 编码一个分片):jst_inventoryInfo 4543 逻辑读 + jst_packInventoryInfo 947 逻辑读,物理读 0、Worktable 0;实测中位耗时 215ms → 391ms(+176ms/分片,配件全集 784 个约 3 个分片,占冷查 61s 的 1% 以内, 且结果按编码缓存 10 分钟)。 说明:SHOWPLAN 权限被拒(账号无该权限),改用 STATISTICS IO + 实测耗时 + 数据量 作为执行计划等价证据。 - 数据量边界:配件仓 jst_inventoryInfo 837 行、jst_packInventoryInfo 1163 行 (bin 非空 1024 行)。 - 后端:check-syntax 179 文件通过;node --test 393 项 392 通过 (唯一失败为既有环境问题 resilientSecretStore 权限断言,与本改动无关)。 --- backend/src/services/mkhBomService.js | 77 +++++++++++++----- backend/test/mkhBomService.test.js | 111 ++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 22 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index f585725..6fe8844 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -51,15 +51,18 @@ // 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。 // · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(), // 只打一条查询,按编码缓存 10 分钟。 -// ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`; -// 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。 -// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱** -// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、 -// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。 -// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到 -// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。 -// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题; -// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。 +// ⚠️ 关联列(09-19 修正,PR #22):仓名用 `jst_warehouseInfo.wms_co_id = p.wms_co_id` +// —— 09-19 实测该表 30,236 行 `wms_co_id` **0 NULL、14 个仓取值**。 +// ~~旧结论「该表 wms_co_id 全表 NULL,必须用 jst_warehouseInfo.Id = p.wh_id」~~ +// 只对 09-17 的表状态成立;误用 `w.Id = p.wh_id`(wh_id 仅 6 个取值、90% 为 1) +// 会让 727 个配件的仓位仓名被统一显示成「常州简尚家居有限公司」。 +// 🔥 该表 **小时级增量刷新**,且 2026-09-18 被上游整表重建 —— +// 09-17 的「全表 85,152 行、仅 3 个箱、`bin` / `bin_group` 全表为空、与 MKH 配件 +// **零交集**」这组结论**已全部失效**,别再照抄。 +// 09-19 实测:目标仓 794 个 SKU 命中 **727** 个(真货位如 ZT-8 / BCP01-01-1); +// 2026-09-30 实测:本配件仓(15718196)箱表有库存(bin 非空)的 SKU **734** 个。 +// → 配件货位 / 箱库存数据是真实存在并持续刷新的; +// 该结论同时修正 ⑪ 的配件库存来源(箱表可作兜底,见下)。 // // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 // @@ -303,13 +306,22 @@ // ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件 // 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集: // 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) -// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓 -// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。 -// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**, -// 跨仓正库存为 0,所以不跨仓汇总。 +// · 配件现有库存 **两级来源**(2026-09-30 修正,与配件监控页 `buildRealtimeStockSql` 同口径): +// ① rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓(wms_co_id = 15718196) +// —— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。 +// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**, +// 跨仓正库存为 0,所以不跨仓汇总。 +// ② 同仓 rpt.jst_packInventoryInfo 的 **bin 非空** 行合计 —— **仅当 ① 为 0 / 缺行时启用**。 +// 🔥 **推翻旧结论**(旧版此处写「不能用 jst_packInventoryInfo 当配件库存 —— +// 与 MKH 配件零交集」,那是 09-17 的表状态,09-18 上游重建后已失效,见 ⑤)。 +// 漏掉这一级会让「库存表滞后停在 0、箱表已有货」的配件被误判不足 —— +// 2026-09-30 案例 MKHDNZ0409260600849 的配件 DNZ040947N1440V01: +// inventoryInfo 停在 09-28 值 0,箱表当日 10:30 已记 bin=ZT-30 qty=200; +// 配件监控页显示 200(判「正常」),MKH-BOM 却因 0 判「配件不足」。 +// 当日 30 行配件不足预警里 **7 行**属此类误判(可组装数 0 → 30~46)。 +// ⚠️ 必须过滤 `bin` 为空的行 —— 那是装箱记录不是库存(见 ⑤); +// 且**只补 0、不覆盖正库存**(箱表可能只记部分装箱量,覆盖会压低有效库存)。 // · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。 -// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」, -// 与 MKH 配件零交集(见 ⑤),仓位口径专用。 // · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。 // 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0 // (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。 @@ -1845,6 +1857,10 @@ async function loadReplenishIndex() { /** * 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。 * · 按基础配件编码精确匹配,SUM(qty) 汇总 + * · **两级来源**(与配件监控页 buildRealtimeStockSql 同口径): + * ① jst_inventoryInfo 有正库存 → 以它为准 + * ② 为 0 / 缺行 → 用同仓 jst_packInventoryInfo 的 **bin 非空** 行合计兜底 + * (2026-09-30 修正:旧版只查 ①,导致「库存表滞后为 0、箱表已有货」的配件被误判不足) * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), * 且失败的分片不写缓存,下次调用重试 @@ -1872,25 +1888,42 @@ async function loadComponentStock(codes = []) { params[`cs${index}`] = code; return `@cs${index}`; }); + const codeFilter = `IN (${placeholders.join(', ')})`; + // 一条语句同时取两张表(UNION ALL + src 标记),避免多一次往返、也保证两个来源同一时点。 + // 库存表不筛 bin;箱表**必须**筛 bin 非空 —— bin 为空的是装箱记录,不是库存(见文件头 ⑤)。 // eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池 const rows = await cloudDatabase.query( - `SELECT i.sku_id AS code, SUM(i.qty) AS qty + `SELECT N'inv' AS src, i.sku_id AS code, SUM(i.qty) AS qty FROM dbo.jst_inventoryInfo i WITH (NOLOCK) - WHERE i.wms_co_id = @componentStockWmsCoId - AND i.sku_id IN (${placeholders.join(', ')}) - GROUP BY i.sku_id`, + WHERE i.wms_co_id = @componentStockWmsCoId AND i.sku_id ${codeFilter} + GROUP BY i.sku_id + UNION ALL + SELECT N'pack' AS src, p.sku_id AS code, SUM(p.qty) AS qty + FROM dbo.jst_packInventoryInfo p WITH (NOLOCK) + WHERE p.wms_co_id = @componentStockWmsCoId AND p.sku_id ${codeFilter} + AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL + GROUP BY p.sku_id`, params, { requestTimeout: 120000 } ); - const found = new Map(); + const inventoryQty = new Map(); + const packQty = new Map(); rows.forEach((row) => { const code = toText(row.code); - if (code) found.set(code, toNumber(row.qty) ?? 0); + if (!code) return; + const qty = toNumber(row.qty) ?? 0; + if (toText(row.src) === 'pack') packQty.set(code, qty); + else inventoryQty.set(code, qty); }); const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS; const writable = epoch === cacheEpoch; chunk.forEach((code) => { - const qty = found.get(code) ?? 0; + // 只补 0、不覆盖正库存:库存表有正数就认它,否则看箱表(箱表可能只记部分装箱量) + let qty = inventoryQty.get(code) ?? 0; + if (qty <= 0) { + const packStock = packQty.get(code) ?? 0; + if (packStock > 0) qty = packStock; + } // 本批结果仍要返回给调用方(stockMap),只是不再写缓存 if (writable) componentStockCache.set(code, { qty, expiresAt }); stockMap.set(code, qty); diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 4f380e5..ce4b006 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -1286,6 +1286,117 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn } }); +// ───────────────────────────────────────────────────────────────────────────── +// 配件库存的箱表兜底(2026-09-30 修正) +// +// 症状:配件监控页显示该配件有 200 件(货位 ZT-30、正常),MKH-BOM 却把成品 +// MKHDNZ0409260600849 判成「配件不足」、可组装数 0。 +// 成因:配件监控页是「库存表 → 同仓箱表 → 快照」三级兜底,而 MKH-BOM 的 +// loadComponentStock **只查 jst_inventoryInfo**。上游库存表滞后停在 0 时, +// 箱表(小时级)已经有货 → 两个页面结论相反。 +// 口径:与配件监控页 buildRealtimeStockSql 对齐 —— 库存表有正数就认它, +// 为 0 / 缺行才用同仓 jst_packInventoryInfo 的 bin 非空行合计兜底。 +// ───────────────────────────────────────────────────────────────────────────── + +test('配件库存兜底:库存表为 0 / 缺行时用同仓箱表补,但正库存不被覆盖', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const ONLY_PACK = 'DNZTESTPACK01'; // 库存表 0、箱表 200 → 必须取 200(误判就是这里) + const HAS_INV = 'DNZTESTPACK02'; // 库存表 5、箱表 300 → 必须取 5(箱表只记部分装箱量) + const PACK_ZERO = 'DNZTESTPACK03'; // 库存表 0、箱表 0 → 0 + const NEITHER = 'DNZTESTPACK04'; // 两表都没有 → 0 + + const INV_ROWS = [ // 库存表:滞后停在旧值 + { code: ONLY_PACK, qty: 0 }, + { code: HAS_INV, qty: 5 }, + { code: PACK_ZERO, qty: 0 }, + ]; + const PACK_ROWS = [ // 同仓箱表(bin 非空):小时级,已反映真实到货 + { code: ONLY_PACK, qty: 200 }, + { code: HAS_INV, qty: 300 }, + { code: PACK_ZERO, qty: 0 }, + ]; + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + // 按 SQL 形状分流:旧实现只查库存表(SQL 里没有箱表名)→ 拿不到箱表数据, + // 兜底失效、ONLY_PACK 被判 0(对照组 = 修复前必然失败)。 + if (!sql.includes('jst_packInventoryInfo')) return INV_ROWS; + return [ + ...INV_ROWS.map((row) => ({ src: 'inv', ...row })), + ...PACK_ROWS.map((row) => ({ src: 'pack', ...row })), + ]; + }; + + try { + svc.clearMkhBomCache(); + const { stockMap, degraded } = await svc._private.loadComponentStock([ + ONLY_PACK, HAS_INV, PACK_ZERO, NEITHER, + ]); + assert.equal(degraded, false); + assert.equal(stockMap.get(ONLY_PACK), 200, + '库存表为 0 时必须用箱表兜底,否则配件被误判不足(本问题的根因)'); + assert.equal(stockMap.get(HAS_INV), 5, + '库存表有正库存时必须以它为准,不能被箱表覆盖'); + assert.equal(stockMap.get(PACK_ZERO), 0, '箱表也是 0 时不能补出正数'); + assert.equal(stockMap.get(NEITHER), 0, '两表都查不到 = 该仓无库存,仍按 0'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('配件库存兜底:单条查询取两张表,且箱表必须过滤 bin 为空的行(防腐化)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const seen = []; + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + seen.push(sql); + return []; + }; + + try { + svc.clearMkhBomCache(); + await svc._private.loadComponentStock(['DNZTESTPACK05']); + assert.equal(seen.length, 1, '一个分片只应打一条查询(避免多一次往返与快照时点差异)'); + const sql = seen[0]; + assert.match(sql, /jst_packInventoryInfo/, '必须查箱表,否则兜底不存在'); + assert.match(sql, /NULLIF\(LTRIM\(RTRIM\(p\.bin\)\), N''\)\s+IS NOT NULL/, + '箱表必须过滤 bin 为空的行 —— 那是装箱记录,不是库存'); + assert.match(sql, /p\.wms_co_id = @componentStockWmsCoId/, + '箱表必须限定配件归属仓,不能跨仓汇总'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('配件库存兜底:查询失败仍降级为「不做判定」,不得当成 0', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const CODE = 'DNZTESTPACK06'; + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + throw new Error('模拟连接抖动'); + }; + + try { + svc.clearMkhBomCache(); + const { stockMap, degraded } = await svc._private.loadComponentStock([CODE]); + assert.equal(degraded, true, '查询失败必须降级,让调用方放弃配件不足判定(宁可少标不误标)'); + assert.equal(stockMap.get(CODE), undefined, '失败时不得把未知库存当作 0 返回'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService');