Merge pull request 'fix: 仓储发货时效按北京时间取「今天」(生产容器 UTC 导致 00:00~08:00 轮次错位)' (#70)
This commit is contained in:
commit
42f60eb0ce
@ -326,6 +326,31 @@ function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
// 🔥 北京时间(UTC+8)偏移,分钟
|
||||
const BEIJING_OFFSET_MINUTES = 480;
|
||||
|
||||
/**
|
||||
* 取「按北京时间读出来」的当前时刻。
|
||||
*
|
||||
* 🔥 为什么不能直接 `new Date()`(2026-10-21 生产实测踩坑):
|
||||
* 生产容器 `/etc/timezone = Etc/UTC`(date = UTC),而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||
* mssql 驱动 `useUTC:false` 又把 DB 值构造成「墙钟当 UTC」的 Date。
|
||||
* 于是「服务器 now」比「库里的时间」慢 8 小时 —— **北京时间每天 00:00~08:00,
|
||||
* 服务端取到的「今天」是昨天**,轮次窗口 / 核验时刻整体错位
|
||||
* (实测 08:04 跑 13:00 轮得 overdueUnshipped=3895,本地北京时区同一轮只有 6 单)。
|
||||
*
|
||||
* 做法:把真实时刻平移成一个「本地 getter 读出来正好是北京时间」的 Date,
|
||||
* 这样 `getHours()/getDate()/formatDateTime()` 与库里的字符串可以直接比较。
|
||||
* 本机已是东八区时平移量为 0,行为不变(测试无需改)。
|
||||
*
|
||||
* @param {Date} [base] 真实时刻,默认 `new Date()`
|
||||
* @returns {Date} 本地 getter = 北京时间的 Date
|
||||
*/
|
||||
function beijingNow(base = new Date()) {
|
||||
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
|
||||
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用「关键词最长匹配」把仓库名归到主仓。
|
||||
*
|
||||
@ -417,10 +442,10 @@ function clearWarehouseCache() {
|
||||
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
|
||||
* 这是一个滚动累积的待发池,不按自然日切。
|
||||
*
|
||||
* @param {Date} now 以**东八区本地时间**为准的 Date(生产/本地机器均为东八区)
|
||||
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
|
||||
* @returns {object|null} null 表示当前不在任何核验时段内
|
||||
*/
|
||||
function resolveActiveRound(now = new Date()) {
|
||||
function resolveActiveRound(now = beijingNow()) {
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
const minutes = hour * 60 + minute;
|
||||
@ -456,7 +481,7 @@ function resolveActiveRound(now = new Date()) {
|
||||
/**
|
||||
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
|
||||
*/
|
||||
function describeRounds(now = new Date()) {
|
||||
function describeRounds(now = beijingNow()) {
|
||||
const y = now.getFullYear();
|
||||
const m = pad2(now.getMonth() + 1);
|
||||
const d = pad2(now.getDate());
|
||||
@ -514,6 +539,8 @@ module.exports = {
|
||||
getWarehouseCache: () => warehouseCache,
|
||||
resolveActiveRound,
|
||||
describeRounds,
|
||||
beijingNow,
|
||||
BEIJING_OFFSET_MINUTES,
|
||||
EXCLUDED_STATUS_SQL,
|
||||
SHIPPED_STATUS,
|
||||
PENDING_STATUS_SQL,
|
||||
|
||||
@ -47,6 +47,14 @@
|
||||
*
|
||||
* 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行)
|
||||
*
|
||||
* 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`):
|
||||
* · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||
* mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
|
||||
* · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**,
|
||||
* 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。
|
||||
* · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。
|
||||
* **不要**去改容器 TZ —— 会波及全部既有定时任务。
|
||||
*
|
||||
* · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值;
|
||||
* 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。
|
||||
* · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created`
|
||||
@ -350,7 +358,9 @@ function buildEmptyCenters(centerList) {
|
||||
}
|
||||
|
||||
async function getShipTimeliness(options = {}) {
|
||||
const now = options.now instanceof Date ? options.now : new Date();
|
||||
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
|
||||
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
|
||||
const now = options.now instanceof Date ? options.now : registry.beijingNow();
|
||||
const limit = Math.min(
|
||||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||
MAX_DETAIL_LIMIT
|
||||
|
||||
@ -634,3 +634,34 @@ test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbed
|
||||
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');
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user