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 1/3] =?UTF-8?q?fix:=20MKH-BOM=20=E6=97=A5=E9=94=80?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=8E=92=E9=99=A4=200.01=20=E5=85=83?= =?UTF-8?q?=E7=89=B9=E6=AE=8A=E5=8D=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=E3=80=8C?= =?UTF-8?q?=E6=B2=A1=E5=8F=91=E8=BF=87=E8=B4=A7=E5=8D=B4=E6=9C=89=E6=97=A5?= =?UTF-8?q?=E9=94=80=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(); From 0e0fcbb3782a553f2d5655b6bc798ca264c39d6d Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 17:53:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20MKH-BOM=20=E6=97=A5=E9=94=80?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=A2=9E=E5=8A=A0=E5=87=BA=E5=BA=93=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=8C=E6=A0=87=E4=BA=86=E3=80=8C=E5=B7=B2=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E3=80=8D=E4=BD=86=E6=97=A0=E5=AE=9E=E9=99=85=E5=87=BA?= =?UTF-8?q?=E5=BA=93=E7=9A=84=E5=8D=95=E4=B8=8D=E8=AE=A1=E9=94=80=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务口径:标了「已发货」但没有实际出库的 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。 --- backend/src/services/mkhBomService.js | 213 +++++++++++++++++++++++--- backend/test/mkhBomService.test.js | 97 +++++++++++- 2 files changed, 284 insertions(+), 26 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index bb740f2..77b16e9 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -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, // ⑪ 配件不足 / 可组装数 diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 52ccce1..051922d 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -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(); From 5e029f831f27d0f6db1a71e986e536f0281a3d70 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 18:11:38 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20MKH-BOM=20=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=AE=B1=E8=A1=A8=E5=85=9C=E5=BA=95=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=8C=E5=BD=93=E6=97=A5=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E5=BD=93=E5=A4=A9=E6=98=BE=E7=A4=BA=E5=BA=93=E5=AD=98=E4=B8=BA?= =?UTF-8?q?=200=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:MKHDNZ0409260700491 当天已入库、实物有货,但页面上「现有库存」一直是 0; 点「同步最新数据」也不变。 根因:页面唯一的库存源 rpt.jst_inventoryInfo 是「有变化才写 updateTime」的同步表, 品牌仓尤其稀疏。该 SKU 在 inventoryInfo 里只有主仓 14138566 的一行 qty=0, 品牌仓连行都没有;而小时级的箱表 rpt.jst_packInventoryInfo 里, 品牌仓 bin='P-1' / qty=4 / createTime 当天 15:19 —— 当日入库的货只有箱表看得见。 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 「同步最新数据」按钮只清服务端缓存,不触发上游同步,所以点多少次都是 0。 改法(只补 0,不覆盖有效值): ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',旧口径不变) ② 否则箱表品牌仓 bin 非空行合计 > 0 → 用它(stockSource='pack') ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0 ④ 否则回退监控快照 jsStock → stockSource='monitor' 为什么不拿箱表当主源:两表都 > 0 且不等的 28 个成品里 25 个是 inventoryInfo 更大 (箱表只记已装箱的货,散货不计),拿它当主源会系统性低估库存、误报缺货。 另外必须过滤 bin 为空的行——那是装箱记录不是库存(品牌仓合计 21 件)。 影响面(实测):品牌仓 4,408 个成品里只有 3 个走箱表兜底 (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4, 同一箱 115870495000001201、货位 P-1、均为当天新建行), 预警列表 27 → 24 条(缺货 21 → 18),少的正是这 3 条「入库当天被误判缺货」。 前端:「现有库存」列 tooltip 增加 pack 来源说明(当天入库、实时库存表尚未同步)。 验证:check-syntax 151 文件通过;node --test 339/340(唯一失败是既有的 resilientSecretStore Windows 权限断言);mkhBomService.test.js 47/47; HTTP 实测该 SKU stock=4 / stockSource=pack,点「同步最新数据」后仍正确。 遗留(需业务/上游跟进):inventoryInfo 当天 06:41 后就没再更新过 (09-17 / 09-18 都是全天逐小时更新),疑似上游同步任务异常; 箱表兜底只解决「当日入库看不见」,存量库存仍依赖该表恢复。 --- backend/src/services/mkhBomService.js | 81 ++++++++++++++++++++++- backend/test/mkhBomService.test.js | 85 +++++++++++++++++++++++++ frontend/src/pages/MkhBomData/index.jsx | 15 ++++- 3 files changed, 175 insertions(+), 6 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 77b16e9..26c9528 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -176,6 +176,42 @@ // ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded: // 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。 // +// ── ⑦-d 库存源时效兜底:当日入库读箱表(2026-09-19 新增)────────── +// 业务原话:「MKHDNZ0409260700491 这个今日已经入库,现有库存是有…… +// 点击同步最新数据这个按钮,数据没有更新,还是显示库存为 0」。 +// 🔥 根因:**页面唯一的库存源 `jst_inventoryInfo` 是「有变化才写 updateTime」的同步表, +// 当天新入库的成品未必有对应行**(品牌仓尤其稀疏,一天只更新几十行)。 +// 2026-09-19 实测: +// · 该 SKU 在 inventoryInfo 全表**只有主仓 14138566 的一行 qty=0**, +// 品牌仓**连行都没有**;而箱表品牌仓 bin='P-1' / qty=4 / createTime 当日 15:19 +// · 横向对照:inv 当天最后更新 06:41(品牌仓最后 04:42,当天仅 3 行), +// 箱表当天最后更新 17:01(小时级)→ **当日入库的货只有箱表看得见** +// · ⚠️ 且 inv 当天 06:41 后**再没更新过**(09-17 / 09-18 都是全天逐小时更新) +// → 大概率上游同步任务当天异常,需业务侧跟进(见文末遗留) +// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 +// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**, +// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。 +// 兜底规则(**只补 0,不覆盖有效值**): +// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致) +// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack') +// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime') +// ④ 否则回退监控快照 jsStock → (stockSource='monitor') +// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大** +// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。 +// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件), +// 不过滤会虚增。 +// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个** +// (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4, +// 同一箱 115870495000001201、货位 P-1,均为**当日新建**行); +// 预警列表随之 27 → 24 条(缺货 21 → 18),这 3 条都是「入库当天被误判缺货」。 +// 性能:箱表全量取数 **87ms**(brand 仓 + bin 非空 + GROUP BY),无额外负担。 +// +// ⚠️ 遗留(需业务 / 上游跟进):箱表兜底只解决「**当日入库看不见**」这一种情形。 +// inventoryInfo 若长期停更,**存量库存的时效性仍依赖该表恢复**——届时页面库存 +// 会停留在停更时点(本次即 09-19 06:41)的旧值,本项目无从感知。 +// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的 +// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。 +// // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 @@ -1005,10 +1041,30 @@ function buildReplenishMetrics(row = {}) { const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0; const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; + // 现有库存来源优先级(见文件头 ⑦-d): + // inventoryInfo 品牌仓有货 → 用它(旧口径不变) + // 否则箱表(小时级)有货 → 用它(补「当日入库、日更库存表还没同步」的盲区) + // 否则 inventoryInfo 有行(qty≤0) → 用 0(该仓确实没货,别再退回更旧的快照) + // 否则回退监控快照 const realtimeStock = toNumber(row.realtimeStock); + const packStock = toNumber(row.packStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; - const stock = hasRealtime ? realtimeStock : monitorStock; + let stock; + let stockSource; + if (hasRealtime && realtimeStock > 0) { + stock = realtimeStock; + stockSource = 'realtime'; + } else if (packStock !== null && packStock > 0) { + stock = packStock; + stockSource = 'pack'; + } else if (hasRealtime) { + stock = realtimeStock; + stockSource = 'realtime'; + } else { + stock = monitorStock; + stockSource = 'monitor'; + } // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); @@ -1044,7 +1100,7 @@ function buildReplenishMetrics(row = {}) { pendingOrderEarliest: toDateText(row.pendingOrderEarliest), orderDriven, thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), - stockSource: hasRealtime ? 'realtime' : 'monitor', + stockSource, safetyDays: toNumber(row.safetyDays), suggestStock: toNumber(row.suggestStock) ?? 0, monitorDate: toDateText(row.monitorDate), @@ -1628,11 +1684,22 @@ async function loadReplenishIndex() { REPLENISH_PARAMS, { requestTimeout: 180000 } ), + // 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到, + // 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。 + cloudDatabase.query( + `SELECT p.sku_id AS skuId, SUM(p.qty) AS qty + FROM dbo.jst_packInventoryInfo p + WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%' + AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL + GROUP BY p.sku_id`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询 loadOrderIndex(), - ]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => { + ]).then(([monitorRows, stockRows, packStockRows, workOrderIndex, orderIndex]) => { const workOrderBySku = workOrderIndex.bySku; const pendingOrderBySku = orderIndex.pendingBySku; const shippedBySku = orderIndex.shippedBySku; @@ -1641,6 +1708,11 @@ async function loadReplenishIndex() { const key = normalizeCode(row.skuId); if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); }); + const packStock = new Map(); + packStockRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) packStock.set(key, toNumber(row.qty)); + }); const seenMonitor = new Set(); const bySku = new Map(); @@ -1657,6 +1729,8 @@ async function loadReplenishIndex() { recentShippedQty: shippedBySku.get(key) || 0, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, + // 箱/货位库存(⑦-d):inventoryInfo 日更,当日入库的货只有这张小时级表有 + packStock: packStock.has(key) ? packStock.get(key) : null, monitorStock: row.monitorStock, safetyDays: row.safetyDays, suggestStock: row.suggestStock, @@ -2376,6 +2450,7 @@ module.exports = { buildReplenishMetrics, loadPendingWorkOrderIndex, loadOrderIndex, + loadReplenishIndex, parseOrderSkus, isMkhProductCode, isExcludedSaleShop, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 051922d..d7f0ad2 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -234,6 +234,33 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only assert.equal(zero.warningLevel, '缺货'); }); +test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => { + // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 + const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); + assert.equal(fillFromZero.stock, 4); + assert.equal(fillFromZero.stockSource, 'pack'); + assert.notEqual(fillFromZero.warningLevel, '缺货'); + + const fillFromMissing = metrics({ avgSale: 1, realtimeStock: null, packStock: 5, monitorStock: 0 }); + assert.equal(fillFromMissing.stock, 5); + assert.equal(fillFromMissing.stockSource, 'pack'); + + // 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存 + const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 }); + assert.equal(realtimeWins.stock, 4); + assert.equal(realtimeWins.stockSource, 'realtime'); + + // 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照 + const bothZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 0, monitorStock: 9 }); + assert.equal(bothZero.stock, 0); + assert.equal(bothZero.stockSource, 'realtime'); + + // 库存表整个品牌仓无记录、箱表也没货 → 才回退监控快照 + const snapshot = metrics({ avgSale: 1, realtimeStock: null, packStock: 0, monitorStock: 9 }); + assert.equal(snapshot.stock, 9); + assert.equal(snapshot.stockSource, 'monitor'); +}); + test('replenish: products without 7-day sales never warn', () => { const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); assert.equal(noSale.hasWarning, false); @@ -419,6 +446,64 @@ test('pending order: only MKH finished products count, combination codes are exc assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除'); }); +test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + const queried = []; + + // 复现 MKHDNZ0409260700491:当日 15:19 入库,日更的 inventoryInfo 还没有这条, + // 只有小时级的箱表有;监控快照 jsStock = 0。 + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_packInventoryInfo')) { + queried.push('pack'); + return [{ skuId: 'MKHDNZ0409260700491', qty: 4 }]; + } + if (sql.includes('jst_inventoryInfo')) { + queried.push('inventory'); + return []; + } + if (sql.includes('pm_stockMonitor')) { + queried.push('monitor'); + return [{ + skuId: 'MKHDNZ0409260700491', + fifteenAvgSale: 0, + monitorStock: 0, + safetyDays: 3, + suggestStock: 0, + monitorDate: new Date('2026-09-18T00:00:00Z'), + }]; + } + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + queried.push('outbound'); + return []; + } + if (sql.includes('jst_qmcust_orderlist')) { + queried.push('qmcust'); + return []; + } + queried.push('orders'); + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ input() {}, query: async () => ({ recordset: [] }) }), + }); + + try { + const index = await svc._private.loadReplenishIndex(); + assert.ok(queried.includes('pack'), '箱表必须被读到'); + assert.ok(queried.includes('inventory'), '实时库存表仍要读'); + const row = index.bySku.get('MKHDNZ0409260700491'); + assert.equal(row.stock, 4, '当日入库的货必须显示出来,不能显示 0'); + assert.equal(row.stockSource, 'pack'); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); + test('order index: reads both channel tables, merges and dedupes by o_id', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index b74ac62..231dc8e 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -147,6 +147,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = { none: '未找到可用的收件人', }; +// 「现有库存」列的取值来源说明(后端 stockSource 字段): +// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更) +// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)—— +// 实时库存表当天还没同步到这条货(当日入库)时用它兜底 +// monitor = 两个实时源都没有记录,退回监控快照库存 +const STOCK_SOURCE_TEXT = { + realtime: '品牌仓实时库存', + pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)', + monitor: '品牌仓实时库存缺失,取监控快照库存', +}; + // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 // 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), // 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 @@ -564,9 +575,7 @@ const MkhBomData = () => { align: 'right', render: (value, row) => ( {number(value)}