'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); });