Commit e199bcdf by luoqi

feat(emr): plan 的"建议"接入 recommendation + 病历显示 + 牙位全展示

医生建议(plan 字段长尾"建议X")原先 MVP 直接 drop,现忠实保留并在病历快读显示:
- transforms 新增 union 算子(表级合并)— 补上"transforms 无 union"的 TODO
- manifest:plan 的"建议"→ _plan_recommendation_raw → union 并入 treat_plan 的 _recommendation_raw
- recommendation.yaml:加 name(医生原文,无 enum)+ sourceEncounterExternalId(按接诊匹配)
- fact-content-schemas:recommendation.code 改可空 + 加 name / source_encounter_external_id
- recommendation.parser:未命中白名单不再丢(code=null 但保留原文 name);空 code 不触发召回
- emr-soap-view:P 段加「医生建议」(忠实原文,如"建议更换 · 11;12;…")

病历牙位:SOAP 全部牙位完整展示(formatToothPosition maxShow=999),不再"等 N 颗"。

注:存量患者需重摄入才有历史"建议";新增量自动带。无 DB migration(content 为 JSON)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent fb388a17
...@@ -21,6 +21,8 @@ field_mapping: ...@@ -21,6 +21,8 @@ field_mapping:
clinicId: organization_id clinicId: organization_id
occurredAt: created_date # 精确就诊时刻(rq 只到日);created_date=医生写建议的时点,100% 同 rq 日 occurredAt: created_date # 精确就诊时刻(rq 只到日);created_date=医生写建议的时点,100% 同 rq 日
code: treat_name # "建议种植" 等 → enum_mapping 翻译到 PAC 推荐码 code: treat_name # "建议种植" 等 → enum_mapping 翻译到 PAC 推荐码
name: treat_name # 医生原文(无 enum,原样)— 病历忠实展示;长尾建议靠它
sourceEncounterExternalId: emr_id # 同次接诊病历 id — 病历快读按接诊匹配
toothPosition: tooth_position toothPosition: tooth_position
doctorId: user_id # 建议医生(从 emr 父级继承) doctorId: user_id # 建议医生(从 emr 父级继承)
doctorName: doctor_name # 建议医生名(快照) doctorName: doctor_name # 建议医生名(快照)
......
...@@ -402,13 +402,13 @@ transforms: ...@@ -402,13 +402,13 @@ transforms:
when: when:
default: true default: true
# ── C.4 plan 路由分流(MVP:只取真治疗,推荐/复查暂 drop)── # ── C.4 plan 路由分流(plan 的"建议"也接入 recommendation;复查暂 drop)──
- kind: route_by_pattern - kind: route_by_pattern
input: _plan_raw input: _plan_raw
field: treat_name field: treat_name
routes: routes:
# MVP:plan 的"建议"暂时 drop(transforms 无 union,合并 treat_plan 留 TODO) # plan 的"建议"→ recommendation(下面 C.4.5 union 合并到 treat_plan 的 _recommendation_raw)
- output: _drop_plan_recommendation - output: _plan_recommendation_raw
when: when:
starts_with: ['建议', '推荐'] starts_with: ['建议', '推荐']
# MVP:plan 的"复查/暂观/流程性/咨询"暂 drop(避免 kind 冲突 + 防止流程性话流入 planned 治疗) # MVP:plan 的"复查/暂观/流程性/咨询"暂 drop(避免 kind 冲突 + 防止流程性话流入 planned 治疗)
...@@ -459,7 +459,12 @@ transforms: ...@@ -459,7 +459,12 @@ transforms:
when: when:
default: true default: true
# ── C.5 推荐(来自 treat_plan) ── # ── C.4.5 合并两源建议 ── treat_plan 的 _recommendation_raw + plan 的 _plan_recommendation_raw
- kind: union
inputs: ['_recommendation_raw', '_plan_recommendation_raw']
output: _recommendation_raw
# ── C.5 推荐(来自 treat_plan + plan 合并) ──
- kind: derive - kind: derive
input: _recommendation_raw input: _recommendation_raw
output: recommendation_rows output: recommendation_rows
......
...@@ -140,7 +140,12 @@ const TreatmentRecordContent = z ...@@ -140,7 +140,12 @@ const TreatmentRecordContent = z
*/ */
const RecommendationRecordContent = z const RecommendationRecordContent = z
.object({ .object({
code: PACDiagnosisCodeSchema, // IMPLANT_RECOMMENDED 等"推荐码" /// 推荐码(IMPLANT_RECOMMENDED 等);未命中白名单的长尾建议 → null(仅展示、不触发召回)
code: PACDiagnosisCodeSchema.nullable(),
/// 医生原文建议(如"建议更换")— 病历忠实展示用;canonical code 缺失时靠它显示
name: nullableString(),
/// 同次接诊病历 id — 病历快读按接诊匹配展示
source_encounter_external_id: nullableString(),
tooth_position: toothPositionText(), tooth_position: toothPositionText(),
/// 建议医生(继承 emr 父级 user_id;话术个性化用) /// 建议医生(继承 emr 父级 user_id;话术个性化用)
doctor_id: nullableString(), doctor_id: nullableString(),
......
...@@ -37,13 +37,14 @@ export class RecommendationParser implements Parser { ...@@ -37,13 +37,14 @@ export class RecommendationParser implements Parser {
return []; return [];
} }
const code = String(c.code ?? '').trim(); // code 命中白名单 → canonical 推荐码(触发召回);未命中 → null(长尾建议,仅病历展示)
const codeParsed = PACDiagnosisCodeSchema.safeParse(code); const rawCode = String(c.code ?? '').trim();
if (!codeParsed.success) { const codeParsed = PACDiagnosisCodeSchema.safeParse(rawCode);
this.logger.warn( const code = codeParsed.success ? codeParsed.data : null;
`recommendation parser: code="${code}" not in PACDiagnosisCodes ` + // 医生原文(如"建议更换")— 忠实展示;选 A:长尾建议也保留(不再 drop)
`(externalId=${externalId});skip`, const name = String(c.name ?? '').trim() || null;
); if (!code && !name) {
// 既无 canonical 码、又无原文 → 无可展示内容,跳过
return []; return [];
} }
...@@ -63,10 +64,13 @@ export class RecommendationParser implements Parser { ...@@ -63,10 +64,13 @@ export class RecommendationParser implements Parser {
status: FactStatus.ACTIVE, status: FactStatus.ACTIVE,
clinicId: ctx.transaction.clinicId, clinicId: ctx.transaction.clinicId,
plannedFor: ctx.transaction.occurredAt, plannedFor: ctx.transaction.occurredAt,
title: `建议 ${codeParsed.data}`, title: name ? `建议:${name}` : `建议 ${code}`,
summary: null, summary: null,
content: { content: {
code: codeParsed.data, code,
name,
source_encounter_external_id:
(c.sourceEncounterExternalId as string | undefined) ?? null,
tooth_position: normalizeToothPosition(c.toothPosition as string | undefined), tooth_position: normalizeToothPosition(c.toothPosition as string | undefined),
doctor_id: c.doctorId ? String(c.doctorId) : null, doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null, doctor_name: (c.doctorName as string | undefined) ?? null,
......
...@@ -43,6 +43,15 @@ export class TransformEngine { ...@@ -43,6 +43,15 @@ export class TransformEngine {
} }
for (const op of opts.transforms) { for (const op of opts.transforms) {
// union 是多输入(op.inputs),不走下面的单 input 取值;缺失表当空表
if (op.kind === 'union') {
const merged = op.inputs.flatMap((n) => tables[n] ?? []);
tables[op.output] = merged;
this.logger.log(
`[transform/union] [${op.inputs.join(',')}] → "${op.output}"(${merged.length})`,
);
continue;
}
const inputName = op.input; const inputName = op.input;
const input = tables[inputName]; const input = tables[inputName];
if (!input) { if (!input) {
......
...@@ -271,8 +271,20 @@ export const FilterOpSchema = z.object({ ...@@ -271,8 +271,20 @@ export const FilterOpSchema = z.object({
}); });
export type FilterOp = z.infer<typeof FilterOpSchema>; export type FilterOp = z.infer<typeof FilterOpSchema>;
/**
* union — 把多个同结构表合并成一个(行级 concat)。
* 场景:treat_plan 与 plan 两源各自 route 出的"建议"行,合并进同一 recommendation 表。
* inputs 缺失的表当空表;output 可与某 input 同名(in-place 合并)。
*/
export const UnionOpSchema = z.object({
kind: z.literal('union'),
inputs: z.array(tableNameSchema).min(1),
output: tableNameSchema,
});
export type UnionOp = z.infer<typeof UnionOpSchema>;
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// 6 operator union + transforms[] 顶层 // 7 operator union + transforms[] 顶层
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
export const TransformOpSchema = z.discriminatedUnion('kind', [ export const TransformOpSchema = z.discriminatedUnion('kind', [
...@@ -282,6 +294,7 @@ export const TransformOpSchema = z.discriminatedUnion('kind', [ ...@@ -282,6 +294,7 @@ export const TransformOpSchema = z.discriminatedUnion('kind', [
RouteByPatternOpSchema, RouteByPatternOpSchema,
PickFirstNonzeroOpSchema, PickFirstNonzeroOpSchema,
FilterOpSchema, FilterOpSchema,
UnionOpSchema,
]); ]);
export type TransformOp = z.infer<typeof TransformOpSchema>; export type TransformOp = z.infer<typeof TransformOpSchema>;
......
...@@ -161,6 +161,8 @@ function EmrSection({ ...@@ -161,6 +161,8 @@ function EmrSection({
const plannedTx = facts.filter((f) => f.type === 'treatment_record' && f.kind === 'planned' && sameEncounter(f)); const plannedTx = facts.filter((f) => f.type === 'treatment_record' && f.kind === 'planned' && sameEncounter(f));
// 本次实际做的治疗(actual,同次接诊)— 病历里体现"这次到底做了什么" // 本次实际做的治疗(actual,同次接诊)— 病历里体现"这次到底做了什么"
const actualTx = facts.filter((f) => f.type === 'treatment_record' && f.kind === 'actual' && sameEncounter(f)); const actualTx = facts.filter((f) => f.type === 'treatment_record' && f.kind === 'actual' && sameEncounter(f));
// 医生建议(同次接诊)— 忠实展示医生原文建议(如"建议更换"),含长尾(code 可能为空)
const recommendations = facts.filter((f) => f.type === 'recommendation_record' && sameEncounter(f));
const images = facts.filter((f) => f.type === 'image_record' && sameEncounter(f)); const images = facts.filter((f) => f.type === 'image_record' && sameEncounter(f));
// S 主观 — 自由文本 // S 主观 — 自由文本
...@@ -217,7 +219,7 @@ function EmrSection({ ...@@ -217,7 +219,7 @@ function EmrSection({
<li key={i} className="text-[12px] text-slate-700 leading-relaxed"> <li key={i} className="text-[12px] text-slate-700 leading-relaxed">
{x.toothPosition && ( {x.toothPosition && (
<span className="inline-block px-1.5 py-0 mr-1.5 bg-slate-100 text-slate-700 rounded text-[10.5px] tabular-nums align-middle"> <span className="inline-block px-1.5 py-0 mr-1.5 bg-slate-100 text-slate-700 rounded text-[10.5px] tabular-nums align-middle">
{formatToothPosition(x.toothPosition)} {formatToothPosition(x.toothPosition, 999)}
</span> </span>
)} )}
{x.message} {x.message}
...@@ -262,7 +264,7 @@ function EmrSection({ ...@@ -262,7 +264,7 @@ function EmrSection({
<span className="px-1.5 py-px mr-1.5 bg-rose-50 text-rose-700 rounded text-[10.5px] font-medium"> <span className="px-1.5 py-px mr-1.5 bg-rose-50 text-rose-700 rounded text-[10.5px] font-medium">
{badgeText} {badgeText}
</span> </span>
{tooth && <span className="text-[11px] text-slate-500">牙位 {formatToothPosition(tooth)}</span>} {tooth && <span className="text-[11px] text-slate-500">牙位 {formatToothPosition(tooth, 999)}</span>}
</li> </li>
); );
})} })}
...@@ -271,7 +273,7 @@ function EmrSection({ ...@@ -271,7 +273,7 @@ function EmrSection({
)} )}
{/* P */} {/* P */}
{(disposals.length > 0 || actualTx.length > 0 || plannedTx.length > 0 || doctorAdvice) && ( {(disposals.length > 0 || actualTx.length > 0 || plannedTx.length > 0 || recommendations.length > 0 || doctorAdvice) && (
<SoapBlock letter="P" tone="emerald" title="计划(Plan)"> <SoapBlock letter="P" tone="emerald" title="计划(Plan)">
{disposals.length > 0 && ( {disposals.length > 0 && (
<div className="mb-2"> <div className="mb-2">
...@@ -281,7 +283,7 @@ function EmrSection({ ...@@ -281,7 +283,7 @@ function EmrSection({
<li key={i} className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-line"> <li key={i} className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-line">
{x.toothPosition && ( {x.toothPosition && (
<span className="inline-block px-1.5 py-0 mr-1.5 bg-slate-100 text-slate-700 rounded text-[10.5px] tabular-nums align-middle"> <span className="inline-block px-1.5 py-0 mr-1.5 bg-slate-100 text-slate-700 rounded text-[10.5px] tabular-nums align-middle">
{formatToothPosition(x.toothPosition)} {formatToothPosition(x.toothPosition, 999)}
</span> </span>
)} )}
{x.message} {x.message}
...@@ -306,7 +308,7 @@ function EmrSection({ ...@@ -306,7 +308,7 @@ function EmrSection({
{badgeText} {badgeText}
</span> </span>
<span className="font-medium">{subtype}</span> <span className="font-medium">{subtype}</span>
{tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth)}</span>} {tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth, 999)}</span>}
</li> </li>
); );
})} })}
...@@ -330,7 +332,26 @@ function EmrSection({ ...@@ -330,7 +332,26 @@ function EmrSection({
{badgeText} {badgeText}
</span> </span>
<span className="font-medium">{subtype}</span> <span className="font-medium">{subtype}</span>
{tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth)}</span>} {tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth, 999)}</span>}
</li>
);
})}
</ul>
</div>
)}
{recommendations.length > 0 && (
<div className="mb-2">
<div className="text-[11px] text-slate-500 mb-1">医生建议:</div>
<ul className="space-y-1">
{recommendations.map((rec) => {
const rc = rec.content as Record<string, unknown>;
const text = String(rc.name ?? '') || rec.title || '建议';
const tooth = String(rc.tooth_position ?? '');
return (
<li key={rec.id} className="text-[12px] text-slate-700 leading-relaxed">
<span className="px-1.5 py-px mr-1.5 bg-amber-50 text-amber-700 rounded text-[10.5px] font-medium">建议</span>
<span className="font-medium">{text}</span>
{tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth, 999)}</span>}
</li> </li>
); );
})} })}
......
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