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

15 KiB
Raw Blame History

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:

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 规则表(新增)

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 老表的变化

-- 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,界面生成,人不手写)

{ "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);前端入参不变