cangku/backend/test/warehouseShipTimelinessNotify.test.js
yanhui 776a2cbde2 feat(wst): 发货时效改用订单两表 + 起算点改为订单创建时间
业务 2026-10-28 定版:「核验抓取的销售订单表是错误的,应该在数据库抓
jst_Orders(订单)+ jst_OrderItem(明细),审核时间就是 jst_Orders 里的创建时间」。

## 为什么换数据源(状态镜像表的硬伤,均有实测)

1. `send_time` 漏回写:近 7 天「状态表说发货中+未发」的 4157 单里,
   订单表已有 `send_date` 的 563 单、status 已是 Sent 的 492 单 —— 发了还算超时。
2. 被合并 / 拆单父单不回写:89 + 755 单被冤枉。
3. 会整批停更:2026-10-27 11:54:59 之后 6.8 小时零写入,18:00 轮推出的
   34 单里混着已发货的。
4. 两张表发货时间其实同源:27387 单逐条比对**零偏差** ⟹ 订单表才是权威。

## 改了什么

后端
- 主查询改打 `rpt.jst_Orders` + `rpt.jst_qmcust_orderlist`(店铺互斥,UNION ALL,
  按 o_id 去重),SQL 侧过滤 `send_date IS NULL` + `status IN (Delivering/WaitConfirm/Question)`。
- 起算点由状态表 `last_oa_confirm_time` 改为订单表 `created`;
  实测 created 100% 早于审核时间,正常单差 7~40 分钟,夜间下单次日审的单差 6~9 小时
  —— 换口径后单量与「已等时长」都会变大,业务已知悉。
- 仓库归属:主查询只回 `wms_co_id`,Node 侧用仓表缓存换仓名再走 `resolveCenter()`
  (实测 SQL 里 LEFT JOIN 仓表会把查询从 28s 拖到 61~105s)。
- `OPTION (RECOMPILE)` + 时间字面量:qmcust 不加 RECOMPILE 实测 33~50s,加上 0.4s。
- 删除 `backfillOrderFacts()` 与 `mapLimit()`(起算点/状态/仓名已由主查询一次取全)。
- 不再按 `is_split / is_merge` 过滤 —— 实测 `is_split=1` 恰是拆单**子单**
  (同一 so_id 多行、各自要发货,近 30 天 1096 行),老逻辑 `is_split=0` 会把它们全排掉。
- 钉钉明细第 5 段「审核 HH:mm」→「创建 HH:mm」,无值文案「未审核」→「无创建时间」。

前端
- 明细/挂起清单/导出列「审核时间」→「创建时间」,Tooltip 与说明文案同步改写;
- 顶部说明补「数据源 = 订单两表」「已发未发取 send_date」;
- 「已支付待审核」统一为「待审核」(新数据源里就是 WaitConfirm)。

测试
- 两个测试文件的桩改成「订单两表 UNION」的返回形状,断言改为
  created 起算、候选状态白名单、SQL 必须带 RECOMPILE / 不含状态镜像表 / 不 JOIN 仓表。

## 验证

- `npm --prefix backend run check`:491/493 通过。2 项失败是既有环境问题:
  `resilientSecretStore`(Windows 文件权限 0o666≠0o600)、
  `cloudDatabaseConfigCache` 负缓存 TTL(依赖隧道状态),均与本改动无关,单独跑同样失败。
- 前端 eslint + build 通过。
- 真实库:NOON 1553 单 / AFTERNOON 1866 单(本地 2026-10-28),
  数据质量:无仓名 0 / 时间异常 0 / 含 undefined|null|NaN 0;
  单次查询耗时 35~38s(30 天护栏,冷缓存)。
- 浏览器真渲染(playwright,强制 round=NOON):
  表头 `…发货时间 / 创建时间 / 付款时间…`、轮次提示「核验今天 07:00 前创建的订单」、
  「导出明细」导出 CSV 表头含「创建时间」共 1551 行数据 + 1 行表头,无 pageerror。

## 风险与回退

- 口径变化是可感知的:单量上升,夜间下单/次日审核的单「已等时长」增加 6~9 小时,
  属业务确认后的预期结果。
- 状态表「售后已作废」判据随换源放弃(实测在「发货中+未发」里只命中 4 单),
  这类单现在按正常单算超时;如业务反馈,可单独补查。
- 回退:单文件回滚本提交即可,无数据迁移、无配置变更。
2026-10-28 11:34:45 +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);
});