diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 77b16e9..26c9528 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -176,6 +176,42 @@ // ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded: // 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。 // +// ── ⑦-d 库存源时效兜底:当日入库读箱表(2026-09-19 新增)────────── +// 业务原话:「MKHDNZ0409260700491 这个今日已经入库,现有库存是有…… +// 点击同步最新数据这个按钮,数据没有更新,还是显示库存为 0」。 +// 🔥 根因:**页面唯一的库存源 `jst_inventoryInfo` 是「有变化才写 updateTime」的同步表, +// 当天新入库的成品未必有对应行**(品牌仓尤其稀疏,一天只更新几十行)。 +// 2026-09-19 实测: +// · 该 SKU 在 inventoryInfo 全表**只有主仓 14138566 的一行 qty=0**, +// 品牌仓**连行都没有**;而箱表品牌仓 bin='P-1' / qty=4 / createTime 当日 15:19 +// · 横向对照:inv 当天最后更新 06:41(品牌仓最后 04:42,当天仅 3 行), +// 箱表当天最后更新 17:01(小时级)→ **当日入库的货只有箱表看得见** +// · ⚠️ 且 inv 当天 06:41 后**再没更新过**(09-17 / 09-18 都是全天逐小时更新) +// → 大概率上游同步任务当天异常,需业务侧跟进(见文末遗留) +// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 +// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**, +// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。 +// 兜底规则(**只补 0,不覆盖有效值**): +// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致) +// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack') +// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime') +// ④ 否则回退监控快照 jsStock → (stockSource='monitor') +// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大** +// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。 +// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件), +// 不过滤会虚增。 +// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个** +// (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4, +// 同一箱 115870495000001201、货位 P-1,均为**当日新建**行); +// 预警列表随之 27 → 24 条(缺货 21 → 18),这 3 条都是「入库当天被误判缺货」。 +// 性能:箱表全量取数 **87ms**(brand 仓 + bin 非空 + GROUP BY),无额外负担。 +// +// ⚠️ 遗留(需业务 / 上游跟进):箱表兜底只解决「**当日入库看不见**」这一种情形。 +// inventoryInfo 若长期停更,**存量库存的时效性仍依赖该表恢复**——届时页面库存 +// 会停留在停更时点(本次即 09-19 06:41)的旧值,本项目无从感知。 +// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的 +// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。 +// // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 @@ -1005,10 +1041,30 @@ function buildReplenishMetrics(row = {}) { const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0; const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; + // 现有库存来源优先级(见文件头 ⑦-d): + // inventoryInfo 品牌仓有货 → 用它(旧口径不变) + // 否则箱表(小时级)有货 → 用它(补「当日入库、日更库存表还没同步」的盲区) + // 否则 inventoryInfo 有行(qty≤0) → 用 0(该仓确实没货,别再退回更旧的快照) + // 否则回退监控快照 const realtimeStock = toNumber(row.realtimeStock); + const packStock = toNumber(row.packStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; - const stock = hasRealtime ? realtimeStock : monitorStock; + let stock; + let stockSource; + if (hasRealtime && realtimeStock > 0) { + stock = realtimeStock; + stockSource = 'realtime'; + } else if (packStock !== null && packStock > 0) { + stock = packStock; + stockSource = 'pack'; + } else if (hasRealtime) { + stock = realtimeStock; + stockSource = 'realtime'; + } else { + stock = monitorStock; + stockSource = 'monitor'; + } // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); @@ -1044,7 +1100,7 @@ function buildReplenishMetrics(row = {}) { pendingOrderEarliest: toDateText(row.pendingOrderEarliest), orderDriven, thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), - stockSource: hasRealtime ? 'realtime' : 'monitor', + stockSource, safetyDays: toNumber(row.safetyDays), suggestStock: toNumber(row.suggestStock) ?? 0, monitorDate: toDateText(row.monitorDate), @@ -1628,11 +1684,22 @@ async function loadReplenishIndex() { REPLENISH_PARAMS, { requestTimeout: 180000 } ), + // 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到, + // 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。 + 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%' + AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL + GROUP BY p.sku_id`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询 loadOrderIndex(), - ]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => { + ]).then(([monitorRows, stockRows, packStockRows, workOrderIndex, orderIndex]) => { const workOrderBySku = workOrderIndex.bySku; const pendingOrderBySku = orderIndex.pendingBySku; const shippedBySku = orderIndex.shippedBySku; @@ -1641,6 +1708,11 @@ async function loadReplenishIndex() { const key = normalizeCode(row.skuId); if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); }); + const packStock = new Map(); + packStockRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) packStock.set(key, toNumber(row.qty)); + }); const seenMonitor = new Set(); const bySku = new Map(); @@ -1657,6 +1729,8 @@ async function loadReplenishIndex() { recentShippedQty: shippedBySku.get(key) || 0, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, + // 箱/货位库存(⑦-d):inventoryInfo 日更,当日入库的货只有这张小时级表有 + packStock: packStock.has(key) ? packStock.get(key) : null, monitorStock: row.monitorStock, safetyDays: row.safetyDays, suggestStock: row.suggestStock, @@ -2376,6 +2450,7 @@ module.exports = { buildReplenishMetrics, loadPendingWorkOrderIndex, loadOrderIndex, + loadReplenishIndex, parseOrderSkus, isMkhProductCode, isExcludedSaleShop, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 051922d..d7f0ad2 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -234,6 +234,33 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only assert.equal(zero.warningLevel, '缺货'); }); +test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => { + // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 + const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); + assert.equal(fillFromZero.stock, 4); + assert.equal(fillFromZero.stockSource, 'pack'); + assert.notEqual(fillFromZero.warningLevel, '缺货'); + + const fillFromMissing = metrics({ avgSale: 1, realtimeStock: null, packStock: 5, monitorStock: 0 }); + assert.equal(fillFromMissing.stock, 5); + assert.equal(fillFromMissing.stockSource, 'pack'); + + // 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存 + const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 }); + assert.equal(realtimeWins.stock, 4); + assert.equal(realtimeWins.stockSource, 'realtime'); + + // 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照 + const bothZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 0, monitorStock: 9 }); + assert.equal(bothZero.stock, 0); + assert.equal(bothZero.stockSource, 'realtime'); + + // 库存表整个品牌仓无记录、箱表也没货 → 才回退监控快照 + const snapshot = metrics({ avgSale: 1, realtimeStock: null, packStock: 0, monitorStock: 9 }); + assert.equal(snapshot.stock, 9); + assert.equal(snapshot.stockSource, 'monitor'); +}); + test('replenish: products without 7-day sales never warn', () => { const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); assert.equal(noSale.hasWarning, false); @@ -419,6 +446,64 @@ test('pending order: only MKH finished products count, combination codes are exc assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除'); }); +test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + const queried = []; + + // 复现 MKHDNZ0409260700491:当日 15:19 入库,日更的 inventoryInfo 还没有这条, + // 只有小时级的箱表有;监控快照 jsStock = 0。 + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_packInventoryInfo')) { + queried.push('pack'); + return [{ skuId: 'MKHDNZ0409260700491', qty: 4 }]; + } + if (sql.includes('jst_inventoryInfo')) { + queried.push('inventory'); + return []; + } + if (sql.includes('pm_stockMonitor')) { + queried.push('monitor'); + return [{ + skuId: 'MKHDNZ0409260700491', + fifteenAvgSale: 0, + monitorStock: 0, + safetyDays: 3, + suggestStock: 0, + monitorDate: new Date('2026-09-18T00:00:00Z'), + }]; + } + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + queried.push('outbound'); + return []; + } + if (sql.includes('jst_qmcust_orderlist')) { + queried.push('qmcust'); + return []; + } + queried.push('orders'); + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ input() {}, query: async () => ({ recordset: [] }) }), + }); + + try { + const index = await svc._private.loadReplenishIndex(); + assert.ok(queried.includes('pack'), '箱表必须被读到'); + assert.ok(queried.includes('inventory'), '实时库存表仍要读'); + const row = index.bySku.get('MKHDNZ0409260700491'); + assert.equal(row.stock, 4, '当日入库的货必须显示出来,不能显示 0'); + assert.equal(row.stockSource, 'pack'); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); + 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'); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index b74ac62..231dc8e 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -147,6 +147,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = { none: '未找到可用的收件人', }; +// 「现有库存」列的取值来源说明(后端 stockSource 字段): +// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更) +// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)—— +// 实时库存表当天还没同步到这条货(当日入库)时用它兜底 +// monitor = 两个实时源都没有记录,退回监控快照库存 +const STOCK_SOURCE_TEXT = { + realtime: '品牌仓实时库存', + pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)', + monitor: '品牌仓实时库存缺失,取监控快照库存', +}; + // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 // 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), // 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 @@ -564,9 +575,7 @@ const MkhBomData = () => { align: 'right', render: (value, row) => ( {number(value)}