diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index c5fdbb0..3ea8996 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -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, }, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 460ffd1..4f380e5 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -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(); + } +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 184de96..e74f650 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -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;