fix: MKH-BOM 库存增加箱表兜底,修复「当日入库当天显示库存为 0」

现象:MKHDNZ0409260700491 当天已入库、实物有货,但页面上「现有库存」一直是 0;
点「同步最新数据」也不变。

根因:页面唯一的库存源 rpt.jst_inventoryInfo 是「有变化才写 updateTime」的同步表,
品牌仓尤其稀疏。该 SKU 在 inventoryInfo 里只有主仓 14138566 的一行 qty=0,
品牌仓连行都没有;而小时级的箱表 rpt.jst_packInventoryInfo 里,
品牌仓 bin='P-1' / qty=4 / createTime 当天 15:19 —— 当日入库的货只有箱表看得见。
页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。
「同步最新数据」按钮只清服务端缓存,不触发上游同步,所以点多少次都是 0。

改法(只补 0,不覆盖有效值):
  ① inventoryInfo 品牌仓 qty > 0        → 用它(stockSource='realtime',旧口径不变)
  ② 否则箱表品牌仓 bin 非空行合计 > 0    → 用它(stockSource='pack')
  ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0
  ④ 否则回退监控快照 jsStock             → stockSource='monitor'

为什么不拿箱表当主源:两表都 > 0 且不等的 28 个成品里 25 个是 inventoryInfo 更大
(箱表只记已装箱的货,散货不计),拿它当主源会系统性低估库存、误报缺货。
另外必须过滤 bin 为空的行——那是装箱记录不是库存(品牌仓合计 21 件)。

影响面(实测):品牌仓 4,408 个成品里只有 3 个走箱表兜底
(MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4,
同一箱 115870495000001201、货位 P-1、均为当天新建行),
预警列表 27 → 24 条(缺货 21 → 18),少的正是这 3 条「入库当天被误判缺货」。

前端:「现有库存」列 tooltip 增加 pack 来源说明(当天入库、实时库存表尚未同步)。

验证:check-syntax 151 文件通过;node --test 339/340(唯一失败是既有的
resilientSecretStore Windows 权限断言);mkhBomService.test.js 47/47;
HTTP 实测该 SKU stock=4 / stockSource=pack,点「同步最新数据」后仍正确。

遗留(需业务/上游跟进):inventoryInfo 当天 06:41 后就没再更新过
(09-17 / 09-18 都是全天逐小时更新),疑似上游同步任务异常;
箱表兜底只解决「当日入库看不见」,存量库存仍依赖该表恢复。
This commit is contained in:
yanhui 2026-09-19 18:11:38 +08:00
parent 0e0fcbb378
commit 5e029f831f
3 changed files with 175 additions and 6 deletions

View File

@ -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,

View File

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

View File

@ -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) => (
<Tooltip
title={row.stockSource === 'realtime'
? '品牌仓实时库存'
: '品牌仓实时库存缺失,取监控快照库存'}
title={STOCK_SOURCE_TEXT[row.stockSource] || STOCK_SOURCE_TEXT.monitor}
>
<span>{number(value)}</span>
</Tooltip>