# 租户模型改造方案:品牌级 → 集团级通用组织模型

> 分支:`refactor/tenant-to-group-org-model`
> 状态:设计稿(待评审,未动代码)
> 背景讨论:见 CDP 对齐讨论、DW 撞号实测、MPI 雏形验证

---

## 0. 一句话

把 `tenant_id` 的语义从**品牌**(现状/误解)改成**集团**(目标通用模型):
品牌降为「患者身份的命名空间限定符 + 组织树节点」,集团内品牌间是否共享数据变成**可配置策略**而非写死的硬墙,并引入 `org_units` 组织树承接集团→品牌→区域→诊所任意层级。客户跨品牌统一身份(MPI)作为独立上层,不在本次范围。

---

## 1. 背景:误解 vs 目标

### 现状(误解)
- `tenant_id` = 品牌。瑞尔(`ba67e6cf…`)、瑞泰(`77057aed…`)是同一 host(`jvs-dw`)下的**两个 tenant**。
- 硬隔离墙焊在 `(host_id, tenant_id)`,品牌之间绝对互不可见。
- `clinic_id` 是散在 5 张表的**扁平字符串**,无实体、无层级。
- 这套**够用但做窄了**:塞不下深组织、做不了集团跨品牌角色、品牌共享无法配置。

### 为什么品牌当 tenant 是误读了数据
DW 实测(`dw_group.fact_client_out`):
- `(patient_id, brand)` 才是真唯一键;`patient_id` 单独不唯一,**472 个跨品牌撞号**。
- 472 撞号**全是不同的人**(王辰 vs 王文彦,姓名 100% 不一致)。
- **撞号只逼一件事**:brand 必须进**患者身份的命名空间**(给源 ID 消歧)。
- **撞号不逼**:brand 当**隔离墙**。

> ⭐ 关键认知:**「品牌当命名空间」≠「品牌当 tenant」**。之前把这俩混成一件事,才得出"tenant 必须=品牌"。纠正后,tenant 可以=集团。

### 目标模型(四件套)
```
host   = PAC 接入宿主(一个被打通的源系统;现实一集团一宿主)
  └─ 集团  = tenant_id = 隔离/账户单元        ← 墙在这层,跟原 SaaS 一致
       └─ 品牌 = org_units 节点(共享=策略,非墙)+ 患者键里的命名空间
            └─ 诊所 = org_units 叶子
patient 身份 = (host, tenant=集团, brand, external_id)   ← brand 只消歧,不隔离
golden 客户  = MPI 集团内跨品牌归一(独立上层,本次不做)
```

- host 不拆正交轴,保持"接入宿主";**`host : 集团(tenant) = 1:N`**(一个 host 下多个集团)。
  - ✅ **现有 schema 已支持**:`(host_id, tenant_id)` 本就允许一 host 挂多 tenant,多集团**无需改 schema**,`tenant_id=集团`直接多值。
- 隔离墙在**集团**;集团内品牌间默认隔离、**可配置打开**。

---

## 2. ⭐ 迁移核心洞察:换槽位,不是加信息

现状 `patients` 唯一键 `UNIQUE(host_id, tenant_id, external_id)`,其中 `tenant_id` 装的就是 brand。
即**今天的患者身份事实上 = `(host, brand, external_id)`**,472 撞号靠 tenant_id(=brand)分开。

目标只是**把 brand 从 tenant_id 槽挪到独立 `brand` 列,tenant_id 槽换装 group**:

| | host_id | tenant_id | brand | external_id |
|---|---|---|---|---|
| 现状 | host | **品牌UUID** | (无) | 患者号 |
| 目标 | host | **集团UUID** | **品牌** | 患者号 |

**键的信息量完全相同**(host + brand + external_id),只是重新贴标签。所以:
- **不发生任何患者合并**;472 撞号照样靠 brand 列分开。
- 迁移是**机械、可逆、低风险**的列搬迁,不是数据融合。

这是整个方案能"安全落地"的根。

---

## 3. 影响面总览

| 层 | 改动性质 | 工作量 |
|---|---|---|
| Schema | 加 `patients.brand` 列 + 改患者唯一键 + 新增 `org_units` 表 + 子表 tenant_id 重映射 | 中 |
| 摄入路由 | manifest 切静态 group + 新增 brand 捕获(resolver 已支持) | 小 |
| 认证/scope | token 加 `orgUnitSubtree` + `brandSharingPolicy`;scope 扩形 | 中 |
| 查询隔离 | tenant→集团;加品牌共享策略过滤;clinic 扁平→org 子树 | 中大 |
| 纵深防御 | 新增 Prisma 中间件 / RLS(墙变软,必须补) | 中 |
| clinic/前端 | clinic_id 挂 org_units;前端 clinic 筛选→组织树 | 中 |
| 数据迁移 | 2 品牌 tenant 合 1 集团 tenant + brand 回填 | 中(一次性) |

---

## 4. Schema 改造

### 4.1 新增 `patients.source_unit` 列(通用命名空间限定符,**不写死 brand**)

> ⚠️ brand 不通用:别的 host 可能根本没"品牌",而是 集团→区域→诊所。
> 患者键里的消歧符应是**通用的"源组织命名空间"**,具体对应哪一层由 manifest 声明。

```prisma
model Patient {
  // ...
  sourceUnit String? @map("source_unit")  // 源组织命名空间值(瑞尔=brand值;别的host=区域码/HIS实例…)
  // 唯一键:加 source_unit
  @@unique([hostId, tenantId, sourceUnit, externalId])  // 原 (hostId, tenantId, externalId)
}
```
- `source_unit` = manifest 指定的"身份命名空间字段"原值。jvs-dw 此字段 = `brand`(瑞尔/瑞泰)。
- 回填:`source_unit` 从现 `tenant_id` 反查(`ba67e6cf→瑞尔`、`77057aed→瑞泰`)。
- 改键后再把 `tenant_id` 改成集团 UUID(顺序见 §9)。
- 与 `org_units` 的关系:`source_unit` 值 = 对应 org 节点的 `external_ref`(瑞尔场景是 brand 节点;别的 host 可能是 clinic 节点——取决于源系统 external_id 在哪一层唯一)。

### 4.2 新增 `org_units` 组织树(本次唯一新实体表)
```prisma
model OrgUnit {
  id          String   @id @default(uuid()) @db.Uuid
  hostId      String   @map("host_id") @db.Uuid
  tenantId    String   @map("tenant_id")          // = 集团
  parentId    String?  @map("parent_id") @db.Uuid // 自关联,树
  unitType    String   @map("unit_type")          // 'group'|'brand'|'region'|'clinic'… 字符串枚举
  externalRef String?  @map("external_ref")       // 宿主侧 brand码 / 区域码 / clinic_id
  active      Boolean  @default(true)
  createdAt   DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt   DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  parent      OrgUnit? @relation("OrgTree", fields: [parentId], references: [id])
  children    OrgUnit[] @relation("OrgTree")
  @@unique([hostId, tenantId, unitType, externalRef])
  @@index([hostId, tenantId, unitType])
  @@index([parentId])
}
```

> **⭐ `unit_type` ≠ 层级**:层级完全靠 `parent_id` 树,`unit_type` 只是**分类标签**。
> 所以**任意深度、任意形状、同 type 可嵌套**都支持:
> - 瑞尔:集团 → 品牌 → 诊所
> - 别的 host:集团 → 区域 → 区域 → 诊所(**无品牌、多级区域**)
> - 再别的:集团 → 事业部 → 品牌 → 城市 → 诊所
> 一律装得下。`unit_type` 用**字符串枚举**(`group|brand|region|clinic|division…`),**不用数字**(数字脆、不可读、扩类型要改一堆)。
> 子树查询性能:可加 `path`(物化路径,如 `/g1/b2/c9`)或用 Postgres `ltree`,避免递归 CTE。

> **设计决策(待定):org_units 的"名字"放哪?**
> PAC 现行原则是「不立 tenants/clinics/users 主数据表,只存 id,展示名走宿主字典」([schema.prisma:18-20])。
> 建议沿用:`org_units` 只存**结构**(id/parent/type/external_ref),**不存 name**;name 仍由宿主登录字典(`dictionary.clinics`)解析。
> 这样 org_units 是 PAC 拥有的**纯结构树**,从摄入数据派生(distinct brand→brand 节点、distinct clinic_id→clinic 节点、集团=tenant 根),不抢宿主主数据。

### 4.3 子表 tenant_id 重映射(15 张带 tenant_id 的表)
所有 `(host_id, tenant_id)` 表的 `tenant_id` 从品牌 UUID → 集团 UUID。**子表不需要加 brand 列**(它们经 `patient_id`/`plan_id` 关联,patient_id 是全局唯一 UUID,身份已消歧)。可选:在 `patient_facts` / `followup_plans` 冗余一个 brand 列做查询过滤优化(非必须,见 §6 策略过滤)。

### 4.4 三处偏唯一索引的迁移校验
| 索引 | 迁移动作 | 风险 |
|---|---|---|
| `patient_transactions (host,tenant,source_event_id) WHERE NOT NULL` | tenant 列重映射;定义不变 | 低 |
| `patient_facts (host,tenant,subject_id) WHERE active` | **先审 subject_id 是否跨品牌撞**;撞则 brand 限定 subject_id 再合 | 中 |
| `followup_plans (host,tenant,patient_id) WHERE active/assigned` | patient_id 是 UUID 全局唯一,合后无撞 | 低 |

---

## 5. 摄入路由改造(改动最小)

`tenant-resolver.ts` 已内置 4 种模式,其中 **mode 4 `StaticTenantResolver`** 正好是"静态 group"。

### 现状(`data/jvs-dw/manifest.yaml`)
```yaml
tenant_field: brand           # → FieldMapTenantResolver,按 brand 路由
tenant_map:
  瑞尔: ba67e6cf30dc4f9c9c46adef188bbd04
  瑞泰: 77057aed269f4a14957ae0ad0eff359a
cohort:
  patient_key_column: patient_id
  tenant_key_column: brand
```

### 集团 ID 哪里来 —— 三种情况
| 情况 | manifest 配置 |
|---|---|
| 宿主数据**有** group_id | `tenant_field: group_id`(pass-through,FieldPassThroughResolver) |
| 宿主**没有**(jvs-dw 只有 brand) | `tenant_field: brand` + `tenant_map: {瑞尔→集团UUID, 瑞泰→集团UUID}`(两品牌映同一新发集团) |
| 都没有、归属未知 | **无法路由,必须先定义** —— 集团归属是业务事实,接新 host 时必填 |

> 集团 UUID **新发**,不复用品牌 UUID(避免语义混淆)。
> 多集团 host:`tenant_map` 里不同品牌映不同集团即可(如 `{A→集团1, B→集团1, C→集团2}`)。

### 目标(jvs-dw,宿主无 group_id)
```yaml
tenant_field: brand           # 复用 FieldMapTenantResolver,但 map 改成 →集团
tenant_map:
  瑞尔: <集团UUID>            # 两品牌都映到同一个新发集团
  瑞泰: <集团UUID>
identity_namespace_field: brand   # 【新增】把 brand 当独立命名空间捕获 → patients.source_unit
cohort:
  patient_key_column: patient_id
  tenant_key_column: brand    # cohort 仍按 (patient_id, brand) 防撞号取数,不变
```

### 代码改动点
- `manifest.schema.ts`:新增可选 `identity_namespace_field`。
- `tenant-resolver.ts`:**不动**(FieldMapTenantResolver 已能 brand→任意值,map 改成→集团即可)。
- 新增**命名空间解析**:按 `identity_namespace_field` 从行取 `source_unit` 值(与 tenant 路由解耦)。
- **患者 upsert 落 source_unit**(4 处):
  - `pipeline/pipeline-dispatcher.service.ts:244`
  - `cold-import/cold-import.service.ts:1314 / 1494 / 1896`
  - 由 `where: { hostId_tenantId_externalId }` → `where: { hostId_tenantId_sourceUnit_externalId }`,create/update 带上 `source_unit`。

---

## 6. 查询隔离 + 品牌共享策略 + 纵深防御

### 6.1 隔离闸从品牌移到集团
- `plan.service.ts:147-150` `buildListWhere` 基座 `{ hostId, tenantId }` 中 `tenantId` 语义变集团(字段名不变)。
- 召回引擎 SQL「①隔离闸」`treatment-initiation-recall.scenario.ts:351-352`
  `WHERE p.host_id=… AND p.tenant_id=…` —— tenant 变集团;按策略可加 brand 过滤。
- `plan-engine.service.ts` 9 处 `(hostId, tenantId)` 同步。
- 约 33 处 `tenantId: scope.tenantId` 注入点语义随之变集团(字段名不变,行为变)。

### 6.2 品牌共享策略(新增,默认隔离)
在基座隔离**之后**插一道品牌过滤(`buildListWhere` 内 + 召回 SQL 内):
```ts
// 默认 closed:只看自己品牌(行为同今天)
if (policy.shareAcrossBrand === false) where.brand = scope.brand;
// open:省略 brand 过滤 → 集团内跨品牌可见
```
- 策略来源:`brandSharingPolicy`(token claim / 集团配置)。
- **默认 closed**,确保上线即与现状行为一致、不误开放。

### 6.3 纵深防御(墙变软,必须补)
现状**零 Prisma 中间件 / 零 RLS**,隔离全靠手写 `where` 注入。tenant→集团后品牌过滤也是手写,**漏一处 = 跨品牌泄漏**。

**✅ 已定:Prisma `$extends` 客户端扩展 + AsyncLocalStorage 租户上下文为主,RLS 作为后续硬化(P2+)。**
- `prisma.service.ts` 加 `$extends({ query })`,从 ALS 取当前集团,对 find*/count/groupBy **自动注入 `tenantId` 过滤**(漏写也兜得住),并对未带租户上下文的查询告警。
- 理由:PAC 每服务单连接池,应用层兜底够;RLS 需按连接设 session 变量、与连接池配合麻烦、调试黑;中间件可增量加、无需 DB 迁移、能灰度。
- RLS 留作规模/合规要求上来后的二次硬化,不阻塞本次。

---

## 7. 认证 / scope 改造

### 现状
- token(`AccessTokenPayload`,`packages/types/src/schemas/auth.ts`):`{ sub, hostId, tenantId, clinicIds[], role, permissions, dictionary }`。
- scope(`tenant-scope.decorator.ts`):`{ hostId, tenantId, clinicIds[], userId }`,锚单 tenant。

> **分期**:`brandSharingPolicy`(数据共享策略)是 **P1**;`orgUnitSubtree`(数据范围迁 org 子树)是 **P2**(随 org_units)。
> **P0/P1 的 scope 基本不变**(只是 tenant 语义变集团 + P1 加一个 `brandSharingPolicy`);下面是 **P2 完成态**的目标形状。

### 目标(P2 完成态)
- token 新增:`brandSharingPolicy?`(**P1**)、`orgUnitSubtree: string[]`(**P2**)。
- scope 扩形:
```ts
interface TenantScopeContext {
  hostId: string;
  tenantId: string;              // = 集团
  clinicIds: string[];           // 现状保留(P2 后 = org 子树里的诊所叶子,派生)
  userId: string;
  brandSharingPolicy?: { shareAcrossBrand?: boolean; visibleBrands?: string[] }; // P1
  orgUnitSubtree?: string[];     // P2:org 子树(集团/品牌/区域/诊所层级角色)
}
```
- (P2)`jwt.strategy.ts:18` 校验加 `orgUnitSubtree` 非空。
- `auth.service.ts` 签发 + mock 登录预设:品牌 slug → **集团 tenantId + source_unit**(P1);+ org 子树(P2)。
- **集团级角色**(看全部品牌)= P1 用 `shareAcrossBrand=true`;P2 后 = org 子树挂 group 节点,自然实现,无需破墙。

---

## 7.5 ⭐ 权限模型:三个正交概念(别再搅在一起)

PAC 现状把三件事搅在两样东西里(角色 + clinicIds 扁平),要拆清:

| 概念 | 管什么 | 现状 | 目标 | 阶段 |
|---|---|---|---|---|
| **功能权限** | 能做什么动作(分配/回收/看统计) | `Permission` 枚举 + `ROLE_PERMISSIONS` 静态码表 + `PermissionsGuard`(全代码,无表) | **保持代码级**(权限词表是产品功能决定的固定词汇,不该让租户改) | 不变 |
| **数据范围** | 能看哪些组织单元的数据 | `clinicIds[]` 扁平 + 角色逃生门 `PLAN_VIEW_ALL`(越过 clinic 看全 tenant) | **绑 org 位置(子树)**:挂哪个节点→看那个子树。替掉"两档"逃生门 | **P2** |
| **数据共享策略** | 集团内跨品牌通不通 | 不存在 | **集团级结构配置**(`brandSharingPolicy`),不绑角色不绑个人 | **P1** |

**判定规则(目标态)**:
> 能看到一条 plan = (它的 org 单元 ∈ 你的 org 子树) **OR** (集团共享策略允许 + 你有跨单元权限)
> 角色只决定动作(assign/recycle/stats),**不决定数据范围**。

**为什么"数据范围用角色"不合理**:现在只有"看本诊所 / 看全部"两档,正因为拿角色(`PLAN_VIEW_ALL`)当数据范围开关。"区域经理看本区 8 家诊所"这种中间档角色给不了,**org 子树才给得了** → 这正是 P2 引入 org_units 的真实驱动。

**P0/P1 不动权限**:功能权限保持代码级;数据范围维持现状(clinicIds 扁平 + 角色逃生门),只是 tenant 语义变集团;新增的只有集团级"品牌共享策略"一个配置。**数据范围迁 org 子树留到 P2**。

> 未来若集团要**自定义角色**(如"区域经理"配权限集),再加薄表 `roles(tenant_id, name, permissions[])`。现在不需要。

---

## 8. clinic_id → org_units + 前端(**P2**)

### 后端
- 5 处 clinic 列保留(`target_clinic_id` / `clinic_id` / `executor_clinic_id` …),**额外挂 org_units 叶子**:
  - 加 `followup_plans.target_org_unit_id`(FK org_units 叶)等,sync/创建时绑定。
- 权限过滤 `plan.service.ts:184-189` 从扁平 `clinicIds[]` → **org 子树展开**:
  `OrganizationService.getSubtreeClinicIds(orgUnitId)`,leader 看子树、staff 看本诊所。
- 渐进:clinic_id 与 org_unit_id 并存一段,RBAC 双查,稳定后下线 clinic_id。

### 前端(`apps/pac-web`)
- clinic 名解析已是通用 `clinicName(id, dict)`([adapt-data.ts:28-31]),dict 来宿主字典 → org_units 不存名,**这块基本不动**。
- clinic 筛选器 `plans-list-app.tsx` / `patient-picker-rail.tsx` 从扁平多选 → **层级树多选**(集团>品牌>区域>诊所),登录时随字典预取 org 树。

---

## 9. 数据迁移步骤(一次性,可逆)

> 现状本地:瑞尔 83,834 + 瑞泰 11,166 患者,472 撞号。线上同构。

1. **加列**:`patients.source_unit`(nullable),不动数据。
2. **回填 source_unit**:`UPDATE patients SET source_unit = CASE tenant_id WHEN 'ba67e6cf…' THEN '瑞尔' WHEN '77057aed…' THEN '瑞泰' END`。
3. **审撞号**:确认 `patient_facts.subject_id`、`followup_plans.patient_id` 跨品牌无撞(patient_id 是 UUID,安全;subject_id 需查)。
4. **改唯一键**:`patients` → `UNIQUE(host, tenant, source_unit, external_id)`。
5. **重映射 tenant_id**:所有 15 张带 tenant_id 的表 `tenant_id` 品牌UUID → 集团UUID(事务内,按表批量)。
6. **建 org_units**:从 distinct source_unit / clinic_id 派生节点(group 根 + brand/region + clinic)。
7. **切 manifest**:`tenant_map`→集团 + `identity_namespace_field`。
8. **回归**:全量重算 plan/persona 校验 99%+ 不变(同上次部署的 plansUnchanged 验证法)。

> 472 撞号在第 4 步后由 source_unit 列分开,第 5 步合 tenant **不产生合并**。可逆:保留 source_unit→旧 tenant 映射即可回退。

---

## 10. 分期实施

| 阶段 | 内容 | 产出 | 用 org_units? |
|---|---|---|---|
| **P0 地基** | `patients.source_unit` + 唯一键 + tenant 重映射 + manifest(tenant_map→集团 + identity_namespace_field)+ 4 处 upsert | tenant=集团跑通,行为同现状 | ❌ |
| **P1 隔离** | 集团级**品牌共享策略**(默认 closed,一个配置)+ Prisma `$extends` 兜底 | 墙在集团、可配置共享、有纵深防御 | ❌ |
| **P2 组织(按需)** | `org_units` 树 + clinic 挂叶 + **数据范围迁 org 子树**(替掉 PLAN_VIEW_ALL 逃生门)+ rollup + 前端组织树 | 集团/品牌/区域/诊所层级角色 + 任意层级 rollup | ✅ |
| **P3(独立)** | MPI:姓名+生日+性别(+身份证)跨品牌归一 golden 客户 | 集团级统一患者 | — |

- **P0+P1 不需要 org_units**,只用 `source_unit` 字符串列 + 集团级一个共享策略 + 维持现状 clinicIds 扁平权限,即满足"租户=集团、集团数据隔离(跟原 SaaS 一致)、品牌共享可配置"的核心目标。
- **P2 才引入 org_units**,且只在真要"多级组织 / 区域层级数据范围 / 任意层级 rollup"时做。加它不用回头改 P0/P1。
- P3 是另一个项目(对齐 CDP)。

---

## 11. 待决策 / 开放问题

1. ~~org_units 存不存 name?~~ **✅ 已定:org_units 整体推到 P2**,P0/P1 不引入(见 §10);存名问题随之延后(届时倾向结构 PAC 拥有、名走字典)。
2. ~~子表冗余 source_unit?~~ **✅ 已定:不冗余**。召回 SQL 与 plan 列表本就 join patient,`p.source_unit` 过滤近乎免费,无需冗余。
3. ~~多集团 host 先锁 1:1?~~ **✅ 已定:支持多集团**,schema 本就支持(tenant_id=集团多值),代码直接按 1:N 实现。
4. **集团 UUID 谁定**?→ **✅ 已定:新发**集团 UUID,不复用品牌 UUID;品牌→集团归属在 manifest `tenant_map` 配置。
5. ~~纵深防御选哪个?~~ **✅ 已定:Prisma `$extends` + AsyncLocalStorage**,RLS 作 P2+ 硬化(见 §6.3)。
6. **权限模型**(见 §7.5):功能权限保持代码级;数据范围 P2 迁 org 子树;数据共享策略 P1 加集团级配置。**✅ 已定方向。**

---

## 附:关键文件改动清单(落地时对照)

| 文件 | 改动 |
|---|---|
| `prisma/schema.prisma` | `patients.source_unit` + 唯一键;新增 `model OrgUnit`;子表 tenant 语义 |
| `prisma/migrations/*` | 加列/改键/重映射/建 org_units 的迁移 SQL |
| `data/jvs-dw/manifest.yaml` | `tenant_map` 改成 brand→集团UUID + 新增 `identity_namespace_field: brand` |
| `sync/cold-import/manifest.schema.ts` | 新增 `identity_namespace_field` |
| `sync/pipeline/pipeline-dispatcher.service.ts:244` | upsert where 加 source_unit |
| `sync/cold-import/cold-import.service.ts:1314/1494/1896` | upsert where 加 source_unit |
| `packages/types/src/schemas/auth.ts` | token 加 `orgUnitSubtree`/`brandSharingPolicy` |
| `common/decorators/tenant-scope.decorator.ts` | scope 扩形 |
| `common/interceptors/tenant-scope.interceptor.ts` | 填新 scope 字段 |
| `auth/jwt.strategy.ts` / `auth/auth.service.ts` | 校验 + 签发新 claim;mock 预设 |
| `modules/plan/plan.service.ts:147,184` | 集团隔离 + 品牌策略 + org 子树过滤 |
| `modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts:351` | ①隔离闸 → 集团(+brand 策略) |
| `modules/plan/engine/plan-engine.service.ts` | 9 处 scope 语义 |
| `prisma/prisma.service.ts` | 新增 `$extends` 租户兜底中间件 |
| `apps/pac-web/.../patient-picker-rail.tsx`, `plans-list-app.tsx` | clinic 筛选 → org 树 |
