diff --git a/backend/src/services/warehouseShipTimelinessRegistry.js b/backend/src/services/warehouseShipTimelinessRegistry.js index 8448b01..684f865 100644 --- a/backend/src/services/warehouseShipTimelinessRegistry.js +++ b/backend/src/services/warehouseShipTimelinessRegistry.js @@ -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, diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index 7866c56..4ef9d6d 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -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 diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index f208978..9f56b96 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -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'); +});