From fa91b9242c49e7b90e7b9e8b1f8770e1da78d8ff Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:05:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E6=A0=B9=E6=B2=BBMKH=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=90=8E=E6=97=A7=E8=AF=B7=E6=B1=82=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/mkhBom.js | 13 ++++--- frontend/src/api/mkhBom.js | 13 ++++--- frontend/src/pages/MkhBomData/index.jsx | 46 ++++++++++++++++++++----- frontend/src/utils/requestGate.js | 17 +++++++++ frontend/test/requestGate.test.mjs | 24 +++++++++++++ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 frontend/src/utils/requestGate.js create mode 100644 frontend/test/requestGate.test.mjs diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 9614490..7cb8b0e 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 || {}); + res.set('Cache-Control', 'no-store'); + res.json({ success: true, data: { cache, snapshot } }); } catch (error) { sendError(res, error, '同步 MKH-BOM 数据失败'); } diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index 4d61416..dddc8cb 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 配件明细(列表展开 / 打印预览共用) @@ -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..0670914 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'; @@ -273,6 +274,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 +313,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, _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 +488,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]); @@ -791,18 +802,35 @@ 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(''); + const monitorDate = result.data.snapshot.monitorDate; + message.success(monitorDate ? `已读取数据库最新快照(监控日 ${monitorDate})` : '已读取数据库最新快照'); } 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); } }; 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); +}); From b8e60a19d253882f01d87f34f44a7d385c85c1f7 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:12:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20MKH=E5=88=B7=E6=96=B0=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=AE=9E=E6=97=B6=E8=AE=A2=E5=8D=95=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/mkhBom.js | 2 +- backend/src/services/mkhBomService.js | 43 +++++++++++++++++++------ backend/test/mkhBomService.test.js | 16 +++++++++ frontend/src/api/mkhBom.js | 2 +- frontend/src/pages/MkhBomData/index.jsx | 35 +++++++++++--------- 5 files changed, 72 insertions(+), 26 deletions(-) diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 7cb8b0e..252019d 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -109,7 +109,7 @@ router.put('/notification-center', async (req, res) => { router.post('/sync', async (req, res) => { try { const cache = clearMkhBomCache(); - const snapshot = await getMkhBomList(req.body?.query || {}); + const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' }); res.set('Cache-Control', 'no-store'); res.json({ success: true, data: { cache, snapshot } }); } catch (error) { 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 dddc8cb..41bc27c 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -25,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 } ); // 补货预警推送设置(接收人 / 开关 / 推送范围) diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 0670914..9e434cb 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -157,6 +157,7 @@ const STOCK_SOURCE_TEXT = { realtime: '品牌仓实时库存', pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)', monitor: '品牌仓实时库存缺失,取监控快照库存', + 'live-missing': '实时库存表和箱/货位库存表均无记录,实时口径按 0 计算', }; // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 @@ -317,7 +318,7 @@ const MkhBomData = () => { setLoading(true); setError(''); try { - const result = await getMkhBomList({ ...query, _refresh: Date.now() }); + 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 资料失败'); @@ -565,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} @@ -822,8 +828,7 @@ const MkhBomData = () => { } setData(result.data.snapshot); setError(''); - const monitorDate = result.data.snapshot.monitorDate; - message.success(monitorDate ? `已读取数据库最新快照(监控日 ${monitorDate})` : '已读取数据库最新快照'); + message.success('已实时读取最新订单、库存、BOM 与仓位数据'); } catch (syncError) { if (listRequestGateRef.current.isCurrent(syncGeneration)) { setError(syncError?.response?.data?.error || syncError?.message || '同步失败'); @@ -849,8 +854,8 @@ const MkhBomData = () => { 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
- 补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 - 该仓无记录时回退监控快照库存;结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), + 补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库; + 实时查询不回退延迟监控快照。结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 无日销且无订单的商品不参与预警。 @@ -891,8 +896,8 @@ const MkhBomData = () => { ]} /> - - + + { - {error ? 重试} /> : null} + {error ? 重试} /> : null} {!loading && !error && data.workOrderDegraded ? ( { type="warning" showIcon message="加工单数据源暂时不可用,在途加工量按 0 处理,预警结果可能偏多,请稍后点击「刷新数据」重试。" - action={} + action={} /> ) : null} @@ -924,7 +929,7 @@ const MkhBomData = () => { type="warning" showIcon message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。" - action={} + action={} /> ) : null} @@ -934,7 +939,7 @@ const MkhBomData = () => { type="warning" showIcon message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。" - action={} + action={} /> ) : null} @@ -946,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天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。