Commit 7375f010 by luoqi

fix(sync): 增量游标 ISO 格式与 DW String 列字典序比较不兼容 — 增量三天空转

诊断(2026-06-10 服务器):增量 cron 每天 success 但 fetched=0 × 3 天;DW 实际有新数据
(fact_appointment_out max updated_date=06-09 22:50)。根因:DW 的 cursor 列是
Nullable(String)('YYYY-MM-DD HH:mm:ss'),WHERE 比较走字典序;游标存的是 run_start ISO
('2026-06-09T03:30:00.012Z'),第 11 字符 'T'(0x54) > ' '(0x20) → 所有真实行字典序都
小于游标 → 永远 0 行。实测同一时刻 ISO 谓词 0 行 / 普通格式 1178 行。

修复:读取侧规范化(toDwCursorLiteral)— 构造 IncrementalConfig 时把 ISO 游标按
manifest.timezone 转成 'YYYY-MM-DD HH:mm:ss'(sv-SE locale 恰好此格式);
旧游标无需迁移;已是普通格式/垃圾值原样返回;秒精度(同秒 .SSS 行值字典序更大仍被选中,
轻微重叠由 source_event_id 幂等吃掉)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent bd442595
......@@ -543,7 +543,17 @@ export class ColdImportService {
perQuery: Object.fromEntries(
Object.entries(perQueryCfg).map(([table, cfg]) => [
table,
{ cursorColumn: cfg.cursor_column, cursorValue: lastCursor[table] ?? null },
{
cursorColumn: cfg.cursor_column,
// ⚠️ 必须规范化:DW 的 cursor 列是 String(如 updated_date: Nullable(String),
// 值形如 'YYYY-MM-DD HH:mm:ss')→ WHERE 比较是字典序。游标存的是 run_start ISO
// ('2026-06-09T03:30:00.012Z'),第 11 字符 'T'(0x54) > ' '(0x20) → 所有真实行
// 都"小于"游标 → 增量永远 fetched=0(2026-06-10 服务器实测三天空转)。
// 这里读取侧转成 DW 时区的同款格式,旧游标无需迁移。
cursorValue: lastCursor[table]
? toDwCursorLiteral(lastCursor[table], manifest.timezone)
: null,
},
]),
),
};
......@@ -2154,6 +2164,29 @@ function synthesizeDemoPhone(externalId: string): string {
return `138${last8}`;
}
/// 增量游标 → DW 字符串列可比的字面量。
/// DW 的 cursor 列是 String('YYYY-MM-DD HH:mm:ss[.SSS]',宿主本地时区)→ 比较走字典序;
/// 游标存的是 run_start ISO(带 'T'/'Z')→ 'T' 比 ' ' 字典序大,所有真实行都排在游标前
/// → fetched 永远 0。这里把 ISO 转成 DW 时区的 'YYYY-MM-DD HH:mm:ss'(秒精度,不带毫秒:
/// 同秒带 .SSS 的行值字典序更大 → 仍会被 > 选中,轻微重叠由 source_event_id 幂等吃掉)。
/// 已是普通格式的值(历史行值推进的游标)原样返回。
function toDwCursorLiteral(value: string, timeZone: string): string {
if (!/\d{4}-\d{2}-\d{2}T/.test(value)) return value;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
// sv-SE locale 恰好输出 'YYYY-MM-DD HH:mm:ss'
return new Intl.DateTimeFormat('sv-SE', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(d);
}
/// 回访治疗项「大类 · 子项」合并值归一(transforms H 的 concat)。
/// 两列皆空 → 合并出来只剩 " · "(纯分隔符/空白)→ 视为无治疗项,归 null;有真实内容则 trim 返回。
function normalizeMergedItems(v: string | undefined): string | null {
......
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