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,6 +10,7 @@
"channel-push",
"friday-push-payload",
"auth-login",
"postmessage-actions",
"execution-callback",
"runbook"
]
......
---
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,
.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;
}
......@@ -46,7 +46,10 @@ import {
ABANDON_REASON_META,
personaFeatureSortKey,
isKeyPersonaFeature,
personaKeyFeatureOrder,
potentialTreatmentItemName,
HOST_CASE_STAGE_CONSULTED,
type HostPotentialTreatmentPayload,
personaFeatureCategoryTone,
type AbandonReason,
type ExecutionOutcome,
} from '@pac/types';
......@@ -161,6 +164,9 @@ export function PlanDetailApp({
const recallHistory = data.recallHistory ?? [];
const returnVisits = data.returnVisits ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
// 顶部那句 AI 召回简报 —— 由 RecallBriefLine 拿到后回报(它负责 get-or-generate)。
// 「打开潜在治疗」的 postMessage 要发同一句话给宿主,所以提到这层存。
const [recallBrief, setRecallBrief] = useState<string | null>(null);
// 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null
const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
......@@ -370,10 +376,34 @@ export function PlanDetailApp({
// 顶栏跳宿主的三个动作(潜在 / 预约 / 回访)**全部过认领闸**:
// 它们都会把人带进宿主系统对这个患者动手(哪怕「潜在」只是看,看完顺手就在宿主侧操作了),
// 而 PAC 这边没认领 = 没有归属人,回头对不上账。口径统一比"哪个算查看"的细分更好维护。
// 「打开潜在治疗」的附加载荷(仅 postMessage 模式带上;契约见 @pac/types/host-action-message)。
// 三个字段一律取**客服此刻在页上看到的东西**,不另算一套 —— 宿主建出来的单要和客服
// 刚读的那句话对得上,否则对账时说不清是谁改的。
// · 描述:顶部那句 AI 召回简报;还没生成好 → 退回结构化召回原因文本(跟 UI 的回退口径一致)
// · 治疗项目:画像 potential_treatment 的 code → **项目名**(卡片措辞去掉「治疗」后缀,
// 宿主拿它当项目名落单;界面上客服看的仍是「种植治疗」。见 labels.ts)
// · 阶段:恒「已咨询」,PAC 不猜宿主流程内的状态
// 本患者的潜在治疗 code(画像 potential_treatment.types)—— 两处用:
// ① 「打开潜在治疗」postMessage 的 treatments ② 关闭机会弹窗里「哪些推荐治疗不准」的候选项
const potentialTreatmentCodes = useMemo(() => {
const raw = ((persona.features.find((f) => f.key === PersonaFeatureKey.POTENTIAL_TREATMENT)
?.data ?? null) as { types?: unknown } | null)?.types;
return Array.isArray(raw) ? raw.filter((c): c is string => typeof c === 'string') : [];
}, [persona.features]);
const potentialTreatmentPayload = (): HostPotentialTreatmentPayload => {
const codes = potentialTreatmentCodes;
return {
desc: recallBrief ?? visibleReasons[0]?.reason ?? '',
treatments: Array.isArray(codes)
? codes.filter((c): c is string => typeof c === 'string').map(potentialTreatmentItemName)
: [],
stage: HOST_CASE_STAGE_CONSULTED,
};
};
const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
? () => {
if (!gateCheck()) return;
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx);
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload());
}
: undefined;
const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
......@@ -393,7 +423,11 @@ export function PlanDetailApp({
// 双写会让同一件事出现在两处、口径还可能打架(recall_feedback 保留给拇指控件那条路)
// 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。
const router = useRouter();
const submitClose = async (reasons: AbandonReason[], note?: string) => {
const submitClose = async (
reasons: AbandonReason[],
note?: string,
inaccurateTreatments?: string[],
) => {
if (!gateCheck()) return; // 认领闸(弹窗入口已拦一次,这里兜底防绕过)
const labels = reasons.map((r) => ABANDON_REASON_META[r].labelZh).join('、');
try {
......@@ -402,6 +436,7 @@ export function PlanDetailApp({
outcome: 'abandoned',
notes: note?.trim() || undefined,
abandonReasons: reasons,
inaccurateTreatments,
});
setPlanOverride({ status: result.planStatus, contactAttempts: result.contactAttempts });
usePlanSyncStore.getState().notify(plan.id, result.planStatus, 'abandoned');
......@@ -437,8 +472,9 @@ export function PlanDetailApp({
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return;
}
// 不弹成功 toast —— 新标签页开出来用户自己看得见,再报一句是噪音(业务 2026-07-30)。
// 失败路径仍有提示:未配置 → 上面那条 amber toast;被 sandbox 拦 → openHostUrl 里降级跳转。
openHostUrl(url);
showToast('emerald', '已在新标签页打开宿主预约页', '带上患者 id');
};
// ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ──
......@@ -519,7 +555,11 @@ export function PlanDetailApp({
<CloseOpportunityDialog
open={closeOpen}
onOpenChange={setCloseOpen}
onConfirm={(reason, note) => void submitClose(reason, note)}
// 「机会识别不准确」的候选项 = 本患者的潜在治疗 code(同召回池卡片那排标签的来源)
potentialTreatments={potentialTreatmentCodes}
onConfirm={(reason, note, inaccurateTreatments) =>
void submitClose(reason, note, inaccurateTreatments)
}
/>
{/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
......@@ -666,8 +706,14 @@ export function PlanDetailApp({
</div>
{/* 第二行:本次召回一句话简报(LLM:谁/解决什么/到诊做什么;生成中 shimmer,失败回退结构化原因)*/}
<div className="text-[11.5px] text-slate-600 leading-snug mt-1.5">
<RecallBriefLine planId={plan.id} visibleReasons={visibleReasons} />
<RecallBriefLine
planId={plan.id}
visibleReasons={visibleReasons}
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 && (
......@@ -780,7 +826,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}
......@@ -1169,11 +1216,15 @@ function TopBar({
type="button"
onClick={onOpenReturnVisit}
onMouseEnter={onHoverReturnVisit}
title="回访"
// 按钮文案「跟进」而非「回访」(业务 2026-07-30):这个按钮跳的是宿主侧的动作页,
// 落到宿主那边不一定叫回访;而 PAC 里「回访」已被"诊所回访记录 / 历史联系"占着,
// 同一个词指两件事。⚠️ 只改按钮字面,槽位 key(OPEN_RETURN_VISIT)不动 ——
// 那是宿主配置里的键名,改了所有宿主都得重配。
title="跟进"
className="inline-flex items-center gap-1.5 rounded-md bg-brand-600 px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-brand-700"
>
<CalendarClock className="h-3.5 w-3.5" />
<span className="hidden sm:inline">回访</span>
<span className="hidden sm:inline">跟进</span>
</button>
)}
{onCloseOpportunity && (
......@@ -1316,12 +1367,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);
......@@ -1373,6 +1430,12 @@ function IdentityCard({
<a
href={originalArchiveUrl}
{...HOST_LINK_PROPS}
// 走 JS 才有兜底:宿主 sandbox iframe 会把声明式 target="_blank" 直接拦掉,
// 而声明式那条路拦了我们既拦不到也补不了。href 留着让右键"复制链接"可用。
onClick={(e) => {
e.preventDefault();
openHostUrl(originalArchiveUrl);
}}
className="text-[10.5px] text-brand-700 hover:underline"
>
原始档案 →
......@@ -1560,6 +1623,85 @@ 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"
// 被年龄纠正过就把源值挂在 hover 上 —— 客服跟宿主对账时能看出差异在哪,
// 而不是以为 PAC 显示错了(源里 97% 的父母/子女方向是反的,见 resolveKinRelationship)
title={
c.ageCorrected
? `源数据记的是「${c.relationshipRaw}」,与双方年龄不符,已按年龄纠正`
: undefined
}
>
{c.relationshipLabel}
{c.ageCorrected && <span className="ml-0.5 text-slate-400">*</span>}
{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(信息不丢)。
......@@ -1568,9 +1710,13 @@ function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] })
function RecallBriefLine({
planId,
visibleReasons,
onSummary,
}: {
planId: string;
visibleReasons: PlanReason[];
/** 简报拿到后报给父层 —— 「打开潜在治疗」的 postMessage 要发同一句话给宿主,
* 不能各查一次(那会出现"页面显示 A、发过去 B")。 */
onSummary?: (summary: string | null) => void;
}) {
const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
......@@ -1582,7 +1728,10 @@ function RecallBriefLine({
plansApi
.getRecallBrief(planId)
.then((r) => {
if (alive) setSummary(r.status === 'ready' ? r.summary : null);
if (!alive) return;
const s = r.status === 'ready' ? r.summary : null;
setSummary(s);
onSummary?.(s);
})
.catch(() => {
/* 生成失败:静默,回退结构化原因 */
......@@ -1955,6 +2104,11 @@ function TreatmentHistoryCard({
<a
href={emrUrl}
{...HOST_LINK_PROPS}
// 同「原始档案」:点击走 openHostUrl 才有 sandbox 兜底(见 action-url.ts)
onClick={(e) => {
e.preventDefault();
openHostUrl(emrUrl);
}}
className="text-[10.5px] text-brand-700 hover:underline"
>
原始病历 →
......@@ -2329,7 +2483,8 @@ function PersonaTagCloud({
<div className={cn('flex flex-wrap', plain ? 'gap-1' : 'gap-1.5')}>
{ordered.map((f) => {
const T = tone(f.tone);
const { text: short, more } = compactPersonaValue(f.value, f.data);
// 传 f.key:potential_treatment 的中文走 code 查表,不读烤死的 data.labels(见 persona-display)
const { text: short, more } = compactPersonaValue(f.value, f.data, f.key);
// plain 下取值就是全部内容 —— 取不到值(理论上不该有)兜底显示标签名,不出空 chip
const text = plain ? short || f.label : short;
const inner = (
......@@ -2347,14 +2502,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,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(/治疗$/, '')
);
}
......@@ -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,
......
......@@ -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