Merge pull request 'fix: MKH 刷新实时读取订单库存并阻止旧请求覆盖' (#23) from codex/mkh-sync-freshness-0923 into main

This commit is contained in:
lunanting 2026-09-23 11:18:13 +08:00
commit 6b0bf59e74
7 changed files with 163 additions and 40 deletions

View File

@ -32,6 +32,7 @@ function sendError(res, error, fallback) {
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
router.get('/list', async (req, res) => {
try {
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 资料列表失败');
@ -42,6 +43,7 @@ router.get('/list', async (req, res) => {
router.get('/detail', async (req, res) => {
try {
const skuId = req.query?.skuId || req.query?.comboSkuId;
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomDetail(skuId) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 明细失败');
@ -102,11 +104,14 @@ router.put('/notification-center', async (req, res) => {
}
});
// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照
router.post('/sync', async (_req, res) => {
// 手动同步:先清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 再在同一次请求中直连副本库读取新快照。这样不会被同步前尚未完成的旧列表请求覆盖。
router.post('/sync', async (req, res) => {
try {
res.json({ success: true, data: clearMkhBomCache() });
const cache = clearMkhBomCache();
const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' });
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: { cache, snapshot } });
} catch (error) {
sendError(res, error, '同步 MKH-BOM 数据失败');
}

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

@ -7,7 +7,11 @@ const EXPORT_TIMEOUT_MS = 5 * 60 * 1000;
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
export const getMkhBomList = (params = {}) => request.get(
'/api/mkh-bom/list',
{ params, timeout: LIST_TIMEOUT_MS }
{
params,
timeout: LIST_TIMEOUT_MS,
headers: { 'Cache-Control': 'no-cache' },
}
);
// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用)
@ -21,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 }
);
// 补货预警推送设置(接收人 / 开关 / 推送范围)
@ -46,10 +50,9 @@ export const saveMkhNotificationCenter = (payload = {}) => request.put(
payload
);
// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 随后重新拉列表即直连副本库取最新快照,可随时点击
export const syncMkhBom = () => request.post(
// 手动同步:服务端清空全部 MKH 缓存,并在同一次请求中返回当前筛选条件的数据库新快照
export const syncMkhBom = (query = {}) => request.post(
'/api/mkh-bom/sync',
null,
{ query },
{ timeout: LIST_TIMEOUT_MS }
);

View File

@ -17,6 +17,7 @@ import {
checkMkhReplenishAlert,
syncMkhBom,
} from '../../api/mkhBom';
import { createRequestGate } from '../../utils/requestGate';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
@ -156,6 +157,7 @@ const STOCK_SOURCE_TEXT = {
realtime: '品牌仓实时库存',
pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)',
monitor: '品牌仓实时库存缺失,取监控快照库存',
'live-missing': '实时库存表和箱/货位库存表均无记录,实时口径按 0 计算',
};
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
@ -273,6 +275,8 @@ const MkhBomData = () => {
const [centerSaving, setCenterSaving] = useState(false);
const tableWrapRef = useRef(null);
const listRequestGateRef = useRef(createRequestGate());
const detailGenerationRef = useRef(0);
const [bodyHeight, setBodyHeight] = useState(420);
useEffect(() => {
@ -310,19 +314,22 @@ const MkhBomData = () => {
}, [page, pageSize, sorter, filters]);
const load = useCallback(async () => {
const requestGeneration = listRequestGateRef.current.begin();
setLoading(true);
setError('');
try {
const result = await getMkhBomList(query);
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 资料失败');
return;
}
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
} catch (requestError) {
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
} finally {
setLoading(false);
if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
}
}, [query]);
@ -482,18 +489,23 @@ const MkhBomData = () => {
const loadDetail = useCallback(async (skuId) => {
if (detailCache[skuId] || detailLoading[skuId]) return;
const detailGeneration = detailGenerationRef.current;
setDetailLoading((old) => ({ ...old, [skuId]: true }));
try {
const result = await getMkhBomDetail(skuId);
if (detailGeneration !== detailGenerationRef.current) return;
if (!result || result.success === false) {
message.error(result?.error || '加载配件明细失败');
return;
}
setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } }));
} catch (e) {
if (detailGeneration !== detailGenerationRef.current) return;
message.error(e?.response?.data?.error || e?.message || '加载配件明细失败');
} finally {
setDetailLoading((old) => ({ ...old, [skuId]: false }));
if (detailGeneration === detailGenerationRef.current) {
setDetailLoading((old) => ({ ...old, [skuId]: false }));
}
}
}, [detailCache, detailLoading]);
@ -554,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>
@ -791,18 +808,34 @@ const MkhBomData = () => {
);
};
// 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新
// 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。
// 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。
const handleSync = async () => {
setSyncing(true);
setLoading(true);
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
listRequestGateRef.current.invalidate();
detailGenerationRef.current += 1;
setDetailCache({});
setDetailLoading({});
setExpandedKeys({});
const syncGeneration = listRequestGateRef.current.begin();
try {
await syncMkhBom();
setDetailCache({});
await load();
message.success('已同步最新数据');
const result = await syncMkhBom(query);
if (!listRequestGateRef.current.isCurrent(syncGeneration)) return;
if (!result || result.success === false || !result?.data?.snapshot) {
throw new Error(result?.error || '同步接口未返回数据库快照');
}
setData(result.data.snapshot);
setError('');
message.success('已实时读取最新订单、库存、BOM 与仓位数据');
} catch (syncError) {
if (listRequestGateRef.current.isCurrent(syncGeneration)) {
setError(syncError?.response?.data?.error || syncError?.message || '同步失败');
}
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
} finally {
if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false);
setSyncing(false);
}
};
@ -821,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 天缓冲)。
无日销且无订单的商品不参与预警。
@ -863,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
@ -878,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
@ -886,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}
@ -896,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}
@ -906,7 +939,7 @@ const MkhBomData = () => {
type="warning"
showIcon
message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={load}>刷新数据</Button>}
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
/>
) : null}
@ -918,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天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。

View File

@ -0,0 +1,17 @@
export function createRequestGate() {
let generation = 0;
return {
begin() {
generation += 1;
return generation;
},
invalidate() {
generation += 1;
return generation;
},
isCurrent(requestGeneration) {
return requestGeneration === generation;
},
};
}

View File

@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRequestGate } from '../src/utils/requestGate.js';
test('only the newest request may update the page', () => {
const gate = createRequestGate();
const oldRequest = gate.begin();
const newRequest = gate.begin();
assert.equal(gate.isCurrent(oldRequest), false);
assert.equal(gate.isCurrent(newRequest), true);
});
test('sync invalidates every request that started before cache clearing', () => {
const gate = createRequestGate();
const beforeSync = gate.begin();
gate.invalidate();
assert.equal(gate.isCurrent(beforeSync), false);
const freshSnapshot = gate.begin();
assert.equal(gate.isCurrent(freshSnapshot), true);
});