# 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::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 | 配置界面 | 文本表达式 + 插入面板 + 实时校验 + 试算 | 配置人是管理员,表达式直接可读;可视化拼装收益低于成本 |