业务原话(2026-10-27):「发货时效也需要钉钉通知:13 点核验 7 点前的超时单, 核验完成后通知;18:00 核验的订单,核验完成后也通知。」 ## 做了什么 - 新增 `warehouseShipTimelinessNotifySettings`:配置与状态存取,两个轮次各一个推送时刻 (NOON 默认 13:05 / AFTERNOON 默认 18:05),加签密钥走 resilientSecretStore。 - 新增 `warehouseShipTimelinessNotifyJob`:正文组装 + 每分钟 tick + 启动后 30 分钟补推窗口。 - 路由:`GET/PUT /api/warehouse-ship-timeliness/notify/settings`、 `GET .../notify/state`、`POST .../notify/run`(dryRun / probe / force)。 - `index.js` 注册 `startShipTimelinessNotifyScheduler()`。 - 前端:页面右上「钉钉通知」→ 弹窗(开关、两个时刻、webhook、加签密钥、明细条数、 @所有人、页面链接、受控预览块、上次推送)。顺带把 held 明细弹窗的 `destroyOnClose` 改成 `destroyOnHidden`(antd 5.25 已弃用)。 ## 口径要点 - **一天两推,各绑一个轮次**:13:00 轮核验「今天 07:00 前审核」、18:00 轮核验 「截止 16:00 前审核」(都含历史未发)。两轮互不影响,两个时刻配成同一个 HH:mm 时两轮都推。 - **默认核验点 + 5 分钟**,不是整点:状态镜像 `query_time` 要略晚于核验时刻, 整点推容易 `coversDeadline=false`(数据没覆盖核验点、超时数偏大)。 这种时候消息里会带一行提示,仍照发(业务要的是「核验完就通知」)。 - **去重键 `<北京日期>|<轮次键>`**,一天两轮各自独立;推送成功才写状态,失败不写。 - **0 单超时也推**(群里看到「本轮 0 单」= 任务没挂)。 - **明细一行 = 仓|内部单号|线上单号|审核 时间|已等 X 小时**; 历史单的审核时间带 MM-DD —— 13:00 轮含历史未发单,只写「审核 16:13」会被读成今天。 - 明细按仓聚集 + 每仓保底 1 条,钉钉 text 超长时从末尾砍。 ## 验证 - `backend/test/warehouseShipTimelinessNotify.test.js` 新增 17 条,全过。 - 后端 `check-syntax` 通过;`node --test test/*.test.js` 491 条 / 489 过 / 2 失败, 失败两条是既有环境问题(`cloudDatabaseConfigCache` 负缓存用例依赖数据库不可达、 `resilientSecretStore` 的 0600 权限断言在 Windows 恒为 0o666),与本改动无关。 - 前端 `eslint src/pages/WarehouseShipTimeliness src/api/warehouseShipTimeliness.js` 0 错; `vite build` 通过(含 `verify-router-lazy`)。 - 本地真渲染:页面右上出现「钉钉通知」,弹窗完整渲染、点「预览正文」出正文块,无 pageerror (验收时把 `/notify/**` 全部 mock —— 本地系统库与生产是同一个,避免写坏生产配置)。 - 本地实数据预览(13:00 轮,冷查 46s):超时 15 单 / 4 个仓 / 不计考核 672 / 剔除 1; 同一时刻请求 18:00 轮返回 `before_deadline`(未到 18:00 不推半成品)。 ## 风险与回退 - 默认关闭(`enabled=false`)且未配 webhook 时一切接口只返回 `not_configured`,不会发消息。 - 想彻底停掉:`DISABLE_WST_NOTIFY=true` 或把推送开关关掉;两者都不影响页面本身。 - 回退:删掉 `startShipTimelinessNotifyScheduler()` 一行即可停调度,新增文件无副作用。
283 lines
14 KiB
JavaScript
283 lines
14 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 「仓储中心 24 小时发货时效」两轮核验完成后推钉钉群 —— 正文组装与调度回归测试
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* 业务口径(2026-10-27):「13 点核验 7 点前的超时单,核验完成后通知;
|
||
* 18:00 核验的订单,核验完成后也通知。」⟹ 一天两推,各绑一个轮次。
|
||
*
|
||
* 这里只测五件容易悄悄坏掉的事:
|
||
* ① 两个轮次各自的推送时刻能不能正确命中(含「配成同一时刻」的边界);
|
||
* ② 群消息正文的数字与明细(业务照着这条消息追单,数字错了没人能发现);
|
||
* ③ 0 单超时也要推(群里看到「本轮 0 单」= 任务没挂);
|
||
* ④ 明细按仓保底 + 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
||
* ⑤ 去重键必须「日期 + 轮次」,一天两轮互不干扰。
|
||
*
|
||
* ⚠️ 本文件不连数据库、不发网络请求,纯逻辑测试。
|
||
*/
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const job = require('../src/jobs/warehouseShipTimelinessNotifyJob');
|
||
const settings = require('../src/services/warehouseShipTimelinessNotifySettings');
|
||
|
||
function sampleData(overrides = {}) {
|
||
return {
|
||
round: { key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEnd: '2026-10-27 07:00:00' },
|
||
deadline: '2026-10-27 13:00:00',
|
||
date: '2026-10-27',
|
||
window: { start: '2026-10-07 00:00:00', end: '2026-10-27 07:00:00', includeHistory: true, boundary: 'audit' },
|
||
summary: {
|
||
overdueUnshipped: 3,
|
||
overdueCenterCount: 2,
|
||
oldestDays: 4,
|
||
heldCount: 5,
|
||
voidCount: 12,
|
||
carrierSkipped: 2,
|
||
},
|
||
centers: [
|
||
{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 2, oldestDays: 4 },
|
||
{ key: 'HL2', name: '横林二仓', overdueUnshipped: 1, oldestDays: 1 },
|
||
{ key: 'SN', name: '睢宁仓', overdueUnshipped: 0, oldestDays: 0 },
|
||
],
|
||
orders: [
|
||
{
|
||
oId: '9855123',
|
||
soId: '261021-301318827670255',
|
||
shopName: '京东旗舰店',
|
||
warehouseName: '简尚-崔桥工厂仓',
|
||
centerName: '崔桥工厂仓',
|
||
auditTime: '2026-10-27 06:32:11',
|
||
overdueMinutes: 388,
|
||
},
|
||
{
|
||
oId: '9855200',
|
||
soId: 'SO20261027001',
|
||
shopName: '天猫店',
|
||
warehouseName: '简尚-崔桥工厂仓',
|
||
centerName: '崔桥工厂仓',
|
||
auditTime: '2026-10-26 09:05:00',
|
||
overdueMinutes: 2875,
|
||
},
|
||
{
|
||
oId: '9855300',
|
||
soId: '',
|
||
shopName: '抖音店',
|
||
warehouseName: '简尚-横林二仓',
|
||
centerName: '横林二仓',
|
||
auditTime: '',
|
||
overdueMinutes: 120,
|
||
},
|
||
],
|
||
freshness: { stateMaxQuery: '2026-10-27 13:04:00', coversDeadline: true, stale: false },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function config(overrides = {}) {
|
||
return {
|
||
enabled: true,
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test',
|
||
webhookSecret: '',
|
||
noonSendTime: '13:05',
|
||
afternoonSendTime: '18:05',
|
||
maxDetailRows: 30,
|
||
atAll: false,
|
||
pageUrl: 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
/** 构造北京时间某个时刻(本机是东八区,直接 new Date(本地字段) 即可) */
|
||
function bj(year, month, day, hour, minute) {
|
||
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
||
}
|
||
|
||
// ── ① 两个轮次各自的推送时刻 ──────────────────────────────────────────────
|
||
test('两个轮次按各自配置的时刻命中', () => {
|
||
const cfg = config();
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON']);
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 18, 5)), ['AFTERNOON']);
|
||
// 非命中时刻一律不推(免得每分钟都轰炸一次)
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 6)), []);
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 12, 59)), []);
|
||
});
|
||
|
||
test('两个时刻配成同一个 HH:mm 时,两轮都要推(不能只推第一轮)', () => {
|
||
const cfg = config({ noonSendTime: '13:05', afternoonSendTime: '13:05' });
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON', 'AFTERNOON']);
|
||
});
|
||
|
||
test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补', () => {
|
||
const cfg = config();
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: true }), ['NOON']);
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 40), { catchUp: true }), []);
|
||
// 关掉补推时,错过就错过
|
||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: false }), []);
|
||
});
|
||
|
||
// ── ② 正文内容 ────────────────────────────────────────────────────────────
|
||
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||
assert.match(built.text, /【仓储中心发货时效】13:00 轮/);
|
||
assert.match(built.text, /核验 07:00 前审核/);
|
||
assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/);
|
||
assert.equal(built.rowCount, 3);
|
||
});
|
||
|
||
test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||
assert.match(built.text, /按仓(超时单):/);
|
||
assert.match(built.text, /1\. 崔桥工厂仓 2 单(最久 4 天)/);
|
||
assert.match(built.text, /2\. 横林二仓 1 单(最久 1 天)/);
|
||
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
|
||
});
|
||
|
||
test('明细一行 = 仓|内部单号|线上单号|审核 时间|已等', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|审核 06:32|已等 6 小时 28 分/);
|
||
// 无线上单号 → 「—」;无审核时间 → 「未审核」;都没有时不能出现 undefined
|
||
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|审核 未审核|已等 2 小时/);
|
||
assert.doesNotMatch(built.text, /undefined|NaN/);
|
||
});
|
||
|
||
test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
|
||
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|审核 10-26 09:05|已等 1 天 23 小时/);
|
||
// 同一天的就只给 HH:mm,别啰嗦
|
||
assert.match(built.text, /审核 06:32/);
|
||
assert.doesNotMatch(built.text, /审核 10-27 06:32/);
|
||
// 直接测函数本身的跨天判定
|
||
assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32');
|
||
assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13');
|
||
assert.equal(job.auditWhen('', '2026-10-27'), '未审核');
|
||
});
|
||
|
||
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||
assert.match(built.text, /另有 5 单不计考核/);
|
||
assert.match(built.text, /已剔除 2 单不考核快递/);
|
||
assert.doesNotMatch(built.text, /⚠️/);
|
||
|
||
const stale = job.buildNotifyText(sampleData({
|
||
freshness: { stateMaxQuery: '2026-10-27 12:31:00', coversDeadline: false, stale: true },
|
||
}), { maxDetailRows: 30 });
|
||
assert.match(stale.text, /⚠️ 数据快照只到 12:31/);
|
||
assert.match(stale.text, /尚未覆盖本轮核验时刻 13:00/);
|
||
assert.match(stale.text, /超时单量偏大/);
|
||
});
|
||
|
||
// ── ③ 0 单也要推 ──────────────────────────────────────────────────────────
|
||
test('本轮 0 单超时:发「全部按时发出」,不带空明细块', () => {
|
||
const built = job.buildNotifyText(sampleData({
|
||
summary: { overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, heldCount: 0, voidCount: 3, carrierSkipped: 0 },
|
||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 0, oldestDays: 0 }],
|
||
orders: [],
|
||
}), { maxDetailRows: 30 });
|
||
assert.match(built.text, /超时未发货 0 单,涉及 0 个仓/);
|
||
assert.match(built.text, /本轮没有超时未发货的订单,全部按时发出/);
|
||
assert.doesNotMatch(built.text, /明细前/);
|
||
assert.doesNotMatch(built.text, /查看全部/);
|
||
assert.equal(built.rowCount, 0);
|
||
assert.equal(built.listedCount, 0);
|
||
});
|
||
|
||
// ── ④ 明细额度:按仓保底 + 超长精简 ───────────────────────────────────────
|
||
test('明细额度按仓保底:一个仓吃不满额度,其它仓至少 1 条', () => {
|
||
const many = [];
|
||
for (let i = 0; i < 40; i += 1) {
|
||
many.push({ oId: `A${i}`, soId: '', centerName: '崔桥工厂仓', warehouseName: '简尚-崔桥工厂仓', overdueMinutes: 600 + i });
|
||
}
|
||
for (let i = 0; i < 2; i += 1) {
|
||
many.push({ oId: `B${i}`, soId: '', centerName: '横林二仓', warehouseName: '简尚-横林二仓', overdueMinutes: 300 });
|
||
}
|
||
const picked = job.pickDetailRows(many, 6);
|
||
assert.equal(picked.length, 6);
|
||
// 每仓保底 1 条,剩余额度按各仓条数比例分(40:2 ⇒ 5:1)
|
||
assert.equal(picked.filter((row) => row.centerName === '横林二仓').length, 1);
|
||
assert.equal(picked.filter((row) => row.centerName === '崔桥工厂仓').length, 5);
|
||
// 组间按仓聚集:条数多的仓排前面,所以首行是崔桥
|
||
assert.equal(picked[0].centerName, '崔桥工厂仓');
|
||
});
|
||
|
||
test('明细条数 0 = 只发汇总不带明细', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
||
assert.equal(built.listedCount, 0);
|
||
assert.match(built.text, /明细共 3 条,消息里不放/);
|
||
});
|
||
|
||
test('超长时从明细末尾往前砍,砍到能发为止', () => {
|
||
// 故意把线上单号拉长(真实数据没见过,但业务改过列宽/加字段后有可能),
|
||
// 保证 50 条明细 + 分仓块必然超过 20000 字节上限
|
||
const rows = [];
|
||
for (let i = 0; i < 300; i += 1) {
|
||
rows.push({
|
||
oId: `98${String(i).padStart(6, '0')}`,
|
||
soId: `SO-2026-10-27-${String(i).padStart(6, '0')}-${'X'.repeat(360)}`,
|
||
centerName: '崔桥工厂仓',
|
||
warehouseName: '简尚-崔桥工厂仓',
|
||
auditTime: '2026-10-27 06:32:11',
|
||
overdueMinutes: 400 + i,
|
||
});
|
||
}
|
||
const built = job.buildNotifyText(sampleData({
|
||
summary: { overdueUnshipped: 300, overdueCenterCount: 1, oldestDays: 4, heldCount: 0, voidCount: 0, carrierSkipped: 0 },
|
||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 300, oldestDays: 4 }],
|
||
orders: rows,
|
||
}), { maxDetailRows: 50 });
|
||
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, `bytes=${built.bytes} 超上限`);
|
||
assert.equal(built.truncated, true);
|
||
assert.ok(built.listedCount < 50);
|
||
assert.match(built.text, /其余 \d+ 条见页面/);
|
||
});
|
||
|
||
// ── ⑤ 去重键 ──────────────────────────────────────────────────────────────
|
||
test('去重键是「日期|轮次」,两轮互不覆盖', () => {
|
||
const noon = settings.buildPushSlot('2026-10-27', 'NOON');
|
||
const afternoon = settings.buildPushSlot('2026-10-27', 'AFTERNOON');
|
||
assert.equal(noon, '2026-10-27|NOON');
|
||
assert.equal(afternoon, '2026-10-27|AFTERNOON');
|
||
assert.notEqual(noon, afternoon);
|
||
// 小写也归一
|
||
assert.equal(settings.buildPushSlot('2026-10-27', 'noon'), '2026-10-27|NOON');
|
||
assert.equal(settings.buildPushSlot('', 'NOON'), '');
|
||
});
|
||
|
||
test('推送时刻归一化:非法值退回默认,不抛错', () => {
|
||
assert.equal(settings.normalizeSendTime('13:05'), '13:05');
|
||
assert.equal(settings.normalizeSendTime('8:5'), '13:05');
|
||
assert.equal(settings.normalizeSendTime('25:00'), '13:05');
|
||
assert.equal(settings.normalizeSendTime(''), '13:05');
|
||
assert.equal(settings.normalizeSendTime('', '18:05'), '18:05');
|
||
assert.equal(settings.DEFAULT_SEND_TIME_NOON, '13:05');
|
||
assert.equal(settings.DEFAULT_SEND_TIME_AFTERNOON, '18:05');
|
||
});
|
||
|
||
test('超长文案格式化:分钟 / 小时 / 跨天', () => {
|
||
assert.equal(job.formatOverdue(30), '30 分钟');
|
||
assert.equal(job.formatOverdue(120), '2 小时');
|
||
assert.equal(job.formatOverdue(388), '6 小时 28 分');
|
||
assert.equal(job.formatOverdue(1500), '1 天 1 小时');
|
||
assert.equal(job.formatOverdue(0), '—');
|
||
assert.equal(job.formatOverdue(null), '—');
|
||
});
|
||
|
||
test('页面链接带上轮次参数,业务点进去看的就是这一轮', () => {
|
||
const link = job.buildPageLink('https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness', 'AFTERNOON');
|
||
assert.equal(link, 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness?round=AFTERNOON');
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||
assert.match(built.text, /\?round=NOON/);
|
||
});
|
||
|
||
test('轮次元信息:13:00 轮核验 07:00、18:00 轮核验 16:00', () => {
|
||
const noon = job.resolveRoundMeta('NOON', bj(2026, 10, 27, 13, 5));
|
||
const afternoon = job.resolveRoundMeta('AFTERNOON', bj(2026, 10, 27, 18, 5));
|
||
assert.equal(noon.deadline, '2026-10-27 13:00:00');
|
||
assert.equal(noon.windowEnd, '2026-10-27 07:00:00');
|
||
assert.equal(afternoon.deadline, '2026-10-27 18:00:00');
|
||
assert.equal(afternoon.windowEnd, '2026-10-27 16:00:00');
|
||
assert.equal(job.resolveRoundMeta('UNKNOWN', bj(2026, 10, 27, 13, 5)), null);
|
||
});
|