fix: MKH刷新改用实时订单库存口径

This commit is contained in:
134 2026-09-23 11:12:08 +08:00
parent fa91b9242c
commit b8e60a19d2
5 changed files with 72 additions and 26 deletions

View File

@ -109,7 +109,7 @@ router.put('/notification-center', async (req, res) => {
router.post('/sync', async (req, res) => {
try {
const cache = clearMkhBomCache();
const snapshot = await getMkhBomList(req.body?.query || {});
const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' });
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: { cache, snapshot } });
} catch (error) {

View File

@ -1069,7 +1069,8 @@ function buildReplenishMetrics(row = {}) {
const orderDailySale = recentShippedQty > 0
? Number((recentShippedQty / SALE_WINDOW_DAYS).toFixed(4))
: 0;
const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0;
const preferLiveData = row.preferLiveData === true;
const useOrderDailySale = preferLiveData || (snapshotDailySale <= 0 && orderDailySale > 0);
const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale;
// 现有库存来源优先级(见文件头 ⑦-d):
@ -1092,9 +1093,13 @@ function buildReplenishMetrics(row = {}) {
} else if (hasRealtime) {
stock = realtimeStock;
stockSource = 'realtime';
} else {
} else if (!preferLiveData) {
stock = monitorStock;
stockSource = 'monitor';
} else {
// 手动实时刷新不能再退回延迟快照;两个当前库存源均无记录时按 0 展示并明确标记来源。
stock = 0;
stockSource = 'live-missing';
}
// 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量
@ -1114,7 +1119,7 @@ function buildReplenishMetrics(row = {}) {
const base = {
dailySale,
// 日销来源(⑦-a):'snapshot' 上游快照 / 'orders' 订单表实算兜底
dailySaleSource: useOrderDailySale ? 'orders' : 'snapshot',
dailySaleSource: preferLiveData ? 'orders-live' : (useOrderDailySale ? 'orders' : 'snapshot'),
// 上游快照原值(保留下来做对照,业务对不上源系统数字时能看出是兜底生效)
snapshotDailySale,
// 近 SALE_WINDOW_DAYS 天已发货件数(⑦-a,已排除样品/测试店)
@ -1756,13 +1761,14 @@ async function loadReplenishIndex() {
const seenMonitor = new Set();
const bySku = new Map();
const liveBySku = new Map();
let monitorDate = null;
monitorRows.forEach((row) => {
const key = normalizeCode(row.skuId);
if (!key || seenMonitor.has(key)) return;
seenMonitor.add(key);
if (!monitorDate) monitorDate = toDateText(row.monitorDate);
bySku.set(key, buildReplenishMetrics({
const metricInput = {
// 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据)
avgSale: row.fifteenAvgSale,
// 日销兜底见文件头 ⑦-a:快照漏算时用订单表实算的近15天发货量
@ -1777,11 +1783,16 @@ async function loadReplenishIndex() {
monitorDate: row.monitorDate,
...(workOrderBySku.get(key) || null),
...(pendingOrderBySku.get(key) || null),
}));
};
bySku.set(key, buildReplenishMetrics(metricInput));
liveBySku.set(key, buildReplenishMetrics({ ...metricInput, preferLiveData: true }));
});
return {
bySku,
liveBySku,
latestStock,
packStock,
workOrderBySku,
pendingOrderBySku,
shippedBySku,
@ -1919,7 +1930,8 @@ async function loadMkhComponentReadiness(skuIds = []) {
* MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。
* 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。
*/
async function buildMergedReplenishRows() {
async function buildMergedReplenishRows(options = {}) {
const preferLiveData = options.preferLiveData === true;
const [products, replenishIndex] = await Promise.all([
loadMkhProductIndex(),
loadReplenishIndex(),
@ -1927,13 +1939,20 @@ async function buildMergedReplenishRows() {
const rows = products.map((product) => {
const key = normalizeCode(product.skuId);
const metrics = replenishIndex.bySku.get(key);
const metrics = (preferLiveData ? replenishIndex.liveBySku : replenishIndex.bySku).get(key);
if (metrics) return { ...product, ...metrics };
// 监控快照里没有该商品时,仍要带上在途加工单(⑧)、待发货订单(⑩)与
// 近15天已发货(⑦-a)信息 —— 后者正是「快照里根本没这个编码」时唯一的日销来源
return {
...product,
...buildReplenishMetrics({
preferLiveData,
realtimeStock: replenishIndex.latestStock?.has(key)
? replenishIndex.latestStock.get(key)
: null,
packStock: replenishIndex.packStock?.has(key)
? replenishIndex.packStock.get(key)
: null,
...(replenishIndex.workOrderBySku?.get(key) || null),
...(replenishIndex.pendingOrderBySku?.get(key) || null),
recentShippedQty: replenishIndex.shippedBySku?.get(key) || 0,
@ -2144,9 +2163,10 @@ async function getMkhBomList(query = {}) {
// 默认按可用天数升序 —— 最紧急的排最前
const sortField = String(query.sortField || 'stockDays');
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh);
const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([
buildMergedReplenishRows(),
buildMergedReplenishRows({ preferLiveData }),
loadOfficialBomIndex(),
loadBusinessBom(),
loadPgBom(),
@ -2218,6 +2238,8 @@ async function getMkhBomList(query = {}) {
...stats,
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
saleWindowDays: SALE_WINDOW_DAYS,
dataMode: preferLiveData ? 'live' : 'snapshot',
refreshedAt: new Date().toISOString(),
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
workOrderDegraded: replenishIndex.workOrderDegraded === true,
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
@ -2393,9 +2415,10 @@ async function listMkhBomExportRows(query = {}) {
const scope = resolveScope(query);
const sortField = String(query.sortField || 'stockDays');
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh);
// 与列表接口完全相同的筛选 / 排序口径:先全量合并,再内存筛选排序(见文件头 ⑦)
const { rows: merged, replenishIndex } = await buildMergedReplenishRows();
const { rows: merged, replenishIndex } = await buildMergedReplenishRows({ preferLiveData });
const sorted = sortRows(filterRows(merged, { keyword, scope }), sortField, sortOrder);
const [officialBom, businessBom, productBom] = await Promise.all([
@ -2448,6 +2471,8 @@ async function listMkhBomExportRows(query = {}) {
monitorDate: replenishIndex.monitorDate,
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
saleWindowDays: SALE_WINDOW_DAYS,
dataMode: preferLiveData ? 'live' : 'snapshot',
refreshedAt: new Date().toISOString(),
workOrderDegraded: replenishIndex.workOrderDegraded === true,
};
}

View File

@ -234,6 +234,22 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only
assert.equal(zero.warningLevel, '缺货');
});
test('replenish: live refresh ignores delayed sales and stock snapshots', () => {
const live = metrics({
preferLiveData: true,
avgSale: 9,
recentShippedQty: 30,
realtimeStock: null,
packStock: 0,
monitorStock: 99,
});
assert.equal(live.dailySale, 2, '实时日销必须按 30 件 ÷ 15 天计算');
assert.equal(live.dailySaleSource, 'orders-live');
assert.equal(live.stock, 0, '实时库存源无记录时不得回退延迟快照库存 99');
assert.equal(live.stockSource, 'live-missing');
});
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 });

View File

@ -25,7 +25,7 @@ export const getMkhBomDetail = (skuId) => request.get(
// ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时
export const getMkhBomExportRows = (params = {}) => request.get(
'/api/mkh-bom/export',
{ params, timeout: EXPORT_TIMEOUT_MS }
{ params: { ...params, live: 1 }, timeout: EXPORT_TIMEOUT_MS }
);
// 补货预警推送设置(接收人 / 开关 / 推送范围)

View File

@ -157,6 +157,7 @@ const STOCK_SOURCE_TEXT = {
realtime: '品牌仓实时库存',
pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)',
monitor: '品牌仓实时库存缺失,取监控快照库存',
'live-missing': '实时库存表和箱/货位库存表均无记录,实时口径按 0 计算',
};
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
@ -317,7 +318,7 @@ const MkhBomData = () => {
setLoading(true);
setError('');
try {
const result = await getMkhBomList({ ...query, _refresh: Date.now() });
const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() });
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
if (!result || result.success === false) {
setError(result?.error || '加载 MKH-BOM 资料失败');
@ -565,13 +566,18 @@ const MkhBomData = () => {
const text = Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>;
// 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。
// 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。
if (row.dailySaleSource !== 'orders') return text;
if (!String(row.dailySaleSource || '').startsWith('orders')) return text;
const liveMode = row.dailySaleSource === 'orders-live';
return (
<Tooltip
title={[
`上游近15天日销快照为 ${number(row.snapshotDailySale, 2)}(漏算)`,
liveMode
? `实时口径未采用延迟快照(快照值 ${number(row.snapshotDailySale, 2)})`
: `上游近15天日销快照为 ${number(row.snapshotDailySale, 2)}(漏算)`,
`此处按订单实算:近15天已发货 ${number(row.recentShippedQty)} 件 ÷ 15 天`,
'已排除样品/测试专用店铺;上游修好后会自动以上游数字为准',
liveMode
? '已排除样品/测试专用店铺;点击同步会重新读取当前订单和库存数据'
: '已排除样品/测试专用店铺;上游修好后会自动以上游数字为准',
].join(';')}
>
<span className="mkh-sale-fallback">{text}</span>
@ -822,8 +828,7 @@ const MkhBomData = () => {
}
setData(result.data.snapshot);
setError('');
const monitorDate = result.data.snapshot.monitorDate;
message.success(monitorDate ? `已读取数据库最新快照(监控日 ${monitorDate})` : '已读取数据库最新快照');
message.success('已实时读取最新订单、库存、BOM 与仓位数据');
} catch (syncError) {
if (listRequestGateRef.current.isCurrent(syncGeneration)) {
setError(syncError?.response?.data?.error || syncError?.message || '同步失败');
@ -849,8 +854,8 @@ const MkhBomData = () => {
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo,
优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
<br />
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
该仓无记录时回退监控快照库存;<b>结余库存 = 现有库存 − 待发货订单</b>(已下单未发货的部分已被占用,不足 0 取 0),
补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库;
实时查询不回退延迟监控快照。<b>结余库存 = 现有库存 − 待发货订单</b>(已下单未发货的部分已被占用,不足 0 取 0),
可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货;
建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。
无日销且无订单的商品不参与预警。
@ -891,8 +896,8 @@ const MkhBomData = () => {
]}
/>
<Button onClick={resetFilters}>重置</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
<Tooltip title="立即重新拉取副本库最新数据(清服务端缓存后重查),可随时点击,不必等凌晨的定时刷新">
<Button icon={<ReloadOutlined />} onClick={handleSync} loading={syncing}>刷新数据</Button>
<Tooltip title="立即清除服务端缓存,实时重查当前订单、库存、BOM 与仓位数据">
<Button type="primary" icon={<SyncOutlined />} onClick={handleSync} loading={syncing}>同步最新数据</Button>
</Tooltip>
<Dropdown
@ -906,7 +911,7 @@ const MkhBomData = () => {
</Space>
</Card>
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={handleSync}>重试</Button>} /> : null}
{!loading && !error && data.workOrderDegraded ? (
<Alert
@ -914,7 +919,7 @@ const MkhBomData = () => {
type="warning"
showIcon
message="加工单数据源暂时不可用,在途加工量按 0 处理,预警结果可能偏多,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={load}>刷新数据</Button>}
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
/>
) : null}
@ -924,7 +929,7 @@ const MkhBomData = () => {
type="warning"
showIcon
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={load}>刷新数据</Button>}
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
/>
) : null}
@ -934,7 +939,7 @@ const MkhBomData = () => {
type="warning"
showIcon
message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={load}>刷新数据</Button>}
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
/>
) : null}
@ -946,7 +951,7 @@ const MkhBomData = () => {
message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`}
description={(
<span>
按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}):
按近 {data.saleWindowDays || 15} 天已出库订单实时核算:
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。