fix: MKH-BOM 日销兜底排除 0.01 元特殊单,修复「没发过货却有日销」
背景
- 业务反馈 MKHDNZ0410260700005 从没发过货,页面却显示日销 0.6 并报缺货。
- 根因是上一版日销兜底(⑦-a)只看 send_date,把「0.01 元特殊单」当成了真实销售:
· 拼多多-亿家达书房家具官方旗舰店在 2026-09-18 10:35–10:36 **一分钟内**批量生成
18 张 MKH 订单,实付合计仅 0.18 元、免额合计 16,344 元(单张:实付 0.01 元、
商品额 908.01、免额 908 全额免),备注统一为 SSDD,标签含「特殊单」「转x00005」;
同批还有 18 张商品码为占位码 1.X00005 的 0.01 元订单。
· 这批单 status=Sent、有物流单号,但 **rpt.jst_salesOutboundInfo 里查不到对应出库记录**
(同店同时段其他订单有 6,467 行出库)—— 即系统标了「已发货」,货并没有真出。
· 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警。
改动
- loadOrderIndex 的**已发货分支**增加异常单过滤:命中任一即不计入日销
· pay_amount <= 0.01(0.01 元全免单)
· labels 含「特殊单」
- 查询补充取出 o.pay_amount / o.labels(两张订单表均有这两列,已确认)。
- 金额取不到(null / 空串 / 非数字)时**不排除** —— 宁可少排也别误杀,取不到就退化成旧口径。
- ⚠️ **不使用 free_amount > 0 作为判据**:正常订单普遍带折扣免额(窗口内实测 569/1086 行
free_amount > 0),按它筛会把实付 356 / 458 / 1338 元的正常单一并误杀(第一版即踩此坑)。
- 本次只作用于「已发货」分支,待发货(⑩)口径未改动。
验证
- 语法检查:151 文件通过。
- 后端测试:338 项 337 通过。唯一失败为 resilientSecretStore 权限断言(期望 0o600、
Windows 实际 0o666),平台固有,与本改动无关。mkhBomService 单测 44/44。
- 新增回归断言:实付 0.01 / 0 元及字符串金额判为异常单;标签含「特殊单」判为异常单;
**有免额的正常单不得被排除**;金额缺省/为 null/非数字时不排除。
- 订单索引用例补充:0.01 元单与「特殊单」标签行不进入 shippedBySku,而带折扣免额的
正常单照常计入(shippedBySku.size 由 1 调整为 2)。
- 真实数据端到端(业务库实时,窗口 15 天):
· 应排除的 10 个异常单 SKU(MKHDNZ0410260700001/0002/0004/0005/0007/0008/0009/
0010/0011/0012)**全部已排除**;
· 应保留的 12 个真实单 SKU **全部保留**(含本问题之外的 MKHDNZ0409260601548 等);
· 兜底生效 SKU 由 **22 → 12**;兜底日销最大值由 **1.8000 → 0.2000**
(0.2 来自 MKHDNZ0409260700483 的 3 件真实发货,属正常)。
风险与回退
- 只影响「快照日销 ≤ 0 且近 15 天有发货」的商品;快照 > 0 的商品行为完全不变。
- 实付 ≤ 0.01 元的订单在任何业务口径下都不构成正常销售,排除不会误伤真实需求。
- 回退:本提交可整体 revert,无表结构变更、无数据迁移。
- 遗留(未在本次改动内):待发货(⑩)分支同样可能被 0.01 元单驱动,
是否一并排除需业务确认;「售后 / 补发」类订单是否计入亦有待定夺。
This commit is contained in:
parent
4946018251
commit
dff2422b34
@ -118,6 +118,7 @@
|
||||
// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架
|
||||
// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐;
|
||||
// 不排除的话那 16 个样品单编码会被误拉进预警
|
||||
// · **排除异常单**(2026-09-19 补充,见下):订单表里的「已发货」不等于真实销售
|
||||
// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径
|
||||
// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到
|
||||
// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货
|
||||
@ -125,10 +126,26 @@
|
||||
// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`,
|
||||
// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判
|
||||
// · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报)
|
||||
//
|
||||
// 🔥 **必须排除「异常单」(2026-09-19 业务反馈后修正)**
|
||||
// 业务报「MKHDNZ0410260700005 没发过货,为啥有日销」→ 查下来是兜底把 **0.01 元特殊单**
|
||||
// 当成了销售。该单画像(拼多多-亿家达书房家具官方旗舰店):
|
||||
// · 实付 **0.01 元**、商品额 908.01、**免额 908(全额免)**、备注 `SSDD`、标签含「特殊单」
|
||||
// · 该店在 **09-18 10:35–10:36 一分钟内**批量生成 18 张同样的 0.01 元 MKH 单
|
||||
// (实付合计 0.18 元、免额合计 16,344 元),另有 18 张占位码 `1.X00005` 的 0.01 元单
|
||||
// · **`rpt.jst_salesOutboundInfo` 里这些 o_id 查不到任何出库记录**
|
||||
// (同店同时段其他单有 6,467 行)→ 系统标「已发货」,货其实没出
|
||||
// · 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警
|
||||
// 规则:已发货分支排除 **`pay_amount ≤ 0.01`** 或 **`labels` 含「特殊单」** 的单。
|
||||
// ⚠️ **别用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行),
|
||||
// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。
|
||||
// ⚠️ 只作用于**已发货**分支;待发货(⑩)口径本次未动。
|
||||
//
|
||||
// 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中
|
||||
// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**,
|
||||
// 含本问题的 MKHDNZ0409260601548,以及 09-18 才发货、快照尚未跟上的
|
||||
// MKHDNZ04102607000xx 一批。
|
||||
// 含本问题的 MKHDNZ0409260601548。这 22 个里:**12 个是真实单驱动**(实付 299~1338 元,
|
||||
// 兜底正确),**10 个是上述 0.01 元异常单驱动**(`MKHDNZ0410260700001/0002/0004/0005/
|
||||
// 0007/0008/0009/0010/0011/0012`)—— 加过滤后这 10 个日销归零,另 12 个不受影响。
|
||||
// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」,
|
||||
// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。
|
||||
//
|
||||
@ -344,6 +361,16 @@ const PENDING_ORDER_SKU_LIKE = '%MKH%';
|
||||
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
||||
// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警
|
||||
const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']);
|
||||
// 日销兜底(⑦-a):**异常单**同样不能计入「已发货」——
|
||||
// 2026-09-19 实测:店铺在 1 分钟内批量生成 18 张「实付 0.01 元 / 商品额 908 元全额免 /
|
||||
// 备注 SSDD / 标签含「特殊单」」的单(同批还有占位码 X00005 的单),
|
||||
// 在 `rpt.jst_salesOutboundInfo` 里**查不到任何出库记录**(同店其他单有 6467 行)——
|
||||
// 即系统标了「已发货」但货没真出。它们曾把 10 个 SKU 的日销从 0 抬到 0.6~1.8。
|
||||
// 判定:实付 ≤ 0.01 元,或标签含「特殊单」。
|
||||
// ⚠️ **不要用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行),
|
||||
// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。
|
||||
const SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX = 0.01;
|
||||
const SALE_EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze(['特殊单']);
|
||||
|
||||
// ── 配件不足 / 可组装数参数(见文件头 ⑪)────────────────────────
|
||||
// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id
|
||||
@ -1275,14 +1302,30 @@ const isExcludedSaleShop = (shopName) => {
|
||||
return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword));
|
||||
};
|
||||
|
||||
/**
|
||||
* 是否为「异常单」—— 标了已发货但货没真出的单,不能计入兜底日销(见文件头 ⑦-a)。
|
||||
* ① 标签含「特殊单」;
|
||||
* ② 实付金额 ≤ 0.01 元(0.01 元全免单)。
|
||||
* ⚠️ 金额未知(null / 非数字)时**不排除** —— 宁可用数据也别误杀,取不到就退化成旧口径。
|
||||
*/
|
||||
const isExcludedSaleOrder = (row = {}) => {
|
||||
const labels = toText(row.labels);
|
||||
if (labels && SALE_EXCLUDED_ORDER_LABEL_KEYWORDS.some((keyword) => labels.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
const paid = toNumber(row.payAmount);
|
||||
return paid !== null && paid <= SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX;
|
||||
};
|
||||
|
||||
/**
|
||||
* 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a):
|
||||
* pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩)
|
||||
* shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店)
|
||||
* 口径:
|
||||
* · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩)
|
||||
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a)
|
||||
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a,已排除样品/测试店 + 0.01 元异常单)
|
||||
* · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计
|
||||
* · 异常单过滤只作用于**已发货**分支:0.01 元特殊单「标了发货但没真出库」,不算销售
|
||||
* ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。
|
||||
*/
|
||||
async function loadOrderIndex() {
|
||||
@ -1307,6 +1350,8 @@ async function loadOrderIndex() {
|
||||
o.order_date AS orderDate,
|
||||
o.send_date AS sendDate,
|
||||
o.shop_name AS shopName,
|
||||
o.pay_amount AS payAmount,
|
||||
o.labels AS labels,
|
||||
o.skus AS skus
|
||||
FROM ${table} o WITH (NOLOCK)
|
||||
WHERE o.skus LIKE @pendingOrderSkuLike
|
||||
@ -1342,6 +1387,8 @@ async function loadOrderIndex() {
|
||||
// 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除
|
||||
const shipped = row.sendDate !== null && row.sendDate !== undefined;
|
||||
if (shipped && isExcludedSaleShop(row.shopName)) return;
|
||||
// 已发货(⑦-a):0.01 元特殊单「标了发货但没真出库」,同样不计入日销
|
||||
if (shipped && isExcludedSaleOrder(row)) return;
|
||||
|
||||
const orderDate = row.orderDate instanceof Date ? row.orderDate : null;
|
||||
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
|
||||
@ -2175,9 +2222,12 @@ module.exports = {
|
||||
parseOrderSkus,
|
||||
isMkhProductCode,
|
||||
isExcludedSaleShop,
|
||||
isExcludedSaleOrder,
|
||||
SALE_EXCLUDED_SHOP_KEYWORDS,
|
||||
SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX,
|
||||
SALE_EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||
PENDING_ORDER_EXCLUDED_STATUS,
|
||||
ORDER_TABLES,
|
||||
SALE_EXCLUDED_SHOP_KEYWORDS,
|
||||
// ⑪ 配件不足 / 可组装数
|
||||
buildComponentOccupation,
|
||||
buildComponentReadiness,
|
||||
|
||||
@ -402,6 +402,21 @@ test('pending order: only MKH finished products count, combination codes are exc
|
||||
assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true);
|
||||
assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false);
|
||||
assert.equal(_private.isExcludedSaleShop(null), false);
|
||||
// 日销兜底(⑦-a)排除异常单:0.01 元特殊单「标了发货但没真出库」,不是销售
|
||||
assert.equal(_private.SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, 0.01);
|
||||
assert.deepEqual([..._private.SALE_EXCLUDED_ORDER_LABEL_KEYWORDS], ['特殊单']);
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 0.01 }), true, '实付 0.01 元 → 异常单');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 0 }), true, '实付 0 元 → 异常单');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: '0.01' }), true, '字符串金额同样识别');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 908.01, labels: '模块化成品,特殊单,禁止拆分' }), true,
|
||||
'标签含「特殊单」→ 异常单(即使实付正常)');
|
||||
// ⚠️ 反例:正常单普遍有折扣免额,**不能**按 free_amount 判异常,否则会误杀
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 356, freeAmount: 50 }), false, '有免额的正常单不得排除');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 1338.82, freeAmount: 236.26 }), false, '有免额的正常单不得排除');
|
||||
// 金额取不到时不排除(宁可用数据也别误杀)
|
||||
assert.equal(_private.isExcludedSaleOrder({}), false, '金额缺省 → 不排除');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: null }), false, '金额为 null → 不排除');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除');
|
||||
});
|
||||
|
||||
test('order index: reads both channel tables, merges and dedupes by o_id', async () => {
|
||||
@ -458,6 +473,36 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
shopName: '某某测试专用店铺',
|
||||
skus: '1.MKHDNZ0409260608888',
|
||||
},
|
||||
// 异常单(⑦-a):0.01 元全免单,标了已发货但没真出库 → 排除
|
||||
// (复现 MKHDNZ0410260700005:实付 0.01 / 免额 908 / 标签含「特殊单」)
|
||||
{
|
||||
oId: 9009,
|
||||
sendDate: new Date('2026-09-18T00:00:00Z'),
|
||||
shopName: '拼多多-亿家达书房家具官方旗舰店',
|
||||
payAmount: 0.01,
|
||||
freeAmount: 908,
|
||||
labels: '大单待确认,模块化成品,特殊单,禁止拆分,线上发货,转x00005',
|
||||
skus: '9.MKHDNZ0410260700005*9',
|
||||
},
|
||||
// 异常单(⑦-a):实付正常但标签含「特殊单」→ 同样排除
|
||||
{
|
||||
oId: 9010,
|
||||
sendDate: new Date('2026-09-18T00:00:00Z'),
|
||||
shopName: '拼多多-亿家达书房家具官方旗舰店',
|
||||
payAmount: 500,
|
||||
labels: '模块化成品,特殊单',
|
||||
skus: '1.MKHDNZ0410260700099',
|
||||
},
|
||||
// 对照:有折扣免额的**正常单**必须照常计入(第一版按 free_amount 判会误杀它)
|
||||
{
|
||||
oId: 9011,
|
||||
sendDate: new Date('2026-09-18T00:00:00Z'),
|
||||
shopName: '拼多多-HX旗舰店103',
|
||||
payAmount: 356,
|
||||
freeAmount: 50,
|
||||
labels: '店铺-拼多多,模块化成品',
|
||||
skus: '1.MKHDNZ0410260700023',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -482,7 +527,12 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
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, '被排除的店铺不进入索引');
|
||||
// 异常单排除(⑦-a):0.01 元全免单、标签含「特殊单」都不计入已发货
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0410260700005'), false, '0.01 元异常单不得计入日销');
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销');
|
||||
// 有免额的正常单必须照常计入
|
||||
assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除');
|
||||
assert.equal(index.shippedBySku.size, 2, '被排除的店铺/异常单不进入索引');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user