Commit 6a7e0bdf by luoqi

merge: fix/host-embed-popup-and-copy → test(第三批)

- 话术头部新增「关联客户」:亲戚姓名/关系/年龄 + 档案链接(配了 VIEW_PATIENT 跳宿主,
  没配回落 PAC 工单页,都新标签页开)
- 画像标签按业务字典 A/B/C/D 类区上色 + 定序,首屏与画像详情抽屉收成一套(删掉原三分组)
- 选患者列 / 详情左栏宽度 300 → 320
parents 6a5d07c6 ea66930a
Pipeline #3490 failed in 0 seconds
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,19 @@ export class PlanAggregateService {
profile: true,
// 联系人/亲属边 → 关系人姓名/电话从 relatedPatient 现取(patient↔patient,零冗余)
relationsOut: {
include: { relatedPatient: { select: { name: true, phone: true } } },
include: {
relatedPatient: {
// externalId / 病历号:给宿主 VIEW_PATIENT 槽位的占位替换用(同本人「原始档案」那条路)
select: {
id: true,
externalId: true,
medicalRecordNumber: true,
name: true,
phone: true,
birthDate: true,
},
},
},
},
// 诊所回访记录(展示用,按时间倒序,封顶 100;每患者 p99=19,够用)
// nulls:'last' —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会顶到列表最前,
......@@ -108,7 +120,20 @@ export class PlanAggregateService {
patient: Prisma.PatientGetPayload<{
include: {
profile: true;
relationsOut: { include: { relatedPatient: { select: { name: true; phone: true } } } };
relationsOut: {
include: {
relatedPatient: {
select: {
id: true;
externalId: true;
medicalRecordNumber: true;
name: true;
phone: true;
birthDate: true;
};
};
};
};
returnVisits: true;
};
}>,
......@@ -190,9 +215,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 +294,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 +408,20 @@ function serializeProfile(
relationship: string;
relatedExternalId: string;
relatedPatientId: string | null;
relatedPatient: { name: string | null; phone: string | null } | null;
relatedPatient: {
id: string;
externalId: string;
medicalRecordNumber: string | null;
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 +452,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 +465,13 @@ 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,
// 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页
externalId: r.relatedPatient?.externalId ?? r.relatedExternalId,
medicalRecordNumber: r.relatedPatient?.medicalRecordNumber ?? null,
planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null,
}))
.sort((a, b) => Number(b.linked) - Number(a.linked));
......
import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_KEY_FEATURE_KEYS,
personaTagDimId, PERSONA_FEATURE_META } from '@pac/types';
personaTagDimId, PERSONA_FEATURE_META, PERSONA_FEATURE_CATEGORY_META,
PERSONA_FEATURE_CATEGORY_ORDER, personaFeatureSortKey } from '@pac/types';
import {
FeatureRegistry,
FEATURE_EXTRACTOR_PROVIDERS,
......@@ -139,3 +140,56 @@ describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
expect(new Set(ids).size).toBe(ids.length);
});
});
/**
* 标签类区(业务字典 A/B/C/D)—— 2026-07-30 起是**唯一一套分类**:
* 首屏 chip 的颜色 / 首屏顺序 / 画像详情抽屉的分组与顺序全从它出。
* 这里守住"别再长出第二套":每个标签必须有类区、类区序必须齐全、两处排序必须同键。
*/
describe('标签类区 A/B/C/D —— 分组 / 排序 / 上色的唯一源', () => {
test('⭐ 16 个标签每个都要有类区,且是 A/B/C/D 之一', () => {
const bad = Object.entries(PERSONA_FEATURE_SPECS)
.filter(([, spec]) => !['A', 'B', 'C', 'D'].includes(spec.display.category))
.map(([k]) => k);
expect(bad).toEqual([]);
});
test('⭐ 类区序覆盖全部四类,顺序是业务定的 C→B→D→A(重要的在前)', () => {
expect(PERSONA_FEATURE_CATEGORY_ORDER).toEqual(['C', 'B', 'D', 'A']);
expect(Object.keys(PERSONA_FEATURE_CATEGORY_META).sort()).toEqual(['A', 'B', 'C', 'D']);
});
test('每个类区都有中文名 + 颜色(缺了抽屉标题会空、chip 会退灰)', () => {
for (const [c, meta] of Object.entries(PERSONA_FEATURE_CATEGORY_META)) {
expect(meta.label.length).toBeGreaterThan(0);
expect(meta.tone.length).toBeGreaterThan(0);
expect(c).toMatch(/^[ABCD]$/);
}
});
test('⭐ 同类区内 order 不许重复 —— 重了排序就退化成不稳定的兜底比较', () => {
const seen = new Map<string, number[]>();
for (const spec of Object.values(PERSONA_FEATURE_SPECS)) {
const arr = seen.get(spec.display.category) ?? [];
arr.push(spec.display.order);
seen.set(spec.display.category, arr);
}
for (const [cat, orders] of seen) {
expect(new Set(orders).size).toBe(orders.length); // cat 见 message
expect(cat).toMatch(/^[ABCD]$/);
}
});
test('⭐ 首屏白名单里的标签排序落在类区序内(首屏与抽屉同一个 sortKey)', () => {
const keys = [...PERSONA_KEY_FEATURE_KEYS];
const sorted = [...keys].sort((a, b) => {
const [ca, oa] = personaFeatureSortKey(a);
const [cb, ob] = personaFeatureSortKey(b);
return ca - cb || oa - ob;
});
// 排完之后类区下标必须单调不减 —— 若有标签没登记类区会被推到最后,这条会挂
const cats = sorted.map((k) => personaFeatureSortKey(k)[0]);
for (let i = 1; i < cats.length; i++) expect(cats[i]!).toBeGreaterThanOrEqual(cats[i - 1]!);
expect(cats[cats.length - 1]!).toBeLessThan(PERSONA_FEATURE_CATEGORY_ORDER.length);
});
});
......@@ -13,7 +13,7 @@ export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header';
/**
* /plans 段布局 — 详情页"一页式工作台"骨架(上下大布局):
* ┌ header(详情 TopBar portal 到此,全宽延伸到最左)┐
* ├ 左栏选患者列 300px │ 详情内容(原三列) ┤
* ├ 左栏选患者列 320px │ 详情内容(原三列) ┤
* 布局挂在动态段之上 → 切患者只重渲染右侧,左栏筛选/分页/滚动全保留。
* 响应式:<lg(1024)左栏收成抽屉(单实例 CSS 平移,状态不丢)+ 左缘竖把手"选患者"作明确点击点;
* 选中患者后自动收起。/plans 列表页(无 planId 段)不渲染本骨架。
......
......@@ -61,6 +61,12 @@ export const mockPatient = {
name: '伍晴晴',
phone: '13800000000',
linked: true,
isKin: true,
age: 58,
relatedPatientId: 'p-mock-mother',
planId: null,
externalId: '5i5ya_PA00999001',
medicalRecordNumber: 'SH0Q099001',
},
] as Array<{
relationship: string;
......@@ -68,6 +74,15 @@ 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;
/** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */
externalId: string;
medicalRecordNumber: string | null;
}>,
// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback),真实手机号到位前仅演示
guardian: {
......
......@@ -2,11 +2,11 @@
import * as React from 'react';
import {
PERSONA_FEATURE_GROUP_LABEL,
PERSONA_FEATURE_GROUP_ORDER,
personaFeatureGroup,
PERSONA_FEATURE_CATEGORY_META,
PERSONA_FEATURE_CATEGORY_ORDER,
personaFeatureCategory,
personaFeatureSortKey,
type PersonaFeatureGroup,
type PersonaFeatureCategory,
} from '@pac/types';
import { cn } from '@/lib/utils';
import { tone } from './shared';
......@@ -21,7 +21,9 @@ import type { AdaptedFact } from './adapt-data';
* 相对旧版改了三件事:
* ① **分组 + 定序**。原来 16 张卡等权重平铺,顺序来自 `orderBy createdAt`;而同一版的 feature
* 行是一条 createMany 写进去的、createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key
* 字母序:「急迫等级 = 紧急」排在最后一个,「性别」「获客渠道」排在最前。现按用途分三组。
* 字母序:「急迫等级 = 紧急」排在最后一个,「性别」「获客渠道」排在最前。
* 现按**业务字典的 A/B/C/D 类区**分组(2026-07-30 起,与身份卡首屏 chip 同出一源;
* 此前这里是另一套「跟进要点 / 价值与阶段 / 基础属性」三分组,两套分类并存必然漂)。
* ② **给出依据**。后端一直存着 evidence.factIds(rfm + lifecycle 两个特征在生产就占 1 GB),
* 但前端 adapt-data 把它写死成空数组 —— 客服只看得到结论、看不到出处。现在反查同一份 facts
* 渲染最近几条,并给出总条数。
......@@ -80,31 +82,38 @@ export function PersonaDetailList({
const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key);
});
const buckets = new Map<PersonaFeatureGroup | 'other', PersonaFeature[]>();
const buckets = new Map<PersonaFeatureCategory | 'other', PersonaFeature[]>();
for (const f of sorted) {
const g = personaFeatureGroup(f.key) ?? 'other';
buckets.set(g, [...(buckets.get(g) ?? []), f]);
const c = personaFeatureCategory(f.key) ?? 'other';
buckets.set(c, [...(buckets.get(c) ?? []), f]);
}
return buckets;
}, [features]);
const sections: Array<[string, PersonaFeature[]]> = [
...PERSONA_FEATURE_GROUP_ORDER.map(
(g) => [PERSONA_FEATURE_GROUP_LABEL[g], grouped.get(g) ?? []] as [string, PersonaFeature[]],
// 分组标题带类区色点 —— 与首屏 chip 的颜色一一对应,客服在两处认的是同一套色
const sections: Array<[string, string, PersonaFeature[]]> = [
...PERSONA_FEATURE_CATEGORY_ORDER.map(
(c) =>
[PERSONA_FEATURE_CATEGORY_META[c].label, PERSONA_FEATURE_CATEGORY_META[c].tone, grouped.get(c) ?? []] as [
string,
string,
PersonaFeature[],
],
),
// 注册表里没登记的标签不吞掉,兜到最后一组(比"标签凭空消失"好排查)
['其他', grouped.get('other') ?? []],
['其他', 'slate', grouped.get('other') ?? []],
];
return (
<div className="space-y-4">
{sections
.filter(([, items]) => items.length > 0)
.map(([label, items]) => (
.filter(([, , items]) => items.length > 0)
.map(([label, sectionTone, items]) => (
<section key={label} className="space-y-2">
<h3 className="text-[10.5px] font-semibold tracking-wide text-slate-400">
<h3 className="flex items-center gap-1.5 text-[10.5px] font-semibold tracking-wide text-slate-400">
<span className={cn('h-1.5 w-1.5 flex-none rounded-full', tone(sectionTone).dot)} />
{label}
<span className="ml-1.5 font-normal text-slate-300">{items.length}</span>
<span className="font-normal text-slate-300">{items.length}</span>
</h3>
{items.map((f) => (
<FeatureCard
......
......@@ -49,7 +49,7 @@ import {
potentialTreatmentItemName,
HOST_CASE_STAGE_CONSULTED,
type HostPotentialTreatmentPayload,
personaKeyFeatureOrder,
personaFeatureCategoryTone,
type AbandonReason,
type ExecutionOutcome,
} from '@pac/types';
......@@ -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 && (
......@@ -809,7 +811,8 @@ function ResponsiveDetail({
{isXl ? (
<div
className="grid h-full gap-3"
style={{ gridTemplateColumns: rightPane ? '300px 1fr 380px' : '300px 1fr' }}
// 左栏 320px:与选患者列同宽(业务 2026-07-30)。原 300px 下画像标签 chip 折得太碎。
style={{ gridTemplateColumns: rightPane ? '320px 1fr 380px' : '320px 1fr' }}
>
{leftPane}
{centerPane}
......@@ -1349,12 +1352,18 @@ function IdentityCard({
onOpenProfile: () => void;
}) {
// 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。
// 选哪些 + 什么顺序都是业务口径,一并收在 PERSONA_KEY_FEATURE_KEYS(数组序即展示序)。
// 选哪些 = PERSONA_KEY_FEATURE_KEYS 白名单;顺序和颜色 = 业务字典的 A/B/C/D 类区(见 types)。
const keyFeatures = useMemo(
() =>
features
.filter((f) => isKeyPersonaFeature(f.key))
.sort((a, b) => personaKeyFeatureOrder(a.key) - personaKeyFeatureOrder(b.key)),
// ⭐ 与画像详情抽屉**同一个排序键**(类区序 C→B→D→A + 类区内序)——
// 以前首屏走白名单数组序、抽屉走分组序,同一批标签两个顺序,客服得重新找一遍。
.sort((a, b) => {
const [ca, oa] = personaFeatureSortKey(a.key);
const [cb, ob] = personaFeatureSortKey(b.key);
return ca - cb || oa - ob;
}),
[features],
);
const [copied, setCopied] = useState(false);
......@@ -1599,6 +1608,75 @@ function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] })
}
// ──────────────────────────────────────────
// ──────────────────────────────────────────
// RelatedKinRow — 关联客户(亲戚)
// 一家人常在同一家诊所看牙。客服打电话前看到"这人的妈妈也是我们的客户",
// 既能顺口关心、也能顺手跟进那一位,所以放在话术头部而不是收进抽屉。
//
// 只列**亲戚**(后端按 KIN_RELATIONSHIPS 打 isKin):friend / other 里混的是推荐人噪音,
// 把推荐人当家属去问病情比不显示更糟。
// 没建档(linked=false / 无姓名)的不出行 —— 一行只有关系没有人,客服拿不到任何可用信息。
// ⭐「关联客户档案」的目标**与本人的「原始档案」同一套逻辑**(业务 2026-07-30):
// 宿主配了 actionUrls.VIEW_PATIENT → 跳**宿主自己的档案页**(占位换成这位关系人的
// {patientId}/{medicalRecordNumber});没配 → 回落 PAC 自己的工单页 /plans/<planId>。
// 理由:宿主档案才是客服真正要看的那一页(有真手机号、有全量病历),PAC 工单页只是兜底。
// 两条路都新标签页开(不顶掉当前工单)。都没有(未配 + 无工单)→ 只显示信息不给链接。
// ──────────────────────────────────────────
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">
{(() => {
// 宿主档案优先,PAC 工单页兜底(见组件头注释)
const hostUrl = resolveActionUrl('VIEW_PATIENT', {
patientId: c.externalId,
medicalRecordNumber: c.medicalRecordNumber,
});
const url = hostUrl ?? (c.planId ? `/plans/${c.planId}` : null);
if (!url) {
return (
<span
className="text-[10.5px] text-slate-400"
title="宿主未配置档案跳转,且该客户在你的数据范围内没有召回工单"
>
无档案入口
</span>
);
}
return (
<a
href={url}
{...HOST_LINK_PROPS}
// 走 JS 才有 sandbox 兜底(同宿主槽位那两个链接,见 action-url.ts)
onClick={(e) => {
e.preventDefault();
openHostUrl(url);
}}
className="text-[10.5px] text-brand-700 hover:underline"
>
关联客户档案 →
</a>
);
})()}
</span>
</div>
))}
</div>
);
}
// RecallBriefLine — 本次召回一句话简报(LLM)
// 交互跟「历史联系 / 画像标签」一致:进来 get-or-generate;有则秒回显示一句话,
// 生成中 shimmer 占位,失败/空则回退到结构化 RecallReasonLine(信息不丢)。
......@@ -2398,14 +2476,26 @@ function PersonaTagCloud({
);
// 首屏:真 button —— 可 Tab 聚焦、读屏能念出标签名(视觉上 label 已去掉,靠 aria-label 补)
// ⭐ 颜色按**类区**(4 色)而不是按标签(16 色):业务 2026-07-30。
// 一标签一色等于没有色彩层次(全是重点=没重点);按类区上色,客服扫一眼就知道
// "这排是该聊什么、那排是要避让的"。色板与抽屉分组标题同源,两处不会各自漂。
if (plain) {
const C = tone(personaFeatureCategoryTone(f.key));
return (
<button
key={f.key}
type="button"
onClick={() => onSelect?.(f.key)}
aria-label={`${f.label}:${text} —— 查看画像详情`}
className="inline-flex max-w-full items-center gap-1 rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200 hover:bg-slate-50 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
// ⚠️ 颜色类必须整串来自 TONE 表(shared.tsx 里是字面量,Tailwind 扫得到)。
// 别写 `'hover:' + C.bg` 这种拼接 —— Tailwind 是静态扫源码的,拼出来的类名不会被生成,
// 页面上表现为"这个颜色没生效",还很难查。
className={cn(
'inline-flex max-w-full items-center gap-1 rounded px-1 py-px text-[10px] leading-4 ring-1 ring-inset hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
C.bg,
C.text,
C.ring,
)}
>
{inner}
</button>
......
......@@ -45,6 +45,15 @@ 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;
/// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案)
externalId: string;
medicalRecordNumber: string | null;
}>;
/// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback)。真实手机号到位前仅演示用。
guardian: {
......
......@@ -191,7 +191,7 @@ export function PatientPickerRail({
});
return (
<aside className="flex h-full w-[300px] flex-none flex-col border-r border-slate-100 bg-white">
<aside className="flex h-full w-[320px] flex-none flex-col border-r border-slate-100 bg-white">
{/* view tabs */}
<div className="flex flex-none gap-1 border-b border-slate-100 px-2 pt-2">
{VIEW_TABS.filter((t) => t.v !== 'all' || canViewAll).map((t) => (
......
......@@ -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 集合。
//
......
......@@ -13,22 +13,36 @@ export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm';
export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed';
/**
* 详情页分组 —— 按「客服打这通电话时的用途」分,不按数据血缘分
* ⭐ 标签类区 —— 出自《客户画像标签字典 v3.0》的 A/B/C/D 编号(每个标签上方注释里那个 `A.1.1` 就是它)
*
* 原来 16 个标签在抽屉里等权重平铺,且顺序取自 `orderBy createdAt`,而同一版的 feature 行是
* 一条 createMany 写进去的、createdAt 完全相同 —— 排序实际未定义,生产上呈现为英文 key 字母序:
* 「急迫等级=紧急」排在最后一个,「性别」「获客渠道」排在最前。
* 2026-07-30 起这是**唯一一套分类**:首屏 chip 的颜色、首屏顺序、画像详情抽屉的分组标题与顺序,
* 全从这里出。此前抽屉另有一套「跟进要点 / 价值与阶段 / 基础属性」的三分组(按打电话的用途分),
* 与业务字典并存 —— 两套分类同时活着,加标签时要想"这个归哪一组"两遍,而且两边会各自漂。
* 现在收成一套:业务字典怎么分,PAC 就怎么分、怎么排、怎么上色。
*/
export type PersonaFeatureGroup = 'action' | 'value' | 'basic';
export type PersonaFeatureCategory = 'A' | 'B' | 'C' | 'D';
export const PERSONA_FEATURE_GROUP_LABEL: Record<PersonaFeatureGroup, string> = {
action: '跟进要点',
value: '价值与阶段',
basic: '基础属性',
/**
* 类区展示元数据 —— 中文名 + 颜色。
* 颜色沿用 PERSONA_FEATURE_META 那套 tone 语义(见 labels.ts 里"红色是稀缺资源"那段):
* C 琥珀 = 有事可做(该聊什么) B 靛蓝 = 钱与阶段
* D 玫红 = 要小心 / 要避让 A 灰 = 背景信息,不需要判断
*/
export const PERSONA_FEATURE_CATEGORY_META: Record<
PersonaFeatureCategory,
{ label: string; tone: string }
> = {
C: { label: '临床需求', tone: 'amber' },
B: { label: '价值与阶段', tone: 'indigo' },
D: { label: '行为与偏好', tone: 'rose' },
A: { label: '基础属性', tone: 'slate' },
};
/** 分组自身的展示顺序 */
export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'value', 'basic'];
/**
* 类区展示顺序(业务定序 2026-07-30):**该聊什么 → 给多大力度 → 怎么谈 / 要避让 → 背景**。
* 不是字典的 A→D 字母序 —— 字母序把"基础属性"排第一,客服最不需要的东西占了首屏开头。
*/
export const PERSONA_FEATURE_CATEGORY_ORDER: PersonaFeatureCategory[] = ['C', 'B', 'D', 'A'];
/**
* ⭐ 身份卡「关键标签」—— 患者姓名下面直接露出的那几个,不是全部 16 个。
......@@ -37,11 +51,9 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va
* 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 ——
* 这里取舍的是**首屏**,不是信息。
*
* ⭐ **数组顺序 = 首屏展示顺序**(业务定序,2026-07-29),不走 personaFeatureSortKey ——
* 抽屉那套序是按"跟进要点 / 价值与阶段 / 基础属性"分组的,首屏没有分组标题,
* 照那个序排出来客服看不出章法。这里是业务自己排的一条线:
* 做过什么 → 还差什么 → 这人什么档位 / 走到哪一步 → 能怎么付 / 谈价底线 →
* 怎么来的 → 最后两颗是风险(禁忌 / 特别关注)。
* ⚠️ **这个数组只管"哪些进首屏",不管顺序**(2026-07-30 起)。顺序统一走
* personaFeatureSortKey(类区序 C→B→D→A + 类区内序)—— 与画像详情抽屉同出一源。
* 以前这里是"数组序即展示序",于是同一批标签在首屏和抽屉里是两个顺序,客服要重新找一遍。
*
* 急迫等级不进首屏:顶栏「优先级」条已经表达同一件事(急迫是它的最大权重项),
* 同屏两处讲一件事,客服会以为是两个指标。
......@@ -50,7 +62,7 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va
* 年龄段 / 性别(姓名行已经有性别年龄,重复占位);家庭构成 / 转介绍达人
* (有价值但不改变这通电话怎么开场)。
*
* ⚠️ 想加 key 先自问:客服扫一眼真会拿它做决定吗?加了要顺手排进上面那条线里。
* ⚠️ 想加 key 先自问:客服扫一眼真会拿它做决定吗?
*/
export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
'treatment_history',
......@@ -68,11 +80,7 @@ export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
export const isKeyPersonaFeature = (key: string): boolean =>
PERSONA_KEY_FEATURE_KEYS.includes(key);
/** 首屏排序键 = 白名单下标;不在首屏的排最后(理论上不会被问到) */
export const personaKeyFeatureOrder = (key: string): number => {
const i = PERSONA_KEY_FEATURE_KEYS.indexOf(key);
return i < 0 ? PERSONA_KEY_FEATURE_KEYS.length : i;
};
/**
* 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
......@@ -101,22 +109,31 @@ export interface PersonaFeatureDisplay {
* 要精简就精简 body 的字数,不是砍掉结构。
*/
rules: Array<{ label: string; body: string }>;
/** 详情页分组 */
group: PersonaFeatureGroup;
/** 内排序(小的在前;留 10 的间隔便于插新标签) */
/** 所属类区(业务字典 A/B/C/D;决定颜色 + 分组 + 排序) */
category: PersonaFeatureCategory;
/** 类区内排序(小的在前;留 10 的间隔便于插新标签) */
order: number;
}
/** 展示排序键:先按组、再按组内序;未登记的标签排最后(仍可见,不吞) */
/**
* ⭐ 展示排序键:先按类区序、再按类区内序;未登记的标签排最后(仍可见,不吞掉)。
* **首屏 chip 和画像详情抽屉都用这一个** —— 这就是"同出一源"的落点,别再各写一套 sort。
*/
export function personaFeatureSortKey(key: string): [number, number] {
const d = PERSONA_FEATURE_SPECS[key]?.display;
if (!d) return [PERSONA_FEATURE_GROUP_ORDER.length, 0];
return [PERSONA_FEATURE_GROUP_ORDER.indexOf(d.group), d.order];
if (!d) return [PERSONA_FEATURE_CATEGORY_ORDER.length, 0];
return [PERSONA_FEATURE_CATEGORY_ORDER.indexOf(d.category), d.order];
}
/** 该标签属于哪个组(未登记 → null,调用方自行归入"其他") */
export const personaFeatureGroup = (key: string): PersonaFeatureGroup | null =>
PERSONA_FEATURE_SPECS[key]?.display?.group ?? null;
/** 该标签属于哪个类区(未登记 → null,调用方自行归入"其他") */
export const personaFeatureCategory = (key: string): PersonaFeatureCategory | null =>
PERSONA_FEATURE_SPECS[key]?.display?.category ?? null;
/** 类区颜色(tone key);未登记 → slate */
export const personaFeatureCategoryTone = (key: string): string => {
const c = personaFeatureCategory(key);
return c ? PERSONA_FEATURE_CATEGORY_META[c].tone : 'slate';
};
export interface PersonaFeatureSpec {
key: string; // PersonaFeatureKey
......@@ -173,7 +190,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '挽留', body: '消费高,但很久没来了' },
{ label: '低活跃', body: '很久没来,或三项都偏低' },
],
group: 'value',
category: 'B',
order: 10,
},
owner: 'pac-algo',
......@@ -214,8 +231,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '青少年 / 青年 / 中青年', body: '12-17 / 18-25 / 26-30 岁' },
{ label: '中年 / 中老年 / 老年', body: '31-45 / 46-54 / 55 岁以上' },
],
group: 'basic',
order: 10,
category: 'A',
order: 20,
},
owner: 'pac-algo',
version: 1,
......@@ -238,8 +255,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '男性 / 女性', body: '按建档性别' },
{ label: '未知', body: '建档没填,或填的值无法识别' },
],
group: 'basic',
order: 20,
category: 'A',
order: 30,
},
owner: 'pac-algo',
version: 1,
......@@ -264,8 +281,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '电商平台 / 自媒体网络', body: '线上平台来的' },
{ label: '内部员工 / 其他', body: '其余来源' },
],
group: 'basic',
order: 40,
category: 'A',
order: 10,
},
owner: 'pac-algo',
version: 1,
......@@ -294,8 +311,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '只有旁系或朋友' },
],
group: 'basic',
order: 30,
category: 'A',
order: 40,
},
owner: 'pac-algo',
version: 1,
......@@ -322,8 +339,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '家庭型', body: '带的是家里人' },
{ label: '社交型', body: '带的是家人以外的人' },
],
group: 'value',
order: 50,
category: 'B',
order: 40,
},
owner: 'pac-algo',
version: 1,
......@@ -353,8 +370,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '儿牙会员', body: '儿牙卡 / 涂氟年卡' },
{ label: '医保客户', body: '用过医保结算' },
],
group: 'value',
order: 40,
category: 'B',
order: 30,
},
owner: 'pac-algo',
version: 2,
......@@ -383,7 +400,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '成长客 / 成熟客', body: '还在来,消费在涨 / 消费平稳' },
{ label: '待激活 / 沉睡客 / 流失客', body: '半年~1年半 / 1年半~2年 / 2年以上没来' },
],
group: 'value',
category: 'B',
order: 20,
},
owner: 'pac-algo',
......@@ -411,8 +428,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '修复史', body: '冠桥 / 贴面 / 嵌体' },
{ label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' },
],
group: 'value',
order: 30,
category: 'C',
order: 10,
},
owner: 'pac-algo',
version: 1,
......@@ -439,8 +456,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '工作日 / 周末', body: '近两年预约多在这几天' },
{ label: '上午 / 下午 / 晚间', body: '8-12 / 12-18 / 18-21 点' },
],
group: 'action',
order: 60,
category: 'D',
order: 50,
},
owner: 'pac-algo',
version: 1,
......@@ -466,8 +483,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '最低折扣', body: '原价 500 元以上的治疗里谈成过的最深折扣' },
{ label: '日期 / 项目', body: '那一次是什么时候、做的什么' },
],
group: 'action',
order: 70,
category: 'D',
order: 10,
},
owner: 'pac-algo',
version: 1,
......@@ -496,7 +513,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '免打扰', body: '已标记不接受主动联系' },
{ label: '不可等候', body: '提过赶时间 / 不能等' },
],
group: 'action',
category: 'D',
order: 30,
},
owner: 'pac-algo',
......@@ -525,8 +542,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '晕针 / 晕血', body: '提过怕打针 / 见血不适' },
{ label: '密闭恐惧', body: '提过幽闭 / 长时间张口不适' },
],
group: 'action',
order: 50,
category: 'D',
order: 40,
},
owner: 'pac-algo',
version: 1,
......@@ -554,8 +571,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '正畸 / 早矫', body: '成人正畸 13-40 岁 / 儿童早矫 3-12 岁' },
{ label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
],
group: 'action',
order: 10,
category: 'C',
order: 20,
},
owner: 'pac-algo',
version: 1,
......@@ -583,8 +600,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '高', body: '30-90 天没来' },
{ label: '中', body: '近期来过或新发现' },
],
group: 'action',
order: 20,
category: 'C',
order: 30,
},
owner: 'pac-algo',
version: 1,
......@@ -616,8 +633,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '麻醉禁忌', body: '局麻药物过敏(利多卡因/普鲁卡因等);哮喘为相对禁忌' },
{ label: '正畸禁忌', body: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
],
group: 'action',
order: 40,
category: 'D',
order: 20,
},
owner: 'pac-algo',
version: 2,
......
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