Commit cd8ee4c8 by luoqi

fix(sync): 宿主 id 去浮点尾巴 + 回执透出空壳患者数 —— 挡住幽灵患者

事故(2026-07-28 19:19,测试服务器):FRIDAY 推 med_emr_info 时 patient_id 变成了
浮点字符串 "676600.0"(典型 pandas 症状:列含空值 → int64 提升 float64 → 导出带 .0)。
PAC 原样收下,患者索引查 "676600.0" 命中不了真档 "676600" → ensurePatientStub
建出无名幽灵患者。3 个幽灵档吸走 4224 条事务,其中一个带着 2246 条诊断进了召回池、
算了画像、生成「应治未治」计划排队等客服打电话 —— 而推送回执是干净的 failed=0,
宿主和我们都看不出来,直到有人在池子里看见一个没名字的患者。

两层防御:

1) 归一层去尾巴(field-mapper.normalizeCanonical)
   `Id` 后缀的 canonical 字段,值形如 "676600.0" / "676600.00" → 截成 "676600"。
   用后缀约定而非逐资源枚举,新增 canonical 字段自动纳入。只处理**整数值**的浮点
   写法,不动 "676600.5" —— 那是另一种问题,应该显形而不是被悄悄改写。
   放在 normalizeCanonical 意味着 cold-import / push / pull / reparse 行为一致。

2) 回执透出 patientStubsCreated(通用信号)
   空壳 >0 本身不是错(「推送顺序无要求」正是靠它兜底),但**持续 >0 说明患者号
   对不上** —— id 格式漂移只是其中一种表现形式,主档漏推同样会触发。宿主据此自检,
   不必等人肉在召回池里发现幽灵。同时 logger.warn 提示常见成因。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent d928dfe1
Pipeline #3477 failed in 0 seconds
......@@ -93,6 +93,7 @@ export function applyMapping(
/**
* 在 zod 校验前做轻量类型规范化。规则:
* - id 去浮点尾巴:"676600.0" → "676600"(宿主把整数 id 过了一遍 float)
* - boolean coerce:0/1/"y"/"n"/"true"/"false"/"是"/"否" → bool
* - gender:"男" → "M"、"女" → "F"(其他自由文本直通)
* - amount:amountUnit=yuan 时 ×100 转 cents int(已 int 不转)
......@@ -107,6 +108,14 @@ export function normalizeCanonical(
const meta = CanonicalResourceMeta[resource];
const out: Record<string, unknown> = { ...raw };
// ─ id 去浮点尾巴 ─(必须在最前:后续所有身份解析都用归一后的值)
for (const k of Object.keys(out)) {
if (isIdLikeKey(k)) {
const fixed = stripFloatTail(out[k]);
if (fixed !== null) out[k] = fixed;
}
}
// ─ boolean coerce ─
for (const k of meta.booleanFields) {
if (k in out) {
......@@ -181,6 +190,39 @@ export function normalizeCanonical(
// ─ helpers ─
/**
* 身份类字段判定 —— canonical 命名里 id 一律以 `Id` 结尾
* (externalId / patientExternalId / relatedExternalId / clinicId / doctorId /
* appointmentExternalId / sourceEncounterExternalId / relatedDiagnosisSubjectId …)。
* 用后缀约定而不是逐资源枚举:新增 canonical 字段自动纳入,不会漏配。
*/
export function isIdLikeKey(key: string): boolean {
return key.length > 2 && key.endsWith('Id') && key !== 'Id';
}
/**
* 去掉整数 id 被 float 化后的尾巴:"676600.0" → "676600"。
*
* 【为什么必须防】宿主导出脚本(尤其 pandas)在列含空值时会把 int64 提升成 float64,
* 导出就变成 "676600.0"。PAC 原样收下 → 患者索引查 "676600.0" 命中不了真档 "676600"
* → 走 ensurePatientStub 建出**无名幽灵患者**,把该批病历全吸走。
* 2026-07-28 19:19 friday 推 med_emr_info 就踩了:3 个幽灵档吃掉 4224 条事务,
* 其中一个带着 2246 条诊断进了召回池、算了画像、生成「应治未治」计划等客服打电话,
* 而回执是干干净净的 failed=0 —— 只有人肉在池子里看到无名患者才会发现。
*
* 【口径】只处理**整数值**的浮点写法(`123.0` / `123.00`),不动 `123.5` 这种
* 真带小数的值 —— 那说明宿主那边是另一种问题,应该让它显形而不是被悄悄改写。
* 返回 null = 无需改写。
*/
export function stripFloatTail(v: unknown): string | null {
// 只管字符串:JSON number 676600.0 在 JS 里就是整数 676600,String() 本来就没尾巴
if (typeof v !== 'string') return null;
const s = v.trim();
// ^ 可选负号 + 至少一位数字 + 小数点 + 全 0 $
if (!/^-?\d+\.0+$/.test(s)) return null;
return s.slice(0, s.indexOf('.'));
}
function coerceBoolean(v: unknown): boolean | null {
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v !== 0;
......
......@@ -1687,6 +1687,7 @@ export class ColdImportService {
let patientId = idx.get(patientIndexKey(sourceUnit, patientExternalId));
if (!patientId) {
patientId = await this.ensurePatientStub(hostId, tenantId, sourceUnit, patientExternalId);
stats.patientStubsCreated++;
idx.set(patientIndexKey(sourceUnit, patientExternalId), patientId);
}
const relatedPatientId = idx.get(patientIndexKey(sourceUnit, relatedExternalId)) ?? null;
......@@ -1775,6 +1776,7 @@ export class ColdImportService {
let patientId = idx.get(patientIndexKey(sourceUnit, patientExternalId));
if (!patientId) {
patientId = await this.ensurePatientStub(hostId, tenantId, sourceUnit, patientExternalId);
stats.patientStubsCreated++;
idx.set(patientIndexKey(sourceUnit, patientExternalId), patientId);
}
......@@ -1896,6 +1898,7 @@ export class ColdImportService {
const patientExternalId = canonicalRow.patientExternalId as string | undefined;
if (patientExternalId && !patientIndex.has(patientIndexKey(sourceUnit, patientExternalId))) {
const stubId = await this.ensurePatientStub(hostId, tenantId, sourceUnit, patientExternalId);
stats.patientStubsCreated++;
patientIndex.set(patientIndexKey(sourceUnit, patientExternalId), stubId);
}
const txn = this.synthesizer.synthesize({
......@@ -2415,6 +2418,7 @@ export class ColdImportService {
sampleCanonical: [],
mappingMisses: [],
suspectFields: [],
patientStubsCreated: 0,
};
}
}
......@@ -2490,6 +2494,10 @@ export interface PerResourceStats extends TotalsBlock {
mappingMisses: MappingMiss[];
/// 字段/列漂移(assembler 透传):整列缺席的映射字段(疑似宿主改名/删列)
suspectFields: SuspectField[];
/// 本批因「引用的患者不在 PAC」而新建的空壳主档数。
/// >0 不一定是错(push 顺序无关本就靠空壳兜底),但**持续 >0 说明宿主的患者号对不上** ——
/// 典型是 id 格式漂移(整数被 float 化成 "676600.0")造出的幽灵患者。透出给宿主自检。
patientStubsCreated: number;
}
/**
......
......@@ -69,8 +69,9 @@ export class PushReceiverService {
dup: a.dup + s.duplicates,
failed: a.failed + s.failed,
factsFailed: a.factsFailed + s.factsFailed,
stubs: a.stubs + s.patientStubsCreated,
}),
{ txn: 0, dup: 0, failed: 0, factsFailed: 0 },
{ txn: 0, dup: 0, failed: 0, factsFailed: 0, stubs: 0 },
);
// fact.content 被 schema 拒收的明细 —— 必须透出去,否则宿主只能看到"成功但没进数据"。
......@@ -115,8 +116,17 @@ export class PushReceiverService {
this.logger.log(
`push/rows host=${hostName} source=${source} rows=${rows.length} ` +
`txn=${agg.txn} dup=${agg.dup} failed=${agg.failed} personaEnqueued=${personaEnqueued}`,
`txn=${agg.txn} dup=${agg.dup} failed=${agg.failed} stubs=${agg.stubs} ` +
`personaEnqueued=${personaEnqueued}`,
);
// 空壳 = 引用的患者号在 PAC 找不到。顺序无关本就靠它兜底,但持续 >0 说明患者号对不上
// (id 格式漂移 / 主档漏推),会造出无名幽灵患者混进召回池 —— 必须让宿主看见。
if (agg.stubs > 0) {
this.logger.warn(
`push/rows host=${hostName} source=${source} 新建了 ${agg.stubs} 个空壳患者主档 —— ` +
`若非首次推送该表,请核对 patient_id 格式(常见:整数被 float 化成 "676600.0")`,
);
}
// 预检:回样本 canonical(各资源首行),供宿主核对 PAC 把原生行翻译成了什么
const samples = dryRun
......@@ -137,6 +147,8 @@ export class PushReceiverService {
/// 上面那些拒收的定位明细(封顶若干条),宿主据此定位到自己源库的具体行
factRejects,
personaEnqueued,
/// 本批新建的空壳患者主档数(引用的患者号在 PAC 找不到)。持续 >0 = 患者号对不上
patientStubsCreated: agg.stubs,
mappingMisses: r.mappingMisses.length,
suspectFields: r.suspectFields.length,
dryRun,
......
......@@ -107,6 +107,15 @@ export const PushRowsResponseSchema = z.object({
)
.describe('拒收明细样本(封顶 10 条;不含 content 原文,避免患者信息随回执外流)'),
personaEnqueued: z.number().int().describe('入队画像重算的患者数(plan 不在此触发,由定时任务保)'),
/**
* 本批因「引用的患者号在 PAC 找不到」而新建的空壳患者主档数。
*
* >0 本身不是错 —— 文档承诺的「推送顺序无要求」正是靠空壳兜底(先推病历后推主档也能落)。
* 但**持续 >0 说明患者号对不上**,典型是 id 格式漂移:宿主导出脚本把整数 id 过了一遍
* float,推来 "676600.0",PAC 匹配不到真档 "676600" → 建出无名幽灵患者,把整批病历吸走,
* 还会被算画像、进召回池等客服打电话。2026-07-28 friday 推 med_emr_info 实际踩过。
*/
patientStubsCreated: z.number().int().describe('新建空壳患者主档数(持续 >0 = 患者号对不上)'),
/// 宿主自检信号:样本批推完看这两个数,>0 先停下联系 PAC。
mappingMisses: z.number().int().describe('映射覆盖缺口种类数(有原值落 _default)'),
suspectFields: z.number().int().describe('疑似宿主改表项数(列缺席/新增,vs 历史基线)'),
......
import { normalizeCanonical, isIdLikeKey, stripFloatTail } from '../src/modules/sync/assembler/field-mapper';
/**
* 宿主 id 被 float 化的防御 —— "676600.0" → "676600"。
*
* 事故(2026-07-28 19:19,测试服务器):
* FRIDAY 推 med_emr_info 时 patient_id 变成了浮点字符串(典型 pandas 症状:
* 列含空值 → int64 提升成 float64 → 导出 "676600.0")。PAC 原样收下,
* 患者索引查 "676600.0" 命中不了真档 "676600" → ensurePatientStub 建出无名幽灵患者。
* 3 个幽灵档吸走 4224 条事务,其中一个带 2246 条诊断进了召回池、算了画像、
* 生成「应治未治」计划排队等客服打电话 —— 而推送回执是干干净净的 failed=0。
*
* 归一放在 normalizeCanonical(所有摄入通道共用),所以 cold-import / push / pull /
* reparse 行为一致。
*/
describe('宿主 id 去浮点尾巴', () => {
describe('stripFloatTail', () => {
test.each([
['676600.0', '676600'],
['676600.00', '676600'],
['0.0', '0'],
['-12.0', '-12'],
])('%s → %s', (input, expected) => {
expect(stripFloatTail(input)).toBe(expected);
});
test.each([
['676600'], // 本来就是整数写法
['676600.5'], // 真带小数 —— 说明是另一种问题,要让它显形而不是被悄悄改写
['A676600.0'], // 非纯数字前缀
['676600.0x'],
[''],
['abc'],
])('%s 不改写', (input) => {
expect(stripFloatTail(input)).toBeNull();
});
test('非字符串不处理(JSON number 676600.0 在 JS 里就是整数,String() 无尾巴)', () => {
expect(stripFloatTail(676600)).toBeNull();
expect(stripFloatTail(null)).toBeNull();
expect(stripFloatTail(undefined)).toBeNull();
});
});
describe('isIdLikeKey —— 用 `Id` 后缀约定,新增 canonical 字段自动纳入', () => {
test.each(['externalId', 'patientExternalId', 'relatedExternalId', 'clinicId', 'doctorId'])(
'%s 是身份字段',
(k) => expect(isIdLikeKey(k)).toBe(true),
);
test.each(['name', 'phone', 'amountCents', 'occurredAt', 'Id', 'id'])(
'%s 不是',
(k) => expect(isIdLikeKey(k)).toBe(false),
);
});
describe('normalizeCanonical 端到端', () => {
const ctx = { amountUnit: 'yuan' as const, timezone: 'Asia/Shanghai' };
test('⭐ 事故原样复现:patientExternalId "676600.0" 归一成 "676600"', () => {
const out = normalizeCanonical(
{ externalId: 'e1', patientExternalId: '676600.0', clinicId: '9001.0' },
'encounter',
ctx,
);
expect(out.patientExternalId).toBe('676600');
expect(out.clinicId).toBe('9001');
});
test('非 id 字段不受影响(金额仍走 yuan→cents,不被当成 id 截断)', () => {
const out = normalizeCanonical(
{ externalId: '1.0', name: '3.0', amount: 12.5 },
'payment',
ctx,
);
expect(out.externalId).toBe('1');
expect(out.name).toBe('3.0'); // 姓名字段原样,不碰
expect(out.amount).toBe(1250); // 金额照常 ×100 转分,没被当成 id 截断
});
test('正常 id 不被改动', () => {
const out = normalizeCanonical({ externalId: '676600', patientExternalId: 'P-001' }, 'patient', ctx);
expect(out.externalId).toBe('676600');
expect(out.patientExternalId).toBe('P-001');
});
});
});
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