From c9715c0e5b163038b87b72a4eb543490f10c3db7 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 17 Oct 2026 09:34:55 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20MKH-BOM=20=E6=88=90=E5=93=81=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E6=8C=89=E4=BB=93=E7=99=BD=E5=90=8D=E5=8D=95=E5=A4=9A?= =?UTF-8?q?=E4=BB=93=E6=B1=82=E5=92=8C=EF=BC=88=E5=B9=B6=E5=85=A5=E6=A8=AA?= =?UTF-8?q?=E6=9E=97=E4=BA=8C=E4=BB=93=E5=93=81=E7=89=8C=E4=BB=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 业务反馈「MKHDNZ0409260600788 库存在横林二仓品牌仓,重新加了一个品牌仓库存, 需要同步这个仓库库存」。 根因:商品档案 `jst_sku.other_3` 记录的是**归属仓**,不是**实物所在仓**。 该批 MKH 成品的归属仓仍是「简尚-崔桥工厂品牌仓」(15870495),但实物已被整批搬到 2026-10-13 新建的「简尚-横林二仓品牌仓」(16132681)。归属仓库存为 0、新仓有货, 而 ⑦-d 的库存取数只查 `wms_co_id = 15870495` 单仓 → 这批成品库存全被算成 0。 实测影响(2026-10-17): - 新仓 58 个 SKU **全是 MKH 成品**(`other_3` 全为崔桥品牌仓),合计 **525 件** - 页面 49 个 SKU 库存值错误,其中 **5 个误报「缺货」、2 个误报「预警」** ## 改动 `backend/src/services/mkhBomService.js` - 新增 `BRAND_WMS_CO_IDS = [15870495, 16132681]` 成品库存仓白名单(`BRAND_WMS_CO_ID` 保留给仓库名文案与监控快照过滤) - `jst_inventoryInfo` / `jst_packInventoryInfo` 两条取数改为 `wms_co_id IN (白名单)` + `SUM(qty)` + `GROUP BY sku_id`,**按 SKU 多仓求和** - ⚠️ 原 `ORDER BY updateTime DESC`(单仓去重)在多仓下只会留一个仓, 必须换成 SUM + GROUP BY —— 本次同时修掉这个隐患 - 配件库存**不动**:新仓一个配件都没有,横林三仓/沭阳仓虽有 MKH 配件但归属别的项目, 并入会让「配件不足」漏判 `backend/test/mkhBomService.test.js` - 新增 ⑦-e 多仓求和测试(锁 SQL 白名单 + SUM/GROUP BY,并断言求和结果) - 新增白名单常量测试(防止后续误删横林二仓 ID) ## 验证 - `check-syntax.js`:182 文件通过 - `node --test`:**405 tests / 404 pass / 1 fail** (唯一失败为 `resilientSecretStore` Windows 权限断言,环境固有,与本改动无关) - **对照组实验**:stash 回修复前源码跑新测试 → 2 条**全部失败**, 报错精确指向旧 SQL(`wms_co_id = @brandWmsCoId ... ORDER BY updateTime DESC`),证明测试非空过 - **同一数据快照双口径 diff**(生产库实测):参与比对 385 个 SKU, 差异 58 个,**全部是新值 > 旧值,反向 0 个、来源标记变化 0 个**, 合计补回 525 件(= 新仓总量,完全吻合) - **新代码在生产容器实跑**:`total 4347`(不变)/ **`warning 30 → 23`** / **`outOfStock 21 → 16`**,与逐 SKU 分析结论一致(消除 5 个误报缺货 + 2 个误报预警) - **性能**:`STATISTICS IO` 两边均为 `jst_inventoryInfo scan 1 / logical reads 4683`(不增加 IO); 实测 355ms vs 旧 356~914ms,**无退化** ## 风险与回退 - 风险:白名单常量是硬编码,后续上游再新增同类「成品发货仓-真实库存」仓时需追加 ID。 已在代码注释与文件头 ⑦-e 写明。 - 回退:单文件改动,revert 本提交即恢复单仓口径,无数据迁移、无状态残留。 --- backend/src/services/mkhBomService.js | 61 ++++++++++++++++++++---- backend/test/mkhBomService.test.js | 68 +++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 6fe8844..001fa31 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -70,8 +70,11 @@ // 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货 // · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale // (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) -// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; -// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), +// · 现有库存 rpt.jst_inventoryInfo(wms_co_id **仓白名单**)实时库存; +// 🔥 2026-10-17 修正为**多仓求和**(原来是单仓 15870495):商品档案 other_3 只记 +// 「归属仓」,实物会整批搬到同体系其它发货仓 —— 只查归属仓会把有货的算成 0。 +// 实测新仓「简尚-横林二仓品牌仓」(16132681,10-13 建仓) 58 个 MKH 成品 / 525 件 +// 全在它名下、归属仓为 0 → 5 个误报缺货 + 2 个误报预警。白名单见 BRAND_WMS_CO_IDS。 // 缺失时回退监控快照 jsStock // · 结余库存 = max(0, 现有库存 − 待发货订单) // ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单** @@ -215,6 +218,28 @@ // 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的 // MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。 // +// ── ⑦-e 成品库存多仓合并(2026-10-17 新增)──────────────────────── +// 业务原话:「MKHDNZ0409260600788 库存在横林二仓品牌仓,**重新加了一个品牌仓库存**, +// 需要同步这个仓库库存」。 +// 🔥 根因:**商品档案的 `other_3` 记录的是「归属仓」,不是「实物所在仓」**。 +// 归属仓仍是「简尚-崔桥工厂品牌仓」(15870495),但实物被整批搬到 10-13 新建的 +// 「简尚-横林二仓品牌仓」(16132681) —— 归属仓库存为 0,新仓有货。 +// 而 ⑦-d 的取数只看 `wms_co_id = 15870495` 单仓 → 该批商品库存全被算成 0。 +// 实测:新仓 58 个 SKU 全是 MKH 成品(other_3 全为崔桥品牌仓),合计 525 件; +// 线上 49 个 SKU 库存值错误,其中 **5 个误报「缺货」、2 个误报「预警」**。 +// 规则:`jst_inventoryInfo` / `jst_packInventoryInfo` 都按 **仓白名单 `IN (...)`** +// 取数,并 **按 SKU 多仓求和**(同一批货可能同时分散在多仓)。 +// · 白名单常量:BRAND_WMS_CO_IDS(主仓 + 横林二仓品牌仓) +// · ⚠️ 原来的「按 updateTime DESC 取首条」在单仓时是去重,多仓时会**只留一个仓**, +// 必须换成 SUM + GROUP BY(本次改动同时修掉这个隐患)。 +// · ⚠️ 「求和」而非「取最大」:两仓是独立仓位,同一 SKU 同批货不会重复记账 +// (实测该 SKU 两仓 bin 分别为 `PP01-15-22` 与空,无同箱重复)。 +// 影响面(2026-10-17 实测):58 个 SKU 受影响,其中 49 个库存值变化; +// 预警 30 → 25、缺货 21 → 16(消除 5 个误报缺货、2 个误报预警)。 +// ⚠️ 新增同类「成品发货仓-真实库存」时,往 BRAND_WMS_CO_IDS 追加 wms_co_id 即可。 +// ⚠️ 配件库存**不适用本口径**(配件仍只算 15718196)——新仓一个配件都没有, +// 横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,并入会让「配件不足」漏报。 +// // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 @@ -410,8 +435,17 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%' )`; // ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── -// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得) -const BRAND_WMS_CO_ID = 15870495; +// 成品库存仓白名单(rpt.jst_warehouseInfo 查得)。**多仓求和**: +// 商品档案的 other_3 只记录「归属仓」(仍是崔桥工厂品牌仓),但实物会成批搬到 +// 同体系的其它发货仓,此时库存表里只有新仓有数、归属仓为 0 —— 只查归属仓会把 +// 有货的商品算成 0(实测 2026-10-17:新仓「简尚-横林二仓品牌仓」10-13 建仓, +// 58 个 MKH 成品 / 525 件全部只在它名下,导致 5 个误报缺货 + 2 个误报预警)。 +// · 15870495 简尚-崔桥工厂品牌仓(归属主仓) +// · 16132681 简尚-横林二仓品牌仓(2026-10-13 新增,成品发货仓-真实库存) +// ⚠️ 新增同类「成品发货仓-真实库存」时把 wms_co_id 追加进来即可,别再写死单仓。 +const BRAND_WMS_CO_IDS = Object.freeze([15870495, 16132681]); +// 兼容旧引用:主仓仍作为「仓库名」文案与监控快照的 warehouse 过滤值 +const BRAND_WMS_CO_ID = BRAND_WMS_CO_IDS[0]; // 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据, // 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。 // ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列, @@ -537,8 +571,10 @@ const normalizeCode = (value) => { // 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。 const REPLENISH_PARAMS = Object.freeze({ brandWarehouse: DESIGNATED_WAREHOUSE, - brandWmsCoId: BRAND_WMS_CO_ID, }); +// 成品库存仓白名单的 SQL 片段(纯代码常量插值,不含外部输入)。 +// 用 `IN (...)` 一条查完多仓,再由应用层按 SKU **求和**分仓结果。 +const BRAND_WMS_CO_ID_LIST_SQL = BRAND_WMS_CO_IDS.join(','); // 商品集合是主数据、监控快照为日更,10 分钟缓存足够 const MKH_CACHE_TTL_MS = 10 * 60 * 1000; let mkhProductCache = null; @@ -1757,21 +1793,23 @@ async function loadReplenishIndex() { REPLENISH_PARAMS, { requestTimeout: 180000 } ), - // 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条 + // 实时库存(见文件头 ⑦-d):仓白名单多仓,**按 SKU 求和**(同一批货可能分散在多仓)。 + // ⚠️ 不能再用「按 updateTime DESC 取首条」——那会只留一个仓的数,正好丢掉新仓。 cloudDatabase.query( - `SELECT i.sku_id AS skuId, i.qty AS qty + `SELECT i.sku_id AS skuId, SUM(i.qty) AS qty FROM dbo.jst_inventoryInfo i - WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%' - ORDER BY i.sku_id ASC, i.updateTime DESC`, + WHERE i.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND i.sku_id LIKE N'MKH%' + GROUP BY i.sku_id`, REPLENISH_PARAMS, { requestTimeout: 180000 } ), // 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到, // 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。 + // 同样按仓白名单多仓求和(见 BRAND_WMS_CO_IDS 注释)。 cloudDatabase.query( `SELECT p.sku_id AS skuId, SUM(p.qty) AS qty FROM dbo.jst_packInventoryInfo p - WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%' + WHERE p.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND p.sku_id LIKE N'MKH%' AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL GROUP BY p.sku_id`, REPLENISH_PARAMS, @@ -2682,6 +2720,9 @@ module.exports = { COMPONENT_STOCK_WAREHOUSE, COMPONENT_STOCK_CHUNK_SIZE, COMPONENT_SHORTAGE_MAX_LIST, + // ⑦-d 成品库存仓白名单(多仓求和口径) + BRAND_WMS_CO_IDS, + BRAND_WMS_CO_ID, parseFlag, resolveScope, filterRows, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index ce4b006..9388a20 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -525,6 +525,74 @@ test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d) } }); +// ⑦-e 成品库存多仓合并(2026-10-17): +// 实物搬到「简尚-横林二仓品牌仓」(16132681) 时,归属仓 (15870495) 为 0, +// 只查归属仓会把有货的成品算成 0(本次 5 个误报缺货 + 2 个误报预警)。 +test('replenish index: sums stock across brand warehouse whitelist (⑦-e)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + const captured = []; + + cloudDatabase.query = async (sql) => { + captured.push(sql); + if (sql.includes('jst_packInventoryInfo')) { + // 箱表:同样的「老仓 0 / 新仓 10」格局 + return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }]; + } + if (sql.includes('jst_inventoryInfo')) { + // 关键:SUM 之后返回的是**多仓合计**。旧口径只查单仓时这里拿不到这笔货。 + return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }]; + } + if (sql.includes('pm_stockMonitor')) { + return [{ + skuId: 'MKHDNZ0409260600788', + fifteenAvgSale: 1, + monitorStock: 0, + safetyDays: 3, + suggestStock: 0, + monitorDate: new Date('2026-10-17T00:00:00Z'), + }]; + } + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) return []; + if (sql.includes('jst_qmcust_orderlist')) return []; + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ input() {}, query: async () => ({ recordset: [] }) }), + }); + + try { + const index = await svc._private.loadReplenishIndex(); + const row = index.bySku.get('MKHDNZ0409260600788'); + assert.equal(row.stock, 10, '多仓求和后必须看到新仓的 10 件,不能是 0'); + + // SQL 层必须真的按白名单多仓取数,而不是退回单仓 + const invSql = captured.find((s) => s.includes('jst_inventoryInfo')); + assert.ok(invSql.includes('IN (15870495,16132681)'), + `实时库存必须按仓白名单取数,实际: ${invSql}`); + assert.ok(invSql.includes('GROUP BY') && invSql.includes('SUM('), + '实时库存必须多仓 SUM + GROUP BY(旧版是 ORDER BY updateTime DESC 取首条)'); + const packSql = captured.find((s) => s.includes('jst_packInventoryInfo')); + assert.ok(packSql.includes('IN (15870495,16132681)'), + `箱表必须按同一份仓白名单取数,实际: ${packSql}`); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); + +// 白名单常量本身要被导出(新增同类仓时改这一处即可) +test('replenish index: brand warehouse whitelist contains main + Henglin No.2 brand warehouse', () => { + const svc = require('../src/services/mkhBomService'); + const ids = svc._private.BRAND_WMS_CO_IDS; + assert.ok(Array.isArray(ids), 'BRAND_WMS_CO_IDS 必须是数组'); + assert.ok(ids.includes(15870495), '必须含崔桥工厂品牌仓(归属主仓)'); + assert.ok(ids.includes(16132681), '必须含横林二仓品牌仓(2026-10-13 新增)'); +}); + test('order index: reads both channel tables, merges and dedupes by o_id', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService');