cangku/backend/test/warehouseLogisticsExceptionNotify.test.js
yanhui 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

545 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* 「仓储中心物流转运异常」每天推文本到钉钉群 —— 正文组装与通道回归测试
* ────────────────────────────────────────────────────────────────────────────
* 这里只测四件容易悄悄坏掉的事:
* ① 群消息正文的数字与明细(业务照着这条消息干活,数字错了没人能发现);
* ② 0 条 / 明细条数上限 / 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
* ③ webhook 地址与加签(sign 算错就是 310000,肉眼看不出来);
* ④ 钉钉报错必须原样透出 errcode/errmsg(业务排错全靠这个码)。
* 不测数据库与真实网络:settings / job 的 DB 分支在接口层验证。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const job = require('../src/jobs/warehouseLogisticsExceptionNotifyJob');
const sender = require('../src/services/dingtalkRobotWebhookSender');
const settings = require('../src/services/warehouseLogisticsExceptionNotifySettings');
function sampleData(overrides = {}) {
return {
batch: {
label: '10-22 09:00 核验 · 10-21 发货',
verifyDate: '2026-10-22',
shipDate: '2026-10-21',
deadline: '2026-10-22 09:00:00',
},
summary: {
shippedOrders: 9886,
packageCount: 9886,
exceptionOrders: 143,
exceptionPackages: 144,
exceptionRate: 1.4,
centerCount: 9,
byType: { PICKED_NO_TRANSIT: 137, NOT_PICKED: 7 },
},
centers: [
{ key: 'sn', name: '睢宁仓', shippedOrders: 1200, exceptionOrders: 1, exceptionRate: 0.1 },
{ key: 'cq', name: '崔桥工厂仓', shippedOrders: 3000, exceptionOrders: 2, exceptionRate: 0.1 },
{ key: 'hl', name: '横林二仓', shippedOrders: 800, exceptionOrders: 0, exceptionRate: 0 },
],
types: [
{ key: 'PICKED_NO_TRANSIT', label: '已揽收未中转', desc: '揽收后没有下一步中转记录' },
{ key: 'NOT_PICKED', label: '未揽收', desc: '快递还没来揽' },
],
exclusions: {
carriers: ['唯品顺丰', '顺丰', '京喜京东'],
conditionalCarriers: [{ keyword: '睢宁中通', keepCenters: ['睢宁仓'] }],
orderStatusKeywords: ['取消', '作废'],
orderLabelKeywords: ['发货后取消'],
orderTypeLabels: ['刷单', '转空包'],
carrierPackages: 12,
voidedPackages: 3,
nonRealPackages: 5,
},
rows: [
{
centerKey: 'cq',
warehouseName: '崔桥工厂仓',
oId: '9845001',
soId: '3318',
shopName: '京东自营',
carrier: '京东快递',
trackingNo: 'JD1',
logisticStatus: '已揽收',
exceptionLabel: '已揽收未中转',
exceptionDesc: '揽收后没有下一步中转记录',
sendTime: '2026-10-21 16:00:00',
lastLogisticTime: '2026-10-21 21:00:00',
pendingHours: 17,
lastLogisticInfo: '已揽收',
},
{
centerKey: 'sn',
warehouseName: '睢宁仓',
oId: '9844248',
soId: '3316',
shopName: '天猫旗舰店',
carrier: '睢宁德邦',
trackingNo: '',
logisticStatus: '',
exceptionLabel: '未揽收',
exceptionDesc: '快递还没来揽',
sendTime: '2026-10-21 14:22:10',
lastLogisticTime: '',
pendingHours: null,
lastLogisticInfo: '',
},
],
...overrides,
};
}
test('群消息正文:抬头带「异常」关键词、数字与分类型、按仓只列有异常的仓', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30, pageUrl: 'https://ck.yydd.ai:8443/p' });
const text = built.text;
// 钉钉「自定义关键词」安全设置一般配「异常」,抬头必须含这两个字
assert.match(text, /【仓储物流转运异常】/);
assert.match(text, /10-22 09:00 核验 · 10-21 发货/);
assert.match(text, /已发货 9886 单 \/ 9886 个包裹,异常 144 个包裹(1\.4%)/);
// 分类型按数量降序,且必须是可读标签(不是 PICKED_NO_TRANSIT 这种 key)
assert.match(text, /类型:已揽收未中转 137 · 未揽收 7/);
// 剔除数量 = 12 + 3 + 5
assert.match(text, /已剔除 20 个包裹/);
// 按仓只列有异常的:睢宁 1、崔桥 1;横林二仓 0 条不能出现
assert.match(text, /按仓(异常包裹):/);
assert.match(text, /睢宁仓 1 个(发货 1200 单)/);
assert.match(text, /崔桥工厂仓 1 个(发货 3000 单)/);
assert.ok(!text.includes('横林二仓'), '没有异常的仓不该占行');
// 页码链接带核验日
assert.match(text, /查看全部 \/ 导出 Excel:https:\/\/ck\.yydd\.ai:8443\/p\?date=2026-10-22/);
assert.equal(built.rowCount, 144);
assert.equal(built.listedCount, 2);
assert.equal(built.truncated, false);
});
test('明细行:仓|快递单号|快递|异常类型(4 段,不带发货时间和待处理时长),字段里换行要压平', () => {
const line = job.buildDetailLine({
warehouseName: '崔桥工厂仓',
oId: '9845001',
trackingNo: 'JDVA00123456789',
carrier: '京东快递',
exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00',
pendingHours: 17.25,
lastLogisticInfo: '第一行\n第二行',
}, 3);
assert.equal(line, '3. 崔桥工厂仓|JDVA00123456789|京东快递|已揽收未中转');
// 业务要求:第二段是快递单号,不是内部单号
assert.ok(!line.includes('9845001'), '明细行不该再显示内部单号');
// 业务要求:明细不带发货时间(哪怕传了 sendTime 也不能出现在消息里)
assert.ok(!line.includes('10-21'), '明细行不该再带发货时间');
assert.ok(!line.includes('16:00'), '明细行不该再带发货时间');
// 业务要求:末尾的待处理小时数也去掉(哪怕传了 pendingHours 也不能出现在消息里)
assert.ok(!line.includes('17.3h'), '明细行不该再带待处理时长');
assert.equal(line.split('|').length, 4, '明细必须正好 4 段');
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
// 未揽收/无物流单没有快递单号 → 退回内部单号(否则群里这条无法定位)
assert.equal(
job.buildDetailLine({ oId: '9845002', carrier: '中通' }, 1),
'1. 未知仓|9845002|中通|异常'
);
// 空值兜底:不能让消息里出现「undefined|null」
assert.equal(
job.buildDetailLine({}, 1),
'1. 未知仓|无单号|未知快递|异常'
);
});
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
const rows = [
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
];
const grouped = job.groupRowsByWarehouse(rows);
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
// 不丢行、不改行
assert.equal(grouped.length, rows.length);
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
const detailBlock = text.split('明细前')[1] || '';
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
// 同仓必须相邻:切成连续段后不应出现重复仓名
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
});
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
const rows = [
{ warehouseName: '', trackingNo: 'A1' },
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
{ trackingNo: 'A2' },
];
const grouped = job.groupRowsByWarehouse(rows);
// 未知仓 2 条(含 undefined)> 睢宁 1 条
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
});
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
warehouseName: name,
trackingNo: `${name}-${i}`,
}));
const rows = [
...make('青岗集仓', 100),
...make('崔桥工厂仓', 20),
...make('睢宁仓', 8),
...make('新苏北仓', 6),
...make('横林二仓', 6),
];
const picked = job.pickDetailRows(rows, 40);
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
const counts = picked.reduce((acc, row) => {
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
return acc;
}, {});
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
}
// 大仓仍然拿得多,但不能吃满额度
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
// 同仓仍然相邻
const names = picked.map((row) => row.warehouseName);
const runs = names.filter((name, i) => name !== names[i - 1]);
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
});
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
warehouseName: name,
trackingNo: `${name}-${i}`,
}));
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
const picked = job.pickDetailRows(rows, 2);
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
// 额度 0 / 超额度的边界
assert.deepEqual(job.pickDetailRows(rows, 0), []);
assert.equal(job.pickDetailRows(rows, 99).length, 17);
});
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
const data = sampleData({
rows: [],
summary: {
...sampleData().summary,
exceptionOrders: 0,
exceptionPackages: 0,
exceptionRate: 0,
byType: {},
centerCount: 0,
},
exclusions: {},
});
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.match(built.text, /本批无转运异常,全部正常/);
assert.match(built.text, /异常 0 个包裹(/);
assert.ok(!built.text.includes('明细前'), '0 条时不该出现明细标题');
assert.equal(built.rowCount, 0);
assert.equal(built.listedCount, 0);
});
test('明细条数受配置控制:0 = 只发汇总,超量只列前 N 条并提示剩余', () => {
const zero = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
assert.ok(!/明细前 \d+ 条/.test(zero.text), 'maxDetailRows=0 时不要明细块');
assert.match(zero.text, /明细共 144 条,消息里不放,请点下面链接查看/);
const one = job.buildNotifyText(sampleData(), { maxDetailRows: 1 });
assert.equal(one.listedCount, 1);
assert.ok(one.text.includes('1. 崔桥工厂仓'), '只列第一条');
assert.ok(!one.text.includes('2. 睢宁仓'), '第二条应被截掉');
assert.match(one.text, /其余 143 条见页面/);
});
test('明细太多时按字节精简:不超 20000 字节上限,且不切断半个汉字', () => {
// 造 60 条很长的明细(仓名 144 字 ≈ 432 字节/行),必然超过 text 上限
const longName = '崔桥工厂半成品包装项目仓'.repeat(12);
const rows = Array.from({ length: 60 }, (_item, index) => ({
centerKey: 'cq',
warehouseName: longName,
oId: `9845${String(index).padStart(3, '0')}`,
carrier: '中通快递',
exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00',
pendingHours: 17,
}));
const data = sampleData({
rows,
summary: { ...sampleData().summary, exceptionPackages: rows.length, exceptionOrders: rows.length },
});
const built = job.buildNotifyText(data, { maxDetailRows: 50 });
assert.ok(built.bytes <= sender.MAX_CONTENT_BYTES, `实得 ${built.bytes} 字节,超了上限`);
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, '应留出安全余量');
assert.equal(built.truncated, true, '应标记为已精简');
assert.ok(built.listedCount < 50, '应自动少列几条');
assert.ok(!built.text.includes('\uFFFD'), '不能出现被截断产生的乱码');
// 削掉明细后仍保留抬头和链接
assert.match(built.text, /【仓储物流转运异常】/);
assert.match(built.text, /查看全部/);
});
test('按字节截断工具:中文按 3 字节算,不切半个字', () => {
assert.deepEqual(sender.truncateBytes('abc', 10), { text: 'abc', truncated: false });
// 「中」= 3 字节:10 字节最多放 3 个字(9 字节)
const cut = sender.truncateBytes('中文字符', 10);
assert.equal(cut.text, '中文字');
assert.equal(cut.truncated, true);
assert.ok(Buffer.byteLength(cut.text, 'utf8') <= 10);
});
test('时区:容器是 UTC,推送时刻仍按北京时间判(09:10 不能变成 01:10)', () => {
// 2026-10-22T01:10Z = 北京时间 09:10
assert.equal(job.chinaHourMinute(new Date('2026-10-22T01:10:00Z')), '09:10');
assert.equal(job.chinaDateKey(new Date('2026-10-22T01:10:00Z')), '2026-10-22');
// 北京时间 00:30(UTC 前一天 16:30)→ 日期必须是新的一天
assert.equal(job.chinaDateKey(new Date('2026-10-21T16:30:00Z')), '2026-10-22');
assert.equal(job.chinaHourMinute(new Date('2026-10-21T16:30:00Z')), '00:30');
});
test('推送时间 / 明细条数:非法输入一律退回默认值,不能把定时任务配挂', () => {
assert.equal(settings.normalizeSendTime('08:05'), '08:05');
assert.equal(settings.normalizeSendTime('9:10'), '09:10');
assert.equal(settings.normalizeSendTime('25:00'), '09:10');
assert.equal(settings.normalizeSendTime(''), '09:10');
assert.equal(settings.normalizeSendTime(undefined), '09:10');
assert.equal(settings.normalizeMaxDetailRows(undefined), 30);
assert.equal(settings.normalizeMaxDetailRows(-5), 0);
assert.equal(settings.normalizeMaxDetailRows(999), 50);
assert.equal(settings.normalizeMaxDetailRows('12'), 12);
assert.equal(settings.normalizeMaxDetailRows('abc'), 30);
});
test('配置校验:只要有 Webhook 就算配好;加签密钥只回掩码', () => {
assert.deepEqual(settings.missingFields({ webhookUrl: '' }), ['Webhook 地址']);
assert.deepEqual(settings.missingFields({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }), []);
assert.equal(settings.isWarehouseLogisticsExceptionNotifyReady({}), false);
assert.equal(
settings.isWarehouseLogisticsExceptionNotifyReady({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }),
true,
'安全设置用「关键词」时不需要加签密钥'
);
const admin = settings.toAdminConfig({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t',
webhookSecret: 'SEC0123456789abcdef',
enabled: true,
});
assert.equal(admin.webhookSecretConfigured, true);
assert.ok(!JSON.stringify(admin).includes('SEC0123456789abcdef'), '明文加签密钥不能出现在返回体里');
assert.match(admin.webhookSecretPreview, /\*+/);
// Webhook 要完整回显:业务需要核对 access_token
assert.match(admin.webhookUrl, /access_token=t/);
});
test('Webhook 地址校验:必须是 robot/send 且带 access_token', () => {
const ok = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
assert.equal(sender.normalizeWebhookUrl(ok), ok);
assert.throws(() => sender.normalizeWebhookUrl(''), /请填写/);
assert.throws(() => sender.normalizeWebhookUrl('oapi.dingtalk.com/robot/send'), /不是合法 URL/);
// 常见的复制错误:贴成了企业内部应用的地址 / 少复制了路径
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/robot/send'), /缺少 access_token/);
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/topapi/robot/send?access_token=a'), /不像群机器人/);
});
test('加签:sign = urlEncode(base64(HmacSHA256(secret, `${ts}\\n${secret}`)))', () => {
const webhook = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
const secret = 'SECtestsecret';
const ts = 1700000000000;
const signed = new URL(sender.buildSignedUrl(webhook, secret, ts));
assert.equal(signed.searchParams.get('timestamp'), String(ts));
assert.equal(signed.searchParams.get('access_token'), 'abc123', '原参数要保留');
// 手工算一遍(顺序写错就是 310000,这条断言就是防这个)
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}\n${secret}`, 'utf8')
.digest('base64');
// searchParams 会自动 decode,所以这里拿到的是原始 base64(含 + / =)
assert.equal(signed.searchParams.get('sign'), expected);
// 没配密钥(安全设置用关键词/IP):只追加 timestamp,不带 sign
const plain = new URL(sender.buildSignedUrl(webhook, '', ts));
assert.equal(plain.searchParams.get('sign'), null);
assert.equal(plain.searchParams.get('timestamp'), String(ts));
});
// ── 钉钉通道:stub fetch 验请求形状与错误透传 ────────────────────────────────
function stubFetch(handler) {
const calls = [];
const original = global.fetch;
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
const body = handler.body === undefined ? {} : handler.body;
return new Response(JSON.stringify(body), { status: handler.status || 200 });
};
return {
calls,
restore: () => { global.fetch = original; },
};
}
test('发文本:msgtype=text,加签参数拼在 URL 上,@所有人开关透传', async () => {
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
try {
const result = await sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=abc123',
secret: 'SECtestsecret',
content: '【仓储物流转运异常】测试',
atAll: true,
});
assert.equal(result.delivered, true);
assert.equal(result.errmsg, 'ok');
const call = stub.calls[0];
assert.equal(call.options.method, 'POST');
assert.match(call.options.headers['Content-Type'], /application\/json/);
// 加签后 URL 必须带 timestamp + sign
const url = new URL(call.url);
assert.ok(url.searchParams.get('timestamp'), '缺少 timestamp');
assert.ok(url.searchParams.get('sign'), '缺少 sign');
const body = JSON.parse(call.options.body);
assert.equal(body.msgtype, 'text');
assert.equal(body.text.content, '【仓储物流转运异常】测试');
assert.equal(body.at.isAtAll, true);
assert.deepEqual(body.at.atMobiles, []);
} finally {
stub.restore();
}
});
test('发文本:空内容不发请求(避免钉钉 400 把错误码埋掉)', async () => {
let touched = 0;
const original = global.fetch;
global.fetch = async () => { touched += 1; throw new Error('不应该发请求'); };
try {
await assert.rejects(
() => sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
content: ' ',
}),
/内容为空/
);
assert.equal(touched, 0);
} finally {
global.fetch = original;
}
});
test('发文本:钉钉 errcode 原样透出(310000 关键词/加签不匹配是最常见的坑)', async () => {
const stub = stubFetch({ body: { errcode: 310000, errmsg: 'sign not match' } });
try {
await assert.rejects(
() => sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
secret: 'SECwrong',
content: '【仓储物流转运异常】测试',
}),
(error) => {
assert.equal(error.code, 310000);
assert.match(error.message, /310000/);
assert.match(error.message, /sign not match/);
return true;
}
);
} finally {
stub.restore();
}
});
test('发文本:超长正文自动截断后仍会发出去(不是直接抛错)', async () => {
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
try {
const huge = '异常'.repeat(12000); // 24000 字 ≈ 72000 字节,远超上限
const result = await sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
content: huge,
});
assert.equal(result.truncated, true);
assert.ok(result.bytes <= sender.MAX_CONTENT_BYTES);
const body = JSON.parse(stub.calls[0].options.body);
assert.ok(Buffer.byteLength(body.text.content, 'utf8') <= sender.MAX_CONTENT_BYTES);
} finally {
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 条不能糊成「全部正常」');
});