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');