Commit 2cfc043c by luoqi

fix(sync): fact 变更检测纳入四时间锚 — 宿主纠正事件时间可传导为新版本

FactWriter 此前只比 content hash + status:occurredAt/plannedFor/validFrom/validUntil
不参与 → 宿主纠正事件时间(如 FRIDAY 伪 UTC 整体 -8h)重摄永远 evidence_appended,
时间锚永不更新。时间锚是时间轴/召回窗口 COALESCE(occurred_at,planned_for)/过期 cron
的直接输入,属事实实质 → 纳入等值判定(毫秒精度,双 null 视为等),单写与 bulk 两路同步。
不纳入 title/summary(展示层)。含 supersede 传导测试。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent e1697a0d
......@@ -14,6 +14,11 @@ function factVersionKey(sourceUnit: string, subjectId: string): string {
return `${sourceUnit}#${subjectId}`;
}
/** 时刻相等 — 双方皆缺(null/undefined)视为等;毫秒精度(列是 timestamptz(3))。 */
function sameInstant(a: Date | null | undefined, b: Date | null | undefined): boolean {
return (a?.getTime() ?? null) === (b?.getTime() ?? null);
}
/**
* FactWriter — patient_facts 版本流写入器
*
......@@ -21,9 +26,9 @@ function factVersionKey(sourceUnit: string, subjectId: string): string {
*
* 行为(对单 subject_id):
* 1. 找当前 active 版本(partial UNIQUE 保证唯一)
* 2. 计算 draft.content 的稳定 hash;
* ┌─ 跟 active 一致 → 幂等(把当前 transactionId 追加到 active.transactionIds,如未存在)
* └─ 不一致 / 无 active → supersede 旧版本 → 插入新版本 version = old.version + 1(无则 1)
* 2. 计算 draft.content 的稳定 hash;比较 hash + status + 时间锚(occurredAt/plannedFor/validFrom/validUntil)
* ┌─ 一致 → 幂等(把当前 transactionId 追加到 active.transactionIds,如未存在)
* └─ 任一不同 / 无 active → supersede 旧版本 → 插入新版本 version = old.version + 1(无则 1)
*
* 并发说明:
* - W2 是单进程顺序写,不需要锁
......@@ -117,8 +122,9 @@ export class FactWriter {
const latestHash = this.hashContent(latest.content as Prisma.InputJsonValue);
const sameContent = latestHash === newHash;
const sameStatus = latest.status === draftStatus;
const sameTemporal = this.sameTemporalAnchors(latest, validatedDraft);
if (sameContent && sameStatus) {
if (sameContent && sameStatus && sameTemporal) {
// 完全一致 — 幂等。把当前 transaction 加进证据数组(去重)
if (!latest.transactionIds.includes(transactionId)) {
await tx.patientFact.update({
......@@ -140,7 +146,7 @@ export class FactWriter {
};
}
// 有变化(content 或 status)— 如果 latest 是 active,supersede 它;
// 有变化(content / status / 时间锚)— 如果 latest 是 active,supersede 它;
// 终态版本(cancelled/fulfilled/expired/invalidated/superseded)不动,只追新版本。
if (latest.status === FactStatus.ACTIVE) {
await tx.patientFact.update({
......@@ -258,6 +264,10 @@ export class FactWriter {
hash: string;
transactionIds: string[];
sourceUpdatedAt: Date | null;
occurredAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
}>();
for (const [k, latest] of latestBySubject.entries()) {
liveLatest.set(k, {
......@@ -267,6 +277,10 @@ export class FactWriter {
hash: this.hashContent(latest.content as Prisma.InputJsonValue),
transactionIds: latest.transactionIds,
sourceUpdatedAt: latest.sourceUpdatedAt ?? null,
occurredAt: latest.occurredAt ?? null,
plannedFor: latest.plannedFor ?? null,
validFrom: latest.validFrom ?? null,
validUntil: latest.validUntil ?? null,
});
}
......@@ -294,7 +308,12 @@ export class FactWriter {
continue;
}
if (live && live.hash === entry.hash && live.status === draftStatus) {
if (
live &&
live.hash === entry.hash &&
live.status === draftStatus &&
this.sameTemporalAnchors(live, entry.draft)
) {
// 内容一致 + 状态一致
if (live.id && !live.transactionIds.includes(entry.transactionId)) {
toEvidenceAppend.push({ factId: live.id, transactionId: entry.transactionId });
......@@ -354,6 +373,10 @@ export class FactWriter {
hash: entry.hash,
transactionIds: [entry.transactionId],
sourceUpdatedAt: entry.sourceUpdatedAt ?? null,
occurredAt: entry.draft.occurredAt ?? null,
plannedFor: entry.draft.plannedFor ?? null,
validFrom: entry.draft.validFrom ?? null,
validUntil: entry.draft.validUntil ?? null,
});
results.push({
action: live ? 'superseded' : 'created',
......@@ -394,6 +417,33 @@ export class FactWriter {
}
/**
* 时间锚相等判定 — 变更检测的组成部分(与 content hash / status 并列)。
*
* occurredAt / plannedFor / validFrom / validUntil 是事实实质:时间轴排序、
* 召回窗口(COALESCE(occurred_at, planned_for))、过期 cron(valid_until)都直接消费。
* 宿主纠正事件时间(如时区修复整体平移)时,content 往往不变 — 只比 hash+status
* 会让纠正永远停在 evidence_appended,occurred_at 无法更新(时间锚不参与检测的旧 bug)。
*
* 不纳入:title / summary(展示层,规则引擎不读)、clinicId(如需纳入另行评估)。
*/
private sameTemporalAnchors(
latest: {
occurredAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
},
draft: Pick<FactDraft, 'occurredAt' | 'plannedFor' | 'validFrom' | 'validUntil'>,
): boolean {
return (
sameInstant(latest.occurredAt, draft.occurredAt) &&
sameInstant(latest.plannedFor, draft.plannedFor) &&
sameInstant(latest.validFrom, draft.validFrom) &&
sameInstant(latest.validUntil, draft.validUntil)
);
}
/**
* 稳定 JSON hash — 递归按 key 排序后 sha256。
* JSON.stringify 默认按 insertion order,key 顺序不同会算出不同 hash。
*/
......
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