40 KiB
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 设计目标
- 表达式与字段绑定,配置一处生效一处,不需要为"公式"新建表;保存链路可复用现有数据接口,但必须增加公式处理钩子;
- V1 覆盖主表字段间计算和跨模块带入快照;明细行内、明细汇总到表头、明细行引用表头先定义作用域契约,待子表保存链路具备后接入;
- 语法可校验、可静态检查(字段存在性、类型、循环引用),不做"跑起来才知道错";
- 数值计算走十进制定点,杜绝
0.1 + 0.2类浮点误差,金额落库精度确定; - 前端即时重算,用户改一个字段立刻看到结果,不需要保存后再刷新;
- 依赖变更才重算:打开旧单据不做静默改写,历史数据保持快照语义;
- 计算结果经服务端校验/重算后随表单落库,列表显示、筛选、排序、合计、导出直接可用;
- 允许按字段配置"可人工改写",覆盖特价、手工调价等真实业务;
- 引擎与作用域解耦(
ctx = { fields, rows, row }),作用域信息进入依赖节点而不是依赖调用方猜测; - 解析、校验、求值规则可由前端和 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)。
- 实现说明:摘要是「规范化公式节点」的 FNV-1a 64(字段按编码升序,含
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 现有"加载配置 → 校验 → 构建表单数据"管线之后追加前端预览;真正写入由服务端公式保存处理器负责:
- 解析:遍历表单字段节点,解析所有
formula,缓存 AST; - 建图:
refsOf→ 依赖图 + 拓扑序;发现环路即显示错误并阻止提交,不能跳过后继续保存; - 快照:记录当前所有字段值(此时不计算);
- 监听与重算(见 10.3);
- 提交:提交用户输入、公式配置摘要和临时覆盖声明;计算字段值仅作为预览,不是可信数据。
10.2 初始化不重算与新增初算
| 场景 | 行为 |
|---|---|
| 编辑已有记录 | 回填后只建快照、不计算;依赖未变时保留历史值,依赖变更时前端预览并由服务端按当前公式重算 |
| 新增记录 | 按默认值构建后执行一次全量初算;服务端保存时再次计算并以服务端结果为准 |
10.3 重算收敛(快照对比法)
不区分"用户输入"还是"程序写回",避免脆弱的来源标记:
watch(formData, { deep: true })触发后与快照逐字段比较,得到真实变化集;- 变化集为空 → 结束;
- 非空 → 取下游闭包,按拓扑序重算,写回 formData;
- 回到第 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,按公式节点逐个处理:
- 新增记录:所有严格计算字段均由公式计算;客户端传入值被忽略,覆写字段只有在声明临时覆盖且配置允许时才采用。
- 编辑记录且依赖未变:严格计算字段保留旧值,但若请求显式改写该字段,必须验证其等于公式结果;不一致则拒绝或以公式结果替换,不能静默接受。
- 编辑记录且依赖已变:严格计算字段由服务端重算。
overridable=true的字段只有在请求的formulaOverrides明确包含该字段时才接受人工值;该声明不落库,仅在本次事务有效。 - 公式字段不得通过普通
updates绕过上述流程;导入、工作流回写、批处理复用同一服务,否则直接拒绝写入并返回“需经公式保存处理器”。 - 服务端计算结果必须按实际数据库列的 precision/scale 校验;任一公式失败时整个保存事务回滚。
- 服务端读取旧行并写入新行必须使用乐观并发版本(优先
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 | 配置界面 | 文本表达式 + 插入面板 + 实时校验 + 试算 | 配置人是管理员,表达式直接可读;可视化拼装收益低于成本 |