Files
workspace/code/fms/FMS计算公式设计.md
T
2026-09-27 21:59:13 +08:00

40 KiB
Raw Blame History

FMS 计算公式设计 V1(评审修订版)

评审结论(2026-09-26):保留“公式属于表单配置、解析为 AST、依赖图拓扑重算、跨模块使用带入快照”的主方向;原稿不能直接实施。主要问题是把前端结果当成可信写入值、把不同作用域的字段编码放进同一张依赖图、未定义 NULL/覆盖保存语义,以及把尚未存在的子表运行时写成已可接入。本版将服务端校验与重算设为保存闭环的必要部分,并把 V1 的交付边界收窄到可验证的范围。

本版必须满足的验收门槛:

  • 前端计算只负责即时反馈;服务端在同一保存事务内校验公式配置、计算结果和可覆盖权限,不能信任客户端提交的计算字段值。
  • 依赖节点使用带作用域的 ID(至少区分主表字段、当前明细行字段、明细集合聚合),禁止仅用字段名建全局图。
  • NULL、舍入、覆盖和历史快照语义在前后端共用一份规范;未实现服务端执行器前,不得宣称公式结果“可安全落库”。
  • V1 先交付主表字段公式和引用带入快照;子表逐行公式、聚合及跨模块实时取数作为后续阶段,必须在关系标识和事务保存链路明确后接入。

1. 文档定位

本文是《FMS 新系统核心表结构设计》界面配置能力的补充设计,解决"单据字段由其它字段算出来"的问题:金额 = 数量 × 单价、税额 = 金额 × 税率、含税金额 = 金额 + 税额、表头合计 = 明细求和。

计算公式被设计成界面配置的一部分,不是数据库计算列,也不是可执行后端脚本;前后端各有一个受限求值器,服务端版本负责最终校验与重算:

业务模块
  └── 表单配置(s_module_schema.edit)
        └── 字段节点 formula
              ├── 表达式(四则 / 函数 / 字段引用)
              ├── 依赖图(谁依赖谁,环路拒绝保存)
              └── 运行期求值(编辑弹窗内即时重算)

业务单据编辑
  └── 加载配置 + 建依赖图
        └── 依赖字段变更
              └── 按拓扑序重算计算字段
                    └── 提交预览值,由服务端校验/重算后落库

本文遵循以下已有设计:

  • 《FMS 新系统核心表结构设计》6.2 节已明确:readonly、required、disabled、visibleWhen、defaultValue、options、format 和 formula 都属于界面配置,不放入 s_field;
  • 计算字段若要参与 SQL、排序或筛选,写在模块的 b_query_sql 中,不依赖表单 JSON;
  • 界面配置统一存 s_module_schema.b_schema_json,模块管理经通用接口 /data/* 读写,保存走逐表 diff;
  • 不依赖数据库外键、触发器和 CHECK 约束表达业务规则;
  • 前端组件自研优先,不轻易引入第三方依赖。

相关文档:

2. 设计目标与边界

2.1 设计目标

  1. 表达式与字段绑定,配置一处生效一处,不需要为"公式"新建表;保存链路可复用现有数据接口,但必须增加公式处理钩子;
  2. V1 覆盖主表字段间计算和跨模块带入快照;明细行内、明细汇总到表头、明细行引用表头先定义作用域契约,待子表保存链路具备后接入;
  3. 语法可校验、可静态检查(字段存在性、类型、循环引用),不做"跑起来才知道错";
  4. 数值计算走十进制定点,杜绝 0.1 + 0.2 类浮点误差,金额落库精度确定;
  5. 前端即时重算,用户改一个字段立刻看到结果,不需要保存后再刷新;
  6. 依赖变更才重算:打开旧单据不做静默改写,历史数据保持快照语义;
  7. 计算结果经服务端校验/重算后随表单落库,列表显示、筛选、排序、合计、导出直接可用;
  8. 允许按字段配置"可人工改写",覆盖特价、手工调价等真实业务;
  9. 引擎与作用域解耦(ctx = { fields, rows, row }),作用域信息进入依赖节点而不是依赖调用方猜测;
  10. 解析、校验、求值规则可由前端和 Java 服务端共用测试向量,避免两套实现漂移。

2.2 设计边界

计算公式负责:

  • 表达式解析、校验、求值;
  • 依赖图构建、拓扑重算、循环检测;
  • 编辑弹窗内的即时重算与覆写交互;
  • 服务端保存事务中的公式校验、重算和精度检查。

计算公式不负责:

  • 数据库计算列、视图、触发器等物理计算;
  • 绕过模块保存策略的任意直写;所有会改变带公式模块数据的入口都必须经过服务端公式校验/重算;
  • 跨模块实时取数(本期用"引用带入快照",见 13 章);
  • 可视化公式拼装(本期为文本表达式 + 插入面板);
  • 替代业务领域的复杂结算逻辑(税率表、阶梯价、多币种折算等应沉淀在业务字段与带入值中)。

服务端公式执行属于 V1 的必要范围,不再列为“以后再做”。可以先只支持主表公式,但保存、导入、工作流回写必须共用同一个权威校验入口。

3. 现状与问题

3.1 现状

环节 现状
字段属性 表单 JSON 的字段节点现有属性:field、span、visible、required、readonly、disabled、height、placeholder、defaultValue
编辑器适配层 design-editor/common/schemaModel.js 通过 row._node 保留节点上的未知扩展属性,回写时展开,因此现在写入 formula 不会被编辑器吞掉(前向兼容)
表单运行时 components/fms-module-edit/FmsModuleEditModal.vue 按配置渲染、按默认值预填、按 b_readonly / b_disabled / 主键控制禁用
表达式能力 唯一已有的表达式是工作流条件(布尔 AST + WorkflowConditionEvaluator),只做判断不做计算,且不在本设计内复用
旧系统 s_expression / s_expressiongroupids(《Fms旧系统表结构》)以"表达式组 + 序号 + 文本"保存,无类型、无校验、无作用域,仅作参考,不沿用

3.2 问题

现状 问题
金额、税额、合计靠人录入 口径依赖人的算术,错一行传一路
想在页面里写点计算 没有配置能力,只能改成业务定制代码,配置人员无法自助
若用数据库计算列 与"界面配置统一存 JSON"的既有设计冲突,且模块换表、字段调整都要动 DDL
若用后端脚本 前端看不到即时结果,录入体验差;且引入了新的脚本安全面

4. 核心概念

4.1 计算公式

写在表单 JSON 字段节点上的字符串表达式,声明"这个字段的值由其它字段算出"。例如:

ROUND(b_qty * b_price, 2)
IF(b_qty > 0, b_amount / b_qty, NULL)
SUM(lines.mx_amount)

其中最后一条仅是后续子表阶段示例,V1 不允许保存。

4.2 计算字段

挂了 formula 的字段。约定:计算字段自动只读,用户不能直接填;是否允许人工改写由 overridable 决定(见 11 章)。计算字段必须出现在表单配置中(不在表单里的字段没有节点,无处挂公式)。

4.3 作用域

表达式里的字段引用必须先解析为带作用域的引用,不能只保存一个字段名。V1 的解析范围按由近到远:

顺序 作用域 说明
1 当前行 仅后续子表阶段启用;明细行公式内优先命中本行字段
2 主表 当前单据的主表字段(含带入字段、隐藏字段)
3 带入字段 选择器写入的主表字段,本质同 2,但保留来源元数据

解析不到、作用域不唯一或引用停用字段:设计期阻断保存;运行期配置异常也必须由服务端拒绝保存,不能静默按空值落库。

4.4 依赖图

以字段为节点、以"公式引用"为边构成的图。两个用途:

  • 保存配置时:拓扑排序,剩余有入度的节点即环路 → 拒绝保存并指出环路;
  • 运行期:某字段变化 → 取其下游闭包 → 按拓扑序重算。

4.5 重算时机

依赖变更才重算:编辑旧单据时,依赖值未变则保留历史计算结果;依赖值变化时按当前公式重算。新增记录执行一次全量初算。服务端必须对客户端传入的计算字段做同样的判断和校验,不能仅依赖前端快照。

4.6 覆写

overridable = true 的字段,允许用户手工改写;改写后该字段在当前编辑会话内不再被公式覆盖,直到用户点"恢复公式"或关闭弹窗。覆写状态不持久化,跨会话的保留依赖"依赖变更才重算"语义(见 11 章)。

5. 总体架构

配置期(模块管理 · 表单设计器)
  字段属性面板
    ├── 公式输入 + 插入字段/函数 + 实时校验 + 试算
    └── 可覆写开关
          ↓ 写入草稿
  s_module_schema.edit 的字段节点 { formula, overridable }
          ↓ 保存前校验(语法 / 字段 / 类型 / 循环 / 互斥)
  /data/saveobjt 落库

运行期(业务单据编辑弹窗)
  加载配置 → 解析表达式 → 建依赖图 → 建立值快照(不计算)
        ↓ 用户改动依赖字段
  快照对比 → 变更字段集 → 下游闭包 → 拓扑序重算 → 收敛 → 更新快照
        ↓ 提交
  提交用户输入 + 公式预览 → 服务端校验/重算 → 列表/筛选/排序/导出直接可用

6. 配置模型

6.1 字段节点扩展

在《FMS 新系统核心表结构设计》6.2 节表单节点的 FieldNode 上增加两个属性:

{
  "field": "b_amount",
  "readonly": true,
  "formula": "ROUND(b_qty * b_price, 2)",
  "overridable": true
}
属性 类型 必填 说明
formula string 否 表达式文本。缺失或空串即普通字段;仅字段节点可挂,行节点与分组节点不允许
overridable boolean 否 仅 true 时写出。缺省即严格只读,不写该属性
readonly boolean — 由设计器在挂公式时自动置 true;运行期以"有 formula 即只读"兜底,双保险

V1 仅允许 data 模块且公式目标字段必须存在于 b_save_table 的真实可写列中;virtual 模块、查询 SQL 别名和纯展示字段如果没有独立保存处理器,不能挂需要落库的公式。配置保存时同时校验目标列的 nullable、precision 和 scale。

6.2 存储约定

  • 继续存 s_module_schema(b_module_id, b_schema_type = 'edit').b_schema_json,不新建表、不新建公式专用接口;现有保存接口需增加模块标识和公式处理元数据;
  • 不写入 s_field:s_field 只表达业务元数据(字段编码、名称、类型、启用、顺序),公式属于界面行为;
  • 不新增 b_schema_type:公式是表单配置的一部分,不单独成行;
  • 编辑器适配层用 row._node 保留未知属性,因此未升级的设计器不会破坏已存在的公式;
  • 服务端对规范化后的公式节点计算 formulaRevision(不新建表),记录/保存请求携带摘要用于防止编辑页面使用过期口径。
    • 实现说明:摘要是「规范化公式节点」的 FNV-1a 64(字段按编码升序,含 formula 与 overridable),前后端算法一致、客户端可自行计算,因此不需要额外接口下发;
    • 目标列的 nullable / precision / scale 不进摘要(客户端拿不到数据库元数据),由服务端在保存事务内直接校验(见 10.6)。

6.3 与相邻能力的关系

相邻能力 关系
自动编码(s_autocode) 自动编码字段在表单中自动只读,不允许挂公式(设计器校验拦截)
字段权限(s_user_field_power) 严格计算字段不可编辑;允许覆写的字段仍必须同时满足字段权限的可编辑位和 overridable=true,不能用公式开关绕过权限
b_query_sql 列表筛选/排序直接读落库值,不需要在 SQL 里重复写公式;只有需要"查询期实时计算"(如按当前汇率折算)才用 SQL
工作流条件 两套语法各司其职:条件做布尔判断,公式做数值/文本计算,不互相调用

7. 表达式语法

7.1 词法单元

类别 规则 示例
字段引用 [a-z][a-z0-9_]*,与 s_field.b_field 一致;后续聚合允许 relationId.field b_qty、mx_price、lines.mx_amount
函数名 [A-Z][A-Z0-9_]*,全大写 ROUND、IF、SUM
数字 123、1.5;不支持千分位与科学计数法,负号走一元运算 12.5
字符串 单引号包裹,\' 转义 '含税'
布尔 TRUE / FALSE TRUE
空值 NULL NULL
运算符 + - * / % & ( ) , > >= < <= == != ! AND OR NOT a > 0 AND NOT(ISBLANK(b))
其它 空白忽略;不支持注释 —

字段用小写、函数用全大写,大小写敏感,天然无歧义,不需要 {} 之类定界符。

7.2 类型系统

由 s_field.b_type 映射到公式类型:

b_type 公式类型 说明
number、money decimal 十进制定点
checkbox boolean 参与逻辑运算
date、datetime date 日期运算
input、textarea、select text select 取值为编码字符串
  • 不做隐式类型转换(decimal 与整数字面量之间除外),避免"看起来很智能、出错很难查";
  • 提供显式转换:TEXT(x)、NUMBER(x);
  • 设计期能静态判断的类型不匹配(如文本字段参与乘法)直接报错。

7.3 运算符与优先级

由高到低:

优先级 运算符 说明
1 ( ) 分组
2 一元 -、! 取负、取非
3 * / % 乘、除、取模
4 + - 加减(仅数值,字符串拼接用 &)
5 & 文本拼接
6 > >= < <= == != 比较,结果为 boolean
7 AND 逻辑与
8 OR 逻辑或

用 & 而非 + 做拼接,避免与算术混用产生歧义,符合表格软件的使用习惯。

7.4 字段引用与作用域解析

  • V1 主表公式:只解析主表字段;出现明细字段或聚合函数直接报“不支持”;
  • 后续明细行公式:先本行、再主表(mx_qty * b_price 即行内取表头值);聚合必须带 relationId;
  • 带入字段:作为主表字段直接引用(b_discount_rate)。

7.5 字面量

NULL 是合法值,可在 IF 分支中显式产出空值:IF(b_qty > 0, b_amount / b_qty, NULL)。NULL 不允许直接参加 == / != 比较,统一使用 ISBLANK(x);否则容易把空值误当成普通编码值。

7.6 空值、除零与错误值语义

情形 语义
空值参与算术 按 0 计算(业务直觉:没填就是没有)
空值参与拼接 按空串
空值参与比较 结果为 NULL;IF 将 NULL 条件按 false 处理,AND / OR 使用三值逻辑
除数为 0 或空 结果为空值(NULL),不落 0,避免"0 元"误导
取模为 0 同上
预览期类型不匹配 显示错误态并阻止提交,不把空值伪装成合法结果
结果为 NULL 落库为空值,不用 0 冒充

7.7 精度与舍入

  • 内部一律十进制定点(字符串 + 缩放整数),不使用 JS 浮点;除法保留 20 位有效小数,最后按结果精度收敛;
  • 落库精度以目标数据库列的真实 precision/scale 为准(s_field 当前不保存这两个属性),服务端保存前必须做溢出检查和统一舍入;不能把所有 money 固定成 2 位;
  • ROUND 的舍入规则固定为 half-up,并在前后端测试向量中覆盖负数;未显式 ROUND 时只在落库边界按数据库列 scale 收敛;
  • 展示格式沿用字段类型与 format 配置,不在公式层处理千分位等外观。

8. 函数库

8.1 数值

函数 说明
ROUND(x, n) / ROUNDUP / ROUNDDOWN 四舍五入 / 向上 / 向下舍入到 n 位
ABS(x) 绝对值
CEIL(x) / FLOOR(x) 向上 / 向下取整
MOD(a, b) 取模,b 为 0 时返回空值
POW(a, b) / SQRT(x) 乘方 / 平方根(负数开方返回空值)
MIN(...) / MAX(...) 多参数取最小 / 最大

8.2 条件与空值

函数 说明
IF(cond, a, b) 条件取分支;只计算命中的分支
IFBLANK(x, fallback) x 为空时取 fallback
COALESCE(...) 返回第一个非空值
ISBLANK(x) 是否为空
AND(...) / OR(...) / NOT(x) 逻辑运算的函数形式

8.3 文本

函数 说明
CONCAT(...) / & 拼接
LEN(x) 长度
LEFT(x, n) / RIGHT(x, n) / MID(x, start, n) 截取
UPPER(x) / LOWER(x) / TRIM(x) 大小写与去空白
REPLACE(x, from, to) 替换
TEXT(x) / NUMBER(x) 显式类型转换

8.4 日期

函数 说明
TODAY() / NOW() 当天 / 当前时刻;V1 禁止用于持久化公式(非确定性),后续需增加显式 volatile 重算策略
DATEADD(x, n, unit) 日期加减,unit 取 day / month / year
DATEDIFF(a, b, unit) 日期差
YEAR(x) / MONTH(x) / DAY(x) 取年 / 月 / 日
FORMATDATE(x, format) 按格式输出文本

8.5 聚合(作用于明细/子表)

仅在后续子表阶段的主表公式中可用,参数必须带关系作用域;V1 保存校验暂不放行聚合函数:

函数 说明
SUM(field) 求和
AVG(field) / COUNT(field) 平均 / 计数(非空行)
MIN(field) / MAX(field) 最小 / 最大

说明:聚合函数将使用 relationId.field 形式消除多个子表同名字段的歧义;聚合依赖、行增删和子表事务保存未落地前,配置保存必须报“V1 暂不支持”。条件形式(如 SUM(lines.mx_amount, lines.mx_flag == 1))另行设计。空集合的 SUM 为 0,AVG / MIN / MAX 为空值。

8.6 取数(预留)

LOOKUP(模块, 条件字段, 条件值, 取值字段) 本期不实现:设计期遇到即报错并提示"暂不支持,请改用引用带入字段"。不保留可执行的“语法位”,避免前端能保存而服务端无法复核。

8.7 函数命名与未实现策略

  • 函数名全大写、snake 之外无特殊字符;
  • 未知函数:设计期报错;历史配置在运行期遇到未知函数时允许查看原值,但禁止覆盖保存并上报配置异常;
  • 错误文案集中在 formula/messages.js,纳入 i18n 资源键管理。

9. 依赖与求值

9.1 求值上下文

ctx = {
  fields: { b_qty: 3, b_price: '12.5', ... },  // 主表(含带入字段与静默加载的隐藏字段)
  rows: [ { mx_qty: 2, ... }, ... ],           // 明细行集合;无子表时为 null
  row: { mx_qty: 2, ... },                     // 当前行(明细行公式)
  meta: { moduleId, recordId, isNew, now }
}

求值是同步纯函数:evaluate(ast, ctx) → { value, error }。这正是"引用带入快照"取代实时取数的原因——引擎不必处理异步与缓存。

9.2 依赖抽取

refsOf(ast) → { refs: [{ scope, relationId?, field }], aggregates: [{ fn, relationId, field }], usesRow: boolean }

  • refs:带作用域的直接引用;主表节点形如 main:b_amount,当前行节点形如 row:<relationId>:mx_amount,不得把同名字段合并;
  • aggregates:带关系 ID 的聚合引用,用于把"明细行集合变化"也当作依赖;
  • usesRow:是否依赖当前行,供子表接入时按行实例化。

9.3 拓扑重算

  • 依赖图由各公式的 refsOf 结果构建,节点是 scope + relationId + field,不是裸字段名;
  • 变更字段集合 → 计算下游闭包 → 拓扑排序 → 顺序求值;
  • 链式公式(main:a → main:b → main:c)天然正确,因为重算按依赖方向推进;跨行聚合必须把行增删、行字段更新都转换成对应关系节点的变更。

9.4 循环引用检测

保存配置时对所有带作用域的公式节点执行拓扑排序;剩余节点即构成环路,报"检测到循环引用:main:a → row:lines:mx_b → main:a"。设计期也能实时提示,但以保存拦截为准。V1 遇到子表节点或聚合节点直接报不支持,不得以不完整图继续保存。

9.5 求值失败降级

阶段 失败类型 处理
设计期 语法错误、字段不存在、循环引用、类型不匹配、函数不支持、互斥违规 阻断配置保存,指出位置或环路
运行期预览 单个字段求值异常(类型不符、函数参数非法) 前端显示错误态,不把空值伪装成合法结果
保存期 公式配置、依赖值或计算结果校验失败 服务端拒绝整个事务,返回字段和公式位置
历史数据 旧配置解析失败 允许查看原值,但禁止覆盖保存,提示管理员修复配置

10. 运行期集成

10.1 编辑弹窗重算管线

在 FmsModuleEditModal.vue 现有"加载配置 → 校验 → 构建表单数据"管线之后追加前端预览;真正写入由服务端公式保存处理器负责:

  1. 解析:遍历表单字段节点,解析所有 formula,缓存 AST;
  2. 建图:refsOf → 依赖图 + 拓扑序;发现环路即显示错误并阻止提交,不能跳过后继续保存;
  3. 快照:记录当前所有字段值(此时不计算);
  4. 监听与重算(见 10.3);
  5. 提交:提交用户输入、公式配置摘要和临时覆盖声明;计算字段值仅作为预览,不是可信数据。

10.2 初始化不重算与新增初算

场景 行为
编辑已有记录 回填后只建快照、不计算;依赖未变时保留历史值,依赖变更时前端预览并由服务端按当前公式重算
新增记录 按默认值构建后执行一次全量初算;服务端保存时再次计算并以服务端结果为准

10.3 重算收敛(快照对比法)

不区分"用户输入"还是"程序写回",避免脆弱的来源标记:

  1. watch(formData, { deep: true }) 触发后与快照逐字段比较,得到真实变化集;
  2. 变化集为空 → 结束;
  3. 非空 → 取下游闭包,按拓扑序重算,写回 formData;
  4. 回到第 1 步,直到无变化(通常一轮收敛;链式公式最多两轮)。

同一轮输入用 nextTick 合并,避免逐字符触发。

10.4 隐藏依赖字段

现状:当前 FmsModuleEditModal.vue 按 b_visible !== 1 过滤字段,隐藏字段不会进入 formData;collectRowData() 还会跳过只读/禁用字段。因此原稿所说的“静默加载”和“计算字段落库”目前都未实现。

策略:加载配置后,把"被公式引用但不在可编辑行里"的字段纳入静默字段集合——只取值、不渲染:

  • 参与求值:进 ctx.fields 与 formData;
  • 不参与渲染与用户编辑;
  • 保存时按基线 diff;计算字段由公式保存处理器单独处理,不能因 readonly 被前端 collectRowData() 丢弃。未改动的普通隐藏字段不提交,避免旧值回写造成并发覆盖。

10.5 提交与保存

  • 计算字段与覆写态字段随请求提交供服务端校验,但不应被当作普通字段绕过公式保存处理器;
  • 只读用 readonly 表达,不用 disabled——disabled 在部分控件下不参与提交;
  • 提交值不是权威值。服务端保存处理器必须加载当前 edit 配置和数据库列元数据,在同一事务内执行:公式校验 → 依赖变更判断 → 严格公式重算/覆盖值校验 → 精度校验 → 写入。通用 saveobjt 需要增加 moduleCode 和公式处理元数据,或由模块保存服务包装调用;仅传 table + key_field 的直写请求不得写入带公式模块。

请求元数据示例(不写入业务表):

{
  "table": "b_order",
  "moduleCode": "b_order",
  "formulaRevision": "sha256:...",
  "key_field": "b_id",
  "formulaOverrides": ["b_amount"],
  "updates": [{ "b_id": "...", "b_qty": 3, "b_amount": "12.00" }]
}

moduleCode 必须由服务端校验其对应的保存表,不能由客户端借此扩大可写范围;formulaRevision 是服务端按规范化公式节点计算的摘要,变化时拒绝本次保存并要求刷新;formulaOverrides 只能列出当前请求实际修改且配置允许覆盖的字段。

10.6 服务端保存规则(V1 必须实现)

服务端以数据库旧值和本次请求合并出 newRow,按公式节点逐个处理:

  1. 新增记录:所有严格计算字段均由公式计算;客户端传入值被忽略,覆写字段只有在声明临时覆盖且配置允许时才采用。
  2. 编辑记录且依赖未变:严格计算字段保留旧值,但若请求显式改写该字段,必须验证其等于公式结果;不一致则拒绝或以公式结果替换,不能静默接受。
  3. 编辑记录且依赖已变:严格计算字段由服务端重算。overridable=true 的字段只有在请求的 formulaOverrides 明确包含该字段时才接受人工值;该声明不落库,仅在本次事务有效。
  4. 公式字段不得通过普通 updates 绕过上述流程;导入、工作流回写、批处理复用同一服务,否则直接拒绝写入并返回“需经公式保存处理器”。
  5. 服务端计算结果必须按实际数据库列的 precision/scale 校验;任一公式失败时整个保存事务回滚。
  6. 服务端读取旧行并写入新行必须使用乐观并发版本(优先 b_updated_at/ETag,或在事务内锁定目标行);版本冲突时回滚,不能基于过期旧值计算后覆盖他人修改。

10.7 明细行与汇总(后续阶段)

引擎可以预留 ctx.rows / ctx.row,但 V1 不宣称已支持;主子表编辑和服务端事务保存就绪后:

  • 明细行公式:每行一个上下文实例,按行重算;
  • 主表汇总公式:refsOf 的 aggregates 使"行集合增删改"也构成依赖 → 触发主表重算;
  • 表头变更影响明细行(mx_qty * b_price)→ 行内公式重算 → 若汇总又依赖行内计算字段,则继续推进,收敛逻辑天然支持。

11. 覆写策略

11.1 字段级开关

overridable 行为
缺省(false) 严格只读,值恒等于公式结果
true 默认显示公式结果,字段旁提供"手工调整"入口;入口临时解除只读并产生 formulaOverrides,不是把 readonly 永久改成可编辑

11.2 会话内覆写

  • 用户手工改写后,该字段进入"已覆写"态,当前会话内不再被公式覆盖(即使依赖字段再变);
  • 提供"恢复公式":重算一次并退出覆写态;
  • 覆写态在 UI 上有明确标识(如字段旁标记),避免"值看起来算错了"的困惑。
  • 保存时通过请求元数据 formulaOverrides: ["b_amount"] 传递本次会话的覆写声明;该列表不是业务字段,不落库,由服务端校验字段的 overridable 后才接受人工值。

11.3 跨会话语义

不持久化覆写状态,重开单据依靠"依赖变更才重算"保留人工值;但服务端仍会校验显式提交值,防止通用接口伪造覆写:

场景 结果
重开旧单据、未改依赖字段 人工值保留
重开旧单据、改了依赖字段 公式重算并覆盖人工值(公式优先)

这是有意为之:等价于"人工值是一次性快照,公式在输入变化时恢复主导"。若将来出现"人工值必须永久锁定"的硬需求,再引入覆写标记表(见 18 章),不提前加表和分支。

12. 配置端(设计器)

12.1 字段属性面板

在 design-editor/form/FormInspector.vue(纯展示、变更均 emit 上抛)新增「计算公式」区,仅字段节点可见:

  • 表达式输入框(多行可展开);
  • 「插入字段」「插入函数」按钮,打开插入面板;
  • 「允许人工改写」开关(对应 overridable);
  • 实时校验结果与错误定位(列位置)。

配套改动:schemaModel.js 的字段行草稿增加 b_formula / b_overridable 两个字段(加载、回写、默认值删除策略与其他属性一致:空值即删除属性),FormDesignEditor.vue 承接 emit 写入草稿。

12.2 插入面板与试算

  • 字段列表:本模块字段(标注主表 / 明细)、带入字段;插入时写入字段编码;
  • 函数列表:按 8 章分类展示,选中插入函数模板并聚焦到参数位置;
  • 试算:用各字段的默认值(或手工填的示例值)执行一次求值,展示结果与中间错误,帮助配置人自查。

12.3 校验与拦截

  • 实时:语法、字段存在性、类型不匹配;
  • 保存配置前:module-management/index.vue 做交互校验;服务端保存配置时必须再次校验——语法、字段存在性、循环引用、自动编码互斥、聚合使用位置、明细字段裸用;
  • 阻断保存并给出可读错误列表;循环引用需展示完整环路。

12.4 画布标识与引用清理

  • 画布上计算字段显示 fx 角标(样式归属 design-editor 内部,走组件作用域样式);
  • 删除 / 停用被引用的字段时,提示"被 N 个公式引用(列出字段名)",不自动改写公式——静默改语义比报错更危险,交由配置人决定;
  • 字段改名(改 b_field)同理提示。

13. 跨模块取数

本期采用引用带入快照:

选择客户(选择器)
  └── 把折扣率、结算方式等写入本地字段(带入字段,s_field 中登记)
        └── 公式只引用本地字段:b_amount * b_discount_rate
维度 结论
求值 同步可算,无需异步取数、缓存与失败降级
历史 快照稳定:主数据后续变更不回溯已保存单据
代价 主数据变更后需显式重选/重带才会更新
约束 带入字段必须在本模块 s_field 登记(可见或隐藏),否则设计期报错
演进 实时取数另立语法和权限设计;当前不接受 LOOKUP

14. 与列表、查询、其它入口的关系

14.1 列表与查询

计算字段的值已落库,因此:

  • 列表显示、筛选、排序、合计(view 的 summary: sum/count)、导出直接可用,不需要在 b_query_sql 里重复定义公式;
  • 口径快照:公式后续修订不回刷历史值,历史行仍按旧口径。这是"依赖变更才重算"的必然结果,属有意设计;需要统一口径时走批量重算(见 18 章)。

14.2 非前端入口

接口直写、数据导入、工作流回写、批处理必须经过同一个服务端公式保存处理器,不能各自实现一套计算:

  • 业务入口传入模块编码和完整变更上下文,由服务端加载公式配置并重算/校验;
  • 不能识别模块或无法完成公式校验时,拒绝写入,而不是落空值或接受客户端结果;
  • 当前 DataController.saveObject() / DataSaveService 只按 table + key_field 执行普通 SQL,尚无该钩子;在钩子完成前,本设计只能作为未交付能力,不能开放带公式模块的通用直写。

14.3 字段权限

严格计算字段在 s_user_field_power 中只保留可见性;允许覆写的字段还要校验可编辑权限,formulaOverrides 不能绕过字段权限。

15. 校验规则汇总

# 规则 时机 处理
1 表达式语法错误 实时 + 保存前 阻断
2 引用字段不存在 / 已停用 实时 + 保存前 阻断
3 循环引用 实时 + 保存前 阻断,展示环路
4 类型不匹配(可静态判断) 实时 + 保存前 阻断
5 自动编码字段挂公式 保存前 阻断
6 V1 使用聚合函数或明细行作用域 实时 + 保存前 阻断,提示等待子表阶段
7 主表公式裸用明细字段(未包关系聚合) 保存前 阻断,提示使用带 relationId 的聚合
8 使用未实现函数(如 LOOKUP) 实时 阻断并提示替代方案
9 依赖字段被隐藏 保存前 允许,但必须纳入静默字段集合;当前运行时未实现时阻断
10 带入字段未在 s_field 登记 保存前 阻断
11 仅 table + key_field 的普通直写 保存前 带公式模块阻断,必须经过公式保存处理器
12 客户端计算字段与服务端结果不一致 保存时 整个事务阻断并返回字段错误
13 使用 TODAY() / NOW() 等非确定性函数 配置保存前 V1 阻断,提示使用显式业务日期字段
14 公式目标不是可写数据表真实字段,或目标列不允许为空但结果为空 配置/保存前 阻断
15 公式配置版本与编辑页面不一致 保存时 拒绝并提示刷新,避免按新旧口径静默保存
16 覆写字段没有字段编辑权限 保存时 拒绝 formulaOverrides,保持公式结果

16. 代码结构与接口

16.1 引擎模块

fms-vue/src/components/fms-module-common/formula/
├── decimalMath.js   十进制定点运算(加 / 减 / 乘 / 除 / 比较 / 舍入)
├── values.js        值类型系统(decimal / boolean / text / date / null 与字段类型映射)
├── lexer.js         词法分析
├── parser.js        语法分析 → 带位置的 AST
├── functions.js     函数库(签名 + 实现 + 元信息,供插入面板复用)
├── evaluate.js      求值(作用域解析 / 空值 / 精度 / 降级)+ 静态类型推断
├── deps.js          依赖抽取(refsOf)
├── graph.js         依赖图、拓扑排序与循环检测
├── validate.js      设计期校验(语法 / 字段 / 类型 / 位置)
├── revision.js      配置摘要(FNV-1a 64,前后端算法一致)
├── messages.js      错误文案(i18n 资源键集中)
└── index.js         对外 API(含 createFormulaModel 运行模型)

Java 侧镜像实现位于 fms-api/.../service/formula/,与前端共用 fms-vue/tests/fixtures/formula-vectors.json 的测试向量,防止两套实现漂移。

16.2 对外 API

API 说明
parse(text) → { ast, errors } 解析表达式,错误带列位置
evaluate(ast, ctx) → { value, error } 同步求值
refsOf(ast) → { refs, aggregates, usesRow } 返回带作用域/关系 ID 的依赖
validateFormula(text, { fields, scope, phase }) → errors[] 设计期和服务端保存期共用校验(返回 { ast, errors, refs, type },errors 为可读错误列表)
validateFormulas(nodes, { fields }) → { errors, graph, results } 整模块校验:逐条 + 依赖图 + 循环引用
buildGraph(formulas) → { order, cycles } 建图与拓扑序
createRecalculator(graph) → recalc(fields, ctx) 运行期重算器
createFormulaModel({ editSchema, fields }) 运行模型:affectedFields / evaluateField / recalculate / revision
formulaRevision(nodes) → 'fnv1a64:…' 配置摘要(前后端一致)

16.3 改动文件清单

文件 改动
design-editor/common/schemaModel.js 字段行草稿增加 b_formula / b_overridable(加载、回写)
design-editor/form/FormInspector.vue 新增「计算公式」区与 emit
design-editor/form/FormDesignEditor.vue 承接公式变更写入草稿
design-editor/common/FormulaEditor.vue(新增) 表达式输入 + 插入面板 + 实时校验 + 试算
module-management/index.vue 保存前统一校验(含环路)
components/fms-module-edit/FmsModuleEditModal.vue 重算管线、静默字段加载、覆写交互
components/fms-module-common/formula/*(新增) 引擎(含 values.js / revision.js)与单元测试
fms-vue/tests/fixtures/formula-vectors.json(新增) 前后端共享测试向量
fms-vue/tests/unit/formula-engine.spec.js 等(新增) 引擎 / 设计器 / 运行期集成测试
fms-api/.../formula/*(新增) Java 服务端解析/校验/求值;与前端共享语法和测试向量
DataSaveService 或模块保存服务 在同一事务内接入公式校验、重算、覆盖声明和精度检查

17. 测试与验收清单

17.1 引擎单测

  • 词法 / 语法:优先级、括号、一元负号、错误位置;
  • 精度:0.1 + 0.2、乘除与舍入、20 位除法收敛、按数据库列 scale 的 half-up;
  • 空值 / 除零 / NULL 字面量、禁止 == NULL 与 IF 分支;
  • 函数:逐个覆盖正常与边界(负数开方、MID 越界、DATEDIFF 跨月跨年);
  • 依赖:refsOf 对带作用域的直接引用 / 关系聚合 / 当前行的识别;
  • 图:链式拓扑序、自环与多节点环路检测;
  • 校验:字段不存在、类型不匹配、自动编码互斥、聚合位置。

17.2 运行时组件测试

  • 打开旧单据不重算;新增记录初算一次;
  • 依赖字段变更触发重算、链式两轮收敛、无变化不重算;
  • 覆写后不再被覆盖、恢复公式可回到计算结果;
  • 隐藏依赖字段静默加载且未改动不提交。

17.3 服务端保存测试

  • 直接调用 saveobjt 不能绕过带公式模块的校验;缺少 moduleCode 或公式上下文时拒绝;
  • 新增和依赖变更时服务端结果覆盖客户端伪造值;依赖未变时历史值保持且显式不一致值被拒绝;
  • formulaOverrides 只能覆盖 overridable=true 的字段,且不写入业务表;
  • 前后端使用同一组 JSON 测试向量,覆盖精度溢出、循环、函数错误和事务回滚。

17.4 验收样例

场景 公式
主表金额 ROUND(b_qty * b_price, 2)
除零保护 IF(b_qty > 0, b_amount / b_qty, NULL)
表头合计(后续子表阶段) SUM(lines.mx_amount)
编号拼接 b_name & '-' & b_no
含税金额 b_amount + ROUND(b_amount * b_tax_rate, 2)

18. 后续演进

方向 说明
子表运行时接入 明确 relationId、逐行保存顺序、聚合依赖和事务边界后再实现
实时取数 另立设计;不在当前语法中保留未实现的 LOOKUP
永久锁定覆写 引入覆写标记表(模块 + 记录主键 + 字段 + 覆写人/时间),保存同事务写入
批量重算历史数据 公式修订后按新口径重算存量记录(列表页动作 + 服务端执行)

19. 决策记录

# 决策 结论 依据
1 存储位置 s_module_schema.edit 字段节点 formula 核心设计文档 6.2 已明确"公式属界面配置,不入 s_field";不建新表、不建公式专用接口,现有保存请求需扩展公式元数据
2 计算范围 V1 先交付主表字段与带入快照;子表场景后续接入 当前编辑器和保存链路尚无可靠的关系/明细事务上下文,先避免不可验证的“设计到位”
3 求值位置 前端预览 + 服务端权威校验/重算,结果落库 录入即时反馈与数据完整性同时成立;所有入口共享同一口径
4 重算时机 依赖变更才重算,新增记录初算 历史数据快照语义,不做静默改写
5 引擎实现 自研轻量解析器 + 十进制定点 零第三方依赖,贴项目自研风格;语法与函数库完全可控
6 跨模块取数 引用带入快照 同步求值、历史稳定;实时取数另立设计,不保留未实现语法
7 人工覆写 字段级 overridable + 请求级 formulaOverrides 覆写状态不入业务表,但必须让服务端能验证本次会话声明,避免普通直写绕过
8 配置界面 文本表达式 + 插入面板 + 实时校验 + 试算 配置人是管理员,表达式直接可读;可视化拼装收益低于成本