Commit 844d19be by luoqi

feat(plan): 召回池卡片信息重排 —— 补病历号/上次到诊/上次医生/潜在治疗,诊所名按权限收起

业务给的调整方向:去掉诊所名,加病历号、上次时间、上次医生、潜在治疗标签。
诊所名改成**按登录数据权限判断** —— 单诊所权限的人每行都是同一家,是噪音;跨诊所才有区分价值。

改的是两个组件(你截图的 10457 人那个列表是 patient-picker-rail,不是 plans-list-app 的卡片,
两处都按同一套字段改了,保持一致):

后端 plan.service.list —— 两条**整页批量**查询,不逐行发(本页 ≤25 人,都走 patient_id 索引):
- fetchLastVisits:DISTINCT ON 取每人最近一次 encounter_record ∪ emr_record。
  · 并集是必须的 —— 部分宿主 appointment.in_time 缺失,只有 EMR 能证明到过场
    (jvs-dw 实测有患者 0 encounter / 39 emr),口径与详情页 lastVisit 一致。
  ·  医生取**同一次就诊**的医生,不是"历史最常见医生",否则会「日期是A次、医生是B次」错配,
    客服照着说就露馅。doctor_name 只有 emr_record 带(encounter_record 实测两宿主都 0% 有名),
    故同一时刻并列时优先取 emr;取不到名返 null,不拿 doctor_id 顶替(工号对客服无意义)。
    测试服实测覆盖率:池内 2,990 个有就诊记录的患者里 2,966 拿得到医生名(99.2%)。
- fetchPotentialTreatments:取当前版画像 potential_treatment.labels。
  它与卡片上的"召回理由"高度重叠但不等价:画像覆盖该患者**全部**潜在治疗,
  召回理由只是其中"这次值得召回"的子集。业务要的是前者。
- patient select 补 medicalRecordNumber(jvs-dw 覆盖 91% / friday 63%,缺则前端不占位)。

前端:
- 行1 追加病历号(mono,弱色);行2 的诊所名加 showClinic 闸;
- rail 新增第三行:潜在治疗 chips(左)+ 上次到诊·医生(右),两者全缺则整行不渲染、不虚占行高;
- 列表卡片:chips 接在场景/状态之后,上次到诊放底栏 meta 位。
- PotentialTreatmentChips 刻意比 ScenarioChip 弱一档(无底色、细描边):场景 chip 是
  "这次为什么召"=主信息,潜在治疗是"这人还有哪些没做"=背景信息,抢色会让客服分不清主次。
  卡片铺 3 个、rail 铺 2 个,其余折 +N(卡片是扫读用的,标签一多就不是信息是墙)。
- ️ showClinic 判据是 `visibleClinics(user).length !== 1` 而不是 `> 1`:
  诊所字典未加载时 visibleClinics 返回 0(集团级用户 clinicIds 为空 → 回退全字典 → 字典空则 0),
  按 `> 1` 会把集团级用户也误隐藏。只在**确知单诊所**时隐藏,未知一律显示。

验证(本地):rail 行实测渲染为
  「张红 女·43 岁 WY0A007548 / 177****4845 / [潜在种植][潜在根管]  2022-06-19 · 张 敏」
诊所名「元和王永口腔」在该单诊所账号下已隐藏。654 tests / 42 suites 全过;
@pac/service 与 @pac/web typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 1966d351
......@@ -106,11 +106,19 @@ export class PlanService {
phoneVerified: true,
gender: true,
birthDate: true,
medicalRecordNumber: true,
},
})
: [];
const patientById = new Map(patients.map((p) => [p.id, p]));
// 卡片补充信息(上次到诊 / 那次的医生 / 潜在治疗标签)—— 两条**整页批量**查询,
// 不逐行发。本页最多 pageSize(25)个患者,都走 patient_id 索引。
const [lastVisitByPatient, potentialByPatient] = await Promise.all([
this.fetchLastVisits(scope, patientIds),
this.fetchPotentialTreatments(patientIds),
]);
// 每个 plan 最近一次执行结果(左栏「成功/不成功/保持」角标用)。
// 本页 plan 数少(≤pageSize),执行记录也少;一次拉齐后 JS 取每 plan 最新一条。
const planIds = rows.map((r) => r.id);
......@@ -143,6 +151,10 @@ export class PlanService {
phoneVerified: patient?.phoneVerified ?? false,
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
medicalRecordNumber: patient?.medicalRecordNumber ?? null,
lastVisitAt: lastVisitByPatient.get(p.patientId)?.at ?? null,
lastVisitDoctor: lastVisitByPatient.get(p.patientId)?.doctor ?? null,
potentialTreatments: potentialByPatient.get(p.patientId) ?? [],
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
......@@ -154,6 +166,78 @@ export class PlanService {
};
}
/**
* 整页患者的「上次到诊 + 那次的医生」—— 一条 DISTINCT ON 取齐。
*
* 口径与详情页 lastVisit 一致:**encounter_record ∪ emr_record** 取 occurred_at 最大的那次。
* 并集是必须的 —— 部分宿主 appointment.in_time 缺失,只有 EMR 能证明患者到过场
* (jvs-dw 实测有患者 0 encounter / 39 emr)。
*
* ⭐ 医生取的是**同一次就诊**的医生,不是"历史最常见医生" —— 否则会出现
* 「日期是 A 次、医生是 B 次」的错配,客服照着说就露馅。
* doctor_name 只有 emr_record 带(encounter_record 实测两个宿主都是 0% 有名),
* 所以同一时刻并列时优先取 emr;取不到名就返 null,前端不显示医生,不退而求其次拿 id 顶替
* (医生 id 对客服无意义,显示出来只会让人以为是工号)。
* 测试服实测覆盖率:池内 2,990 个有就诊记录的患者里 2,966 拿得到医生名(99.2%)。
*/
private async fetchLastVisits(
scope: TenantScopeContext,
patientIds: string[],
): Promise<Map<string, { at: string; doctor: string | null }>> {
const out = new Map<string, { at: string; doctor: string | null }>();
if (patientIds.length === 0) return out;
const rows = await this.prisma.$queryRaw<
Array<{ patient_id: string; occurred_at: Date; doctor_name: string | null }>
>`
SELECT DISTINCT ON (patient_id)
patient_id,
occurred_at,
nullif(content->>'doctor_name', '') AS doctor_name
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${patientIds}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND superseded_at IS NULL
ORDER BY patient_id, occurred_at DESC, (type = 'emr_record') DESC
`;
for (const r of rows) {
out.set(r.patient_id, {
at: r.occurred_at.toISOString().slice(0, 10),
doctor: r.doctor_name,
});
}
return out;
}
/**
* 整页患者的潜在治疗标签 —— 取**当前版**画像的 potential_treatment.labels。
*
* 注意它跟卡片上的「召回理由」高度重叠但不等价:画像覆盖该患者**全部**潜在治疗
* (诊断了没做),召回理由只是其中"这次值得召回"的子集。业务要的是前者(出自画像)。
*/
private async fetchPotentialTreatments(patientIds: string[]): Promise<Map<string, string[]>> {
const out = new Map<string, string[]>();
if (patientIds.length === 0) return out;
const rows = await this.prisma.personaFeature.findMany({
where: {
key: 'potential_treatment',
persona: { patientId: { in: patientIds }, supersededAt: null },
},
select: { data: true, persona: { select: { patientId: true } } },
});
for (const r of rows) {
const labels = (r.data as { labels?: unknown } | null)?.labels;
if (!Array.isArray(labels)) continue;
out.set(
r.persona.patientId,
labels.filter((l): l is string => typeof l === 'string' && l.length > 0),
);
}
return out;
}
// ─────────────────────────────────────────────
// buildListWhere — list / queueStats 共用的 where 构造(view + 显式 filter + clinic 隔离 + persona 圈人)
// ─────────────────────────────────────────────
......
......@@ -69,6 +69,10 @@ export function PatientPickerRail({
const user = useAuthStore((st) => st.user);
const clinicDict = user?.dictionary?.clinics; // 显示 plan 诊所名用(字典查表,非 scope)
// 诊所名只在跨诊所权限下有信息量;口径同顶栏「N 个诊所」(visibleClinics)。
// ⚠️ 用 `!== 1` 而非 `> 1`:字典未加载时会返回 0,按 `> 1` 会误隐藏集团级用户的诊所名。
// 只在确知单诊所时隐藏,未知一律显示(理由同列表页 PlanCard)。
const showClinic = visibleClinics(user).length !== 1;
const clinicOptions = useMemo(() => visibleClinics(user), [user]);
// 关键词防抖:手动排定,**不依赖 search 状态变化触发**。
......@@ -290,6 +294,7 @@ export function PatientPickerRail({
plan={p}
active={p.id === currentPlanId}
clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''}
showClinic={showClinic}
hidePhone={hasPatientArchive}
onClick={() => {
router.push(`/plans/${p.id}`);
......@@ -527,6 +532,34 @@ function OutcomeTag({ outcome }: { outcome: string }) {
);
}
/**
* 潜在治疗标签(画像 potential_treatment.labels)—— 与列表页 PlanCard 同款,尺寸小一号。
* 刻意比状态药丸弱一档(无底色、细描边):这是"这人还有哪些没做"的背景信息,
* 不该跟"这次为什么召"抢注意力。rail 一屏要放十几行,最多铺 2 个,其余折 +N。
*/
function PotentialTreatmentChips({ labels }: { labels: string[] }) {
if (!labels.length) return null;
const shown = labels.slice(0, 2);
const rest = labels.length - shown.length;
return (
<>
{shown.map((l) => (
<span
key={l}
className="inline-block flex-none rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200"
>
{l}
</span>
))}
{rest > 0 && (
<span className="flex-none text-[10px] text-slate-400" title={labels.slice(2).join(' · ')}>
+{rest}
</span>
)}
</>
);
}
// ── 优先级(与列表页 PriorityBar 同款:五格条 + 10 分制)────────
function PriorityBar({ score }: { score: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
......@@ -553,6 +586,7 @@ function PatientRow({
plan: p,
active,
clinicName,
showClinic,
hidePhone,
onClick,
onClaim,
......@@ -561,6 +595,8 @@ function PatientRow({
plan: PlanListItem;
active: boolean;
clinicName: string;
/** 跨诊所权限才显示诊所名(单诊所的人每行同一家 = 噪音);口径同顶栏「N 个诊所」 */
showClinic: boolean;
/** 宿主配了原始档案(VIEW_PATIENT)时隐藏手机号 —— 真号在宿主档案页,PAC 侧为造数假号。与详情页同条件。 */
hidePhone: boolean;
onClick: () => void;
......@@ -588,6 +624,12 @@ function PatientRow({
<span className="flex-none text-[10.5px] tabular-nums text-slate-500">
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
{/* 病历号:客服口头核对身份用。宿主不一定给(jvs-dw 91% / friday 63%),缺就不占位 */}
{p.patient.medicalRecordNumber && (
<span className="min-w-0 truncate font-mono text-[10px] tabular-nums text-slate-400" title="病历号">
{p.patient.medicalRecordNumber}
</span>
)}
{p.patient.phoneVerified && (
<span className="flex-none rounded bg-teal-50 px-1 text-[9.5px] font-medium leading-4 text-teal-700 ring-1 ring-inset ring-teal-200">
......@@ -607,7 +649,8 @@ function PatientRow({
{p.patient.phoneMasked ?? ''}
</span>
)}
{clinicName && (
{/* 诊所名:单诊所权限的人每行都是同一家,纯噪音 → 隐藏;跨诊所才有区分价值 */}
{clinicName && showClinic && (
<span className="min-w-0 flex-1 truncate text-[10px] text-slate-400" title={clinicName}>
{hidePhone ? '' : '· '}
{clinicName}
......@@ -645,6 +688,23 @@ function PatientRow({
);
})()}
</div>
{/* 第三行:潜在治疗(左) + 上次到诊·医生(右)。两者都可能缺,全缺则整行不渲染,行高不虚占。 */}
{(p.patient.potentialTreatments.length > 0 || p.patient.lastVisitAt) && (
<div className="mt-1 flex items-center gap-1">
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
<PotentialTreatmentChips labels={p.patient.potentialTreatments} />
</span>
{p.patient.lastVisitAt && (
<span
className="flex-none whitespace-nowrap text-[10px] tabular-nums text-slate-400"
title="上次到诊"
>
{p.patient.lastVisitAt}
{p.patient.lastVisitDoctor && ` · ${p.patient.lastVisitDoctor}`}
</span>
)}
</div>
)}
</div>
);
}
......@@ -746,6 +746,12 @@ function PatientPlanCard({
}) {
const p = plan;
const clinicDict = useAuthStore((s) => s.user?.dictionary?.clinics);
// 诊所名只在「跨诊所权限」下才有信息量:单诊所的人每张卡都是同一家,纯噪音。
// ⚠️ 判据用 `!== 1` 而不是 `> 1`:visibleClinics 在诊所字典尚未拉到时会返回 0
// (集团级用户 clinicIds 为空 → 回退全字典 → 字典空则 0),按 `> 1` 会把集团级用户
// 也误隐藏。只在**确知是单诊所**时才隐藏,未知一律显示 —— 多显示一个字段是小事,
// 把跨诊所的人看不到诊所归属是大事。
const showClinic = useAuthStore((s) => visibleClinics(s.user).length) !== 1;
const isMine = p.assigneeUserId === me;
const isPool = p.status === 'active' && !p.assigneeUserId;
const isClosed = p.status === 'completed' || p.status === 'abandoned';
......@@ -786,6 +792,12 @@ function PatientPlanCard({
<span className="text-[10.5px] text-slate-500 nums">
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
{/* 病历号:客服口头核对身份用。宿主不一定给(jvs-dw 91% / friday 63%),缺就整块不占位 */}
{p.patient.medicalRecordNumber && (
<span className="truncate font-mono text-[10.5px] text-slate-400 nums" title="病历号">
{p.patient.medicalRecordNumber}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono nums">{p.patient.phoneMasked ?? '电话未知'}</span>
......@@ -797,10 +809,11 @@ function PatientPlanCard({
</span>
)}
{p.targetClinicId && (
{/* 诊所名:单诊所权限的人每张卡都是同一家,纯噪音 → 隐藏;跨诊所才有区分价值 */}
{p.targetClinicId && showClinic && (
<>
<span className="text-slate-300">·</span>
<span>{clinicDict?.[p.targetClinicId] ?? p.targetClinicId}</span>
<span className="truncate">{clinicDict?.[p.targetClinicId] ?? p.targetClinicId}</span>
</>
)}
</div>
......@@ -823,6 +836,9 @@ function PatientPlanCard({
<div className={cn('flex flex-wrap items-center gap-1.5', pad, 'pb-2 pt-0')}>
<ScenarioChip label={p.scenarioLabel} scenario={p.scenario} />
<StatusPill status={p.status} />
{/* 潜在治疗(出自画像 potential_treatment):覆盖该患者全部"诊断了没做"的项,
比下面的召回理由更宽 —— 理由只是这次值得召回的那条。最多 3 个,其余折成 +N。 */}
<PotentialTreatmentChips labels={p.patient.potentialTreatments} />
</div>
{/* 召回理由富文本(跟详情页 WhyCard 同源 ReasonLine):后端给 signals raw JSON,前端字典翻译。
......@@ -862,6 +878,16 @@ function PatientPlanCard({
{/* 底:meta + 操作 */}
<div className={cn('mt-auto flex items-center justify-between gap-2 border-t border-slate-100', d.foot)}>
<div className="flex min-w-0 items-center gap-2.5 text-[11px] text-slate-600">
{/* 上次到诊 + 那一次的医生(同源于同一次就诊,不会日期/医生错配)。
医生名只有 emr_record 带,取不到就只显示日期,不拿医生 id 顶替。 */}
{p.patient.lastVisitAt && (
<span className="truncate nums" title="上次到诊">
上次 {p.patient.lastVisitAt}
{p.patient.lastVisitDoctor && (
<span className="text-slate-500"> · {p.patient.lastVisitDoctor}</span>
)}
</span>
)}
{p.assigneeUserId && view === 'all' && (
<span className="truncate font-mono text-[10.5px] text-slate-500">承接 {p.assigneeUserId}</span>
)}
......@@ -946,6 +972,36 @@ function StatusPill({ status }: { status: string }) {
return <span className={cn('inline-block rounded px-1.5 py-0.5 text-[10.5px] font-medium', m.tone)}>{m.label}</span>;
}
/**
* 潜在治疗标签(画像 potential_treatment.labels)。
*
* 样式上刻意比 ScenarioChip 弱一档(无底色、细描边、slate 色):场景 chip 是"这次为什么召",
* 是主信息;潜在治疗是"这人还有哪些没做",是背景信息。抢色会让客服分不清主次。
* 最多铺 3 个,其余折成 +N —— 卡片是扫读用的,标签一多就不是信息是墙。
*/
function PotentialTreatmentChips({ labels }: { labels: string[] }) {
if (!labels.length) return null;
const shown = labels.slice(0, 3);
const rest = labels.length - shown.length;
return (
<>
{shown.map((l) => (
<span
key={l}
className="inline-block rounded px-1.5 py-0.5 text-[10.5px] text-slate-600 ring-1 ring-inset ring-slate-200"
>
{l}
</span>
))}
{rest > 0 && (
<span className="text-[10.5px] text-slate-400" title={labels.slice(3).join(' · ')}>
+{rest}
</span>
)}
</>
);
}
function ScenarioChip({ label, scenario }: { label: string; scenario: string }) {
const tone =
scenario === 'treatment_initiation_recall'
......
......@@ -163,6 +163,18 @@ export const PlanPatientBriefSchema = z.object({
phoneVerified: z.boolean().optional(),
gender: z.string().nullable(),
age: z.number().int().nullable(),
/// 病历号(客服口头核对身份用的档案号)。宿主可能不给 —— jvs-dw 覆盖 91%、friday 63%。
medicalRecordNumber: z.string().nullable(),
/// 最近一次到诊日期(YYYY-MM-DD)。口径同详情页 lastVisit:encounter_record ∪ emr_record
/// 取 occurred_at 最大的那次(部分宿主 appointment.in_time 缺失、只有 EMR,故必须并集)。
lastVisitAt: z.string().nullable(),
/// **那一次**的接诊医生姓名(不是"历史最常见医生")—— 日期与医生同源于同一次就诊,
/// 否则会出现"日期是A次、医生是B次"的错配。doctor_name 只有 emr_record 带
/// (encounter_record 实测 0% 有名),故同日并列时优先取 emr。取不到 → null,前端不显示。
lastVisitDoctor: z.string().nullable(),
/// 潜在治疗标签(画像 potential_treatment.labels,如「潜在种植」「潜在牙周」)。
/// 无画像 / 无该特征 → 空数组。
potentialTreatments: z.array(z.string()),
});
export type PlanPatientBrief = z.infer<typeof PlanPatientBriefSchema>;
......
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