Commit c21e0221 by luoqi

docs(refactor): 收敛为最终最小方案 — 画像/召回零改动 + slug 去写死UUID

核查摄入/画像/召回全链后重写计划:
- 关键发现:画像&召回计算零改动(重算驱动是 distinct tenantId 循环 +
  per-patient,tenant=集团自动适配;召回 SQL 仅 scope 过滤)
- tenant=集团用稳定 slug(ruier-grp),删 4 处写死 DW UUID
- brand→source_unit(发证命名空间,进键);换槽位不合并、472撞号仍分开
- org_tree 最简一行 yaml,不建表;子表不冗余
- 改动集中:schema+摄入(4处upsert)+去写死UUID(4处)+少数隔离查询面
  (患者搜索/召回池NULL-clinic/by-id guard 加 source_unit 默认过滤)
- 迁移前置:审 subject_id 跨品牌不撞;一刀完成 品牌→集团+UUID→slug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 8d74885e
# 租户模型改造方案:品牌级 → 集团级通用组织模型 # 租户模型改造 — 最终方案(集团级 + 最小改动)
> 分支:`refactor/tenant-to-group-org-model` > 分支:`refactor/tenant-to-group-org-model` · 状态:设计定稿(未动代码)
> 状态:设计稿(待评审,未动代码) > 本文为收敛后的**最终计划**,取代此前各轮草稿。
> 背景讨论:见 CDP 对齐讨论、DW 撞号实测、MPI 雏形验证
--- ---
## 0. 一句话 ## 0. 一句话
`tenant_id` 的语义从**品牌**(现状/误解)改成**集团**(目标通用模型): `tenant_id` 语义 **品牌 → 集团**(用稳定 slug `ruier-grp`,不用写死 UUID);
品牌降为「患者身份的命名空间限定符 + 组织树节点」,集团内品牌间是否共享数据变成**可配置策略**而非写死的硬墙,并引入 `org_units` 组织树承接集团→品牌→区域→诊所任意层级。客户跨品牌统一身份(MPI)作为独立上层,不在本次范围。 品牌降为 **`source_unit`**(发证命名空间,防撞号,进患者键);
组织树**只在 yaml**(最简一行,无 `org_units` 表);
**画像 / 召回计算零改动**
--- ---
## 1. 背景:误解 vs 目标 ## 1. 已敲定的设计(决策清单)
### 现状(误解) | # | 决策 |
- `tenant_id` = 品牌。瑞尔(`ba67e6cf…`)、瑞泰(`77057aed…`)是同一 host(`jvs-dw`)下的**两个 tenant** |---|---|
- 硬隔离墙焊在 `(host_id, tenant_id)`,品牌之间绝对互不可见。 | 1 | `tenant_id` = 集团,用**稳定 slug**(`ruier-grp`),各环境一致;**删掉写死的 DW UUID** |
- `clinic_id` 是散在 5 张表的**扁平字符串**,无实体、无层级。 | 2 | brand → `patients.source_unit`(**发证命名空间,不可变**),进患者唯一键 |
- 这套**够用但做窄了**:塞不下深组织、做不了集团跨品牌角色、品牌共享无法配置。 | 3 | 组织树在 **yaml**:只声明集团;品牌/诊所来自数据;无区域不声明;**不建 org_units 表** |
| 4 | 跨品牌共享 = **默认按 source_unit 隔离**(复刻现状),P1 可配置;**无独立策略对象** |
### 为什么品牌当 tenant 是误读了数据 | 5 | 功能权限**保持代码级**(`role → permissions`) |
DW 实测(`dw_group.fact_client_out`): | 6 | 纵深防御:**Prisma `$extends` + AsyncLocalStorage**(P1),RLS 作 P2+ |
- `(patient_id, brand)` 才是真唯一键;`patient_id` 单独不唯一,**472 个跨品牌撞号** | 7 | 登录契约:`host_id + group_id + user{id,name,role} + org_scope[]` |
- 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. ⭐ 迁移核心洞察:换槽位,不是加信息 ## 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**: 现状患者键 `UNIQUE(host, tenant_id, external_id)`,`tenant_id` 槽里装的就是 brand。
目标只是**把 brand 挪到 `source_unit` 列、tenant_id 槽换装集团**:
| | host_id | tenant_id | brand | external_id | | | host_id | tenant_id | source_unit | external_id |
|---|---|---|---|---| |---|---|---|---|---|
| 现状 | host | **品牌UUID** | (无) | 患者号 | | 现状 | host | 品牌UUID | (无) | 患者号 |
| 目标 | host | **集团UUID** | **品牌** | 患者号 | | 目标 | host | **集团slug** | **品牌** | 患者号 |
**键的信息量完全相同**(host + brand + external_id),只是重新贴标签。所以: **键的信息量完全相同**(host + brand + external_id),只是重贴标签。→ **不发生患者合并**,472 撞号照样靠 source_unit 分开,迁移机械可逆。
- **不发生任何患者合并**;472 撞号照样靠 brand 列分开。 `source_unit` 定义为**"发证命名空间"**(当初哪个系统发的患者号,历史不可变)——即便诊所将来改挂品牌,键也不动。
- 迁移是**机械、可逆、低风险**的列搬迁,不是数据融合。
这是整个方案能"安全落地"的根。
--- ---
## 3. 影响面总览 ## 3. ⭐ 关键发现:画像 & 召回计算零改动
| 层 | 改动性质 | 工作量 | | 驱动 | 现状 | tenant=集团后 |
|---|---|---| |---|---|---|
| Schema | 加 `patients.brand` 列 + 改患者唯一键 + 新增 `org_units` 表 + 子表 tenant_id 重映射 | 中 | | `runAllForHost` 调用方(`recompute-plans.cli:71` / `sync-incremental.cli:207` / `scheduler:137` / `reconcile:52`) | `distinct:['tenantId']` + 循环,每 tenant 跑一次 | distinct 收成 `{ruier-grp}` → 一次循环覆盖全集团,**自动适配,零改动** |
| 摄入路由 | manifest 切静态 group + 新增 brand 捕获(resolver 已支持) | 小 | | persona `recompute({patientId})` | per-patient | 不变(按 patient_id) |
| 认证/scope | token 加 `orgUnitSubtree` + `brandSharingPolicy`;scope 扩形 | 中 | | 召回 SQL `WHERE p.tenant_id=scope.tenantId`(①隔离闸) | scope 过滤 | 值变集团即可,**逻辑不变** |
| 查询隔离 | tenant→集团;加品牌共享策略过滤;clinic 扁平→org 子树 | 中大 | | 一切计算 | 按 `patient_id`(全局唯一 UUID) | 王辰/王文彦本就不同 id,合 tenant 不影响每患者计算 |
| 纵深防御 | 新增 Prisma 中间件 / RLS(墙变软,必须补) | 中 |
| clinic/前端 | clinic_id 挂 org_units;前端 clinic 筛选→组织树 | 中 |
| 数据迁移 | 2 品牌 tenant 合 1 集团 tenant + brand 回填 | 中(一次性) |
---
## 4. Schema 改造
### 4.1 新增 `patients.source_unit` 列(通用命名空间限定符,**不写死 brand**) **摄入→facts→画像→召回→plan 这条计算链,逻辑全不动**,只继承新的 tenant 值。
> ⚠️ 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` 只是**分类标签**。 **唯一迁移前置校验**:`patient_facts` 偏唯一 `(host,tenant,subject_id) WHERE active` —— 审 `subject_id` 跨品牌不撞(撞则 brand 限定);`followup_plans (host,tenant,patient_id)` 的 patient_id 是 UUID,安全。
> 所以**任意深度、任意形状、同 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. 摄入路由改造(改动最小) ## 4. 真正要改的(集中、最小)
`tenant-resolver.ts` 已内置 4 种模式,其中 **mode 4 `StaticTenantResolver`** 正好是"静态 group"。 ### 4.1 Schema
- `patients.source_unit`(nullable)+ 唯一键 `(host, tenant, source_unit, external_id)`
- **不加** org_units 表;子表**不冗余** source_unit。
### 现状(`data/jvs-dw/manifest.yaml`) ### 4.2 摄入(`data/jvs-dw/manifest.yaml` + 解析)
```yaml ```yaml
tenant_field: brand # → FieldMapTenantResolver,按 brand 路由 # 删掉(写死 UUID 的旧路由):
tenant_map: # tenant_field: brand
瑞尔: ba67e6cf30dc4f9c9c46adef188bbd04 # tenant_map: { 瑞尔: ba67e6cf…, 瑞泰: 77057aed… }
瑞泰: 77057aed269f4a14957ae0ad0eff359a
# 换成:
identity_namespace_field: brand # 患者命名空间(防撞号)取 brand 列
org_tree:
group: { id: ruier-grp, name: 瑞尔集团 } # 集团=tenant_id,稳定 slug;就这一行
# 单集团:所有 brand 默认归此;品牌/诊所来自数据;无区域不声明
cohort: cohort:
patient_key_column: patient_id tenant_key_column: brand # 保留:cohort 仍按 (patient_id,brand) 防撞号取数
tenant_key_column: 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`
### 集团 ID 哪里来 —— 三种情况 ### 4.3 去写死 UUID → slug(4 处)
| 情况 | manifest 配置 | | 文件 | 改 |
|---|---| |---|---|
| 宿主数据**有** group_id | `tenant_field: group_id`(pass-through,FieldPassThroughResolver) | | `manifest.yaml:54-55` | 删 tenant_map(见 4.2) |
| 宿主**没有**(jvs-dw 只有 brand) | `tenant_field: brand` + `tenant_map: {瑞尔→集团UUID, 瑞泰→集团UUID}`(两品牌映同一新发集团) | | `auth.service.ts:235,244` | mock 两预设 tenantId → `'ruier-grp'`,各带 `sourceUnit: '瑞尔'/'瑞泰'` |
| 都没有、归属未知 | **无法路由,必须先定义** —— 集团归属是业务事实,接新 host 时必填 | | `weixin-aibot.service.ts:66` | fallback `|| 'ruier-grp'` |
| `seed.ts:7-8` | 注释更新 |
> 集团 UUID **新发**,不复用品牌 UUID(避免语义混淆)。
> 多集团 host:`tenant_map` 里不同品牌映不同集团即可(如 `{A→集团1, B→集团1, C→集团2}`)。 > 顺带:`pull.orchestrator.ts:160` 的 `t_default/t_demo` 另一套约定,一并收口到 slug。
### 目标(jvs-dw,宿主无 group_id) ### 4.4 隔离保持(合 tenant 后,补 source_unit 默认过滤的少数面)
```yaml 合并后,**plan 列表仍靠 clinic 过滤隔离**(用户 clinicIds 是本品牌诊所);但**不按 clinic 过滤的面**会泄漏到另一品牌,需补 `source_unit` 默认过滤(= 只看本品牌,复刻现状):
tenant_field: brand # 复用 FieldMapTenantResolver,但 map 改成 →集团 1. **患者搜索 / 列表**(`patient.service.ts`)— 按 (host,tenant) 查,不带 clinic;
tenant_map: 2. **召回池 NULL-clinic 工单**(`buildListWhere` pool 的 `targetClinicId IS NULL` 分支);
瑞尔: <集团UUID> # 两品牌都映到同一个新发集团 3. **按 id 直取患者/plan 的 guard**(`plan.service.ts:353``tenantId===scope.tenantId` 校验,补 source_unit)。
瑞泰: <集团UUID> > scope 加一个 `sourceUnits: string[]`(用户所属品牌,登录派生);默认过滤 `IN sourceUnits`。
identity_namespace_field: brand # 【新增】把 brand 当独立命名空间捕获 → patients.source_unit
cohort: ### 4.5 迁移(一次性,可逆)
patient_key_column: patient_id 1.`patients.source_unit`(nullable);
tenant_key_column: brand # cohort 仍按 (patient_id, brand) 防撞号取数,不变 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 耦合);
- `manifest.schema.ts`:新增可选 `identity_namespace_field` 6. 回归:全量重算 plan/persona,`plansUnchanged` 99%+(同上次部署验证法)。
- `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. 查询隔离 + 品牌共享策略 + 纵深防御 ## 5. 分期
### 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 改造 | 阶段 | 内容 | 结果 | 用 org_units? |
### 现状
- 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=集团跑通,行为同现状 | ❌ | | **P0** | §4.1–4.5 | tenant=集团 slug、源 UUID 消失、**行为与现状一致**(品牌仍隔离) | ❌ |
| **P1 隔离** | 集团级**品牌共享策略**(默认 closed,一个配置)+ Prisma `$extends` 兜底 | 墙在集团、可配置共享、有纵深防御 | ❌ | | **P1** | source_unit 过滤**可配置**(集团可开跨品牌)+ Prisma `$extends` 兜底 + 登录契约 `org_scope`→clinicIds 展开 | 墙在集团、共享可配、有纵深防御 | ❌ |
| **P2 组织(按需)** | `org_units` 树 + clinic 挂叶 + **数据范围迁 org 子树**(替掉 PLAN_VIEW_ALL 逃生门)+ rollup + 前端组织树 | 集团/品牌/区域/诊所层级角色 + 任意层级 rollup | ✅ | | **P2(按需)** | yaml org_tree 加**区域**等多级 + 数据范围迁 org 子树 + rollup + 前端树 | 层级角色 / 任意层级汇总 | yaml(内存树,仍不建表;除非要运行时编辑/SQL rollup) |
| **P3(独立)** | MPI:姓名+生日+性别(+身份证)跨品牌归一 golden 客户 | 集团级统一患者 | — | | **P3(独立)** | MPI:姓名+生日+性别(+身份证)跨品牌归一 golden 客户 | 集团级统一患者 | — |
- **P0+P1 不需要 org_units**,只用 `source_unit` 字符串列 + 集团级一个共享策略 + 维持现状 clinicIds 扁平权限,即满足"租户=集团、集团数据隔离(跟原 SaaS 一致)、品牌共享可配置"的核心目标。 P0 即达成"租户=集团、集团数据隔离(跟原 SaaS 一致)、写死 UUID 消除";P1 让共享可配 + 兜底;P2/P3 按需。
- **P2 才引入 org_units**,且只在真要"多级组织 / 区域层级数据范围 / 任意层级 rollup"时做。加它不用回头改 P0/P1。
- P3 是另一个项目(对齐 CDP)。
--- ---
## 11. 待决策 / 开放问题 ## 6. 文件改动清单(P0 精确对照)
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 实现。 | `prisma/schema.prisma` | `patients.source_unit` + 唯一键 |
4. **集团 UUID 谁定**?→ **✅ 已定:新发**集团 UUID,不复用品牌 UUID;品牌→集团归属在 manifest `tenant_map` 配置。 | `prisma/migrations/*` | 加列 / 回填 / 改键 / 重映射 tenant_id→slug |
5. ~~纵深防御选哪个?~~ **✅ 已定:Prisma `$extends` + AsyncLocalStorage**,RLS 作 P2+ 硬化(见 §6.3)。 | `data/jvs-dw/manifest.yaml` | 删 tenant_map;加 `org_tree.group` + `identity_namespace_field` |
6. **权限模型**(见 §7.5):功能权限保持代码级;数据范围 P2 迁 org 子树;数据共享策略 P1 加集团级配置。**✅ 已定方向。** | `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. 确认 **瑞尔、瑞泰都归这一个集团**
| `prisma/schema.prisma` | `patients.source_unit` + 唯一键;新增 `model OrgUnit`;子表 tenant 语义 | 3. P0 合并后,**瑞尔/瑞泰默认保持隔离**(我按这个做 = 复刻现状),对吗?
| `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 树 |
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