问题(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。
668 lines
39 KiB
JavaScript
668 lines
39 KiB
JavaScript
/**
|
||
* 仓储中心 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');
|
||
});
|