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,
),
})), })),
}; };
} }
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
lookupDxTreatment, lookupDxTreatment,
lookupTreatmentMilestone, lookupTreatmentMilestone,
lookupTreatmentLifecycle, lookupTreatmentLifecycle,
isRestorationIneligibleDxName,
parseComplaintCategories, parseComplaintCategories,
LegacyStepSubtypeKeywords, LegacyStepSubtypeKeywords,
PACTreatmentStep, PACTreatmentStep,
...@@ -15,6 +16,10 @@ import { ...@@ -15,6 +16,10 @@ import {
} from '@pac/types'; } from '@pac/types';
import { fmtYearMonthDay } from '@pac/utils'; import { fmtYearMonthDay } from '@pac/utils';
/// 无牙位 actual 视为"全口覆盖"的类目 —— 整牙弓治疗,无牙位是常态(牙周/正畸)。
/// ⚠️ 必须跟召回 scenario ⑤a(b0b9705)口径一致:`tx.tooth='' AND category IN ('periodontic','orthodontic')`。
const WHOLE_MOUTH_COVERAGE_CATS = new Set(['periodontic', 'orthodontic']);
/** /**
* ChainComposerService — 5 阶段治疗链分析(纯展示用,不参与召回算法) * ChainComposerService — 5 阶段治疗链分析(纯展示用,不参与召回算法)
* *
...@@ -267,10 +272,15 @@ function inferChainStage( ...@@ -267,10 +272,15 @@ function inferChainStage(
// 都算到 K03@12 桶里 → 误判 s3Reached=true → stage=4 ongoing // 都算到 K03@12 桶里 → 误判 s3Reached=true → stage=4 ongoing
// 实际上 12 一次治疗没做,SQL 召回是对的,chain 状态也该 stage=1 discovered // 实际上 12 一次治疗没做,SQL 召回是对的,chain 状态也该 stage=1 discovered
// wholeMouth 桶('*whole')+ actual-only 桶('')不过滤(全口治疗按牙位无意义) // wholeMouth 桶('*whole')+ actual-only 桶('')不过滤(全口治疗按牙位无意义)
// ⭐ 镜像召回 ⑤a(b0b9705):**无牙位 actual 仅 periodontic/orthodontic 视为"全口覆盖"**
// (整牙弓治疗无牙位是常态,牙周 134:1 / 正畸 19:1)→ 覆盖该桶具体牙位。
// 否则曾松柏 K06 牙周脓肿@45;46 全是无牙位"龈上洁治" → 链显示 discovered,但召回已按全口覆盖排除 → 分叉。
// 其它类目(restorative/surgical/implant 等)的无牙位 actual 不当覆盖(蒋卓易 K03@12 仍按牙位过滤)。
const allActualsByCategory = getActualTreatments(category, byType); const allActualsByCategory = getActualTreatments(category, byType);
const allActuals = bucket.tooth && bucket.tooth !== '*whole' const allActuals = bucket.tooth && bucket.tooth !== '*whole'
? allActualsByCategory.filter((tx) => { ? allActualsByCategory.filter((tx) => {
const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? ''); const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? '').trim();
if (!txTooth && WHOLE_MOUTH_COVERAGE_CATS.has(category)) return true; // 无牙位 perio/ortho = 全口覆盖
return toothOverlap(bucket.tooth, txTooth); return toothOverlap(bucket.tooth, txTooth);
}) })
: allActualsByCategory; : allActualsByCategory;
...@@ -350,6 +360,12 @@ function inferChainStage( ...@@ -350,6 +360,12 @@ function inferChainStage(
} else if (s3Reached) { } else if (s3Reached) {
currentStage = 3; currentStage = 3;
status = 'ongoing'; status = 'ongoing';
} else if (actuals.length > 0) {
// ⭐ 桶里有同牙位同类 actual,但 milestone 没匹配上(host 笼统 subtype 如 bare "种植" / 牙位级残缺)。
// 治疗物理上已发生 —— 召回 ⑤a 正是据此(category 级)排除 → 链至少 ongoing(治疗中),不再 discovered。
// 例 Ammu 36:K08 缺牙 + actual"种植"(subtype 不含 milestone 关键词)→ 召回已排除,链却 discovered → 分叉。
currentStage = 3;
status = 'ongoing';
} else if (s2Hits.length > 0) { } else if (s2Hits.length > 0) {
currentStage = 2; currentStage = 2;
status = 'entered'; status = 'entered';
...@@ -363,7 +379,18 @@ function inferChainStage( ...@@ -363,7 +379,18 @@ function inferChainStage(
const rawTooth = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).tooth_position ?? '') : ''; const rawTooth = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).tooth_position ?? '') : '';
const dxRule = code ? lookupDxTreatment(code) : undefined; const dxRule = code ? lookupDxTreatment(code) : undefined;
const fallback = dxRule?.wholeMouth ? '全口' : '未标注牙位'; const fallback = dxRule?.wholeMouth ? '全口' : '未标注牙位';
const chainLabel = dxRule?.chainLabel ?? CATEGORY_LABEL[category] ?? category; // ⭐ 废用牙/无功能牙(host 映射 K08,但牙还在无功能)— 桶内诊断全是这类 → "无修复必要"。
// 召回侧已剔除(scenario ④');展示侧 Option B:不当"种植修复·发现机会"潜在链,
// 改中性标签(诊断名)+ 建议拔除/观察 + 非召回目标(target=false),保留可见性。
const dxNameZh = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).name_zh ?? '') : '';
const restorationIneligible =
s1Facts.length > 0 &&
s1Facts.every((f) =>
isRestorationIneligibleDxName(String((f.content as Record<string, unknown>).name_zh ?? '')),
);
const chainLabel = restorationIneligible
? (dxNameZh || dxRule?.chainLabel || CATEGORY_LABEL[category] || category)
: (dxRule?.chainLabel ?? CATEGORY_LABEL[category] ?? category);
// ⭐ tooth 显示用 bucket.tooth(union-find 合并后的并集 normalize 排序),不用 s1 第一条 rawTooth // ⭐ tooth 显示用 bucket.tooth(union-find 合并后的并集 normalize 排序),不用 s1 第一条 rawTooth
// union-find 把同 (category, code) tooth 有交集的多个桶合并 → bucket.tooth 是完整并集 // union-find 把同 (category, code) tooth 有交集的多个桶合并 → bucket.tooth 是完整并集
// 显示并集让客服一眼看到"全部受影响牙位",不是只看到首诊那 1-3 颗 // 显示并集让客服一眼看到"全部受影响牙位",不是只看到首诊那 1-3 颗
...@@ -379,10 +406,23 @@ function inferChainStage( ...@@ -379,10 +406,23 @@ function inferChainStage(
? bucket.tooth ? bucket.tooth
: (rawTooth || actualToothUnion)); : (rawTooth || actualToothUnion));
const chainName = `${chainLabel} · ${tooth || fallback}`; const chainName = `${chainLabel} · ${tooth || fallback}`;
const diagnosedAt = s1Earliest?.occurredAt ? fmtYearMonthDay(s1Earliest.occurredAt) : '—'; // ⭐ 用 effectiveTime(occurred_at ?? planned_for),跟召回 SQL 的 COALESCE(occurred_at, planned_for) 同口径。
const gapDays = s1Earliest?.occurredAt // recommendation_record(医生建议,planned)只有 planned_for 没 occurred_at —— 旧版只看 occurredAt →
? Math.floor((Date.now() - s1Earliest.occurredAt.getTime()) / 86400_000) // gapDays=undefined→0 < cooldown → 链被 cooldown 闸误滤掉(return null),但召回照样命中 → 召回有/链没有。
// 例 Miyabe Masashi:CROWN_RECOMMENDED@36 planned_for 2021-06-02 → 召回 1823 天,链却不显示。
const s1Time = s1Earliest ? effectiveTime(s1Earliest) : null;
const diagnosedAt = s1Time ? fmtYearMonthDay(s1Time) : '—';
const gapDays = s1Time
? Math.floor((Date.now() - s1Time.getTime()) / 86400_000)
: undefined; : undefined;
// ⭐ 最近一次诊断(re-trigger 证据)— 多次诊断时即"在管又被诊断"那条;附带其医生真实医嘱
const s1Latest = latest(s1Facts);
const s1LatestC = s1Latest?.content as Record<string, unknown> | undefined;
const latestDxAt = s1Latest && effectiveTime(s1Latest) ? fmtYearMonthDay(effectiveTime(s1Latest)!) : undefined;
const latestDxName = s1LatestC
? (String(s1LatestC.name_zh ?? s1LatestC.name ?? '') || undefined)
: undefined;
const latestDxAdvice = findDoctorAdvice(s1Latest, byType.emr) ?? undefined;
// ★ 潜在新链 = 仅 discovered(纯诊断、零治疗规划 / 付费)。 // ★ 潜在新链 = 仅 discovered(纯诊断、零治疗规划 / 付费)。
// entered(已有 planned 治疗 / 大额付款,但还没 actual)属于"已启动·待治疗",跟 scenario SQL 的 // entered(已有 planned 治疗 / 大额付款,但还没 actual)属于"已启动·待治疗",跟 scenario SQL 的
...@@ -408,9 +448,13 @@ function inferChainStage( ...@@ -408,9 +448,13 @@ function inferChainStage(
allCategories: dxRule?.categories, allCategories: dxRule?.categories,
status, status,
currentStage, currentStage,
target: status === 'discovered', // 废用牙/无功能牙不是召回目标(跟 scenario 剔除口径一致)
target: status === 'discovered' && !restorationIneligible,
diagnosedAt, diagnosedAt,
gapDays, gapDays,
latestDxAt,
latestDxName,
latestDxAdvice,
lifecycleNoteZh: lifecycle.noteZh, lifecycleNoteZh: lifecycle.noteZh,
// 给 cross-chain alternative-closed pass 用;wholeMouth 桶 bucket.tooth='*whole'(内部 key), // 给 cross-chain alternative-closed pass 用;wholeMouth 桶 bucket.tooth='*whole'(内部 key),
// 那种情况回填 s1Earliest 真实牙位串;其他用 bucket.tooth 并集(union-find 后) // 那种情况回填 s1Earliest 真实牙位串;其他用 bucket.tooth 并集(union-find 后)
...@@ -436,6 +480,7 @@ function inferChainStage( ...@@ -436,6 +480,7 @@ function inferChainStage(
doctorMap, doctorMap,
crownOk, crownOk,
uncoveredTeeth, uncoveredTeeth,
restorationIneligible,
}), }),
}; };
} }
...@@ -452,6 +497,7 @@ interface FactsByType { ...@@ -452,6 +497,7 @@ interface FactsByType {
payment: ChainComposeInputFact[]; payment: ChainComposeInputFact[];
encounter: ChainComposeInputFact[]; encounter: ChainComposeInputFact[];
refund: ChainComposeInputFact[]; refund: ChainComposeInputFact[];
emr: ChainComposeInputFact[];
} }
function groupByType(facts: ChainComposeInputFact[]): FactsByType { function groupByType(facts: ChainComposeInputFact[]): FactsByType {
...@@ -463,10 +509,39 @@ function groupByType(facts: ChainComposeInputFact[]): FactsByType { ...@@ -463,10 +509,39 @@ function groupByType(facts: ChainComposeInputFact[]): FactsByType {
payment: facts.filter((f) => f.type === FactType.PAYMENT_RECORD), payment: facts.filter((f) => f.type === FactType.PAYMENT_RECORD),
encounter: facts.filter((f) => f.type === FactType.ENCOUNTER_RECORD), encounter: facts.filter((f) => f.type === FactType.ENCOUNTER_RECORD),
refund: facts.filter((f) => f.type === FactType.REFUND_RECORD), refund: facts.filter((f) => f.type === FactType.REFUND_RECORD),
// emr_record 自由文本:**仅用于展示层证据**(把医生真实医嘱 doctor_advice 透给 UI),
// 不进任何 stage 判定 / 召回决策(遵守"算法不读自由文本"纪律)。
emr: facts.filter((f) => f.type === FactType.EMR_RECORD),
}; };
} }
/** /**
* 取某次诊断对应的医生真实医嘱(doctor_advice)— **展示层证据用,非算法决策**。
* 关联口径:同 source 文档(dx.source_encounter_external_id == emr.emr_external_id)优先,
* 退化到同日期(occurred_at 同天)。命中且 doctor_advice 非空才返回。
*/
function findDoctorAdvice(
dx: ChainComposeInputFact | null,
emrFacts: ChainComposeInputFact[],
): string | null {
if (!dx) return null;
const dc = dx.content as Record<string, unknown> | null;
const srcId = dc?.source_encounter_external_id ? String(dc.source_encounter_external_id) : '';
const dxDay = effectiveTime(dx)?.toISOString().slice(0, 10) ?? '';
let dateFallback: string | null = null;
for (const e of emrFacts) {
const ec = e.content as Record<string, unknown> | null;
const advice = ec?.doctor_advice ? String(ec.doctor_advice).trim() : '';
if (!advice) continue;
// 精确:同 source 文档 id
if (srcId && String(ec?.emr_external_id ?? '') === srcId) return advice;
// 兜底:同日期(记下,循环结束没精确命中再用)
if (dxDay && effectiveTime(e)?.toISOString().slice(0, 10) === dxDay) dateFallback ??= advice;
}
return dateFallback;
}
/**
* doctor_id → doctor_name 解析表(从所有 facts 学习一遍)。 * doctor_id → doctor_name 解析表(从所有 facts 学习一遍)。
* *
* 缘由:host 部分 fact 给 doctor_id 但缺 doctor_name(典型:fact_settlement_out 只有 doctor_id; * 缘由:host 部分 fact 给 doctor_id 但缺 doctor_name(典型:fact_settlement_out 只有 doctor_id;
...@@ -1032,6 +1107,7 @@ function buildStageNodes(opts: { ...@@ -1032,6 +1107,7 @@ function buildStageNodes(opts: {
doctorMap: Map<string, string>; doctorMap: Map<string, string>;
crownOk: boolean; // W4 末:lifecycle.requiresCrownProtection 满足与否(inferChainStage 算过) crownOk: boolean; // W4 末:lifecycle.requiresCrownProtection 满足与否(inferChainStage 算过)
uncoveredTeeth: string[]; // W4 末:多牙位桶里 actual 没覆盖到的牙位(用于 S5 hint) uncoveredTeeth: string[]; // W4 末:多牙位桶里 actual 没覆盖到的牙位(用于 S5 hint)
restorationIneligible: boolean; // 废用牙/无功能牙 — 建议拔除/观察而非修复(Option B)
}): ChainNode[] { }): ChainNode[] {
const { const {
currentStage, currentStage,
...@@ -1051,11 +1127,15 @@ function buildStageNodes(opts: { ...@@ -1051,11 +1127,15 @@ function buildStageNodes(opts: {
doctorMap, doctorMap,
crownOk, crownOk,
uncoveredTeeth, uncoveredTeeth,
restorationIneligible,
} = opts; } = opts;
const reached = (s: number) => currentStage >= s; const reached = (s: number) => currentStage >= s;
const cur = (s: number) => currentStage === s; const cur = (s: number) => currentStage === s;
const fmt = (d: Date | null | undefined) => (d ? fmtYearMonthDay(d) : '—'); const fmt = (d: Date | null | undefined) => (d ? fmtYearMonthDay(d) : '—');
const expectedHint = CATEGORY_RECOMMEND[category] ?? '相应治疗'; // 废用牙/无功能牙:不建议种植/桥/义齿,改建议拔除/观察(临床:无功能牙该拔或观察)
const expectedHint = restorationIneligible
? '拔除 / 观察'
: (CATEGORY_RECOMMEND[category] ?? '相应治疗');
// ─── S1 发现机会 ─────────────────────────────────── // ─── S1 发现机会 ───────────────────────────────────
const s1Node: ChainNode = (() => { const s1Node: ChainNode = (() => {
...@@ -1102,8 +1182,10 @@ function buildStageNodes(opts: { ...@@ -1102,8 +1182,10 @@ function buildStageNodes(opts: {
matchedSteps.matched.length >= (milestone?.minSteps ?? 1) || matchedSteps.matched.length >= (milestone?.minSteps ?? 1) ||
(!milestone && actuals.length > 0); (!milestone && actuals.length > 0);
const s2Node: ChainNode = (() => { const s2Node: ChainNode = (() => {
// S2 done:① 有 s2Earliest 强信号 OR ② S3 已到达(治疗都做了一定经过 S2) // S2 done:① 有 s2Earliest 强信号 OR ② S3 已到达 OR ③ 桶里有 actual(治疗发生过必经 S2)
const s2Done = !!s2Earliest || s3Reached; // ③ 兜底 milestone 没匹配上的笼统 subtype(bare"种植"等)—— 跟 inferChainStage 的 actuals>0→ongoing 一致,
// 否则 S3 已治疗(✓)但 S2 还"尚未启动"(○)自相矛盾 + 误挂"建议种植"hint。
const s2Done = !!s2Earliest || s3Reached || actuals.length > 0;
const n: ChainNode = { stage: 2, at: '—', done: s2Done, current: cur(2), missing: !s2Done }; const n: ChainNode = { stage: 2, at: '—', done: s2Done, current: cur(2), missing: !s2Done };
// 文案优先级 ① s2Earliest 强信号 // 文案优先级 ① s2Earliest 强信号
...@@ -1135,8 +1217,8 @@ function buildStageNodes(opts: { ...@@ -1135,8 +1217,8 @@ function buildStageNodes(opts: {
return n; return n;
} }
// ③ 没强信号 也没 planned — 看 status // ③ 没强信号 也没 planned — 看是否已有治疗(s3Reached 或 有 actual:笼统 subtype 也算治疗发生过)
if (s3Reached) { if (s3Reached || actuals.length > 0) {
n.title = '直接执行'; n.title = '直接执行';
n.detail = '未经预约'; n.detail = '未经预约';
} else { } else {
...@@ -1169,7 +1251,9 @@ function buildStageNodes(opts: { ...@@ -1169,7 +1251,9 @@ function buildStageNodes(opts: {
// - 单 step 类(periodontic/restorative minSteps=1)→ 一律走"次数"分支 // - 单 step 类(periodontic/restorative minSteps=1)→ 一律走"次数"分支
// 杨光宗 27 根管 3 次 actual:旧版"1/3 步骤"误导(像"差 2 步"),新版"已完成 3 次" 准确 // 杨光宗 27 根管 3 次 actual:旧版"1/3 步骤"误导(像"差 2 步"),新版"已完成 3 次" 准确
const totalCents = sumAmountCents(actuals); const totalCents = sumAmountCents(actuals);
const isMultiStep = !!milestone && milestone.minSteps > 1; // matched>0 才显"X/Y 步骤"(真有部分步骤命中);matched=0 是笼统 subtype 没匹配上(bare"种植"等),
// 治疗确实发生了,显"N 次治疗"而非"0/Y 步骤(进行中)"(后者误导成"几乎没做")。
const isMultiStep = !!milestone && milestone.minSteps > 1 && matchedSteps.matched.length > 0;
const progress = s3Reached const progress = s3Reached
? `已完成 ${actuals.length} ` ? `已完成 ${actuals.length} `
: isMultiStep : isMultiStep
...@@ -1480,6 +1564,13 @@ export interface ComposedChain { ...@@ -1480,6 +1564,13 @@ export interface ComposedChain {
diagnosedAt?: string; diagnosedAt?: string;
/// S1 至今天数 — gap 越大临床越紧迫(召回算法的紧迫度信号) /// S1 至今天数 — gap 越大临床越紧迫(召回算法的紧迫度信号)
gapDays?: number; gapDays?: number;
/// ⭐ 最近一次诊断日(re-trigger 证据)— 多次诊断时 = 再诊断日(如牙周在管又被诊断 2023-10-16)
latestDxAt?: string;
/// 最近一次诊断的中文名(如"慢性牙龈炎")— banner "又诊断「X」" 用
latestDxName?: string;
/// 该次诊断对应的医生真实医嘱(emr_record.doctor_advice,如"两个月复诊")— **展示层落地证据**,
/// 替代前端硬编码的"医嘱建议 X 个月内";无则前端不显示"医嘱"避免夸大。
latestDxAdvice?: string;
/// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip /// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip
lifecycleNoteZh?: string; lifecycleNoteZh?: string;
/// 桶 tooth_position(用于 cross-chain "alternative-closed" 判定:同 patient 同 tooth overlap) /// 桶 tooth_position(用于 cross-chain "alternative-closed" 判定:同 patient 同 tooth overlap)
......
...@@ -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