---
title: 取数说明
description: 瑞尔召回数据自助取数 —— 涉及哪些表、召回池怎么筛、认领与关闭操作落在哪、数据范围怎么过滤。
icon: Database
---

直连生产 PG 捞召回数据用。只讲数据库这一层。

## 零、范围

库 `pac`,建议只读账号。瑞尔的数据固定在:

```sql
host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
tenant_id = 'ruier-grp'
```

时间列都是 `timestamptz`,**存 UTC**,转北京时间用 `col at time zone 'Asia/Shanghai'`。

---

## 一、表关系

```
followup_plans     召回工单(一行 = 一个患者的一次召回任务)
  ├─ plan_reasons      1:N  召回理由(命中了哪些临床信号)
  ├─ plan_executions   1:N  执行记录(客服每次操作一行,关闭也在这)
  ├─ plan_event_logs   1:N  归属/反馈账本(认领、返池、召回反馈)
  └─ patients          N:1  患者主数据
       └─ patient_profiles  1:1  合规标记(勿打扰 / 已故)
```

⚠️ `followup_plans` 是**版本化**的:患者数据变化会重算生成新版工单,旧版留档并打上
`superseded_at`。**查当前态一律加 `superseded_at is null`**,漏了会重复计数。

---

## 二、数据范围怎么过滤

三个维度,按需组合:

### 品牌 — `patients.source_unit`

| 值 | 患者数 | 说明 |
|---|---|---|
| `瑞尔` | 351,024 | **PAC 业务实际服务的范围** |
| `瑞泰` | 64,883 | 不在业务范围内,**必须排除** |

```sql
join patients pt on pt.id = p.patient_id and pt.source_unit = '瑞尔'
```

### 诊所 — `target_clinic_id` / `executor_clinic_id`

值就是**你们侧的诊所 id**(32 位 hex),直接对得上。两列语义不同:

| 列 | 在哪张表 | 含义 |
|---|---|---|
| `target_clinic_id` | `followup_plans` | 工单**归属**诊所(该患者该由谁召回) |
| `executor_clinic_id` | `plan_executions` | 实际**执行**诊所(代办场景两者会不一致) |

```sql
-- 按归属诊所(注意带上 NULL)
and (p.target_clinic_id in ('1f765bce...', '...') or p.target_clinic_id is null)
```

⚠️ `target_clinic_id` 为 `NULL` 表示**集团统一池**(不属于任何诊所),用 `in (...)` 会把它漏掉。

### 时间

```sql
and p.created_at >= '2026-07-01'          -- 工单生成时间
and e.created_at >= '2026-07-01'          -- 执行/关闭发生时间
```

---

## 三、召回池怎么筛

「召回池」= 工作台待认领列表,当前约 23.5 万条。四个条件的交集:

```sql
select p.id, p.priority_score, p.target_clinic_id, p.created_at,
       pt.medical_record_number, pt.name
from followup_plans p
join patients pt on pt.id = p.patient_id
where p.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
  and p.tenant_id = 'ruier-grp'
  and pt.source_unit = '瑞尔'
  and p.superseded_at is null            -- ① 只看当前版本
  and p.status = 'active'                -- ② 未结案
  and p.assignee_user_id is null         -- ③ 未被认领
  and (p.snoozed_until is null
       or p.snoozed_until <= now())      -- ④ 抑制期已过
order by p.priority_score desc;
```

| 条件 | 说明 |
|---|---|
| ② `status='active'` | 未结案。认领后变 `assigned`,结案后 `completed` / `abandoned` |
| ③ `assignee_user_id is null` | 池 = 无主工单;有主的进"我的",出池 |
| ④ `snoozed_until` | 冷静期。未到期的不露面,到点自动回池 |

---

## 三之二、认领怎么捞

「认领」= 客服把工单收到自己名下,认领后才能操作(提交结果 / 关闭 / 生成话术 / 打反馈)。

两个数据源:

| 源 | 是什么 | 用途 |
|---|---|---|
| `followup_plans.assignee_user_id` / `assigned_at` | 当前快照,返池即置 `NULL` | 现在谁手里有多少单 |
| `plan_event_logs` | 历史账本,append-only | 谁处理过哪些患者 |

⚠️ **统计"处理过"用 `plan_event_logs`,不要用 `assignee_user_id`** —— 后者返池后被清空。
账本 **2026-07-24 起**才有数据。

### plan_event_logs

| 字段 | 说明 |
|---|---|
| `event` | `claim` 认领 / `assign` 指派他人 / `release` 返池 / `feedback` 召回反馈 |
| `assignee_user_id` | 变更后的归属人;`release` 为 `NULL` |
| `actor_user_id` | 操作人。`claim` 时 = `assignee_user_id` |
| `held_seconds` | 本次归属持续秒数,仅 `release` 有值 |
| `reason` | `feedback` = `up` / `down` |
| `details` | JSON,事件补充(如反馈文字) |
| `plan_id` / `patient_id` | **按患者聚合用 `patient_id`** |

**某客服处理过哪些患者**

```sql
select l.actor_user_id as 客服, count(distinct l.patient_id) as 处理患者数
from plan_event_logs l
where l.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
  and l.event in ('claim', 'assign', 'feedback')
  and l.created_at >= '2026-07-24'
group by 1 order by 处理患者数 desc;
```

**接手后放弃**(认领了但没提交任何执行)

```sql
select count(distinct l.patient_id)
from plan_event_logs l
where l.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
  and l.event = 'claim'
  and not exists (
    select 1 from plan_executions e where e.plan_id = l.plan_id
  );
```

**认领时长分布**

```sql
select width_bucket(held_seconds, 0, 86400, 12) as 桶, count(*),
       min(held_seconds), max(held_seconds)
from plan_event_logs
where event = 'release' and held_seconds is not null
group by 1 order by 1;
```

---

## 四、关闭操作怎么捞

回访在你们系统里做,PAC 只提供「关闭机会」做闭环。一次关闭在一个事务里写两处:

| 表 | 写入 |
|---|---|
| `plan_executions` 新增一行 | `outcome='abandoned'`、`channel='other'`、`abandon_reasons` = 勾选的原因(多选)、`notes` = 「其他」的说明 |
| `followup_plans` 更新 | `status='abandoned'`、`snoozed_until` = 冷静期、`contact_attempts` +1 |

**捞关闭记录的判据:`outcome='abandoned' and channel='other'`。**

### 关闭原因字典(`abandon_reasons`,多选)

| 值 | 中文 | 冷静期 |
|---|---|---|
| `no_intent` | 客户无意愿 | 60 天 |
| `inaccurate` | 机会识别不准确 | **永久** |
| `competitor` | 选择竞品机构 | 永久 |
| `price` | 价格因素 | 60 天 |
| `treated` | 已完成治疗 | **永久** |
| `unreachable` | 无法联系客户 | 30 天 |
| `other` | 其他(说明写在 `notes`) | 60 天 |

多选时冷静期取**最大值**,落到 `followup_plans.snoozed_until`。"永久" = 36500 天(哨兵值,不是 `NULL`)。

### 常用查询

**关闭原因分布**

```sql
select unnest(e.abandon_reasons) as reason, count(*) as n
from plan_executions e
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
  and e.outcome = 'abandoned' and e.channel = 'other'
  and e.created_at >= '2026-07-01'
group by 1 order by n desc;
```

**按诊所 × 客服的关闭量**

```sql
select e.executor_clinic_id, e.operator_user_id, count(*) as 关闭数,
       count(*) filter (where 'inaccurate' = any(e.abandon_reasons)) as 识别不准
from plan_executions e
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
  and e.outcome = 'abandoned' and e.channel = 'other'
group by 1, 2 order by 关闭数 desc;
```

**关闭明细**

```sql
select e.created_at at time zone 'Asia/Shanghai' as 关闭时间,
       pt.medical_record_number as 病历号,
       p.target_clinic_id as 归属诊所, e.executor_clinic_id as 执行诊所,
       e.operator_user_id as 操作人,
       e.abandon_reasons as 关闭原因, e.notes as 说明,
       r.sub_key as 病种, r.reason as 召回理由
from plan_executions e
join followup_plans p on p.id = e.plan_id
join patients pt      on pt.id = p.patient_id
left join lateral (
  select sub_key, reason from plan_reasons
  where plan_id = p.id order by priority_score desc limit 1
) r on true
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
  and e.outcome = 'abandoned' and e.channel = 'other'
order by e.created_at desc;
```

**「识别不准确」反查**(哪类病种被判得最不准)

```sql
select r.scenario, r.sub_key, count(*) as 被判不准
from plan_executions e
join plan_reasons r on r.plan_id = e.plan_id
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
  and 'inaccurate' = any(e.abandon_reasons)
group by 1, 2 order by 被判不准 desc;
```

---

## 五、字段说明

### followup_plans

| 字段 | 说明 |
|---|---|
| `patient_id` | → `patients.id` |
| `target_clinic_id` | 归属诊所;`NULL` = 集团统一池 |
| `priority_score` | 优先级分 = 该工单下 `plan_reasons.priority_score` 的 **MAX** |
| `status` | `active` 待处理 / `assigned` 处理中 / `completed` 完成 / `abandoned` 放弃(含关闭)/ `superseded` 已被新版取代 |
| `contact_attempts` | 累计触达次数(患者级) |
| `snoozed_until` | 冷静期截止,未到期不进池 |
| `assignee_user_id` / `assigned_at` | 经办人 / 认领时间。**当前快照**,返池即置 `NULL`;历史见 `plan_event_logs` |
| `recall_feedback` | 客服对"这条召回准不准"的反馈(`up`/`down`),独立于关闭操作 |
| `superseded_at` | 被新版取代的时间;当前版为 `NULL` |
| `created_at` | 工单生成时间 |

### plan_reasons

一个工单 N 条理由,每条一个独立临床缺口。

| 字段 | 说明 |
|---|---|
| `scenario` | `treatment_aftercare_recall` 疗效保障 / `treatment_initiation_recall` 启治召回 |
| `sub_key` | 子规则,如 `missing_tooth` / `caries_no_filling` / `impacted_tooth`。**按病种切用这个** |
| `reason` | 自然语言的"为什么召回"(含牙位、日期、医嘱) |
| `signals` | JSON,结构化信号(牙位 / 诊断码 / 触发日期) |

### plan_executions

不可变,只增不改。

| 字段 | 说明 |
|---|---|
| `executor_clinic_id` | 执行诊所 |
| `operator_user_id` | 操作人(你们侧 user id) |
| `channel` | `phone` / `wecom` / `sms` / `other`。**关闭固定 `other`** |
| `outcome` | 结果。关闭恒为 `abandoned` |
| `abandon_reasons` | `text[]`,关闭原因,见上方字典 |
| `notes` | 说明 / 通话纪要 |
| `created_at` | 提交时间 |

### patients / patient_profiles

| 字段 | 说明 |
|---|---|
| `medical_record_number` | **病历号**,跟你们系统对号用这个 |
| `external_id` | PAC 内部 id,对不上业务系统,别用 |
| `source_unit` | 品牌,`瑞尔` / `瑞泰` |
| `patient_profiles.do_not_contact` | 勿打扰;为 true 则不再生成新工单 |
| `patient_profiles.deceased` | 已故;同为硬过滤 |

---

## 六、容易踩的坑

| 坑 | 正确做法 |
|---|---|
| 忘了 `superseded_at is null` | 查当前态一律加,否则同一患者多版工单重复计数 |
| 用 `target_clinic_id in (...)` | 会漏掉集团池,要 `or target_clinic_id is null` |
| 拿 `followup_plans` 数关闭量 | 关闭数查 `plan_executions`,一个工单可能有多次操作 |
| 用 `assignee_user_id` 数"处理过多少患者" | 那是快照,返池即清空;用 `plan_event_logs` |
| 按 `plan_id` 聚合认领 / 执行 | 升版本会换新 `plan_id`,同一次认领被算多次;**按 `patient_id` 聚合** |
| 用 `external_id` 对号 | 用 `medical_record_number` |
| 按 `created_at` 直接当北京时间 | 差 8 小时,要 `at time zone 'Asia/Shanghai'` |
| 把 36500 天后的 `snoozed_until` 当脏数据 | 那是"永久"哨兵值 |
| 忘了排除 `source_unit='瑞泰'` | 不在业务范围 |
