Commit fa9ba3be by luoqi

fix(sync): preserve UTC timestamps in Friday cold exports

parent fa09e5ff
Pipeline #3815 failed in 0 seconds
...@@ -62,8 +62,8 @@ if [ -n "$CLINICS$SINCE" ]; then ...@@ -62,8 +62,8 @@ if [ -n "$CLINICS$SINCE" ]; then
let ids = null; let ids = null;
if (cl.length) ids = db.med_emr_info.distinct("patientId", Object.assign({ organizationId: { $in: cl.flatMap(both) } }, base)).map(S); if (cl.length) ids = db.med_emr_info.distinct("patientId", Object.assign({ organizationId: { $in: cl.flatMap(both) } }, base)).map(S);
if (sn) { if (sn) {
// clinicTime 是"北京墙钟伪装成 UTC"的 Date(见下方 EMR 导出注释)→ cutoff 按 Z 构造即对齐存储 // --since 按宿主北京时间零点筛选;Mongo Date 保存真实 UTC 瞬间。
const set = new Set(db.med_emr_info.distinct("patientId", Object.assign({ clinicTime: { $gte: new Date(sn + "T00:00:00Z") } }, base)).map(S)); const set = new Set(db.med_emr_info.distinct("patientId", Object.assign({ clinicTime: { $gte: new Date(sn + "T00:00:00+08:00") } }, base)).map(S));
ids = ids === null ? Array.from(set) : ids.filter((x) => set.has(x)); ids = ids === null ? Array.from(set) : ids.filter((x) => set.has(x));
} }
print(JSON.stringify(ids ?? [])); print(JSON.stringify(ids ?? []));
...@@ -171,14 +171,12 @@ mysql_csv "SELECT id,tenant_id,organization_id,customer_id,task_director_id,task ...@@ -171,14 +171,12 @@ mysql_csv "SELECT id,tenant_id,organization_id,customer_id,task_director_id,task
echo "── Mongo 导出(med_emr_info,status∈{3,4} 正式病历)──" echo "── Mongo 导出(med_emr_info,status∈{3,4} 正式病历)──"
# 不用 mongoexport:Extended JSON 会把 Date/Long 包成 {$date}/{$numberLong} 对象,破坏平铺契约。 # 不用 mongoexport:Extended JSON 会把 Date/Long 包成 {$date}/{$numberLong} 对象,破坏平铺契约。
# mongosh 手动平铺:Date→naive 北京墙钟字符串,Long→字符串,数组(diag/treat/dispose/examine)原样保留 # mongosh 手动平铺:Date→带 Z 的 ISO 8601 字符串,Long→字符串,数组(diag/treat/dispose/examine)原样保留
# (PAC split_json_array 原生支持已 parse 数组)。产出 JSON 数组文件(cold-import json 格式)。 # (PAC split_json_array 原生支持已 parse 数组)。产出 JSON 数组文件(cold-import json 格式)。
# #
# ⚠️ Date 时区实证(2026-07-20,clinicTime/createdGmtAt 小时分布双证):FRIDAY 写库时把 # Mongo Date 保存真实 UTC 瞬间(2026-10-15 按同一预约的 MySQL 到诊时间与 Mongo clinicTime 核实)。
# 北京墙钟"伪装成 UTC"存进 Mongo Date(直读 UTC 字段=营业时间双峰 上午7-10/下午13-17; # 保留 toISOString() 的 Z 和毫秒;截掉 Z 会被 PAC 按 Asia/Shanghai 再转换,导致时间提前 8 小时。
# 按真 UTC 解释则一半病历落深夜——荒谬)。故导出**不能** toISOString 带 Z(会被 PAC 当真 # MySQL 无时区 datetime 仍按宿主北京时间导出,由 PAC 统一归一。
# UTC 再 +8,EMR 链时间全偏 8 小时,已踩):把 UTC 字段值直读为墙钟,输出 naive
# "YYYY-MM-DD HH:mm:ss",交给 manifest timezone=Asia/Shanghai 解释 → 瞬间正确。
# 诊断字典 map(diag_code→std_code)—— diag[] inline std_code 的 within-host join;linkCode 在 Mongo、 # 诊断字典 map(diag_code→std_code)—— diag[] inline std_code 的 within-host join;linkCode 在 Mongo、
# 字典在 MySQL,故预取成 map 挂进 mongo 容器,eval 里逐元素解析(不再 PAC 侧 lookup std_diag)。 # 字典在 MySQL,故预取成 map 挂进 mongo 容器,eval 里逐元素解析(不再 PAC 侧 lookup std_diag)。
printf 'SELECT diag_code,std_code FROM emr.std_diag\n' | docker run -i --rm mysql:8 mysql \ printf 'SELECT diag_code,std_code FROM emr.std_diag\n' | docker run -i --rm mysql:8 mysql \
...@@ -196,7 +194,7 @@ echo " std_diag_map.json: $(node -e 'process.stdout.write(String(Object.keys(JS ...@@ -196,7 +194,7 @@ echo " std_diag_map.json: $(node -e 'process.stdout.write(String(Object.keys(JS
MONGO_ARGS=(-v "$PWD/std_diag_map.json:/std_diag_map.json:ro") MONGO_ARGS=(-v "$PWD/std_diag_map.json:/std_diag_map.json:ro")
if [ -n "$COHORT_IN" ]; then MONGO_ARGS+=(-v "$PWD/cohort_ids.json:/cohort_ids.json:ro" -e "COHORT=1"); fi if [ -n "$COHORT_IN" ]; then MONGO_ARGS+=(-v "$PWD/cohort_ids.json:/cohort_ids.json:ro" -e "COHORT=1"); fi
docker run --rm ${MONGO_ARGS[@]+"${MONGO_ARGS[@]}"} mongo:7 mongosh "$FRIDAY_MONGO_URI" --quiet --eval ' docker run --rm ${MONGO_ARGS[@]+"${MONGO_ARGS[@]}"} mongo:7 mongosh "$FRIDAY_MONGO_URI" --quiet --eval '
const flat = (v) => v instanceof Date ? v.toISOString().slice(0, 19).replace("T", " ") const flat = (v) => v instanceof Date ? v.toISOString()
: (v && v.constructor && v.constructor.name === "Long") ? String(v) : (v && v.constructor && v.constructor.name === "Long") ? String(v)
: v === undefined ? null : v; : v === undefined ? null : v;
// id 类字段字符串化:Mongo 存的是 number/Long 混型,canonical 契约要 string // id 类字段字符串化:Mongo 存的是 number/Long 混型,canonical 契约要 string
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { normalizeDatetime } from '../src/modules/sync/assembler/field-mapper';
// 执行脚本内实际交给 mongosh 的代码,无需连接数据库或运行完整导出。
const script = readFileSync(join(__dirname, '../data/friday/export.sh'), 'utf8');
const programs = Array.from(script.matchAll(/--eval '([\s\S]*?)'/g), (m) => m[1]!);
if (programs.length !== 2) throw new Error('未找到 cohort 与病历导出的 mongosh 程序');
const [cohortProgram, exportProgram] = programs as [string, string];
describe('FRIDAY Mongo 导出时间', () => {
it.each([
'2026-10-15T06:40:00.000Z', // 北京 14:40
'2026-10-14T16:00:00.000Z', // 北京次日零点
'2026-10-15T13:36:24.634Z', // 保留源时间毫秒
])('导出后经 Asia/Shanghai 归一仍是原始时刻: %s', (instant) => {
const output: string[] = [];
runInNewContext(exportProgram, {
Date,
print: (line: string) => output.push(line),
require: () => ({
readFileSync: (path: string) => {
if (path === '/std_diag_map.json') return '{}';
throw new Error('全量导出无 cohort 文件');
},
}),
db: {
med_emr_info: {
find: () => ({
forEach: (visit: (row: Record<string, unknown>) => void) => visit({
emrSubId: 'emr-1', patientId: 123, status: 3,
clinicTime: new Date(instant),
createdGmtAt: new Date(instant),
updatedGmtAt: new Date(instant),
}),
}),
},
},
});
const [row] = JSON.parse(output.join('\n')) as Array<Record<string, unknown>>;
for (const field of ['clinic_time', 'created_gmt_at', 'updated_gmt_at']) {
expect(row![field]).toBe(instant);
expect(new Date(normalizeDatetime(String(row![field]), 'Asia/Shanghai')).toISOString())
.toBe(instant);
}
});
it('按北京时间 --since 零点选人,包含当天前 8 小时并排除前一天', () => {
const rows = [
{ patientId: 'before', status: 3, clinicTime: new Date('2026-10-14T15:59:59.999Z') },
{ patientId: 'midnight', status: 3, clinicTime: new Date('2026-10-14T16:00:00Z') },
{ patientId: 'early', status: 4, clinicTime: new Date('2026-10-14T23:59:59Z') },
{ patientId: 'later', status: 3, clinicTime: new Date('2026-10-15T00:00:00Z') },
{ patientId: 'draft', status: 2, clinicTime: new Date('2026-10-15T00:00:00Z') },
];
let result: string[] = [];
runInNewContext(cohortProgram, {
Date,
process: { env: { SN: '2026-10-15' } },
print: (line: string) => { result = JSON.parse(line) as string[]; },
db: {
med_emr_info: {
distinct: (_field: string, query: { status: { $in: number[] }; clinicTime: { $gte: Date } }) =>
rows.filter((r) => query.status.$in.includes(r.status)
&& r.clinicTime >= query.clinicTime.$gte).map((r) => r.patientId),
},
},
});
expect(result).toEqual(['midnight', 'early', 'later']);
});
});
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