cangku/backend/test/warehouseShipTimeliness.test.js
yanhui cb7d06c20c feat(wst): 数据源停更熔断——订单表停更时醒目标注,别把「没同步」当「没发货」
## 业务问题

10-28 18:00 轮页面核验出 546 单超时未发,业务核对「基本都发了,怎么会核验出未发」。

## 根因(不是判定写错,是数据源停更)

`jst_Orders` 当时**从 14:40 就停止同步了**(停了 4 小时),停更之后发出的单
`send_date` 一直是空,因此全被判成「未发货」:

| 来源表 | 超时单数 | 该表状态 |
|---|---|---|
| `jst_Orders` | 540 | 最后更新 14:40(停 4 小时) |
| `jst_qmcust_orderlist` | 6 | 17:30 正常 |

两个订单表**各自独立同步**,同一天完全可能一个正常一个停更。原来只拿候选行的
`MAX(updated_at)` 判快照滞后,会被「碰巧新」的行掩盖 —— 必须直接问源表。

## 改动

后端 `warehouseShipTimelinessService.js`:

- 新增 `loadSourceFreshness()`:分别直接查 `jst_Orders.MAX(updateTime)` 与
  `jst_qmcust_orderlist.MAX(local_updated)`(限轮次窗口起 30 天,避免全表扫描),
  两源并行;按 `CANGKU_WST_SOURCE_STALE_MINUTES`(默认 60 分钟)判停更
  - 查询失败或取不到更新时间 → **不判停更**(宁可漏报,不谎报为停更)
- 明细行新增 `src` / `srcLabel` / `sourceStale`;`summary` 新增 `sourceStaleUnshipped`
- `freshness.stale` 改为「候选行滞后 **OR** 任一源停更」;`hint` 说清停更源、
  停更时刻、以及各源各贡献多少单(不能只写「这张表」)
- 导出数据带 `dataSources`,Excel 脱离页面也有上下文
- 响应新增 `dataSources` 数组(每源 label / lastUpdate / lagMinutes / stale)

前端 `WarehouseShipTimeliness/index.jsx`:

- 红条 Alert:「N 单所在订单表已停止同步,这些单很可能已经发货」(error 级),
  描述里逐源列出「最后同步 14:40:05(已 3.9 小时)」
- 超时统计卡下方小字:「其中 N 单数据未同步」(悬停有说明)
- 明细「内部单号」旁「未同步」红标签 + tooltip,一眼看出哪几行不可信
- 导出新增「数据源 / 数据源是否停更」两列(`是(该单可能已发)`)

## 验证

- 后端 `npm run check`:**494/496**,2 个失败是既有环境问题(负缓存 TTL 计时、
  Windows 文件权限 0o600),改动前同样失败
- `warehouseShipTimeliness.test.js` **23/23** 通过(含新增「停更表超时单必须打标
  并计入 sourceStaleUnshipped」用例)
- 本地真渲染(Playwright + 手签 token,localhost:5173):
  - 真数据:13:00 前 `notStarted` 正常展示「—」,无 pageerror
  - mock 停更数据:红条 / 逐源行 / 统计小字 / 3 行「未同步」标签全部渲染,无 pageerror
- 真实接口 10-28 13:00 轮:`dataSources` 正确识别 `jst_Orders` 停更(滞后 96 分钟)
  而 qmcust 正常;`sourceStaleUnshipped=1146` 与明细逐行打标同源同口径

## 风险与回退

- 只新增字段与展示,**不改动分轮、起算点、候选池口径** —— 超时单量本身不变
- 阈值可用 `CANGKU_WST_SOURCE_STALE_MINUTES` 调;不配默认 60 分钟
- 低峰期(凌晨)可能出现「1 小时无新单」被判停更,但页面只在 13:00 / 18:00
  核验时刻展示超时数据,这两个时点 1 小时不同步即属异常
- 回退:revert 本提交即可,无数据迁移、无新表、无新依赖
2026-10-29 08:25:41 +08:00

705 lines
41 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 = [], freshnessUpd = '2026-10-20 18:00:00') {
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, freshness: 0, sql: [], params: [] };
const handler = async (sql, params) => {
const text = String(sql);
if (/jst_warehouseInfo/u.test(text)) {
calls.warehouse += 1;
return warehouseRows;
}
// 数据源新鲜度探测:`SELECT MAX(updateTime) FROM jst_Orders` /
// `SELECT MAX(local_updated) FROM jst_qmcust_orderlist`(2026-10-28 新增)。
// 单独记账,**不算订单候选查询** —— 否则「订单两表只允许查一次」的老断言会误伤。
if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text)) {
calls.freshness += 1;
// 默认 18:00(用例 now=18:05 → 滞后 5 分钟 < 60,不触发停更告警);
// 要测停更场景就把 freshnessUpd 传成当天更早的时刻
return [{ maxUpd: freshnessUpd }];
}
// 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间,
// 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 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 = [], freshnessUpd = '2026-10-20 18:00:00') {
return async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd);
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 },
]));
test('数据源停更:来自停更表的超时单必须打标,并计入 sourceStaleUnshipped', async () => {
// 🔥 2026-10-28 18:35 现场:`jst_Orders` 停更在 14:40,18:00 轮 546 单里 540 单来自它,
// 业务当场质疑「基本都发了」。页面对这种单**不能静默当确认未发**。
// 用例 now=18:05,源表停在 10:00 → 滞后 8 小时 > 60 分钟阈值
await withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
assert.ok(data.summary.overdueUnshipped > 0, '前置:默认场景应有超时单');
assert.equal(
data.summary.sourceStaleUnshipped,
data.summary.overdueUnshipped,
'源停更时,全部超时单都要计入「数据未同步」',
);
assert.ok(data.dataSources.length >= 2, '两个订单表的新鲜度都要透出');
assert.ok(data.dataSources.every((x) => x.stale), '两张表都停在 10:00,都算停更');
assert.equal(data.orders[0].sourceStale, true, '明细行要带 sourceStale 标记');
assert.ok(data.orders[0].srcLabel, '明细行要带来源表名,前端才解释得清');
assert.match(String(data.freshness.hint), /停更/u, '页面提示要说明是哪张表停更');
}, 'default', [], '2026-10-20 10:00:00');
// 反方向:源正常时不能误标,否则业务会以为每天都在停更
await withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
assert.equal(data.summary.sourceStaleUnshipped, 0, '源正常时不能标停更');
assert.ok(data.dataSources.every((x) => !x.stale), '滞后 5 分钟不达阈值');
assert.equal(Boolean(data.orders[0].sourceStale), false, '明细行不带停更标记');
}, 'default');
});