diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js
index 0d97c89..94c7428 100644
--- a/backend/src/services/mkhBomService.js
+++ b/backend/src/services/mkhBomService.js
@@ -102,6 +102,36 @@
// 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底
// ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。
//
+// ── ⑦-a 日销兜底:订单表实算(2026-09-19 新增)────────────────────
+// 问题:上游快照 pm_stockMonitor.fifteenAvgSale 会**漏算**。实测(快照 09-18,
+// 窗口内有发货的 MKH 成品共 178 个编码)**24 个被记成 0**,其中 16 个是
+// 「订单全部来自样品专用店铺」(上游同样不计样品单,属合理口径),
+// 剩 **8 个是真实店铺**(天猫 / 京东 / 拼多多),这才是异常。
+// 业务实例:`MKHDNZ0409260601548` 近 15 天实发 2 件(09-09 / 09-19),
+// 快照日销却是 0,库存又是 0 → 走「无销量」分支彻底隐身,**连缺货都不报**。
+// ⚠️ 该列是**保留两位小数的日均**,不是取整值:品牌仓 177 个非零值范围
+// 0.07 ~ 4.96,含小数的占 170 个,最小非零 0.07 = 1 件 ÷ 15 天。
+// 所以「发过货却记 0」**无法用四舍五入解释**,就是上游没算进去。
+// 兜底规则:**快照日销 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货**时,
+// 日销改用订单表实算值 = 已发货件数 ÷ SALE_WINDOW_DAYS,
+// 随后照常走 ⑦/⑫ 的日销分支(结余库存、3 天阈值、建议补货量都不改)。
+// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架
+// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐;
+// 不排除的话那 16 个样品单编码会被误拉进预警
+// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径
+// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到
+// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货
+// (均约 1.25s),而拆成两条独立查询要在现有开销之上**再加一次全表扫描**。
+// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`,
+// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判
+// · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报)
+// 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中
+// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**,
+// 含本问题的 MKHDNZ0409260601548,以及 09-18 才发货、快照尚未跟上的
+// MKHDNZ04102607000xx 一批。
+// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」,
+// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。
+//
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
@@ -296,20 +326,24 @@ const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_
// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量
const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000;
-// ── 待发货订单参数(见文件头 ⑩)──────────────────────────────────
-// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里。
+// ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)──────────────
+// 订单主表本地副本;含 `send_date`(发货日)、`shop_name`(店铺)与 `status`,
+// 明细在 `skus` 文本列里。
// ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表,
-// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部待发货需求。
-const PENDING_ORDER_TABLES = Object.freeze([
+// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部需求。
+// ⚠️ 待发货(⑩)与近15天已发货(⑦-a)**共用这一组表、共用同一次查询**。
+const ORDER_TABLES = Object.freeze([
'rpt.jst_Orders', // 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会
'rpt.jst_qmcust_orderlist', // 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单
]);
-// 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单
+// 不计入需求的订单状态:Cancelled 已取消 / Merged 已合并进别的单
const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']);
-// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s / 4.91M 行约 0.17s)
+// 只捞含 MKH 的订单,避免全表扫描(实测 1.18M 行 + 4.91M 行,合并查询约 1.25s)
const PENDING_ORDER_SKU_LIKE = '%MKH%';
// 订单数据源异常时的降级缓存时长
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
+// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警
+const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']);
// ── 配件不足 / 可组装数参数(见文件头 ⑪)────────────────────────
// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id
@@ -371,7 +405,7 @@ let replenishIndexCache = null;
// 加工单在途量索引(见文件头 ⑧)
let workOrderIndexCache = null;
// 待发货订单索引(见文件头 ⑩)
-let pendingOrderIndexCache = null;
+let orderIndexCache = null;
// 配件库存(按编码缓存,见文件头 ⑪)
const componentStockCache = new Map();
@@ -866,7 +900,10 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(
/**
* 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。
*
- * 口径(见文件头 ⑦ + ⑩ + ⑫):
+ * 口径(见文件头 ⑦ + ⑦-a + ⑩ + ⑫):
+ * ⓪ 日销取值:上游快照(⑦);**快照 ≤ 0 且近15天有真实发货时**改用订单实算值(⑦-a),
+ * 结果带 `dailySaleSource`('snapshot' / 'orders')与 `snapshotDailySale` 便于对照。
+ * 下面 ① / ② 用的 `dailySale` 即此处的最终取值。
* ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警
* 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫)
* 结余库存 = 0 → 缺货(订单都发不出去)
@@ -876,7 +913,9 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(
* 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。
*
* @param {object} row
- * row.avgSale 近15天日均发货量(取自 rpt.pm_stockMonitor.fifteenAvgSale)
+ * row.avgSale 上游近15天日均发货量(rpt.pm_stockMonitor.fifteenAvgSale)
+ * row.recentShippedQty 近15天已发货件数(订单表实算,⑦-a;已排除样品/测试店)
+ * → 快照 ≤ 0 且该值 > 0 时,日销改用 recentShippedQty ÷ 15
* row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录)
* row.monitorStock 监控快照库存(fallback)
* row.safetyDays 源系统安全天数(仅展示)
@@ -886,7 +925,17 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(
* row.pendingOrderQty 待发货订单量(未发货订单件数)
*/
function buildReplenishMetrics(row = {}) {
- const dailySale = Math.max(0, toNumber(row.avgSale) ?? 0);
+ // 日销 = 上游快照(⑦) + 订单兜底(⑦-a):
+ // 上游 fifteenAvgSale 会漏算,**只在快照 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货时**
+ // 才改用订单实算值(已发货件数 ÷ 天数);快照 > 0 一律以上游为准,不覆盖。
+ const snapshotDailySale = Math.max(0, toNumber(row.avgSale) ?? 0);
+ const recentShippedQty = Math.max(0, toNumber(row.recentShippedQty) ?? 0);
+ const orderDailySale = recentShippedQty > 0
+ ? Number((recentShippedQty / SALE_WINDOW_DAYS).toFixed(4))
+ : 0;
+ const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0;
+ const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale;
+
const realtimeStock = toNumber(row.realtimeStock);
const monitorStock = toNumber(row.monitorStock) ?? 0;
const hasRealtime = realtimeStock !== null;
@@ -908,6 +957,12 @@ function buildReplenishMetrics(row = {}) {
const base = {
dailySale,
+ // 日销来源(⑦-a):'snapshot' 上游快照 / 'orders' 订单表实算兜底
+ dailySaleSource: useOrderDailySale ? 'orders' : 'snapshot',
+ // 上游快照原值(保留下来做对照,业务对不上源系统数字时能看出是兜底生效)
+ snapshotDailySale,
+ // 近 SALE_WINDOW_DAYS 天已发货件数(⑦-a,已排除样品/测试店)
+ recentShippedQty,
stock,
availableStock,
pendingWorkOrderQty,
@@ -1213,18 +1268,29 @@ const isMkhProductCode = (code) => {
return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text);
};
+/** 是否为「样品 / 测试专用店铺」—— 上游日销不计这些单,兜底也必须排除(见文件头 ⑦-a) */
+const isExcludedSaleShop = (shopName) => {
+ const text = toText(shopName);
+ if (!text) return false;
+ return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword));
+};
+
/**
- * 待发货订单索引 —— 进程内缓存
- * { bySku: Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }>, degraded }。
- * 口径见文件头 ⑩:未发货 = send_date 为空且状态非取消/合并,商品取自主表 skus 文本列。
+ * 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a):
+ * pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩)
+ * shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店)
+ * 口径:
+ * · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩)
+ * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a)
+ * · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计
* ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。
*/
-async function loadPendingOrderIndex() {
+async function loadOrderIndex() {
const now = Date.now();
- if (pendingOrderIndexCache?.value && pendingOrderIndexCache.expiresAt > now) {
- return pendingOrderIndexCache.value;
+ if (orderIndexCache?.value && orderIndexCache.expiresAt > now) {
+ return orderIndexCache.value;
}
- if (pendingOrderIndexCache?.pending) return pendingOrderIndexCache.pending;
+ if (orderIndexCache?.pending) return orderIndexCache.pending;
const statusParams = {};
const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => {
@@ -1232,25 +1298,37 @@ async function loadPendingOrderIndex() {
return `@pendingOrderStatus${index}`;
}).join(', ');
- // 两张订单表**并行**读取后合并(见文件头 ⑩:渠道互不重叠,只读一张会漏需求)
- const pending = Promise.allSettled(PENDING_ORDER_TABLES.map((table) => cloudDatabase.query(
+ // 两张订单表**并行**读取后合并(见 ⑩:渠道互不重叠,只读一张会漏需求)
+ // ⚠️ 一条查询同时覆盖「待发货」(send_date IS NULL)与「近 N 天已发货」(send_date 在窗口内):
+ // 两者共用同一次表扫描,拆成两条查询会在现有开销之上再加一次全表扫描(实测约 +1.25s)。
+ // 窗口取含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径。
+ const pending = Promise.allSettled(ORDER_TABLES.map((table) => cloudDatabase.query(
`SELECT o.o_id AS oId,
o.order_date AS orderDate,
+ o.send_date AS sendDate,
+ o.shop_name AS shopName,
o.skus AS skus
FROM ${table} o WITH (NOLOCK)
WHERE o.skus LIKE @pendingOrderSkuLike
- AND o.send_date IS NULL
- AND o.status NOT IN (${statusPlaceholders})`,
+ AND o.status NOT IN (${statusPlaceholders})
+ AND (
+ o.send_date IS NULL
+ OR (
+ o.send_date >= DATEADD(DAY, -${SALE_WINDOW_DAYS - 1}, CAST(GETDATE() AS date))
+ AND o.send_date < DATEADD(DAY, 1, CAST(GETDATE() AS date))
+ )
+ )`,
{ pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams },
{ requestTimeout: 180000 }
))).then((results) => {
- const bySku = new Map();
+ const pendingBySku = new Map();
+ const shippedBySku = new Map();
const seenOrderIds = new Set();
const failed = [];
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
- failed.push(`${PENDING_ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`);
+ failed.push(`${ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`);
return;
}
result.value.forEach((row) => {
@@ -1261,12 +1339,20 @@ async function loadPendingOrderIndex() {
if (seenOrderIds.has(orderId)) return;
seenOrderIds.add(orderId);
}
+ // 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除
+ const shipped = row.sendDate !== null && row.sendDate !== undefined;
+ if (shipped && isExcludedSaleShop(row.shopName)) return;
+
const orderDate = row.orderDate instanceof Date ? row.orderDate : null;
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
if (!isMkhProductCode(code)) return;
const key = normalizeCode(code);
if (!key) return;
- const entry = bySku.get(key) || {
+ if (shipped) {
+ shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty);
+ return;
+ }
+ const entry = pendingBySku.get(key) || {
pendingOrderQty: 0,
pendingOrderCount: 0,
pendingOrderEarliest: null,
@@ -1276,7 +1362,7 @@ async function loadPendingOrderIndex() {
if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) {
entry.pendingOrderEarliest = orderDate;
}
- bySku.set(key, entry);
+ pendingBySku.set(key, entry);
});
});
});
@@ -1284,19 +1370,25 @@ async function loadPendingOrderIndex() {
if (failed.length) {
// 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出,
// 并用短 TTL 尽快重试补齐。
- console.warn('[MKH-BOM] 待发货订单部分数据源读取失败,需求口径可能不完整:', failed.join(' | '));
- return { bySku, degraded: true, error: failed.join(' | '), ttl: PENDING_ORDER_DEGRADED_TTL_MS };
+ console.warn('[MKH-BOM] 订单数据源部分读取失败,需求与日销口径可能不完整:', failed.join(' | '));
+ return {
+ pendingBySku,
+ shippedBySku,
+ degraded: true,
+ error: failed.join(' | '),
+ ttl: PENDING_ORDER_DEGRADED_TTL_MS,
+ };
}
- return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
+ return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
});
- pendingOrderIndexCache = { pending, value: null, expiresAt: 0 };
+ orderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
- pendingOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
+ orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
return value;
} catch (error) {
- pendingOrderIndexCache = null;
+ orderIndexCache = null;
throw error;
}
}
@@ -1334,11 +1426,12 @@ async function loadReplenishIndex() {
),
// 在途加工单(见文件头 ⑧)
loadPendingWorkOrderIndex(),
- // 待发货订单(见文件头 ⑩)
- loadPendingOrderIndex(),
- ]).then(([monitorRows, stockRows, workOrderIndex, pendingOrderIndex]) => {
+ // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询
+ loadOrderIndex(),
+ ]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => {
const workOrderBySku = workOrderIndex.bySku;
- const pendingOrderBySku = pendingOrderIndex.bySku;
+ const pendingOrderBySku = orderIndex.pendingBySku;
+ const shippedBySku = orderIndex.shippedBySku;
const latestStock = new Map();
stockRows.forEach((row) => {
const key = normalizeCode(row.skuId);
@@ -1356,6 +1449,8 @@ async function loadReplenishIndex() {
bySku.set(key, buildReplenishMetrics({
// 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据)
avgSale: row.fifteenAvgSale,
+ // 日销兜底见文件头 ⑦-a:快照漏算时用订单表实算的近15天发货量
+ recentShippedQty: shippedBySku.get(key) || 0,
// 实时库存缺失(而非为 0)时才回退监控快照
realtimeStock: latestStock.has(key) ? latestStock.get(key) : null,
monitorStock: row.monitorStock,
@@ -1371,8 +1466,9 @@ async function loadReplenishIndex() {
bySku,
workOrderBySku,
pendingOrderBySku,
+ shippedBySku,
workOrderDegraded: workOrderIndex.degraded === true,
- pendingOrderDegraded: pendingOrderIndex.degraded === true,
+ pendingOrderDegraded: orderIndex.degraded === true,
monitorDate,
};
});
@@ -1508,12 +1604,14 @@ async function buildMergedReplenishRows() {
const key = normalizeCode(product.skuId);
const metrics = replenishIndex.bySku.get(key);
if (metrics) return { ...product, ...metrics };
- // 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息
+ // 监控快照里没有该商品时,仍要带上在途加工单(⑧)、待发货订单(⑩)与
+ // 近15天已发货(⑦-a)信息 —— 后者正是「快照里根本没这个编码」时唯一的日销来源
return {
...product,
...buildReplenishMetrics({
...(replenishIndex.workOrderBySku?.get(key) || null),
...(replenishIndex.pendingOrderBySku?.get(key) || null),
+ recentShippedQty: replenishIndex.shippedBySku?.get(key) || 0,
}),
};
});
@@ -2035,7 +2133,7 @@ function clearMkhBomCache() {
mkhProductCache = null;
replenishIndexCache = null;
workOrderIndexCache = null;
- pendingOrderIndexCache = null;
+ orderIndexCache = null;
const cleared = [
['officialBomDetailCache', officialBomDetailCache.size],
@@ -2073,11 +2171,13 @@ module.exports = {
COMPONENT_LOCATION_CACHE_TTL_MS,
buildReplenishMetrics,
loadPendingWorkOrderIndex,
- loadPendingOrderIndex,
+ loadOrderIndex,
parseOrderSkus,
isMkhProductCode,
+ isExcludedSaleShop,
PENDING_ORDER_EXCLUDED_STATUS,
- PENDING_ORDER_TABLES,
+ ORDER_TABLES,
+ SALE_EXCLUDED_SHOP_KEYWORDS,
// ⑪ 配件不足 / 可组装数
buildComponentOccupation,
buildComponentReadiness,
diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js
index c409030..3034c53 100644
--- a/backend/test/mkhBomService.test.js
+++ b/backend/test/mkhBomService.test.js
@@ -389,15 +389,22 @@ test('pending order: only MKH finished products count, combination codes are exc
assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false);
assert.equal(_private.isMkhProductCode(''), false);
assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']);
- // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的待发货需求,
- // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因)
+ // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的需求,
+ // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因)。
+ // ⚠️ 待发货(⑩)与近15天已发货(⑦-a)共用这一组表。
assert.deepEqual(
- [..._private.PENDING_ORDER_TABLES],
+ [..._private.ORDER_TABLES],
['rpt.jst_Orders', 'rpt.jst_qmcust_orderlist']
);
+ // 日销兜底(⑦-a)排除样品/测试专用店铺:上游同样不计这些单,不排除会误拉进预警
+ assert.deepEqual([..._private.SALE_EXCLUDED_SHOP_KEYWORDS], ['样品', '测试']);
+ assert.equal(_private.isExcludedSaleShop('样品专用店铺'), true);
+ assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true);
+ assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false);
+ assert.equal(_private.isExcludedSaleShop(null), false);
});
-test('pending order: reads both channel tables, merges and dedupes by o_id', async () => {
+test('order index: reads both channel tables, merges and dedupes by o_id', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
@@ -407,69 +414,162 @@ test('pending order: reads both channel tables, merges and dedupes by o_id', asy
if (sql.includes('jst_qmcust_orderlist')) {
queried.push('qmcust');
return [
- { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), skus: '1.MKHDNZ0409260700059' },
- { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' },
+ { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' },
+ { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' },
+ // 已发货行(⑦-a):与待发货**同一次查询**返回,靠 send_date 是否有值区分
+ {
+ oId: 9005,
+ sendDate: new Date('2026-09-09T00:00:00Z'),
+ shopName: '天猫-HX旗舰店101',
+ skus: '1.MKHDNZ0409260601548',
+ },
+ // 样品专用店铺的已发货单必须排除(上游同样不计这些单)
+ {
+ oId: 9006,
+ sendDate: new Date('2026-09-10T00:00:00Z'),
+ shopName: '样品专用店铺',
+ skus: '2.MKHDNZ0409260609999',
+ },
];
}
queried.push('orders');
return [
// 与 qmcust 同 o_id → 必须只算一次(防御上游把同一单补录进另一张表)
- { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' },
- { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), skus: '1.MKHDNZ0409260700059*2' },
+ { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' },
+ { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059*2' },
// 组合装(MKH- 打头)不计入成品需求
{
oId: 9004,
orderDate: new Date('2026-09-16T00:00:00Z'),
+ sendDate: null,
skus: '1.MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1',
},
+ // 已发货:真实店铺,同编码跨表累加
+ {
+ oId: 9007,
+ sendDate: new Date('2026-09-11T00:00:00Z'),
+ shopName: '京东-HX旗舰店102',
+ skus: '1.MKHDNZ0409260601548*2',
+ },
+ // 已发货:测试专用店铺 → 排除
+ {
+ oId: 9008,
+ sendDate: new Date('2026-09-12T00:00:00Z'),
+ shopName: '某某测试专用店铺',
+ skus: '1.MKHDNZ0409260608888',
+ },
];
};
try {
svc.clearMkhBomCache();
- const index = await _private.loadPendingOrderIndex();
+ const index = await _private.loadOrderIndex();
assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到');
assert.equal(index.degraded, false);
- const a = index.bySku.get('MKHDNZ0409260700059');
+ const a = index.pendingBySku.get('MKHDNZ0409260700059');
assert.equal(a.pendingOrderQty, 3, '两表合并:1 + 2');
assert.equal(a.pendingOrderCount, 2);
- const b = index.bySku.get('MKHDNZ0409260601228');
+ const b = index.pendingBySku.get('MKHDNZ0409260601228');
assert.equal(b.pendingOrderQty, 1, '同 o_id 跨表重复只计一次');
assert.equal(b.pendingOrderCount, 1);
- assert.equal(index.bySku.size, 2, '组合装不进入索引');
+ assert.equal(index.pendingBySku.size, 2, '组合装不进入索引');
+
+ // 已发货(⑦-a):同一次查询拿到,真实店铺跨表累加、样品/测试店排除
+ assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加');
+ assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除');
+ assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除');
+ assert.equal(index.shippedBySku.size, 1, '被排除的店铺不进入索引');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
-test('pending order: one failing channel degrades but still returns the other table', async () => {
+test('order index: one failing channel degrades but still returns the other table', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
cloudDatabase.query = async (sql) => {
if (sql.includes('jst_qmcust_orderlist')) throw new Error('qmcust unavailable');
- return [{ oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260700059' }];
+ return [
+ { oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' },
+ {
+ oId: 9101,
+ sendDate: new Date('2026-09-15T00:00:00Z'),
+ shopName: '京东-HX旗舰店102',
+ skus: '1.MKHDNZ0409260601548',
+ },
+ ];
};
try {
svc.clearMkhBomCache();
- const index = await _private.loadPendingOrderIndex();
+ const index = await _private.loadOrderIndex();
assert.equal(index.degraded, true, '部分源失败必须透出 degraded');
assert.match(index.error, /qmcust unavailable/);
- assert.equal(index.bySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留');
+ assert.equal(index.pendingBySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留');
+ assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 1, '成功源的已发货数据同样保留');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
+test('replenish: daily sale falls back to order-derived value when snapshot is 0', () => {
+ // 上游快照漏算(⑦-a):`MKHDNZ0409260601548` 近15天实发 2 件、快照日销 0、库存 0,
+ // 旧口径走「无销量」分支彻底隐身,连缺货都不报。
+ const fallback = metrics({ avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 0 });
+ assert.equal(fallback.dailySaleSource, 'orders', '快照为 0 且有发货 → 用订单实算兜底');
+ assert.equal(fallback.snapshotDailySale, 0);
+ assert.equal(fallback.recentShippedQty, 2);
+ assert.equal(fallback.dailySale, Number((2 / 15).toFixed(4)), '日销 = 2 件 ÷ 15 天');
+ assert.equal(fallback.warningLevel, '缺货', '兜底后库存 0 必须报缺货');
+ assert.equal(fallback.hasWarning, true);
+ // 建议补货量 = ceil(3 × 0.1333 + 0 − 0) = 1
+ assert.equal(fallback.replenishQty, 1);
+
+ // 快照 > 0 → 一律以上游为准,不被订单实算值覆盖
+ const snapshotWins = metrics({ avgSale: 0.5, recentShippedQty: 30, realtimeStock: 0 });
+ assert.equal(snapshotWins.dailySaleSource, 'snapshot');
+ assert.equal(snapshotWins.dailySale, 0.5, '不覆盖上游快照');
+
+ // 快照 0 且无发货 → 仍是「无销量」,不参与预警(兜底不能凭空造需求)
+ const noFallback = metrics({ avgSale: 0, recentShippedQty: 0, realtimeStock: 0 });
+ assert.equal(noFallback.dailySale, 0);
+ assert.equal(noFallback.dailySaleSource, 'snapshot');
+ assert.equal(noFallback.warningLevel, '无销量');
+ assert.equal(noFallback.hasWarning, false);
+
+ // 兜底日销很小但库存充足 → 正常,不制造噪声
+ const enough = metrics({ avgSale: 0, recentShippedQty: 1, realtimeStock: 50, pendingOrderQty: 0 });
+ assert.equal(enough.dailySaleSource, 'orders');
+ assert.equal(enough.warningLevel, '正常');
+ assert.equal(enough.hasWarning, false);
+
+ // 兜底后又快又慢:兜底日销 > 0 时走日销驱动分支(①),不再走订单驱动(②)——
+ // 因为此时已经有真实需求历史,用「结余库存 ÷ 日销」衡量比「库存 ÷ 代发件数」更准
+ const both = metrics({
+ avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1,
+ });
+ assert.equal(both.dailySaleSource, 'orders');
+ assert.equal(both.orderDriven, false, '有兜底日销后不再是订单驱动');
+ assert.equal(both.warningLevel, '缺货');
+ // 建议补货量 = ceil(3 × 0.1333 + 5 − 0) = ceil(5.3999) = 6(先补订单缺口再补 3 天缓冲)
+ assert.equal(both.replenishQty, 6);
+
+ // 订单源整体不可用 → recentShippedQty 缺省 0 → 退化成旧口径(不会误报)
+ const degraded = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 });
+ assert.equal(degraded.recentShippedQty, 0);
+ assert.equal(degraded.dailySaleSource, 'snapshot');
+ assert.equal(degraded.warningLevel, '无销量');
+});
+
test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => {
// 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。
// 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存
diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx
index f5d9f8d..b74ac62 100644
--- a/frontend/src/pages/MkhBomData/index.jsx
+++ b/frontend/src/pages/MkhBomData/index.jsx
@@ -539,7 +539,23 @@ const MkhBomData = () => {
dataIndex: 'dailySale',
width: 106,
align: 'right',
- render: (value) => (Number(value) > 0 ? number(value, 2) : 0),
+ render: (value, row) => {
+ const text = Number(value) > 0 ? number(value, 2) : 0;
+ // 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。
+ // 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。
+ if (row.dailySaleSource !== 'orders') return text;
+ return (
+
+ {text}
+
+ );
+ },
},
{
title: '现有库存',
diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css
index 55300ae..afb18b0 100644
--- a/frontend/src/pages/MkhBomData/style.css
+++ b/frontend/src/pages/MkhBomData/style.css
@@ -129,6 +129,13 @@
font-weight: 600;
}
+/* 日销兜底(上游快照漏算时按订单表实算,见服务文件头 ⑦-a):
+ 虚线表示该值不是上游快照,悬停有说明,避免与源系统数字对照时误判为数据错误 */
+.mkh-sale-fallback {
+ border-bottom: 1px dashed currentColor;
+ cursor: help;
+}
+
/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */
.mkh-tag--shortage {
color: #722ed1;