Merge pull request 'fix(wle): 0 单不再冒充「全部正常」——窗口空时查数据源是否停更' (#94) from yanhui-wle-emptydata-1102 into main

This commit is contained in:
yanhui 2026-11-02 13:32:49 +08:00
commit 54ad6aad89
5 changed files with 204 additions and 11 deletions

View File

@ -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)

View File

@ -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<string>} '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,

View File

@ -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);
});
});

View File

@ -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 条不能糊成「全部正常」');
});

View File

@ -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) ? (
<Alert
type={freshness.coversDeadline === false ? 'error' : 'warning'}
type={freshness.emptyWindow || freshness.coversDeadline === false ? 'error' : 'warning'}
showIcon
className="wle-alert"
message={freshness.coversDeadline === false
? '物流快照未覆盖本轮核验时刻,异常量偏大'
: '物流数据可能滞后'}
message={freshness.emptyWindow
? '本批 0 单:物流镜像表里没有当天的发货记录,这个 0 不可当结论'
: (freshness.coversDeadline === false
? '物流快照未覆盖本轮核验时刻,异常量偏大'
: '物流数据可能滞后')}
description={freshness.hint}
/>
) : null}
@ -589,7 +598,11 @@ function WarehouseLogisticsException() {
formatter={(v) => (notStarted ? '—' : number(v))}
/>
<Text type="secondary" className="wle-stat__sub">
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
{notStarted
? '—'
: (freshness?.emptyWindow
? '数据未同步,见上方红条'
: `共 ${number(summary.packageCount)} 个包裹`)}
</Text>
</Card>
<Card bordered={false} className="wle-stat">