Commit dd6795c0 by luoqi

merge: fix/host-embed-popup-and-copy → main(本周第二批迭代)

## 嵌宿主可用性(修一个已上线的回归)
- 宿主槽位跳转三级兜底:新标签页 → 顶层跳转 → 本 frame。上一版只有一行 window.open,
  宿主 iframe 缺 allow-popups 时**点了没反应**(生产 actionUrls 全配着,路径可达)。
- 一步 open 带 URL,不再"先开 about:blank 再导航" —— 那个写法在 sandbox 下会因
  「断了 opener 就无权导航该弹窗」抛 SecurityError。
- 交付文档 docs/integration/postmessage-actions.mdx(可直接给宿主开发)。

## 宿主对接
- 打开潜在治疗的 postMessage 补 desc / treatments / stage 三字段(treatments 发项目名,不带「治疗」后缀)。
- 顶栏「回访」→「跟进」;去掉「已在新标签页打开」toast。

## 画像 / 详情页
- 标签按业务字典 A/B/C/D 类区上色 + 定序(C→B→D→A),首屏与画像详情抽屉收成一套(删原三分组)。
- 话术头部新增「关联客户」:亲戚姓名 / 关系 / 年龄 + 档案链接(配了 VIEW_PATIENT 跳宿主,否则回落 PAC 工单页)。
- 亲戚关系方向按年龄实时纠正 —— 兜的是人工录错(瑞尔 4%),与 friday 摄入侧根治是两件事,两者都要。
- 潜在治疗中文一律 code 查表,修「潜在补牙」vs「充填治疗」口径不一致。
- 选患者列 / 详情左栏 300 → 320;召回池卡片去掉优先级五色点。

## 数据 / 权限
- 「机会识别不准确」补必填多选:哪几类推荐治疗不准 → plan_executions.inaccurate_treatments
  (存 code / 仅统计 / 不参与抑制)。
- 医生名单缓存 6h → 10min + 重算收尾主动清;客服可自助返池(带归属闸,只能退自己的)。
parents a38f1b60 d90ea308
Pipeline #3498 failed in 0 seconds
......@@ -10,7 +10,8 @@
"channel-push",
"friday-push-payload",
"auth-login",
"postmessage-actions",
"execution-callback",
"runbook"
]
}
\ No newline at end of file
}
---
title: postMessage 动作契约(宿主侧接收)
description: PAC 嵌在宿主 iframe 里时,「打开潜在治疗」等动作如何以 postMessage 通知宿主 —— 信封、字段、示例监听代码、安全要点。
icon: MessageSquareShare
---
本页是给**宿主开发**看的交付文档:PAC 作为 iframe 嵌在你们系统里,某些动作按钮点下去需要**你们弹自己的组件**,
PAC 通过 `window.parent.postMessage` 把患者上下文推给你们。
<Callout type="info">
**优先用 URL,不是 postMessage**。动作只要有独立页面(或能用深链参数触发弹窗),就配 URL 模板 —— 见
[接入总览 §4](/docs/integration/overview)。只有「动作是弹窗/组件、拿不到 URL」时才用本页这条通道。
</Callout>
---
## 1. 怎么开启
在宿主管理页(或让 PAC 侧配)把动作键的值配成**哨兵字符串** `postMessage`,并且**必须同时配 `HOST_ORIGIN`**:
```json
{
"OPEN_POTENTIAL_TREATMENT": "postMessage",
"HOST_ORIGIN": "https://host.example.com"
}
```
- `HOST_ORIGIN` = 承载 PAC iframe 的**父页 origin**,用作 `postMessage` 的 `targetOrigin`。
- **不接受 `*`** —— 载荷含患者信息,不能广播给任意父页。没配 `HOST_ORIGIN` 时 PAC 判定该动作不可用、按钮直接不渲染。
- 哨兵值大小写不敏感(`postMessage` / `POSTMESSAGE` 都认)。
---
## 2. 消息信封
```ts
{
source: 'pac', // 固定
type: 'action', // 固定
action: 'OPEN_POTENTIAL_TREATMENT', // 动作键,同 actionUrls 的 key
payload: { /* 见下 */ }
}
```
**请用 `source === 'pac' && type === 'action'` 过滤** —— 同一个父页可能还嵌着别的 iframe,别按 `action` 裸判。
信封**没有** `version` 字段。兼容纪律由 PAC 保证:**只增字段、不改已有字段语义、不删字段**。
你们按需要读字段即可,多出来的忽略。
---
## 3. `OPEN_POTENTIAL_TREATMENT` 载荷
| 字段 | 类型 | 说明 |
|---|---|---|
| `patientId` | `string` | 宿主侧患者 id —— 就是你们摄入时给 PAC 的那个 id,可直接用来定位患者 |
| `desc` | `string` | **待治疗描述**。取客服此刻在页面顶部读到的那句 AI 召回简报(「这通电话的由头」);简报还没生成好时退回结构化召回原因文本。两者都没有 → 空串(**字段一定在,不会是 undefined**) |
| `treatments` | `string[]` | **关联治疗项目**,项目名数组(不带「治疗」后缀,如 `"种植"`)。无潜在治疗 → 空数组 |
| `stage` | `string` | **病例阶段**,PAC 恒发 `"已咨询"` |
### 完整示例
```json
{
"source": "pac",
"type": "action",
"action": "OPEN_POTENTIAL_TREATMENT",
"payload": {
"patientId": "1499498",
"desc": "多颗缺牙拖 3 个月易致邻牙移位;高价值老客,医生医嘱交代过「半年定期检查」,可约复查顺带评估种植修复。",
"treatments": ["种植", "修复"],
"stage": "已咨询"
}
}
```
### `treatments` 取值范围(共 8 类)
**只有下面 8 个值**,不带「治疗」后缀。中文是 PAC 的措辞口径、可能随业务调整;
要**稳定键**请按下表自己映射一次。
| 取值 | 稳定 code | 含义 |
|---|---|---|
| 种植 | `implant` | 缺牙待种 |
| 正畸 | `ortho` | 成人正畸 |
| 早矫 | `early_ortho` | 儿童早矫(替牙期)。界面上这一项叫「早期矫治」,项目名简称「早矫」 |
| 根管 | `endo` | 牙髓 |
| 牙周 | `perio` | 牙周 |
| 充填 | `filling` | 龋齿 |
| 修复 | `restoration` | 冠桥 / 贴面 / 嵌体 |
| 拔牙 | `extraction` | 残根残冠等需拔 |
<Callout type="info">
PAC 界面上客服看到的是「种植**治疗**」「根管**治疗**」,发给你们的是去掉后缀的项目名 ——
**两处措辞不同是有意的**(那边是给人读的标签,这边是给系统落单的项目名),
且由同一张表推导,不会各自漂。
</Callout>
### `stage` 为什么恒为「已咨询」
PAC 侧的"潜在治疗"是从**诊断 / 医生建议**推出来的客观缺口,还没进你们的病例流程;
客服点这个按钮的语义就是"我已经跟患者聊过、请在宿主侧建单",所以阶段恒为已咨询。
PAC **不推断**「已确诊 / 已排期」这类流程内状态 —— 那是宿主的真理源,猜错比不给更糟。
---
## 4. 宿主侧监听示例
```js
window.addEventListener('message', (e) => {
// ① 校验来源 origin —— 只信你自己嵌的那个 PAC 域名,别用 e.origin 之外的东西判
if (e.origin !== 'https://pac.example.com') return;
const msg = e.data;
if (!msg || msg.source !== 'pac' || msg.type !== 'action') return;
if (msg.action === 'OPEN_POTENTIAL_TREATMENT') {
const { patientId, desc, treatments, stage } = msg.payload;
openPotentialTreatmentDialog({
patientId,
desc, // 待治疗描述
items: treatments, // ["种植", "修复", ...]
stage, // "已咨询"
});
}
});
```
<Callout type="warn">
**必须校验 `e.origin`**。`message` 事件任何页面都能发,不校验 origin 等于给自己开了个后门。
PAC 侧已经用 `targetOrigin` 定向发送(不广播),但那只防"发错人",防不了"别人冒充 PAC 发给你"。
</Callout>
---
## 5. 注意事项
- **URL 模式不带这三个字段**。`desc` 是长文本、`treatments` 是数组,
塞 query string 会撞长度上限,还会把病情描述写进浏览器历史和你们的 access log。
URL 模式只带 `{patientId}` `{brandId}` `{clinicId}` `{medicalRecordNumber}` 这类占位。
要 URL 模式也拿到全量字段,得改成宿主提供 POST 接口 —— 需要就提。
- **单向、无回调**。PAC 不等你们的响应、也不接收回执。动作结果通过数据摄入回流(如新建的治疗单 → 治疗事实)
+ 客服在 PAC 记通话结果来体现。
- **iframe sandbox 别漏权限**。若你们给 iframe 加了 `sandbox`,至少要有
`allow-scripts allow-same-origin`;**动作要开新标签页则必须加 `allow-popups`**
(否则浏览器直接拦掉,控制台报 `Blocked opening ... 'allow-popups' permission is not set`,
PAC 会退化成整页跳转)。
**建议再加 `allow-popups-to-escape-sandbox`** —— 只给 `allow-popups` 时新标签页会**继承沙箱**,
你们自己的页面在里面可能功能不全(存储受限、表单提交被挡等)。推荐:
```html
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation"
```
- **认领前点不到**。这些按钮在 PAC 侧过「认领闸」——工单没被客服认领时点了会被拦,
是有意为之(没有归属人后面对不上账),不是 bug。
-- 「机会识别不准确」限定:哪几类推荐治疗不准(plan_executions.inaccurate_treatments)
--
-- 【为什么加列而不是建表】它是 abandon_reasons 里 'inaccurate' 那一项的**限定词**,不是独立事实:
-- 同一次提交、同一条 execution、生命周期完全依附那行,基数 ≤8(画像潜在治疗共 8 类)。
-- 单独建表只多一次 join,换不来任何东西。
--
-- 【为什么存 code 不存中文】这列唯一的价值是**可统计**(哪类召回最容易被判不准 → 回去改规则)。
-- 中文措辞两天内改过三轮(潜在种植 → 种植治疗 → 种植),存中文等于把当时的措辞烤进历史数据,
-- 三个月后统计要 `WHERE label IN (三种写法)`。存 implant / endo / perio… 这种稳定键。
-- (同类教训:原来的 abandon_other 自由文本列就是因为"只能取到字面量、统计不了"被删的。)
--
-- 【⚠️ 不参与抑制】业务 2026-07-30 明确:只作统计 / 算法改进输入。抑制仍是信号级、
-- 按 plan 的全部 reason 一起压(plan-engine.fetchSnoozedSignalKeys)—— 客服选"只有种植不准"
-- **不会**只放过根管那条。看到这列别以为闸已经接上了。
--
-- 【默认值】DEFAULT '{}' 而不是允许 NULL:数组列的空态就用空数组,省掉全站 `?? []`。
-- 存量行自动补 '{}',无需回填。加列 + 常量默认在 PG11+ 是元数据操作,不重写表。
ALTER TABLE "plan_executions"
ADD COLUMN IF NOT EXISTS "inaccurate_treatments" TEXT[] NOT NULL DEFAULT '{}';
......@@ -1303,6 +1303,18 @@ model PlanExecution {
/// 它只可能取到字面量"其他",信息已被 abandonReasons 'other' 覆盖(2026-07)
abandonReasons String[] @map("abandon_reasons")
/// 「机会识别不准确」的限定:客服勾了**哪几类推荐治疗**不准。
/// 存画像 potential_treatment code(implant / endo / perio),**不存中文** ——
/// 中文措辞两天内改过三轮(潜在种植→种植治疗→种植),存中文等于把当时的措辞烤进历史数据,
/// 之后统计要 `WHERE label IN (三种写法)`code 是稳定键。
///
/// ⚠️ **本列只作统计 / 算法改进的输入,不参与抑制计算**(业务 2026-07-30 明确)
/// 抑制仍是信号级、按 plan 的全部 reason 一起压( plan-engine.fetchSnoozedSignalKeys):
/// 客服选"只有种植不准"**不会**只放过根管那条。别看到这列就以为闸已经接上了。
///
/// 只在 abandonReasons 'inaccurate' 时非空;其他原因( other)不收集这个 —— 两者不构成关联。
inaccurateTreatments String[] @map("inaccurate_treatments")
/// outcome=scheduled_next ,下次回访时间
scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3)
......
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, resolveKinRelationship } 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,20 @@ 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,
gender: true,
},
},
},
},
// 诊所回访记录(展示用,按时间倒序,封顶 100;每患者 p99=19,够用)
// nulls:'last' —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会顶到列表最前,
......@@ -108,7 +121,21 @@ 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;
gender: true;
};
};
};
};
returnVisits: true;
};
}>,
......@@ -190,9 +217,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 +296,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 +410,21 @@ 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;
gender: string | 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,16 +455,43 @@ function serializeProfile(
),
).slice(0, 2);
// 联系人/亲属:本人视角(relatedPatient 是本人的 X)。姓名/电话从 relatedPatient 现取。
// 联系人/亲属:本人视角(relatedPatient 是本人的 X)。姓名/电话/生日从 relatedPatient 现取。
// linked=false(关系人未建档)→ 无姓名,前端展示时跳过。已建档的排前。
// ⭐ isKin:是不是**亲戚**(KIN_RELATIONSHIPS 白名单)。详情页「关联客户」只列亲戚 ——
// friend / other 那两类里混的是推荐人噪音(other 占了边表一半以上),列出来客服会误当家属。
// ⭐ planId:关系人在**本 scope 内**能打开的工单;没有(没建工单 / 不在数据范围)→ null,
// 前端就不给链接,免得点进去 404。
const selfAge = patient.birthDate ? calcAge(patient.birthDate) : null;
const contacts = (patient.relationsOut ?? [])
.map((r) => ({
relationship: r.relationship,
relationshipLabel: RELATIONSHIP_LABEL[r.relationship] ?? r.relationship,
name: r.relatedPatient?.name ?? null,
phone: r.relatedPatient?.phone ?? null,
linked: !!r.relatedPatientId,
}))
.map((r) => {
const relAge = r.relatedPatient?.birthDate ? calcAge(r.relatedPatient.birthDate) : null;
// ⭐ 关系方向按**年龄实时判**,不信源里填的方向 —— FRIDAY 侧 97% 的父母/子女对是反的
// (根因在 data/friday/assemblers/patient_relation.yaml 多做了一次逆关系映射,摄入侧要修;
// 展示侧不能等:说错亲属关系,客服在通话中会当场出丑)。口径见 resolveKinRelationship。
const { relationship, ageCorrected } = resolveKinRelationship(
r.relationship,
selfAge,
relAge,
r.relatedPatient?.gender ?? null,
);
return {
relationship,
relationshipLabel: RELATIONSHIP_LABEL[relationship] ?? relationship,
/** 源数据原样的关系码 —— 纠正过时留着,排查 / 跟宿主对账用 */
relationshipRaw: r.relationship,
ageCorrected,
name: r.relatedPatient?.name ?? null,
phone: r.relatedPatient?.phone ?? null,
linked: !!r.relatedPatientId,
isKin: KIN_RELATIONSHIPS.includes(relationship),
age: relAge,
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));
// ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人手机 fallback(详见 pickGuardianTestOnly 注释)。
......@@ -451,6 +559,7 @@ const RELATIONSHIP_LABEL: Record<string, string> = {
grandparent: '祖辈',
spouse: '配偶',
child: '子女',
parent: '父母', // 年龄纠正出来的中性长辈码(关系人性别缺失时用;源数据里没有这个码)
sibling: '兄弟姐妹',
grandchild: '孙辈',
friend: '朋友',
......
......@@ -53,6 +53,8 @@ export interface SubmitExecutionInput {
outcome: string;
notes?: string;
abandonReasons?: string[];
/** 「机会识别不准确」勾中的治疗 code(仅统计,不参与抑制);选了 inaccurate 必填 */
inaccurateTreatments?: string[];
scheduledNextAt?: string;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId?: string;
......@@ -120,6 +122,13 @@ export class ExecutionService {
throw new BadRequestException(`未知 outcome=${input.outcome}`);
}
// ⭐ 选了「机会识别不准确」必须说清是哪几类治疗不准 —— **服务端也拦**,不能只信前端:
// 这条数据的唯一用途是回头统计"哪类召回最容易被判不准"去改规则,收进来一批空的
// 就等于这次反馈白填了,而且事后补不回来(客服不会为一条已结案的单再来一遍)。
if (input.abandonReasons?.includes('inaccurate') && !input.inaccurateTreatments?.length) {
throw new BadRequestException('选了「机会识别不准确」时,必须勾选具体哪些推荐治疗不准');
}
const nextContactAttempts = plan.contactAttempts + 1;
let newStatus: string = plan.status;
let breakerTripped = false;
......@@ -164,6 +173,11 @@ export class ExecutionService {
outcome: input.outcome,
notes: input.notes ?? null,
abandonReasons: input.abandonReasons ?? [],
// 只在选了 inaccurate 时落 —— 其他原因不构成关联(业务 2026-07-30),
// 免得前端残留的勾选被顺带写进去,污染统计口径
inaccurateTreatments: input.abandonReasons?.includes('inaccurate')
? (input.inaccurateTreatments ?? [])
: [],
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
},
select: { id: true },
......@@ -247,6 +261,7 @@ export class ExecutionService {
outcome: e.outcome as ExecutionOutcome,
notes: e.notes,
abandonReasons: e.abandonReasons,
inaccurateTreatments: e.inaccurateTreatments,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
}));
......
......@@ -904,6 +904,7 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu
outcome: e.outcome as import('@pac/types').ExecutionOutcome,
notes: e.notes,
abandonReasons: e.abandonReasons,
inaccurateTreatments: e.inaccurateTreatments,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
};
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
HOST_ACTION_MESSAGE_SOURCE,
HOST_ACTION_MESSAGE_TYPE,
HOST_CASE_STAGE_CONSULTED,
POTENTIAL_TREATMENT_CARD_LABEL,
potentialTreatmentItemName,
} from '@pac/types';
/**
* postMessage 动作契约 ↔ 交付文档 防漂移。
*
* 这份契约是**发给宿主开发照着写监听**的,漂了不会在 PAC 里报错 —— 只会让对方按文档写完发现
* 收不到 / 值对不上,而且要等到联调才暴露。所以拿测试盯住三件事:
* ① 信封固定字段(source/type)别改字面 —— 宿主是按它过滤的
* ② 病例阶段恒「已咨询」—— 文档承诺了"恒为",代码里改成别的值就是骗人
* ③ 8 类治疗项目的**项目名和稳定 code 都要在文档里列全** ——
* 改措辞(labels.ts)或加一类,交付文档必须同步,否则宿主的映射表会缺项。
* 注意查的是**项目名**(去「治疗」后缀)而不是卡片标签:发出去的是前者,
* 文档要跟载荷一致,不是跟界面一致
*/
const DOC = readFileSync(
join(__dirname, '../../pac-docs/content/docs/integration/postmessage-actions.mdx'),
'utf-8',
);
describe('postMessage 动作契约 ↔ 交付文档', () => {
test('⭐ 信封固定字段不许改字面(宿主按 source+type 过滤)', () => {
expect(HOST_ACTION_MESSAGE_SOURCE).toBe('pac');
expect(HOST_ACTION_MESSAGE_TYPE).toBe('action');
expect(DOC).toContain("source: 'pac'");
expect(DOC).toContain("type: 'action'");
});
test('⭐ 病例阶段恒「已咨询」—— 文档承诺了"恒为",代码不能偷偷换值', () => {
expect(HOST_CASE_STAGE_CONSULTED).toBe('已咨询');
expect(DOC).toContain('已咨询');
});
test('⭐ 8 类治疗项目的项目名 + 稳定 code 都要在交付文档里列全', () => {
const missing: string[] = [];
for (const code of Object.keys(POTENTIAL_TREATMENT_CARD_LABEL)) {
const item = potentialTreatmentItemName(code);
if (!DOC.includes(`| ${item} |`)) missing.push(`项目名「${item}」`);
if (!DOC.includes(`\`${code}\``)) missing.push(`code \`${code}\``);
}
expect(missing).toEqual([]);
});
test('⭐ 项目名不带「治疗」后缀、不出空串', () => {
for (const code of Object.keys(POTENTIAL_TREATMENT_CARD_LABEL)) {
const item = potentialTreatmentItemName(code);
expect(item.length).toBeGreaterThan(0);
expect(item.endsWith('治疗')).toBe(false);
}
expect(potentialTreatmentItemName('implant')).toBe('种植');
// 早矫是例外(卡片叫「早期矫治」,砍后缀砍不出来)—— 锁住,别哪天被"统一成推导"改回去
expect(potentialTreatmentItemName('early_ortho')).toBe('早矫');
});
test('⭐ 载荷字段名就是 desc / treatments / stage —— 改名等于毁约,宿主的解构会拿到 undefined', () => {
for (const f of ['`desc`', '`treatments`', '`stage`']) expect(DOC).toContain(f);
// 旧名字不许还留在交付文档里(留着对方会照旧名写)
for (const old of ['pendingTreatmentDesc', 'potentialTreatments', 'caseStage']) {
expect(DOC).not.toContain(old);
}
});
test('文档给出了 origin 校验示例 —— 少这一句宿主就等于开后门', () => {
expect(DOC).toContain('e.origin');
});
});
import { resolveKinRelationship } from '@pac/types';
/**
* 亲戚关系方向的年龄纠正。
*
* 由来(2026-07-30 测试服全量实证):
* jvs-dw 43,279 条亲戚边里与年龄矛盾 428 条(1.0%),方向基本可信;
* friday 只看父母/子女**对**:mother↔child 326 对里只有 8 对方向是对的(2.5%)、
* father↔child 231 对里只有 9 对(3.9%)—— **97% 反了**。
* 根因在 data/friday/assemblers/patient_relation.yaml 多做了一次逆关系映射。
*
* 用例锁的是这条纪律:**年龄说了算,但只在能判的时候判**。
* 说错亲属关系(把女儿说成妈妈)客服在通话中会当场出丑,比不显示更糟。
*/
describe('resolveKinRelationship — 年龄定方向', () => {
test('⭐ 标成妈妈但对方更年轻 → 纠正成子女(实测样本:秦佳47 / "妈妈"韩秦瑜21)', () => {
expect(resolveKinRelationship('mother', 47, 21, '女')).toEqual({
relationship: 'child',
ageCorrected: true,
});
});
test('⭐ 标成子女但对方更年长 → 按对方性别纠成父/母(实测样本:韩秦瑜21 / "子女"秦佳47)', () => {
expect(resolveKinRelationship('child', 21, 47, '女')).toEqual({
relationship: 'mother',
ageCorrected: true,
});
expect(resolveKinRelationship('child', 35, 62, '男')).toEqual({
relationship: 'father',
ageCorrected: true,
});
});
test('⭐ 纠成长辈但对方性别缺失 → 中性「parent」,不硬猜父还是母', () => {
expect(resolveKinRelationship('child', 20, 50, null)).toEqual({
relationship: 'parent',
ageCorrected: true,
});
expect(resolveKinRelationship('child', 20, 50, ' ')).toEqual({
relationship: 'parent',
ageCorrected: true,
});
});
test('方向本来就对 → 原样返回,不标纠正', () => {
expect(resolveKinRelationship('mother', 34, 58, '女')).toEqual({
relationship: 'mother',
ageCorrected: false,
});
expect(resolveKinRelationship('child', 56, 7, '男')).toEqual({
relationship: 'child',
ageCorrected: false,
});
});
test('祖辈 / 孙辈 同样纠(阈值只看谁更年长,不额外要求 30 岁差)', () => {
expect(resolveKinRelationship('grandparent', 60, 20).relationship).toBe('grandchild');
expect(resolveKinRelationship('grandchild', 20, 60).relationship).toBe('grandparent');
});
test('⭐ 同龄也算矛盾 —— 父母子女不可能同岁', () => {
expect(resolveKinRelationship('mother', 40, 40, '女').ageCorrected).toBe(true);
expect(resolveKinRelationship('child', 40, 40, '女').ageCorrected).toBe(true);
});
test('⭐ 配偶 / 兄弟姐妹是对称关系,没有方向可纠 —— 同岁配偶很正常,不许被误标', () => {
expect(resolveKinRelationship('spouse', 64, 64, '男')).toEqual({
relationship: 'spouse',
ageCorrected: false,
});
expect(resolveKinRelationship('sibling', 30, 45, '女')).toEqual({
relationship: 'sibling',
ageCorrected: false,
});
});
test('⭐ 任一方缺生日 → 原样返回,不猜(会显示的边有 98.8% 两边都有生日,剩下的宁可不判)', () => {
expect(resolveKinRelationship('mother', null, 21, '女')).toEqual({
relationship: 'mother',
ageCorrected: false,
});
expect(resolveKinRelationship('mother', 47, null, '女')).toEqual({
relationship: 'mother',
ageCorrected: false,
});
});
test('非亲戚码(friend/other)不参与纠正', () => {
expect(resolveKinRelationship('friend', 20, 60)).toEqual({
relationship: 'friend',
ageCorrected: false,
});
expect(resolveKinRelationship('other', 60, 20)).toEqual({
relationship: 'other',
ageCorrected: false,
});
});
});
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 段)不渲染本骨架。
......
'use client';
import { useState } from 'react';
import { ABANDON_REASON_META, AbandonReason, abandonReasonsFor } from '@pac/types';
import {
ABANDON_REASON_META,
AbandonReason,
abandonReasonsFor,
potentialTreatmentCardLabel,
} from '@pac/types';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
......@@ -33,14 +38,21 @@ export function CloseOpportunityDialog({
open,
onOpenChange,
onConfirm,
potentialTreatments = [],
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 确认关闭(原因多选 + 需要说明时的文字)。 */
onConfirm?: (reasons: CloseReasonKey[], note?: string) => void;
/** 确认关闭(原因多选 + 需要说明时的文字 + 「识别不准确」勾中的治疗 code)。 */
onConfirm?: (reasons: CloseReasonKey[], note?: string, inaccurateTreatments?: string[]) => void;
/**
* 本患者的潜在治疗 **code**(画像 potential_treatment.types)—— 「机会识别不准确」的候选项。
* 跟召回池卡片那排标签同一个来源,所以每个患者不一样;中文在这里查表(改措辞即时生效)。
*/
potentialTreatments?: string[];
}) {
const [reasons, setReasons] = useState<CloseReasonKey[]>([]);
const [note, setNote] = useState('');
const [inaccurate, setInaccurate] = useState<string[]>([]);
const toggle = (key: CloseReasonKey) =>
setReasons((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
......@@ -48,11 +60,23 @@ export function CloseOpportunityDialog({
const selected = CLOSE_REASONS.filter((r) => reasons.includes(r.key));
// 勾中任一 needNote 项(目前只有「其他」)→ 说明必填
const needNote = selected.some((r) => r.needNote === true);
const canConfirm = reasons.length > 0 && (!needNote || note.trim().length > 0);
// ⭐ 勾了「机会识别不准确」→ 必须说清是哪几类治疗不准。
// 这条反馈的唯一用途是回头统计"哪类召回最容易被判不准"去改规则,收进来一批空的就等于白填,
// 而且事后补不回来(没人会为一条已结案的单再来一遍)。服务端同样会拦。
// ⚠️ 只跟 inaccurate 关联,跟「其他原因」不构成关联(业务 2026-07-30)。
const needInaccurate = reasons.includes('inaccurate');
// 患者没有潜在治疗标签时不卡住(理论上不该发生 —— 没有机会何来"识别不准"),
// 但真遇到了宁可放行,不能让客服卡在一个填不了的必填项上
const inaccurateOk = !needInaccurate || potentialTreatments.length === 0 || inaccurate.length > 0;
const canConfirm = reasons.length > 0 && (!needNote || note.trim().length > 0) && inaccurateOk;
const toggleInaccurate = (code: string) =>
setInaccurate((prev) => (prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code]));
const reset = () => {
setReasons([]);
setNote('');
setInaccurate([]);
};
return (
......@@ -114,6 +138,35 @@ export function CloseOpportunityDialog({
})}
</div>
{/* 「机会识别不准确」的限定:哪几类推荐治疗不准 —— 候选项 = 本患者的潜在治疗标签 */}
{needInaccurate && potentialTreatments.length > 0 && (
<div className="space-y-1.5">
<div className="text-[12.5px] font-medium text-slate-900">
哪些推荐治疗不准确?(可多选) <span className="text-rose-600">*</span>
</div>
<div className="flex flex-wrap gap-1.5">
{potentialTreatments.map((code) => {
const on = inaccurate.includes(code);
return (
<button
key={code}
type="button"
onClick={() => toggleInaccurate(code)}
className={cn(
'rounded-md border px-2.5 py-1 text-[12px] transition-colors',
on
? 'border-brand-400 bg-brand-50 font-medium text-brand-800'
: 'border-slate-200 bg-white text-slate-700 hover:border-brand-300 hover:bg-brand-50/50',
)}
>
{potentialTreatmentCardLabel(code)}
</button>
);
})}
</div>
</div>
)}
{needNote && (
<div className="space-y-1.5">
<div className="text-[12.5px] font-medium text-slate-900">
......@@ -154,7 +207,12 @@ export function CloseOpportunityDialog({
disabled={!canConfirm}
onClick={() => {
if (reasons.length === 0) return;
onConfirm?.(reasons, note.trim() ? note.trim() : undefined);
// 只在选了 inaccurate 时带上,其他原因不构成关联(免得残留勾选污染统计)
onConfirm?.(
reasons,
note.trim() ? note.trim() : undefined,
needInaccurate ? inaccurate : undefined,
);
onOpenChange(false);
reset();
}}
......
......@@ -22,6 +22,11 @@ export interface SubmitExecutionBody {
* key 和 label 同源就不会再漂(那张表里「已转介他人 → treated_elsewhere」就是漂出来的语义错位)。
*/
abandonReasons?: string[];
/**
* 「机会识别不准确」勾中的治疗 **code**(画像 potential_treatment.types,不是中文)。
* 选了 inaccurate 就必填 —— 服务端也拦(见 execution.service)。仅统计 / 改算法用,不参与抑制。
*/
inaccurateTreatments?: string[];
scheduledNextAt?: string;
}
......
......@@ -61,6 +61,14 @@ export const mockPatient = {
name: '伍晴晴',
phone: '13800000000',
linked: true,
isKin: true,
age: 58,
relatedPatientId: 'p-mock-mother',
planId: null,
externalId: '5i5ya_PA00999001',
medicalRecordNumber: 'SH0Q099001',
relationshipRaw: 'mother',
ageCorrected: false,
},
] as Array<{
relationship: string;
......@@ -68,6 +76,18 @@ 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;
/** 源数据原样关系码 + 是否被年龄纠正过 */
relationshipRaw: string;
ageCorrected: boolean;
}>,
// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback),真实手机号到位前仅演示
guardian: {
......
......@@ -2,16 +2,17 @@
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';
import { PersonaFeatureHover } from './persona-feature-hover';
import { factLabel } from './fact-label';
import { personaValueLabels } from './persona-display';
import type { PersonaFeature } from './mock-data';
import type { AdaptedFact } from './adapt-data';
......@@ -21,7 +22,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 +83,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
......@@ -139,9 +149,8 @@ function FeatureCard({
// 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开,
// 不像卡片那样截成 "+N"。单值特征直接显示后端那句完整 description。
const labels = (f.data as { labels?: unknown } | null | undefined)?.labels;
const valueLabels =
Array.isArray(labels) && labels.every((x) => typeof x === 'string') ? (labels as string[]) : null;
// ⭐ potential_treatment 走 code 查表(措辞可改),不读烤死的 labels —— 见 persona-display 注释。
const valueLabels = personaValueLabels(f.data, f.key);
// 证据:只渲染能在本次响应的 facts 里找到的(跨版本的旧 fact 可能已被 supersede,查不到就不显)
const resolved = f.evidence
......
import { potentialTreatmentCardLabel } from '@pac/types';
/**
* persona feature.description 的展示辅助 —— 把后端那句自包含描述裁成不同密度的展示形态。
*
......@@ -23,20 +25,54 @@ export function shortPersonaValueLabel(raw: string | null | undefined): string {
}
/**
* 潜在治疗的展示中文 —— **从 code 查表,不读 data.labels**。
*
* ⚠️ 2026-07-30 线上口径不一致就是这里踩的:同一个患者,召回池卡片显示「充填治疗」、
* 详情页 chip 显示「潜在补牙」。因为 `data.labels` 是**算画像那一刻烤进 JSON 的**
* (`{"types":["filling"],"labels":["潜在补牙"]}`),而卡片早就改成"只拿 code、中文查表"了。
* 措辞这几天改过三轮,烤死的那份自然全是旧词 —— 要改回来得全量重算画像(百万级、几小时)。
*
* 纪律:**凡是中文措辞可能改的标签,展示一律 code → 查表**,别读 labels。
* 目前只有 potential_treatment 有 code 表(POTENTIAL_TREATMENT_CARD_LABEL);
* 其余多值特征(治疗史/权益/禁忌/时间偏好…)暂时只能读 labels,它们的措辞也没改过。
*/
function potentialTreatmentLabels(data: unknown): string[] | null {
const types = (data as { types?: unknown } | null | undefined)?.types;
if (!Array.isArray(types)) return null;
const codes = types.filter((t): t is string => typeof t === 'string');
return codes.length > 0 ? codes.map(potentialTreatmentCardLabel) : null;
}
/**
* 多值标签的紧凑显示(首值 + `+N` 溢出)。
*
* 治疗史 / 权益 / 禁忌 / 潜在治疗 / 时间偏好 / 治疗敏感 / 特别关注 这 7 个特征是「`/` 并列多值」,
* 直接交给 shortPersonaValueLabel 会在第一个 `/` 处截断、只剩首值且不提示还有更多。
* 这里改读结构化 `data.labels`(这 7 个后端都带),显示首值 + `+N`,完整列表走 hover 卡片。
* 单值特征 → `{ text, more: 0 }`。
*
* ⭐ 传 featureKey:potential_treatment 走 code 查表(见上方注释),不读烤死的 labels。
*/
export function compactPersonaValue(
raw: string | null | undefined,
data?: unknown,
featureKey?: string,
): { text: string; more: number } {
const labels = (data as { labels?: unknown } | null | undefined)?.labels;
const fromCode = featureKey === 'potential_treatment' ? potentialTreatmentLabels(data) : null;
const labels =
fromCode ?? (data as { labels?: unknown } | null | undefined)?.labels;
if (Array.isArray(labels) && labels.length > 0 && typeof labels[0] === 'string') {
return { text: labels[0] as string, more: labels.length - 1 };
}
return { text: shortPersonaValueLabel(raw), more: 0 };
}
/** 抽屉里"全部展开"用的多值中文 —— 同样对 potential_treatment 走 code 查表 */
export function personaValueLabels(data: unknown, featureKey?: string): string[] | null {
const fromCode = featureKey === 'potential_treatment' ? potentialTreatmentLabels(data) : null;
if (fromCode) return fromCode;
const labels = (data as { labels?: unknown } | null | undefined)?.labels;
return Array.isArray(labels) && labels.every((x) => typeof x === 'string')
? (labels as string[])
: null;
}
......@@ -45,6 +45,19 @@ 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;
/// 源数据原样的关系码;与 relationship 不同 = 被年龄纠正过
relationshipRaw: string;
/// 关系方向被年龄纠正过 —— UI 给一句 hover 说明,让客服知道这不是源数据原文
ageCorrected: boolean;
}>;
/// ⚠️ 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) => (
......@@ -658,23 +658,23 @@ function DoctorPicker({
);
}
// ── 优先级(与列表页 PriorityBar 同款:五格条 + 10 分制)────────
// ── 优先级(只留分数,10 分制)────────────────────────────────
// 业务 2026-07-30:去掉原来那排五个色点。分数本身已经带档位信息(还按档着色),
// 五个点是同一件事的第二种编码 —— 一行里两处讲优先级,反而要人对照着看。
// 档位色保留在数字上:极低/低 绿 → 中/高 琥珀 → 极高 玫红,扫一眼仍分得出轻重。
// hover 的算分明细走外层 PriorityHover,不在这里。
function PriorityBar({ score }: { score: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
const filled = Math.max(1, Math.round(pct * 5));
const colors = ['bg-emerald-400', 'bg-emerald-500', 'bg-amber-400', 'bg-amber-500', 'bg-rose-500'];
const labelTone =
pct >= 0.6 ? (pct >= 0.8 ? 'text-rose-700' : 'text-amber-700') : pct >= 0.2 ? 'text-emerald-700' : 'text-slate-500';
// 同列表页:展示值 = 排序键 / 10,别再读 breakdown.raw(见 plans-list-app 注释)
const disp = (score / 10).toFixed(2);
return (
<span className="inline-flex flex-none items-center gap-1" title={`优先级 ${disp} / 10`}>
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={cn('h-1.5 w-[6px] rounded-sm', i < filled ? colors[i] : 'bg-slate-200')} />
))}
</span>
<span className={cn('text-[10.5px] font-semibold tabular-nums', labelTone)}>{disp}</span>
<span
className={cn('flex-none text-[10.5px] font-semibold tabular-nums', labelTone)}
title={`优先级 ${disp} / 10`}
>
{disp}
</span>
);
}
......
......@@ -47,9 +47,75 @@ export function resolveActionUrl(
*
* 用 a 标签的地方用 HOST_LINK_PROPS,用 JS 派发的地方用 openHostUrl(),两条路同一口径。
* `noopener`:新页拿不到 window.opener,防宿主页被反向导航(tabnabbing)。
*
* ⚠️ **a 标签必须同时挂 onClick={openHostUrl}**(见各调用点):宿主用 sandbox iframe 嵌 PAC 时,
* 声明式的 target="_blank" 会被浏览器直接拦掉、且拦不住也拦不到 —— 只有走 JS 才有兜底余地。
* href 保留是为了右键"复制链接地址"仍可用。
*/
export const HOST_LINK_PROPS = { target: '_blank', rel: 'noopener noreferrer' } as const;
/**
* ⭐ 打开宿主页 —— 新标签页优先,**被 sandbox 拦掉时逐级兜底**。
*
* 2026-07-30 线上(嵌宿主)实证,控制台报:
* `Blocked opening '<url>' in a new window because the request was made in a sandboxed
* frame whose 'allow-popups' permission is not set.`
* 宿主的 `<iframe sandbox>` 没给 `allow-popups` → window.open 直接被拦,**点了没反应**。
* 上一版只写了一行 window.open,连"被拦了"都不知道。
*
* 三级兜底(能开新页就开新页,开不了也要让人到得了那个页面):
* ① 新标签页(业务要的形态)
* ② 顶层跳转(老 `_top` 行为)—— 会顶掉宿主页,但比"点了没反应"好;
* sandbox 若也没给 allow-top-navigation,赋值会抛 SecurityError,继续降级
* ③ 本 frame 内跳转 —— 一定成立,PAC 自己被替换掉(客服可用宿主的返回回来)
*
* ⚠️ **必须一步 open 带上 URL,不能"先开 about:blank 再导航"**(2026-07-30 踩过):
* 那一版是为了"开完手工断 opener 以等效 noopener,同时保留可检测性"。宿主加上 allow-popups 之后
* 立刻炸了两条:
* `Unsafe attempt to initiate navigation for frame with URL 'about:blank' … The frame
* attempting navigation is sandboxed and is trying to navigate a popup, but is not the
* popup's opener and is not set to propagate sandboxing to popups.`
* `Uncaught SecurityError: Failed to execute 'replace' on 'Location': The current window
* does not have permission to navigate the target frame to '<host url>'.`
* 因果很直白:**opener 一断,我们就不再是那个弹窗的 opener**,而沙箱化的 frame 只有作为 opener
* 才有权导航它 —— 于是第二步 `location.replace` 被拒,新标签页停在空白页。
* 顺序反过来(先导航后断 opener)也不行:导航到跨源之后再设 opener 会抛。
*
* 所以结论是:**一步到位 `window.open(url, '_blank')`**。
* · 可检测性还在 —— 被弹窗拦时返回 null(features 里**不写** noopener;写了成功也返回 null,
* 那才是分不清被拦和成功的那种写法)
* · opener 只做**尽力而为**的切断(跨源会抛,catch 掉即可)。这里能接受:目标 URL 是宿主管理页
* 配好的自家地址,不是用户输入的任意站点,tabnabbing 面本来就很窄。
*
* 根治仍在宿主侧:`sandbox` 要有 `allow-popups`;**并且建议加 `allow-popups-to-escape-sandbox`**
* —— 只给 allow-popups 时新标签页会继承沙箱,宿主自己的页面在里面可能功能不全。
*/
export function openHostUrl(url: string): void {
window.open(url, '_blank', 'noopener,noreferrer');
const opened = window.open(url, '_blank');
if (opened) {
try {
opened.opener = null; // 尽力而为:同源能断,跨源会抛(见上方注释,不影响已经开出来的页)
} catch {
/* 断不掉就算了 —— 目标是宿主自家配置的地址 */
}
return;
}
// 被拦了 —— 只能退回"跳转"。⚠️ 这里**不弹 toast**:下一行就导航走了,提示根本来不及被看见
// (试过,页面卸载时 toast 刚挂上就消失)。要留痕就留给控制台,那是给对接方看的。
console.warn(
`[pac] 新标签页被宿主 iframe 的 sandbox 拦下(缺 allow-popups),降级为跳转:${url}\n` +
'根治:请宿主给 <iframe sandbox> 加 allow-popups(想让新页不继承沙箱再加 allow-popups-to-escape-sandbox)',
);
try {
// 顶层跳转(老 `_top` 行为):会顶掉宿主页,但比"点了没反应"好。
// sandbox 没给 allow-top-navigation 时赋值会抛 SecurityError → 落到本 frame 内跳。
if (window.top && window.top !== window.self) {
window.top.location.href = url;
return;
}
} catch {
/* 继续降级 */
}
window.location.href = url;
}
......@@ -3,6 +3,12 @@
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/auth-store';
import { fillActionUrl, openHostUrl } from '@/lib/action-url';
import {
HOST_ACTION_MESSAGE_SOURCE,
HOST_ACTION_MESSAGE_TYPE,
type HostActionMessage,
type HostPotentialTreatmentPayload,
} from '@pac/types';
/**
* 宿主动作派发(潜在治疗 / 回访)—— 模式由 actionUrls[key] 的值形态决定:
......@@ -55,6 +61,13 @@ export function hostActionMode(key: HostAction): 'url' | 'postMessage' | undefin
export function openHostAction(
key: HostAction,
ctx: Record<string, string | null | undefined>,
/**
* 附加载荷(仅 postMessage 模式带上)—— 目前只有「打开潜在治疗」用:
* 待治疗描述 / 关联治疗项目 / 病例阶段。契约见 @pac/types/host-action-message。
* ⚠️ 刻意**不进 URL 模式**:这三个字段是长文本 + 数组,塞 query string 会撞长度上限、
* 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。
*/
extraPayload?: HostPotentialTreatmentPayload,
): boolean {
const raw = rawValue(key);
const mode = hostActionMode(key);
......@@ -67,9 +80,12 @@ export function openHostAction(
return true;
}
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
window.parent.postMessage(
{ source: 'pac', type: 'action', action: key, payload: { patientId: ctx.patientId ?? '' } },
hostOrigin()!,
);
const message: HostActionMessage = {
source: HOST_ACTION_MESSAGE_SOURCE,
type: HOST_ACTION_MESSAGE_TYPE,
action: key,
payload: { patientId: ctx.patientId ?? '', ...(extraPayload ?? {}) },
};
window.parent.postMessage(message, hostOrigin()!);
return true;
}
......@@ -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 集合。
//
......
/**
* 宿主动作 postMessage 契约 —— PAC(iframe 内)→ 宿主父页。
*
* 什么时候走 postMessage:宿主的动作**是弹窗/组件、没有独立 URL**。
* 有 URL 就配 URL 模板(`actionUrls[key]` 填链接),那是推荐姿势;
* 配成哨兵字符串 `postMessage` 才走本文件这条通道,并且必须同时配 `actionUrls.HOST_ORIGIN`
* 作 targetOrigin(不允许 `*` —— 患者信息不能广播给任意父页)。
*
* ⭐ 契约放 @pac/types 而不是前端:它是**对外接口**,宿主按它写监听。
* 放在这里意味着改字段会经过类型检查 + 文档(docs/integration/postmessage-actions.mdx 同源),
* 而不是某个组件里悄悄多塞一个 key。
*
* 兼容纪律:只增字段、不改已有字段语义、不删字段。宿主按 `type + source` 过滤,
* 多出来的字段它读不到也不会坏。
*/
/** 信封固定字段 —— 宿主用 source+type 过滤,别的 iframe 消息不会误入 */
export const HOST_ACTION_MESSAGE_SOURCE = 'pac' as const;
export const HOST_ACTION_MESSAGE_TYPE = 'action' as const;
/**
* 病例阶段 —— **PAC 固定发「已咨询」**。
*
* 为什么固定:PAC 侧的"潜在治疗"是从诊断/建议推出来的**客观缺口**,还没进宿主的病例流程;
* 客服点这个按钮的动作语义就是"我已经跟患者聊过、请在宿主侧建单",所以阶段恒为已咨询。
* PAC 不去推断"已确诊/已排期"这类宿主流程内的状态 —— 那是宿主的真理源,猜错比不给更糟。
*/
export const HOST_CASE_STAGE_CONSULTED = '已咨询' as const;
/**
* 「打开潜在治疗」的附加载荷(OPEN_POTENTIAL_TREATMENT 专有)。
*
* 三个字段都取**客服此刻在页面上看到的东西**,不另算一套 —— 宿主侧建出来的单
* 要和客服刚才读的那句话对得上,否则对账时说不清。
*/
export interface HostPotentialTreatmentPayload {
/**
* 待治疗描述 —— 取页面顶部那句 AI 召回简报(「这通电话的由头」),
* 简报还没生成好时退回结构化召回原因文本。两者都拿不到 → 空串(字段仍在,别让宿主判 undefined)。
*/
desc: string;
/**
* 关联治疗项目 —— **项目名**数组,共 8 类:
* 种植 / 正畸 / 早矫 / 根管 / 牙周 / 充填 / 修复 / 拔牙(见 potentialTreatmentItemName)。
* 无潜在治疗 → 空数组。
*
* ⚠️ 不带「治疗」后缀:宿主拿它当项目名落单,「种植治疗」在那边读起来像句子、不像项目。
* 界面上客服看到的仍是「种植治疗」(业务 2026-07-29 定的展示措辞)—— 两处措辞不同是有意的,
* 且由同一张表推导(砍后缀),不会各自漂。
*
* 为什么发中文不发 code:宿主拿去直接显示/落单最省事;要稳定键按文档里的对照表映一次。
*/
treatments: string[];
/** 病例阶段 —— 恒为 {@link HOST_CASE_STAGE_CONSULTED} */
stage: typeof HOST_CASE_STAGE_CONSULTED;
}
/** 所有动作共有的载荷字段 */
export interface HostActionBasePayload {
/** 宿主侧患者 id(PAC 的 patients.external_id,即摄入时宿主给的那个 id) */
patientId: string;
}
/** 完整消息体 —— 宿主 `window.addEventListener('message', ...)` 里 e.data 的形状 */
export interface HostActionMessage {
source: typeof HOST_ACTION_MESSAGE_SOURCE;
type: typeof HOST_ACTION_MESSAGE_TYPE;
/** 动作键,同 actionUrls 的 key(HostActionKey),如 OPEN_POTENTIAL_TREATMENT */
action: string;
payload: HostActionBasePayload & Partial<HostPotentialTreatmentPayload>;
}
......@@ -7,3 +7,5 @@ export * from './persona-feature-specs';
export * from './clinical-signals';
export * from './visit-recency';
export * from './persona-tag-filters';
export * from './host-action-message';
export * from './kin-relationship';
/**
* 亲戚关系方向的**运行时纠正** —— 按年龄实时判,不信源数据填的方向。
*
* ## 为什么需要(2026-07-30 测试服全量实证)
* `patient_relations.relationship` 的方向在两个宿主上质量差了两个数量级:
*
* jvs-dw(瑞尔) 43,279 条亲戚边,与年龄矛盾 428 条 → **1.0%**,基本可信
* friday 2,427 条亲戚边,矛盾 1,187 条;只看父母/子女**对**更刺眼:
* mother↔child 326 对里只有 8 对方向对(2.5%)
* father↔child 231 对里只有 9 对方向对(3.9%)
* → **97% 是反的**
*
* 根因在 data/friday/assemblers/patient_relation.yaml:那份 enum_mapping 假设源码语义是
* 「本人是对方的 X」,于是映射时取了**逆关系**(码2 爸爸 → child)。数据说这个假设是错的 ——
* FRIDAY 存的码本来就是「对方是本人的 X」,跟 PAC 契约同向,多反转了一次。
* 摄入侧要修(改 yaml + 重摄该资源),但那是另一件事;**展示侧不能等**:
* 跟客服说"这是他妈妈"而实际是女儿,通话中会当场出丑。
*
* ## 做法:年龄定方向
* 生日覆盖率 98.8%(会显示的那批边),所以这道判定几乎总能用上。
* · 标成长辈但对方更年轻 → 实际是晚辈
* · 标成晚辈但对方更年长 → 实际是长辈(按对方性别拆父/母;性别缺 → 「父母」不硬猜)
* · 配偶 / 兄弟姐妹是**对称关系**,没有方向可纠,原样返回
* · 任一方缺生日 → 原样返回(不猜)
*
* 同龄(差 0 岁)也算矛盾:父母子女不可能同岁,而配偶同岁很正常 —— 后者本就不参与判定。
*/
/** 长辈类(关系人比本人年长才合理) */
const ELDER: Record<string, string> = { mother: 'child', father: 'child', grandparent: 'grandchild' };
/** 晚辈类(关系人比本人年轻才合理)→ 纠正后的长辈码由性别决定,见下 */
const YOUNGER = new Set(['child', 'grandchild']);
export interface KinResolution {
/** 纠正后的关系码(可能等于入参) */
relationship: string;
/** 是否被年龄纠正过 —— UI 可据此给一句 hover 说明,让客服知道这不是源数据原文 */
ageCorrected: boolean;
}
/**
* @param relationship 源数据里的关系码
* @param selfAge 本人年龄
* @param relAge 关系人年龄
* @param relGender 关系人性别('男'/'女'/'M'/'F' 等;缺失 → 纠正成通用「父母」)
*/
export function resolveKinRelationship(
relationship: string,
selfAge: number | null | undefined,
relAge: number | null | undefined,
relGender?: string | null,
): KinResolution {
const keep = { relationship, ageCorrected: false };
if (selfAge == null || relAge == null) return keep;
// 长辈类标错 → 降到对应晚辈类(child / grandchild),不需要性别
if (ELDER[relationship] && relAge <= selfAge) {
return { relationship: ELDER[relationship]!, ageCorrected: true };
}
// 晚辈类标错 → 升到长辈类;父/母要看关系人性别,拿不到就用中性的 parent
if (YOUNGER.has(relationship) && relAge >= selfAge) {
if (relationship === 'grandchild') return { relationship: 'grandparent', ageCorrected: true };
const g = (relGender ?? '').trim().toUpperCase();
const code = g === '男' || g === 'M' ? 'father' : g === '女' || g === 'F' ? 'mother' : 'parent';
return { relationship: code, ageCorrected: true };
}
return keep;
}
......@@ -105,3 +105,27 @@ export const POTENTIAL_TREATMENT_CARD_LABEL: Record<string, string> = {
export function potentialTreatmentCardLabel(code: string): string {
return POTENTIAL_TREATMENT_CARD_LABEL[code] ?? code;
}
/**
* 项目名的例外 —— 砍后缀砍不出来的那几个,在这里显式给。
* early_ortho 的卡片措辞是「早期矫治」(没有「治疗」后缀可砍),业务要的项目名是「早矫」。
* ⚠️ 只放**真的推不出来**的;能靠砍后缀得到的别往这里堆,否则又变成两张手维护的表。
*/
const POTENTIAL_TREATMENT_ITEM_OVERRIDE: Record<string, string> = {
early_ortho: '早矫',
};
/**
* code → **项目名**:种植 / 正畸 / 早矫 / 根管 / 牙周 / 充填 / 修复 / 拔牙。
*
* 用途只有一个:发给宿主的 postMessage 载荷(`treatments`)。宿主拿它当**项目名**落单,
* 「种植治疗」在那边读起来像句子、不像项目;界面上客服看的仍是「种植治疗」(业务 2026-07-29 定的措辞)。
*
* ⭐ 默认从卡片措辞**推导**(砍掉结尾的「治疗」)而非另立一张全表 —— 两张手维护的表必然漂;
* 推不出来的走上面那张例外表(目前只有早矫一个)。
*/
export function potentialTreatmentItemName(code: string): string {
return (
POTENTIAL_TREATMENT_ITEM_OVERRIDE[code] ?? potentialTreatmentCardLabel(code).replace(/治疗$/, '')
);
}
......@@ -112,6 +112,8 @@ export const PlanExecutionSchema = z.object({
outcome: ExecutionOutcomeSchema,
notes: z.string().nullable(),
abandonReasons: z.array(z.string()),
/// 「机会识别不准确」勾中的治疗 code(仅统计用,不参与抑制)
inaccurateTreatments: z.array(z.string()),
scheduledNextAt: z.string().nullable(),
/// 召回反馈已迁到 plan 级(FollowupPlan.recallFeedback),不再随执行
createdAt: z.string().describe('提交时间 ≈ 通话结束时间'),
......@@ -248,6 +250,10 @@ export const SubmitExecutionRequestSchema = z.object({
notes: z.string().optional(),
/// 放弃原因(多选,无上限)—— PAC 表单 / 宿主关闭弹窗共用;说明写进 notes
abandonReasons: z.array(AbandonReasonSchema).optional(),
/// 「机会识别不准确」勾了哪几类推荐治疗不准 —— 画像 potential_treatment 的 code,不是中文。
/// 选了 inaccurate 就必填(服务端也校验,别只信前端);其他原因不带这个字段。
/// 用途只有统计 / 改算法,不参与抑制(见 schema.prisma 那列的注释)。
inaccurateTreatments: z.array(z.string()).optional(),
scheduledNextAt: z.string().optional(),
});
export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>;
......
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