cangku/backend/test/warehouseShipTimeliness.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

622 lines
35 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.

/**
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-28 第九版:换数据源 + 换起算点)
*
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
* ③ 🔥 数据源必须是**订单两表** `jst_Orders` + `jst_qmcust_orderlist`(业务 2026-10-28:
* 「销售订单表是错误的,应该在数据库抓 jst_Orders + jst_OrderItem」)。
* 原来打的状态镜像表 `jst_order_express_details` 有三处硬伤(send_time 漏回写 /
* 合并拆单不回写 / 整批停更 6.8 小时),已废弃;
* 两张订单表**店铺互斥**(交集 0),必须 UNION ALL 都读,按 o_id 去重;
* ④ 🔥 起算点 = `jst_Orders.created`(**订单创建时间**)。
* 业务原话:「审核时间 jst_Orders 是这个表里面的创建时间」。
* 实测 created 100% 早于状态表的 last_oa_confirm_time:正常单差 7~40 分钟,
* 夜间下单次日才审的单差 6~9 小时 —— 所以换口径后单量与时长都会变大,这是**预期**;
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时;
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核(WaitConfirm)·异常(Question)=不计考核;
* 作废单(labels 含「发货前售后」)不考核;
* ⑦ 性能护栏:订单两表只允许 **1 次** querySlow,不得 JOIN 仓表、不得投影宽列;
* ⑧ **不按 is_split / is_merge 过滤** —— 实测 is_split=1 是拆单**子单**(要发货的),
* 按老逻辑 is_split=0 会把近 30 天 1096 行真单排掉。
*
* ⚠️ 本文件**不连数据库**,纯逻辑测试(注入假 cloudDatabase)。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const registry = require('../src/services/warehouseShipTimelinessRegistry');
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
});
test('resolveCenter 能捞到各种命名的子仓', () => {
const cases = [
['简尚-运营C横林二仓专用仓', 'HL2'],
['简尚-横林二仓京东仓', 'HL2'],
['京仓-横林二仓京东自营仓', 'HL2'],
['简尚-崔桥工厂仓唯品仓', 'CQ'],
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
['简尚-新苏北临时仓', 'XSB'],
['简尚-分销部睢宁专用仓', 'SN'],
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
['简尚-河南洛宁仓', 'HN_LN'],
];
for (const [name, expected] of cases) {
assert.equal(registry.resolveCenter(name)?.key, expected, name);
}
});
test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => {
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null);
// ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段
assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
});
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
const shouldBeNull = [
'工厂-山东菏泽森美代发仓',
'工厂-山东恒思代发仓',
'工厂-河南千优代发仓',
'工厂-河北霸州派雅世佳代发仓',
'简尚-苏州福利品仓',
];
for (const name of shouldBeNull) {
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
}
});
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
assert.ok(c, 'CQ_WIP 必须存在');
assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓');
assert.equal(c.name, '崔桥工厂半成品包装项目仓');
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND');
});
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
});
test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => {
for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) {
const r = registry.resolveActiveRound(at(h, m));
assert.ok(r, `${h}:${m} 应已进入首轮核验`);
assert.equal(r.key, 'NOON');
// 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它)
assert.equal(r.deadline, '2026-10-20 13:00:00');
assert.equal(r.verifyHour, 13);
// 订单窗口上界 = 07:00(只核验该时刻前创建的单)
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值');
assert.equal(r.includeHistory, true);
assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00');
assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
}
});
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
const r = registry.resolveActiveRound(at(18, 0));
assert.equal(r.key, 'AFTERNOON');
assert.equal(r.deadline, '2026-10-20 18:00:00');
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
});
test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => {
const rounds = registry.describeRounds(at(16, 5));
assert.deepEqual(
rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]),
[['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']]
);
assert.equal(rounds[0].active, true);
assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮');
assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON');
});
test('创建晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => {
const [noon, afternoon] = registry.describeRounds(at(16, 5));
assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00');
assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00',
'18:00 是当天最后一轮 → 次日 13:00');
// 跨月边界也要对
assert.equal(
registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }),
'2026-11-01 13:00:00'
);
});
// ── ③ 常量口径 ──────────────────────────────────────────────────────────
test('数据源必须是订单两表,不再是状态镜像表', () => {
assert.deepEqual(
[...registry.ORDER_SOURCE_TABLES],
['jst_Orders', 'jst_qmcust_orderlist'],
'两张订单表店铺互斥(交集 0),必须都读'
);
// 状态表的常量还留着(兜底 / 老代码引用),但服务层不许再用它取候选
assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
});
test('候选状态白名单:发货中 / 待审核 / 异常 三种', () => {
assert.equal(registry.ORDER_PENDING_STATUS, 'Delivering');
assert.equal(registry.ORDER_UNAUDITED_STATUS, 'WaitConfirm');
assert.equal(registry.ORDER_QUESTION_STATUS, 'Question');
assert.equal(registry.ORDER_SHIPPED_STATUS, 'Sent');
assert.deepEqual(
[...registry.ORDER_CANDIDATE_STATUSES],
['Delivering', 'WaitConfirm', 'Question'],
'Sent / Cancelled / Split / Merged 一律不进候选'
);
// 中文状态只是给人看的
assert.equal(registry.SHIPPING_STATUS, '发货中');
assert.equal(registry.UNAUDITED_STATUS, '已支付待审核');
assert.equal(registry.ABNORMAL_STATUS, '异常');
// 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时
assert.equal(registry.SHIP_TIMELINESS_HOURS, 24);
});
test('起算点是 orders.created,且只从订单两表取', () => {
assert.deepEqual(
[...registry.CREATED_SOURCE_TABLES],
['jst_Orders', 'jst_qmcust_orderlist'],
'两张订单表都要查(店铺互斥,交集为 0)'
);
assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '常量保留(分批 IN 的批量上限)');
});
// ── ④ 服务层分桶(注入假查询,不连库)──
function stubDb(dbPath, scenario = 'default', oaRows = []) {
const WMS = {
CQ: 15038426,
SD_QGJ: 15137523,
SD_BRAND: 15182705,
HN_LN: 15539084,
JD_CLOUD: 99999901,
};
const warehouseRows = [
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: WMS.CQ },
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: WMS.SD_QGJ },
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: WMS.SD_BRAND },
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: WMS.HN_LN },
// 业务指定排除的京东云仓:仓表里有它,但 resolveCenter 返回 null
{ Id: 5, name: '京仓-横林二仓京东云仓', wms_co_id: WMS.JD_CLOUD },
// 代发仓:归不到主仓 → 计入 unknownWarehouse
{ Id: 6, name: '工厂-山东菏泽森美代发仓', wms_co_id: 88880001 },
];
// 订单行 = 主查询的投影(SQL 已经按 send_date IS NULL + status IN 过滤过)
const o = (id, wmsCoId, created, extra = {}) => ({
o_id: id,
so_id: `SO-${id}`,
shop_name: '测试店',
status: 'Delivering',
created,
pay_date: created,
logistics_company: '',
l_id: '',
presaleVoid: 0,
src: 'jst_Orders',
updated_at: '2026-10-20 18:02:00',
wms_co_id: wmsCoId,
...extra,
});
const defaultRows = [
// 1:创建 09-25 00:50 → 到 18:00 核验 = 25 天 17 小时 = 37020 分,超时
o(1, WMS.CQ, '2026-09-25 00:50:00', { pay_date: '2026-09-24 23:50:00' }),
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验
o(2, WMS.CQ, '2026-10-20 16:20:00'),
// 3:没创建时间(真实 SQL 里 created >= 30 天护栏会挡掉,这里测 JS 兜底分支)→ 不计考核
o(3, WMS.CQ, null, { pay_date: '2026-09-26 00:10:00' }),
// 4:WaitConfirm 待审核 → 不计考核
o(4, WMS.CQ, '2026-09-23 01:00:00', { status: 'WaitConfirm' }),
// 5:Question 异常 → 不计考核
o(5, WMS.CQ, '2026-09-23 01:05:00', { status: 'Question' }),
// 6:创建 17:00 晚于 16:00 → 本轮不核验
o(6, WMS.CQ, '2026-10-20 17:00:00'),
// 7:京东云仓(业务排除)→ 不计入任何主仓
o(7, WMS.JD_CLOUD, '2026-09-25 00:10:00'),
// 8:与 1 同 so_id + 同创建时间(拆包多行)→ 必须去重成 1 单
o(1, WMS.CQ, '2026-09-25 00:50:00', { pay_date: '2026-09-24 23:50:00', so_id: 'SO-1' }),
// 13:创建 15:00 → 18:00 只等 3 小时,也必须算超时(无 24h 门槛)
o(13, WMS.CQ, '2026-10-20 15:00:00'),
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
o(31, WMS.SD_QGJ, '2026-10-20 15:30:00'), // 晚于 15:00 上界 → 不核验
o(32, WMS.SD_BRAND, '2026-10-20 14:30:00'), // 早于 15:00 上界 → 核验(210 分)
o(33, WMS.HN_LN, '2026-10-20 13:30:00'), // 晚于 13:00 上界 → 不核验
o(34, WMS.HN_LN, '2026-10-20 12:30:00'), // 早于 13:00 上界 → 核验(330 分)
// 26:昨天中午创建、至今未发 → 历史未发池,超时 3240 分
o(26, WMS.CQ, '2026-10-18 12:00:00'),
// 25:前天一早就创建里挂着 → 超时 1920 分
o(25, WMS.CQ, '2026-10-19 10:00:00'),
// 35/36/41/42:不考核快递 —— 状态/时间都够超时,靠快递名剔除
o(35, WMS.CQ, '2026-09-25 01:00:00', { logistics_company: '中铁物流' }),
o(36, WMS.CQ, '2026-09-25 02:00:00', { logistics_company: '壹米滴答专线' }),
o(41, WMS.CQ, '2026-09-25 03:00:00', { logistics_company: '武进安能' }),
o(42, WMS.CQ, '2026-09-25 04:00:00', { logistics_company: '武进安能大件' }),
// 38:Question(聚水潭后台「已作废/问题单」)→ 不计考核
o(38, WMS.CQ, '2026-10-20 09:10:00', { status: 'Question' }),
// 39:labels 含「发货前售后」(SQL 已折成布尔)→ 作废,进 voidCount
o(39, WMS.CQ, '2026-10-20 10:10:00', { presaleVoid: 1 }),
// 40:创建 11:10 → 410 分。⚠️ 状态表那条「售后已作废」判据已随数据源切换放弃
// (实测只命中 4 单,不值得为它多扫 402 万行),所以这类单现在按正常单算超时
o(40, WMS.CQ, '2026-10-20 11:10:00'),
// ── 以下由 SQL 过滤掉(status 不在白名单 / send_date 有值),
// 这里照样塞进最后端,验证「SQL 过滤 + JS 兜底」两道闸都不会漏 ──
o(9, WMS.CQ, '2026-09-21 08:00:00', { status: 'Cancelled' }),
o(10, WMS.CQ, '2026-09-21 09:59:00', { status: 'Merged', send_date: '2026-09-22 06:54:49' }),
o(11, WMS.CQ, '2026-09-21 10:59:00', { send_date: '2026-09-22 08:00:00' }),
o(37, WMS.CQ, '2026-09-22 08:50:00', { status: 'Sent' }),
// 拆单子单(is_split=true 也要算,别按 is_split 过滤)
o(43, WMS.CQ, '2026-10-20 13:00:00', { is_split: true }),
];
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
const noonRows = [
o(21, WMS.CQ, '2026-10-20 06:45:00', { updated_at: '2026-10-20 11:18:27' }), // 375 分
o(22, WMS.CQ, '2026-10-20 05:55:00', { updated_at: '2026-10-20 11:18:27' }), // 425 分
o(23, WMS.CQ, '2026-10-19 19:55:00', { updated_at: '2026-10-20 11:18:27' }), // 1025 分
o(24, WMS.CQ, '2026-10-20 07:30:00', { updated_at: '2026-10-20 11:18:27' }), // 晚于 07:00 → 不核验
o(25, WMS.CQ, '2026-10-19 10:00:00', { updated_at: '2026-10-20 11:18:27' }), // 1620 分
o(26, WMS.CQ, '2026-10-18 12:00:00', { updated_at: '2026-10-20 11:18:27' }), // 2940 分
];
const rows = scenario === 'noon' ? noonRows : defaultRows;
const calls = { orders: 0, oaConfirm: 0, warehouse: 0, sql: [], params: [] };
const handler = async (sql, params) => {
const text = String(sql);
if (/jst_warehouseInfo/u.test(text)) {
calls.warehouse += 1;
return warehouseRows;
}
// 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间,
// 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 oaRows
if (/jst_order_express_details/u.test(text)) {
calls.oaConfirm += 1;
return oaRows;
}
calls.orders += 1;
calls.sql.push(text);
calls.params.push(params || {});
// 模拟 SQL 侧过滤:send_date IS NULL 且 status 在白名单里
return rows.filter((r) => r.send_date == null
&& ['Delivering', 'WaitConfirm', 'Question'].includes(r.status));
};
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: { query: handler, querySlow: handler },
};
return { calls, WMS };
}
function withStubbedService(fn, scenario = 'default', oaRows = []) {
return async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const ctx = stubDb(dbPath, scenario, oaRows);
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
try {
const svc = require('../src/services/warehouseShipTimelinessService');
await fn(svc, ctx);
} finally {
if (realDb) require.cache[dbPath] = realDb;
else delete require.cache[dbPath];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
}
};
}
test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => {
// 18:05 → 18:00 轮:窗口上界 16:00(按创建时间),核验时刻 18:00
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
const c = data.centers.find((x) => x.key === 'CQ');
assert.ok(c, '应能归到崔桥工厂仓');
// 崔桥工厂仓超时 = 1(09-25) + 13(15:00) + 25(10-19 10:00) + 26(10-18 12:00) + 40(11:10) + 43(13:00)
// 2/6 创建晚于 16:00、3 没创建时间、4/5/38 不计考核、7 非主仓、8 去重
assert.equal(c.overdueUnshipped, 6, '1、13、25、26、40、43 六单算超时');
assert.equal(c.oldestDays, 25, '最久一单 09-25 00:50 创建,到 18:00 核验是 25 天');
assert.equal(c.heldCount, 4, '待审核 1 + 异常 2 + 缺创建时间 1');
// 全局:崔桥 6 + 山东品牌 1 + 河南洛宁 1 = 8
assert.equal(data.summary.overdueUnshipped, 8);
assert.equal(data.summary.overdueCenterCount, 3);
assert.equal(data.summary.oldestDays, 25);
assert.equal(data.summary.heldCount, 4);
// 京东云仓归不到主仓 → unknownWarehouse(代发仓不在本场景行里,故只有 1 单)
assert.equal(data.summary.unknownWarehouse, 1);
// 明细:最久的排最前;起算点就是创建时间
assert.equal(data.orders.length, 8);
assert.equal(String(data.orders[0].oId), '1');
assert.equal(data.orders[0].auditTime, '2026-09-25 00:50:00', '审核时间 = 订单创建时间');
assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00');
assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00');
assert.equal(data.orders[0].benchmarkSource, 'created', '起算点必须是 created');
// 09-25 00:50 → 10-20 18:00 = 25 天 + 17 小时 10 分 = 37030 分钟
assert.equal(data.orders[0].overdueMinutes, 37030);
// 13 号单:15:00 创建 → 18:00 核验只等了 180 分钟,旧版 24h 门槛会漏,本版必须算
const fresh = data.orders.find((o) => String(o.oId) === '13');
assert.ok(fresh, '只等 3 小时也必须算超时(无 24h 门槛)');
assert.equal(fresh.overdueMinutes, 180);
assert.equal(fresh.waitedHours, 3);
// 26 号单:10-18 12:00 创建的老单 → 历史未发池,超时 3240 分
const stale = data.orders.find((o) => String(o.oId) === '26');
assert.ok(stale, '历史未发单必须进池');
assert.equal(stale.overdueMinutes, 3240);
// 43 号:拆单子单(is_split=true)也必须算 —— 别按 is_split 过滤
assert.ok(data.orders.some((o) => String(o.oId) === '43'),
'is_split=1 是拆单子单(真单),必须算超时');
// 3 号单没创建时间 → 归「不计考核」,原因写清楚
const noCreated = data.heldOrders.find((o) => String(o.oId) === '3');
assert.ok(noCreated, '缺起算点(审核时间与创建时间都没有)的单不能被静默丢掉');
assert.equal(noCreated.holdReason, '缺审核时间与创建时间');
// 创建时间晚于窗口上界 → 本轮不核验(2 号:16:20、6 号:17:00)
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验');
// ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)──
assert.ok(!data.orders.some((o) => String(o.oId) === '31'),
'山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '32'),
'山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '33'),
'河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '34'),
'河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验');
assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外');
assert.deepEqual(
data.window.overrides.map((o) => [o.key, o.hour]).sort(),
[['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]]
);
// 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例)
assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u);
// 被排除的仓不能算
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
assert.ok(!data.heldOrders.some((o) => String(o.oId) === '7'), '京东云仓也不进挂起清单');
// 9(已取消) / 10(Merged) / 11(已发) / 37(Sent) 都不能进任何清单
for (const id of ['9', '10', '11', '37']) {
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
}
// 作废(labels 发货前售后)只计 39 这一单;9/10/11/37 由 SQL 直接过滤,不在这张清单里
assert.equal(data.summary.voidCount, 1, 'only 39 (labels 发货前售后)');
// 不考核快递:中铁物流、壹米滴答专线、武进安能必须整单跳过
// (业务 2026-10-20 定前两个,2026-10-27 追加武进安能)
assert.equal(data.summary.carrierSkipped, 4, '中铁/壹米滴答/武进安能 两种写法 要计入 carrierSkipped');
assert.ok(
!data.orders.some((o) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(o.soId)),
'不考核快递的单不能出现在超时明细里'
);
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
// 🔥 「武进安能」只剔这一个网点,**不能**泛化成「安能」:
// 库里带「安能」的还有 常州安能新 / 安能物流 / 华韵安能 / 雅柏安能…(常州安能新单量最大)
assert.ok(
!svc._private.EXCLUDED_CARRIERS.some((k) => '常州安能新'.includes(k)),
'常州安能新必须照旧考核,别把关键词放大成「安能」'
);
assert.ok(
svc._private.EXCLUDED_CARRIERS.some((k) => '武进安能大件'.includes(k)),
'武进安能大件要能被「武进安能」包含命中'
);
// 不计考核清单
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
assert.deepEqual(
reasons,
['缺审核时间与创建时间', '待审核', '订单异常', '订单异常'].sort(),
'4 单:缺起算点 1 + 待审核 1 + 异常 2'
);
// 新鲜度:取自主查询的 MAX(updated_at)
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 18:02:00');
assert.equal(data.freshness.source, 'jst_Orders', '数据源已换成订单表');
assert.equal(data.freshness.ageMinutes, 3, '18:05 − 18:02 = 3 分钟');
assert.equal(data.freshness.stale, false, '3 分钟不算滞后');
assert.equal(data.freshness.coversDeadline, true, '18:02 已覆盖 18:00 核验时刻');
assert.equal(data.freshness.hint, '', '没异常就不要吓人');
// ── 性能 / 口径护栏 ──────────────────────────────────────────────────
assert.equal(calls.orders, 1, '订单两表只允许查一次(UNION ALL 合成一条 SQL)');
assert.equal(calls.warehouse, 1, '仓名映射查一次缓存表');
const sql = calls.sql[0];
assert.match(sql, /rpt\.jst_Orders\s+o/u, '主数据源必须含 jst_Orders');
assert.match(sql, /rpt\.jst_qmcust_orderlist\s+c/u, '第二张订单表必须 UNION ALL 进来');
assert.match(sql, /UNION ALL/u, '两张表是并集,不是 JOIN');
assert.match(sql, /o\.send_date IS NULL/u, '已发货靠 send_date 判');
assert.match(sql, /c\.send_date IS NULL/u, '第二张表也要判未发货');
assert.match(sql, /o\.status IN \(N'Delivering', N'WaitConfirm', N'Question'\)/u,
'候选状态白名单必须下推到 SQL');
assert.match(sql, /o\.created >= '/u, '窗口下界按创建时间卡(30 天护栏)');
assert.match(sql, /OPTION \(RECOMPILE\)/u,
'🔥 必须带 RECOMPILE —— qmcust 不加会被参数嗅探拖到 33~50s');
// 起算点列必须在投影里
assert.match(sql, /\bo\.created\b/u, '起算点必须取订单创建时间');
// 🔥 不许再 JOIN 仓表:实测 JOIN 让同一条查询从 28s 恶化到 61~105s
assert.doesNotMatch(sql, /JOIN\s+dbo\.jst_warehouseInfo/iu, '仓名在 Node 侧换,不许 JOIN 仓表');
// 🔥 不许再用状态镜像表取候选
assert.doesNotMatch(sql, /jst_order_express_details/u, '状态镜像表已废弃');
// 🔥 不许按 is_split / is_merge 过滤(is_split=1 是子单,要考的)
assert.doesNotMatch(sql, /is_split|is_merge/u, '拆单子单也要考核,不许拿这两个字段过滤');
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
assert.doesNotMatch(sql, /buyer_message|remark/u, '不能投影长文本列');
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
// labels 只允许出现在 LIKE 里折成布尔,不许直接投影原文
assert.match(sql, /CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END/u,
'labels 只用来判「发货前售后」,不外传原文');
assert.doesNotMatch(sql, /\blabels\s*,/u, 'labels 长文本不许直接投影');
assert.doesNotMatch(sql, /SELECT \*/u, '不许 SELECT *');
// ⚠️ 时间条件走**字面量**(已用正则校验过格式),不再走参数 —— 参数嗅探会走错计划
assert.match(sql, /created >= '2026-09-20 00:00:00'/u, '下界是近 30 天,写进字面量');
assert.deepEqual(calls.params[0], {}, '这条查询不带参数');
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
assert.equal(data.round.key, 'AFTERNOON');
assert.match(String(data.round.windowEnd), /16:00:00$/u);
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'oa_confirm', '已等时长按审核时间算');
}));
test('13:00 轮:只核验「今天 07:00 前审核」的单 + 历史未发', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
assert.equal(data.round.key, 'NOON');
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
// 24 号:今天 07:30 创建 → 晚于 7 点,不属于 13:00 轮
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
'今天 07:30 创建 → 不进 13:00 轮');
// 21 号:今天 06:45 创建 → 13:00 只等 375 分钟,必须超时(无 24h 门槛)
const a = data.orders.find((o) => String(o.oId) === '21');
assert.ok(a, '7 点前创建、13:00 未发就是超时');
assert.equal(a.benchmarkSource, 'created');
assert.equal(a.overdueMinutes, 375);
assert.equal(a.waitedHours, 6.3);
// 22 号:05:55 创建 → 425 分(旧口径按审核时间 06:40 算只有 380 分,换口径后更严)
const b = data.orders.find((o) => String(o.oId) === '22');
assert.equal(b.overdueMinutes, 425, '起算点提前到创建时间,已等时长跟着变长');
// 23 号:昨天创建、至今未发 → 历史未发池也要看到
const d = data.orders.find((o) => String(o.oId) === '23');
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
assert.equal(d.overdueMinutes, 1025);
// 13:00 轮 = 21 + 22 + 23 + 25 + 26 = 5 单
// (25/26 都是「今天之前创建、至今未发」,历史未发池两个轮次都算)
assert.equal(data.summary.overdueUnshipped, 5);
assert.equal(data.window.boundary, 'oa_confirm', '窗口按审核时间判(取不到回退创建时间)');
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
// 快照只到 11:18(早于 13:00 核验时刻)→ 必须标滞后 + 明确「没覆盖核验时刻」
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
assert.equal(data.freshness.stale, true, '107 分钟 > 90 分钟阈值,必须标 stale');
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时必须为 false');
assert.match(data.freshness.hint, /未刷新|没覆盖|未覆盖/u, '要给出人话提示');
}, 'noon'));
test('服务层 13:00 前返回 notStarted,且不去查库', withStubbedService(async (svc, { calls }) => {
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
assert.equal(data.notStarted, true);
assert.equal(data.round, null);
assert.equal(data.orders.length, 0);
assert.equal(data.summary.overdueUnshipped, 0);
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
assert.equal(data.centers.length, registry.CENTERS.length);
assert.ok(data.centers.every((x) => x.overdueUnshipped === 0));
assert.equal(calls.orders, 0, '未到核验时间不应查订单表');
assert.match(data.note, /13:00/u);
assert.equal(data.nextRoundAt, '2026-10-20 13:00:00');
assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00');
}));
// ── ⑧ 时区:生产容器是 UTC,库里时间是北京时间 ──────────────────────────
// 2026-10-21 实测:容器 /etc/timezone=Etc/UTC,mssql useUTC:false 把 GETDATE()
// 构造成「墙钟当 UTC」的 Date → 比真实 now 快 8 小时。北京时间 00:00~08:00
// 服务端取到的是「昨天」,13:00 轮因此把整个历史未发池算进来(3895 单 vs 本地 6 单)。
test('beijingNow:任何机器时区下读出来的本地时间都等于北京时间', () => {
// 真实时刻 2026-10-21T00:04:00Z = 北京时间 2026-10-21 08:04
const base = new Date('2026-10-21T00:04:00Z');
const bj = registry.beijingNow(base);
const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
+ `-${String(d.getDate()).padStart(2, '0')} `
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
assert.equal(stamp(bj), '2026-10-21 08:04', '平移后本地 getter 必须读出北京时间');
// 跨日场景:真实时刻 2026-10-20T23:48:00Z = 北京时间 2026-10-21 07:48(UTC 上还是「昨天」)
const midnight = registry.beijingNow(new Date('2026-10-20T23:48:00Z'));
assert.equal(stamp(midnight).slice(0, 10), '2026-10-21',
'北京时间 00:00~08:00 不能取成前一天');
});
test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器时区', () => {
// 北京时间 2026-10-21 07:48(UTC 上是 10-20 23:48)→ 还没到 13:00,应当 notStarted
const round = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-20T23:48:00Z')));
assert.equal(round, null, '北京时间 07:48 未到 13:00 首轮核验');
// 北京时间 2026-10-21 13:30 → 13:00 轮生效,窗口上界 07:00、核验 13:00
const noon = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-21T05:30:00Z')));
assert.equal(noon.key, 'NOON');
assert.equal(noon.date, '2026-10-21');
assert.equal(noon.deadline, '2026-10-21 13:00:00');
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
});
// ── ⑨ 导出明细(2026-10-27 新增「导出明细」按钮的后端口径)──────────────────
// 关键约束:导出必须是**全量**,且不能因为导出又重扫一遍订单表 ——
// 所以缓存键不含 limit(存全量、出口截断)。这个用例专门锁这两点。
test('导出取数:不受 limit 截断,且与列表共用同一份缓存', withStubbedService(async (svc, { calls }) => {
// 列表按 limit=3 取(页面就是这样截断的)
const limited = await svc.getShipTimeliness({ now: at(18, 5), limit: 3 });
assert.equal(limited.orders.length, 3, 'limit=3 只给 3 行');
assert.equal(limited.truncated, true, '被截断必须打标记');
assert.equal(limited.detailTotal, 8, '截断后仍要给出真实总数(页面「共 N 单」用它)');
assert.equal(calls.orders, 1, '第一次查询打了一次订单表');
// 导出:同一轮次同一天 → 必须命中同一份缓存,且拿到全部 8 行
const exported = await svc.listShipTimelinessExportRows({ round: 'AFTERNOON', now: at(18, 6) });
assert.equal(calls.orders, 1, '导出不能再扫一遍订单表(旧实现把 limit 拼进缓存键,每次导出都重查)');
assert.equal(exported.total, 8, '导出必须是全部超时单,不能被列表的 limit 带跑');
assert.equal(exported.rows.length, 8);
// 导出列里业务要能直接看到快递 / 运单号(追单用),centerKey 供前端按仓过滤
for (const row of exported.rows) {
for (const key of ['oId', 'soId', 'shopName', 'centerKey', 'centerName',
'warehouseName', 'logisticCompany', 'logisticId', 'auditTime', 'overdueMinutes']) {
assert.ok(key in row, `导出缺字段 ${key}`);
}
}
// 不考核快递的单不能出现在导出里(口径与页面一致)
assert.ok(!exported.rows.some((r) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(r.soId)),
'不考核快递的单不能导出');
assert.deepEqual(exported.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
}));
test('导出行为纯函数:缺字段一律给空串,不导出 undefined', () => {
const service = require('../src/services/warehouseShipTimelinessService');
const rows = service._private.buildShipTimelinessExportRows([
{ oId: 1, soId: 'SO-1', overdueMinutes: null },
null,
]);
assert.equal(rows.length, 2, '空行也要占位,别让行号错位');
assert.equal(rows[0].oId, '1', '数字单号要转成字符串,避免 Excel 科学计数法');
assert.equal(rows[0].logisticCompany, '');
assert.equal(rows[0].overdueMinutes, 0, 'null 时长按 0 处理,别给 NaN');
assert.equal(rows[1].oId, '');
assert.doesNotMatch(JSON.stringify(rows), /undefined|null|NaN/u);
});