From dff2422b34a5d78ee3603bfe57c9539d34da67ac Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 14:49:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20MKH-BOM=20=E6=97=A5=E9=94=80=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E6=8E=92=E9=99=A4=200.01=20=E5=85=83=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E5=8D=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=E3=80=8C=E6=B2=A1?= =?UTF-8?q?=E5=8F=91=E8=BF=87=E8=B4=A7=E5=8D=B4=E6=9C=89=E6=97=A5=E9=94=80?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 - 业务反馈 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 元单驱动, 是否一并排除需业务确认;「售后 / 补发」类订单是否计入亦有待定夺。 --- backend/src/services/mkhBomService.js | 58 +++++++++++++++++++++++++-- backend/test/mkhBomService.test.js | 52 +++++++++++++++++++++++- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 94c7428..bb740f2 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -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, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 3034c53..52ccce1 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -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();