业务 2026-10-28 下午拿 9837824 指出:页面「创建时间」显示 10-19 23:44, 而该单实际是 10-28 09:58:42 才审核。逐字段核对确认起算点口径错了 —— 上午那版按 jst_Orders.created 算,等于把客服审核积压算到仓库头上。 - 起算点 = 状态表 last_oa_confirm_time(业务选「最后审核时间」); 取不到审核时间的单回退 created,页面 / 导出 / 钉钉该列打 * - 已发判定补一条兜底:订单表 send_date 空、但状态表 send_time 有值的按已发剔除 (实测 144 单里 6 单属此类,且有揽收轨迹) - 明细与导出列改为「审核时间 / 下单时间 / 发货时间」并列,去掉旧「创建时间」列 - 钉钉明细第五段由「创建」改回「审核」;抬头「核验 XX 前创建」改「前审核」 - 回归测试同步改断言;stub 增加审核时间查询分支(默认空 = 全量回退 created) 实测(10-28 13:00 轮):599 → 144(按审核时间分轮)→ 138(剔除漏回写已发单)。 599 单里 376 单是当天 08:10~10:30 才批量审核的积压单,491 单其实已有运单号。 验收:后端 check 491/493(2 个失败为既有 Windows 权限 / TTL 用例,改动前同样失败); 前端 eslint 通过、build 23.92s 通过;本地真渲染 138 单、表头含「审核时间 / 下单时间」、 回退单带 *、无 pageerror。
295 lines
15 KiB
JavaScript
295 lines
15 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: '崔桥工厂仓',
|
||
logisticCompany: '武进韵达',
|
||
auditTime: '2026-10-27 06:32:11',
|
||
overdueMinutes: 388,
|
||
},
|
||
{
|
||
oId: '9855200',
|
||
soId: 'SO20261027001',
|
||
shopName: '天猫店',
|
||
warehouseName: '简尚-崔桥工厂仓',
|
||
centerName: '崔桥工厂仓',
|
||
logisticCompany: '常州安能新',
|
||
auditTime: '2026-10-26 09:05:00',
|
||
overdueMinutes: 2875,
|
||
},
|
||
{
|
||
oId: '9855300',
|
||
soId: '',
|
||
shopName: '抖音店',
|
||
warehouseName: '简尚-横林二仓',
|
||
centerName: '横林二仓',
|
||
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
|
||
logisticCompany: '',
|
||
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('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看是哪家快递)', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
|
||
assert.ok(line, '明细第 2 行不见了');
|
||
const parts = line.replace(/^\d+\.\s*/, '').split('|');
|
||
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
|
||
});
|
||
|
||
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);
|
||
});
|