# 数据分层与元数据策略

> **最后更新**: 2026-05-16
> **定位**: 项目级数据架构原则。所有新建模块的数据设计必须遵循本文档的分层与"是否立元数据表"决策。
> **关联**:
> - `04-database-architecture.md` — multi-schema 划分、命名约定、标准字段（本文档与之正交，关注"分层与抽象边界"）
> - `02-backend-architecture.md` — 后端三层（Core/Engines/Modules），与本文档的数据层正交

---

## 1. 总原则

所有业务数据按业界事实标准（SAP / Salesforce / NetSuite）分**三层**，**默认不立第四层（元数据驱动层）**。

```
L3  Transactional Data    业务/交易数据（流程产生、引用主数据）
     ↑
L2  Domain Master Data    领域主数据（模块专属、慢变）
     ↑
L1  Platform Master Data  平台公共数据（跨模块复用）
     ├── L1a Master       Customer / Supplier / Partner / Location / ...
     ├── L1b Reference    Currency / Country / LabelType / ...（字典 / 参考数据）
     └── L1c Common       Attachment 多态 / Note 多态（按需立）
```

**依赖方向单向**：L1 不依赖 L2/L3；L2 只依赖 L1；L3 依赖 L1 + L2。反向引用必须走事件流（见 §3.2），不走 FK。**例外**：L2 由 L3 流程批量出生时，可持有 L3 FK（见 §3.3 — 如 `RobotUnit.purchaseOrderId`）。

---

## 2. 三层定义与归属判据

### 2.1 L1 — 平台公共数据（跨模块复用）

**判据**：被 ≥ 2 个业务模块引用 / 被引用、生命周期独立于任何单一业务流程。

**典型**：
- L1a 主数据：`Customer` / `Supplier` / `Partner` / `Location`
- L1b 字典：`Currency` / `Country` / `LabelType` / `TariffType`
- L1c 通用：`Attachment`（多态附件）

**物理位置**：`prisma/schema/platform_master.prisma` + Postgres schema `platform_master`。

**注意**：
- 即使当前只有一个模块引用，但**已经能预见**第二个模块要复用（如 robot-manager 用 Customer，将来销售模块也要用），仍然归 L1
- 反例：`RobotModel` / `RobotSku` 只 robot 自己用，**不上 L1**

### 2.2 L2 — 领域主数据（模块专属、慢变）

**判据**：模块的核心实体、不变属性、生命周期跨业务流程稳定。

**典型**：
- `Model` / `Sku` — 机器人型号与变体
- `RobotUnit` — 机器人个体（**仅不变属性**：ffsn / sku / 出厂 PO；状态字段不在这里）

**物理位置**：模块自己的 prisma schema，如 `robot_manager.prisma`。

**关键规则**：
- L2 实体的"当前状态"**必须**剥离到 L3 事件 + 物化 Snapshot（见 §3.1）
- L2 表本身只放**不变属性**，否则会污染所有 join 它的查询

### 2.3 L3 — 业务/交易数据（流程产生）

**判据**：业务流程产生的记录、有时间戳、引用主数据。

**典型**：
- `PurchaseOrder` / `SalesOrder` / `DeliveryRequest` / `PaymentRecord` / `ServiceTicket`
- `RobotLifecycleEvent`（事件源）/ `RobotUnitSnapshot`（物化读模型）

**物理位置**：跟 L2 同 schema（同模块内）。

---

## 3. 两个必须遵守的子规则

### 3.1 核心实体的"状态 vs 不变属性"必须分离

**规则**：任何被多个模块引用的 L2 实体，**禁止**把"会变的状态"挂在实体本身。

- ❌ 反例：`RobotUnit.currentStatus / currentLocationId / currentCustomerId`
- ✅ 正例：
  - `RobotUnit` 只放不变属性（ffsn / skuId / purchaseOrderId / manufactureDate）
  - `RobotLifecycleEvent` 记录所有状态变化（事件源）
  - `RobotUnitSnapshot` 物化"当前状态"（同事务刷，CQRS read model）

**理由**：
- 外部模块引用 L2 实体时，状态变更不会污染其查询
- 完整历史可重放、可审计
- 跨实体追溯只需一句 SQL（"这台机器人的全部历史"）

### 3.2 跨模块写 L2 实体必须走事件流

**规则**：模块 A 想写模块 B 的 L2 实体（如销售模块给 RobotUnit 绑定 SalesOrderId），**禁止**直接 import B 的 prisma client 直写；必须 emit 事件，由 B 自己消费。

- ❌ 反例：`salesModule.prisma.robotUnit.update({...})`
- ✅ 正例：`salesModule.emitEvent({type: 'sales_reserved', robotUnitId, salesOrderId})` → `robotManagerModule.subscribe('sales_reserved')` → 自己更新 snapshot

**理由**：
- L2 实体的写权责归位（始终只被自身模块写）
- 跨模块竞态消失
- 事件天然可审计、可重放、可异步

### 3.3 例外：L2 实体的"出生事件"可持有 L3 FK

**规则**：L2 实体如果是由某个 L3 业务流程**批量创建**的（"L3 出生 L2"模式），允许 L2 表持有该 L3 表的 FK 引用。

**适用场景**（满足以下**全部**才算）：
1. L2 实体不是手动一个个建，而是 L3 流程**批量预生成**（如采购单创建时按 quantity 预留 N 个 unit）
2. L3 实体是 L2 实体的"出生 batch / 来源记录"，业务上不可解耦（去掉 L3 ref，L2 历史就丢了"我从哪来"）
3. L3 → L2 是 1 → N 关系，L2 创建后**不会**改变这个 ref（出生属性，非状态）

**典型例子**：
- `RobotUnit.purchaseOrderId / purchaseOrderLineId` → `PurchaseOrder` / `PurchaseOrderLine`
  - 占位 SN 流程：PO 创建时按 line.quantity 批量生成 N 个占位 unit
  - PO 是 unit 的"出生事件"，不是后期关联的业务流程
  - unit 创建后 PO ref 永不变（即使 PO 软删，unit 仍记得自己从哪个 PO 来 → onDelete: SetNull）

**反例**（仍禁止）：
- `RobotUnit.currentSalesOrderId` ❌ — sales order 不是 unit 的出生事件，是后期销售流程
  - 正确做法：放 `RobotUnitSnapshot.currentSalesOrderId`（状态投影，事件流刷新）

**判据**：问 "去掉这个 ref，L2 实体的'身世'还完整吗？"
- 完整 → ref 是状态，挪 Snapshot
- 不完整 → ref 是出生属性，可留 L2

**理由**：批量出生模式下走事件流反而失真——出生事件本身就是同事务原子创建，没有"先有 unit 再 emit 事件创 PO ref"的物理顺序。

---

## 4. 默认不立 L4（元数据驱动层）

### 4.1 什么是 L4

元数据驱动（Metadata-Driven）= 把"业务规则"从代码挪到数据：
- `FieldDefinition` — 字段定义存表里，不在 schema 里
- `StatusDefinition` — 状态枚举存表里，不在 enum 里
- `WorkflowDefinition` — 状态转换图存表里
- `GuardDefinition` — 校验规则存表里

业务实体落地变成**强类型骨架字段 + JSONB metadata + 运行时 FieldDef 校验渲染**。

### 4.2 元数据驱动的原始价值（和它崩塌的前提）

SAP / Salesforce 搞元数据驱动的根本原因只有一个：**绕过"代码变更要工程团队 + 部署周期是周/月级"的瓶颈**。

派生好处：
1. 业务运营不用等工程师就能改字段
2. 多租户：不同客户能有不同字段集
3. 不重启就生效

这些好处的**共同前提**是：代码变更慢、贵、需要工程师。

### 4.3 AI 时代前提崩了

| 维度 | 传统 | AI 时代 |
|---|---|---|
| 加一个字段 | 3 天 | 30 分钟（prisma 加列 + 前端表单 + migration）|
| 改状态机 | 1 周 | 1 小时 |
| 工程师是瓶颈 | 是 | **不是** |

元数据驱动是为了绕开工程师瓶颈——瓶颈消失了，绕道变成**净亏损**。

### 4.4 元数据驱动的成本（净亏损部分）

1. **类型安全损失**：`metadata` JSONB 让 TypeScript 失去类型推断，IDE 没补全，重构没保障
2. **运行时性能开销**：每次查询 join FieldDefinition、动态校验、动态渲染
3. **AI 协作效率下降**：AI 读硬编码 prisma schema 一目了然；读 FieldDefinition 表得先翻 DB 才知道字段长什么样——**元数据驱动是反 AI 协作的**
4. **调试链路长**：bug 来自数据还是代码？两层都得查
5. **测试组合爆炸**：不只测代码，还要测各种元数据配置组合
6. **新人/新 AI 上手陡**：看代码看不出业务实体长什么样，得先看运行时数据

### 4.5 默认规则

**默认不立 L4**。具体含义：
- 字段全部强类型落 prisma schema
- 状态用 Prisma enum（i18n 标签放前端 locale 文件）
- 状态转换硬编码：`const transitions: Record<from, to[]> = {...}`
- 控制门用 service 层方法：`checkG1ToShipReady()` 这种，逻辑清晰可读
- `metadata` JSONB 仅作"未定型字段的临时落地"兜底，**不立配套元数据定义表**

### 4.6 加回 L4 的明确触发条件（满足任一）

将来满足以下**任一条件**时再加 L4 元数据表：

1. **模块对外 SaaS 化**（卖给不同客户/租户）→ `FieldDefinition` 必须有，因为不同租户字段集不同
2. **业务运营要在生产 UI 自助加字段**（不走部署）→ `FieldDefinition` + 动态表单
3. **状态机需要租户级定制**（不同客户 stage 不一样）→ `StatusDefinition`
4. **跨 ≥3 个模块出现"字段定义重复编写"**（指相同业务字段在多个模块各写一遍校验/渲染）→ 提取 `FieldDefinition` 复用

**前 3 个都是产品形态变化，不是工程方便性**。第 4 个是真实复用需求。

**禁止**以下理由立 L4：
- ❌ "未来可能要灵活" — YAGNI
- ❌ "看 SAP/Salesforce 都这么做" — 不同产品定位
- ❌ "字段会经常变" — AI 时代加字段 10 分钟，不需要绕道

---

## 5. 字典 / 参考数据（L1b）的特殊处理

字典 / 参考数据**不是元数据**——它是"业务数据的一种"。枚举值本身是业务事实（"当前支持的币种就这些"），仍然要表。

### 5.1 立独立表 vs 合并到通用 Dictionary

**判据**：
- **独立表**（`Currency` / `Country` / `LabelType` / `UnitOfMeasure`）：有专属字段（如 `Currency.decimals` / `Country.iso3`）或被强类型 FK 频繁引用
- **合并 `Dictionary(category, code, label)`**：纯枚举无专属字段、引用次数少（如 `DeclarationType` / `TariffType` / `Industry` / `ServiceIssueType`）

**理由**：
- 全独立表太啰嗦（DeclarationType 5 个值不值得一张表）
- 全合并 Dictionary 丢类型安全（Currency.decimals 塞 metadata 不优雅）
- 混合兼得

---

## 6. RobotUnitSnapshot 模式（L2 状态物化的标准做法）

凡是 L2 核心实体满足 §3.1 "状态与不变属性分离"的，**物化 Snapshot 是默认选择**（不要做 Postgres VIEW）。

### 6.1 为什么物化表 vs VIEW

| 维度 | VIEW | 物化表 |
|---|---|---|
| 实时性 | 永远一致 | 同事务刷新，最终一致（毫秒级） |
| 查询性能 | 慢（每次聚合）| 快（0 join） |
| 写复杂度 | 0 | service 层多一行 update |
| 规模扩展 | 千级以下 | 万级以上 |

业界做法：所有事件溯源系统（Eventuate / Axon / AWS EventBridge + DynamoDB projection）都是物化表。

### 6.2 一致性保证

snapshot 刷新必须跟事件 insert **同事务**：

```typescript
await prisma.$transaction([
  prisma.robotLifecycleEvent.create({ data: event }),
  prisma.robotUnitSnapshot.upsert({ where: { robotUnitId }, ... })
])
```

事件成功 + snapshot 失败 = 事务回滚 = 两者都没发生。

---

## 7. 多态表的克制使用

`Attachment` / `Note` / `Tag` 这类多态表（`owner_type` + `owner_id`）有用，但**只在多模块复用真实发生时立**。

- ✅ `Attachment` 已经多个模块要用 → 立
- ❌ `Note` / `Tag` 当前只 robot 模块需要 → **不立**，各表加 `notes String?` 字段；等第二个模块需要时再立

理由：多态表牺牲了类型 FK 完整性（DB 层无法约束 `owner_id` 真的存在），为了"未来可能复用"提前付这个代价不划算。

---

## 8. 跟现有 standards 的关系

| Standard | 关注点 | 跟本文档的边界 |
|---|---|---|
| `04-database-architecture.md` | Multi-schema 划分、命名约定、标准字段、迁移流程 | 本文档关注"分层与抽象边界"，04 关注"落地约定" |
| `02-backend-architecture.md` | 后端 Core/Engines/Modules 三层 | 那是代码分层；本文档是数据分层；正交 |
| `00-product-philosophy.md` | 二常驻 + 一临时 | 元规则；本文档遵从 |

---

## 9. 历史背景与决策记录

- **2026-05-16 立此文档**：起因是 robot-manager 业务方迭代 v3/v5 暴露"Customer/Supplier/Location 跨模块复用"和"RobotUnit 状态混杂"两个问题。讨论中提出 4 层架构（含 L4 元数据驱动），用户提出"AI 时代加字段成本接近零，元数据表还要不要？"——重新评估 L4 的成立前提，确认其原始价值（绕开工程师瓶颈）已失效，决定**默认不立 L4**，落地此 standard。
- **关联讨论沉淀**：`docs/modules/robot-manager/business-analysis/数据分层方案.md`（robot-manager 模块按此 standard 的具体实例化）
