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:
parent
0e0fcbb378
commit
5e029f831f
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user