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();