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

702 lines
40 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(评审修订版)
> **评审结论(2026-09-26)**:保留“公式属于表单配置、解析为 AST、依赖图拓扑重算、跨模块使用带入快照”的主方向;原稿不能直接实施。主要问题是把前端结果当成可信写入值、把不同作用域的字段编码放进同一张依赖图、未定义 `NULL`/覆盖保存语义,以及把尚未存在的子表运行时写成已可接入。本版将服务端校验与重算设为保存闭环的必要部分,并把 V1 的交付边界收窄到可验证的范围。
**本版必须满足的验收门槛**:
- 前端计算只负责即时反馈;服务端在同一保存事务内校验公式配置、计算结果和可覆盖权限,不能信任客户端提交的计算字段值。
- 依赖节点使用带作用域的 ID(至少区分主表字段、当前明细行字段、明细集合聚合),禁止仅用字段名建全局图。
- `NULL`、舍入、覆盖和历史快照语义在前后端共用一份规范;未实现服务端执行器前,不得宣称公式结果“可安全落库”。
- V1 先交付主表字段公式和引用带入快照;子表逐行公式、聚合及跨模块实时取数作为后续阶段,必须在关系标识和事务保存链路明确后接入。
## 1. 文档定位
本文是《FMS 新系统核心表结构设计》界面配置能力的补充设计,解决"单据字段由其它字段算出来"的问题:金额 = 数量 × 单价、税额 = 金额 × 税率、含税金额 = 金额 + 税额、表头合计 = 明细求和。
计算公式被设计成**界面配置的一部分**,不是数据库计算列,也不是可执行后端脚本;前后端各有一个受限求值器,服务端版本负责最终校验与重算:
```text
业务模块
└── 表单配置(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` 约束表达业务规则;
- 前端组件自研优先,不轻易引入第三方依赖。
相关文档:
- [FMS 新系统核心表结构设计](./FMS新系统核心表结构设计.md)
- [FMS 工作流与审批设计](./FMS工作流与审批设计.md)
## 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 字段节点上的字符串表达式,声明"这个字段的值由其它字段算出"。例如:
```text
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. 总体架构
```text
配置期(模块管理 · 表单设计器)
字段属性面板
├── 公式输入 + 插入字段/函数 + 实时校验 + 试算
└── 可覆写开关
↓ 写入草稿
s_module_schema.edit 的字段节点 { formula, overridable }
↓ 保存前校验(语法 / 字段 / 类型 / 循环 / 互斥)
/data/saveobjt 落库
运行期(业务单据编辑弹窗)
加载配置 → 解析表达式 → 建依赖图 → 建立值快照(不计算)
↓ 用户改动依赖字段
快照对比 → 变更字段集 → 下游闭包 → 拓扑序重算 → 收敛 → 更新快照
↓ 提交
提交用户输入 + 公式预览 → 服务端校验/重算 → 列表/筛选/排序/导出直接可用
```
## 6. 配置模型
### 6.1 字段节点扩展
在《FMS 新系统核心表结构设计》6.2 节表单节点的 FieldNode 上增加两个属性:
```json
{
"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 求值上下文
```js
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` 的直写请求不得写入带公式模块。
请求元数据示例(不写入业务表):
```json
{
"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. 跨模块取数
本期采用**引用带入快照**:
```text
选择客户(选择器)
└── 把折扣率、结算方式等写入本地字段(带入字段,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 引擎模块
```text
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 | 配置界面 | 文本表达式 + 插入面板 + 实时校验 + 试算 | 配置人是管理员,表达式直接可读;可视化拼装收益低于成本 |