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:
parent
dff2422b34
commit
0e0fcbb378
@ -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,
|
||||
// ⑪ 配件不足 / 可组装数
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user