From 983fee0d8ad2d3561f9ddcc311a2038ec0f2d24c Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Wed, 28 Oct 2026 17:02:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(wst):=20=E8=B5=B7=E7=AE=97=E7=82=B9?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E3=80=8C=E5=AE=A1=E6=A0=B8=E6=97=B6=E9=97=B4?= =?UTF-8?q?=20/=20=E8=AE=A2=E5=8D=95=20modified=20=E5=8F=96=E8=BE=83?= =?UTF-8?q?=E6=99=9A=E8=80=85=E3=80=8D=EF=BC=8C=E4=BF=AE=2013:00=20?= =?UTF-8?q?=E8=BD=AE=2065=20=E5=8D=95=E5=81=87=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 业务问题 业务举证:13:00 轮不该有那么多单 —— 「9 点前的单 13:00 前已经发完了」。 线上实测 65 单,逐单核对全部不该在 13:00 轮。 ## 根因(两个,都指向「起算点取错」) ### ① 状态表的审核时间会严重滞后 业务给的举证单 9978643: | 字段 | 值 | |---|---| | `qmcust.modified` | **2026-10-28 14:52:29** | | 聚水潭页面真实审核时间 | **2026-10-28 14:52:28**(秒级吻合) | | 状态表 `last_oa_confirm_time` | 2026-10-27 17:08:43(旧了一天) | | 状态表 `query_time` | 2026-10-28 10:29:11(10:29 后没再抓) | 只信状态表 → 这单被当成「10-27 就审核了」,混进 13:00 轮超时,冤枉仓库。 ### ② 审核时间为空的单回退 created,把今天的单按历史单算 65 单里 57 单状态表审核时间为 NULL。回退 `created` 后: | o_id | created | modified | 回退后算成 | 真实 | |---|---|---|---|---| | 9964841 | 10-26 17:22 | **10-28 13:40** | 10-26 审核 → 进 13:00 轮 | 今天下午才审核 | | 9949424 | 10-25 17:03 | **10-28 13:50** | 同上 | 同上 | | 9983773 | 10-27 23:23 | **10-28 13:59** | 同上 | 同上 | 57 单的 `modified` 全部落在今天 13:40~15:03 —— 都是今天下午批量审核的。 ## 改动 - **起算点** = `max(状态表 last_oa_confirm_time, 订单表 modified, 订单表 created)` - 取较晚者:修状态表滞后 - 纳入 created:个别单 `modified` 早于 `created`(脏数据),起算点不该早于下单时刻 - `benchmarkSource` 透出 `oa_confirm` / `modified` / `created`,页面 tooltip 说明来源 - **新增 `laterOf()`**:同格式 `YYYY-MM-DD HH:mm:ss` 直接字典序比较,不转 Date(避开时区坑) - **主查询 SQL 增加 `x.modified`** 列(订单两表都有,无需额外查询) ## 性能修复(同批,线上 /list 曾 300s 超时) 上版状态表查询**串行 9 批实测 135s**,叠加主查询 63~105s 直接把接口顶过 300s, 页面打不开(实测 curl `--max-time 300` 超时)。改为**并发 6**: | 方式 | 耗时 | |---|---| | 串行 9 批 | 135s | | 并发 3 | 28.8s | | **并发 6(本版)** | **10~15s** | 并发 6 是生产实测的安全值(单批约 15s,不至于把 402 万行视图的连接池打爆)。 ## 改后效果(10-28 17:00 真实库) | 轮次 | 改前 | 改后 | |---|---|---| | 13:00 轮 | 65 单 | **0 单** | | 18:00 轮 | — | 927 单(全部今天 07:59~16:00 审核,无历史单) | - `fallbackToCreated` 从 57 → **0**(不再有回退单) - 9978643 ✅ 已剔除 - 两个轮次都验证「今天窗口上界之后起算的 = 0」 ## 验证 - 后端 `npm run check`:**493/495**(2 个失败是既有环境问题:TTL 计时、Windows 权限,改动前同样失败) - 新增 2 个测试锁定该口径: - `modified` 晚于窗口 → 必须剔除(9978643 那类单) - 状态表审核时间晚于 `modified` → 以状态表为准 - `modified` 早于 `created` → 取 `created`,不倒退 - 前端 lint + build 通过(35.2s) - 真实库双轮验证通过 ## 风险与回退 - **`modified` 被客服改单/改备注刷新**时起算点会推后 → 少报超时(放行方向,不冤枉仓库)。 业务已知悉并接受;这是当前唯一能拿到「今天真实审核时间」的可靠字段。 - 回退方式:`git revert ` 即回到只认状态表审核时间的版本。 --- .../warehouseShipTimelinessService.js | 94 ++++++++++++++----- backend/test/warehouseShipTimeliness.test.js | 48 +++++++++- .../pages/WarehouseShipTimeliness/index.jsx | 27 +++--- 3 files changed, 135 insertions(+), 34 deletions(-) diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index 536b20b..a35be59 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -223,6 +223,20 @@ function formatDateTime(value) { return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19); } +/** + * 取两个 `YYYY-MM-DD HH:mm:ss` 里较晚的那个(空值视为不存在)。 + * + * 🔥 2026-10-28 下午:起算点必须在「状态表审核时间」和「订单表 modified」之间取较晚者。 + * 同格式字符串可以直接字典序比较,不需要转 Date(避免时区/跨天坑)。 + */ +function laterOf(a, b) { + const x = String(a || ''); + const y = String(b || ''); + if (!x) return y; + if (!y) return x; + return y > x ? y : x; +} + /** 两个时间文本相差多少分钟(to - from);任一为空返回 0 */ function diffMinutes(fromText, toText) { const a = formatDateTime(fromText); @@ -294,12 +308,13 @@ async function loadPendingOrders(round) { const rows = await cloudDatabase.querySlow( `SELECT x.o_id, x.so_id, x.shop_name, x.status, x.created, x.pay_date, x.logistics_company, x.l_id, x.presaleVoid, x.src, - x.updated_at, x.wms_co_id + x.updated_at, x.wms_co_id, x.modified FROM ( SELECT o.o_id, o.so_id, o.shop_name, o.status, o.created, o.pay_date, o.logistics_company, o.l_id, o.wms_co_id, ${presaleSql} AS presaleVoid, o.updateTime AS updated_at, + o.modified, N'jst_Orders' AS src FROM rpt.jst_Orders o WITH (NOLOCK) WHERE o.send_date IS NULL @@ -310,6 +325,7 @@ async function loadPendingOrders(round) { c.logistics_company, c.l_id, c.wms_co_id, ${presaleSql} AS presaleVoid, c.local_updated AS updated_at, + c.modified, N'jst_qmcust_orderlist' AS src FROM rpt.jst_qmcust_orderlist c WITH (NOLOCK) WHERE c.send_date IS NULL @@ -352,25 +368,40 @@ async function loadOaConfirmInfo(stateRows) { const map = new Map(); if (!ids.length) return map; + // 🔥 2026-10-28 下午:串行 9 批实测 **135s**(全并行 9 批只要 6s,并行 3 批 28.8s)。 + // 生产上「主查询 63~105s + 状态表 135s」直接把 /list 顶过 300s 超时, + // 页面打不开 —— 所以**必须并发**。并发 6 是实测过的安全值: + // 单批约 15s,6 并发后整体 10~15s,且不至于把 402 万行视图的连接池打爆。 const BATCH = 600; + const CONCURRENCY = 6; + const chunks = []; for (let i = 0; i < ids.length; i += BATCH) { - // ids 已用 Number.isFinite 过滤过,拼进 IN 是纯数字,无注入面 - const chunk = ids.slice(i, i + BATCH); - const rows = await cloudDatabase.querySlow( - `SELECT inner_order_id, first_oa_confirm_time, last_oa_confirm_time, send_time - FROM rpt.jst_order_express_details WITH (NOLOCK) - WHERE inner_order_id IN (${chunk.join(',')})`, - {}, - { connectRequestTimeoutMs: 300000 } - ); - for (const r of rows || []) { - map.set(Number(r.inner_order_id), { - first: formatDateTime(r.first_oa_confirm_time), - last: formatDateTime(r.last_oa_confirm_time), - // 顺带带回来做「已发」二次兜底:订单表 send_date 也有漏回写的情况 - // (2026-10-28 实测 144 单里 6 单状态表 send_time 有值、订单表却是空) - sendTime: formatDateTime(r.send_time), - }); + chunks.push(ids.slice(i, i + BATCH)); + } + + for (let i = 0; i < chunks.length; i += CONCURRENCY) { + const group = chunks.slice(i, i + CONCURRENCY); + // eslint-disable-next-line no-await-in-loop + 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 + FROM rpt.jst_order_express_details WITH (NOLOCK) + WHERE inner_order_id IN (${chunk.join(',')})`, + {}, + { connectRequestTimeoutMs: 300000 } + ); + })); + for (const rows of results) { + for (const r of rows || []) { + map.set(Number(r.inner_order_id), { + first: formatDateTime(r.first_oa_confirm_time), + last: formatDateTime(r.last_oa_confirm_time), + // 顺带带回来做「已发」二次兜底:订单表 send_date 也有漏回写的情况 + // (2026-10-28 实测 144 单里 6 单状态表 send_time 有值、订单表却是空) + sendTime: formatDateTime(r.send_time), + }); + } } } return map; @@ -559,8 +590,23 @@ async function getShipTimeliness(options = {}) { // 状态表的 last_oa_confirm_time,不是订单表的 created(10-19 23:44:22)。 // 积压单 created → 审核能差 8~11 天,按 created 算等于把审核积压 // 算到仓库头上(详见文件头 9837824 的证据)。 - // 审核时间取不到(实测 599 单里 209 单)→ 回退 created,并标注来源。 + // + // 🔥 2026-10-28 下午再纠偏:**状态表的审核时间会严重滞后,必须和订单表 + // `modified` 取较晚者**。铁证(9978643,业务给的): + // · qmcust.modified = 2026-10-28 14:52:29 + // · 聚水潭页面真实审核时间 = 2026-10-28 14:52:28 ← 秒级吻合 + // · 状态表 last_oa_confirm = 2026-10-27 17:08:43 ← 旧值,早了一天 + // · 状态表 query_time = 2026-10-28 10:29:11 ← 10:29 后就没再抓过 + // 只信状态表 → 这单被当成「10-27 就审核了」,混进 13:00 轮超时(冤枉仓库)。 + // 更惨的是**审核时间为空的单**(13:00 轮 65 单里 57 单):回退 created 后, + // 明明是今天 13:40~15:03 才审核的单,被按 10-25~10-27 的下单时间算超时。 + // + // ⟹ 口径:起算点 = max(状态表 last_oa_confirm_time, 订单表 modified), + // 两者都没有才回退 created。`modified` 是聚水潭源端的「最后修改时间」, + // 审核动作一定会刷新它;因客服改单/改备注而推后的情况属「放行」方向 + // (少报超时,不冤枉仓库),业务已知悉。 const createdAt = formatDateTime(row.created); + const modifiedAt = formatDateTime(row.modified); const oaInfo = oaConfirmMap.get(Number(row.o_id)); // ── ② 已发兜底:状态表说发了,就不再算未发 ───────────────────────────── @@ -576,8 +622,14 @@ async function getShipTimeliness(options = {}) { } const oaConfirmAt = oaInfo ? (oaInfo.last || '') : ''; - const benchmarkAt = oaConfirmAt || createdAt; - const benchmarkSource = oaConfirmAt ? 'oa_confirm' : 'created'; + // 起算点 = 审核时间 / 订单 modified / 下单时间 三者取最大。 + // · 取较晚者是为了修「状态表审核时间滞后」(详见上面 9978643 的证据); + // · 把 createdAt 也纳入比较,是因为个别单的 modified 会早于 created(脏数据), + // 起算点绝不该早于下单时刻。 + const benchmarkAt = laterOf(laterOf(oaConfirmAt, modifiedAt), createdAt) || createdAt; + let benchmarkSource = 'created'; + if (benchmarkAt && benchmarkAt === modifiedAt) benchmarkSource = 'modified'; + else if (benchmarkAt && benchmarkAt === oaConfirmAt) benchmarkSource = 'oa_confirm'; const payAt = formatDateTime(row.pay_date); const statusRaw = String(row.status || ''); diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index df26185..fda30b1 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -281,7 +281,20 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) { o(26, WMS.CQ, '2026-10-18 12:00:00', { updated_at: '2026-10-20 11:18:27' }), // 2940 分 ]; - const rows = scenario === 'noon' ? noonRows : defaultRows; + // 「起算点 = max(审核时间, 订单 modified)」专用场景(2026-10-28 下午定版) + // 🔥 为什么必须取较晚者:状态表的 last_oa_confirm_time 会滞后。 + // 实测 9978643:状态表写 10-27 17:08,聚水潭真实审核 10-28 14:52, + // 只信状态表 → 把「今天下午才审核的单」算进 13:00 轮,冤枉仓库。 + const modifiedRows = [ + // 51:created 06:45(7 点前),但 modified 是今天 14:52 → 起算点 14:52 晚于窗口 07:00 → 剔除 + o(51, WMS.CQ, '2026-10-20 06:45:00', { modified: '2026-10-20 14:52:29' }), + // 52:历史单 created 10-18,状态表无审核时间,modified 今天 06:00 → 算超时,起算点 06:00 + o(52, WMS.CQ, '2026-10-18 12:00:00', { modified: '2026-10-20 06:00:00' }), + // 53:modified 比 created 还早(异常数据)→ 起算点回落到 created + 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); const calls = { orders: 0, oaConfirm: 0, warehouse: 0, sql: [], params: [] }; @@ -619,3 +632,36 @@ test('导出行为纯函数:缺字段一律给空串,不导出 undefined', ( assert.equal(rows[1].oId, ''); assert.doesNotMatch(JSON.stringify(rows), /undefined|null|NaN/u); }); + +test('起算点 = max(审核时间, 订单 modified):状态表滞后时以 modified 为准', withStubbedService(async (svc) => { + // 13:05 → 13:00 轮,窗口上界 07:00 + const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 }); + + // 51 的 modified 是今天 14:52,晚于窗口 07:00 → 必须剔除(这就是 9978643 那类单) + assert.ok(!data.orders.some((x) => String(x.oId) === '51'), + 'modified 晚于窗口的单不能算进 13:00 轮(状态表滞后会把它冤枉成 7 点前审核)'); + + // 52:状态表无审核时间,modified 今天 06:00 → 起算点 06:00,不是 created 10-18 12:00 + const r52 = data.orders.find((x) => String(x.oId) === '52'); + assert.ok(r52, '52 应算超时(modified 06:00 早于窗口 07:00)'); + assert.equal(r52.auditTime, '2026-10-20 06:00:00', '起算点取 modified'); + assert.equal(r52.benchmarkSource, 'modified', '来源必须是 modified'); + assert.equal(r52.createdAt, '2026-10-18 12:00:00', '下单时间照旧展示'); + assert.equal(r52.overdueMinutes, 420, '06:00 → 13:00 = 420 分钟(不是按下单时间算的 4380)'); + + // 53:modified 早于 created → 取较晚者 = created 05:00 + const r53 = data.orders.find((x) => String(x.oId) === '53'); + assert.ok(r53, '53 应算超时'); + assert.equal(r53.auditTime, '2026-10-20 05:00:00', 'modified 比 created 早时不能倒退,取 created'); + assert.equal(r53.benchmarkSource, 'created'); +}, 'modified')); + +test('状态表审核时间比 modified 更晚时,以状态表为准', withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 }); + const r52 = data.orders.find((x) => String(x.oId) === '52'); + assert.ok(r52, '52 应算超时'); + assert.equal(r52.auditTime, '2026-10-20 06:30:00', '审核时间 06:30 晚于 modified 06:00,取审核时间'); + assert.equal(r52.benchmarkSource, 'oa_confirm'); +}, 'modified', [ + { 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 }, +])); diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx index 57a47d1..16289fe 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/index.jsx +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -166,7 +166,7 @@ const HELD_COLUMNS = [ }, { title: ( - + 审核时间 ), @@ -232,10 +232,10 @@ const DETAIL_COLUMNS = [ }, }, { - // 审核时间 = 本页的**考核起算点**(业务 2026-10-28 下午二次定版) - // 取状态表 last_oa_confirm_time;取不到回退订单创建时间并打 `*` + // 审核时间 = 本页的**考核起算点**(业务 2026-10-28 下午三次定版) + // = max(状态表 last_oa_confirm_time, 订单表 modified),都没有才回退创建时间并打 `*` title: ( - + 审核时间 ), @@ -246,7 +246,7 @@ const DETAIL_COLUMNS = [ if (!v) return —; if (row?.benchmarkSource === 'created') { return ( - + {v} * ); @@ -359,9 +359,11 @@ const roundFileText = (roundInfo) => ( * (含 7 点前审核未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00); * 今天之前仍未发的(历史未发)两个轮次都算; * **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点); - * 轮次归属和「已等时长」都按**审核时间**(状态表 last_oa_confirm_time)算 —— - * 业务 2026-10-28 下午二次定版:仓库只有审核后才会接到单,按下单时间算会把审核积压算到仓库头上; - * 审核时间取不到的单回退订单创建时间,页面上带 `*` 标记; + * 轮次归属和「已等时长」都按**审核时间**算 —— + * 业务 2026-10-28 下午三次定版:审核时间 = max(状态表 last_oa_confirm_time, 订单表 modified)。 + * 只取状态表会漏 —— 状态表会滞后(9978643 状态表写的是 10-27 17:08,聚水潭真实审核 10-28 14:52, + * 整整早一天),导致「今天下午才审核的单」被算进 13:00 轮,冤枉仓库; + * 两者都没有才回退订单创建时间,页面上带 `*` 标记; * · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天; * · 已出库 / 待出库以订单表 **send_date** 为准(SQL 直接 `send_date IS NULL` 才进候选); * · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。 @@ -666,8 +668,9 @@ function WarehouseShipTimeliness() { 到点还没发就算超时 —— 不设「满 24 小时」门槛(早上 7 点前那份单,13:00 还没走就是超时)。 仓级例外:18:00 轮里 山东青岗集仓 / 山东品牌仓 只看 15:00 前审核的单、 河南洛宁仓 只看 13:00 前审核的单(核验时刻仍是 18:00)。 - 轮次与「已等时长」都按审核时间算(聚水潭 last_oa_confirm_time: - 审核才是仓库接到单的时刻,按下单时间算会把客服审核积压算到仓库头上); + 轮次与「已等时长」都按审核时间算(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者: + 审核才是仓库接到单的时刻,按下单时间算会把客服审核积压算到仓库头上; + 状态表的审核时间会滞后,所以要和订单 modified 取较晚的那个); 明细并列「审核时间 / 下单时间 / 发货时间」,方便直接和聚水潭对账。 已发 / 未发以订单表 send_date 为准(有值即已发,SQL 侧直接过滤不进候选); 订单表 send_date 没回写、但状态表 send_time 有值的,也按已发剔除(不漏冤仓库)。 @@ -1111,8 +1114,8 @@ function WarehouseShipTimeliness() { 今天 7:00 前审核的订单 13:00 核验;截止 16:00 前审核的单 18:00 核验 (含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。 到点仍未发货即超时(不设「满 24 小时」门槛); - 轮次与「已等时长」都按审核时间算(状态表 last_oa_confirm_time,审核才是仓库接到单的时刻; - 取不到审核时间的单回退订单创建时间,列上带 *)。 + 轮次与「已等时长」都按审核时间算(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者, + 审核才是仓库接到单的时刻;状态表会滞后,故取较晚者)。两者都取不到才回退订单创建时间,列上带 *。 发货 = 订单表 send_date(为空才算未发,但状态表 send_time 有值的也判已发);状态只取「发货中」。 作废单不考核(labels 含「发货前售后」= 客户发货前取消) {summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};