fix: force fresh MKH operational data

This commit is contained in:
134 2026-09-24 13:30:09 +08:00
parent 1afa99a783
commit ea312d9070
3 changed files with 142 additions and 9 deletions

View File

@ -376,6 +376,14 @@ let productBomCache = null;
*/
let cacheEpoch = 0;
// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰
// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工,
// 又避免用户每刷新一次就把所有稳定表全量重扫。
let operationalCacheEpoch = 0;
let operationalRefreshPending = null;
let operationalRefreshedAt = 0;
const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000;
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
@ -1405,15 +1413,16 @@ async function loadPendingWorkOrderIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) workOrderIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null;
throw error;
}
}
@ -1700,15 +1709,16 @@ async function loadOrderIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
orderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) orderIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null;
throw error;
}
}
@ -1818,15 +1828,16 @@ async function loadReplenishIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
replenishIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) replenishIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null;
throw error;
}
}
@ -2178,7 +2189,12 @@ 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 forceFresh = parseFlag(query.fresh);
const preferLiveData = parseFlag(query.live) || forceFresh;
// fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户
// 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。
if (forceFresh) await refreshMkhOperationalData();
const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([
buildMergedReplenishRows({ preferLiveData }),
@ -2255,6 +2271,9 @@ async function getMkhBomList(query = {}) {
saleWindowDays: SALE_WINDOW_DAYS,
dataMode: preferLiveData ? 'live' : 'snapshot',
refreshedAt: new Date().toISOString(),
operationalRefreshedAt: operationalRefreshedAt
? new Date(operationalRefreshedAt).toISOString()
: null,
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
workOrderDegraded: replenishIndex.workOrderDegraded === true,
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
@ -2496,11 +2515,60 @@ async function listMkhBomExportRows(query = {}) {
// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照,
// 而不是 2~15 分钟前的缓存结果。
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
function clearMkhOperationalCache() {
operationalCacheEpoch += 1;
operationalRefreshedAt = 0;
replenishIndexCache = null;
workOrderIndexCache = null;
orderIndexCache = null;
return {
operationalCacheEpoch,
clearedAt: new Date().toISOString(),
};
}
/**
* 强制重读会变的运营数据。
* - single-flight:并发用户共用同一次云库查询;
* - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库;
* - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。
*/
async function refreshMkhOperationalData() {
const now = Date.now();
if (operationalRefreshPending) {
const result = await operationalRefreshPending;
return { ...result, joined: true };
}
if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) {
return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() };
}
clearMkhOperationalCache();
const refreshEpoch = operationalCacheEpoch;
const pending = (async () => {
await loadReplenishIndex();
if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now();
return {
refreshed: true,
refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null,
};
})();
operationalRefreshPending = pending;
try {
return await pending;
} finally {
if (operationalRefreshPending === pending) operationalRefreshPending = null;
}
}
function clearMkhBomCache() {
// 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询,
// 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值,
// 导致按钮点完反而 2~10 分钟一直显示旧数据)。
cacheEpoch += 1;
operationalCacheEpoch += 1;
operationalRefreshPending = null;
operationalRefreshedAt = 0;
businessBomCache = null;
officialBomIndexCache = null;
@ -2522,6 +2590,7 @@ function clearMkhBomCache() {
return {
syncedAt: new Date().toISOString(),
cacheEpoch,
operationalCacheEpoch,
cleared: cleared.map(([name, entries]) => ({ name, entries })),
};
}
@ -2529,6 +2598,7 @@ function clearMkhBomCache() {
module.exports = {
getMkhBomList,
clearMkhBomCache,
refreshMkhOperationalData,
getMkhBomDetail,
listReplenishAlertRows,
listMkhBomExportRows,
@ -2590,5 +2660,8 @@ module.exports = {
EXPORT_MODES,
buildProductExportRows,
buildComponentExportRows,
clearMkhOperationalCache,
refreshMkhOperationalData,
OPERATIONAL_REFRESH_DEDUPE_MS,
},
};

View File

@ -1285,3 +1285,53 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn
svc.clearMkhBomCache();
}
});
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const originalGetPool = cloudDatabase.getPool;
let queryCount = 0;
let releaseMonitor;
let markMonitorStarted;
const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; });
const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; });
cloudDatabase.query = async (sql) => {
queryCount += 1;
if (sql.includes('pm_stockMonitor')) {
markMonitorStarted();
await monitorGate;
}
return [];
};
cloudDatabase.getPool = async () => ({
request: () => ({
input() { return this; },
async query() { queryCount += 1; return { recordset: [] }; },
}),
});
try {
svc.clearMkhBomCache();
const first = svc.refreshMkhOperationalData();
await monitorStarted;
const second = svc.refreshMkhOperationalData();
releaseMonitor();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult.refreshed, true);
assert.equal(secondResult.joined, true);
assert.equal(secondResult.refreshedAt, firstResult.refreshedAt);
const afterConcurrent = queryCount;
const reused = await svc.refreshMkhOperationalData();
assert.equal(reused.reused, true);
assert.equal(queryCount, afterConcurrent,
'15 秒保护窗内不应重复访问业务云库');
} finally {
cloudDatabase.query = originalQuery;
cloudDatabase.getPool = originalGetPool;
svc.clearMkhBomCache();
}
});

View File

@ -323,14 +323,24 @@ const MkhBomData = () => {
if (!background) setLoading(true);
if (!background) setError('');
try {
const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() });
const now = Date.now();
const forceFresh = !lastListRefreshAtRef.current
|| now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS;
// 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。
// 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。
const result = await getMkhBomList({
...query,
live: 1,
...(forceFresh ? { fresh: 1 } : null),
_refresh: 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 });
lastListRefreshAtRef.current = Date.now();
if (forceFresh) lastListRefreshAtRef.current = Date.now();
setError('');
} catch (requestError) {
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;