Files
workspace/code/fms/FMS删除规则引擎设计.md
T
2026-09-17 23:13:31 +08:00

336 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FMS 删除规则设计
> 版本:v2.0(取代 v1)。关联:`FMS新系统核心表结构设计.md`(§9 模块关系)、`FMS模块设计评审.md`。
> 一句话:**删之前先查一查,查不过就拒绝;查什么、怎么查,由 IT 配置,不写代码。**
>
> v2 相对 v1 的变化(动因见 `FMS模块设计评审.md` 问题一/问题三):
> - **引用规则退出 `s_rule`**:原「勾引用」由 `s_relation.b_on_delete = 'restrict'` 表达,关系面板只配一列;
> - **`s_rule` 收敛为模块级**:去掉 `b_scope_type`、`b_scope_id`(拼接串)、`b_hook`,加 `b_kind` 显式区分勾条件与写 SQL;
> - **方向语义统一**:四个取值都描述「删除**被引用方**时,**引用方**怎么办」,被引用方由基数判定,不再有删源/删目标两套口径;
> - **默认值 `restrict`**:被引用即不可删成为默认,多数关系零配置。
> - 库中无存量数据,全部按新结构直接建,不做迁移。
## 这个设计解决什么问题
业务上「能不能删」的规矩五花八门:
- 单据还在审核中,不许删
- 已经销账了,不许删
- 核销过钱的,不许删
- 费用已经做到账单里了,不许删
- 删报销单时,下面的费用明细要跟着一起删
以前这些规矩要么写死在代码里(改一条要发版),要么干脆没有(误删了才发现)。现在的做法:**IT 在界面上配置,配完立即生效**。
规矩分两类,配在两个地方:
- **记录自身什么状态不许删** → 模块上配「删除规则」(勾条件 / 写 SQL);
- **被别人引用着不许删、删主单连带删明细** → 关系上选一个「删除行为」(一列四值,默认即防护)。
## 1. 整体图景
一个用户点「删除」,系统背后做四件事:
```
1. 找出连带要删的记录(删报销单 → 连费用明细一起)
2. 把配置的所有删除规则挨条查一遍
3. 任何一条不过 → 整批拒绝,告诉用户为什么
4. 全过 → 先弹预览给用户确认,再一个事务里一起删
```
IT 要做的事只有两件:模块上**配条件规则**,关系上**选删除行为**。配的地方都在模块管理。
## 2. 模块规则:一条就是一句「什么情况不许删」
每条规则就是一句话:
> **当【什么条件】时,不许删,提示【什么话】**
规则有两种写法,按配的难度从易到难:
### 2.1 勾条件(日常用这个,占绝大多数)
在模块管理 → 「删除规则」tab 点「新增规则」,弹出来的界面和**查询配置长得一样**:
```
满足以下条件时,不许删除:
┌──────────┬────────┬─────────────────┐
│ 字段 │ 操作符 │ 值 │
│ 单据状态 │ 属于 │ [审核中, 已生效] │
│ 已销账 │ 等于 │ 1 │
└──────────┴────────┴─────────────────┘
```
- 字段下拉就是该模块字段定义里的字段
- 值可以直接填,也可以选另一个字段比(比如「已核销金额 大于 0」)
- 提示文案**不用写**——系统自动生成「单据 SKD-2026-001 状态为审核中,不能删除」
会配查询条件的人,零学习成本。
### 2.2 写 SQL(兜底,藏在角落)
界面勾不出来的判断——比如「核销金额加起来大于 0」这种要查别的表的——在规则类型的「高级」选项里直接写一句 SQL:
```sql
select b.b_id, b.b_no from bill b
join bill_detail d on d.bill_id = b.b_id
where b.b_id in (:ids) and d.writeoff > 0
```
约定就两条:`:ids` 会自动换成当前删的这批单据的 id;**查出来有结果就拦**。提示文案自己填,可以 `{b_no}` 这样引用查询结果的列。
平时用不到,会写的人自己找得到,不会打扰别人。谁改过这条 SQL、每次执行了什么,系统日志里全程有记录(内部系统,不做限制,只做留痕)。
(v1 的「勾引用」一节取消——引用保护挪到关系上,见 §3。)
## 3. 关系上的删除行为:`s_relation.b_on_delete`(一列四值)
统一语义:**删除「被引用方」记录时,「引用方」记录怎么办。**
谁是"被引用方"由基数决定,不需要额外配置:
| 基数 | 外键在 | 被引用方 |
| --- | --- | --- |
| `many_to_one` | 源字段 | 目标 |
| `one_to_many` | 目标字段 | 源 |
| `one_to_one` | 目标字段(约定) | 源 |
四个取值:
| 值 | 含义 | 典型场景 |
| --- | --- | --- |
| `restrict`(**默认**) | 仍有引用方记录时,拒绝删除被引用方 | 费用→客户:客户被费用引用,删不掉 |
| `cascade` | 引用方记录一起物理删除 | 报销单→费用明细:删单连带删明细 |
| `archive` | 引用方记录打归档标记,不物理删 | 删报销单,明细归档供账单追溯 |
| `none` | 不处理 | 日志→用户等确实无关的关联 |
几个例子(源→目标按基数标注被引用方):
| 关系 | 基数 | 被引用方 | b_on_delete | 效果 |
| --- | --- | --- | --- | --- |
| 费用 → 客户 | many_to_one | 客户 | restrict(默认) | 客户被费用引用时删不掉 |
| 费用类型 → 费用 | one_to_many | 费用类型 | restrict(默认) | 类型下有费用时删不掉 |
| 报销单 → 费用明细 | one_to_many | 报销单 | cascade | 删报销单连带删明细 |
| 报销单 → 费用明细(归档版) | one_to_many | 报销单 | archive | 删报销单,明细归档可追溯 |
设计要点:
- **默认即防护**:新关系默认 `restrict`,「主数据被引用不许删」这类最通用的规矩零配置;确实要放开才显式改 `none`。安全方向从「记得配防护」反转为「记得放开」
- **一列取代原来的三个栏位**:v1 的 `b_on_delete`(三值)+「有引用时拒绝删除」勾选(假列,实际生成 `s_rule` 行)+ 方向口径混用,合并为**一个真列**
- **取值即语义,无非法组合**:v1 可同时配出「cascade + 引用规则」(连带删但又不许删,自相矛盾);单列四值后该组合在类型层面不存在
- **基数不禁取值**:是否 `cascade` 是「被引用方是否独占拥有引用方」的业务判断,只有配置者知道;把 `cascade` 配在共享主数据的关系上属于配置错误,系统不做基数层面的硬拦(内部系统,配置留痕即可),界面在选 `cascade` / `archive` 时给出确认提示
- **`archive` 前提**:引用方表需有 `b_archived` + `b_archive_datetime` 通用列,保存时服务层校验列存在
连带展开的关键行为:
- **查连带时也过规则**:删报销单连带要删费用明细,但费用明细被账单明细引用(restrict 入边)→ 整个删除(包括报销单)被拒绝,提示定位到那笔明细和那个账单
- **一荣俱荣一损俱损**:任何一条卡住,整批都删不了,不存在删一半的中间状态
- 关系套关系(单→明细→附件)会自动一层层找下去;层数太多(比如配置成了环)会直接报配置错误,不会死循环
## 4. 删除的操作流程(业务用户视角)
列表页勾几行点删除,系统**先预览后真删**:
```
第一步:系统先算一遍
↓ 有问题? → 弹错误列表:「这 3 条审核中删不了」「这笔费用被账单引用」
↓ 没问题? → 弹确认框:「将删除 1 张报销单 + 3 条费用明细,确认?」
第二步:用户确认后才真删(删之前系统再快速查一遍,防止确认的几秒里有变化)
```
错误提示都是人话,不用猜。批量删 500 行时提示按规则归类显示,不会刷屏。
## 5. 配置入口在哪
**配规则**(IT 日常):
- 模块管理 → 「删除规则」tab → 新增:模块级条件规则,每条 3 个格子一分钟;改完立即生效,**不用发版**
- 模块管理 → 「模块关联」tab → 每条关系一列「删除行为」,默认"被引用时禁止删除";主子表关系显式选「连带删除」或「连带归档」
- 常见条件规则可以**复制到其他模块**(多选目标模块批量克隆)——v2 起全部规则都是模块级、结构同构,无例外;复制时校验目标模块存在条件里用到的字段,缺字段的目标模块在确认框里点名
**看全貌**(排查、盘点):
- 系统管理 → 「业务规则」:全系统所有模块级规则的列表——哪个模块的、什么类型、谁最后改的、什么时候。点行跳到对应模块
- 排查「这单为什么删不掉」:错误提示里带规则内容 → 全局列表找到它 → 看条件/SQL → 完事;被引用拦下的,提示里直接带引用方单号和关系来源
**谁可以配**:规则的增删改走权限控制(和菜单权限同体系)。看规则不受限。
## 6. 数据落库(给开发看的部分)
### 6.1 s_relation:删除行为列
权威表结构见 `FMS新系统核心表结构设计.md` §9,本节只列差异:
```sql
alter table dbo.s_relation add
b_on_delete varchar(20) not null default 'restrict';
-- restrict(默认) 被引用时禁止删除 / cascade 连带删除 / archive 连带归档 / none 不处理
```
- 语义、取值、被引用方判定见本文 §3
- `archive` 由服务层校验引用方表存在 `b_archived` / `b_archive_datetime` 列
- 引擎读取时只认 `b_canuse = 1` 的关系行
- 库中无存量数据,不需要从 v1 的 `none` 默认值做迁移;`fms_core.sql`、`fms_delete_rule.sql` 脚本需按本结构同步(现脚本默认值仍是 `none`)
### 6.2 s_rule:模块级规则表(v2 结构)
```sql
create table dbo.s_rule (
b_id bigint not null, -- 雪花主键(新增时前端经 /data/nextid 取号)
b_module_id varchar(50) not null, -- 所属模块编码
b_kind varchar(20) not null, -- condition 勾条件 / sql 写 SQL
b_predicate nvarchar(max) not null, -- condition=JSON AST(§6.3);sql=SQL 文本
b_message nvarchar(500) null, -- condition 可留空(引擎按条件自动生成);sql 必填
b_canuse tinyint not null default 1,
b_xh int not null default 0,-- 执行顺序(并列按 b_xh、b_id)
b_created_by varchar(50) null, -- 审计列由服务端维护,配置界面不提交
b_created_at datetime2 null,
b_updated_by varchar(50) null,
b_updated_at datetime2 null,
primary key (b_id)
);
create index ix_s_rule_module on dbo.s_rule (b_module_id, b_canuse, b_xh);
```
相对 v1 的变化与理由:
- **去掉 `b_scope_type` / `b_scope_id`**:规则只挂模块,`b_module_id` 直存模块编码。v1 的拼接串键(`源模块|源字段|目标模块|目标字段`)随之消失——它无法有效索引、无法校验、编码含 `|` 时错位,是明确的反模式
- **去掉 `b_hook`**:一期只有 `delete.pre` 一种挂点,单值枚举列属过早抽象(`开发规范.md`「为真实出现的第二个使用方而抽象」);将来做 save.pre 时再按当时的真实需求定表形态
- **加 `b_kind`**:v1 靠 `b_predicate` 三态推断(空=引用 / `{` 开头=JSON / 其余=SQL),判别依据靠猜且依赖「SQL 不以 `{` 开头」这个未声明约定;显式一列后判别依据存在数据里
- `b_xh` 保留:多条规则任一不过即拒绝,顺序不影响结论,但决定错误提示的展示顺序,也支持「便宜的勾条件规则排前、贵的 SQL 规则排后」的执行优化
其他要点:
- **模块上没有任何规则 = 该模块不受条件约束**(引用保护仍由关系默认值兜着)
- 错误类型不用存——勾条件报「状态不符」、写 SQL 报自定义文案,写法本身决定了报什么
- **删模块时,服务层把挂着的规则一起删掉**(`where b_module_id = …`,单条件直查,与字段、权限点同一套清理逻辑,全系统本来就不建物理外键)
- 删规则 = 物理删 + 日志记全文;临时不想用某条规则 → 停用(`b_canuse=0`),不删
### 6.3 勾条件那部分的格式(JSON,界面生成,人不手写)
```json
{ "kind": "dsl", "expr":
{ "and": [
{ "in": [ { "field": "b_status" }, { "const": ["draft", "rejected"] } ] },
{ "eq": [ { "field": "b_settled" }, { "const": 0 } ] }
] }
}
```
开发注意三条:
- 只能引用本模块的字段(想跨表?去写 SQL 规则),后端翻译成**参数化 SQL**,没有注入风险
- 常量按字段类型校验(数字/日期/文本),和查询配置的校验规则共用一套
- 防手滑:条件嵌套最多 8 层、最多 100 个节点(界面上分组最多 3 层,比后端还紧)
### 6.4 后端删除流程
**引擎拆成两个部件:检查器 和 执行器**。执行器管连带和真删;检查器只查规则(一个方法:`check(连接, 模块, ids, 小闭包)`)。两个入口共用同一个检查器:
```
删除(模块, ids, 是否预览):
① 执行器找连带:从这批 ids 出发,沿 b_on_delete ∈ {cascade, archive}
且 b_canuse=1 的关系,把引用方记录一层层装进删除清单
(被删的是被引用方;已装过的跳过——防关系成环;超过10层报配置错误)
② 检查器查规则:
模块条件:清单内每个模块的 s_rule(b_canuse=1) 逐条执行
勾条件 → 按条件查清单内记录,不符的记一条错误(带实际值)
写SQL → 拿清单内该模块的 id 跑那条 SQL,查出结果每行记一条错误(全程写日志)
引用保护:对清单内每条记录,查它作为「被引用方」的 restrict 入边
(b_on_delete='restrict' 且 b_canuse=1 的关系)
引用方存在、且引用方不在清单内 → 记错误(带引用方单号)
③ 有错误 → 整批拒绝,返回错误列表(按规则归类,每类带数量和前几条示例)
④ 全过 → 只是预览?返回"将删除 X 张单 + Y 条明细"摘要
真删?→ 开事务:再快速过一遍规则(防确认间隙有变化)
→ 归档的打标记 → 按先子后父的顺序物理删
→ 记审计日志(按删的这一批记一条 + 明细)
```
开发注意:
- 查库全部按批 IN,不逐行循环
- ids 数量不设上限——引擎内部把整批 id 装成一个数组参数(`OPENJSON`),不受绑定变量 2100 个的限制
- 真删前在事务里重查一遍规则(预览的通过**不算数**),防确认的几秒里有人改了规则或新增了引用
- 写 SQL 规则的每次执行:实际 SQL + 参数 + 结果行数都写进技术日志,关联本次请求——排查「为什么删不掉」从错误提示一路能追到原始 SQL
### 6.5 saveobjt 保存时删行,规则同样生效(重要)
**问题**:海运编辑这类主子表表单,保存走通用接口 `saveobjt`——前端把主表 + 子表(费用)的增删改 diff 成一个请求数组,一个事务提交。用户在表单里删掉一行费用,就是请求数组里子表的 `deletes` 段。**这条删除路径如果不过规则,配置就白配了**——列表页删除被拦,表单里删行却畅通无阻。
**接法**(对前端完全透明,入参不变):
```
DataSaveService.saveTable() 的 delete 分支,执行真删之前:
① 按表名反查模块(b_savetable = 这张表的 data 模块);查不到模块(如日志表)→跳过
② 该模块挂有规则、或它的记录存在 restrict 入边关系 → 调检查器,
传当前 connection(同一个事务!) + 待删的这批行
③ 任一规则不过 → 抛 BusinessException(文案就是规则生成的提示)
→ 整个 saveobjt 回滚——主表的修改和子表的删除一起回滚,
用户的前端草稿还在,知道哪行费用删不掉、为什么
④ 无规则或全过 → 照常执行 deletes
```
三个关键点:
- **同事务检查**:检查器用的就是 saveobjt 自己的 connection——表单里先改了主表状态、再删子表行,规则查到的是**改过之后**的值,不会出现「检查用旧数据、删的是新数据」的错位
- **小闭包语义**:检查 restrict 时,要把「本次 saveobjt 所有 deletes 覆盖的记录」当作一个小闭包——引用方也在被删之列就不算外部引用。场景:删主单连带删子表行(前端编排的),子表对主单的引用指向一个同批正在删除的记录,不该拦。实现:把请求数组里所有 deletes 的 id 收集起来传给检查器
- **不需要预览**:表单保存是「保存」动作,用户预期就是要么全成功要么报错,不做两阶段确认
### 6.6 接口
```
POST /data/deleteobj
{
"moduleCode": "receipt",
"ids": [1, 2, 3],
"dryRun": true // true=只预览(前端删除按钮的第一步)
}
POST /data/saveobjt // 不变,deletes 分支内部自动走规则检查
```
## 7. 其他约定
- **字段联动**:条件规则里用了的字段,删除该字段时会被拦住并提示规则内容——不让规则悄悄失效
- **配置权限**:改规则要权限点(如 `rule.edit`),和菜单权限同一套体系;SQL 不限制内容,但改它的人必须有配置权限
- **归档标记**:`archive` 要求引用方表有通用列 `b_archived` + `b_archive_datetime`(配归档时服务层校验列存在);通用查询默认不显示已归档行
- **将来扩展**(二期再议,**不留列**,届时按真实需求定表形态):
- 「已审核的单不能**改**」——形态未必与删除规则同构,到时候再决定复用还是另起
- 「删之前先自动红冲」这种要执行动作的——预留挂 Java 代码的口子
- 核销总额想在列表上显示成列——展示需求,单独做,跟删除无关
- **已知边界**:restrict 只覆盖 `s_relation` 里**登记过的**引用;没登记关系的表(日志、迁移脚本写的表)不在保护范围内。这是配置边界,不是数据库级引用完整性,文档与培训材料里要写明
## 8. 开工顺序
1. 建表:`s_relation` 加 `b_on_delete`(四值、默认 `restrict`);`s_rule` 按 §6.2 新结构建;同步重写 `fms_core.sql`、`fms_delete_rule.sql`(现脚本仍是 v1 结构)
2. 后端:条件(JSON → 参数化 SQL)+ 检查器 + 执行器(连带展开 → 规则 → 事务删除)
3. **saveobjt 接入检查器**:delete 分支反查模块 → 同事务规则检查 → 不过整体回滚(见 §6.5)
4. 错误提示:条件自动生成人话 + 字段格式化(金额带 ¥、日期带格式)+ SQL 占位符填充
5. 前端:关联面板一列化(去掉「有引用时拒绝删除」假列及其全部联动补丁)、规则面板收敛(去掉引用规则只读区与跳转)、列表删除先预览后确认、错误列表展示
6. 二期:save.pre、Java 钩子、聚合展示
## 9. 定案记录
| # | 事项 | 结论 | 版本 |
| --- | --- | --- | --- |
| 1 | 节奏 | 一期只做删除规则;保存校验二期再议,不留 `b_hook` 列 | v2 修订 |
| 2 | 跨表判断 | 不做计算字段,直接写 SQL 兜底 | v1 |
| 3 | SQL | 兜底逃生舱:不限制内容、不做体验优化、入口藏角落;**每次执行全程写日志** | v1 |
| 4 | 引用保护 | **`s_relation.b_on_delete='restrict'`(默认)表达,不进 `s_rule`**;删关系时规则随之消失,无孤儿清理 | v2 修订 |
| 5 | 归档 | 通用列 `b_archived` + `b_archive_datetime` | v1 |
| 6 | 条件规模 | 嵌套 ≤ 8 层、节点 ≤ 100;界面分组 ≤ 3 层 | v1 |
| 7 | 规则类型 | **显式 `b_kind` 列(condition / sql)**,不靠 `b_predicate` 首字符推断 | v2 修订 |
| 8 | 级联清理 | 删模块时服务层连带删 `s_rule`(`b_module_id` 单条件直查);关系级规则不复存在 | v2 修订 |
| 9 | 批量 | ids 不设上限;整批一个数组参数(OPENJSON) | v1 |
| 10 | 复制 | **全部规则可多选模块批量克隆**(v2 起无引用规则例外);复制时校验目标模块存在条件所用字段 | v2 修订 |
| 11 | saveobjt 集成 | 引擎拆检查器/执行器;saveobjt 的 deletes 分支同事务调检查器,不过则整批回滚(§6.5);前端入参不变 | v1 |
| 12 | 方向语义 | 四值统一描述「删除被引用方时引用方怎么办」;被引用方由基数判定(§3) | v2 新增 |
| 13 | 默认值 | `restrict`:被引用即不可删为默认,放开需显式改 `none` | v2 新增 |
| 14 | 存量迁移 | 库中无数据,直接按新结构建表,不做转换 | v2 新增 |