① 导出明细(业务要求:超时未发要能导出核对)
· 新增 GET /api/warehouse-ship-timeliness/export:不分页、不截断,返回本轮全部行
· 页面「超时未发货订单」卡片右上角加「导出明细」下拉(Excel / CSV)
· 选中某个仓时只导该仓(前端按 centerKey 过滤,与后端主仓归属同一套)
· 导出列比页面多「快递公司 / 运单号」(追单要看有没有打单),单号一律转字符串
避免 Excel 显示成科学计数法
② 缓存改成「存全量、出口截断」
· 缓存键去掉 limit(原为企业级教训:WLE 核验缓存早就这么改了)
· 页面 limit=1000、导出要全量,旧实现两边永不命中 → 每点一次导出重查一遍视图
· 实测:首次查询 86.3s,导出命中缓存 5ms
· 顺带修 detailTotal / heldTotal:明细被截断时页面仍显示真实总数,
不再把「只显示前 1000 条」写成「一共就这么点」
③ 不考核快递追加「武进安能」(业务 2026-10-27)
· 包含匹配,同时覆盖「武进安能大件」
· ⚠️ 严格不泛化成「安能」:实测库里带「安能」的有 8 个写法,
常州安能新 4207 单 / 安能物流 110 单等仍照旧考核(与
warehouseLogisticsExceptionRegistry 同一口径,那里也写着这句)
④ 钉钉推送明细行在「线上单号」后加「快递」
· 一行 = 仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
· 群里直接能判断是哪家快递、要不要走站点,不用回页面逐单点开
· 「已剔除 N 单不考核快递」的快递清单改成读服务层,别再各写一份
⑤ 修复推送任务时区(生产容器 /etc/timezone = Etc/UTC)
· 症状:pushShipTimelinessNotify 把裸 new Date() 传给 getShipTimeliness,
而 service / registry 内部用 getDate()/getHours() 直读 →
北京 13:05 = UTC 05:05,被判「未到 13:00 核验时间」→ 两轮推送永不触发
· 改法:rawNow(真实时刻,给 Intl / toISOString)+ bjNow(平移后,
给本地 getter 与 service 调用)双轨,与 2026-10-21 那次同源问题的修法一致
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);
|
||
});
|