Compare commits

...

3 Commits

Author SHA1 Message Date
54ad6aad89 Merge pull request 'fix(wle): 0 单不再冒充「全部正常」——窗口空时查数据源是否停更' (#94) from yanhui-wle-emptydata-1102 into main 2026-11-02 13:32:49 +08:00
555ac6b116 fix(wle): 0 单不再冒充「全部正常」——窗口空时查数据源是否停更
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 本提交即可,无数据结构变更。
2026-11-02 10:31:11 +08:00
b955e78ba2 Merge pull request 'fix(wst): 用聚水潭状态终态 + 销售出库单判作废/已发' (#93) from yanhui-wst-void-1029 into main 2026-10-29 15:38:51 +08:00
5 changed files with 204 additions and 11 deletions

View File

@ -245,8 +245,26 @@ function buildNotifyText(data = {}, options = {}) {
const typeLine = buildTypeLine(data); const typeLine = buildTypeLine(data);
if (typeLine) head.push(`类型:${typeLine}`); if (typeLine) head.push(`类型:${typeLine}`);
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
const freshness = data.freshness || {};
if (totalPackages === 0) { 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) const excludedCount = (Number(exclusions.carrierPackages) || 0)

View File

@ -38,6 +38,10 @@ const EXPORT_DETAIL_LIMIT = 5000;
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮) // 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
const SNAPSHOT_STALE_MINUTES = 120; 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; const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存: // key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
@ -148,6 +152,36 @@ async function loadShippedPackages(batch) {
return rows || []; 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 多少单 + 查询超时。 // 售后拦截判定:一次 IN 多少单 + 查询超时。
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫, // 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以: // 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
@ -315,7 +349,17 @@ async function getLogisticsExceptions(options = {}) {
orders: [], orders: [],
detailLimit: limit, detailLimit: limit,
truncated: false, 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, generatedAt,
cached: false, cached: false,
cacheAgeSec: 0, 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 ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES; 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; const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
let hint = ''; 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)} 核验时刻 ——` hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。'; + ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
} else if (stale) { } else if (stale) {
@ -561,6 +627,12 @@ async function getLogisticsExceptions(options = {}) {
ageMinutes, ageMinutes,
stale, stale,
coversDeadline, coversDeadline,
// 本批窗口 0 行:0 单含义不明,必须配合下面两个字段读
emptyWindow,
// 表级最后灌库时刻(只有 emptyWindow 时才探测,否则为空串)
sourceLastUpdate,
sourceAgeMinutes,
sourceStale,
hint, hint,
}, },
generatedAt, generatedAt,

View File

@ -154,6 +154,10 @@ function stubDb(rows, options = {}) {
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) { if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
return options.interceptRows || []; 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; return rows;
}; };
require.cache[dbPath] = { require.cache[dbPath] = {
@ -672,3 +676,42 @@ test('售后表查询失败时降级:不剔单、不炸页面', async () => {
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; 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(); 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 [activeCenter, setActiveCenter] = useState('');
// 异常类型筛选:为空 = 全部 // 异常类型筛选:为空 = 全部
const [activeType, setActiveType] = useState(''); const [activeType, setActiveType] = useState('');
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批) // 核验日;默认 null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
const [verifyDate, setVerifyDate] = useState(null); // 🔥 钉钉消息里的链接带 `?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); const [exporting, setExporting] = useState(false);
// 每天定点把异常文本推到钉钉群(「异常通知」) // 每天定点把异常文本推到钉钉群(「异常通知」)
@ -564,14 +571,16 @@ function WarehouseLogisticsException() {
/> />
) : null} ) : null}
{freshness && (freshness.coversDeadline === false || freshness.stale) ? ( {freshness && (freshness.emptyWindow || freshness.coversDeadline === false || freshness.stale) ? (
<Alert <Alert
type={freshness.coversDeadline === false ? 'error' : 'warning'} type={freshness.emptyWindow || freshness.coversDeadline === false ? 'error' : 'warning'}
showIcon showIcon
className="wle-alert" className="wle-alert"
message={freshness.coversDeadline === false message={freshness.emptyWindow
? '物流快照未覆盖本轮核验时刻,异常量偏大' ? '本批 0 单:物流镜像表里没有当天的发货记录,这个 0 不可当结论'
: '物流数据可能滞后'} : (freshness.coversDeadline === false
? '物流快照未覆盖本轮核验时刻,异常量偏大'
: '物流数据可能滞后')}
description={freshness.hint} description={freshness.hint}
/> />
) : null} ) : null}
@ -589,7 +598,11 @@ function WarehouseLogisticsException() {
formatter={(v) => (notStarted ? '—' : number(v))} formatter={(v) => (notStarted ? '—' : number(v))}
/> />
<Text type="secondary" className="wle-stat__sub"> <Text type="secondary" className="wle-stat__sub">
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹 {notStarted
? '—'
: (freshness?.emptyWindow
? '数据未同步,见上方红条'
: `共 ${number(summary.packageCount)} 个包裹`)}
</Text> </Text>
</Card> </Card>
<Card bordered={false} className="wle-stat"> <Card bordered={false} className="wle-stat">