Commit 6002f215 by luoqi

fix: 召回/治疗链口径对齐 + 诊断时间精度 + 展示修复(本地核验一轮)

诊断时间精度(摄入):
- diagnosis/treatment/recommendation/review 的 occurredAt 从 rq(纯日期→零点)改 created_date
  (精确就诊时刻,实测 100% 同 rq 日 + 就诊时段分布)→ 跟带时间的预约能正确比先后
  (修 Miyabe Juria 阻生牙被同次就诊预约误判"已进入"而误排召回)

召回/治疗链口径对齐(chain-composer 镜像 scenario,消除"召回有/链没有"分叉):
- 无牙位 perio/ortho actual = 全口覆盖(镜像 ⑤a b0b9705a)→ 曾松柏 K06@45;46 显示在管
- 桶有同牙位 category actual(笼统 subtype milestone 没匹配)→ ongoing 不再 discovered(Ammu 36 bare"种植")
- recommendation-only 链(只 planned_for)gapDays 用 effectiveTime → 不再被 cooldown 误滤(36 建议戴冠)
- 废用牙/无功能牙不立"种植修复·发现机会"潜在链,改中性标签 + 建议拔除/观察(常量收口到 canonical-codes)
- milestone 关键词补术式全称/缩写:根管治疗/RCT、种植修复/戴牙/即拔即种、正畸、牙周基础治疗 等
- implant 加 terminalSteps(戴冠=完成)+ 种植二/三期归 placement

展示:
- 召回理由"距今天数"实时化(signalOccurredAt 锚点,与治疗链断口同源,免随天数陈旧)
- 年龄缺失显示"?"岁(对齐列表页,不再"0岁")
- 再启 banner 用真实再诊断日/名 + emr 真实医嘱(替硬编码"医嘱建议X个月内")

96 测试通过;本地 100 患者已按此重摄(rq→created_date)+ 重算核验。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent f048aa96
...@@ -21,7 +21,7 @@ field_mapping: ...@@ -21,7 +21,7 @@ field_mapping:
createdAt: created_date createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq occurredAt: created_date # 就诊精确时刻(rq 只到日→零点,无法跟带时间的预约比先后);created_date=初次录入=就诊点,100% 同 rq 日
sourceEncounterExternalId: emr_id sourceEncounterExternalId: emr_id
code: diag_code_src # K 码 或 归一中文名 → enum_mapping 翻译 code: diag_code_src # K 码 或 归一中文名 → enum_mapping 翻译
stdCodeRaw: std_code # 溯源:判 code_source(std_code / name_map / null) stdCodeRaw: std_code # 溯源:判 code_source(std_code / name_map / null)
......
...@@ -19,7 +19,7 @@ field_mapping: ...@@ -19,7 +19,7 @@ field_mapping:
createdAt: created_date createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq occurredAt: created_date # 精确就诊时刻(rq 只到日);created_date=医生写建议的时点,100% 同 rq 日
code: treat_name # "建议种植" 等 → enum_mapping 翻译到 PAC 推荐码 code: treat_name # "建议种植" 等 → enum_mapping 翻译到 PAC 推荐码
toothPosition: tooth_position toothPosition: tooth_position
doctorId: user_id # 建议医生(从 emr 父级继承) doctorId: user_id # 建议医生(从 emr 父级继承)
......
...@@ -30,7 +30,7 @@ field_mapping: ...@@ -30,7 +30,7 @@ field_mapping:
createdAt: created_date createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq # treat_plan 已发生,rq=就诊日,actual 时点 occurredAt: created_date # 精确就诊时刻(rq 只到日);created_date=医生录治疗的时点,100% 同 rq 日,可跟预约/其它事件比先后
sourceEncounterExternalId: emr_id sourceEncounterExternalId: emr_id
category: category_raw # transforms 已带 treat_name 原文,这里 enum_mapping 翻译 category: category_raw # transforms 已带 treat_name 原文,这里 enum_mapping 翻译
subtype: treat_name # 原始 treatName 留作 subtype(给 chain milestone 字典 fallback 匹配) subtype: treat_name # 原始 treatName 留作 subtype(给 chain milestone 字典 fallback 匹配)
......
...@@ -29,7 +29,7 @@ field_mapping: ...@@ -29,7 +29,7 @@ field_mapping:
createdAt: created_date createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq # rq=就诊日(医生当天写的计划);plannedFor 由 parser 用此填 occurredAt: created_date # 精确就诊时刻(医生当天写计划的时点;rq 只到日);plannedFor 由 parser 用此填,100% 同 rq 日
sourceEncounterExternalId: emr_id sourceEncounterExternalId: emr_id
category: category_raw # transforms 已带 treat_name 原文,这里 enum_mapping 翻译 category: category_raw # transforms 已带 treat_name 原文,这里 enum_mapping 翻译
subtype: treat_name # 原始 treatName 留作 subtype subtype: treat_name # 原始 treatName 留作 subtype
......
...@@ -21,7 +21,7 @@ field_mapping: ...@@ -21,7 +21,7 @@ field_mapping:
createdAt: created_date createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq occurredAt: created_date # 精确就诊时刻(rq 只到日);created_date=就诊点,100% 同 rq 日
sourceEncounterExternalId: emr_id sourceEncounterExternalId: emr_id
category: category_raw # transforms 已固定填 _review_sentinel category: category_raw # transforms 已固定填 _review_sentinel
subtype: treat_name # 原始动作名("常规复查"/"拆线"等) subtype: treat_name # 原始动作名("常规复查"/"拆线"等)
......
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { maskName, maskPhone } from '@pac/utils'; import { maskName, maskPhone } from '@pac/utils';
import { applyLiveDays } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service'; import { ChainComposerService } from '../plan/engine/chain-composer.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -335,7 +336,11 @@ function serializePlan(plan: { ...@@ -335,7 +336,11 @@ function serializePlan(plan: {
/// (不含 ruleIds — 算法版本追溯靠 git + plan.createdAt) /// (不含 ruleIds — 算法版本追溯靠 git + plan.createdAt)
breakdown: r.breakdown, breakdown: r.breakdown,
/// W3 末:结构化召回信号(raw enum / canonical code,前端字典翻译富文本) /// W3 末:结构化召回信号(raw enum / canonical code,前端字典翻译富文本)
signals: r.signals, /// daysSince 用锚点 signalOccurredAt 实时重算 → 跟治疗链断口(同请求 server 时刻)精确一致,
/// 不再随天数陈旧("388/389"漂移);旧 plan 无锚点 → 原样回落 baked。
signals: applyLiveDays(
r.signals as { daysSince?: number; signalOccurredAt?: string | null } | null,
),
})), })),
}; };
} }
......
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
PlanScenario, PlanScenario,
DiagnosisTreatmentMap, DiagnosisTreatmentMap,
APPT_COMPLAINT_TO_CATEGORY, APPT_COMPLAINT_TO_CATEGORY,
RESTORATION_INELIGIBLE_DX_NAMES,
lookupDxTreatment, lookupDxTreatment,
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZh, treatmentCategoryNameZh,
...@@ -69,7 +70,8 @@ import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util'; ...@@ -69,7 +70,8 @@ import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util';
/// → 不进 K08 种植召回。host 原文 name_zh 在 diagnosis_record.content 留底,据此精确剔除。 /// → 不进 K08 种植召回。host 原文 name_zh 在 diagnosis_record.content 留底,据此精确剔除。
/// 案例:韩雷 38;48 / 826790 18;28;38;48 都是"废用牙"被误当缺牙召回种植(91 分误召)。 /// 案例:韩雷 38;48 / 826790 18;28;38;48 都是"废用牙"被误当缺牙召回种植(91 分误召)。
/// 注:这跟"真缺失"(缺失/牙列缺损 → 该修复)区分开;拔牙本身不做泛召回(详见 docs 讨论)。 /// 注:这跟"真缺失"(缺失/牙列缺损 → 该修复)区分开;拔牙本身不做泛召回(详见 docs 讨论)。
const RESTORATION_INELIGIBLE_NAMES = ['废用牙', '无功能牙']; /// ⭐ 单一真理源(canonical-codes):chain-composer 同表 → 不立"种植修复·发现机会"潜在链,口径一致。
const RESTORATION_INELIGIBLE_NAMES = [...RESTORATION_INELIGIBLE_DX_NAMES];
@Injectable() @Injectable()
export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...@@ -500,6 +502,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -500,6 +502,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
triggers: r.cluster_triggers ?? [{ type: triggerType, code: r.signal_code }], triggers: r.cluster_triggers ?? [{ type: triggerType, code: r.signal_code }],
toothPosition: r.tooth ?? null, toothPosition: r.tooth ?? null,
daysSince: r.days_since, daysSince: r.days_since,
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...excludeCats], expectedCategories: [...excludeCats],
}, },
priorityBreakdown: breakdown, priorityBreakdown: breakdown,
......
...@@ -9,6 +9,7 @@ import type { Prisma } from '@prisma/client'; ...@@ -9,6 +9,7 @@ import type { Prisma } from '@prisma/client';
import { import {
Permission, Permission,
planScenarioLabel, planScenarioLabel,
applyLiveDays,
type ListPlansResponse, type ListPlansResponse,
type PlanCountsResponse, type PlanCountsResponse,
type PlanDetailResponse, type PlanDetailResponse,
...@@ -377,7 +378,8 @@ function toPlanReasonBrief( ...@@ -377,7 +378,8 @@ function toPlanReasonBrief(
id: r.id, id: r.id,
scenario: r.scenario, scenario: r.scenario,
subKey: r.subKey, subKey: r.subKey,
signals: (r.signals as PlanReasonBrief['signals']) ?? null, // daysSince 用锚点 signalOccurredAt 实时重算(跟详情页/治疗链同源,不随天数陈旧);旧 plan 无锚点 → 回落 baked
signals: applyLiveDays((r.signals as PlanReasonBrief['signals']) ?? null),
priorityScore: r.priorityScore, priorityScore: r.priorityScore,
reason: r.reason, reason: r.reason,
breakdown: r.breakdown ?? null, breakdown: r.breakdown ?? null,
......
import { liveDaysSince, applyLiveDays } from '@pac/types';
/**
* 召回理由"距今天数"实时化(修"召回 388 / 治疗链 389 差一天"漂移)。
*
* 根因:plan 生成那一刻把 daysSince 算好写死进 signals(快照),随天数推移陈旧;
* 治疗链断口(gapDays)是 read 时刻实时算的 → 两数差 = 中间过了几天。
* 修法:signals 存不可变锚点 signalOccurredAt,展示层用 chain 同款公式实时重算。
*/
describe('reason live daysSince', () => {
const NOW = new Date('2026-05-30T01:31:00Z');
const OCCURRED = '2025-05-06T00:00:00Z'; // 吴远 K07 诊断日
test('liveDaysSince 跟 chain gapDays 同款 floor 公式(2025-05-06 → 2026-05-30 = 389)', () => {
expect(liveDaysSince(OCCURRED, NOW)).toBe(389);
});
test('liveDaysSince 与 chain-composer 公式逐位等价(Math.floor((now-occurred)/一天))', () => {
const chainGap = Math.floor((NOW.getTime() - new Date(OCCURRED).getTime()) / 86_400_000);
expect(liveDaysSince(OCCURRED, NOW)).toBe(chainGap);
});
test('无锚点 → null(调用方回落 baked daysSince,不回归)', () => {
expect(liveDaysSince(null, NOW)).toBeNull();
expect(liveDaysSince(undefined, NOW)).toBeNull();
expect(liveDaysSince('not-a-date', NOW)).toBeNull();
});
test('未来锚点 clamp 到 0(防负数,满足 schema nonnegative)', () => {
expect(liveDaysSince('2099-01-01T00:00:00Z', NOW)).toBe(0);
});
test('applyLiveDays:有锚点覆盖 daysSince 为实时值(388 快照 → 389 实时)', () => {
const baked = {
subKey: 'ortho_no_consult',
triggers: [{ type: 'diagnosis', code: 'K07' }],
toothPosition: null,
daysSince: 388, // 昨天生成时的快照
signalOccurredAt: OCCURRED,
expectedCategories: ['orthodontic'],
};
const live = applyLiveDays(baked, NOW);
expect(live?.daysSince).toBe(389);
// 不可变:原对象不动
expect(baked.daysSince).toBe(388);
});
test('applyLiveDays:无锚点(旧 plan)→ 原样返回 baked', () => {
const oldPlan = {
subKey: 'ortho_no_consult',
triggers: [{ type: 'diagnosis', code: 'K07' }],
daysSince: 388,
expectedCategories: ['orthodontic'],
};
const out = applyLiveDays(oldPlan, NOW);
expect(out?.daysSince).toBe(388);
expect(out).toBe(oldPlan); // 引用不变(无锚点不拷贝)
});
test('applyLiveDays:null 透传', () => {
expect(applyLiveDays(null, NOW)).toBeNull();
});
});
...@@ -28,7 +28,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -28,7 +28,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
name: real.patient.name ?? '(未知)', name: real.patient.name ?? '(未知)',
nameMasked: real.patient.nameMasked ?? '*', nameMasked: real.patient.nameMasked ?? '*',
gender: real.patient.gender ?? '—', gender: real.patient.gender ?? '—',
age: real.patient.age ?? 0, age: real.patient.age ?? null, // 生日缺失保留 null(渲染 "?" 岁),不要落成 0 岁
birthDate: real.patient.birthDate ?? '', birthDate: real.patient.birthDate ?? '',
phone: real.patient.phone ?? '', phone: real.patient.phone ?? '',
phoneMasked: real.patient.phoneMasked ?? '', phoneMasked: real.patient.phoneMasked ?? '',
...@@ -61,6 +61,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -61,6 +61,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
target: c.target, target: c.target,
diagnosedAt: c.diagnosedAt, diagnosedAt: c.diagnosedAt,
gapDays: c.gapDays, gapDays: c.gapDays,
latestDxAt: c.latestDxAt,
latestDxName: c.latestDxName,
latestDxAdvice: c.latestDxAdvice,
lifecycleNoteZh: c.lifecycleNoteZh, lifecycleNoteZh: c.lifecycleNoteZh,
toothPosition: c.toothPosition, toothPosition: c.toothPosition,
alternativeClosedBy: c.alternativeClosedBy, alternativeClosedBy: c.alternativeClosedBy,
......
...@@ -253,13 +253,20 @@ function TargetTimelineRow({ chain }: { chain: Chain }) { ...@@ -253,13 +253,20 @@ function TargetTimelineRow({ chain }: { chain: Chain }) {
<strong className="font-semibold tabular-nums"> · {formatDaysReadable(chain.gapDays)}未进入治疗链</strong> <strong className="font-semibold tabular-nums"> · {formatDaysReadable(chain.gapDays)}未进入治疗链</strong>
</> </>
) : ( ) : (
// 再启新链:链已 ongoing/entered,gapDays 用最早诊断算误导(实际有 actual) // 再启新链:在管治疗链上又被诊断 → 用后端给的真实再诊断日 + 诊断名(落地证据,不再相对说法)
// 用相对说法,避免误用 diagnosedAt;后续后端给 latestDxAt + lastActualAt 再精化
<> <>
<strong className="font-semibold">再启信号</strong> · 在管治疗链上又被诊断 <strong className="font-semibold">建议再启一轮基础治疗</strong> <strong className="font-semibold">再启信号</strong> ·{' '}
{chain.latestDxAt && chain.latestDxName
? <>{chain.latestDxAt} 又诊断「{chain.latestDxName}</>
: '在管治疗链上又被诊断'}
</> </>
)} )}
<span className="text-rose-600/80 ml-1">{chainTargetMeta(chain.category).window}</span> {/* 落地证据:有真实医嘱(emr doctor_advice)→ 显示「医嘱:X」;没有则不再硬编码"医嘱建议 X 个月内"夸大 */}
{chain.latestDxAdvice ? (
<span className="ml-1">· 医嘱:<strong className="font-semibold">{chain.latestDxAdvice}</strong></span>
) : chain.status !== 'discovered' ? (
<strong className="font-semibold ml-1">建议再启一轮基础治疗</strong>
) : null}
</div> </div>
</div> </div>
</div> </div>
......
...@@ -34,7 +34,7 @@ export const mockPatient = { ...@@ -34,7 +34,7 @@ export const mockPatient = {
name: '张志远', name: '张志远',
nameMasked: '张志*', nameMasked: '张志*',
gender: '男', gender: '男',
age: 42, age: 42 as number | null, // null = 生日缺失,UI 显示 "?" 岁(跟列表页一致)
birthDate: '1984-03-12', birthDate: '1984-03-12',
phone: '13855612937', phone: '13855612937',
phoneMasked: '138****2937', phoneMasked: '138****2937',
...@@ -99,6 +99,10 @@ export type Chain = { ...@@ -99,6 +99,10 @@ export type Chain = {
target?: boolean; target?: boolean;
diagnosedAt?: string; diagnosedAt?: string;
gapDays?: number; gapDays?: number;
/// 最近一次诊断(re-trigger 证据)+ 该次医生真实医嘱(doctor_advice)
latestDxAt?: string;
latestDxName?: string;
latestDxAdvice?: string;
/// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip /// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip
lifecycleNoteZh?: string; lifecycleNoteZh?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示) /// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
......
...@@ -744,7 +744,7 @@ function IdentityCard({ ...@@ -744,7 +744,7 @@ function IdentityCard({
<div className="flex items-center gap-1.5 flex-wrap min-w-0"> <div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-[15px] font-semibold text-slate-900 leading-tight">{patient.name}</span> <span className="text-[15px] font-semibold text-slate-900 leading-tight">{patient.name}</span>
<span className="text-[10.5px] text-slate-500"> <span className="text-[10.5px] text-slate-500">
{formatGender(patient.gender)}·{patient.age} {formatGender(patient.gender)}·{patient.age ?? '?'}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 flex-none"> <div className="flex items-center gap-2 flex-none">
......
...@@ -118,6 +118,10 @@ export type PlanDetailData = { ...@@ -118,6 +118,10 @@ export type PlanDetailData = {
estimatedValueCents?: number; estimatedValueCents?: number;
diagnosedAt?: string; diagnosedAt?: string;
gapDays?: number; gapDays?: number;
/// 最近一次诊断(re-trigger 证据)+ 该次医生真实医嘱
latestDxAt?: string;
latestDxName?: string;
latestDxAdvice?: string;
/// 生命周期提示("终身维护(永不闭环)" 等) /// 生命周期提示("终身维护(永不闭环)" 等)
lifecycleNoteZh?: string; lifecycleNoteZh?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示) /// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
......
...@@ -209,6 +209,20 @@ export const DiagnosisTreatmentMap = { ...@@ -209,6 +209,20 @@ export const DiagnosisTreatmentMap = {
export type DiagnosisTreatmentCode = keyof typeof DiagnosisTreatmentMap; export type DiagnosisTreatmentCode = keyof typeof DiagnosisTreatmentMap;
/**
* "算诊断但无修复必要"的诊断名(host 把它们映射到 K08 缺牙类,但牙还在、只是无功能)。
* 临床:该拔除 / 观察,**不是种植/桥/义齿修复对象**。
*
* **单一真理源**:召回 scenario(剔除出召回池)和 chain-composer(不立"种植修复·发现机会"潜在链,
* 改中性展示 + 建议拔除/观察)共用此表,避免两处口径分叉(canonical-fact-layer「单一收口」)。
*/
export const RESTORATION_INELIGIBLE_DX_NAMES = ['废用牙', '无功能牙'] as const;
/// 判断诊断 name_zh 是否属"无修复必要"(废用牙/无功能牙)
export function isRestorationIneligibleDxName(nameZh: string | null | undefined): boolean {
return !!nameZh && (RESTORATION_INELIGIBLE_DX_NAMES as readonly string[]).includes(nameZh);
}
/// 查表(找不到返回 undefined — 调用方决定如何降级) /// 查表(找不到返回 undefined — 调用方决定如何降级)
export function lookupDxTreatment(code: string): DxTreatmentRule | undefined { export function lookupDxTreatment(code: string): DxTreatmentRule | undefined {
return (DiagnosisTreatmentMap as Record<string, DxTreatmentRule>)[code]; return (DiagnosisTreatmentMap as Record<string, DxTreatmentRule>)[code];
...@@ -362,7 +376,11 @@ export interface TreatmentMilestone { ...@@ -362,7 +376,11 @@ export interface TreatmentMilestone {
} }
export const TreatmentMilestones = { export const TreatmentMilestones = {
implant: { steps: ['implant_placement', 'crown_placement'], minSteps: 2, lifecycle: 'linear' }, // terminalSteps: crown_placement(戴牙/上部修复)= 种植功能性完成 —— 临床上没有植体不可能戴冠,
// 所以即使"种植体植入"步因数据缺失(无牙位 / 早于诊断 / subtype 不命中)没匹配上,戴冠即视为收尾。
// 修复刘晓芳 47 案例:戴牙完成却因植入步丢失被误判 discovered ★(潜在新链)。
// 单做植入未戴冠 → 仍 matched=1<minSteps=2 且非 terminal → s3 不达成(保持"待戴冠"语义)。
implant: { steps: ['implant_placement', 'crown_placement'], minSteps: 2, lifecycle: 'linear', terminalSteps: ['crown_placement'] },
// endodontic 3 步 + minSteps=2(开髓 + 根充至少);patient 真做了开髓+根备+根充才算完整 // endodontic 3 步 + minSteps=2(开髓 + 根充至少);patient 真做了开髓+根备+根充才算完整
// lifecycle=linear_then_crown:闭环额外要求 prosthodontic 冠保护(临床:根管后不戴冠折裂率 ~30%) // lifecycle=linear_then_crown:闭环额外要求 prosthodontic 冠保护(临床:根管后不戴冠折裂率 ~30%)
endodontic: { steps: ['pulp_extirpation', 'canal_preparation', 'canal_filling', 'pulpotomy'], minSteps: 2, lifecycle: 'linear_then_crown', terminalSteps: ['canal_filling', 'pulpotomy'] }, endodontic: { steps: ['pulp_extirpation', 'canal_preparation', 'canal_filling', 'pulpotomy'], minSteps: 2, lifecycle: 'linear_then_crown', terminalSteps: ['canal_filling', 'pulpotomy'] },
...@@ -382,21 +400,37 @@ export const TreatmentMilestones = { ...@@ -382,21 +400,37 @@ export const TreatmentMilestones = {
export const LegacyStepSubtypeKeywords: Partial<Record<PACTreatmentStepKey, readonly string[]>> = { export const LegacyStepSubtypeKeywords: Partial<Record<PACTreatmentStepKey, readonly string[]>> = {
pulp_extirpation: ['开髓', '拔髓'], pulp_extirpation: ['开髓', '拔髓'],
canal_preparation: ['根备', '根管预备'], canal_preparation: ['根备', '根管预备'],
canal_filling: ['根充', '根管充填'], // canal_filling = RCT 终末步。除细分"根充/根管充填",再纳入**整根管术式全称/缩写**:
// host 结算项常写笼统名(根管治疗 / 根管再治疗 / RCT / 根管治疗+冠修复…),不写细分步骤 →
// 旧版匹配 0/4 步骤,链误判"未做根管"(实际已做)。这些全称词即"根管已完成"的等价信号,归终末步。
// ⚠️ 仍只匹配 subtype 不含细分 stages 的记录(matchMilestoneSteps:有 stages 则以 stages 为准),
// 子步骤(开髓/根备/根充)语义完整保留;"根管预备/根充"等仍各归其步,不受影响。
canal_filling: ['根充', '根管充填', '根管治疗', '根管再治疗', '根管二次治疗', 'RCT'],
// pulpotomy 类:活髓切断 / 部分活髓切断 / 部分牙髓切断 / 冠髓切断 / 干髓 / 盖髓 / 牙髓血运重建 // pulpotomy 类:活髓切断 / 部分活髓切断 / 部分牙髓切断 / 冠髓切断 / 干髓 / 盖髓 / 牙髓血运重建
// 这些都是"保留根髓"的终末术式(VPT — vital pulp therapy),临床等价 // 这些都是"保留根髓"的终末术式(VPT — vital pulp therapy),临床等价
pulpotomy: ['活髓切断', '部分活髓切断', '部分牙髓切断', '冠髓切断', '干髓', '盖髓', '牙髓血运重建', '活髓保存'], pulpotomy: ['活髓切断', '部分活髓切断', '部分牙髓切断', '冠髓切断', '干髓', '盖髓', '牙髓血运重建', '活髓保存'],
implant_placement: ['种植体植入', '种植手术', '种植一期', '简单种植', '复杂种植', '即刻种植', '延期种植', '拔除后种植'], // 种植一/二/三期都是"外科植入阶段"(一期植入 → 二期暴露/愈合帽 → 三期),归 implant_placement;
crown_placement: ['种植上部修复', '种植冠修复', '种植戴牙', '种植二期', '种植三期'], // 不能进 crown_placement,否则"只做二期没戴冠"会被 terminalSteps 误判为种植完成。
implant_placement: ['种植体植入', '种植钉植入', '种植手术', '种植一期', '种植二期', '种植三期', '简单种植', '复杂种植', '即刻种植', '即拔即种', '延期种植', '拔除后种植'],
// crown_placement = 真正"戴上冠/上部修复完成"的终末信号(见 TreatmentMilestones.implant.terminalSteps)
// 除"种植上部修复/冠修复/戴牙",再纳入笼统全称 **种植修复**(host 结算项最常用,= 种植上部修复完成)、
// 种植冠加瓷(烤瓷冠)。同 endodontic 的"根管治疗"全称兜底:不写细分步骤的整术式记录也能认出"已修复"。
// 注:"种植上部修复"含"修复"但不含"种植修复"子串,两者各自独立匹配,不冲突。
crown_placement: ['种植上部修复', '种植冠修复', '种植戴牙', '种植修复', '种植冠加瓷'],
supragingival_scaling: ['洁治', '洁牙', '洗牙'], supragingival_scaling: ['洁治', '洁牙', '洗牙'],
subgingival_scaling: ['刮治', '龈下'], // 笼统全称"牙周基础治疗/牙周系统治疗"(host 结算项常用,= 已做 SRP 系统治疗)归 SRP 步,
// 否则牙周链误判"未做"。注:洁牙 vs SRP 的召回区分在 scenario 侧(SRP_RECOMMENDED),展示层不受影响。
subgingival_scaling: ['刮治', '龈下', '牙周基础治疗', '牙周系统治疗'],
periodontal_maintenance: ['维护'], periodontal_maintenance: ['维护'],
composite_filling: ['充填'], composite_filling: ['充填'],
inlay: ['嵌体'], inlay: ['嵌体'],
crown_restoration: ['冠'], // crown_restoration:除"冠",纳入"戴牙"(冠桥/义齿戴入完成 = 修复终末)。
crown_restoration: ['冠', '戴牙'],
post_core: ['桩核'], post_core: ['桩核'],
tooth_extraction: ['拔除', '拔牙'], tooth_extraction: ['拔除', '拔牙'],
bracket_placement: ['矫治器', '附件', '托槽'], // bracket_placement:除具体器械(矫治器/托槽/附件),纳入笼统全称"正畸/正畸治疗/矫治"
// (host 结算项最常写"正畸"——做正畸必然已戴矫治器,= 已进入正畸执行)。
bracket_placement: ['矫治器', '附件', '托槽', '正畸', '正畸治疗', '矫治'],
retainer: ['保持器'], retainer: ['保持器'],
ortho_adjustment: ['加力', '调整'], ortho_adjustment: ['加力', '调整'],
fluoride_application: ['涂氟'], fluoride_application: ['涂氟'],
......
...@@ -34,8 +34,15 @@ export const ReasonSignalsSchema = z.object({ ...@@ -34,8 +34,15 @@ export const ReasonSignalsSchema = z.object({
toothPosition: z.string().nullable().optional(), toothPosition: z.string().nullable().optional(),
/// 紧迫维度:触发信号距今多少天(跨场景通用) /// 紧迫维度:触发信号距今多少天(跨场景通用)
/// ⚠️ 这是 plan 生成那一刻**固化的快照**,随天数推移会陈旧。
/// 展示层应优先用 signalOccurredAt 实时重算(见 applyLiveDays),此字段作 AI prompt / 旧数据兜底。
daysSince: z.number().int().nonnegative(), daysSince: z.number().int().nonnegative(),
/// 触发信号发生时刻(诊断 occurred_at / 推荐 planned_for 的 ISO)— **不可变锚点**。
/// 展示层据此实时算天数,跟治疗链断口(chain.gapDays)同源同公式,避免"召回 388 / 治疗链 389"漂移。
/// optional/nullable:旧 plan 无此字段 → 回落 baked daysSince(无回归)。
signalOccurredAt: z.string().datetime().nullable().optional(),
/// 期望待启动类别(raw PAC treatment category enum;前端用 treatmentCategoryNameZh 翻译) /// 期望待启动类别(raw PAC treatment category enum;前端用 treatmentCategoryNameZh 翻译)
/// 不同场景含义: /// 不同场景含义:
/// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管) /// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
...@@ -43,3 +50,35 @@ export const ReasonSignalsSchema = z.object({ ...@@ -43,3 +50,35 @@ export const ReasonSignalsSchema = z.object({
expectedCategories: z.array(z.string()), expectedCategories: z.array(z.string()),
}); });
export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>; export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>;
/**
* 据锚点 signalOccurredAt 实时算"距今天数"。
* **跟 chain-composer 的 gapDays 完全同款公式**(`Math.floor((now - occurred)/一天)`),
* 保证召回理由天数与治疗链断口永远精确一致(差异源自两者算的时刻不同 → 统一到 read 时刻)。
* @param iso 锚点 ISO 时间;为空返回 null(调用方回落 baked daysSince)
* @param now 参考时刻(默认当下)— 便于测试注入
*/
export function liveDaysSince(
iso: string | null | undefined,
now: Date = new Date(),
): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.max(0, Math.floor((now.getTime() - t) / 86_400_000));
}
/**
* 把 signals 的 daysSince 用锚点实时重算后返回(不可变拷贝)。
* 有锚点 → 覆盖为实时值;无锚点 → 原样返回(旧数据兜底)。
* 服务端两条序列化路径(列表 / 详情)共用,保证两处口径一致。
*/
export function applyLiveDays<T extends { daysSince?: number; signalOccurredAt?: string | null }>(
signals: T | null,
now: Date = new Date(),
): T | null {
if (!signals) return signals;
const live = liveDaysSince(signals.signalOccurredAt, now);
if (live === null) return signals;
return { ...signals, daysSince: live };
}
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