From cb7d06c20c1e8eeb348e0210b52d9501d1b82c58 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 29 Oct 2026 08:25:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(wst):=20=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E5=81=9C=E6=9B=B4=E7=86=94=E6=96=AD=E2=80=94=E2=80=94=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=A1=A8=E5=81=9C=E6=9B=B4=E6=97=B6=E9=86=92=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=EF=BC=8C=E5=88=AB=E6=8A=8A=E3=80=8C=E6=B2=A1?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E3=80=8D=E5=BD=93=E3=80=8C=E6=B2=A1=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 业务问题 10-28 18:00 轮页面核验出 546 单超时未发,业务核对「基本都发了,怎么会核验出未发」。 ## 根因(不是判定写错,是数据源停更) `jst_Orders` 当时**从 14:40 就停止同步了**(停了 4 小时),停更之后发出的单 `send_date` 一直是空,因此全被判成「未发货」: | 来源表 | 超时单数 | 该表状态 | |---|---|---| | `jst_Orders` | 540 | 最后更新 14:40(停 4 小时) | | `jst_qmcust_orderlist` | 6 | 17:30 正常 | 两个订单表**各自独立同步**,同一天完全可能一个正常一个停更。原来只拿候选行的 `MAX(updated_at)` 判快照滞后,会被「碰巧新」的行掩盖 —— 必须直接问源表。 ## 改动 后端 `warehouseShipTimelinessService.js`: - 新增 `loadSourceFreshness()`:分别直接查 `jst_Orders.MAX(updateTime)` 与 `jst_qmcust_orderlist.MAX(local_updated)`(限轮次窗口起 30 天,避免全表扫描), 两源并行;按 `CANGKU_WST_SOURCE_STALE_MINUTES`(默认 60 分钟)判停更 - 查询失败或取不到更新时间 → **不判停更**(宁可漏报,不谎报为停更) - 明细行新增 `src` / `srcLabel` / `sourceStale`;`summary` 新增 `sourceStaleUnshipped` - `freshness.stale` 改为「候选行滞后 **OR** 任一源停更」;`hint` 说清停更源、 停更时刻、以及各源各贡献多少单(不能只写「这张表」) - 导出数据带 `dataSources`,Excel 脱离页面也有上下文 - 响应新增 `dataSources` 数组(每源 label / lastUpdate / lagMinutes / stale) 前端 `WarehouseShipTimeliness/index.jsx`: - 红条 Alert:「N 单所在订单表已停止同步,这些单很可能已经发货」(error 级), 描述里逐源列出「最后同步 14:40:05(已 3.9 小时)」 - 超时统计卡下方小字:「其中 N 单数据未同步」(悬停有说明) - 明细「内部单号」旁「未同步」红标签 + tooltip,一眼看出哪几行不可信 - 导出新增「数据源 / 数据源是否停更」两列(`是(该单可能已发)`) ## 验证 - 后端 `npm run check`:**494/496**,2 个失败是既有环境问题(负缓存 TTL 计时、 Windows 文件权限 0o600),改动前同样失败 - `warehouseShipTimeliness.test.js` **23/23** 通过(含新增「停更表超时单必须打标 并计入 sourceStaleUnshipped」用例) - 本地真渲染(Playwright + 手签 token,localhost:5173): - 真数据:13:00 前 `notStarted` 正常展示「—」,无 pageerror - mock 停更数据:红条 / 逐源行 / 统计小字 / 3 行「未同步」标签全部渲染,无 pageerror - 真实接口 10-28 13:00 轮:`dataSources` 正确识别 `jst_Orders` 停更(滞后 96 分钟) 而 qmcust 正常;`sourceStaleUnshipped=1146` 与明细逐行打标同源同口径 ## 风险与回退 - 只新增字段与展示,**不改动分轮、起算点、候选池口径** —— 超时单量本身不变 - 阈值可用 `CANGKU_WST_SOURCE_STALE_MINUTES` 调;不配默认 60 分钟 - 低峰期(凌晨)可能出现「1 小时无新单」被判停更,但页面只在 13:00 / 18:00 核验时刻展示超时数据,这两个时点 1 小时不同步即属异常 - 回退:revert 本提交即可,无数据迁移、无新表、无新依赖 --- .../warehouseShipTimelinessService.js | 146 +++++++++++++++++- backend/test/warehouseShipTimeliness.test.js | 45 +++++- .../pages/WarehouseShipTimeliness/index.jsx | 75 ++++++++- .../pages/WarehouseShipTimeliness/style.css | 39 +++++ 4 files changed, 292 insertions(+), 13 deletions(-) diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index a35be59..bf83ce4 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -118,6 +118,22 @@ const MAX_DETAIL_LIMIT = 2000; // 2026-10-28 起数据源是订单表(`updateTime` / `local_updated`),实测滞后约 2 小时。 const STATE_STALE_MINUTES = 90; +/** + * 单个**订单表数据源**的滞后阈值(分钟)。 + * + * 🔥 为什么必须按源单独判(2026-10-28 业务第二次质疑「18:00 轮基本都发了」时定型): + * 订单两表是**各自独立同步**的,同一天可以一个正常一个停更 —— + * 18:35 实测:`jst_Orders` 最后更新 **14:40**(停 4 小时), + * 而 `jst_qmcust_orderlist` 更新到 **17:30**(正常)。 + * 只取候选行的 `MAX(updated_at)` 会被"碰巧新"的行掩盖,必须直接问源表。 + * + * 影响:该源停更 ⟹ 停更之后发出的单,`send_date` 仍是 NULL ⟹ 被判「未发货」。 + * 18:00 轮 546 单里 **540 单来自 jst_Orders**,全部是这种假超时。 + * + * 所以停更源的单在页面上必须**明确标注**,绝不静默当成「确认未发」。 + */ +const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WST_SOURCE_STALE_MINUTES) || 60; + /** * 不参与发货时效考核的快递(业务 2026-10-20 / 2026-10-27 指定): * 自提 —— 客户自己来拉,仓库不负责派送 @@ -407,6 +423,72 @@ async function loadOaConfirmInfo(stateRows) { return map; } +/** + * 两个订单表**各自**的最后更新时间,用于判断「这个源还活着吗」。 + * + * 🔥 为什么不能只看候选行:候选池本身就是 `send_date IS NULL` 的单, + * 某张表停更后它的候选单 `updateTime` 会集体冻在停更那一刻,看起来「都一样旧」; + * 但若停更表里恰好有几单新改过,`MAX` 就假装新鲜了 —— 必须直接问源表。 + * + * 口径: + * · `jst_Orders` → `MAX(updateTime)`(5 分钟一批的增量同步时间) + * · `jst_qmcust_orderlist` → `MAX(local_updated)`(该表没有 updateTime 列) + * · 只扫 30 天窗口(`round.windowStart` 起),不然要全表扫描 + * · 任一源查询失败 → 该源记 `lastUpdate:null`、`stale:false`, + * **不谎报停更**(查不到 ≠ 停更),但会在 `error` 里留痕 + * + * @param {object} round 轮次(用 windowStart 做扫描下界) + * @param {Date} now 当前时间(已按北京时间取) + * @returns {Promise>} + */ +async function loadSourceFreshness(round, now) { + const startLit = String(round.windowStart || '').slice(0, 19); + const nowText = formatDateTime(now); + const defs = [ + { + key: 'jst_Orders', + label: 'jst_Orders', + sql: `SELECT MAX(updateTime) AS maxUpd FROM rpt.jst_Orders WITH (NOLOCK) + WHERE updateTime >= '${startLit}'`, + }, + { + key: 'jst_qmcust_orderlist', + label: 'qmcust', + sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_orderlist WITH (NOLOCK) + WHERE local_updated >= '${startLit}'`, + }, + ]; + + // 两个源并行查(各自 60s 上限,任一个慢不拖累另一个) + const settled = await Promise.allSettled(defs.map((d) => cloudDatabase.querySlow( + d.sql, {}, { connectRequestTimeoutMs: 120000 }, + ))); + + return defs.map((d, i) => { + const s = settled[i]; + if (s.status !== 'fulfilled') { + return { + key: d.key, + label: d.label, + lastUpdate: '', + lagMinutes: null, + stale: false, + error: String((s.reason && s.reason.message) || s.reason || '查询失败').slice(0, 160), + }; + } + const rows = s.value || []; + const lastUpdate = formatDateTime(rows[0] && rows[0].maxUpd); + const lagMinutes = lastUpdate ? Math.max(0, diffMinutes(lastUpdate, nowText)) : null; + return { + key: d.key, + label: d.label, + lastUpdate, + lagMinutes, + // 查不到更新时间时**不判停更**(宁可漏报,也不谎报) + stale: lagMinutes != null && lagMinutes > SOURCE_STALE_MINUTES, + }; + }); +} function emptySummary() { return { @@ -422,6 +504,9 @@ function emptySummary() { carrierSkipped: 0, // 已剔除的「其实已发」单量:订单表 send_date 空、状态表 send_time 有值(漏回写) stateShippedSkipped: 0, + // 🔥 超时单里「所在数据源已停更」的单量 —— 这些单的「未发」不可信,可能早已发出。 + // 2026-10-28 18:00 轮实测:546 单里 540 单来自停更的 jst_Orders。 + sourceStaleUnshipped: 0, // 不属于 11 主仓的单量(代发仓 / POP 仓 / 仓库表查不到) unknownWarehouse: 0, }; @@ -504,6 +589,7 @@ async function getShipTimeliness(options = {}) { heldTotal: 0, detailDegraded: false, freshness: null, + dataSources: [], note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`, }; } @@ -544,6 +630,20 @@ async function getShipTimeliness(options = {}) { console.warn('[WST] 审核时间查询失败,全量回退订单创建时间:', e.message); } + // ③.6 数据源新鲜度:两个订单表**各自独立同步**,必须分源判断停更 ────────── + // 🔥 2026-10-28 18:35 实测:`jst_Orders` 停更在 14:40(4 小时), + // `jst_qmcust_orderlist` 正常到 17:30。那批「未发 546 单」里 540 单来自 + // 停更的表 —— 它们的 send_date 全空纯粹是没同步,业务说「基本都发了」是对的。 + // 只取候选行的 MAX(updated_at) 会被"碰巧新"的行掩盖,所以直接查源表。 + let sourceFreshness = []; + try { + sourceFreshness = await loadSourceFreshness(round, now); + } catch (e) { + // 查不到新鲜度不阻断页面,但要在日志里留痕(页面会退化成"不标注") + console.warn('[WST] 数据源新鲜度查询失败:', e.message); + } + const sourceMap = new Map(sourceFreshness.map((s) => [s.key, s])); + // 被剔除的作废 / 已发单计数(只用于页面说明,不进考核) let voidCount = 0; // 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账 @@ -552,6 +652,10 @@ async function getShipTimeliness(options = {}) { let stateShippedSkipped = 0; // 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…) let unknownWarehouse = 0; + // 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信 + let sourceStaleUnshipped = 0; + // 按来源表分开计数(提示里要说「jst_Orders 540 单、qmcust 6 单」,不能只说「这张表」) + const sourceStaleBySrc = {}; // ④ 分桶 const acc = new Map(centerList.map((c) => [c.key, { @@ -653,6 +757,12 @@ async function getShipTimeliness(options = {}) { state: ORDER_STATE_LABELS[statusRaw] || statusRaw, statusRaw, src: String(row.src || ''), + // 该单来自哪张订单表、这张表是否已经停更 ──────────────────────────── + // 🔥 停更 ⟹ 停更之后发出的单在这里仍是 send_date 空 ⟹ 被判「未发」, + // 但真实情况很可能是已经发了。所以**不能静默当确认未发**, + // 页面要标出来(2026-10-28 18:00 轮 546 单里 540 单是这种)。 + srcLabel: (sourceMap.get(String(row.src || '')) || {}).label || String(row.src || ''), + sourceStale: Boolean((sourceMap.get(String(row.src || '')) || {}).stale), createdAt, oaConfirmAt, payAt, @@ -709,6 +819,10 @@ async function getShipTimeliness(options = {}) { const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline)); bucket.pendingCount += 1; + if (base.sourceStale) { + sourceStaleUnshipped += 1; + sourceStaleBySrc[base.srcLabel] = (sourceStaleBySrc[base.srcLabel] || 0) + 1; + } const days = Math.floor(waited / 1440); if (days > bucket.oldestDays) bucket.oldestDays = days; pendingRows.push({ ...base, waited }); @@ -751,6 +865,10 @@ async function getShipTimeliness(options = {}) { benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, + // 该单所在订单表是否已停更 —— 前端据此给单打「数据未同步」标记 + src: x.src, + srcLabel: x.srcLabel, + sourceStale: x.sourceStale, // 订单表没有物流轨迹,只能从「有没有运单号」判断仓库是否已打单 logisticStatus: x.logisticId ? '已打单 · 未揽收' : '', // 运单号 / 快递公司:有运单号说明仓库**已打单**(但还没回传发货时间)。 @@ -825,6 +943,7 @@ async function getShipTimeliness(options = {}) { summary.voidCount = voidCount; summary.carrierSkipped = carrierSkipped; summary.stateShippedSkipped = stateShippedSkipped; + summary.sourceStaleUnshipped = sourceStaleUnshipped; summary.unknownWarehouse = unknownWarehouse; // 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表) @@ -833,7 +952,21 @@ async function getShipTimeliness(options = {}) { // 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。 // 必须把滞后暴露到页面上,否则业务只会以为判定写错。 const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null; - const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES; + // 停更判定要**两个口径取或**: + // ① 候选行的 MAX(updated_at) 太旧(老口径,只能反映"候选池里最新的那条") + // ② 任一订单表自己停更(新口径,直接问源表 —— 候选行会骗人,见 SOURCE_STALE_MINUTES 注释) + const staleSources = sourceFreshness.filter((s) => s.stale); + const stale = (snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES) + || staleSources.length > 0; + // 停更源的一句话描述:「jst_Orders 停止同步于 14:40(已 4.3 小时)」 + const staleSrcText = staleSources + .map((s) => `${s.label} 停止同步于 ${String(s.lastUpdate || '').slice(11, 16) || '—'}` + + `(已 ${Math.round((s.lagMinutes || 0) / 6) / 10} 小时)`) + .join('、'); + // 停更源各贡献了多少超时单(540 来自 jst_Orders、6 来自 qmcust 这种要说清,别只写「这张表」) + const staleBySrcText = Object.entries(sourceStaleBySrc) + .map(([k, v]) => `${k} ${v} 单`) + .join('、'); // 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。 // 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。 const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline; @@ -845,8 +978,11 @@ async function getShipTimeliness(options = {}) { // 数据源已经换成订单表,文案跟着改(别再写「状态镜像」) source: 'jst_Orders', hint: [ - stale - ? `订单表最后更新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。` + // 有源停更就一定要说,哪怕本轮 0 单 —— 「没停更但没单」和「停更了所以看不到单」是两件事 + staleSrcText ? `数据源停更:${staleSrcText}。` : '', + sourceStaleUnshipped > 0 + ? `本轮 ${sourceStaleUnshipped} 单来自停更的数据源(${staleBySrcText}):` + + '停更之后发出的单在这里仍是「未发货」,这些单很可能已经发货,请先在聚水潭核对同步状态再判超时。' : '', !coversDeadline ? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:` @@ -895,6 +1031,8 @@ async function getShipTimeliness(options = {}) { detailLimit: detailRows.length, detailDegraded: false, freshness, + // 两个订单表各自的同步新鲜度(页面据此出红条告警) + dataSources: sourceFreshness, cached: false, cacheAgeSec: 0, note: '', @@ -969,6 +1107,8 @@ async function listShipTimelinessExportRows(query = {}) { summary: payload.summary || emptySummary(), excludedCarriers: payload.excludedCarriers || EXCLUDED_CARRIERS, freshness: payload.freshness || null, + // 导出也要能说明「这批单里有多少来自停更的源」,否则导出的 Excel 脱离页面就没了上下文 + dataSources: payload.dataSources || [], }; } diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index fda30b1..5cbdcdf 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 = []) { +function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') { const WMS = { CQ: 15038426, SD_QGJ: 15137523, @@ -296,7 +296,7 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) { const rows = scenario === 'modified' ? modifiedRows : (scenario === 'noon' ? noonRows : defaultRows); - const calls = { orders: 0, oaConfirm: 0, warehouse: 0, sql: [], params: [] }; + const calls = { orders: 0, oaConfirm: 0, warehouse: 0, freshness: 0, sql: [], params: [] }; const handler = async (sql, params) => { const text = String(sql); @@ -304,6 +304,15 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) { calls.warehouse += 1; return warehouseRows; } + // 数据源新鲜度探测:`SELECT MAX(updateTime) FROM jst_Orders` / + // `SELECT MAX(local_updated) FROM jst_qmcust_orderlist`(2026-10-28 新增)。 + // 单独记账,**不算订单候选查询** —— 否则「订单两表只允许查一次」的老断言会误伤。 + if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text)) { + calls.freshness += 1; + // 默认 18:00(用例 now=18:05 → 滞后 5 分钟 < 60,不触发停更告警); + // 要测停更场景就把 freshnessUpd 传成当天更早的时刻 + return [{ maxUpd: freshnessUpd }]; + } // 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间, // 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 oaRows if (/jst_order_express_details/u.test(text)) { @@ -327,11 +336,11 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) { return { calls, WMS }; } -function withStubbedService(fn, scenario = 'default', oaRows = []) { +function withStubbedService(fn, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') { return async () => { const dbPath = require.resolve('../src/config/cloudDatabase'); const realDb = require.cache[dbPath]; - const ctx = stubDb(dbPath, scenario, oaRows); + const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd); delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; try { @@ -665,3 +674,31 @@ test('状态表审核时间比 modified 更晚时,以状态表为准', withStu }, '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 }, ])); + +test('数据源停更:来自停更表的超时单必须打标,并计入 sourceStaleUnshipped', async () => { + // 🔥 2026-10-28 18:35 现场:`jst_Orders` 停更在 14:40,18:00 轮 546 单里 540 单来自它, + // 业务当场质疑「基本都发了」。页面对这种单**不能静默当确认未发**。 + // 用例 now=18:05,源表停在 10:00 → 滞后 8 小时 > 60 分钟阈值 + await withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 }); + assert.ok(data.summary.overdueUnshipped > 0, '前置:默认场景应有超时单'); + assert.equal( + data.summary.sourceStaleUnshipped, + data.summary.overdueUnshipped, + '源停更时,全部超时单都要计入「数据未同步」', + ); + assert.ok(data.dataSources.length >= 2, '两个订单表的新鲜度都要透出'); + assert.ok(data.dataSources.every((x) => x.stale), '两张表都停在 10:00,都算停更'); + assert.equal(data.orders[0].sourceStale, true, '明细行要带 sourceStale 标记'); + assert.ok(data.orders[0].srcLabel, '明细行要带来源表名,前端才解释得清'); + assert.match(String(data.freshness.hint), /停更/u, '页面提示要说明是哪张表停更'); + }, 'default', [], '2026-10-20 10:00:00'); + + // 反方向:源正常时不能误标,否则业务会以为每天都在停更 + await withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 }); + assert.equal(data.summary.sourceStaleUnshipped, 0, '源正常时不能标停更'); + assert.ok(data.dataSources.every((x) => !x.stale), '滞后 5 分钟不达阈值'); + assert.equal(Boolean(data.orders[0].sourceStale), false, '明细行不带停更标记'); + }, 'default'); +}); diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx index 16289fe..213189c 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/index.jsx +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -185,7 +185,26 @@ const HELD_COLUMNS = [ const DETAIL_COLUMNS = [ // 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单) - { title: '内部单号', dataIndex: 'oId', width: 110 }, + { + title: '内部单号', + dataIndex: 'oId', + width: 158, + // 🔥 来自停更订单表的单必须打标(2026-10-28): + // 该表停更之后发出的单,send_date 仍是空 → 被判「未发」,实际很可能已经发了。 + // 18:00 轮实测 546 单里 540 单是这种,不标出来业务会以为判定写错。 + render: (v, row) => ( + + {v || '—'} + {row?.sourceStale ? ( + + 未同步 + + ) : null} + + ), + }, // 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户) { title: '线上订单号', @@ -318,6 +337,14 @@ const EXPORT_COLUMNS = [ { title: '运单号', dataIndex: 'logisticId', exportValue: (v) => v || '未打单' }, { title: '物流状态', dataIndex: 'logisticStatus' }, { title: '发货状态', dataIndex: 'state' }, + // 导出也要带上「这单的来源表 + 该表是否停更」,否则 Excel 脱离页面就没有上下文, + // 拿着 540 单「未发」去追仓库,实际人家早就发了。 + { title: '数据源', dataIndex: 'srcLabel', exportValue: (v) => v || '—' }, + { + title: '数据源是否停更', + dataIndex: 'sourceStale', + exportValue: (v) => (v ? '是(该单可能已发)' : '否'), + }, { title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' }, { title: '审核时间', @@ -427,6 +454,11 @@ function WarehouseShipTimeliness() { const summary = data?.summary || {}; const round = data?.round || null; const freshness = data?.freshness || null; + // 🔥 超时单里「所在订单表已停更」的数量(2026-10-28 定型): + // 停更⟹停更之后发出的单 send_date 仍为空⟹被判「未发」,但实际很可能已发。 + // 18:00 轮实测 546 单里 540 单属于这种,必须显著提示,否则业务会以为判定写错。 + const staleUnshipped = Number(summary.sourceStaleUnshipped || 0); + const staleSources = Array.isArray(data?.dataSources) ? data.dataSources.filter((s) => s && s.stale) : []; // 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字** const notStarted = Boolean(data?.notStarted); const hasRound = Boolean(round); @@ -730,14 +762,38 @@ function WarehouseShipTimeliness() { /> ) : null} - {freshness && (freshness.stale || freshness.coversDeadline === false) ? ( + {(freshness && (freshness.stale || freshness.coversDeadline === false)) + || staleUnshipped > 0 + || staleSources.length > 0 ? ( 0 || freshness?.coversDeadline === false ? 'error' : 'warning'} showIcon className="wst-alert" - message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'} - description={freshness.hint} + message={ + staleUnshipped > 0 + ? `${number(staleUnshipped)} 单所在订单表已停止同步,这些单很可能已经发货` + : (staleSources.length > 0 + ? '订单表数据源已停止同步' + : (freshness?.coversDeadline === false + ? '数据未覆盖本轮核验时刻,超时单量偏大' + : '发货数据可能滞后')) + } + description={( + <> + {freshness?.hint} + {staleSources.length ? ( +
+ {staleSources.map((s) => ( + + {s.label}:最后同步 {String(s.lastUpdate || '').slice(11, 19) || '—'} + (已 {Math.round((s.lagMinutes || 0) / 6) / 10} 小时) + + ))} +
+ ) : null} + + )} /> ) : null} @@ -754,6 +810,13 @@ function WarehouseShipTimeliness() { formatter={(v) => (notStarted ? '—' : number(v))} valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }} /> + {staleUnshipped > 0 ? ( + +
+ 其中 {number(staleUnshipped)} 单数据未同步 +
+
+ ) : null}