# 租户模型改造 — 最终方案(集团级 + 最小改动)

> 分支:`refactor/tenant-to-group-org-model` · 状态:设计定稿(未动代码)
> 本文为收敛后的**最终计划**,取代此前各轮草稿。

---

## 0. 一句话

`tenant_id` 语义 **品牌 → 集团**(用稳定 slug `ruier-grp`,不用写死 UUID);
品牌降为 **`source_unit`**(发证命名空间,防撞号,进患者键);
组织树**只在 yaml**(最简一行,无 `org_units` 表);
**画像 / 召回计算零改动**。

---

## 1. 已敲定的设计(决策清单)

| # | 决策 |
|---|---|
| 1 | `tenant_id` = 集团,用**稳定 slug**(`ruier-grp`),各环境一致;**删掉写死的 DW UUID** |
| 2 | brand → `patients.source_unit`(**发证命名空间,不可变**),进患者唯一键 |
| 3 | 组织树在 **yaml**:只声明集团;品牌/诊所来自数据;无区域不声明;**不建 org_units 表** |
| 4 | 跨品牌共享 = **默认按 source_unit 隔离**(复刻现状),P1 可配置;**无独立策略对象** |
| 5 | 功能权限**保持代码级**(`role → permissions`) |
| 6 | 纵深防御:**Prisma `$extends` + AsyncLocalStorage**(P1),RLS 作 P2+ |
| 7 | 登录契约:`host_id + group_id + user{id,name,role} + org_scope[]` |

---

## 2. 换槽位洞察(为什么低风险)

现状患者键 `UNIQUE(host, tenant_id, external_id)`,`tenant_id` 槽里装的就是 brand。
目标只是**把 brand 挪到 `source_unit` 列、tenant_id 槽换装集团**:

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

**键的信息量完全相同**(host + brand + external_id),只是重贴标签。→ **不发生患者合并**,472 撞号照样靠 source_unit 分开,迁移机械可逆。
`source_unit` 定义为**"发证命名空间"**(当初哪个系统发的患者号,历史不可变)——即便诊所将来改挂品牌,键也不动。

---

## 3. ⭐ 关键发现:画像 & 召回计算零改动

| 驱动 | 现状 | tenant=集团后 |
|---|---|---|
| `runAllForHost` 调用方(`recompute-plans.cli:71` / `sync-incremental.cli:207` / `scheduler:137` / `reconcile:52`) | `distinct:['tenantId']` + 循环,每 tenant 跑一次 | distinct 收成 `{ruier-grp}` → 一次循环覆盖全集团,**自动适配,零改动** |
| persona `recompute({patientId})` | per-patient | 不变(按 patient_id) |
| 召回 SQL `WHERE p.tenant_id=scope.tenantId`(①隔离闸) | scope 过滤 | 值变集团即可,**逻辑不变** |
| 一切计算 | 按 `patient_id`(全局唯一 UUID) | 王辰/王文彦本就不同 id,合 tenant 不影响每患者计算 |

→ **摄入→facts→画像→召回→plan 这条计算链,逻辑全不动**,只继承新的 tenant 值。

**唯一迁移前置校验**:`patient_facts` 偏唯一 `(host,tenant,subject_id) WHERE active` —— 审 `subject_id` 跨品牌不撞(撞则 brand 限定);`followup_plans (host,tenant,patient_id)` 的 patient_id 是 UUID,安全。

---

## 4. 真正要改的(集中、最小)

### 4.1 Schema

> ⚠️ **实施期前置审推翻"只改 patients"**:合 tenant 前实测跨品牌撞键 —— 不止 external_id,
> `patient_facts.subject_id`(撞 **631**,键 `(host,tenant,subject_id,version)`)和
> `patient_return_visits.external_id`(撞 **611**)也是品牌级,合 tenant 会**撞键失败/改坏 fact 版本链**。
> 故 **source_unit 必须加在 3 张表**(不是只 patients)。`patient_transactions.source_event_id` 撞 0(全局唯一,免)、`patient_relations` 按 patientId(UUID)免。

- **patients**:`source_unit`(NOT NULL 默认 '')+ 键 `(host,tenant,source_unit,external_id)`。✅ 已做(P0-1/2)
- **patient_facts**:`source_unit` + 键 `(host,tenant,source_unit,subject_id,version)`;
  ⭐ `fact-writer.writeDraft` 的**版本链 findFirst** 必须按 source_unit 过滤(否则取错品牌版本)。
- **patient_return_visits**:`source_unit` + 键 `(host,tenant,source_unit,external_id)`。
- **不加** org_units 表。

### 4.2 摄入(`data/jvs-dw/manifest.yaml` + 解析)
```yaml
# 删掉(写死 UUID 的旧路由):
#   tenant_field: brand
#   tenant_map: { 瑞尔: ba67e6cf…, 瑞泰: 77057aed… }

# 换成:
identity_namespace_field: brand          # 患者命名空间(防撞号)取 brand 列
org_tree:
  group: { id: ruier-grp, name: 瑞尔集团 }   # 集团=tenant_id,稳定 slug;就这一行
  # 单集团:所有 brand 默认归此;品牌/诊所来自数据;无区域不声明
cohort:
  tenant_key_column: brand               # 保留:cohort 仍按 (patient_id,brand) 防撞号取数
```
代码:
- `manifest.schema.ts`:支持 `org_tree` + `identity_namespace_field`。
- `tenant-resolver.ts`:tenant 用 `StaticTenantResolver(ruier-grp)`(已支持静态);**另加** source_unit 取值(从 brand 字段,与 tenant 解耦)。
- 患者 upsert **4 处**(`pipeline-dispatcher.service.ts:244`、`cold-import.service.ts:1314/1494/1896`):where 键加 `source_unit`,create/update 落 `source_unit`。

### 4.3 去写死 UUID → slug(4 处)
| 文件 | 改 |
|---|---|
| `manifest.yaml:54-55` | 删 tenant_map(见 4.2) |
| `auth.service.ts:235,244` | mock 两预设 tenantId → `'ruier-grp'`,各带 `sourceUnit: '瑞尔'/'瑞泰'` |
| `weixin-aibot.service.ts:66` | fallback `|| 'ruier-grp'` |
| `seed.ts:7-8` | 注释更新 |

> 顺带:`pull.orchestrator.ts:160` 的 `t_default/t_demo` 另一套约定,一并收口到 slug。

### 4.4 数据范围:换槽位在查询层的对应(非新策略、非 RLS)

> **没有独立"隔离策略"。** 现状隔离 = 纯代码 where(`WHERE tenant_id=scope.tenantId`,~33 处;schema 无 RLS、无 `$extends`,UNIQUE 只管身份)。
> brand 从 tenant_id 槽搬到 source_unit 槽(§2),那么**"按品牌隔离"的 where 判别列也跟着 `tenant_id` → `source_unit`** —— 同一条 where,换个列名,不是新机制。

**数据范围有两种粒度,都来自 org 位置:**
- **plan** 绑诊所 → 范围用 `clinicIds`(现状已有);
- **患者**不绑单诊所 → 范围用**品牌 `source_unit`**。

具体:scope 加 `sourceUnits: string[]`(用户所属品牌,登录派生,与 clinicIds 并列同源)。合并 tenant 后,**今天靠 `tenant_id=品牌` 隔离、但不按 clinic 过滤的面**,把判别列换成 `source_unit IN scope.sourceUnits`:
1. **患者搜索 / 列表**(`patient.service.ts:54` 等)— 现 `where:{hostId,tenantId,…}`,改 `tenantId=集团 + sourceUnit IN …`;
2. **召回池 NULL-clinic 工单**(`buildListWhere` pool 的 `targetClinicId IS NULL` 分支);
3. **按 id 直取患者/plan 的 guard**(`plan.service.ts:353` 的 `tenantId===scope.tenantId`,加 source_unit)。
> plan 列表本就按 clinic 过滤(用户 clinicIds 是本品牌诊所),已隔离,source_unit 于此冗余、可不加。
> 默认 `sourceUnits` = 用户本品牌(复刻现状);**P1 让它可配置**(集团可开跨品牌)。

### 4.5 迁移(一次性,可逆)
1. 加 `patients.source_unit`(nullable);
2. 回填:`source_unit = CASE tenant_id WHEN 旧瑞尔UUID THEN '瑞尔' WHEN 旧瑞泰UUID THEN '瑞泰' END`;
3. **前置审**:`subject_id` 跨品牌不撞;
4. 改患者唯一键 → 含 source_unit;
5. **重映射 tenant_id**:15 张带 tenant_id 的表,品牌UUID → `ruier-grp` slug(一刀同时完成:品牌→集团 + UUID→slug + 去 env 耦合);
6. 回归:全量重算 plan/persona,`plansUnchanged` 99%+(同上次部署验证法)。

---

## 5. 分期

| 阶段 | 内容 | 结果 | 用 org_units? |
|---|---|---|---|
| **P0** | §4.1–4.5 | tenant=集团 slug、源 UUID 消失、**行为与现状一致**(品牌仍隔离) | ❌ |
| **P1** | source_unit 过滤**可配置**(集团可开跨品牌)+ Prisma `$extends` 兜底 + 登录契约 `org_scope`→clinicIds 展开 | 墙在集团、共享可配、有纵深防御 | ❌ |
| **P2(按需)** | yaml org_tree 加**区域**等多级 + 数据范围迁 org 子树 + rollup + 前端树 | 层级角色 / 任意层级汇总 | yaml(内存树,仍不建表;除非要运行时编辑/SQL rollup) |
| **P3(独立)** | MPI:姓名+生日+性别(+身份证)跨品牌归一 golden 客户 | 集团级统一患者 | — |

P0 即达成"租户=集团、集团数据隔离(跟原 SaaS 一致)、写死 UUID 消除";P1 让共享可配 + 兜底;P2/P3 按需。

---

## 6. 文件改动清单(P0 精确对照)

| 文件 | 改动 |
|---|---|
| `prisma/schema.prisma` | `patients.source_unit` + 唯一键 |
| `prisma/migrations/*` | 加列 / 回填 / 改键 / 重映射 tenant_id→slug |
| `data/jvs-dw/manifest.yaml` | 删 tenant_map;加 `org_tree.group` + `identity_namespace_field` |
| `sync/cold-import/manifest.schema.ts` | 支持 `org_tree` + `identity_namespace_field` |
| `sync/cold-import/tenant-resolver.ts` | tenant 静态 slug;加 source_unit 取值 |
| `sync/pipeline/pipeline-dispatcher.service.ts:244` | upsert 键 + 落 source_unit |
| `sync/cold-import/cold-import.service.ts:1314/1494/1896` | 同上 |
| `auth/auth.service.ts:235,244` | mock 预设 → slug + sourceUnit |
| `weixin-aibot/weixin-aibot.service.ts:66` | fallback → slug |
| `prisma/seed.ts` | 注释 |
| `patient/patient.service.ts` | 患者搜索加 source_unit 默认过滤(§4.4) |
| `plan/plan.service.ts:~160,353` | pool NULL-clinic 分支 + by-id guard 加 source_unit(§4.4) |
| `common/decorators/tenant-scope.decorator.ts` | scope 加 `sourceUnits` |
| `common/interceptors/tenant-scope.interceptor.ts` | 填 `sourceUnits` |

**不动**:召回引擎 / 画像 service / 重算驱动 / 子表 schema(除 tenant_id 值重映射)。

---

## 7. 待你给的业务输入(给了即可开 P0)

1. **集团 slug**:`ruier-grp` 可以吗?
2. 确认 **瑞尔、瑞泰都归这一个集团**。
3. P0 合并后,**瑞尔/瑞泰默认保持隔离**(我按这个做 = 复刻现状),对吗?
