cangku/backend/test/warehouseShipTimelinessNotify.test.js
yanhui e7e0b96b46 fix(wst): 起算点改回「最后审核时间」,并剔除订单表漏回写的已发单
业务 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。
2026-10-28 15:34:29 +08:00

295 lines
15 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';
/**
* 「仓储中心 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);
});