cangku/backend/test/warehouseShipTimeliness.test.js
yanhui 983fee0d8a fix(wst): 起算点改为「审核时间 / 订单 modified 取较晚者」,修 13:00 轮 65 单假超时
## 业务问题

业务举证:13:00 轮不该有那么多单 —— 「9 点前的单 13:00 前已经发完了」。
线上实测 65 单,逐单核对全部不该在 13:00 轮。

## 根因(两个,都指向「起算点取错」)

### ① 状态表的审核时间会严重滞后

业务给的举证单 9978643:

| 字段 | 值 |
|---|---|
| `qmcust.modified` | **2026-10-28 14:52:29** |
| 聚水潭页面真实审核时间 | **2026-10-28 14:52:28**(秒级吻合) |
| 状态表 `last_oa_confirm_time` | 2026-10-27 17:08:43(旧了一天) |
| 状态表 `query_time` | 2026-10-28 10:29:11(10:29 后没再抓) |

只信状态表 → 这单被当成「10-27 就审核了」,混进 13:00 轮超时,冤枉仓库。

### ② 审核时间为空的单回退 created,把今天的单按历史单算

65 单里 57 单状态表审核时间为 NULL。回退 `created` 后:

| o_id | created | modified | 回退后算成 | 真实 |
|---|---|---|---|---|
| 9964841 | 10-26 17:22 | **10-28 13:40** | 10-26 审核 → 进 13:00 轮 | 今天下午才审核 |
| 9949424 | 10-25 17:03 | **10-28 13:50** | 同上 | 同上 |
| 9983773 | 10-27 23:23 | **10-28 13:59** | 同上 | 同上 |

57 单的 `modified` 全部落在今天 13:40~15:03 —— 都是今天下午批量审核的。

## 改动

- **起算点** = `max(状态表 last_oa_confirm_time, 订单表 modified, 订单表 created)`
  - 取较晚者:修状态表滞后
  - 纳入 created:个别单 `modified` 早于 `created`(脏数据),起算点不该早于下单时刻
  - `benchmarkSource` 透出 `oa_confirm` / `modified` / `created`,页面 tooltip 说明来源
- **新增 `laterOf()`**:同格式 `YYYY-MM-DD HH:mm:ss` 直接字典序比较,不转 Date(避开时区坑)
- **主查询 SQL 增加 `x.modified`** 列(订单两表都有,无需额外查询)

## 性能修复(同批,线上 /list 曾 300s 超时)

上版状态表查询**串行 9 批实测 135s**,叠加主查询 63~105s 直接把接口顶过 300s,
页面打不开(实测 curl `--max-time 300` 超时)。改为**并发 6**:

| 方式 | 耗时 |
|---|---|
| 串行 9 批 | 135s |
| 并发 3 | 28.8s |
| **并发 6(本版)** | **10~15s** |

并发 6 是生产实测的安全值(单批约 15s,不至于把 402 万行视图的连接池打爆)。

## 改后效果(10-28 17:00 真实库)

| 轮次 | 改前 | 改后 |
|---|---|---|
| 13:00 轮 | 65 单 | **0 单** |
| 18:00 轮 | — | 927 单(全部今天 07:59~16:00 审核,无历史单) |

- `fallbackToCreated` 从 57 → **0**(不再有回退单)
- 9978643 ✅ 已剔除
- 两个轮次都验证「今天窗口上界之后起算的 = 0」

## 验证

- 后端 `npm run check`:**493/495**(2 个失败是既有环境问题:TTL 计时、Windows 权限,改动前同样失败)
- 新增 2 个测试锁定该口径:
  - `modified` 晚于窗口 → 必须剔除(9978643 那类单)
  - 状态表审核时间晚于 `modified` → 以状态表为准
  - `modified` 早于 `created` → 取 `created`,不倒退
- 前端 lint + build 通过(35.2s)
- 真实库双轮验证通过

## 风险与回退

- **`modified` 被客服改单/改备注刷新**时起算点会推后 → 少报超时(放行方向,不冤枉仓库)。
  业务已知悉并接受;这是当前唯一能拿到「今天真实审核时间」的可靠字段。
- 回退方式:`git revert <merge commit>` 即回到只认状态表审核时间的版本。
2026-10-28 17:02:54 +08:00

668 lines
38 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 分
];
// 「起算点 = max(审核时间, 订单 modified)」专用场景(2026-10-28 下午定版)
// 🔥 为什么必须取较晚者:状态表的 last_oa_confirm_time 会滞后。
// 实测 9978643:状态表写 10-27 17:08,聚水潭真实审核 10-28 14:52,
// 只信状态表 → 把「今天下午才审核的单」算进 13:00 轮,冤枉仓库。
const modifiedRows = [
// 51:created 06:45(7 点前),但 modified 是今天 14:52 → 起算点 14:52 晚于窗口 07:00 → 剔除
o(51, WMS.CQ, '2026-10-20 06:45:00', { modified: '2026-10-20 14:52:29' }),
// 52:历史单 created 10-18,状态表无审核时间,modified 今天 06:00 → 算超时,起算点 06:00
o(52, WMS.CQ, '2026-10-18 12:00:00', { modified: '2026-10-20 06:00:00' }),
// 53:modified 比 created 还早(异常数据)→ 起算点回落到 created
o(53, WMS.CQ, '2026-10-20 05:00:00', { modified: '2026-10-19 23:00:00' }),
];
const rows = scenario === 'modified' ? modifiedRows : (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);
});
test('起算点 = max(审核时间, 订单 modified):状态表滞后时以 modified 为准', withStubbedService(async (svc) => {
// 13:05 → 13:00 轮,窗口上界 07:00
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
// 51 的 modified 是今天 14:52,晚于窗口 07:00 → 必须剔除(这就是 9978643 那类单)
assert.ok(!data.orders.some((x) => String(x.oId) === '51'),
'modified 晚于窗口的单不能算进 13:00 轮(状态表滞后会把它冤枉成 7 点前审核)');
// 52:状态表无审核时间,modified 今天 06:00 → 起算点 06:00,不是 created 10-18 12:00
const r52 = data.orders.find((x) => String(x.oId) === '52');
assert.ok(r52, '52 应算超时(modified 06:00 早于窗口 07:00)');
assert.equal(r52.auditTime, '2026-10-20 06:00:00', '起算点取 modified');
assert.equal(r52.benchmarkSource, 'modified', '来源必须是 modified');
assert.equal(r52.createdAt, '2026-10-18 12:00:00', '下单时间照旧展示');
assert.equal(r52.overdueMinutes, 420, '06:00 → 13:00 = 420 分钟(不是按下单时间算的 4380)');
// 53:modified 早于 created → 取较晚者 = created 05:00
const r53 = data.orders.find((x) => String(x.oId) === '53');
assert.ok(r53, '53 应算超时');
assert.equal(r53.auditTime, '2026-10-20 05:00:00', 'modified 比 created 早时不能倒退,取 created');
assert.equal(r53.benchmarkSource, 'created');
}, 'modified'));
test('状态表审核时间比 modified 更晚时,以状态表为准', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
const r52 = data.orders.find((x) => String(x.oId) === '52');
assert.ok(r52, '52 应算超时');
assert.equal(r52.auditTime, '2026-10-20 06:30:00', '审核时间 06:30 晚于 modified 06:00,取审核时间');
assert.equal(r52.benchmarkSource, 'oa_confirm');
}, 'modified', [
{ inner_order_id: 52, first_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), last_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), send_time: null },
]));