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

> 分支:`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(多集团 SaaS 当 host 时),现实 1:1。
- 隔离墙在**集团**;集团内品牌间默认隔离、**可配置打开**。

---

## 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.brand` 列(命名空间限定符)
```prisma
model Patient {
  // ...
  brand String?  @map("brand")   // 品牌命名空间;NULL=单品牌 host
  // 唯一键:加 brand
  @@unique([hostId, tenantId, brand, externalId])   // 原 (hostId, tenantId, externalId)
}
```
- 回填:`brand` 从现 `tenant_id` 反查(`ba67e6cf→瑞尔`、`77057aed→瑞泰`)。
- 改键后再把 `tenant_id` 改成集团 UUID(顺序见 §8)。

### 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 // 自关联,树
  nodeType    String   @map("node_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, nodeType, externalRef])
  @@index([hostId, tenantId, nodeType])
  @@index([parentId])
}
```

> **设计决策(待定):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
```

### 目标
```yaml
tenant_id: <集团UUID>         # 静态 → StaticTenantResolver,所有行 = 集团
brand_field: brand            # 【新增】把 brand 当独立列捕获,落 patients.brand
cohort:
  patient_key_column: patient_id
  tenant_key_column: brand    # cohort 仍按 (patient_id, brand) 防撞号取数,不变
```

### 代码改动点
- `manifest.schema.ts`:新增可选 `brand_field`。
- `tenant-resolver.ts`:不动(已支持静态)。
- **患者 upsert 落 brand**(4 处):
  - `pipeline/pipeline-dispatcher.service.ts:244`
  - `cold-import/cold-import.service.ts:1314 / 1494 / 1896`
  - 由 `where: { hostId_tenantId_externalId }` → `where: { hostId_tenantId_brand_externalId }`,create/update 带上 `brand`。

---

## 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→集团后品牌过滤也是手写,**漏一处 = 跨品牌泄漏**。补一层兜底:
- 方案 A:`prisma.service.ts` 加 `$extends({ query })`,用 AsyncLocalStorage 注入当前集团,对 find*/count/groupBy 自动加 `tenantId` 过滤(漏写也兜得住)。
- 方案 B:Postgres RLS,`tenant_id` 行级策略。
- 建议先 A(应用层、改动小、可灰度),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。

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

---

## 8. clinic_id → org_units + 前端

### 后端
- 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.brand`(nullable),不动数据。
2. **回填 brand**:`UPDATE patients SET brand = 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, brand, external_id)`。
5. **重映射 tenant_id**:所有 15 张带 tenant_id 的表 `tenant_id` 品牌UUID → 集团UUID(事务内,按表批量)。
6. **建 org_units**:从 distinct brand / clinic_id 派生节点(group 根 + brand + clinic)。
7. **切 manifest**:静态 group + `brand_field`。
8. **回归**:全量重算 plan/persona 校验 99%+ 不变(同上次部署的 plansUnchanged 验证法)。

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

---

## 10. 分期实施

| 阶段 | 内容 | 产出 |
|---|---|---|
| **P0 地基** | `patients.brand` + 唯一键 + tenant 重映射 + manifest 静态 group + upsert 落 brand | tenant=集团跑通,行为同现状 |
| **P1 隔离** | 品牌共享策略(默认 closed)+ Prisma 中间件兜底 + scope 加 orgUnitSubtree | 墙在集团、可配置共享、有纵深防御 |
| **P2 组织** | `org_units` 树 + clinic 挂叶 + 权限子树化 + 前端组织树筛选 | 集团/品牌/区域/诊所角色 + rollup |
| **P3(独立)** | MPI:姓名+生日+性别(+身份证)跨品牌归一 golden 客户 | 集团级统一患者 |

P0+P1 即满足"租户=集团、集团数据隔离(跟原 SaaS 一致)、品牌共享可配置"的核心目标。P2 解决深组织/角色。P3 是另一个项目(对齐 CDP)。

---

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

1. **org_units 存不存 name**?建议不存(沿用"宿主字典"原则),但若要做集团级报表/树形 UI,可能需要 PAC 侧缓存名。→ 倾向:结构 PAC 拥有,名走字典。
2. **子表要不要冗余 brand 列**?查询过滤性能 vs 冗余。→ 倾向:`patient_facts`/`followup_plans` 冗余 brand,省 join;其余不加。
3. **多集团 host**(host:集团 = 1:N)现在要不要支持,还是先锁 1:1?→ 倾向:schema 留得住(tenant_id=集团已支持多值),代码先按 1:1 实现。
4. **集团 UUID 谁定**?用现瑞尔 tenant `ba67e6cf` 顶上当集团,还是新发一个?→ 倾向:新发集团 UUID,避免与品牌 UUID 语义混淆。
5. **纵深防御选 Prisma 中间件还是 RLS**?→ 倾向:先中间件,RLS 后续硬化。

---

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

| 文件 | 改动 |
|---|---|
| `prisma/schema.prisma` | `patients.brand` + 唯一键;新增 `model OrgUnit`;子表 tenant 语义 |
| `prisma/migrations/*` | 加列/改键/重映射/建 org_units 的迁移 SQL |
| `data/jvs-dw/manifest.yaml` | `tenant_field:brand` → `tenant_id:<group>` + `brand_field:brand` |
| `sync/cold-import/manifest.schema.ts` | 新增 `brand_field` |
| `sync/pipeline/pipeline-dispatcher.service.ts:244` | upsert where 加 brand |
| `sync/cold-import/cold-import.service.ts:1314/1494/1896` | upsert where 加 brand |
| `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 树 |
