Files
workspace/code/fms/FMS删除规则引擎设计.md
T
2026-09-01 22:20:55 +08:00

291 lines
15 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 删除规则设计
> 版本:v1.0。关联:`FMS新系统核心表结构设计.md`。
> 一句话:**删之前先查一查,查不过就拒绝;查什么、怎么查,由 IT 配置,不写代码。**
## 这个设计解决什么问题
业务上「能不能删」的规矩五花八门:
- 单据还在审核中,不许删
- 已经销账了,不许删
- 核销过钱的,不许删
- 费用已经做到账单里了,不许删
- 删报销单时,下面的费用明细要跟着一起删
以前这些规矩要么写死在代码里(改一条要发版),要么干脆没有(误删了才发现)。现在的做法:**IT 在界面上配置,配完立即生效**。
## 1. 整体图景
一个用户点「删除」,系统背后做四件事:
```
1. 找出连带要删的记录(删报销单 → 连费用明细、附件一起)
2. 把配置的所有删除规则挨条查一遍
3. 任何一条不过 → 整批拒绝,告诉用户为什么
4. 全过 → 先弹预览给用户确认,再一个事务里一起删
```
IT 要做的事只有一件:**配规则**。配的地方在模块管理。
## 2. 规则:一条就是一句「什么情况不许删」
每条规则就是一句话:
> **当【什么条件】时,不许删,提示【什么话】**
规则有三种写法,按配的难度从易到难:
### 2.1 勾条件(日常用这个,占绝大多数)
在模块管理 → 「删除规则」tab 点「新增规则」,弹出来的界面和**查询配置长得一样**:
```
满足以下条件时,不许删除:
┌──────────┬────────┬─────────────────┐
│ 字段 │ 操作符 │ 值 │
│ 单据状态 │ 属于 │ [审核中, 已生效] │
│ 已销账 │ 等于 │ 1 │
└──────────┴────────┴─────────────────┘
```
- 字段下拉就是该模块字段定义里的字段
- 值可以直接填,也可以选另一个字段比(比如「已核销金额 大于 0」)
- 提示文案**不用写**——系统自动生成「单据 SKD-2026-001 状态为审核中,不能删除」
会配查询条件的人,零学习成本。
### 2.2 勾引用(防误删最常用)
在**关系配置**上勾一下「有引用时拒绝删除」。
效果:这模块的记录只要被别的单据引用着(费用被账单引用),就删不掉,提示「费用【海运费 ¥2,000】已被账单 ZD-2026-001 引用」。
模块的删除规则 tab 里能看到这个勾的只读展示和跳转,不会迷路。
### 2.3 写 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、每次执行了什么,系统日志里全程有记录(内部系统,不做限制,只做留痕)。
## 3. 连带删除:删主单时子表跟着删
这个不用配条件,在模块关系上选一个「删除时连带」的策略:
| 选项 | 意思 |
|---|---|
| 连带删除 | 删报销单,费用明细一起物理删掉 |
| 连带归档 | 明细不删,打个「已归档」标记,列表里不再显示,账单引用还能追溯 |
| 不连带 | 明细自己管自己 |
关键行为:
- **查连带时也过规则**:删报销单连带要删费用,但费用被账单引用着 → 整个删除(包括报销单)被拒绝,提示定位到那笔费用和那个账单
- **一荣俱荣一损俱损**:任何一条卡住,整批都删不了,不存在删一半的中间状态
- 关系套关系(单→明细→附件)会自动一层层找下去;层数太多(比如配置成了环)会直接报配置错误,不会死循环
## 4. 删除的操作流程(业务用户视角)
列表页勾几行点删除,系统**先预览后真删**:
```
第一步:系统先算一遍
↓ 有问题? → 弹错误列表:「这 3 条审核中删不了」「这笔费用被账单引用」
↓ 没问题? → 弹确认框:「将删除 1 张报销单 + 3 条费用明细,确认?」
第二步:用户确认后才真删(删之前系统再快速查一遍,防止确认的几秒里有变化)
```
错误提示都是人话,不用猜。批量删 500 行时提示按规则归类显示,不会刷屏。
## 5. 配置入口在哪
**配规则**(IT 日常):
- 模块管理 → 找到模块 → 「删除规则」tab → 新增
- 每条规则 3 个格子一分钟;改完立即生效,**不用发版**
- 常见规则可以**复制到其他模块**(多选目标模块批量克隆)——「审核中不可删」这种几十个模块通用的规矩,配一次、勾一圈目标模块就完事
**看全貌**(排查、盘点):
- 系统管理 → 「业务规则」:全系统所有规则的列表——哪个模块的、什么类型、谁最后改的、什么时候。点行跳到对应模块
- 排查「这单为什么删不掉」:错误提示里带规则名 → 全局列表找到它 → 看条件/SQL → 完事
**谁可以配**:规则的增删改走权限控制(和菜单权限同体系)。看规则不受限。
## 6. 数据落库(给开发看的部分)
### 6.1 s_rule 规则表(新增)
```sql
create table s_rule (
b_id bigint not null,
b_code varchar(50) not null, -- 稳定标识(复制/导入导出用)
b_name nvarchar(100) not null, -- 规则名(如"审核中不可删")
b_scope_type varchar(20) not null, -- module / relation:挂在模块还是关系上
b_scope_id bigint not null, -- 挂在哪个模块/哪条关系上
b_hook varchar(30) not null, -- delete.pre(一期只做这个)
-- save.pre(将来做"已审核不能改")
b_predicate nvarchar(max) null, -- 条件(勾条件=JSON;写SQL=SQL文本;勾引用=空)
b_message nvarchar(500) null, -- 提示文案(写SQL的必填;勾条件的可留空自动生成)
b_canuse int not null default 1,
b_xh int not null default 0, -- 多条规则谁先查(并列按 b_xh, b_id)
b_inputuser_id bigint null,
b_inputdatetime datetime2 null default sysutcdatetime(),
b_updateuser_id bigint null,
b_updatedatetime datetime2 null,
constraint pk_s_rule primary key (b_id),
constraint ux_s_rule_code unique (b_code)
);
-- b_scope_type / b_hook 的取值范围不建 CHECK 约束,由服务层校验
-- (与全系统「元数据合法性走服务层」的原则一致,新增取值不用改表)
create index ix_s_rule_scope on s_rule (b_scope_type, b_scope_id, b_hook, b_canuse, b_xh);
```
要点:
- **模块上没有任何规则 = 直接删**(无约束)
- 想要旧「restrict 主数据」的效果?一键模板:给所有入边关系批量勾上引用规则
- 错误类型不用存——勾条件报「状态不符」、勾引用报「被引用」、写 SQL 报自定义文案,写法本身决定了报什么
- 提示文案只存这一列,不重复塞进条件 JSON 里
- **删模块/删关系时,服务层把挂着的规则一起删掉**(和字段、权限点同一套清理逻辑,全系统本来就不建物理外键)
- 删规则 = 物理删 + 日志记全文;临时不想用某条规则 → 停用(b_canuse=0),不删
### 6.2 老表的变化
```sql
-- s_module:删掉 b_delete_policy(就是那个 restrict/lifecycle/hard 三选一,表达力不够,废弃)
-- s_relation:删掉 b_delete_policy,新增:
alter table s_relation add
b_on_delete varchar(20) null; -- cascade连带删 / archive连带归档 / none不连带(默认)
```
存量数据转换:旧 cascade→cascade;旧 archive→archive;旧 restrict→不连带+生成引用规则;旧 set-null→丢弃(本就不该有)。
### 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 出发,沿"连带删/连带归档"的关系一层层查,
全部装进删除清单(已装过的跳过——防关系成环;超过10层报配置错误)
② 查规则:按顺序逐条执行挂在清单上的删除规则
勾条件 → 按条件查清单内记录,不符的记一条错误(带实际值)
写SQL → 拿这批 ids 跑那条 SQL,查出结果每行记一条错误(全程写日志)
勾引用 → 查有没有清单外的单据引用清单内记录,有则记错误(带引用方单号)
③ 有错误 → 整批拒绝,返回错误列表(按规则归类,每类带数量和前几条示例)
④ 全过 → 只是预览?返回"将删除 X 张单 + Y 条明细"摘要
真删?→ 开事务:再快速过一遍规则(防确认间隙有变化)
→ 归档的打标记 → 按先子后父的顺序物理删
→ 记审计日志(按删的这一批记一条 + 明细)
```
开发注意:
- 查库全部按批 IN,不逐行循环
- ids 数量不设上限——引擎内部把整批 id 装成一个数组参数(`OPENJSON`),不受绑定变量 2100 个的限制
- 真删前在事务里重查一遍规则(预览的通过**不算数**),防确认的几秒里有人改了规则或新增了引用
- 写 SQL 规则的每次执行:实际 SQL + 参数 + 结果行数都写进技术日志,关联本次请求——排查「为什么删不掉」从错误提示一路能追到原始 SQL
### 6.5 saveobjt 保存时删行,规则同样生效(重要)
**问题**:海运编辑这类主子表表单,保存走通用接口 `saveobjt`——前端把主表 + 子表(费用)的增删改 diff 成一个请求数组,一个事务提交。用户在表单里删掉一行费用,就是请求数组里子表的 `deletes` 段。**这条删除路径如果不过规则,配置就白配了**——列表页删除被拦,表单里删行却畅通无阻。
**接法**(对前端完全透明,入参不变):
```
DataSaveService.saveTable() 的 delete 分支,执行真删之前:
① 按表名反查模块(b_savetable = 这张表的 data 模块);查不到模块(如日志表)→跳过
② 该模块挂有删除规则 → 调检查器,传当前 connection(同一个事务!)
+ 待删的这批行
③ 任一规则不过 → 抛 BusinessException(文案就是规则生成的提示)
→ 整个 saveobjt 回滚——主表的修改和子表的删除一起回滚,
用户的前端草稿还在,知道哪行费用删不掉、为什么
④ 无规则或全过 → 照常执行 deletes
```
三个关键点:
- **同事务检查**:检查器用的就是 saveobjt 自己的 connection——表单里先改了主表状态、再删子表行,规则查到的是**改过之后**的值,不会出现「检查用旧数据、删的是新数据」的错位
- **小闭包语义**:勾引用规则检查子表行时,要把「本次 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 不限制内容,但改它的人必须有配置权限
- **归档标记**:连带归档要求子表有通用列 `b_archived` + `b_archive_datetime`(配归档时服务层校验列存在);通用查询默认不显示已归档行
- **将来扩展**(表结构已留位,暂不实现):
- 「已审核的单不能**改**」——同一套规则换个挂点(save.pre)就行,界面和错误提示全复用
- 「删之前先自动红冲」这种要执行动作的——预留了挂 Java 代码的口子
- 核销总额想在列表上显示成列——那是展示需求,将来单独做,跟删除无关
## 8. 开工顺序
1. 建表 s_rule;删老字段、加 `b_on_delete`(存量按 §6.2 转换);前端基本信息表单同步去掉「删除策略」下拉
2. 后端:条件(JSON → 参数化 SQL)+ 检查器 + 执行器(连带展开 → 规则 → 事务删除)
3. **saveobjt 接入检查器**:delete 分支反查模块 → 同事务规则检查 → 不过整体回滚(见 §6.5)
4. 错误提示:条件自动生成人话 + 字段格式化(金额带 ¥、日期带格式)+ SQL 占位符填充
5. 前端:删除先预览后确认、错误列表展示;模块管理「删除规则」tab(条件构造 + 复制到模块 + SQL 角落入口);系统管理全局规则列表页
6. 二期:「已审核不能改」、Java 钩子、聚合展示
## 9. 定案记录
| # | 事项 | 结论 |
|---|---|---|
| 1 | 节奏 | 一期只做删除规则;表结构给保存校验留好位 |
| 2 | 跨表判断 | 不做计算字段,直接写 SQL 兜底 |
| 3 | SQL | 兜底逃生舱:不限制内容、不做体验优化、入口藏角落;**每次执行全程写日志** |
| 4 | 旧 set-null | 废弃(财务上不该有悬空引用) |
| 5 | 归档 | 通用列 `b_archived` + `b_archive_datetime` |
| 6 | 条件规模 | 嵌套 ≤ 8 层、节点 ≤ 100;界面分组 ≤ 3 层 |
| 7 | 错误类型 | 不单独存列,由规则写法决定 |
| 8 | 级联清理 | 删模块/关系时业务层连带删规则(同权限点模式) |
| 9 | 批量 | ids 不设上限;整批一个数组参数 |
| 10 | 复制 | 规则可多选模块批量克隆;引用规则除外 |
| 11 | saveobjt 集成 | 引擎拆检查器/执行器;saveobjt 的 deletes 分支同事务调检查器,不过则整批回滚(§6.5);前端入参不变 |