fix: MKH-BOM 新增日销订单表兜底,修复上游快照漏算导致「库存 0 却不报缺货」

背景
- 业务反馈 MKHDNZ0409260601548 近 15 天实发 2 件、库存 0,页面既不报缺货也不进任何预警。
- 根因是双重盲区:
  ① 上游 rpt.pm_stockMonitor.fifteenAvgSale 漏算 —— 快照 09-18 下,窗口内有真实发货的
     178 个 MKH 成品编码里有 24 个被记成 0;其中 16 个订单全部来自样品专用店铺(上游同样
     不计样品单,属合理口径),剩 8 个是真实店铺(天猫/京东/拼多多)。该列是**保留两位
     小数的日均**(最小非零 0.07 = 1 件 ÷ 15 天),无法用四舍五入解释。
  ② 快照日销 = 0 且待发货订单 = 0 时,判定直接落到「无销量」分支、hasWarning=false,
     彻底排除在预警之外 —— 库存为 0 也救不回来。

改动
- 日销兜底(服务文件头新增 ⑦-a):**快照日销 ≤ 0 且近 15 天有真实发货**时,日销改用
  订单表实算值 = 已发货件数 ÷ 15;快照 > 0 一律以上游为准,不覆盖,避免两套口径打架。
  · 排除样品/测试专用店铺(shop_name 含「样品」「测试」),与上游口径对齐
  · 窗口 = 含今天在内的 15 个自然日,与分母同口径
  · 结果透出 dailySaleSource('snapshot'|'orders') / snapshotDailySale / recentShippedQty
- 取数:loadPendingOrderIndex 重构为 loadOrderIndex,一条查询同时覆盖「待发货
  (send_date IS NULL)」与「近 15 天已发货(send_date 在窗口内)」。实测合并后总耗时
  约 1.25s ≈ 单查待发货,而拆成两条独立查询要在现有开销之上再加一次全表扫描
  (首轮冷执行计划下 qmcust 单表可劣化到 20s+)。
- PENDING_ORDER_TABLES 更名 ORDER_TABLES:两张表的口径没变,只是不再仅服务待发货。
- 前端:日销来自兜底时加虚线下划 + 悬停说明,业务拿该数字与源系统 fifteenAvgSale
  对照时不会被误判为数据错误。

验证
- 语法检查:151 文件通过。
- 后端测试:338 项 337 通过。唯一失败为 resilientSecretStore 的权限断言(期望 0o600、
  Windows 实际 0o666),平台固有,与本改动无关。
- 新增回归测试:兜底生效 / 快照非零不覆盖 / 无发货不兜底 / 与订单驱动的优先级 /
  订单源降级时退化成旧口径;订单索引测试补充「已发货同编码跨表累加」与「样品、测试店排除」断言。
- 真实数据端到端(本地接口 /api/mkh-bom/list,4,223 个成品):
  · MKHDNZ0409260601548:日销 0 → 0.1333(source=orders),不再隐身
  · 日销 > 0 的商品 177 → 198;有效预警(缺货 + 预警)18 → 28
  · 21 个等级变化**全部是「无销量 → 缺货/正常/已排产」**,没有任何既有预警被降级
  · 其中 10 个新缺货均为「库存 0 + 近 15 天真实发货 9~27 件」的真盲区
  · 样品店排除按整行生效:实测某编码窗口内 5 件(含样品店 1 件)→ 索引记为 4 件
- 前端 eslint 通过、vite build 通过。

风险与回退
- 只影响「快照日销 = 0」的商品,快照 > 0 的商品行为完全不变。
- 订单源不可用时兜底自动失效,退化成旧口径(不会误报)。
- 回退:本提交可整体 revert,无表结构变更、无数据迁移。
This commit is contained in:
yanhui 2026-09-19 13:24:15 +08:00
parent d650fd02f2
commit 3e64702f12
4 changed files with 280 additions and 57 deletions

View File

@ -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,

View File

@ -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 → 缺货,补货量 = 代发件数 − 现有库存

View File

@ -539,7 +539,23 @@ const MkhBomData = () => {
dataIndex: 'dailySale',
width: 106,
align: 'right',
render: (value) => (Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>),
render: (value, row) => {
const text = Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>;
// 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。
// 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。
if (row.dailySaleSource !== 'orders') return text;
return (
<Tooltip
title={[
`上游近15天日销快照为 ${number(row.snapshotDailySale, 2)}(漏算)`,
`此处按订单实算:近15天已发货 ${number(row.recentShippedQty)} 件 ÷ 15 天`,
'已排除样品/测试专用店铺;上游修好后会自动以上游数字为准',
].join(';')}
>
<span className="mkh-sale-fallback">{text}</span>
</Tooltip>
);
},
},
{
title: '现有库存',

View File

@ -129,6 +129,13 @@
font-weight: 600;
}
/* 日销兜底(上游快照漏算时按订单表实算,见服务文件头 ⑦-a):
虚线表示该值不是上游快照,悬停有说明,避免与源系统数字对照时误判为数据错误 */
.mkh-sale-fallback {
border-bottom: 1px dashed currentColor;
cursor: help;
}
/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */
.mkh-tag--shortage {
color: #722ed1;