Commit f187fb97 by luoqi

feat(plan-detail): 话术头部插入「关联客户」—— 亲戚姓名/关系/年龄 + 跳该客户工单(新标签页)

一家人常在同一家诊所看牙。客服打电话前看到"这人的配偶也是我们的客户",既能顺口关心、
也能顺手跟进那一位,所以放在话术头部(AI 简报下方)而不是收进抽屉。

patient_relations 表本来就有(摄自 fact_customer_referee_out,family_structure 特征在用),
详情接口原来也已经返回 contacts,这次补三件:

 只列**亲戚**。新增 KIN_RELATIONSHIPS 白名单(配偶/子女/孙辈/父母/祖辈/兄弟姐妹),
  friend 和 other **刻意不进** —— 那条边表摄自推荐关系,other 占了一半以上(本地 4043/6222),
  混的是推荐人、代付人这类"认识但不是亲属"。字典把 other 译成「亲属」,但照它列出来
  客服会把推荐人当家属去问病情,比不显示更糟。
  (sibling 这里算亲戚,而 family_structure 把它算"非直系" —— 两处判的不是同一件事,口径不同是有意的)

 年龄:从关系人 birthDate 现算(include 补 birthDate)。

 链接目标 = PAC 自己的工单页,**且必须过 scope**。关系人常和本人不同品牌/诊所,不过滤就会
  给出一个点进去 404 的链接 —— 那比"没有链接"更糟,客服会以为系统坏了。
  批量一条 SQL 查(loadRelatedPlanIds),查不到 → planId=null → 只显示信息、位置上标「无工单」。
  新标签页走 openHostUrl(带 sandbox 三级兜底),不顶掉当前工单。

未建档(linked=false / 无姓名)的关系不出行:一行只有关系没有人,客服拿不到任何可用信息。

本地实测(王红兵):渲染出「王希亮 配偶·59岁 → 关联客户档案」「王迪 妈妈·31岁 → …」,
href=/plans/<id> target=_blank rel=noopener noreferrer。
708 tests / 46 suites + 两端 typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 114e7fdc
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import { applyLiveDays, ApiCode } from '@pac/types';
import { applyLiveDays, ApiCode, KIN_RELATIONSHIPS } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service';
......@@ -60,7 +60,7 @@ export class PlanAggregateService {
profile: true,
// 联系人/亲属边 → 关系人姓名/电话从 relatedPatient 现取(patient↔patient,零冗余)
relationsOut: {
include: { relatedPatient: { select: { name: true, phone: true } } },
include: { relatedPatient: { select: { id: true, name: true, phone: true, birthDate: true } } },
},
// 诊所回访记录(展示用,按时间倒序,封顶 100;每患者 p99=19,够用)
// nulls:'last' —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会顶到列表最前,
......@@ -108,7 +108,7 @@ export class PlanAggregateService {
patient: Prisma.PatientGetPayload<{
include: {
profile: true;
relationsOut: { include: { relatedPatient: { select: { name: true; phone: true } } } };
relationsOut: { include: { relatedPatient: { select: { id: true; name: true; phone: true; birthDate: true } } } };
returnVisits: true;
};
}>,
......@@ -190,9 +190,23 @@ export class PlanAggregateService {
const orgAliases = (hostRow?.orgAliases ?? {}) as Record<string, string>;
const brandId = patient.sourceUnit ? (orgAliases[patient.sourceUnit] ?? null) : null;
// 关联客户(亲戚)可点进去的工单 —— 一条 SQL 批量查,别在 serialize 里逐个 await。
// scope 必须带上:关系人可能在别的品牌/诊所,越权的链接点进去是 404,不如不给。
const relatedPlanIdByPatient = await this.loadRelatedPlanIds(
scope,
(patient.relationsOut ?? [])
.filter((r) => r.relatedPatientId && KIN_RELATIONSHIPS.includes(r.relationship))
.map((r) => r.relatedPatientId!),
);
return {
patient: { ...serializePatient(patient), brandId },
profile: serializeProfile(patient, facts, facts.filter((f) => f.type === 'encounter_record')),
profile: serializeProfile(
patient,
facts,
facts.filter((f) => f.type === 'encounter_record'),
relatedPlanIdByPatient,
),
plan: plan ? serializePlan(plan) : null,
persona: persona ? serializePersona(persona) : null,
chains,
......@@ -255,6 +269,36 @@ export class PlanAggregateService {
// 数据加载(分小函数 — 便于测试 + 单一职责)
// ─────────────────────────────────────────────
/**
* 关系人 patientId → 本 scope 内可打开的 plan id。
*
* 为什么要过 scope:关系人跟本人不一定同品牌/同诊所(家属在别家看的很常见)。
* 不过滤就会给出一个点进去 404 的链接 —— 那比"没有链接"更糟,客服会以为系统坏了。
* 查不到的患者不进 Map,前端据此只渲染姓名、不渲染链接。
* 同一患者多条活跃工单取最新那条(客服要打开的是"这个人现在的工单")。
*/
private async loadRelatedPlanIds(
scope: TenantScopeContext,
patientIds: string[],
): Promise<Map<string, string>> {
const out = new Map<string, string>();
if (patientIds.length === 0) return out;
const rows = await this.prisma.followupPlan.findMany({
where: {
patientId: { in: [...new Set(patientIds)] },
hostId: scope.hostId,
tenantId: scope.tenantId,
supersededAt: null,
status: { in: ['active', 'assigned'] },
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
},
select: { id: true, patientId: true },
orderBy: { createdAt: 'desc' },
});
for (const r of rows) if (!out.has(r.patientId)) out.set(r.patientId, r.id);
return out;
}
private loadCurrentPersona(patientId: string) {
return this.prisma.persona.findFirst({
where: { patientId, supersededAt: null },
......@@ -339,11 +383,18 @@ function serializeProfile(
relationship: string;
relatedExternalId: string;
relatedPatientId: string | null;
relatedPatient: { name: string | null; phone: string | null } | null;
relatedPatient: {
id: string;
name: string | null;
phone: string | null;
birthDate: Date | null;
} | null;
}>;
},
allFacts: Array<{ type: string; content: Prisma.JsonValue; occurredAt: Date | null }>,
encounters: Array<{ occurredAt: Date | null; content: Prisma.JsonValue }>,
/** 关系人 patientId → 本 scope 内可打开的 plan id(查不到/越权 → 无此键,前端不给链接) */
relatedPlanIdByPatient: Map<string, string> = new Map(),
) {
if (!patient.profile) return null;
......@@ -374,8 +425,12 @@ function serializeProfile(
),
).slice(0, 2);
// 联系人/亲属:本人视角(relatedPatient 是本人的 X)。姓名/电话从 relatedPatient 现取。
// 联系人/亲属:本人视角(relatedPatient 是本人的 X)。姓名/电话/生日从 relatedPatient 现取。
// linked=false(关系人未建档)→ 无姓名,前端展示时跳过。已建档的排前。
// ⭐ isKin:是不是**亲戚**(KIN_RELATIONSHIPS 白名单)。详情页「关联客户」只列亲戚 ——
// friend / other 那两类里混的是推荐人噪音(other 占了边表一半以上),列出来客服会误当家属。
// ⭐ planId:关系人在**本 scope 内**能打开的工单;没有(没建工单 / 不在数据范围)→ null,
// 前端就不给链接,免得点进去 404。
const contacts = (patient.relationsOut ?? [])
.map((r) => ({
relationship: r.relationship,
......@@ -383,6 +438,10 @@ function serializeProfile(
name: r.relatedPatient?.name ?? null,
phone: r.relatedPatient?.phone ?? null,
linked: !!r.relatedPatientId,
isKin: KIN_RELATIONSHIPS.includes(r.relationship),
age: r.relatedPatient?.birthDate ? calcAge(r.relatedPatient.birthDate) : null,
relatedPatientId: r.relatedPatientId,
planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null,
}))
.sort((a, b) => Number(b.linked) - Number(a.linked));
......
......@@ -61,6 +61,10 @@ export const mockPatient = {
name: '伍晴晴',
phone: '13800000000',
linked: true,
isKin: true,
age: 58,
relatedPatientId: 'p-mock-mother',
planId: null,
},
] as Array<{
relationship: string;
......@@ -68,6 +72,12 @@ export const mockPatient = {
name: string | null;
phone: string | null;
linked: boolean;
/** 是不是亲戚(KIN_RELATIONSHIPS 白名单;friend/other 不算)—— 只有亲戚进「关联客户」行 */
isKin: boolean;
age: number | null;
relatedPatientId: string | null;
/** 关系人在本 scope 内可打开的工单;null = 没有 → 只显示姓名,不给链接 */
planId: string | null;
}>,
// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback),真实手机号到位前仅演示
guardian: {
......
......@@ -697,6 +697,8 @@ export function PlanDetailApp({
onSummary={setRecallBrief}
/>
</div>
{/* 第三行:关联客户(亲戚)—— 家属也在院里看牙时,客服一眼能看到并跳过去 */}
<RelatedKinRow contacts={patient.profile?.contacts ?? []} />
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{deepSteps && deepSteps.length > 0 && (
......@@ -1606,6 +1608,58 @@ function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] })
}
// ──────────────────────────────────────────
// ──────────────────────────────────────────
// RelatedKinRow — 关联客户(亲戚)
// 一家人常在同一家诊所看牙。客服打电话前看到"这人的妈妈也是我们的客户",
// 既能顺口关心、也能顺手跟进那一位,所以放在话术头部而不是收进抽屉。
//
// 只列**亲戚**(后端按 KIN_RELATIONSHIPS 打 isKin):friend / other 里混的是推荐人噪音,
// 把推荐人当家属去问病情比不显示更糟。
// 没建档(linked=false / 无姓名)的不出行 —— 一行只有关系没有人,客服拿不到任何可用信息。
// 「关联客户档案」跳的是 **PAC 自己的患者工单页**,新标签页开(不顶掉当前工单);
// 关系人在本 scope 内没有活跃工单(planId=null)→ 只显示信息不给链接,免得点进去 404。
// ──────────────────────────────────────────
function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.contacts }) {
const kin = contacts.filter((c) => c.isKin && c.linked && c.name);
if (kin.length === 0) return null;
return (
<div className="mt-1.5 space-y-1">
{kin.map((c, i) => (
<div
key={`${c.relationship}-${c.relatedPatientId ?? i}`}
className="flex items-center gap-3 rounded-md bg-slate-50 px-2.5 py-1.5 text-[11.5px]"
>
<span className="flex-none font-medium text-slate-900">{c.name}</span>
<span className="flex-none text-slate-500">
{c.relationshipLabel}
{c.age != null && ` · ${c.age}岁`}
</span>
<span className="ml-auto flex-none">
{c.planId ? (
<a
href={`/plans/${c.planId}`}
{...HOST_LINK_PROPS}
// 走 JS 才有 sandbox 兜底(同宿主槽位那两个链接,见 action-url.ts)
onClick={(e) => {
e.preventDefault();
openHostUrl(`/plans/${c.planId}`);
}}
className="text-[10.5px] text-brand-700 hover:underline"
>
关联客户档案 →
</a>
) : (
<span className="text-[10.5px] text-slate-400" title="该客户在你的数据范围内没有召回工单">
无工单
</span>
)}
</span>
</div>
))}
</div>
);
}
// RecallBriefLine — 本次召回一句话简报(LLM)
// 交互跟「历史联系 / 画像标签」一致:进来 get-or-generate;有则秒回显示一句话,
// 生成中 shimmer 占位,失败/空则回退到结构化 RecallReasonLine(信息不丢)。
......
......@@ -45,6 +45,12 @@ export type PlanDetailData = {
name: string | null;
phone: string | null;
linked: boolean;
/// 是不是亲戚(后端按 KIN_RELATIONSHIPS 判;friend/other 不算)—— 只有亲戚进「关联客户」行
isKin: boolean;
age: number | null;
relatedPatientId: string | null;
/// 关系人在本 scope 内可打开的工单;null = 没有(或越权)→ 前端不给链接,免得点进去 404
planId: string | null;
}>;
/// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback)。真实手机号到位前仅演示用。
guardian: {
......
......@@ -67,6 +67,27 @@ export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
// Patient(主档状态)
// =============================================================
/**
* ⭐ 亲戚关系白名单 —— 详情页「关联客户」只列这几类。
*
* patient_relations.relationship 的全集还有 `friend` 和 `other`,**刻意不进**:
* 那条边表摄自 fact_customer_referee_out(推荐关系),`other` 占了一半以上(本地 4043/6222),
* 里面混的是推荐人、同事、代付人这类"认识但不是亲属"的关系。字典把 other 译成「亲属」,
* 但按它列出来客服会把推荐人当家属去问病情,比不显示更糟。
*
* sibling(兄弟姐妹)是亲戚,进;family_structure 特征把它算作"非直系"是另一件事
* (那里判的是家庭结构,不是能不能称作亲戚),两处口径不同是有意的。
*/
export const KIN_RELATIONSHIPS: readonly string[] = [
'spouse',
'child',
'grandchild',
'mother',
'father',
'grandparent',
'sibling',
];
// =============================================================
// Host action URL keys — 前端跳宿主 deep-link 的 known action 集合。
//
......
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