Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/adr-0117-owning-business-unit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
---

docs(adr): ADR-0117 记录级业务单元归属(owning business unit)提案

仅新增一份 ADR 文档,不改动任何包的代码或公开 API,因此不发布任何版本。

提案内容:给记录归属补上缺失的中间一层。今天一条记录只有 `owner_id`(人)与
`organization_id`(租户墙),"属于哪个部门/哪个法人"是从所有者推导的(层级深度
档位最终编译成 `owner_id IN (…)`),带来归属漂移、无所有者语义的数据没有归属、
报表没有可聚合列三个问题。

ADR 提出 `owning_business_unit_id` 记录戳(挂靠既有 `ownership` 轴、新增
`business_unit` 一档)、三档盖章策略、`org_id` 由 BU 链推导的不变量、复用
`allowTransfer` 的写入守卫,以及把 `IHierarchyScopeResolver` 从返回 owner id
列表改为返回谓词规格的破坏性契约变更。实现另行开 PR。
199 changes: 199 additions & 0 deletions docs/adr/0117-owning-business-unit-record-stamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# ADR-0117: 记录级业务单元归属(owning business unit)

- **状态**: Proposed(提案,待评审)
- **日期**: 2026-07-31
- **关联**: ADR-0057(BU 树与深度档位)、ADR-0090(岗位与任职锚点)、ADR-0091(授权时效)、
ADR-0103(`managedBy` 写策略)、ADR-0105(租户姿态与 org 作用域)
- **动因**: 集团管控场景需要"记录属于哪个部门/法人"成为结构事实,而不是从所有者推导

## 背景

### 现状:记录归属只有两个坐标,中间一层缺失

今天一条业务记录携带两个系统维护的归属标记:

- `owner_id` —— 人。由 registry 按 `ownership` 轴自动注入
(`packages/objectql/src/registry.ts` `applySystemFields`),SecurityPlugin 在插入时
盖章,改动受 `allowTransfer` / `modifyAllRecords` 约束。
- `organization_id` —— 租户墙。列无条件注入(`registry.ts:305-313`,除显式
`systemFields.tenant === false` / `tenancy.enabled === false` 外),值由多组织运行时
权威盖章。

**中间的一层——"这条记录属于哪个部门 / 哪个法人"——不存在。** 它今天是从所有者
*推导*的:层级深度档位(`unit` / `unit_and_below` / `own_and_reports`)最终编译成
`owner_id IN (…)`(`packages/plugins/plugin-sharing/src/sharing-service.ts:47,151,214`),
即"这条记录归属于当前在该组织单元里的某个人"。

### 这个推导带来三个问题

1. **归属漂移**:所有者调动部门,其历史单据的部门归属无声跟随。对订单、凭证、工单
这类"钉在开出它的组织上"的记录,语义是错的——人走单不该走。
2. **无所有者语义的数据没有归属**:库存、设备台账、共享主数据属于组织而不属于个人,
owner 派生模型对它们无话可说。
3. **报表没有可聚合列**:按部门汇总成本、按法人出报表需要行上有一个能 `GROUP BY`
的列;`owner_id IN (一大串人)` 既不能用于聚合,谓词规模也是用户数量级。

### 业界坐标

- **Dataverse**:每条记录带 `owningbusinessunit`,与 `ownerid` 并存;表分
User/Team-owned 与 Organization-owned 两类——与本仓既有的
`ownership: 'user' | 'org' | 'none'`(`packages/spec/src/data/object.zod.ts:818`)
同构。
- **SAP**:凭证同时携带成本中心与公司代码,法定报表按公司代码出。

### 可复用的既有部件

| 部件 | 位置 |
|---|---|
| 记录归属轴声明 `ownership` | `packages/spec/src/data/object.zod.ts:800-823` |
| 系统字段注入管线 `applySystemFields` | `packages/objectql/src/registry.ts` |
| BU 树与子树遍历 | `packages/platform-objects/src/identity/sys-business-unit.object.ts:127`、`packages/plugins/plugin-sharing/src/business-unit-graph.ts` |
| 用户主 BU 投影(**注意:这是用户属性,不是记录戳**) | `packages/platform-objects/src/identity/sys-user.object.ts:690`、`plugin-sharing/src/primary-bu-projection.ts` |
| 私有+层级对象必须带 org 列的门 | EE `hierarchy/tenant-scope-gate.ts` |

## 决策

### D1 字段与注入:挂靠既有 `ownership` 轴,新增一档

字段名 **`owning_business_unit_id`**(lookup → `sys_business_unit`)。注入规则由对象级
`ownership` 决定,该枚举**扩展一档**:

| `ownership` | `owner_id` | `owning_business_unit_id` | 适用 |
|---|---|---|---|
| `user`(默认) | ✅ | ✅ | 有责任人的业务对象(订单、工单、商机) |
| **`business_unit`**(新) | ❌ | ✅ | 属于组织单元而非个人(库存、设备台账、部门预算) |
| `org` | ❌ | ❌ | 全组织目录/编目表 |
| `none` | ❌ | ❌ | 连接表 |

`managedBy` 平台表与 `sys_` 命名空间照现有规则跳过注入。

### D2 盖章策略:按对象声明,默认"钉死"

对象可声明 `owningBusinessUnit.policy`:

| 策略 | 语义 | 默认适用 |
|---|---|---|
| **`pinned`(默认)** | 插入时盖章,此后不随所有者变动 | 单据、凭证、工单——ERP 语义 |
| `follow_owner` | 所有者变更时重算为新所有者的主 BU | CRM 语义(客户随销售转移) |
| `transferable` | 仅允许显式重新指派,不自动重算 | 需要人工调拨归属的对象 |

**默认值来源**(插入时,按序):① 调用方显式传入且通过 D4 守卫;② 所有者的
`primary_business_unit_id` 投影;③ 若对象声明 `owningBusinessUnit.required = true`
则**拒绝插入**并给出可操作错误("当前账号无主业务单元,请联系管理员分配"),
否则留空。

### D3 不变量:`org_id` 由 BU 链推导

**`record.organization_id == sys_business_unit(owning_business_unit_id).organization_id`**

盖章中间件在服务端推导并校验;客户端传入的值一律被覆盖而非采信(与
`organization_id` 现有的权威盖章纪律一致)。两个戳因此不可能互相矛盾,也不存在
"把记录塞进别的公司"的写入路径。

### D4 写入守卫:归属变更是转移类操作

- 改动 `owning_business_unit_id` 复用 **`allowTransfer`**(或 `modifyAllRecords`)
——语义上与改 `owner_id` 同类:都是重新指派记录归属;
- 且**目标 BU 必须落在调用方的写作用域内**,否则拒绝。这防止"把记录推到自己看不见
的地方"或"从别人手里拉过来"两种越权形态;
- `pinned` 策略下,即便持有 `allowTransfer` 也不允许改(策略优先于权限位)。

### D5 法人归属:解析规则,不新增列 ⚠️ *偏离父 ADR,需评审*

"这条记录属于哪个法人"的答案由部署形态决定:法人建成组织时是 `organization_id`;
法人建成 `kind='company'` 的 BU 节点时,是 `owning_business_unit_id` 沿树向上最近的
company 祖先。本 ADR **提供框架侧解析helper,不新增物化列**。

理由:物化列若要同时指向组织行与 BU 行,只能做成多态外键,代价大于收益;而两种
部署形态下答案都可由既有两个戳解析得出。**这偏离了上游"盖章时物化"的表述**,
若报表性能实测需要,可后续按 `primary_business_unit_id` 的先例增加去规范化投影列。
提交评审确认。

### D6 契约变更:解析器返回"谓词规格"而非 owner id 列表 💥 *破坏性*

现契约只能表达所有者集合:

```ts
// packages/spec/src/contracts/sharing-service.ts:315-320
resolveOwnerIds(ctx, scope): Promise<string[]>
```

改为返回**按哪个戳过滤**的规格,由 `sharing-service` 编译成对应列的谓词:

```ts
type ScopeResolution =
| { field: 'owner_id'; ids: string[] } // own 档保留
| { field: 'owning_business_unit_id'; ids: string[] } // unit / unit_and_below
| { field: 'organization_id'; ids: string[] } // 公司轴(后续)
resolveScope(ctx, scope): Promise<ScopeResolution>
```

`sharing-service.ts:47` 的 `OWNER_FIELD` 硬编码随之解除。这是**破坏性契约变更**,
唯一实现方是企业版层级解析器,需与其同步发布。收益:谓词从"数千用户"缩短为
"几十个 BU",归属漂移消除,且为公司轴(`organization_id`)留出同形位置。

### D7 豁免:哪些对象不参与

`ownership` 为 `org` / `none` 的对象、显式关闭租户列的对象、以及被声明为集团集中
管控的配置/主数据对象,**不注入该列,也不受 BU 轴过滤约束**——集团统一维护的科目
表、客商不应因"调用方的 BU 集合不含集团根"而被挡掉。豁免清单必须显式维护;遗漏的
表现形式是"子公司看不到集团主数据"。

### D8 迁移:回填 + 启用门(fail-closed)

存量记录该列为空。空值在 BU 轴过滤下不可见——若直接开启,会把历史数据从所有
unit 档用户眼前抹掉。因此:

1. 提供回填作业:按 `pinned` 语义从记录所有者当时的主 BU 推导(无法推导的留空并
计数);
2. **启用门**:BU 轴过滤在某对象上启用前,校验该对象无未回填行,否则拒绝启用并
报告待回填数量。**不做"空值回退到 owner 判定"的兼容层**——那会把迁移期变成
两套并存的事实契约(AGENTS.md「契约优先」)。

### D9 开源 / 企业边界

- **开源(framework)**:列的定义与注入、盖章中间件、D3 不变量、D4 守卫、回填与
启用门。结构与归属是所有部署都该有的能力;
- **企业版**:由哪些 BU 构成作用域集合的**层级解析**(`hierarchySecurity`)。

这与"结构开放、隔离与层级付费"的既有边界一致,也让无企业版许可的部署照样获得
正确的部门归属与报表列。

### D10 命名纪律

**不得复用 `primary_business_unit_id`**。它是 `sys_user` 上的用户属性(由
`sys_business_unit_member.is_primary` 投影而来),与本 ADR 的记录戳是不同对象上的
不同概念;同名会造成难以察觉的语义混淆。

## 后果

**正面**

- 部门/法人归属成为**结构事实**:报表可直接按列聚合,审计可直接回答"这条记录属于
哪个组织单元",不再依赖"它的所有者现在在哪";
- **归属漂移消除**:所有者调动不再改变单据的组织归属(`pinned`),而确需跟随的
对象仍可声明 `follow_owner`——两种业务语义都能表达;
- **谓词大幅缩短**:层级档位从 `owner_id IN (数千用户)` 变为
`owning_business_unit_id IN (几十个 BU)`,并可退役 owner 集合的展开与缓存;
- **无所有者的数据获得归属**:`ownership: 'business_unit'` 覆盖库存、台账这类对象;
- 为公司轴(`organization_id` 过滤)留出同形位置,无需再改一次契约。

**负面 / 成本**

- **D6 是破坏性契约变更**,必须与企业版层级解析器同步发布;
- **迁移有真实工作量**:回填作业 + 启用门 + 无法推导行的人工处理;
- 新增一个对象级声明(`owningBusinessUnit`)与一档 `ownership` 值,元数据面变宽;
- D7 豁免清单需长期维护,遗漏的故障形态(子公司看不到集团主数据)在测试里不显眼,
需要专门的 conformance 用例守住。

## 未决问题(提交评审)

1. **D5 的偏离**:法人归属做成解析规则而非物化列,是否接受?(上游表述为"盖章时
物化"。)若坚持物化,需先决定多态外键 vs 两个可空列。
2. **`ownership: 'business_unit'` 是否值得新增**,还是让这类对象用 `ownership:'user'`
并接受一个名义所有者?(Dataverse 没有对应档位;ERP 场景有真实需求。)
3. **`pinned` 作为默认值**是否正确——平台既有对象以 CRM 形态居多,默认 `pinned`
会与它们的直觉相反;但对新建的 ERP 类对象,默认 `follow_owner` 更危险。
4. **D8 启用门的粒度**:按对象启用,还是按部署一次性启用?前者迁移更平滑,后者
语义更简单。
5. **是否需要独立的权限位**(如 `allowChangeOwningUnit`)而不是复用 `allowTransfer`。
Loading