diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js
index 9614490..252019d 100644
--- a/backend/src/api/mkhBom.js
+++ b/backend/src/api/mkhBom.js
@@ -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 数据失败');
}
diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js
index eda345c..401e60e 100644
--- a/backend/src/services/mkhBomService.js
+++ b/backend/src/services/mkhBomService.js
@@ -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,
};
}
diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js
index 5fe2b89..93cdded 100644
--- a/backend/test/mkhBomService.test.js
+++ b/backend/test/mkhBomService.test.js
@@ -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 });
diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js
index 4d61416..41bc27c 100644
--- a/frontend/src/api/mkhBom.js
+++ b/frontend/src/api/mkhBom.js
@@ -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 }
);
diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx
index 231dc8e..9e434cb 100644
--- a/frontend/src/pages/MkhBomData/index.jsx
+++ b/frontend/src/pages/MkhBomData/index.jsx
@@ -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) : 0;
// 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。
// 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。
- if (row.dailySaleSource !== 'orders') return text;
+ if (!String(row.dailySaleSource || '').startsWith('orders')) return text;
+ const liveMode = row.dailySaleSource === 'orders-live';
return (
{text}
@@ -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,
优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
- 补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
- 该仓无记录时回退监控快照库存;结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0),
+ 补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库;
+ 实时查询不回退延迟监控快照。结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0),
可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货;
建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。
无日销且无订单的商品不参与预警。
@@ -863,8 +896,8 @@ const MkhBomData = () => {
]}
/>
- } onClick={load} loading={loading}>刷新数据
-
+ } onClick={handleSync} loading={syncing}>刷新数据
+
} onClick={handleSync} loading={syncing}>同步最新数据
{
- {error ? 重试} /> : null}
+ {error ? 重试} /> : null}
{!loading && !error && data.workOrderDegraded ? (
{
type="warning"
showIcon
message="加工单数据源暂时不可用,在途加工量按 0 处理,预警结果可能偏多,请稍后点击「刷新数据」重试。"
- action={}
+ action={}
/>
) : null}
@@ -896,7 +929,7 @@ const MkhBomData = () => {
type="warning"
showIcon
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
- action={}
+ action={}
/>
) : null}
@@ -906,7 +939,7 @@ const MkhBomData = () => {
type="warning"
showIcon
message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。"
- action={}
+ action={}
/>
) : null}
@@ -918,7 +951,7 @@ const MkhBomData = () => {
message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`}
description={(
- 按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}):
+ 按近 {data.saleWindowDays || 15} 天已出库订单实时核算:
其中 {data.outOfStockCount || 0} 个已缺货,
{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。
diff --git a/frontend/src/utils/requestGate.js b/frontend/src/utils/requestGate.js
new file mode 100644
index 0000000..a33ec9b
--- /dev/null
+++ b/frontend/src/utils/requestGate.js
@@ -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;
+ },
+ };
+}
diff --git a/frontend/test/requestGate.test.mjs b/frontend/test/requestGate.test.mjs
new file mode 100644
index 0000000..f4770b6
--- /dev/null
+++ b/frontend/test/requestGate.test.mjs
@@ -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);
+});