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 = () => {
]}
/>
- } 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}
@@ -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天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。