cangku/backend/test/warehouseShipTimeliness.test.js
yanhui f42f461430 fix: 仓储发货时效按北京时间取「今天」(生产容器是 UTC,每天 00:00~08:00 轮次错位)
问题(2026-10-21 生产实测,PR #69 上线后发现):
- 生产容器 `/etc/timezone = Etc/UTC`、`date` = UTC;而 MSSQL `GETDATE()` 返回北京时间,
  mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
- 于是**北京时间每天 00:00~08:00,服务端 `new Date()` 取到的「今天」是昨天**:
  round.date / deadline / windowEnd 整体错位。
  实测 08:04 跑 13:00 轮得 `overdueUnshipped=3895`(历史未发池被整段纳入),
  而本地(东八区)同一轮只有 6 单。

修法(只动这一个模块,不动容器时区):
- registry 新增 `beijingNow()`:把真实时刻平移成「本地 getter 读出来正好是北京时间」的 Date,
  本机已是东八区时平移量为 0,行为不变。
- `resolveActiveRound` / `describeRounds` 的 now 默认值改为 `beijingNow()`;
  service 的 `getShipTimeliness` 同样改用 `registry.beijingNow()`。
- ⚠️ 没改容器 TZ:`/opt/cangku/shared/backend.env` 里没有 TZ,改了会波及全部既有定时任务
  (dailyRefresh / dailyInventorySnapshot 等都各自显式写了 Asia/Shanghai)。

测试:
- 新增 2 个用例(跨日 00:00~08:00 不取成前一天;默认按北京时间判定 13:00 前 notStarted)。
- `node --test backend/test/warehouseShipTimeliness.test.js` → 19 / 19 pass。
2026-10-21 08:17:52 +08:00

668 lines
39 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-20 第八版)
*
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
* ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)——
* 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错;
* ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**:
* 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。
* ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做,
* 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废;
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。
* 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回;
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃;
* **作废单(已取消 / 合并 / 拆单父单)不考核**;
* ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。
*
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
*/
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.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
});
test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => {
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('作废单(已取消)明确不考核', () => {
assert.ok(
registry.IGNORED_STATES.includes('已取消'),
'「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍'
);
assert.ok(registry.IGNORED_STATES.includes('已发货'));
assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货'));
});
test('创建时间要从订单表补,且只补窄列', () => {
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') {
const wmsId = 15038426;
// 创建时间(展示)+ 作废事实来源:o_id → 订单表行
// 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间
const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o });
const defaultFacts = {
1: f('jst_Orders', { created: '2026-09-25 00:50:00' }),
2: f('jst_Orders', { created: '2026-10-20 16:20:00' }),
6: f('jst_Orders', { created: '2026-10-20 17:00:00' }),
7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }),
9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }),
// 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例
// 5127276315127080023(内部单号 9486414)就是这样被误判成超时的
10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }),
// 11:状态表 send_time 空,订单表 send_date 有值(已发)
11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }),
// 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货)
// —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」
37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }),
// 12:拆单父单,status 不是 Split 但 is_split=1
12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }),
13: f('jst_Orders', { created: '2026-10-20 15:00:00' }),
// 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮)
31: f('jst_Orders', { created: '2026-10-20 15:30:00' }),
32: f('jst_Orders', { created: '2026-10-20 14:30:00' }),
33: f('jst_Orders', { created: '2026-10-20 13:30:00' }),
34: f('jst_Orders', { created: '2026-10-20 12:30:00' }),
// 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476)
// 只能进 18:00 轮,不能进 13:00 轮
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
};
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
const noonFacts = {
21: f('jst_Orders', { created: '2026-10-20 06:45:00' }),
22: f('jst_Orders', { created: '2026-10-20 05:55:00' }),
23: f('jst_Orders', { created: '2026-10-19 19:55:00' }),
24: f('jst_Orders', { created: '2026-10-20 07:30:00' }),
// 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
// 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
};
const factsMap = scenario === 'noon' ? noonFacts : defaultFacts;
const mk = (o) => ({
shop_name: '测试店',
logistic_status_name: '',
query_time: '2026-10-20 11:18:27',
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
...o,
});
const defaultRows = [
// 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
last_oa_confirm_time: '2026-09-25 01:00:00' }),
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间)
mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 16:10:00',
last_oa_confirm_time: '2026-10-20 16:30:00' }),
// 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时
mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-26 00:10:00',
last_oa_confirm_time: '2026-09-26 00:15:00' }),
// 4:已支付待审核(还没审单、没有审核时间)→ 不计考核
mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00',
last_oa_confirm_time: null }),
// 5:异常 → 不计考核
mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00',
last_oa_confirm_time: '2026-09-23 01:05:00' }),
// 6:审核 17:05 晚于窗口上界 → 本轮不该核验
mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 16:55:00',
last_oa_confirm_time: '2026-10-20 17:05:00' }),
// 7:被业务排除的京东云仓 → 不归属任何主仓
mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓',
order_status_name: '发货中', pay_time: '2026-09-25 00:00:00',
last_oa_confirm_time: '2026-09-25 00:10:00' }),
// 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
last_oa_confirm_time: '2026-09-25 01:00:00' }),
// 9:作废单(已取消)—— 业务定性不考核
mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '已取消', pay_time: '2026-09-21 08:00:00',
last_oa_confirm_time: '2026-09-21 08:10:00' }),
// 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除
mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 09:59:00',
last_oa_confirm_time: '2026-09-21 10:05:00' }),
// 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除
mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 10:59:00',
last_oa_confirm_time: '2026-09-21 11:05:00' }),
// 12:拆单父单(is_split=1)—— 自己不发货,必须剔除
mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 11:59:00',
last_oa_confirm_time: '2026-09-21 12:05:00' }),
// 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉,
// 本版必须算超时(业务口径:到点未发就算超时)
mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 14:59:00',
last_oa_confirm_time: '2026-10-20 15:30:00' }),
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
// 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验
mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓',
order_status_name: '发货中', pay_time: '2026-10-20 15:29:00',
last_oa_confirm_time: '2026-10-20 15:35:00' }),
// 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时
mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓',
order_status_name: '发货中', pay_time: '2026-10-20 14:29:00',
last_oa_confirm_time: '2026-10-20 14:35:00' }),
// 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验
mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓',
order_status_name: '发货中', pay_time: '2026-10-20 13:29:00',
last_oa_confirm_time: '2026-10-20 13:35:00' }),
// 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时
mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓',
order_status_name: '发货中', pay_time: '2026-10-20 12:29:00',
last_oa_confirm_time: '2026-10-20 12:35:00' }),
// 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。
// 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
first_oa_confirm_time: '2026-10-20 13:10:00',
last_oa_confirm_time: '2026-10-20 13:10:00' }),
// 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10)
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
first_oa_confirm_time: '2026-10-19 10:10:00',
last_oa_confirm_time: '2026-10-20 13:30:00' }),
// 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。
// 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped
mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-25 01:00:00',
last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }),
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
last_oa_confirm_time: '2026-09-22 09:05:00' }),
];
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
const noonRows = [
// 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分
mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 06:40:00',
last_oa_confirm_time: '2026-10-20 07:20:00' }),
// 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛)
mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 05:50:00',
last_oa_confirm_time: '2026-10-20 06:40:00' }),
// 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到
mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 19:50:00',
last_oa_confirm_time: '2026-10-19 20:10:00' }),
// 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间)
mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 07:29:00',
last_oa_confirm_time: '2026-10-20 07:40:00' }),
// 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。
// 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
first_oa_confirm_time: '2026-10-19 10:10:00',
last_oa_confirm_time: '2026-10-20 13:30:00' }),
// 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算,
// 不能显示 0 分(真实案例:创建 10-18 的单今天才审)
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
first_oa_confirm_time: '2026-10-20 13:10:00',
last_oa_confirm_time: '2026-10-20 13:10:00' }),
];
const rows = scenario === 'noon' ? noonRows : defaultRows;
const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] };
const handler = async (sql, params) => {
const text = String(sql);
if (/jst_warehouseInfo/u.test(text)) {
return [
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId },
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 },
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 },
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 },
];
}
// 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表
if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) {
calls.backfill += 1;
calls.backfillSql.push(text);
const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders';
const m = text.match(/IN \(([0-9,\s]+)\)/u);
const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : [];
return ids
.filter((id) => factsMap[id] && factsMap[id].table === table)
.map((id) => {
const x = factsMap[id];
return {
o_id: id,
created: x.created,
status: x.status,
send_date: x.send_date,
is_merge: x.is_merge,
is_split: x.is_split,
};
});
}
calls.state += 1;
calls.sql.push(text);
calls.params.push(params || {});
return rows;
};
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: { query: handler, querySlow: handler },
};
return { calls, wmsId };
}
function withStubbedService(fn, scenario = 'default') {
return async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const ctx = stubDb(dbPath, scenario);
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 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时)
// + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核)
// = 6 单
// 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发
// 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的
assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时');
assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天');
assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单');
assert.equal(data.summary.overdueUnshipped, 7);
assert.equal(data.summary.overdueCenterCount, 3);
assert.equal(data.summary.oldestDays, 25);
assert.equal(data.summary.heldCount, 2);
// 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示
assert.equal(data.orders.length, 7);
assert.equal(String(data.orders[0].oId), '1');
assert.equal(data.orders[0].auditTime, '2026-09-25 01:00: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, 'last_audit');
// 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟
assert.equal(data.orders[0].overdueMinutes, 37020);
// 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算
const fresh = data.orders.find((o) => String(o.oId) === '13');
assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)');
assert.equal(fresh.overdueMinutes, 150);
assert.equal(fresh.waitedHours, 2.5);
// 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290
const lateAudit = data.orders.find((o) => String(o.oId) === '26');
assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)');
assert.equal(lateAudit.overdueMinutes, 290);
// 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10
const reAudited = data.orders.find((o) => String(o.oId) === '25');
assert.ok(reAudited, '重审单也要算');
assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核');
assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30');
// 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间
const third = data.orders.find((o) => String(o.oId) === '3');
assert.ok(third, '补不到创建时间的单也要算超时');
assert.equal(third.createdAt, '', '确实没补到');
assert.equal(third.benchmarkSource, 'last_audit');
assert.equal(third.payTime, '2026-09-26 00:10:00');
// 创建时间晚于窗口上界 → 本轮不核验(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'), '京东云仓不参与考核');
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
for (const id of ['9', '10', '11', '12']) {
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} 不该进挂起清单`);
}
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
assert.ok(
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
'不考核快递的单不能出现在超时明细里'
);
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
// 不计考核清单
assert.equal(data.heldOrders.length, 2);
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
assert.deepEqual(reasons, ['待审核', '订单异常'].sort());
// 新鲜度直接取自主查询的 query_time,不再额外扫表
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
// 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新,
// 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。
assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟');
assert.equal(
data.freshness.stale,
data.freshness.ageMinutes > 90,
'滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)'
);
if (data.freshness.stale) {
assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示');
}
// 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false');
assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚');
// ── 性能护栏 ────────────────────────────────────────────────────────
assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)');
const sql = calls.sql[0];
assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表');
assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判');
// 本版基准是审核时间 → 两列审核时间都必须在投影里
assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列');
assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底');
// 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消)
assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数');
assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断');
assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用');
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列');
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
for (const bs of calls.backfillSql) {
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
'多取一列就会把 0.2s 拖到 38s');
}
// ⚠️ 窗口参数走 params,不在 SQL 里手写
assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除');
assert.equal(calls.params[0].shipStatus, '发货中');
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
assert.equal(data.round.key, 'AFTERNOON');
assert.match(String(data.round.windowEnd), /16:00:00$/u);
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算');
}));
test('13:00 轮:只核验「今天 00: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');
// 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验)
assert.ok(!data.orders.some((o) => String(o.oId) === '21'),
'今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现');
// 24 号:今天 07:40 审核 → 同理不核验
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
'今天 07:40 审核 → 不进 13:00 轮');
// 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮
assert.ok(!data.orders.some((o) => String(o.oId) === '26'),
'下午才审核的老单不能进 13:00 轮');
// 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛)
const b = data.orders.find((o) => String(o.oId) === '22');
assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时');
assert.equal(b.benchmarkSource, 'last_audit');
assert.equal(b.overdueMinutes, 380);
assert.equal(b.waitedHours, 6.3);
// 23 号:昨天审核、至今未发 → 历史未发池也要看到
const d = data.orders.find((o) => String(o.oId) === '23');
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
assert.equal(d.overdueMinutes, 1010);
// 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验
assert.ok(!data.orders.some((o) => String(o.oId) === '25'),
'重审到 13:30 的单归 18:00 轮,不进 13:00 轮');
// 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单
assert.equal(data.summary.overdueUnshipped, 2);
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判');
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
}, '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.state, 0, '未到核验时间不应查库');
assert.equal(calls.backfill, 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');
});