Files
workspace/code/fms/.trae/documents/删除规则一期实现方案.md
2026-09-17 21:29:08 +08:00

15 KiB
Raw Permalink Blame History

删除规则一期(配置侧)实现方案

Context

「删除规则引擎」的设计已定稿(FMS删除规则引擎设计.md),但代码侧一期尚未开工:s_rule 表不存在、s_relation 没有 b_on_delete、模块管理里没有规则 tab、后端无 /data/deleteobj。

本次做一期:配置侧的元数据落地(前端 + SQL 脚本,不含后端引擎)。完成后 IT 就能把规则配起来,但不改变现有删除行为——没有引擎去读这些配置。二期再写检查器/执行器与 saveobjt 接入。

职责划分(这是本次的设计主线):

性质 落哪 配置入口
结构性删除动作(连带删除/连带归档) 无条件,跟"谁引用谁"绑定 s_relation.b_on_delete 模块关联面板
引用检查(有引用时拒绝删除) 无条件(存在引用即拒) s_rule,scope_type='relation' 模块关联面板勾选
业务条件(审核中/已销账/核销金额>0…) 带条件、长尾 s_rule,scope_type='module' 模块管理「删除规则」tab(勾条件 / 写 SQL)

关系表只说结构与结构性动作,条件一律不进关系表。

与设计文档的偏离(3 处,均为落地适配)

  1. s_rule 用业务联合主键 (b_scope_type, b_scope_id, b_code),砍掉 b_id(bigint 雪花) 与 ux_s_rule_code。理由:全仓元数据表都用业务键/复合键,开发规范.md 第 6 条要求配置表用业务键、不用雪花 ID;且文档的 unique(b_code) 与"规则可复制到其他模块"互相矛盾,scope 内唯一才能复制。
  2. b_scope_id 用 varchar(250):模块是编码 varchar(50),关系是 4 列各 50 的复合键(拼串最长约 203),文档里的 bigint 装不下(这一点文档 §6.1 与 §6.2 本身不自洽)。
  3. DSL 的 expr 直接复用现有查询 AST:{"kind":"dsl","expr":buildAdvancedAst({nodes,groups})},即 {type:'group',children:[{type:'condition',field,operator,value,join}…]}。前端已有 astToSql 能把同构 AST 翻成 SQL 片段,避免自造第二套语法(文档 §6.3 的 and/in 写法是同一信息的另一种表述)。

另外遵循文档定案:规则类型不单独存列,由 b_predicate 推断——空 → 引用规则;可解析为 JSON → 勾条件;否则 → SQL 文本。b_hook 一期固定 'delete.pre'。b_archived/b_archive_datetime 一期不落列(引擎启用时才校验)。

SQL(2 个文件)

sql/fms_core.sql(全新安装,紧接 §9 关系段之后新增第 9.1 节,并在 s_relation 建表段补列):

alter table dbo.s_relation add b_on_delete varchar(20) not null default 'none'  -- none/cascade/archive
create table dbo.s_rule (
    b_scope_type varchar(20)  not null,   -- module / relation
    b_scope_id   varchar(250) not null,   -- module=模块编码;relation=源模块|源字段|目标模块|目标字段
    b_code       varchar(50)  not null,   -- 规则编码(scope 内唯一,复制/导入导出的稳定标识)
    b_name       nvarchar(100) not null,  -- 规则名(如"审核中不可删")
    b_hook       varchar(30)  not null,   -- delete.pre(save.pre 留位)
    b_predicate  nvarchar(max) null,      -- 空=引用规则;JSON=勾条件;其余=SQL 文本
    b_message    nvarchar(500) null,      -- 提示文案(写 SQL 必填)
    b_canuse     tinyint not null default 1,
    b_xh         int not null default 0,
    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_scope_type, b_scope_id, b_code)
);
create index ix_s_rule_scope on dbo.s_rule (b_scope_type, b_scope_id, b_hook, b_canuse, b_xh);
  • b_on_delete 用 not null default 'none':存量行 alter 时回填 none,新增行与存量行取值一致 → 打开即不脏。
  • 新增增量脚本 sql/fms_delete_rule.sql(沿用 fms_rename_othercompany_category.sql 的风格:头部注释写背景与前置核查「确认无 s_rule、s_relation 无 b_on_delete」,正文 alter table + create table/index,GO 分批)。已有库执行走 fms-api/tools/migration/RunSqlFile.java(GO 分批、单事务、失败整体回滚),先 --dry-run 再实跑。
  • 后端零改动:loaddata/saveobjt 无表白名单,复合主键用逗号拼 key_field,deletes 按行内列值 AND 匹配。

前端改动

新增 3 个文件

src/views/module/module-management/ruleUtils.js relationScopeId(row)(四列 | 拼串,与主键同序)/ parseRulePredicate(row)(三态)/ ruleSummary(row, itemByField, t)(dsl 走 describeAdvancedQuery,sql 取单行化前 80 字)/ nextRuleCode(rules)(rule_{最大序号+1},可改)/ createRuleRow(moduleId, seq) / createRefRuleRow(scopeId)(b_code='referenced',一个关系最多一条)/ buildRuleQueryItems(fields, querySchema)。

ModuleRulePanel.vue(样板:ModuleRelationPanel.vue)

  • props:moduleId / fields / querySchema / relations / refRulesReverse / loading;defineModel 行数组 rules;defineExpose({ validate })。
  • 列:b_name / _typeText(条件|SQL)/ _summaryText(条件中文摘要或 SQL 片段)/ b_canuse(checkbox 行内编辑)/ b_xh(拖拽重排 (i+1)*10)/ actions(编辑、删除)。派生列在 watch(rules, deep) 里就地补(_ 前缀不落库、不判脏),补完换数组引用触发表格刷新。
  • 工具栏:新增规则、批量删除。行点击/编辑 → 打开编辑弹窗。
  • 只读区「来自模块关联」:列出 refRulesReverse(关系上的引用规则),列 源模块/源字段/目标模块/目标字段/说明;操作「查看」→ 当前模块的规则 emit('navigate') 由父级切到关系 tab,其它模块走 onModuleSelect(带未保存守卫)。
  • validate():规则名非空;b_code 非空、合法标识符、scope 内不重复;dsl 行至少一个有效条件;sql 行 b_message 非空。

ModuleRuleEditModal.vue(受控弹窗,参考 UnsavedChangesModal.vue + ui/Modal)

  • props open / row / moduleId / items / existingCodes;emits update:open / ok。
  • 表单:规则名、规则编码(默认自动生成,可改)、类型 radio(勾条件 / 写 SQL)、启用、提示文案(sql 必填)。
  • 勾条件分支:显示条件摘要 + 「编辑条件」→ 以嵌套抽屉打开 FmsAdvancedQueryPanel(v-model:open + :items + v-model 条件状态;Modal 与 Drawer 的滚动锁是引用计数、z-index 同层后挂载,已确认安全),@apply 回写状态与摘要。
  • ok 时组装:dsl → {"kind":"dsl","expr":buildAdvancedAst(state)};sql → 文本。

复用契约:items 怎么造(这是接条件面板的关键)

照抄 FmsModuleListPage.vue 的组装方式:先 buildQueryRender(query_schema)(@/components/fms-module-common/schemaRender)取到 conditions(含 operator/component/options/defaultValue),与当前模块 fields 合并,产出

{ config: { b_field, b_operator, b_component, b_default_value }, field, optionConfig, options }

b_component 缺省按字段类型推导(select→select、date/datetime→date-picker、number/money→number、checkbox→checkbox、其余 input);select 字段若查询配置里没有 options,降级为 input(提示直接填值),避免空下拉死路。首版先做到这一步——字段权限裁剪(查询侧有 loadFieldPower)本次不接,规则面板直接列全部启用字段。

queryUtils.js 增一个反向函数

src/components/fms-module-common/queryUtils.js 已有 buildAdvancedAst(界面 → AST)但没有反向,回显已保存规则需要:

export function advancedQueryFromAst(ast, { idFactory } = {})
// → { nodes, groups, degraded }

condition → node(join 缺省 'and');group → 组;组内再出现 group(>1 层)时把内层 children 拍平进该组并置 degraded: true,面板据此提示一次"原条件含多层分组,已按单层展开,请确认后保存"。

ModuleRelationPanel.vue 加两列

  • b_on_delete「删除连带」:select,选项 不连带/连带删除/连带归档;many_to_one 行 disabled,且 onCellChange 把关系类型切成 many_to_one 时自动置回 none 并 warning(防脏数据绕过校验)。
  • _hasRefRule「有引用时拒绝删除」:checkbox(_ 前缀,不落 s_relation、不参与关系行的 diff)。勾选/取消 → 在 refRules 草稿里增/删一条规则行(scope_type='relation'、scope_id=relationScopeId(row))。
  • 主键列变化处理:关系行的源字段/目标模块/目标字段一旦改动,scope_id 就变了 → 自动取消勾选并删除对应规则行,提示"关系主键已变化,请重新勾选引用保护"。避免规则挂在旧键上产生孤儿行。
  • props 增加 v-model:refRules(父级持有 refRules/refRules_org 成对草稿,走正常 diff 与脏判定,不需要任何 _ 前缀的脏判定特例)。

index.vue 接入点(9 处)

  1. import ModuleRulePanel;configGroups 的 capability 组追加 { key: 'rule', label: '删除规则', icon: CircleSlash }(已确认 @lucide/vue 有该图标)。
  2. 数据对:rules / rules_org(scope=module)、refRules / refRules_org(scope=relation,源侧)、refRulesReverse(只读)。
  3. clearModuleData 清空上述 ref;面板 ref rulePanelRef。
  4. 模板挂载(relation 面板之后):<ModuleRulePanel ref="rulePanelRef" v-model="rules" v-model:ref-rules="refRules" … @navigate="onRuleNavigate" />,onRuleNavigate 切 activeSection 或 onModuleSelect。
  5. loadModuleConfig 加第 7 张表:s_rule,条件 b_scope_type='module' AND b_scope_id = <模块编码>,排序 b_xh, b_code;关系侧规则必须第二次查询(规则表没有模块列,deletes 也不支持 LIKE 前缀):关系加载完后用双向关系键集合查 b_scope_type='relation' AND b_scope_id IN (…),按 scope_id 属于源侧/目标侧拆成 refRules_org 与 refRulesReverse(无关系时跳过这次查询)。
  6. buildSaveData 在 s_relation 段之后加两段(都先物化 scope 两列):
for (const row of rules.value) { row.b_scope_type = 'module'; if (row.b_scope_id !== moduleId) row.b_scope_id = moduleId }
const ruleReq = tableChange('s_rule',
  diffRows(stripInternalKeys(rules.value), stripInternalKeys(rules_org.value),
    { keyFields: ['b_scope_type', 'b_scope_id', 'b_code'] }),
  ['b_scope_type', 'b_scope_id', 'b_code'])
if (hasChanges(ruleReq)) saveReqs.push(ruleReq)

for (const row of refRules.value) { row.b_scope_type = 'relation'; row.b_hook = 'delete.pre' }
const refRuleReq = tableChange('s_rule',
  diffRows(stripInternalKeys(refRules.value), stripInternalKeys(refRules_org.value),
    { keyFields: ['b_scope_type', 'b_scope_id', 'b_code'] }),
  ['b_scope_type', 'b_scope_id', 'b_code'])
if (hasChanges(refRuleReq)) saveReqs.push(refRuleReq)
  1. beforeSave:接 rulePanelRef.value?.validate?.(),失败则 activeSection='rule' + 首条文案。
  2. hasUnsavedChanges 的 pairs 追加 [rules, rules_org] 与 [refRules, refRules_org]。
  3. deleteModule:追加模块级清理 { table:'s_rule', key_field:'b_scope_type,b_scope_id', deletes:[{ b_scope_type:'module', b_scope_id:id2 }] };关系级需先查一次 s_relation(双向)拿到关系键,再一条批量 delete;确认文案补「删除规则」。
    • 顺带:在关联面板删掉某条关系并保存时,规则行由 refRules 的 diff 自然删除(勾选态随行消失),无需额外逻辑。

边界与风险

  • many_to_one 禁连带:面板 disabled + 改类型自动清 + validate() 三层拦截。
  • 归档列校验点:一期前端查不了 sys.columns,选「连带归档」时给一次性 Message.warning('连带归档要求目标表含 b_archived / b_archive_datetime 列,引擎启用时会校验'),真正的校验留在二期服务层。
  • 打开即干净:b_on_delete 非空默认值 + 派生列一律 _ 前缀 + refRules 走常规 pairs(无脏判定特例)。
  • 删除规则 tab 只对 data 模块显示(与自动编码/权限/模块关联一致)。

验证

手工(pnpm dev → /module/module-management,需 g3soft 账号):

  1. RunSqlFile --dry-run 后实跑增量脚本,核对 s_rule 与 s_relation.b_on_delete。
  2. 关联面板新增 one_to_many 关系选「连带删除」→ 保存 → 库里 b_on_delete='cascade'。
  3. 改成 many_to_one → 下拉只剩「不连带」且原值被清。
  4. 勾「有引用时拒绝删除」→ 保存 → s_rule 出现 scope_type='relation'、scope_id 为四段竖线串的一行;取消勾选再保存 → 该行删除。
  5. 改该关系的目标模块 → 勾选自动取消并提示;保存后库里无旧键孤儿规则。
  6. 「删除规则」tab 新增勾条件规则(两条件 and/or 混用)→ 保存 → predicate 为 {"kind":"dsl",…};重开模块条件与摘要一致。
  7. 新增 SQL 规则不填提示文案 → 被拦。
  8. 打开模块立即保存 → 提示「没有需要保存的变更」(不脏)。
  9. 删除该模块 → s_rule/s_relation 残留为 0(含关系级规则)。

测试(一源文件一 spec,见 tests/README.md):

  • 改 tests/unit/query-ast.spec.js:advancedQueryFromAst 空 AST / 单层往返 / 二层拍平置 degraded。
  • 改 tests/unit/module-relation-panel.spec.js:b_on_delete 加载不置脏、many_to_one disabled 与自动清、cascade+many_to_one 被 validate 拦、勾选增删 refRules 行、改主键自动取消勾选。
  • 新增 tests/unit/module-rule-panel.spec.js:回填只补 _ 键、dsl 无条件被拦、sql 缺文案被拦、编码重复被拦、只读区渲染与 navigate。
  • 新增 tests/unit/module-rule-utils.spec.js:scopeId 往返、predicate 三态(含非法 JSON 降级为 sql)、nextRuleCode 递增、items 推导。
  • 改 tests/views/module-unsaved-after-save.spec.js:加载 7 张表 + 二次规则查询、buildSaveData 产出两条 s_rule 请求、deleteModule 的模块级与关系级清理。

明确不做(一期之外)

后端检查器/执行器与连带展开、POST /data/deleteobj(预览/错误列表)、saveobjt deletes 分支接入、b_archived 归档列、规则复制到其他模块、系统管理全局规则列表页、「值 vs 另一字段」比较操作符、条件面板的字段权限裁剪、rule.edit 权限点(页面已由 meta.adminOnly 兜底)。