业务 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 单), 这类单现在按正常单算超时;如业务反馈,可单独补查。 - 回退:单文件回滚本提交即可,无数据迁移、无配置变更。
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);
|
||
});
|