From 9a7215a46c05bdfdda0f2d9c120d25b3690bdfdb Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Wed, 11 Nov 2026 11:14:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(wle):=20=E6=AD=A3=E6=96=87=E5=86=99?= =?UTF-8?q?=E6=98=8E=E7=89=A9=E6=B5=81=E5=BF=AB=E7=85=A7=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E6=A0=B8=E9=AA=8C=E6=97=B6=E5=88=BB=20/=20?= =?UTF-8?q?=E6=98=AF=E5=90=A6=E8=BF=87=E6=97=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11-11 09:10 那批:物流镜像表停在 11-10 11:53,算出 990 单**全部异常**, 钉钉正文却只有「已发货 990 单,异常 990 个包裹(100%)」。群里没法分辨 这是真异常还是数据没更新 —— 而这恰恰决定当天要不要去催快递。 PR #94 只修了「0 单」这一种(emptyWindow),有异常但快照降级的情形仍然是哑的。 - 新增 `buildFreshnessWarning`:`coversDeadline === false` 或 `stale === true` 时,在数字行下面插一条 ⚠️ 告警,写明快照最新时刻、本轮核验时刻、未刷新时长, 并明确「请以聚水潭实时物流为准,别按这个数去催快递」。 - 新增 `formatSnapshotMoment`:快照时刻**跨天必须带 MM-DD**。 11-11 那批的 snapshotAt 是前一天的,只写「11:53」会被读成当天上午的新鲜数据 (与 WST「历史单审核时间必须带 MM-DD」是同一个坑)。 - 只在「算出异常」时告警;0 单走原有 emptyWindow 分支,不叠加两条。 - 字段缺失(老缓存 / 接口未返回)静默跳过,不能把推送搞崩,也不能瞎报警。 测试:新增 5 条(未覆盖核验时刻含跨天日期 / 同日不写日期 / stale 带时长 / 正常批次无告警 / 字段缺失不崩),26/26 通过。 --- .../warehouseLogisticsExceptionNotifyJob.js | 56 +++++++++++++++- .../warehouseLogisticsExceptionNotify.test.js | 65 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js index 109a757..8ef4db7 100644 --- a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js +++ b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js @@ -20,6 +20,9 @@ * 关键词一般是「异常」,缺了会被拒(errcode 310000)。 * ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重, * 服务重启不会重复轰炸;手动「立即推送」才强制重发。 + * ⑤ 数据新鲜度必须进正文(2026-11-11 加):只要算出异常,就要说明物流快照 + * 是否覆盖本轮核验时刻、是否过旧 —— 否则「990 单 100% 异常」到底是真异常 + * 还是镜像表停在昨天,群里看不出来。见 `buildFreshnessWarning`。 * * 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。 */ @@ -208,6 +211,51 @@ function buildTypeLine(data = {}) { .join(' · '); } +/** + * 快照时刻的展示形式:`2026-11-11 09:05:00` → `09:05`;**跨天则带 `MM-DD`**。 + * ⚠️ 只显示 HH:mm 会把「昨天 11:53」读成「今天 11:53」。11-11 那批的 `snapshotAt` + * 其实是前一天的(镜像表停在 11-10 11:53),群里看到「快照最新只到 11:53」会误以为 + * 当天上午的数据很新鲜 —— 与 WST「历史单审核时间必须带 MM-DD」是同一个坑。 + */ +function formatSnapshotMoment(value, referenceDate = '') { + const text = String(value || '').trim(); + if (text.length < 16) return text; + const datePart = text.slice(0, 10); + const timePart = text.slice(11, 16); + const refDate = String(referenceDate || '').trim().slice(0, 10); + if (refDate && datePart === refDate) return timePart; + return `${datePart.slice(5)} ${timePart}`; +} + +/** + * 快照新鲜度告警 —— **有异常数据时才用**(0 单的情形由 `buildNotifyText` 里的 emptyWindow 分支处理)。 + * + * 🔥 2026-11-11 实况:物流镜像表停在 11-10 11:53,09:10 那批算出 990 单**全部异常**, + * 正文却只有一句「异常 990 个(100%)」。群里没法分辨这是真异常还是数据没更新, + * 而这恰恰决定当天要不要去催快递。所以「没覆盖核验时刻」「快照过旧」必须写进正文, + * 不能只留在页面 hint 里 —— 群里干活的人看不到页面。 + */ +function buildFreshnessWarning(freshness = {}, batch = {}) { + const info = freshness && typeof freshness === 'object' ? freshness : {}; + const snapshotAt = formatSnapshotMoment(info.snapshotAt, batch.verifyDate); + + if (info.coversDeadline === false) { + const deadline = formatSnapshotMoment(batch.deadline, batch.verifyDate); + return `⚠️ 数据未覆盖核验时刻:物流快照最新只到 ${snapshotAt || '未知'}` + + `${deadline ? `,本轮核验 ${deadline}` : ''} —— 之后的揽收/中转轨迹没进库,异常量会偏大。` + + '请以聚水潭实时物流为准,别按这个数去催快递。'; + } + if (info.stale === true) { + const ageMinutes = Number(info.ageMinutes); + const hours = Number.isFinite(ageMinutes) && ageMinutes > 0 + ? `(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新)` + : ''; + return `⚠️ 物流快照偏旧:最新只到 ${snapshotAt || '未知'}${hours} —— 状态可能滞后,` + + '异常量仅供参考,请以聚水潭实时物流为准。'; + } + return ''; +} + function buildPageLink(pageUrl, verifyDate) { const base = String(pageUrl || DEFAULT_PAGE_URL); if (!verifyDate) return base; @@ -245,10 +293,15 @@ function buildNotifyText(data = {}, options = {}) { const typeLine = buildTypeLine(data); if (typeLine) head.push(`类型:${typeLine}`); + // 快照新鲜度告警:只对「算出了异常」的批次有意义 —— 11-11 那批 990 单全部异常, + // 正文却完全看不出数据停在昨天,业务只能凭印象判断要不要催快递。 + const freshness = data.freshness || {}; + const freshnessWarning = totalPackages > 0 ? buildFreshnessWarning(freshness, batch) : ''; + if (freshnessWarning) head.push(freshnessWarning); + // 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」—— // ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿; // ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。 - const freshness = data.freshness || {}; if (totalPackages === 0) { if (freshness.emptyWindow && freshness.sourceStale) { const hours = Number(freshness.sourceAgeMinutes) > 0 @@ -516,6 +569,7 @@ module.exports = { TIMEZONE, buildCenterLines, buildDetailLine, + buildFreshnessWarning, buildNotifyText, buildTypeLine, chinaDateKey, diff --git a/backend/test/warehouseLogisticsExceptionNotify.test.js b/backend/test/warehouseLogisticsExceptionNotify.test.js index 05baecc..5e85d1e 100644 --- a/backend/test/warehouseLogisticsExceptionNotify.test.js +++ b/backend/test/warehouseLogisticsExceptionNotify.test.js @@ -541,4 +541,69 @@ test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」 assert.match(built.text, /请到聚水潭核对/); assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更'); assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」'); + assert.ok(!built.text.includes('数据未覆盖核验时刻'), '0 单已有专门告警,别再叠一条快照告警'); +}); + +// ── ⑥ 有异常时的快照新鲜度也必须进正文(2026-11-11)────────────────── +// 实况:镜像表停在 11-10 11:53,09:10 那批算出 990 单全部异常,正文只有一句 +// 「异常 990 个(100%)」。群里没法分辨「真异常」和「数据停在昨天」, +// 而这恰恰决定当天要不要催快递。下面把两种降级(未覆盖核验时刻 / 快照过旧)钉死。 +test('快照没覆盖核验时刻 → 正文点名「未覆盖」,并把前一天的日期带上(不能只写 11:53)', () => { + const data = sampleData({ + freshness: { + snapshotAt: '2026-10-21 11:53:00', + ageMinutes: 1270, + stale: true, + coversDeadline: false, + emptyWindow: false, + }, + }); + const built = job.buildNotifyText(data, { maxDetailRows: 30 }); + + assert.match(built.text, /数据未覆盖核验时刻/); + assert.match(built.text, /最新只到 10-21 11:53/, '跨天必须带 MM-DD,否则会被读成当天 11:53'); + assert.match(built.text, /本轮核验 09:00/, '同一天的时刻不带日期'); + assert.match(built.text, /别按这个数去催快递/); + // 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收 + assert.match(built.text, /【仓储物流转运异常】/); +}); + +test('快照同日但没过核验时刻 → 只写时分,不啰嗦日期', () => { + const data = sampleData({ + freshness: { snapshotAt: '2026-10-22 08:30:00', ageMinutes: 30, stale: false, coversDeadline: false }, + }); + const built = job.buildNotifyText(data, { maxDetailRows: 30 }); + assert.match(built.text, /最新只到 08:30/); + assert.ok(!built.text.includes('10-22 08:30'), '同一天不重复写日期'); +}); + +test('快照过旧(stale)→ 正文写「偏旧」+ 未刷新时长,异常量标注仅供参考', () => { + const data = sampleData({ + freshness: { snapshotAt: '2026-10-22 09:05:00', ageMinutes: 200, stale: true, coversDeadline: true }, + }); + const built = job.buildNotifyText(data, { maxDetailRows: 30 }); + assert.match(built.text, /物流快照偏旧/); + assert.match(built.text, /09:05/); + assert.match(built.text, /已 3\.3 小时未刷新/); + assert.match(built.text, /仅供参考/); +}); + +test('快照正常 → 正文不出现任何新鲜度告警(别把正常批次也刷成黄色)', () => { + const data = sampleData({ + freshness: { snapshotAt: '2026-10-22 09:05:00', ageMinutes: 3, stale: false, coversDeadline: true }, + }); + const built = job.buildNotifyText(data, { maxDetailRows: 30 }); + assert.ok(!built.text.includes('数据未覆盖核验时刻')); + assert.ok(!built.text.includes('物流快照偏旧')); + assert.match(built.text, /异常 144 个包裹/); +}); + +test('快照字段缺失(老缓存 / 接口未返回)→ 不能崩,也不能瞎报警', () => { + const data = sampleData({ freshness: undefined }); + const built = job.buildNotifyText(data, { maxDetailRows: 30 }); + assert.match(built.text, /异常 144 个包裹/); + assert.ok(!built.text.includes('⚠️ 数据未覆盖')); + + assert.equal(job.buildFreshnessWarning(undefined, {}), ''); + assert.equal(job.buildFreshnessWarning({ coversDeadline: null }, {}), ''); }); -- 2.47.2