fix: 页面刷新强制更新 MKH 运营数据 #35
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user