Commit caa2f312 by luoqi

fix(sync): 宿主纯日期按当地零点解释 —— 直通会被 JS 当成 UTC 零点,整整偏 8 小时

normalizeDatetime 旧版对纯日期('2026-05-10')直通不补 offset,下游 new Date() 按 ISO
规范解析成 **UTC 零点**;而宿主给的是**当地日期**(jvs-dw 的 DW 是 Asia/Shanghai):
    DW created_date = 2022-09-30(北京)
      旧:2022-09-30T00:00:00Z = 北京 09-30 08:00  
      新:2022-09-29T16:00:00Z = 北京 09-30 00:00  

【怎么发现的】回访补 source_created_at 时,手写回填(按 +08:00)与摄入路径写出的值
差整 8 小时。查下去发现不是新字段的问题,是这条既有路径 —— 测试服实测
diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(占 6%),正是它;
落在 UTC 16:00(正确形态)的只有 5 条。

【边界:为什么不怕补 offset 导致跨日】本函数只作用于 CanonicalResourceMeta.datetimeFields
声明的字段,那些目标列全是 timestamptz。真正的纯日期列(patient_return_visit.taskDate /
patient.birthDate)刻意不在该清单里,走各自 new Date() 直解 —— 对它们补 offset 会让 PG
取 UTC 日期时退一天。新增字段按"目标列类型"归类,不能凭字段名像不像时间。

【存量】老数据不会自动修正(需 reparse,会触发一次性 fact 版本波)。偏差是 8 小时、
日期级判断基本不受影响,故不随本次修 —— 但新摄入的数据从此正确。
️ 若不修,回访刚回填好的正确值会被下一轮增量覆盖成错的。

测试 815 项(+7),含"旧行为偏 8 小时"的固化回归。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 72e41782
Pipeline #3517 failed in 0 seconds
...@@ -262,8 +262,22 @@ function coerceArray(v: unknown): unknown[] | null { ...@@ -262,8 +262,22 @@ function coerceArray(v: unknown): unknown[] | null {
/** /**
* 把 datetime 字符串规范成 ISO 8601 带 offset 形态: * 把 datetime 字符串规范成 ISO 8601 带 offset 形态:
* - 已带 tz('Z' / '+08:00' / '-05:00')→ 直通 * - 已带 tz('Z' / '+08:00' / '-05:00')→ 直通
* - 仅日期 'YYYY-MM-DD' → 直通(给 birthDate 用) * - 仅日期 'YYYY-MM-DD' → **补 offset 到当地零点**('2026-05-10T00:00:00+08:00')
* - 无 tz('2026-05-10 14:00:00' / '2026-05-10T14:00:00')→ 按 timezone 配置补 offset * - 无 tz('2026-05-10 14:00:00' / '2026-05-10T14:00:00')→ 按 timezone 配置补 offset
*
* ⚠️ 【为什么仅日期也要补 offset】2026-08-01 实测发现的既有 bug:
* 旧版对纯日期直通,下游 `new Date('2026-05-10')` 按 **ISO 规范解析成 UTC 零点** ——
* 而宿主给的日期是**当地日期**(DW 是 Asia/Shanghai),于是整整偏 8 小时:
* DW created_date = 2022-09-30(北京)→ 存成 2022-09-30T00:00:00Z = 北京 09-30 **08:00**
* 正确应为 2022-09-29T16:00:00Z = 北京 09-30 **00:00**
* 测试服实测:diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(6%),
* 正是这条路径来的;落在 UTC 16:00(正确形态)的只有 5 条。
*
* ⚠️ 【为什么不怕"补 offset 导致跨日"】只有目标是 **@db.Date**(纯日期列)的字段才有这风险
* (北京零点 → UTC 前一天 16:00 → PG 取 UTC 日期会退一天)。而本函数只作用于
* `CanonicalResourceMeta.datetimeFields` 声明的字段,那些目标**全是 timestamptz**;
* 真正的纯日期列(patient_return_visit.taskDate / patient.birthDate)刻意不在该清单里,
* 走各自的 `new Date(...)` 直解,行为不变。新增字段时按此规则归类。
*/ */
export function normalizeDatetime(s: string, timezone: string): string { export function normalizeDatetime(s: string, timezone: string): string {
const trimmed = s.trim(); const trimmed = s.trim();
...@@ -271,8 +285,10 @@ export function normalizeDatetime(s: string, timezone: string): string { ...@@ -271,8 +285,10 @@ export function normalizeDatetime(s: string, timezone: string): string {
if (/[Zz]$/.test(trimmed) || /[+\-]\d{2}:?\d{2}$/.test(trimmed)) { if (/[Zz]$/.test(trimmed) || /[+\-]\d{2}:?\d{2}$/.test(trimmed)) {
return trimmed.replace(' ', 'T'); return trimmed.replace(' ', 'T');
} }
// 仅日期 // 仅日期 → 当地零点(见上方注释:直通会被 JS 当成 UTC 零点)
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed; if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return `${trimmed}T00:00:00${timezoneToOffsetSuffix(timezone)}`;
}
// 看起来像 datetime 无 tz // 看起来像 datetime 无 tz
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) { if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) {
const offset = timezoneToOffsetSuffix(timezone); const offset = timezoneToOffsetSuffix(timezone);
......
import { normalizeDatetime } from '../src/modules/sync/assembler/field-mapper';
/**
* 宿主时间归一的时区语义。
*
* 【2026-08-01 实测发现的既有 bug】旧版对**纯日期**('2026-05-10')直通不补 offset,
* 下游 `new Date('2026-05-10')` 按 ISO 规范解析成 **UTC 零点** —— 而宿主给的是**当地日期**
* (jvs-dw 的 DW 是 Asia/Shanghai),于是整整偏 8 小时:
* DW created_date = 2022-09-30(北京)
* 旧:2022-09-30T00:00:00Z = 北京 09-30 **08:00** ❌
* 新:2022-09-29T16:00:00Z = 北京 09-30 **00:00** ✅
* 测试服实测规模:diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(占 6%),
* 正是这条路径来的;落在 UTC 16:00(正确形态)的只有 5 条。
*
* 【边界纪律】本函数只作用于 CanonicalResourceMeta.datetimeFields 声明的字段,
* 那些目标列**全是 timestamptz**。真正的纯日期列(patient_return_visit.taskDate /
* patient.birthDate)刻意不进该清单 —— 对它们补 offset 会让 PG 取 UTC 日期时**退一天**。
* 新增 canonical 字段时按"目标列类型"归类,别凭字段名像不像时间。
*/
describe('normalizeDatetime — 时区语义', () => {
const SH = 'Asia/Shanghai';
test('⭐ 纯日期 → 当地零点(不是 UTC 零点)', () => {
const out = normalizeDatetime('2022-09-30', SH);
expect(out).toBe('2022-09-30T00:00:00+08:00');
// 落到实际时刻:北京零点 = UTC 前一天 16:00
expect(new Date(out).toISOString()).toBe('2022-09-29T16:00:00.000Z');
});
test('⭐ 回归:旧行为会偏 8 小时 —— 直通字符串被 JS 当成 UTC 零点', () => {
// 这一行是"如果有人把补 offset 改回直通"会发生什么的固化证据
expect(new Date('2022-09-30').toISOString()).toBe('2022-09-30T00:00:00.000Z');
// 与正确值差整 8 小时
const correct = new Date(normalizeDatetime('2022-09-30', SH)).getTime();
expect(new Date('2022-09-30').getTime() - correct).toBe(8 * 3600 * 1000);
});
test('UTC 宿主不受影响(offset 是 Z,纯日期仍是 UTC 零点)', () => {
expect(new Date(normalizeDatetime('2022-09-30', 'UTC')).toISOString()).toBe(
'2022-09-30T00:00:00.000Z',
);
});
test('东京宿主按 +09:00 解释(时区取自 manifest,不写死东八区)', () => {
expect(new Date(normalizeDatetime('2022-09-30', 'Asia/Tokyo')).toISOString()).toBe(
'2022-09-29T15:00:00.000Z',
);
});
test('无 tz 的 datetime 照旧补 offset(既有行为不变)', () => {
expect(normalizeDatetime('2026-05-10 14:00:00', SH)).toBe('2026-05-10T14:00:00+08:00');
expect(normalizeDatetime('2026-05-10T14:00:00', SH)).toBe('2026-05-10T14:00:00+08:00');
});
test('已带 tz 的直通(只把空格换成 T),不重复补 offset', () => {
expect(normalizeDatetime('2026-05-10 14:00:00+08:00', SH)).toBe('2026-05-10T14:00:00+08:00');
expect(normalizeDatetime('2026-05-10T06:00:00Z', SH)).toBe('2026-05-10T06:00:00Z');
});
test('识别不了的形态原样返回(交给 zod 报错,不猜)', () => {
expect(normalizeDatetime('not-a-date', SH)).toBe('not-a-date');
expect(normalizeDatetime('', SH)).toBe('');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment