fix: MKH-BOM 日销兜底增加出库校验,标了「已发货」但无实际出库的单不计销量

业务口径:标了「已发货」但没有实际出库的 SKU,不需要出现缺货预警。
即在 ⑦-a 的金额/标签判据之上,再以出库表为准判定「有没有真出货」。

已发货分支计入销量需同时满足(待发货分支不变):
  1. 非样品/测试店铺(⑦-a)
  2. 非异常单:pay_amount <= 0.01 或 labels 含「特殊单」(⑦-a)
  3. 出库表能查到该 o_id:rpt.jst_salesOutboundInfo / rpt.jst_qmcust_salesoutbound

两个必须豁免的情形(否则会大面积误杀真实单):
  · 拆分单(status='Split'):出库记录挂在子单上,父单必然查不到。
    实测窗口内 40 张 Split 单 100% 无出库记录;不豁免会误杀
    「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单
  · 今天发货的单:当天出库尚未同步完(09-19 全天出库表仅 1 行,09-18 有 14,690 行)。
    豁免只到「昨天」为止——09-18 的 18 张无出库 Sent 单 100% 是特殊单,
    且出库表 09-18 数据完整,豁免 09-18 会放过刷单
  豁免只免「出库校验」,金额/标签判据照样生效。

真实数据验收(近 15 天窗口,1,086 张已发货 MKH 单):
  · 出库表命中 889 张 → 197 张无出库 = 40 张拆分单 + 157 张 Sent
  · 157 张里 131 张是 ≤0.01 元或「特殊单」(即 ⑦-a 已排除的刷单),
    唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消/发货后售后/风控订单」)
  · 兜底生效 SKU = 12 个(与只按金额/标签判据一致),兜底日销最大值 = 0.2
  · 10 个异常单 SKU 在预警列表中零出现;总预警 27 条(缺货 21 + 仅预警 6)

性能:1086 个订单号分 2 块并行 UNION 查询,冷缓存约 0.4~1.4s;
      整窗口拉全量 DISTINCT o_id 实测要 8s,故不采用。

降级策略:出库数据取不到时整体跳过出库校验(退化成只按金额/标签判据)并标 degraded。
漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。

注意:jst_salesOutboundInfo.qty 全表 1,119,606 行恒为 0,只能判「有没有出库行」,
不能用 SUM(qty)(永远得 0)。

验证:check-syntax 151 文件通过;node --test 338/339(唯一失败是既有的
resilientSecretStore Windows 权限断言,与本改动无关);mkhBomService.test.js 45/45。
This commit is contained in:
yanhui 2026-09-19 17:53:16 +08:00
parent dff2422b34
commit 0e0fcbb378
2 changed files with 284 additions and 26 deletions

View File

@ -149,6 +149,33 @@
// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」,
// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。
//
// ── ⑦-c 出库校验:**必须真出库才算销量**(2026-09-19 业务确认口径)────
// 业务原话:「这些异常 SKU,系统标记了『已发货』,但没有实际出库的,不需要出现缺货预警」。
// 即 ⑦-a 的金额/标签判据再往根上退一步——**以出库表为准**:
// 已发货分支要计入销量,必须同时满足
// ① 不是异常单(`pay_amount ≤ 0.01` 或 `labels` 含「特殊单」,⑦-a)
// ② 在出库表里查得到该 `o_id`(`jst_salesOutboundInfo` / `jst_qmcust_salesoutbound`)
// 实测(2026-09-19,窗口 15 天、1,086 张已发货 MKH 单):
// · 出库表命中 889 张 → 197 张无出库,其中 **40 张是拆分单、157 张是 Sent**
// · 157 张里 **131 张是 ≤0.01 元或「特殊单」**(即 ⑦-a 已排除的那批刷单),
// 剩下唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消 / 发货后售后 /
// 风控订单」)—— ② 这条判据正是为它这类「标了发货其实没出库」的单兜底
// · 与只按金额/标签判据相比,**当前窗口的兜底生效 SKU 集合完全一致(12 个)**,
// 属"同解但更根本",代价是每次冷缓存多约 0.5s 的出库查询
// 🔥 **两个必须豁免的情形**(不豁免就会大面积误杀真实单,实测数据如下):
// ① **拆分单**(`status='Split'`):父单被拆成子单后出库挂在**子单**上,
// 父单必然查不到出库 —— 窗口内 40 张 Split 单 **100% 无出库记录**,
// 若不豁免会误杀「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单
// ② **今天发货的单**:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行,
// 而 09-18 有 14,690 行)→ 09-19 的 7 张 Sent 无出库单里特殊单为 0,
// 说明无出库是**同步滞后**而非虚假发货。
// 注意豁免只到「昨天」为止:09-18 的 18 张无出库 Sent 单 **100% 是特殊单**,
// 出库表 09-18 数据完整,所以 09-18 不能被豁免(否则会放过刷单)
// ⚠️ 只判**有没有出库行**:`jst_salesOutboundInfo.qty` 全表 1,119,606 行恒为 0
// (`SUM(qty)` 永远得 0,近 15 天 180,433 行同样),别拿数量做判据。
// ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded:
// 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。
//
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
@ -372,6 +399,21 @@ const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']);
const SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX = 0.01;
const SALE_EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze(['特殊单']);
// ── 出库校验参数(见文件头 ⑦-c)──────────────────────────────────
// 出库表:判定「标了已发货的单到底有没有真出货」。两张表按渠道分开,**必须都查**——
// 2026-09-19 实测近 15 天窗口内 1,086 张 MKH 已发货单:京东表命中 889、天猫表命中 198,
// jst_qmcust_orderlist 来源的 208 张单里 7 张只有京东表有记录、1 张只有天猫表有记录。
const OUTBOUND_TABLES = Object.freeze([
'rpt.jst_salesOutboundInfo', // 京东 / 拼多多 / 抖音 等
'rpt.jst_qmcust_salesoutbound', // 天猫 / 淘宝 / 1688 等
]);
// 单个查询里塞多少个 o_id:1000 个参数远低于 MSSQL 单语句 2100 上限,
// 实测 1086 个订单号分 2 块并行查询合计 ≈ 515ms(整窗口拉全量 DISTINCT 要 8s,别那么干)
const OUTBOUND_LOOKUP_CHUNK_SIZE = 1000;
// 「拆分单」状态:父单被拆成子单后,出库记录挂在**子单**上,父单必然查不到出库。
// 2026-09-19 实测窗口内 40 张 Split 单 **100% 无出库记录** → 不能拿「无出库」判它造假。
const SPLIT_ORDER_STATUSES = Object.freeze(['split']);
// ── 配件不足 / 可组装数参数(见文件头 ⑪)────────────────────────
// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id
const COMPONENT_STOCK_WMS_CO_ID = 15718196;
@ -1317,15 +1359,92 @@ const isExcludedSaleOrder = (row = {}) => {
return paid !== null && paid <= SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX;
};
/** 是否为「拆分单」——父单的出库记录挂在子单上,父单查不到出库属正常(见 ⑦-c) */
const isSplitOrder = (status) => {
const text = toText(status);
return Boolean(text) && SPLIT_ORDER_STATUSES.includes(text.toLowerCase());
};
/** 发货日是否为**今天** —— 当天出库尚未同步完成,不能用出库判据(见 ⑦-c) */
const isShippedToday = (sendDate) => {
const date = sendDate instanceof Date ? sendDate : new Date(sendDate);
if (Number.isNaN(date.getTime())) return false;
const today = new Date();
return date.getFullYear() === today.getFullYear()
&& date.getMonth() === today.getMonth()
&& date.getDate() === today.getDate();
};
/**
* 该已发货单能否用「出库记录」来判定真伪(见 ⑦-c)。
* 不能判定的两种情形,退回只按 ⑦-a 的金额/标签判据,**不做无出库排除**,避免误杀:
* · 拆分单:出库挂在子单上,父单必然查不到
* · 今天发货:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行)
* 取不到订单号时同样豁免——宁可漏杀,不可错杀。
*/
const isOutboundCheckSkipped = (row = {}) => (
isSplitOrder(row.status)
|| isShippedToday(row.sendDate)
|| !toText(row.oId)
);
/**
* 批量查「这些订单号在出库表里有没有实际出库记录」(见 ⑦-c)。
* 只取 o_id 一列、分块并行查,失败时返回空集合 + 错误信息让调用方降级。
* ⚠️ 只判**有没有行**:`qty` 列全表 1,119,606 行恒为 0,`SUM(qty)` 永远得 0,别用它。
*/
async function loadOutboundOrderIds(orderIds) {
const ids = [...new Set((orderIds || []).map((id) => toText(id)).filter(Boolean))];
if (!ids.length) return { ids: new Set(), error: null };
const chunks = [];
for (let index = 0; index < ids.length; index += OUTBOUND_LOOKUP_CHUNK_SIZE) {
chunks.push(ids.slice(index, index + OUTBOUND_LOOKUP_CHUNK_SIZE));
}
const settled = await Promise.allSettled(chunks.map((chunk) => {
const params = {};
const placeholders = chunk
.map((value, index) => { params[`outboundOid${index}`] = value; return `@outboundOid${index}`; })
.join(', ');
const union = OUTBOUND_TABLES
.map((table) => `SELECT o_id AS oId FROM ${table} WITH (NOLOCK) WHERE o_id IN (${placeholders})`)
.join('\n UNION\n ');
return cloudDatabase.query(
`SELECT DISTINCT outbound_rows.oId AS oId
FROM (
${union}
) outbound_rows`,
params,
{ requestTimeout: 180000 }
);
}));
const hits = new Set();
const failed = [];
settled.forEach((result, index) => {
if (result.status !== 'fulfilled') {
failed.push(`出库表分块#${index + 1}: ${result.reason?.message || result.reason}`);
return;
}
result.value.forEach((row) => {
const text = toText(row.oId);
if (text) hits.add(text);
});
});
return { ids: hits, error: failed.length ? failed.join(' | ') : null };
}
/**
* 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a):
* pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩)
* shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店)
* 口径:
* · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩)
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a,已排除样品/测试店 + 0.01 元异常单)
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a),且
* ①非样品/测试店铺、②非异常单(金额/标签)、③**有实际出库记录**(⑦-c)
* · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计
* · 异常单过滤只作用于**已发货**分支:0.01 元特殊单「标了发货但没真出库」,不算销售
* · 三条过滤只作用于**已发货**分支:待发货(⑩)本来就是没出货的订单,不做出库校验
* ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。
*/
async function loadOrderIndex() {
@ -1352,6 +1471,7 @@ async function loadOrderIndex() {
o.shop_name AS shopName,
o.pay_amount AS payAmount,
o.labels AS labels,
o.status AS status,
o.skus AS skus
FROM ${table} o WITH (NOLOCK)
WHERE o.skus LIKE @pendingOrderSkuLike
@ -1365,11 +1485,14 @@ async function loadOrderIndex() {
)`,
{ pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams },
{ requestTimeout: 180000 }
))).then((results) => {
))).then(async (results) => {
const pendingBySku = new Map();
const shippedBySku = new Map();
const seenOrderIds = new Set();
const failed = [];
const shippedRows = [];
const pendingRows = [];
const outboundCheckIds = [];
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
@ -1390,27 +1513,61 @@ async function loadOrderIndex() {
// 已发货(⑦-a):0.01 元特殊单「标了发货但没真出库」,同样不计入日销
if (shipped && isExcludedSaleOrder(row)) 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;
if (shipped) {
shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty);
return;
}
const entry = pendingBySku.get(key) || {
pendingOrderQty: 0,
pendingOrderCount: 0,
pendingOrderEarliest: null,
};
entry.pendingOrderQty += qty;
entry.pendingOrderCount += 1;
if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) {
entry.pendingOrderEarliest = orderDate;
}
pendingBySku.set(key, entry);
});
if (shipped) {
shippedRows.push(row);
// ⑦-c:可用于出库校验的单先记下订单号,第二遍统一过滤
if (!isOutboundCheckSkipped(row)) outboundCheckIds.push(orderId);
return;
}
pendingRows.push(row);
});
});
// ⑦-c:已发货单再过一道「有没有真出库」。
// 出库数据取不到(或只取到一部分)时**整体不做出库排除**,退化成只按金额/标签判据——
// 宁可漏杀不可错杀:漏杀只是多一条预警,错杀会让真实缺货彻底隐身。
let outboundReady = false;
let outboundIds = new Set();
if (outboundCheckIds.length) {
const outbound = await loadOutboundOrderIds(outboundCheckIds);
if (outbound.error) {
console.warn('[MKH-BOM] 出库数据源读取失败,本次跳过出库校验:', outbound.error);
failed.push(outbound.error);
} else {
outboundReady = true;
outboundIds = outbound.ids;
}
}
shippedRows.forEach((row) => {
const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId);
// ⑦-c:能判定、却没查到出库 → 系统标了「已发货」但货没真出,不计入销量
if (outboundReady && !isOutboundCheckSkipped(row) && !outboundIds.has(orderId)) return;
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
if (!isMkhProductCode(code)) return;
const key = normalizeCode(code);
if (!key) return;
shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty);
});
});
pendingRows.forEach((row) => {
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 = pendingBySku.get(key) || {
pendingOrderQty: 0,
pendingOrderCount: 0,
pendingOrderEarliest: null,
};
entry.pendingOrderQty += qty;
entry.pendingOrderCount += 1;
if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) {
entry.pendingOrderEarliest = orderDate;
}
pendingBySku.set(key, entry);
});
});
@ -2226,6 +2383,14 @@ module.exports = {
SALE_EXCLUDED_SHOP_KEYWORDS,
SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX,
SALE_EXCLUDED_ORDER_LABEL_KEYWORDS,
// ⑦-c 出库校验
isSplitOrder,
isShippedToday,
isOutboundCheckSkipped,
loadOutboundOrderIds,
OUTBOUND_TABLES,
OUTBOUND_LOOKUP_CHUNK_SIZE,
SPLIT_ORDER_STATUSES,
PENDING_ORDER_EXCLUDED_STATUS,
ORDER_TABLES,
// ⑪ 配件不足 / 可组装数

View File

@ -425,7 +425,14 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
const originalQuery = cloudDatabase.query;
const queried = [];
// 出库表命中集合(⑦-c):9005 / 9007 / 9011 有真实出库记录;
// 9012(金额正常但无出库)、9009 / 9010 / 9015(异常单)刻意不在此列
const outboundHits = ['9005', '9007', '9011'];
cloudDatabase.query = async (sql) => {
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
queried.push('outbound');
return outboundHits.map((oId) => ({ oId }));
}
if (sql.includes('jst_qmcust_orderlist')) {
queried.push('qmcust');
return [
@ -503,6 +510,46 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
labels: '店铺-拼多多,模块化成品',
skus: '1.MKHDNZ0410260700023',
},
// ⑦-c:金额正常、但出库表查不到 → 系统标了「已发货」货没真出 → 排除
// (业务原话:标了已发货但没有实际出库的不需要出现缺货预警)
{
oId: 9012,
sendDate: new Date('2026-09-10T00:00:00Z'),
shopName: '拼多多-HX旗舰店105',
payAmount: 388,
labels: '发货后取消,发货后售后,风控订单',
skus: '1.MKHDNZ0409260601888',
},
// ⑦-c 豁免①:拆分单的出库挂在子单上,父单查不到属正常 → 必须保留
// (实测窗口内 40 张 Split 单 100% 无出库记录)
{
oId: 9013,
status: 'Split',
sendDate: new Date('2026-09-15T00:00:00Z'),
shopName: '京东-HX旗舰店104',
payAmount: 1194.14,
labels: '以旧换新,模块化成品',
skus: '1.MKHDNZ0409260601777',
},
// ⑦-c 豁免②:今天发货的单,当天出库尚未同步完 → 必须保留
{
oId: 9014,
sendDate: new Date(),
shopName: '京东-HX旗舰店104',
payAmount: 500,
labels: '模块化成品',
skus: '1.MKHDNZ0409260601666',
},
// ⑦-c:拆分单/今天单虽然豁免**出库**校验,但金额/标签判据照样生效
{
oId: 9015,
status: 'Split',
sendDate: new Date('2026-09-15T00:00:00Z'),
shopName: '拼多多-亿家达书房家具官方旗舰店',
payAmount: 0.01,
labels: '特殊单,模块化成品',
skus: '1.MKHDNZ0410260700777',
},
];
};
@ -510,7 +557,7 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
svc.clearMkhBomCache();
const index = await _private.loadOrderIndex();
assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到');
assert.deepEqual(queried.sort(), ['orders', 'outbound', 'qmcust'], '两张订单表 + 出库表都必须读到');
assert.equal(index.degraded, false);
const a = index.pendingBySku.get('MKHDNZ0409260700059');
@ -532,7 +579,53 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销');
// 有免额的正常单必须照常计入
assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除');
assert.equal(index.shippedBySku.size, 2, '被排除的店铺/异常单不进入索引');
// ⑦-c 出库校验:标了已发货但出库表查不到 → 不计入日销
assert.equal(index.shippedBySku.has('MKHDNZ0409260601888'), false, '无实际出库记录的不得计入日销');
// ⑦-c 豁免:拆分单 / 今天发货的单不能用出库判定,必须保留
assert.equal(index.shippedBySku.get('MKHDNZ0409260601777'), 1, '拆分单无出库记录属正常,不得误杀');
assert.equal(index.shippedBySku.get('MKHDNZ0409260601666'), 1, '今天发货的出库尚未同步,不得误杀');
// ⑦-c 豁免只免「出库校验」,金额/标签判据照样生效
assert.equal(index.shippedBySku.has('MKHDNZ0410260700777'), false, '拆分单的 0.01 元异常单仍要排除');
assert.equal(index.shippedBySku.size, 4, '被排除的店铺/异常单/无出库单不进入索引');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('order index: outbound lookup failure skips the check instead of dropping sales', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
// 出库表整体不可用(例如视图未同步)——此时宁可漏杀不可错杀:
// 不做出库排除(否则真实销售会被整批砍掉),但要标 degraded 触发短 TTL 重试。
cloudDatabase.query = async (sql) => {
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
throw new Error('出库视图不可用');
}
if (sql.includes('jst_qmcust_orderlist')) return [];
return [
{
oId: 9101,
sendDate: new Date('2026-09-10T00:00:00Z'),
shopName: '京东-HX旗舰店104',
payAmount: 388,
labels: '模块化成品',
skus: '1.MKHDNZ0409260601777',
},
];
};
try {
svc.clearMkhBomCache();
const index = await _private.loadOrderIndex();
assert.equal(index.degraded, true, '出库源失败要标 degraded,让接口透出并尽快重试');
assert.equal(
index.shippedBySku.get('MKHDNZ0409260601777'),
1,
'出库数据取不到时不得据此砍掉销量(退化成只按金额/标签判据)'
);
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();