From 1139e4c364380f42e8aea2e355e504a9ccd5e2e5 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 29 Oct 2026 13:45:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(wst):=20=E7=94=A8=E8=81=9A=E6=B0=B4?= =?UTF-8?q?=E6=BD=AD=E7=8A=B6=E6=80=81=E7=BB=88=E6=80=81=E5=88=A4=E4=BD=9C?= =?UTF-8?q?=E5=BA=9F/=E5=B7=B2=E5=8F=91=EF=BC=8C=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E4=BD=9C=E5=BA=9F=E5=8D=95=E5=B7=B2=E5=8F=91=E5=8D=95=E8=BF=98?= =?UTF-8?q?=E5=9C=A8=E8=80=83=E6=A0=B8=E9=87=8C=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务 2026-10-29 第三次报同一件事:13:00 轮里作废的单、已经发货的单还在考核。 根因不是判定写错,而是**订单两表各自停更**,停更期间聚水潭做的「取消 / 发货」 订单表完全看不到 —— status 仍是 Delivering、send_date 仍是空。 ## 现场证据(2026-10-29 13:00 轮) - 当天 qmcust 停在 07:25(6 小时),13:00 轮 159 单**全部**来自它 - 订单表侧:159 单 status 全 Delivering、send_date 全空、labels 里**没有** 「发货前售后」⟹ 原来的 `presaleVoid` 判据(订单表 labels 含关键词)完全失效 - 状态镜像表(停在 11:52,比订单表新鲜 4 小时): `order_status_name='已取消'` **11 单**,`order_labels` 里才有「发货前售后」 「已打印被取消」;其余 145 单「发货中」 qmcust 13:25 补数后重算,误判量比想象大得多: **overdue 159 → 16,作废剔除 195 单、已发剔除 17 单。** ## 改动 判据(新增,排在窗口判断与「不计考核」之前): | 判据 | 处理 | |---|---| | `order_status_name='已取消'` | 作废,剔除(计 `stateVoidSkipped`) | | `send_time` 有值 | 已发,剔除 | | `order_status_name='已发货'` | 已发,剔除 | | `logistic_status_name` ∈ 已揽收/运输中/派送中/已签收/拒收 | 已发,剔除 | - `loadOaConfirmInfo` → `loadStateInfo`,同一次 IN 多取 4 个语义列,**不增加往返** - ⚠️ **判据只用 `order_status_name`**:`aftersale_status_name='已作废'` 是「售后单被撤销、 订单照常发货」,近 2 天 1387 单全是这种,混为一谈会把该考核的单全放跑 - ⚠️ `未揽收` / `无物流单` **不算已发**(后者是连快递单都没录,当天 39/159 单是这种) - 状态镜像表纳入 `dataSources` 停更监控(它停了,「取消了吗」的依据就过期了) - 明细/导出新增「聚水潭状态」「物流状态(聚水潭)」「状态表抓取时间」; 页面「发货状态」列改优先显示聚水潭终态,与订单表不一致时打 ⚠ ## 验证 - 后端 `npm run check` 496/498(2 个失败是老问题:负缓存 TTL、Windows 文件权限) - WST 单测 **25/25**,新增两组:终态剔除(含「售后已作废不能误剔」的反向保护)、 状态表纳入新鲜度监控 - 真实数据(本地直连生产 MSSQL,2026-10-29 13:00 轮): `overdue=16 / stateVoidSkipped=195 / stateShippedSkipped=17`, 明细里「已取消」残留 **0**;剩余 16 单全是河南洛宁仓「发货中 + 无物流单」的真积压 - 作废证据交叉核对:状态表近 2 天「已取消」59263 单,94% 带「发货前售后」标签, 与业务口径一致 --- .../warehouseShipTimelinessRegistry.js | 38 ++++++ .../warehouseShipTimelinessService.js | 126 +++++++++++++++--- backend/test/warehouseShipTimeliness.test.js | 74 +++++++++- .../pages/WarehouseShipTimeliness/index.jsx | 66 ++++++--- 4 files changed, 263 insertions(+), 41 deletions(-) diff --git a/backend/src/services/warehouseShipTimelinessRegistry.js b/backend/src/services/warehouseShipTimelinessRegistry.js index bbc539a..2e30293 100644 --- a/backend/src/services/warehouseShipTimelinessRegistry.js +++ b/backend/src/services/warehouseShipTimelinessRegistry.js @@ -378,6 +378,41 @@ const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']); // 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。 const AFTERSALE_VOID_STATUS = '已作废'; +/** + * 状态镜像表 `jst_order_express_details.order_status_name` 的**终态**。 + * + * 🔥 2026-10-29 血的教训(业务第三次报「作废单/已发单还在考核里」): + * 订单两表**会各自停更**(当天 qmcust 停在 07:25、jst_Orders 停在 12:45), + * 停更期间在聚水潭做的「取消 / 发货」动作,订单表里根本看不到 —— + * 订单 status 仍是 `Delivering`、`send_date` 仍是空,于是被当成真超时。 + * 状态镜像表虽然也会停(当天 11:52),但**比订单表新鲜 4 小时**, + * 而且 `order_status_name` 是聚水潭订单状态的直接映射。 + * + * 实测(2026-10-29 13:00 轮 159 单): + * · `order_status_name='已取消'` 11 单 —— 订单表的 `labels` 里**没有**「发货前售后」 + * 这个关键词(`presaleVoid` 判据完全失效),状态表的 `order_labels` 才有,且带 + * 「已打印被取消」。业务就是拿这 11 单说「作废的也在考核之内」。 + * · `order_status_name='已发货'` 0 单 —— 机制上必须有,否则停更期发的货永远判不出。 + * + * ⚠️ **`aftersale_status_name='已作废'` 不是作废单**!那是「售后单被撤销」, + * 订单照常发货、**仍然要考核**(近 2 天 1387 单都是这种)。别把两个混为一谈, + * 能判「这单不用发了」的只有 `order_status_name='已取消'`。 + */ +const STATE_ORDER_VOID_STATUS = '已取消'; +const STATE_ORDER_SHIPPED_STATUS = '已发货'; + +/** + * 状态镜像表 `logistic_status_name` 里**等于已发**的值。 + * + * `拒收` 也算已发 —— 快递拿走过了,客户拒收是后续环节,仓库的发货动作已完成。 + * ⚠️ **`无物流单` 不算**(39/159 单是这种:单子还没录快递单,可能自提/待定), + * `未揽收` 也不算(只是打了单,快递还没来取)。 + * ⚠️ 别拿 `logistic_time` 当发货时间:它多数是「快递员将上门取件」的预告。 + */ +const STATE_LOGISTIC_SHIPPED_STATUSES = Object.freeze([ + '已揽收', '运输中', '派送中', '已签收', '拒收', +]); + // 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。 // 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」, // 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。 @@ -651,6 +686,9 @@ module.exports = { VOID_QUESTION_STATUS, VOID_ORDER_LABEL_KEYWORDS, AFTERSALE_VOID_STATUS, + STATE_ORDER_VOID_STATUS, + STATE_ORDER_SHIPPED_STATUS, + STATE_LOGISTIC_SHIPPED_STATUSES, SHIP_TIMELINESS_HOURS, toNumber, }; diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index bf83ce4..12021c3 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -107,6 +107,9 @@ const { ORDER_QUESTION_STATUS, ORDER_CANDIDATE_STATUSES, VOID_ORDER_LABEL_KEYWORDS, + STATE_ORDER_VOID_STATUS, + STATE_ORDER_SHIPPED_STATUS, + STATE_LOGISTIC_SHIPPED_STATUSES, resolveCenter, } = registry; @@ -357,7 +360,7 @@ async function loadPendingOrders(round) { } /** - * 批量取**审核时间**(起算点)—— 唯一来源是状态镜像表 `jst_order_express_details`。 + * 批量取**状态镜像表**里的单号级信息(起算点 + 终态校验)。 * * 为什么必须回状态表拿: * · `jst_Orders` / `jst_qmcust_orderlist` **都没有审核时间字段**(85 列逐一看过, @@ -367,17 +370,26 @@ async function loadPendingOrders(round) { * ⚠️ 只从它拿**这一个字段**,候选取舍(哪些单未发货)仍然只看订单两表 —— * 状态表的 send_time 漏回写、会整批停更,那些坑一个都不能重新踩回来。 * + * 🔥 2026-10-29 扩展:**不止取时间,还要取终态**。 + * 订单两表会各自停更(当天 qmcust 停在 07:25),停更期间聚水潭里的「取消 / 发货」 + * 订单表根本看不到 —— 订单 status 仍是 Delivering、send_date 仍是空。 + * 而状态表虽也停(11:52),却比订单表新鲜 4 小时,`order_status_name` 已经变成 + * 「已取消」/「已发货」。业务当天报的「作废单还在考核里」11 单就是这么来的: + * 订单表 labels 里**没有**「发货前售后」,`presaleVoid` 完全失效。 + * * 业务口径(2026-10-28):起算点用 `last_oa_confirm_time`(业务选「最后审核时间」, * 理由见文件头)。空值不在这里兜,交给调用方回退 `created`。 * * 性能:`inner_order_id IN (...)` 一次 600 个约 **15s**(全表扫一次,402 万行视图)。 * 🔥 千万别改成逐单查 —— 实测单条 **14.5s**,599 条要 2.4 小时。 * 分批 600 是因为 T-SQL 单条语句 IN 列表太长会解析超时;两批各 600 / 剩余。 + * 多取 4 个语义列(`order_status_name` 等)是同一批 IN 同一次扫描, + * **不增加往返**,实测耗时无变化。 * * 失败降级:抛错由调用方 catch → 全部回退 created,并在 payload 里标 degraded, * **不要因为状态表挂了就整页 500**(候选池在订单表,本来就有数据)。 */ -async function loadOaConfirmInfo(stateRows) { +async function loadStateInfo(stateRows) { const ids = [...new Set( (stateRows || []).map((r) => Number(r.o_id)).filter((n) => Number.isFinite(n)) )]; @@ -401,7 +413,9 @@ async function loadOaConfirmInfo(stateRows) { const results = await Promise.all(group.map((chunk) => { // ids 已用 Number.isFinite 过滤过,拼进 IN 是纯数字,无注入面 return cloudDatabase.querySlow( - `SELECT inner_order_id, first_oa_confirm_time, last_oa_confirm_time, send_time + `SELECT inner_order_id, first_oa_confirm_time, last_oa_confirm_time, send_time, + order_status_name, aftersale_status_name, logistic_status_name, + order_labels, query_time FROM rpt.jst_order_express_details WITH (NOLOCK) WHERE inner_order_id IN (${chunk.join(',')})`, {}, @@ -416,6 +430,13 @@ async function loadOaConfirmInfo(stateRows) { // 顺带带回来做「已发」二次兜底:订单表 send_date 也有漏回写的情况 // (2026-10-28 实测 144 单里 6 单状态表 send_time 有值、订单表却是空) sendTime: formatDateTime(r.send_time), + // ── 终态校验(2026-10-29 新增)──────────────────────────────── + // 这两个是**聚水潭订单状态**的直接映射,订单表停更时只有它靠得住 + orderStatus: String(r.order_status_name || '').trim(), + aftersaleStatus: String(r.aftersale_status_name || '').trim(), + logisticStatus: String(r.logistic_status_name || '').trim(), + // 状态表自己的抓取时刻:同一单可能 08:50 抓过就没再抓,判据可信度要看它 + queryTime: formatDateTime(r.query_time), }); } } @@ -457,6 +478,17 @@ async function loadSourceFreshness(round, now) { sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_orderlist WITH (NOLOCK) WHERE local_updated >= '${startLit}'`, }, + { + // 🔥 2026-10-29 新增:状态镜像表也必须监控。 + // 它是「这单取消了吗 / 发出了吗」的唯一权威来源(订单表会停更), + // 当天它停在 11:52,而 13:00 轮的超时判定就建立在这份 11:52 的快照上。 + // 不监控它 ⟹ 页面只说「订单表停了」,实际判据也已经过期 1.5 小时。 + key: 'jst_order_express_details', + label: '状态镜像表', + // 这张表没有 updateTime 列,用 `updatedAt`(ETL 写入时刻) + sql: `SELECT MAX(updatedAt) AS maxUpd FROM rpt.jst_order_express_details WITH (NOLOCK) + WHERE updatedAt >= '${startLit}'`, + }, ]; // 两个源并行查(各自 60s 上限,任一个慢不拖累另一个) @@ -502,8 +534,12 @@ function emptySummary() { voidCount: 0, // 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能) carrierSkipped: 0, - // 已剔除的「其实已发」单量:订单表 send_date 空、状态表 send_time 有值(漏回写) + // 已剔除的「其实已发」单量:状态表 send_time 有值 / order_status_name='已发货' + // / 物流已揽收+ (订单表 send_date 空但聚水潭已经发了) stateShippedSkipped: 0, + // 已剔除的「订单已取消」单量:状态表 order_status_name='已取消' + // ⚠️ 订单表停更期间这是**唯一**能判出作废的来源(labels 判据会整体失效) + stateVoidSkipped: 0, // 🔥 超时单里「所在数据源已停更」的单量 —— 这些单的「未发」不可信,可能早已发出。 // 2026-10-28 18:00 轮实测:546 单里 540 单来自停更的 jst_Orders。 sourceStaleUnshipped: 0, @@ -619,15 +655,15 @@ async function getShipTimeliness(options = {}) { throw err; } - // ③.5 起算点:批量取审核时间(最后审核时间优先,缺失回退订单创建时间) + // ③.5 状态镜像表:起算点(审核时间)+ 终态(已取消 / 已发货) // ⚠️ 状态表挂了不阻断页面:全量回退 created,并在 payload 里标降级 - let oaConfirmMap = new Map(); + let stateInfoMap = new Map(); let oaConfirmDegraded = false; try { - oaConfirmMap = await loadOaConfirmInfo(stateRows); + stateInfoMap = await loadStateInfo(stateRows); } catch (e) { oaConfirmDegraded = true; - console.warn('[WST] 审核时间查询失败,全量回退订单创建时间:', e.message); + console.warn('[WST] 状态镜像表查询失败,全量回退订单创建时间:', e.message); } // ③.6 数据源新鲜度:两个订单表**各自独立同步**,必须分源判断停更 ────────── @@ -650,6 +686,8 @@ async function getShipTimeliness(options = {}) { let carrierSkipped = 0; // 订单表 send_date 为空、但状态表 send_time 有值(漏回写)→ 已发,剔除 let stateShippedSkipped = 0; + // 状态表 `order_status_name='已取消'` → 订单已作废,剔除(订单表停更时唯一可靠来源) + let stateVoidSkipped = 0; // 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…) let unknownWarehouse = 0; // 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信 @@ -711,21 +749,12 @@ async function getShipTimeliness(options = {}) { // (少报超时,不冤枉仓库),业务已知悉。 const createdAt = formatDateTime(row.created); const modifiedAt = formatDateTime(row.modified); - const oaInfo = oaConfirmMap.get(Number(row.o_id)); + const stateInfo = stateInfoMap.get(Number(row.o_id)); + // 状态镜像表里的聚水潭终态(订单表停更时唯一可信的「这单还发不发」) + const stateOrderStatus = stateInfo ? (stateInfo.orderStatus || '') : ''; + const stateLogisticStatus = stateInfo ? (stateInfo.logisticStatus || '') : ''; - // ── ② 已发兜底:状态表说发了,就不再算未发 ───────────────────────────── - // PR #88 换源时记录的是「状态表 send_time 漏回写」这个方向,但**反方向也会漏**: - // 2026-10-28 实测 13:00 轮 144 单里 **6 单**状态表 send_time 有值、且有 - // 物流揽收轨迹,而订单表 send_date 仍是 NULL —— 只信订单表就会冤枉仓库。 - // ⟹ 口径:**任一方有发货时间即视为已发**,剔除量记 summary.stateShippedSkipped。 - // ⚠️ 只认 send_time,**不要**拿 `logistic_time` 当发货时间:它多数是 - // 「快递员将上门取件」的预告(实测 9983162 有轨迹但并未收件),会提前放行。 - if (oaInfo && oaInfo.sendTime) { - stateShippedSkipped += 1; - continue; - } - - const oaConfirmAt = oaInfo ? (oaInfo.last || '') : ''; + const oaConfirmAt = stateInfo ? (stateInfo.last || '') : ''; // 起算点 = 审核时间 / 订单 modified / 下单时间 三者取最大。 // · 取较晚者是为了修「状态表审核时间滞后」(详见上面 9978643 的证据); // · 把 createdAt 也纳入比较,是因为个别单的 modified 会早于 created(脏数据), @@ -742,6 +771,47 @@ async function getShipTimeliness(options = {}) { if (seenSo.has(dedupeKey)) continue; seenSo.add(dedupeKey); + // ══ 终态校验(2026-10-29 新增,**必须排在窗口判断和「不计考核」之前**)════ + // 🔥 背景:业务第三次报「作废的、已经发货的还在考核之内」。根因不是判定写错, + // 而是**订单两表各自停更**:当天 qmcust 停在 07:25(6 小时),停更之后在 + // 聚水潭做的「取消 / 发货」,订单表里完全没反映 —— status 仍是 Delivering、 + // send_date 仍是空,于是被当成真超时。13:00 轮 159 单**全部**来自停更的 qmcust。 + // + // 状态镜像表虽也停(当天 11:52),但比订单表新鲜 4 小时,`order_status_name` + // 已经是聚水潭的订单终态。实测这 159 单里 11 单 `order_status_name='已取消'`, + // 而这 11 单在订单表 `labels` 里**根本没有**「发货前售后」—— + // 原来的 `presaleVoid` 判据(订单表 labels 含关键词)在这批数据上 0 命中。 + // + // ⚠️ **判据只用 `order_status_name`**,别用 `aftersale_status_name`: + // · `order_status_name='已取消'` → 订单真的取消了 → 不考核 + // · `aftersale_status_name='已作废'` → **售后单被撤销,订单照常发货** → 仍要考核 + // (近 2 天 1387 单全是这种,混为一谈会把该考核的单全放跑) + // + // 顺序:**先判取消、再判已发**。发了又被取消的单,两边都不该考核仓库, + // 但归到「作废」比归到「已发」更贴合业务口径(业务对账看的是作废量)。 + if (stateOrderStatus === STATE_ORDER_VOID_STATUS) { + voidCount += 1; + stateVoidSkipped += 1; + continue; + } + + // ── 已发:三条任一成立即算(状态表比订单表可信得多)──────────────────── + // ① `send_time` 有值 —— 订单表 send_date 漏回写时的兜底 + // (2026-10-28 实测 144 单里 6 单是这种) + // ② `order_status_name='已发货'` —— 聚水潭订单终态(近 2 天 15.4 万单) + // ③ `logistic_status_name` ∈ 已揽收 / 运输中 / 派送中 / 已签收 / 拒收 + // —— 快递已经把货拿走了。`拒收` 也算:仓库的发货动作已完成。 + // ⚠️ **`未揽收` / `无物流单` 都不算**:前者只是打了单快递没来取, + // 后者连快递单都没录(当天 39/159 单是这种),都是真未发。 + // ⚠️ 也别拿 `logistic_time` 当发货时间 —— 它多数是「快递员将上门取件」的预告。 + const shippedByState = (stateInfo && stateInfo.sendTime) + || stateOrderStatus === STATE_ORDER_SHIPPED_STATUS + || STATE_LOGISTIC_SHIPPED_STATUSES.includes(stateLogisticStatus); + if (shippedByState) { + stateShippedSkipped += 1; + continue; + } + // ── 本轮核验范围(窗口上界按**起算点**卡)────────────────────────────── // 13:00 轮:今天 07:00 前审核的单 + 历史未发 // 18:00 轮:今天 16:00 前审核的单 + 历史未发(山东两仓 15:00、河南洛宁 13:00) @@ -766,6 +836,11 @@ async function getShipTimeliness(options = {}) { createdAt, oaConfirmAt, payAt, + // 状态镜像表里的聚水潭终态 —— 订单表停更时,页面靠它告诉业务「这单其实取消了/发了」 + stateOrderStatus, + stateLogisticStatus, + stateQueryTime: stateInfo ? (stateInfo.queryTime || '') : '', + stateSendTime: stateInfo ? (stateInfo.sendTime || '') : '', // 起算点(审核时间;缺失回退创建时间)—— 「已等时长」从它算 benchmarkAt, benchmarkSource, @@ -865,6 +940,11 @@ async function getShipTimeliness(options = {}) { benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, + // 聚水潭侧终态(来自状态镜像表):订单表停更时,这一列才是真的 + // 业务核对时看这个 —— 「已取消」本该被剔除,出现在明细里就说明状态表也没抓到 + stateOrderStatus: x.stateOrderStatus, + stateLogisticStatus: x.stateLogisticStatus, + stateQueryTime: x.stateQueryTime, // 该单所在订单表是否已停更 —— 前端据此给单打「数据未同步」标记 src: x.src, srcLabel: x.srcLabel, @@ -943,6 +1023,8 @@ async function getShipTimeliness(options = {}) { summary.voidCount = voidCount; summary.carrierSkipped = carrierSkipped; summary.stateShippedSkipped = stateShippedSkipped; + // 由**状态镜像表终态**剔除的作废单量(订单表 labels 判据在停更期会整体失效) + summary.stateVoidSkipped = stateVoidSkipped; summary.sourceStaleUnshipped = sourceStaleUnshipped; summary.unknownWarehouse = unknownWarehouse; diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index 5cbdcdf..1912ca0 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -294,7 +294,24 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026- o(53, WMS.CQ, '2026-10-20 05:00:00', { modified: '2026-10-19 23:00:00' }), ]; - const rows = scenario === 'modified' ? modifiedRows : (scenario === 'noon' ? noonRows : defaultRows); + // 终态校验专用场景(2026-10-29) + // 🔥 现场:业务第三次报「作废的、已经发货的还在考核之内」。根因是订单两表各自停更, + // 停更之后聚水潭里的「取消 / 发货」订单表看不到,只有状态镜像表能反映。 + // 这一组行**订单表侧全部干净**(status=Delivering + send_date 空 + labels 无关键词), + // 全靠状态表终态区分,专门验证「订单表谎报时状态表能救回来」。 + const terminalRows = [ + o(61, WMS.CQ, '2026-10-20 05:00:00'), // 状态表 已取消 → 剔除(作废) + o(62, WMS.CQ, '2026-10-20 05:10:00'), // 状态表 已发货 → 剔除(已发) + o(63, WMS.CQ, '2026-10-20 05:20:00'), // 物流 已揽收 → 剔除(已发) + o(64, WMS.CQ, '2026-10-20 05:30:00'), // 售后「已作废」+ 订单「发货中」→ **仍要考核** + o(65, WMS.CQ, '2026-10-20 05:40:00'), // 物流 未揽收 → **仍要考核** + o(66, WMS.CQ, '2026-10-20 05:50:00'), // send_time 有值(订单表漏回写)→ 剔除 + o(67, WMS.CQ, '2026-10-20 06:00:00'), // 状态表停更,无终态信息 → 按未发算 + ]; + + const rows = scenario === 'modified' ? modifiedRows + : (scenario === 'noon' ? noonRows + : (scenario === 'terminal' ? terminalRows : defaultRows)); const calls = { orders: 0, oaConfirm: 0, warehouse: 0, freshness: 0, sql: [], params: [] }; @@ -305,9 +322,11 @@ function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026- return warehouseRows; } // 数据源新鲜度探测:`SELECT MAX(updateTime) FROM jst_Orders` / - // `SELECT MAX(local_updated) FROM jst_qmcust_orderlist`(2026-10-28 新增)。 + // `SELECT MAX(local_updated) FROM jst_qmcust_orderlist` / + // `SELECT MAX(updatedAt) FROM jst_order_express_details`(状态镜像表,2026-10-29 新增)。 // 单独记账,**不算订单候选查询** —— 否则「订单两表只允许查一次」的老断言会误伤。 - if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text)) { + if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text) + || /MAX\(updatedAt\)/iu.test(text)) { calls.freshness += 1; // 默认 18:00(用例 now=18:05 → 滞后 5 分钟 < 60,不触发停更告警); // 要测停更场景就把 freshnessUpd 传成当天更早的时刻 @@ -675,6 +694,55 @@ test('状态表审核时间比 modified 更晚时,以状态表为准', withStu { inner_order_id: 52, first_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), last_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), send_time: null }, ])); +test('状态表终态:已取消 / 已发货 / 已揽收的单必须剔除,售后「已作废」不能误剔', async () => { + // 🔥 2026-10-29 现场(业务第三次报同一件事): + // 13:00 轮 159 单**全部**来自停更的 qmcust(停 07:25),订单表里 + // status 仍是 Delivering、send_date 仍是空、labels 里**没有**「发货前售后」。 + // 而状态镜像表(停 11:52,比订单表新鲜 4 小时)已经把它们标成「已取消」。 + // ⟹ 判据必须补状态表终态,否则停更期的取消/发货永远判不出来。 + const oa = [ + // 已取消 → 作废 + { inner_order_id: 61, order_status_name: '已取消', aftersale_status_name: '已确认', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: null }, + // 已发货 → 已发 + { inner_order_id: 62, order_status_name: '已发货', aftersale_status_name: '暂无售后', logistic_status_name: '已签收', last_oa_confirm_time: null, send_time: null }, + // 订单还「发货中」,但快递已揽收 → 已发 + { inner_order_id: 63, order_status_name: '发货中', aftersale_status_name: '暂无售后', logistic_status_name: '已揽收', last_oa_confirm_time: null, send_time: null }, + // ⚠️ 售后「已作废」= 售后单被撤销,订单照常发货 → **仍要考核** + { inner_order_id: 64, order_status_name: '发货中', aftersale_status_name: '已作废', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: null }, + // 未揽收 → 仍要考核 + { inner_order_id: 65, order_status_name: '发货中', aftersale_status_name: '暂无售后', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: null }, + // 订单表 send_date 漏回写,但状态表 send_time 有值 → 已发 + { inner_order_id: 66, order_status_name: '发货中', aftersale_status_name: '暂无售后', logistic_status_name: '未揽收', last_oa_confirm_time: null, send_time: new Date(2026, 9, 20, 12, 0, 0) }, + // 状态表没有这单的行(停更/未覆盖)→ 按未发算,不能因为查不到就放行 + ]; + + await withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 20 }); + const ids = data.orders.map((x) => String(x.oId)).sort(); + assert.deepEqual(ids, ['64', '65', '67'], '只该剩「售后已作废 / 未揽收 / 状态表无记录」三单'); + + assert.equal(data.summary.stateVoidSkipped, 1, '已取消要单独记账(业务对账看这个数)'); + assert.equal(data.summary.stateShippedSkipped, 3, '已发货 + 已揽收 + send_time 有值 = 3 单剔为已发'); + assert.equal(data.summary.voidCount, 1, '作废量含状态表判出的那一单'); + + // 明细行要把聚水潭终态透出来,业务核对时能一眼看出「这单其实取消了」 + const r64 = data.orders.find((x) => String(x.oId) === '64'); + assert.equal(r64.stateOrderStatus, '发货中'); + assert.equal(r64.stateLogisticStatus, '未揽收'); + }, 'terminal', oa); +}); + +test('状态表也纳入数据源新鲜度监控', async () => { + // 状态表是「取消了吗 / 发出了吗」的唯一权威来源,它停更了判据就过期了, + // 页面必须一起暴露,不能只报订单表停更。 + await withStubbedService(async (svc) => { + 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, '两个订单表 + 状态镜像表'); + }, 'terminal'); +}); + test('数据源停更:来自停更表的超时单必须打标,并计入 sourceStaleUnshipped', async () => { // 🔥 2026-10-28 18:35 现场:`jst_Orders` 停更在 14:40,18:00 轮 546 单里 540 单来自它, // 业务当场质疑「基本都发了」。页面对这种单**不能静默当确认未发**。 diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx index 213189c..8ec9354 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/index.jsx +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -288,23 +288,46 @@ const DETAIL_COLUMNS = [ // 付款时间 = 客户付款那一刻(并列展示,方便对账) { title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' }, { - // 发货状态:订单表 status(翻译成中文) + // 发货状态:**优先显示聚水潭终态**(状态镜像表),订单表状态只作对照 + // 🔥 2026-10-29:订单两表会各自停更,停更期间聚水潭里已经「已取消 / 已发货」的单, + // 订单表里仍是「发货中」——业务看到的「作废的也在考核里」就是这么来的。 + // 状态镜像表比订单表新鲜,`order_status_name` 才是聚水潭的真状态。 title: ( - + 发货状态 ), - dataIndex: 'state', - width: 104, - render: (v) => ( - {v || '—'} - ), + dataIndex: 'stateOrderStatus', + width: 128, + render: (v, row) => { + const lake = v || ''; + const ord = row?.state || ''; + const diff = Boolean(lake && ord && lake !== ord); + const text = lake || ord || '—'; + const color = text === '已取消' ? 'red' : (text === '发货中' ? 'orange' : 'default'); + return ( + + {text}{diff ? ' ⚠' : ''} + + ); + }, }, { - title: '物流状态', - dataIndex: 'logisticStatus', - width: 100, - render: (v) => (v ? {v} : '—'), + // 物流状态:优先用状态表的真实物流节点(未揽收 / 已揽收 / 运输中 / 已签收…), + // 订单表没有物流轨迹,只能靠「有没有运单号」猜「已打单 · 未揽收」 + title: ( + + 物流状态 + + ), + dataIndex: 'stateLogisticStatus', + width: 108, + render: (v, row) => { + const text = v || row?.logisticStatus || ''; + if (!text) return '—'; + const shipped = ['已揽收', '运输中', '派送中', '已签收', '拒收'].includes(v); + return {text}; + }, }, { title: ( @@ -335,8 +358,11 @@ const EXPORT_COLUMNS = [ { title: '发货仓', dataIndex: 'warehouseName' }, { title: '快递公司', dataIndex: 'logisticCompany', exportValue: (v) => v || '未指定' }, { title: '运单号', dataIndex: 'logisticId', exportValue: (v) => v || '未打单' }, - { title: '物流状态', dataIndex: 'logisticStatus' }, + { title: '物流状态', dataIndex: 'stateLogisticStatus', exportValue: (v, row) => v || row?.logisticStatus || '' }, { title: '发货状态', dataIndex: 'state' }, + // 聚水潭终态与订单表状态分开导出:两者不一致的单,就是「订单表没同步」的证据 + { title: '聚水潭状态', dataIndex: 'stateOrderStatus', exportValue: (v) => v || '—' }, + { title: '状态表抓取时间', dataIndex: 'stateQueryTime', exportValue: (v) => v || '—' }, // 导出也要带上「这单的来源表 + 该表是否停更」,否则 Excel 脱离页面就没有上下文, // 拿着 540 单「未发」去追仓库,实际人家早就发了。 { title: '数据源', dataIndex: 'srcLabel', exportValue: (v) => v || '—' }, @@ -774,7 +800,8 @@ function WarehouseShipTimeliness() { staleUnshipped > 0 ? `${number(staleUnshipped)} 单所在订单表已停止同步,这些单很可能已经发货` : (staleSources.length > 0 - ? '订单表数据源已停止同步' + // 状态镜像表也算数据源:它停了,判「已取消 / 已发货」的依据就过期了 + ? '数据源已停止同步(订单表 / 状态镜像表)' : (freshness?.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后')) @@ -1179,9 +1206,16 @@ function WarehouseShipTimeliness() { 到点仍未发货即超时(不设「满 24 小时」门槛); 轮次与「已等时长」都按审核时间算(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者, 审核才是仓库接到单的时刻;状态表会滞后,故取较晚者)。两者都取不到才回退订单创建时间,列上带 *。 - 发货 = 订单表 send_date(为空才算未发,但状态表 send_time 有值的也判已发);状态只取「发货中」。 - 作废单不考核(labels 含「发货前售后」= 客户发货前取消) - {summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''}; + 发货 = 订单表 send_date(为空才算未发)。已发货不考核:状态表 send_time 有值、 + 聚水潭状态「已发货」、或物流已揽收及之后,任一成立即判已发 + {summary.stateShippedSkipped > 0 ? ` —— 本轮已剔除 ${summary.stateShippedSkipped} 单` : ''}。 + 状态只取「发货中」。 + 作废单不考核:聚水潭状态「已取消」,或订单表 labels 含「发货前售后」 + {summary.voidCount > 0 + ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` + + `${summary.stateVoidSkipped > 0 ? `(其中 ${summary.stateVoidSkipped} 单靠聚水潭状态判出,订单表当时没同步)` : ''}` + : ''}; + ⚠️ 售后状态「已作废」不算作废单 —— 那是售后单被撤销、订单照常发货,仍要考核。 {excludedCarrierText}不考核 {summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''}; 待审核、订单异常的单归入「不计考核」,避免冤枉仓库。