From 54518ab4d3643e82f35241711f33f10e7c9b5c97 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 29 Oct 2026 15:16:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(wst):=20=E6=8A=8A=E9=94=80=E5=94=AE?= =?UTF-8?q?=E5=87=BA=E5=BA=93=E5=8D=95=E7=BA=B3=E5=85=A5=E4=BD=9C=E5=BA=9F?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E4=B8=8E=E5=81=9C=E6=9B=B4=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务 14:34 点名 9992097 / 9992098 / 9992924 / 9992925 / 9992926 / 9993284 / 9993285 / 9995464 / 9995465「洛宁仓 16 单都是作废订单,为啥还在 考核之内」,并指明「在聚水潭的销售出库单里面找一下这个作废单据」。 ## 查出来的事实 这 9 单在库里**三处全部显示「还在发货」**: 订单表 qmcust status=Delivering、send_date 空 销售出库单 status=WaitConfirm(待出库) 状态镜像表 order_status_name=发货中、logistics=无物流单 不是判据漏了,是**三条数据源全停在作废动作之前**(2026-10-29 15:13 实测): jst_Orders 14:45 正常 qmcust 13:25 滞后 1.8h 状态镜像表 11:52 滞后 3.4h 出库单(聚水潭/奇门) 03:10 滞后 12.0h ← 作废就落在这张表上 聚水潭的作废落在**出库单** `status='Delete'` 上,而这张表今天凌晨 3 点后 零写入,所以上午作的废一条都没进来 ## 改动 1. 新增 `loadOrderIoStatus()`:按 o_id 批量取出库单状态(两表、 分批 500 / 并发 3),判据是 **「该 o_id 下没有任何非 Delete 的出库单」**。 - 只对「发货中」生效:全库「出库单全 Delete」的候选单里 127/131 状态表是「异常」,那批还挂着待处理,删出库单更可能是重开单, 当成作废剔会让业务在「不计考核」里也看不到 → 丢可见性比漏剔更糟 - 拆包/重开的单有多张出库单,删一张不算作废(实测 51 单里 7 单是这种) - **「没有出库单」绝不判作废**:近 3 天未发单 82%(1467/1790)本来就没出库单 2. 两张出库单表纳入 `loadSourceFreshness()`(用 local_updated / updateTime 灌库时刻,**不能用 modified**,否则没变更时天天误报) 3. 新增 `freshness.voidJudgeReliable` + hint 明说「作废单/取消单可能判不出来」, 告诉业务去聚水潭「销售出库单」核对 4. 明细加「销售出库单」列(已作废/待出库/已出库/未建单),导出同步加列 ## 验证 - WST 单测 27/27(新增:出库单全删必剔 / 拆包重开不剔 / 异常单不剔 / 无出库单不剔 / 停更时提示作废判不出来 / 源正常时不误报) - 后端全量 498/500(2 个既有失败:负缓存 TTL、Windows 文件权限) - 真实数据:ioVoidSkipped=11(发货中 + 出库单全删),overdue 仍 16 单 - 本地真渲染:表头有「销售出库单」、首行显示「待出库」、 红条列出 5 个数据源停更状态、**pageerror 0** ## 没解决(已在页面明说,不掩盖) 这 9 单今天仍然判不出作废 —— 数据没进来,判据再准也没用。 根子在 ETL:出库单表停 12 小时。ETL 恢复后判据立即生效, 但那 9 单是否作废仍需 IT 修同步或业务在聚水潭复核。 --- .../warehouseShipTimelinessRegistry.js | 24 +++ .../warehouseShipTimelinessService.js | 192 +++++++++++++++++- backend/test/warehouseShipTimeliness.test.js | 105 +++++++++- .../pages/WarehouseShipTimeliness/index.jsx | 53 ++++- 4 files changed, 360 insertions(+), 14 deletions(-) diff --git a/backend/src/services/warehouseShipTimelinessRegistry.js b/backend/src/services/warehouseShipTimelinessRegistry.js index 2e30293..c8b5ceb 100644 --- a/backend/src/services/warehouseShipTimelinessRegistry.js +++ b/backend/src/services/warehouseShipTimelinessRegistry.js @@ -401,6 +401,29 @@ const AFTERSALE_VOID_STATUS = '已作废'; const STATE_ORDER_VOID_STATUS = '已取消'; const STATE_ORDER_SHIPPED_STATUS = '已发货'; +/** + * 销售出库单 `status` 里**等于单据作废**的值。 + * + * 🔥 2026-10-29 新增(业务点名 9992097 等 9 单:「在聚水潭的销售出库单里找一下这个作废单据」)。 + * + * 聚水潭的作废会落在**出库单**上:`jst_salesOutboundInfo.status` / `jst_qmcust_salesoutbound.status` + * 取值只有四种(近 2 天实测):`Confirmed` / `WaitConfirm` / `Delete` / `OuterConfirming`, + * 其中 `Delete` 就是单据作废。 + * + * 为什么必须加这一条判据(状态表 `order_status_name='已取消'` 不够): + * · 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`、36 单是 `Question`**, + * 这些单状态镜像表未必抓得到(它一天只抓几次),只能靠出库单判; + * · 出库单是仓库侧最贴身的单据,作废动作一定先落在这里。 + * + * ⚠️ **不能只看「有 Delete 就算作废」**:一个订单可能有多张出库单(拆包 / 重开), + * 删掉旧的那张、新的还在 `WaitConfirm` —— 这种单照常要发。 + * ⟹ 判据是「**该订单没有任何非 Delete 的出库单**」,见 service 里 ioAllDeleted 的说明。 + * + * ⚠️ 也**不能**用「没有出库单」当判据:近 3 天未发单里 **82%(1467/1790)本来就没有出库单** + * (订单还没推下游),拿它判作废会把绝大多数正常待发单全放跑。 + */ +const IO_VOID_STATUS = 'Delete'; + /** * 状态镜像表 `logistic_status_name` 里**等于已发**的值。 * @@ -689,6 +712,7 @@ module.exports = { STATE_ORDER_VOID_STATUS, STATE_ORDER_SHIPPED_STATUS, STATE_LOGISTIC_SHIPPED_STATUSES, + IO_VOID_STATUS, SHIP_TIMELINESS_HOURS, toNumber, }; diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index 12021c3..a57e74c 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -110,6 +110,7 @@ const { STATE_ORDER_VOID_STATUS, STATE_ORDER_SHIPPED_STATUS, STATE_LOGISTIC_SHIPPED_STATUSES, + IO_VOID_STATUS, resolveCenter, } = registry; @@ -445,7 +446,103 @@ async function loadStateInfo(stateRows) { } /** - * 两个订单表**各自**的最后更新时间,用于判断「这个源还活着吗」。 + * 批量取**销售出库单**的状态 —— 判「这单在聚水潭里被作废了吗」。 + * + * 🔥 2026-10-29 新增。业务原话:「9992097…这些订单都是作废订单,为啥还在考核之内」, + * 并指明「在聚水潭的**销售出库单**里面找一下这个作废单据」。 + * + * 为什么必须补这一层(订单表 + 状态镜像表都不够): + * · 订单表 `labels` 的「发货前售后」判据,在停更/正常数据上都会漏(15:00 那批 0 命中); + * · 状态镜像表 `order_status_name='已取消'` 一天只抓几次,抓不到的作废就漏; + * · **出库单 `status='Delete'` 才是仓库侧最贴身的作废痕迹** —— 近 7 天实测: + * 出库单被删的 2209 单里,**55 单订单表仍是 `Delivering`、36 单 `Question`**, + * 这批人只能靠出库单判出来。 + * + * 判据边界(血泪): + * · 一个订单可能有多张出库单(拆包 / 重开)→ **必须「该 o_id 下没有任何非 Delete 的出库单」** + * 才算作废。实测近 7 天 51 个「订单发货中但出库单被删」的单里,有 7 个挂着 2~8 张 + * 出库单且全为 Delete(拆包重开),不能凭「有一张 Delete」就剔。 + * · **没有出库单 ≠ 作废**:近 3 天未发单 **82%(1467/1790)压根没有出库单** + * (订单还没推下游),拿它判作废会把正常待发单全放跑。 + * + * 性能:按来源表分别 `o_id IN (...)`,一批 500、并发 3(出库单表比状态表小得多, + * 实测单批 1~3s)。查询失败 → 返回空 Map,**不阻断页面**(判据退化成「不判」)。 + * + * 🔴 2026-10-29 13:10 实测:两张出库单表 `local_updated` 一起停在 **03:10**(停更 11h), + * `modified` 也全是凌晨的。停更期间业务新作废的单在这里**永远是 WaitConfirm** —— + * 所以出库单也必须进 `loadSourceFreshness`,否则「查不到作废」会被误读成「没作废」。 + * + * @param {Array} orderRows 候选订单行(含 `o_id` / `src`) + * @returns {Promise>} + */ +async function loadOrderIoStatus(orderRows) { + const TABLE_BY_SRC = { + jst_Orders: 'rpt.jst_salesOutboundInfo', + jst_qmcust_orderlist: 'rpt.jst_qmcust_salesoutbound', + }; + // src → 去重后的 o_id 列表 + const idsBySrc = new Map(); + for (const r of orderRows || []) { + const src = String(r.src || ''); + if (!TABLE_BY_SRC[src]) continue; + const oid = Number(r.o_id); + if (!Number.isFinite(oid)) continue; + if (!idsBySrc.has(src)) idsBySrc.set(src, new Set()); + idsBySrc.get(src).add(oid); + } + const result = new Map(); + if (!idsBySrc.size) return result; + + const BATCH = 500; + const CONCURRENCY = 3; + const tasks = []; + for (const [src, idSet] of idsBySrc) { + const ids = [...idSet]; + for (let i = 0; i < ids.length; i += BATCH) { + tasks.push({ src, chunk: ids.slice(i, i + BATCH) }); + } + } + + for (let i = 0; i < tasks.length; i += CONCURRENCY) { + const group = tasks.slice(i, i + CONCURRENCY); + // eslint-disable-next-line no-await-in-loop + const settled = await Promise.allSettled(group.map((t) => cloudDatabase.querySlow( + // o_id 已用 Number.isFinite 过滤,拼进 IN 是纯数字,无注入面 + `SELECT o_id, io_id, status FROM ${TABLE_BY_SRC[t.src]} WITH (NOLOCK) + WHERE o_id IN (${t.chunk.join(',')})`, + {}, + { connectRequestTimeoutMs: 120000 }, + ))); + for (const s of settled) { + if (s.status !== 'fulfilled') continue; // 单批失败不影响其它批 + for (const r of s.value || []) { + const oid = Number(r.o_id); + if (!Number.isFinite(oid)) continue; + const st = String(r.status || '').trim(); + if (!result.has(oid)) result.set(oid, { statuses: [], alive: 0, dead: 0 }); + const rec = result.get(oid); + if (st && !rec.statuses.includes(st)) rec.statuses.push(st); + if (st === IO_VOID_STATUS) rec.dead += 1; + else rec.alive += 1; // 含空状态:认不出就当还活着(宁可漏判,不误剔) + } + } + } + + const out = new Map(); + for (const [oid, rec] of result) { + out.set(oid, { + // 页面展示用:优先显示「还有效」的那个状态(业务一眼看出这单的进度) + status: rec.alive > 0 ? (rec.statuses.find((s) => s !== IO_VOID_STATUS) || '') : IO_VOID_STATUS, + statuses: rec.statuses, + // 唯一可信的作废判据:有出库单、且一张都没活着 + allDeleted: rec.dead > 0 && rec.alive === 0, + count: rec.dead + rec.alive, + }); + } + return out; +} +/** + * 各数据源**各自**的最后更新时间,用于判断「这个源还活着吗」。 * * 🔥 为什么不能只看候选行:候选池本身就是 `send_date IS NULL` 的单, * 某张表停更后它的候选单 `updateTime` 会集体冻在停更那一刻,看起来「都一样旧」; @@ -489,6 +586,26 @@ async function loadSourceFreshness(round, now) { sql: `SELECT MAX(updatedAt) AS maxUpd FROM rpt.jst_order_express_details WITH (NOLOCK) WHERE updatedAt >= '${startLit}'`, }, + { + // 🔥 2026-10-29 新增:两张**销售出库单**表也必须监控。 + // 它是「单据作废了吗」的唯一来源(订单表停更、状态表一天只抓几次)。 + // 实测当天它俩的 `local_updated` **一起停在 03:10**(停更 11 小时), + // 而业务上午在聚水潭作的废全落在这张表上 —— 页面看到的是 11 小时前的 + // `WaitConfirm`(待出库),于是那批单永远「查不出作废」。 + // ⚠️ 判断必须用 `local_updated / updateTime`(灌库时刻), + // **不能用 `modified`**(业务字段)—— 没有单据变更时它天然是旧的, + // 拿它判停更会天天误报(这个坑在 `jst_inventoryInfo` 上踩过一次)。 + key: 'jst_salesOutboundInfo', + label: '出库单(聚水潭)', + sql: `SELECT MAX(updateTime) AS maxUpd FROM rpt.jst_salesOutboundInfo WITH (NOLOCK) + WHERE updateTime >= '${startLit}'`, + }, + { + key: 'jst_qmcust_salesoutbound', + label: '出库单(奇门)', + sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_salesoutbound WITH (NOLOCK) + WHERE local_updated >= '${startLit}'`, + }, ]; // 两个源并行查(各自 60s 上限,任一个慢不拖累另一个) @@ -540,6 +657,9 @@ function emptySummary() { // 已剔除的「订单已取消」单量:状态表 order_status_name='已取消' // ⚠️ 订单表停更期间这是**唯一**能判出作废的来源(labels 判据会整体失效) stateVoidSkipped: 0, + // 已剔除的「出库单整体作废」单量:销售出库单 status 全为 Delete + // 🔥 2026-10-29 新增 —— 业务点名「洛宁仓 16 单都是作废订单」,聚水潭的作废落在出库单上 + ioVoidSkipped: 0, // 🔥 超时单里「所在数据源已停更」的单量 —— 这些单的「未发」不可信,可能早已发出。 // 2026-10-28 18:00 轮实测:546 单里 540 单来自停更的 jst_Orders。 sourceStaleUnshipped: 0, @@ -666,6 +786,17 @@ async function getShipTimeliness(options = {}) { console.warn('[WST] 状态镜像表查询失败,全量回退订单创建时间:', e.message); } + // ③.55 销售出库单状态:判「单据作废」(业务 2026-10-29 点名要求) + // ⚠️ 查不到不阻断页面 —— 判据退化成「不判作废」,由停更告警兜底提示不可信 + let ioStatusMap = new Map(); + let ioDegraded = false; + try { + ioStatusMap = await loadOrderIoStatus(stateRows); + } catch (e) { + ioDegraded = true; + console.warn('[WST] 销售出库单查询失败,作废判据降级:', e.message); + } + // ③.6 数据源新鲜度:两个订单表**各自独立同步**,必须分源判断停更 ────────── // 🔥 2026-10-28 18:35 实测:`jst_Orders` 停更在 14:40(4 小时), // `jst_qmcust_orderlist` 正常到 17:30。那批「未发 546 单」里 540 单来自 @@ -688,6 +819,8 @@ async function getShipTimeliness(options = {}) { let stateShippedSkipped = 0; // 状态表 `order_status_name='已取消'` → 订单已作废,剔除(订单表停更时唯一可靠来源) let stateVoidSkipped = 0; + // 出库单全部为 `Delete` → 单据作废,剔除(2026-10-29 新增,业务点名的作废单) + let ioVoidSkipped = 0; // 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…) let unknownWarehouse = 0; // 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信 @@ -753,6 +886,10 @@ async function getShipTimeliness(options = {}) { // 状态镜像表里的聚水潭终态(订单表停更时唯一可信的「这单还发不发」) const stateOrderStatus = stateInfo ? (stateInfo.orderStatus || '') : ''; const stateLogisticStatus = stateInfo ? (stateInfo.logisticStatus || '') : ''; + // 销售出库单状态(2026-10-29 新增)—— 判「单据作废」的唯一可靠来源 + const ioInfo = ioStatusMap.get(Number(row.o_id)) || null; + const ioStatus = ioInfo ? (ioInfo.status || '') : ''; + const ioAllDeleted = Boolean(ioInfo && ioInfo.allDeleted); const oaConfirmAt = stateInfo ? (stateInfo.last || '') : ''; // 起算点 = 审核时间 / 订单 modified / 下单时间 三者取最大。 @@ -795,6 +932,30 @@ async function getShipTimeliness(options = {}) { continue; } + // ── 作废②:销售出库单整体作废(2026-10-29 新增,业务点名的那批单)────────── + // 🔥 业务原话:「9992097 / 9992098 / 9992924 … 洛宁仓 16 单,这些订单都是作废订单, + // 为啥还在考核之内」,并指明「在聚水潭的**销售出库单**里面找一下这个作废单据」。 + // + // 聚水潭的作废落在**出库单**上(`status='Delete'`),而不是订单表的 labels。 + // 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`** —— + // 订单表和状态表都可能抓不到,只有出库单能判出来。 + // + // ⚠️ **必须「该 o_id 下没有任何非 Delete 出库单」才算作废**: + // 拆包 / 重开的单会有多张出库单,删掉旧的那张、新的还在 WaitConfirm —— 照常要发。 + // 实测近 7 天 51 个「订单发货中但出库单被删」的单里,7 个挂着 2~8 张全 Delete 的出库单。 + // ⚠️ **「没有出库单」绝不能判作废**:近 3 天未发单 82%(1467/1790)压根没出库单。 + // + // 🔴 **只对「发货中」生效,异常单/待审核单不碰**(判据放在 here、held 判断之前用 + // `statusRaw === ORDER_PENDING_STATUS` 卡住)。原因:实测全库「出库单全 Delete」 + // 的候选单里 **127/131 状态表是「异常」**,那批单在聚水潭还挂着待处理, + // 出库单被删更可能是「重新建单」,不是作废 —— 当成作废剔掉会让业务 + // 在「不计考核」里也看不到它们,是**丢可见性**,比漏剔更糟。 + if (ioAllDeleted && statusRaw === ORDER_PENDING_STATUS) { + voidCount += 1; + ioVoidSkipped += 1; + continue; + } + // ── 已发:三条任一成立即算(状态表比订单表可信得多)──────────────────── // ① `send_time` 有值 —— 订单表 send_date 漏回写时的兜底 // (2026-10-28 实测 144 单里 6 单是这种) @@ -841,6 +1002,10 @@ async function getShipTimeliness(options = {}) { stateLogisticStatus, stateQueryTime: stateInfo ? (stateInfo.queryTime || '') : '', stateSendTime: stateInfo ? (stateInfo.sendTime || '') : '', + // 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库) + // 业务核对时直接对照聚水潭「销售出库单」页面 + ioStatus, + ioCount: ioInfo ? ioInfo.count : 0, // 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算 benchmarkAt, benchmarkSource, @@ -945,6 +1110,10 @@ async function getShipTimeliness(options = {}) { stateOrderStatus: x.stateOrderStatus, stateLogisticStatus: x.stateLogisticStatus, stateQueryTime: x.stateQueryTime, + // 销售出库单状态(Delete=作废 / WaitConfirm=待出库 / Confirmed=已出库) + // 🔥 业务在聚水潭「销售出库单」页面看到的作废,就对应这里的 Delete + ioStatus: x.ioStatus, + ioCount: x.ioCount, // 该单所在订单表是否已停更 —— 前端据此给单打「数据未同步」标记 src: x.src, srcLabel: x.srcLabel, @@ -1025,6 +1194,8 @@ async function getShipTimeliness(options = {}) { summary.stateShippedSkipped = stateShippedSkipped; // 由**状态镜像表终态**剔除的作废单量(订单表 labels 判据在停更期会整体失效) summary.stateVoidSkipped = stateVoidSkipped; + // 由**销售出库单整体 Delete** 剔除的作废单量(业务 2026-10-29 点名的作废单) + summary.ioVoidSkipped = ioVoidSkipped; summary.sourceStaleUnshipped = sourceStaleUnshipped; summary.unknownWarehouse = unknownWarehouse; @@ -1052,6 +1223,16 @@ async function getShipTimeliness(options = {}) { // 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。 // 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。 const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline; + + // 🔴 出库单/状态表停更 ⟹ **「这单作废了吗」这件事本身查不出来**(2026-10-29 新增) + // 业务当天点名 9992097 等 9 单「都是作废订单,为啥还在考核之内」: + // 聚水潭里已经作废,但我们库里订单表还是 Delivering、出库单还是 WaitConfirm、 + // 状态表还是「发货中」—— 三个来源全停在作废动作**之前**,所以判据再准也判不出来。 + // ⟹ 这种情况必须**明说判据失效**,不能让业务以为「系统认定这单没作废」。 + const voidJudgeStale = ['jst_order_express_details', 'jst_salesOutboundInfo', 'jst_qmcust_salesoutbound'] + .map((k) => sourceMap.get(k)) + .filter((s) => s && s.stale); + const freshness = { stateMaxQuery: snapshotAt, ageMinutes: snapshotAgeMin, @@ -1059,6 +1240,8 @@ async function getShipTimeliness(options = {}) { coversDeadline, // 数据源已经换成订单表,文案跟着改(别再写「状态镜像」) source: 'jst_Orders', + // 作废/取消判据是否可信(false = 出库单或状态表停更,作废单可能没剔干净) + voidJudgeReliable: voidJudgeStale.length === 0, hint: [ // 有源停更就一定要说,哪怕本轮 0 单 —— 「没停更但没单」和「停更了所以看不到单」是两件事 staleSrcText ? `数据源停更:${staleSrcText}。` : '', @@ -1066,6 +1249,11 @@ async function getShipTimeliness(options = {}) { ? `本轮 ${sourceStaleUnshipped} 单来自停更的数据源(${staleBySrcText}):` + '停更之后发出的单在这里仍是「未发货」,这些单很可能已经发货,请先在聚水潭核对同步状态再判超时。' : '', + voidJudgeStale.length > 0 + ? `⚠️ ${voidJudgeStale.map((s) => s.label).join('、')} 已停止同步,` + + '作废单 / 取消单可能判不出来(聚水潭里的作废动作还没同步到本系统),' + + '下面凡是显示「发货中 · 无物流单」的单,请先在聚水潭「销售出库单」里核对是否已作废。' + : '', !coversDeadline ? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:` + '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。' @@ -1098,6 +1286,8 @@ async function getShipTimeliness(options = {}) { // 审核时间缺失、回退订单创建时间的单量(页面/钉钉需要标出来) fallbackToCreated: orders.filter((o) => o.benchmarkSource === 'created').length, fallbackDegraded: oaConfirmDegraded, + // 销售出库单查询失败 ⟹ 作废判据降级(页面据此提示「作废单可能没剔干净」) + ioDegraded: ioDegraded, // 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮) overrides: windowOverrides, }, diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index 1912ca0..28a017f 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -184,7 +184,7 @@ test('起算点是 orders.created,且只从订单两表取', () => { }); // ── ④ 服务层分桶(注入假查询,不连库)── -function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') { +function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00', ioRows = []) { const WMS = { CQ: 15038426, SD_QGJ: 15137523, @@ -309,11 +309,36 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026- o(67, WMS.CQ, '2026-10-20 06:00:00'), // 状态表停更,无终态信息 → 按未发算 ]; + // 出库单作废专用场景(2026-10-29) + // 🔥 现场:业务点名 9992097 等洛宁仓 9 单「都是作废订单,为啥还在考核之内」, + // 并指明「在聚水潭的**销售出库单**里面找这个作废单据」。 + // 聚水潭作废落在出库单 `status='Delete'` 上,所以判据必须看出库单。 + const ioDbRows = [ + // 71:唯一出库单被删 → 单据作废,剔除 + { o_id: 71, io_id: 9001, status: 'Delete' }, + // 72:拆包重开:旧单 Delete + 新单 WaitConfirm → **仍要考核**(不能凭一张 Delete 就剔) + { o_id: 72, io_id: 9002, status: 'Delete' }, + { o_id: 72, io_id: 9003, status: 'WaitConfirm' }, + // 73:异常单 + 出库单全删 → **仍进「不计考核」**,不能当作用废丢掉可见性 + { o_id: 73, io_id: 9004, status: 'Delete' }, + // 74:压根没有出库单 → 仍要考核(近 3 天 82% 未发单都没有出库单) + // 75:出库单待出库 → 仍要考核(就是洛宁仓那批显示 WaitConfirm 的真积压) + { o_id: 75, io_id: 9005, status: 'WaitConfirm' }, + ]; + const ioScenarioRows = [ + o(71, WMS.HN_LN, '2026-10-20 05:00:00'), + o(72, WMS.HN_LN, '2026-10-20 05:10:00'), + o(73, WMS.HN_LN, '2026-10-20 05:20:00', { status: 'Question' }), + o(74, WMS.HN_LN, '2026-10-20 05:30:00'), + o(75, WMS.HN_LN, '2026-10-20 05:40:00'), + ]; + const rows = scenario === 'modified' ? modifiedRows : (scenario === 'noon' ? noonRows - : (scenario === 'terminal' ? terminalRows : defaultRows)); + : (scenario === 'terminal' ? terminalRows + : (scenario === 'io' ? ioScenarioRows : defaultRows))); - const calls = { orders: 0, oaConfirm: 0, warehouse: 0, freshness: 0, sql: [], params: [] }; + const calls = { orders: 0, oaConfirm: 0, io: 0, warehouse: 0, freshness: 0, sql: [], params: [] }; const handler = async (sql, params) => { const text = String(sql); @@ -323,7 +348,9 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026- } // 数据源新鲜度探测:`SELECT MAX(updateTime) FROM jst_Orders` / // `SELECT MAX(local_updated) FROM jst_qmcust_orderlist` / - // `SELECT MAX(updatedAt) FROM jst_order_express_details`(状态镜像表,2026-10-29 新增)。 + // `SELECT MAX(updatedAt) FROM jst_order_express_details`(状态镜像表,2026-10-29 新增)/ + // `SELECT MAX(updateTime) FROM jst_salesOutboundInfo`、`MAX(local_updated) FROM jst_qmcust_salesoutbound` + // (出库单两表,2026-10-29 新增)。 // 单独记账,**不算订单候选查询** —— 否则「订单两表只允许查一次」的老断言会误伤。 if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text) || /MAX\(updatedAt\)/iu.test(text)) { @@ -332,6 +359,12 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026- // 要测停更场景就把 freshnessUpd 传成当天更早的时刻 return [{ maxUpd: freshnessUpd }]; } + // 销售出库单状态(2026-10-29 新增):`SELECT o_id, io_id, status FROM jst_*salesoutbound` + // 默认空 → 服务层「没有出库单就不判作废」,不影响老用例 + if (/jst_salesOutboundInfo|jst_qmcust_salesoutbound/u.test(text)) { + calls.io += 1; + return ioRows; + } // 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间, // 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 oaRows if (/jst_order_express_details/u.test(text)) { @@ -355,11 +388,11 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026- return { calls, WMS }; } -function withStubbedService(fn, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') { +function withStubbedService(fn, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00', ioRows = []) { return async () => { const dbPath = require.resolve('../src/config/cloudDatabase'); const realDb = require.cache[dbPath]; - const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd); + const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd, ioRows); delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; try { @@ -739,7 +772,65 @@ test('状态表也纳入数据源新鲜度监控', async () => { const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 }); const keys = data.dataSources.map((x) => x.key); assert.ok(keys.includes('jst_order_express_details'), '状态镜像表要作为独立数据源透出'); - assert.equal(data.dataSources.length, 3, '两个订单表 + 状态镜像表'); + // 2026-10-29:加入两张销售出库单表(作废判据的唯一来源,实测一起停更 11 小时) + assert.ok(keys.includes('jst_salesOutboundInfo'), '聚水潭出库单要作为独立数据源透出'); + assert.ok(keys.includes('jst_qmcust_salesoutbound'), '奇门出库单要作为独立数据源透出'); + assert.equal(data.dataSources.length, 5, '两个订单表 + 状态镜像表 + 两张出库单表'); + }, 'terminal'); +}); + +test('出库单整体 Delete = 单据作废,必须剔除;拆包重开 / 无出库单 / 异常单不能误剔', async () => { + // 🔥 2026-10-29 现场:业务点名 9992097 / 9992098 / 9992924 … 洛宁仓 9 单 + // 「这些订单都是作废订单,为啥还在考核之内」,并指明 + // 「在聚水潭的**销售出库单**里面找一下这个作废单据」。 + // 聚水潭的作废落在出库单 status='Delete' 上(订单表 labels 判据在这批单上 0 命中)。 + await withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 20 }); + + // 71 = 唯一出库单被删 → 剔除;73 = 异常单(本就进「不计考核」) + const ids = data.orders.map((x) => String(x.oId)).sort(); + assert.deepEqual(ids, ['72', '74', '75'], + '只剩:拆包重开(72) / 无出库单(74) / 出库单待出库(75)'); + + assert.equal(data.summary.ioVoidSkipped, 1, '出库单作废单独记账'); + assert.equal(data.summary.voidCount, 1, '作废量含出库单判出的那一单'); + + // 🔴 关键反向保护:异常单 + 出库单全删 **不能**被剔成作废, + // 它必须留在「不计考核」里(业务要看到这批待处理的单) + const held = data.heldOrders.map((x) => String(x.oId)); + assert.ok(held.includes('73'), '异常单即便出库单被删,也要留在不计考核清单里'); + + // 明细行要把出库单状态透出来,业务能直接对照聚水潭「销售出库单」页面 + const r75 = data.orders.find((x) => String(x.oId) === '75'); + assert.equal(r75.ioStatus, 'WaitConfirm', '待出库状态要透出'); + const r72 = data.orders.find((x) => String(x.oId) === '72'); + assert.equal(r72.ioStatus, 'WaitConfirm', '拆包重开时展示「还活着」的那张'); + }, 'io', [], '2026-10-20 18:00:00', [ + { o_id: 71, io_id: 9001, status: 'Delete' }, + { o_id: 72, io_id: 9002, status: 'Delete' }, + { o_id: 72, io_id: 9003, status: 'WaitConfirm' }, + { o_id: 73, io_id: 9004, status: 'Delete' }, + { o_id: 75, io_id: 9005, status: 'WaitConfirm' }, + ]); +}); + +test('出库单/状态表停更时,提示要明说「作废判不出来」', async () => { + // 🔥 2026-10-29 13:10 实测:两张出库单表 local_updated 一起停在 03:10(停更 11 小时), + // 业务当天在聚水潭作的废**一条都没同步进来** —— + // 页面必须说清「判据失效」,不能让业务以为「系统认定这单没作废」。 + await withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 }); + assert.equal(data.freshness.voidJudgeReliable, false, '出库单停更 → 作废判据不可信'); + assert.match(String(data.freshness.hint), /作废单\/取消单可能判不出来/u, + '提示要明说作废判不出来'); + assert.match(String(data.freshness.hint), /销售出库单/u, '要告诉业务去哪儿核对'); + }, 'terminal', [], '2026-10-20 10:00:00'); + + // 反方向:源都正常时不能吓唬人 + await withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 }); + assert.equal(data.freshness.voidJudgeReliable, true, '源正常时判据可信'); + assert.doesNotMatch(String(data.freshness.hint), /可能判不出来/u, '不能误报判据失效'); }, 'terminal'); }); diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx index 8ec9354..bf0817e 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/index.jsx +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -329,6 +329,30 @@ const DETAIL_COLUMNS = [ return {text}; }, }, + { + // 销售出库单状态(2026-10-29 新增) + // 🔥 业务点名「这些订单都是作废订单」后挖出来的:聚水潭的作废**落在出库单上** + // (`status='Delete'`),订单表的 labels 判据在那批单上 0 命中。 + // 这一列让业务能直接对照聚水潭「销售出库单」页面,不用再问「系统里为什么没有」。 + title: ( + + 销售出库单 + + ), + dataIndex: 'ioStatus', + width: 112, + render: (v) => { + const map = { + Delete: ['已作废', 'red'], + WaitConfirm: ['待出库', 'blue'], + Confirmed: ['已出库', 'green'], + OuterConfirming: ['外部确认中', 'default'], + }; + const hit = map[v]; + if (!hit) return '—'; + return {hit[0]}; + }, + }, { title: ( @@ -362,6 +386,18 @@ const EXPORT_COLUMNS = [ { title: '发货状态', dataIndex: 'state' }, // 聚水潭终态与订单表状态分开导出:两者不一致的单,就是「订单表没同步」的证据 { title: '聚水潭状态', dataIndex: 'stateOrderStatus', exportValue: (v) => v || '—' }, + // 销售出库单状态:聚水潭的作废落在这一层(Delete=单据已作废), + // 业务对账时要拿这一列跟聚水潭「销售出库单」页面对 + { + title: '销售出库单', + dataIndex: 'ioStatus', + exportValue: (v) => ({ + Delete: '已作废', + WaitConfirm: '待出库', + Confirmed: '已出库', + OuterConfirming: '外部确认中', + }[v] || '未建单'), + }, { title: '状态表抓取时间', dataIndex: 'stateQueryTime', exportValue: (v) => v || '—' }, // 导出也要带上「这单的来源表 + 该表是否停更」,否则 Excel 脱离页面就没有上下文, // 拿着 540 单「未发」去追仓库,实际人家早就发了。 @@ -799,12 +835,17 @@ function WarehouseShipTimeliness() { message={ staleUnshipped > 0 ? `${number(staleUnshipped)} 单所在订单表已停止同步,这些单很可能已经发货` - : (staleSources.length > 0 - // 状态镜像表也算数据源:它停了,判「已取消 / 已发货」的依据就过期了 - ? '数据源已停止同步(订单表 / 状态镜像表)' - : (freshness?.coversDeadline === false - ? '数据未覆盖本轮核验时刻,超时单量偏大' - : '发货数据可能滞后')) + : (freshness?.voidJudgeReliable === false + // 🔥 2026-10-29:出库单/状态表停更 ⟹ 作废判据失效。 + // 业务当天报「洛宁仓 16 单都是作废单」,但我们三个来源全停在作废动作之前, + // 判据再准也判不出来 —— 必须明说,别让业务以为是系统认定「没作废」。 + ? '出库单数据源已停止同步,作废单可能判不出来' + : (staleSources.length > 0 + // 状态镜像表也算数据源:它停了,判「已取消 / 已发货」的依据就过期了 + ? '数据源已停止同步(订单表 / 状态镜像表)' + : (freshness?.coversDeadline === false + ? '数据未覆盖本轮核验时刻,超时单量偏大' + : '发货数据可能滞后'))) } description={( <>