From 555ac6b116ac3090d2f9944586ebc559484670bf Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Mon, 2 Nov 2026 10:31:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(wle):=200=20=E5=8D=95=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=86=92=E5=85=85=E3=80=8C=E5=85=A8=E9=83=A8=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E3=80=8D=E2=80=94=E2=80=94=E7=AA=97=E5=8F=A3=E7=A9=BA=E6=97=B6?= =?UTF-8?q?=E6=9F=A5=E6=95=B0=E6=8D=AE=E6=BA=90=E6=98=AF=E5=90=A6=E5=81=9C?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11-02 09:00 那批推到钉钉群的是「已发货 0 单 / 0 个包裹,异常 0 个包裹(0%)」, 业务问「怎么可能 0 单」。查库确认:物流镜像表 jst_order_express_details 最后灌库停在 2026-11-01 07:55,11-01 当天 12 个发货仓一条发货记录都没有 (订单表 jst_Orders 同日有 4601 单发货,说明货是真发了,只是物流没进库)。 根因不是口径,是「0 单没有告警」这个设计缺陷:freshness 只用本批窗口内行的 query_time 算,窗口一行都没有时 snapshotAt 为空串 → stale=false、hint='', 于是「上游没同步」和「当天真没发货」在页面和钉钉里长得完全一样,还写「全部正常」。 改动 - service:窗口 0 行时补查一次表级 MAX(query_time)(全表 13.5s,只在空窗口付这笔钱, 随主结果进 5 分钟缓存),新增 freshness.emptyWindow / sourceLastUpdate / sourceAgeMinutes / sourceStale,并按「真停更 / 表新但当天无记录 / 取不到时间」 三种情况给不同 hint。 - 钉钉正文:0 条时不再无脑写「本批无转运异常,全部正常」;真停更写 「⚠️ 物流数据未同步:镜像表最后更新 …(已 N 小时未同步)… 0 单不代表当天没发货」, 表新但当天无记录则说明「库里 0 条发货记录(数据源正常),请到聚水潭核对」。 - 前端:emptyWindow 走红条(原来只判 coversDeadline/stale,空窗口不告警), 发货卡片副标改为「数据未同步,见上方红条」。 - 前端顺带修:钉钉消息链接带 ?date=YYYY-MM-DD,但页面从不读这个参数, 点进去永远显示「今天这批」;现按 URL 初始化核验日。 验证 - npm --prefix backend run check:505 tests / 503 pass;新增 5 个用例 (停更 hint、表新但 0 条、有行时不付全表 MAX、推送文案两态)全过。 既有失败 2 个与本任务无关:cloudDatabaseConfigCache 负缓存 TTL、 resilientSecretStore 的 Windows 0600 权限断言。 - 生产库实跑(本地 4002,直连 JSReportReplica): date=2026-11-02 → emptyWindow=true、sourceLastUpdate=2026-11-01 07:55:04、 sourceAgeMinutes≈1590、hint 正确;date=2026-11-01 → 4390 单 / 9 异常、 emptyWindow=false,原有「未覆盖核验时刻」提示不变。 - 钉钉 dryRun 正文实测:前 3 行即带「⚠️ 物流数据未同步 … 0 单不代表当天没发货」。 - Playwright 真渲染(5173): ?date=2026-11-02 → 红条 + 「数据未同步,见上方红条」,0 pageerror; ?date=2026-11-01 → 4,390 单 / 9 异常正常出表,且 URL 参数已生效。 - 前端 eslint 干净,vite build 通过。 风险与回退 - 只增字段与提示,不改任何统计口径;packageCount/shippedOrders 计算逻辑未动。 - 空窗口多一条全表 MAX 查询(13.5s),已用 5 分钟缓存兜住,且只在异常路径触发。 - 回退:单文件 revert 本提交即可,无数据结构变更。 --- .../warehouseLogisticsExceptionNotifyJob.js | 20 ++++- .../warehouseLogisticsExceptionService.js | 76 ++++++++++++++++++- .../test/warehouseLogisticsException.test.js | 43 +++++++++++ .../warehouseLogisticsExceptionNotify.test.js | 47 ++++++++++++ .../WarehouseLogisticsException/index.jsx | 29 +++++-- 5 files changed, 204 insertions(+), 11 deletions(-) diff --git a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js index 28618f8..109a757 100644 --- a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js +++ b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js @@ -245,8 +245,26 @@ function buildNotifyText(data = {}, options = {}) { const typeLine = buildTypeLine(data); if (typeLine) head.push(`类型:${typeLine}`); + // 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」—— + // ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿; + // ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。 + const freshness = data.freshness || {}; if (totalPackages === 0) { - head.push('本批无转运异常,全部正常。'); + if (freshness.emptyWindow && freshness.sourceStale) { + const hours = Number(freshness.sourceAgeMinutes) > 0 + ? `(已 ${Math.round((Number(freshness.sourceAgeMinutes) / 60) * 10) / 10} 小时未同步)` + : ''; + head.push(`⚠️ 物流数据未同步:镜像表最后更新 ${freshness.sourceLastUpdate || '未知'}${hours},` + + `${batch.shipDate || '该日'} 的发货记录一条都没进库 —— 上面 0 单不代表当天没发货,请先到聚水潭核对。`); + } else if (freshness.emptyWindow) { + head.push(`${batch.shipDate || '该日'} 在物流镜像表里 0 条发货记录` + + `(表最后更新 ${freshness.sourceLastUpdate || '未知'},数据源本身正常)——` + + ' 若当天确有发货,说明上游漏同步了这一天,请到聚水潭核对。'); + } else if (shipped === 0) { + head.push('本批发货 0 单,无异常可核。'); + } else { + head.push('本批无转运异常,全部正常。'); + } } const excludedCount = (Number(exclusions.carrierPackages) || 0) diff --git a/backend/src/services/warehouseLogisticsExceptionService.js b/backend/src/services/warehouseLogisticsExceptionService.js index 4917ac8..251fb50 100644 --- a/backend/src/services/warehouseLogisticsExceptionService.js +++ b/backend/src/services/warehouseLogisticsExceptionService.js @@ -38,6 +38,10 @@ const EXPORT_DETAIL_LIMIT = 5000; // 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮) const SNAPSHOT_STALE_MINUTES = 120; +// 数据源(整张镜像表)多久没刷新算停更。与上面同值,但语义不同: +// SNAPSHOT_STALE_MINUTES 判的是「本批窗口内行的 query_time」, +// SOURCE_STALE_MINUTES 判的是「整张表最后被灌库的时刻」—— 窗口内 0 行时只能靠后者。 +const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WLE_SOURCE_STALE_MINUTES) || 120; const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000; // key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存: @@ -148,6 +152,36 @@ async function loadShippedPackages(batch) { return rows || []; } +/** + * 整张镜像表最后被灌库的时刻(表级新鲜度)。 + * + * 🔥 2026-11-02 新增,起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」, + * 业务问「怎么可能 0 单」。查出来镜像表 **11-01 07:55 之后就没再灌过**, + * 而 v1 的 freshness 只用「本批窗口内行的 query_time」算 —— 窗口一行都没有时 + * `snapshotAt` 是空串 → `stale=false`、`hint=''`,页面和钉钉于是把它当成 + * 「本批确实没有发货 / 全部正常」。这是最危险的一种假绿。 + * + * 成本:全表 `MAX(query_time)` 在一个 402 万行的视图上实测 **13.5s**(谓词下推差), + * 所以**只在窗口 0 行时才调**(正常批次一行都不用付这笔钱),结果随主结果进 5 分钟缓存。 + * + * @returns {Promise} 'YYYY-MM-DD HH:mm:ss',取不到返回空串 + */ +async function loadSourceLastUpdate() { + try { + const rows = await cloudDatabase.query( + `SELECT MAX(e.query_time) AS max_q + FROM dbo.jst_order_express_details e WITH (NOLOCK)`, + {}, + { timeoutMs: 240000 } + ); + return formatDateTime(rows?.[0]?.max_q); + } catch (err) { + // 探测失败不该让整页挂掉:退回「不知道」,hint 里会说明取不到表级时间 + console.warn(`[WLE] 数据源新鲜度探测失败:${err.message}`); + return ''; + } +} + // 售后拦截判定:一次 IN 多少单 + 查询超时。 // 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫, // 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以: @@ -315,7 +349,17 @@ async function getLogisticsExceptions(options = {}) { orders: [], detailLimit: limit, truncated: false, - freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' }, + freshness: { + snapshotAt: '', + ageMinutes: null, + stale: false, + coversDeadline: null, + emptyWindow: false, + sourceLastUpdate: '', + sourceAgeMinutes: null, + sourceStale: false, + hint: '', + }, generatedAt, cached: false, cacheAgeSec: 0, @@ -510,11 +554,33 @@ async function getLogisticsExceptions(options = {}) { } // ── 新鲜度 ────────────────────────────────────────────────────────── + // 🔥 emptyWindow = 本批窗口里**一行都没取到**(12 仓范围)。 + // 这时 snapshotAt 必然是空串,v1 的判据(coversDeadline=null、stale=false、hint='') + // 会让「上游没同步」和「当天真的没发货」长得一模一样,页面/钉钉都显示成 0 单全绿。 + // 所以窗口为空时补查一次**表级**最后灌库时刻,把两种情形分开。 + const emptyWindow = rows.length === 0; + const sourceLastUpdate = emptyWindow ? await loadSourceLastUpdate() : ''; const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null; const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES; + const sourceAgeMinutes = sourceLastUpdate ? diffMinutes(sourceLastUpdate, generatedAt) : null; + const sourceStale = sourceAgeMinutes !== null && sourceAgeMinutes > SOURCE_STALE_MINUTES; const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null; let hint = ''; - if (coversDeadline === false) { + if (emptyWindow) { + const lastText = sourceLastUpdate || ''; + const hours = sourceAgeMinutes === null ? 0 : Math.round((sourceAgeMinutes / 60) * 10) / 10; + if (sourceStale) { + hint = `${batch.shipDate} 的发货记录在物流镜像表里一条都没有 —— 该表最后更新 ${lastText}(已 ${hours} 小时未同步)。` + + ' 上面的「0 单 / 0 个包裹」是数据没进库,不代表当天没发货。' + + ' 请先到聚水潭核对当天发货量,等上游同步后再刷新本页。'; + } else if (lastText) { + hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录(该表最后更新 ${lastText},数据源本身正常)。` + + ' 若当天确实有发货,说明上游漏同步了这一天,请到聚水潭核对;若当天本就停发,这个 0 才是真的。'; + } else { + hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录,且取不到该表最后更新时间 ——` + + ' 分不清是「当天没发货」还是「数据没同步」,请到聚水潭核对后再看本页。'; + } + } else if (coversDeadline === false) { hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——` + ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。'; } else if (stale) { @@ -561,6 +627,12 @@ async function getLogisticsExceptions(options = {}) { ageMinutes, stale, coversDeadline, + // 本批窗口 0 行:0 单含义不明,必须配合下面两个字段读 + emptyWindow, + // 表级最后灌库时刻(只有 emptyWindow 时才探测,否则为空串) + sourceLastUpdate, + sourceAgeMinutes, + sourceStale, hint, }, generatedAt, diff --git a/backend/test/warehouseLogisticsException.test.js b/backend/test/warehouseLogisticsException.test.js index cc43d13..61f031c 100644 --- a/backend/test/warehouseLogisticsException.test.js +++ b/backend/test/warehouseLogisticsException.test.js @@ -154,6 +154,10 @@ function stubDb(rows, options = {}) { if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) { return options.interceptRows || []; } + // 窗口 0 行时的「表级最后灌库时刻」探测 + if (/MAX\(\s*e\.query_time\s*\)/iu.test(String(sql))) { + return options.sourceMaxQueryTime === undefined ? [] : [{ max_q: options.sourceMaxQueryTime }]; + } return rows; }; require.cache[dbPath] = { @@ -672,3 +676,42 @@ test('售后表查询失败时降级:不剔单、不炸页面', async () => { delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; } }); + +// ── ⑧ 窗口 0 行:0 单不能被当成「当天没发货 / 全部正常」(2026-11-02)────── +// 起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」, +// 实况是镜像表 11-01 07:55 之后就没再灌库,11-01 的 12 仓发货记录一条都没进库。 +test('窗口 0 行 + 数据源停更 → emptyWindow/sourceStale,hint 明说「数据没进库」', async () => { + const nov2 = new Date(2026, 10, 2, 10, 0, 0); // 2026-11-02 10:00(本机时区) + await withStub(async (svc, calls) => { + const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 }); + assert.equal(data.summary.shippedOrders, 0); + assert.equal(data.freshness.emptyWindow, true); + assert.equal(data.freshness.sourceLastUpdate, '2026-11-01 07:55:04'); + assert.ok(data.freshness.sourceAgeMinutes > 120, `应算出停更小时数,实际 ${data.freshness.sourceAgeMinutes}`); + assert.equal(data.freshness.sourceStale, true); + assert.match(data.freshness.hint, /一条都没有/); + assert.match(data.freshness.hint, /不代表当天没发货/); + // 只在空窗口才付那次全表 MAX(query_time)(实测 13.5s) + assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 1); + }, [], { sourceMaxQueryTime: '2026-11-01 07:55:04' }); +}); + +test('窗口 0 行但数据源是新的 → 仍然提示「0 条记录」,不能静默全绿', async () => { + const nov2 = new Date(2026, 10, 2, 10, 0, 0); + await withStub(async (svc) => { + const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 }); + assert.equal(data.freshness.emptyWindow, true); + assert.equal(data.freshness.sourceStale, false, '表是新的,不该报停更'); + assert.match(data.freshness.hint, /0 条发货记录/, '真 0 单也要说清是「库里没有记录」'); + assert.match(data.freshness.hint, /数据源本身正常/); + }, [], { sourceMaxQueryTime: '2026-11-02 09:58:00' }); +}); + +test('窗口有行时不做全表探测(省掉 13.5s 的表级 MAX)', async () => { + await withStub(async (svc, calls) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); + assert.equal(data.freshness.emptyWindow, false); + assert.equal(data.freshness.sourceLastUpdate, ''); + assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 0); + }); +}); diff --git a/backend/test/warehouseLogisticsExceptionNotify.test.js b/backend/test/warehouseLogisticsExceptionNotify.test.js index 2d60c27..05baecc 100644 --- a/backend/test/warehouseLogisticsExceptionNotify.test.js +++ b/backend/test/warehouseLogisticsExceptionNotify.test.js @@ -495,3 +495,50 @@ test('发文本:超长正文自动截断后仍会发出去(不是直接抛 stub.restore(); } }); + +// ── ⑤ 0 单不能用「全部正常」糊过去(2026-11-02)────────────────────────── +// 实况:11-02 09:00 那批推的是「0 单 / 0 个包裹,异常 0 个包裹」,业务以为真没发货, +// 其实是物流镜像表 11-01 07:55 后就没同步。群里绝不能出现「全部正常」这种假绿。 +test('数据源停更导致 0 单 → 正文首部醒目告警,且不出现「全部正常」', () => { + const data = sampleData({ + rows: [], + summary: { + shippedOrders: 0, packageCount: 0, exceptionOrders: 0, + exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {}, + }, + exclusions: {}, + freshness: { + emptyWindow: true, + sourceLastUpdate: '2026-11-01 07:55:04', + sourceAgeMinutes: 1565, + sourceStale: true, + hint: '11-01 的发货记录在物流镜像表里一条都没有', + }, + }); + const built = job.buildNotifyText(data, { maxDetailRows: 30 }); + assert.match(built.text, /物流数据未同步/); + assert.match(built.text, /镜像表最后更新 2026-11-01 07:55:04/); + assert.match(built.text, /已 26\.1 小时未同步/); + assert.match(built.text, /不代表当天没发货/); + assert.ok(!built.text.includes('全部正常'), '数据没进库时不能说「全部正常」'); + // 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收 + assert.match(built.text, /【仓储物流转运异常】/); +}); + +test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」,不说「全部正常」也不谎报停更', () => { + const data = sampleData({ + rows: [], + summary: { + shippedOrders: 0, packageCount: 0, exceptionOrders: 0, + exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {}, + }, + exclusions: {}, + freshness: { emptyWindow: true, sourceLastUpdate: '2026-11-02 09:58:00', sourceAgeMinutes: 2, sourceStale: false }, + }); + const built = job.buildNotifyText(data, { maxDetailRows: 30 }); + assert.match(built.text, /在物流镜像表里 0 条发货记录/); + assert.match(built.text, /数据源本身正常/); + assert.match(built.text, /请到聚水潭核对/); + assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更'); + assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」'); +}); diff --git a/frontend/src/pages/WarehouseLogisticsException/index.jsx b/frontend/src/pages/WarehouseLogisticsException/index.jsx index e8de790..2087c01 100644 --- a/frontend/src/pages/WarehouseLogisticsException/index.jsx +++ b/frontend/src/pages/WarehouseLogisticsException/index.jsx @@ -252,8 +252,15 @@ function WarehouseLogisticsException() { const [activeCenter, setActiveCenter] = useState(''); // 异常类型筛选:为空 = 全部 const [activeType, setActiveType] = useState(''); - // 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批) - const [verifyDate, setVerifyDate] = useState(null); + // 核验日;默认 null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批) + // 🔥 钉钉消息里的链接带 `?date=YYYY-MM-DD`(见 buildPageLink),页面必须认这个参数 —— + // 否则群里点进来看到的是「今天这批」,跟消息里说的不是同一批,历史消息会误导人。 + const [verifyDate, setVerifyDate] = useState(() => { + const raw = String(new URLSearchParams(window.location.search).get('date') || '').trim(); + if (!/^\d{4}-\d{2}-\d{2}$/u.test(raw)) return null; + const parsed = dayjs(raw); + return parsed.isValid() ? parsed : null; + }); // 导出中(导出比列表慢,按钮要转圈,避免重复点) const [exporting, setExporting] = useState(false); // 每天定点把异常文本推到钉钉群(「异常通知」) @@ -564,14 +571,16 @@ function WarehouseLogisticsException() { /> ) : null} - {freshness && (freshness.coversDeadline === false || freshness.stale) ? ( + {freshness && (freshness.emptyWindow || freshness.coversDeadline === false || freshness.stale) ? ( ) : null} @@ -589,7 +598,11 @@ function WarehouseLogisticsException() { formatter={(v) => (notStarted ? '—' : number(v))} /> - 共 {notStarted ? '—' : number(summary.packageCount)} 个包裹 + {notStarted + ? '—' + : (freshness?.emptyWindow + ? '数据未同步,见上方红条' + : `共 ${number(summary.packageCount)} 个包裹`)}