Odoo 中的循环重新计算和舍入漂移
I’m happy to translate the article for you, but I need the full text of the post (the part you’d like translated) in order to do so. Could you please paste the article’s content here? Once I have it, I’ll provide a Simplified‑Chinese translation while keeping the source link, formatting, markdown, and any code blocks unchanged.
实际问题
在 ERP 系统中,常常会有两个字段以不同的单位表示同一个值——例如,公司币种的采购价和以 USD 为单位的同一价格。这两个字段都是可编辑的,并且必须保持同步。
为简化起见,假设转换逻辑如下:
purchase_price_usd = round(purchase_price / rate, 2)
purchase_price = round(purchase_price_usd * rate, 2)
这里的公式故意写得很简化。实际系统中你会使用合适的货币工具、精度处理以及框架帮助函数。此示例的目标是演示重新计算问题,而不是货币实现细节。
一个基本案例
rate = 3- 用户输入
purchase_price = 10
系统计算得到:
purchase_price_usd = round(10 / 3, 2) = 3.33
purchase_price = round(3.33 * 3, 2) = 9.99
此时原始值从 10 变成了 9.99。数学上这是正确的,但从用户的角度来看却不合理。用户输入了 10;系统不应悄悄把它改写。
- 这 不是 浮点数错误。
- 这 不是 四舍五入错误。
- 这是一种组合导致的后果:
- 双向字段派生
- 四舍五入
- 自动重新计算
…而这正是循环重新计算开始破坏用户意图的地方。
为什么会出现:Onchange 循环
在 Odoo 中,可编辑表单在很大程度上依赖 @api.onchange。当用户在 UI 中修改字段时:
- 前端将更改的值发送到服务器。
- Odoo 创建一个临时记录快照。
- 被修改的字段被标记为已更改。
- 所有相关的
@api.onchange和@api.depends逻辑被执行。 - 如果在此过程中还有其他字段发生变化,Odoo 会再次执行一次遍历。
这会一直持续,直到没有更多字段被视为“已更改”。这种行为是正确且有意的——它确保了依赖字段之间的一致性。
然而,在 双向 设置中,例如:
purchase_price → updates purchase_price_usd
purchase_price_usd → updates purchase_price
机制就变成了对称的。
- 当用户更改
purchase_price时:purchase_price_usd被重新计算。- 那次重新计算又修改了
purchase_price。 - 系统检测到变化 → 开始另一次遍历。
即使差异仅是四舍五入的微调,也仍然被视为一次变化。框架并不知道哪个字段代表用户的原始意图;它只看到数值不同并尝试同步它们。这就是循环重新计算产生的原因。
Rounding as the Hidden Amplifier
循环重新计算本身并不总是可见的。真正的问题在涉及四舍五入时才会显现。
在双向转换中,我们隐含地假设:
f⁻¹(f(x)) = x
但一旦应用四舍五入,这一等式就不再成立。以保留两位小数为例:
round(round(x / rate, 2) * rate, 2) ≠ x
即使是 0.01 的差异也会被检测为真实的变化:
- 从框架的角度来看:数值不同 → 必须同步。
- 从用户的角度来看:他们输入的数值被重新写入。
四舍五入并没有创建循环;它使循环可观察到。若不进行四舍五入,微小的浮点误差仍可能存在,但通常在 UI 层面不可见。进行四舍五入后,漂移变得显式,用户意图被覆盖。这正是数学正确性与实际 ERP 行为冲突的所在。
为什么常规方法无法解决
完全可以使用 @api.depends 和 inverse 实现双向同步。许多 Odoo 模块在 inverse 方法中使用上下文标记来防止递归写入。在数据库层面,这种做法运行可靠。
真正的限制出现在 UI 层面。
compute + inverse能确保在记录 写入 时保持一致,但 不能 保证用户编辑表单时的即时同步。
在表单视图中,更新通过 onchange 进行:
- Odoo 构建记录的临时快照。
- 标记已更改的字段。
- 执行相关的
onchange与计算逻辑。 - 检查哪些字段被修改。
- 如有必要,进行额外的遍历。
所有这些都在单个 onchange 执行周期内完成——没有新的请求,也没有全新的评估上下文。相同的记录快照会被迭代处理,直至不再检测到进一步的更改。
如果两个字段相互更新且四舍五入产生了哪怕很小的差异,框架会将其视为真实的修改,循环就会继续。因此,虽然 compute + inverse 能在 写入时 保持数值一致,却 不能 解决 UI 级别的循环重新计算问题。
要在表单编辑期间保留用户意图,必须在 onchange 循环内部控制方向。
Source: …
控制 Onchange 循环中的方向
核心洞见: 在任意时刻,只有一个字段代表用户意图。
- 当用户编辑
purchase_price时,该值必须被视为权威。 - 同步字段(
purchase_price_usd)应当被更新,但在同一次循环中原始字段不能再次被触碰。
反之亦然。
onchange 循环对两个字段对称处理——它只看到值的差异并尝试保持它们一致。要打破这种对称性,我们需要:
- 检测是哪一个字段触发了
onchange。 - 设置一个上下文标记,指示同步的方向。
- 防止在同一次循环中进行反向更新。
通过显式控制传播方向,我们可以阻止因四舍五入导致的无休止来回,并保留用户实际输入的值。
示例(简化版)
def onchange(self, values, field_names, fields_spec):
ctx = {}
if "purchase_price" in field_names:
ctx["skip_purchase_price_recompute"] = True
if "purchase_price_usd" in field_names:
ctx["skip_purchase_price_usd_recompute"] = True
self = self.with_context(**ctx)
return super().onchange(values, field_names, fields_spec)
对应的 compute 方法
@api.depends("purchase_price")
def _compute_purchase_price_usd(self):
if self.env.context.get("skip_purchase_price_usd_recompute"):
return
for record in self:
record.purchase_price_usd = ...
这 不会 改变数学模型;它改变的是执行模型。
与其让两个字段相互争夺主导权,不如显式定义在特定用户操作下哪一侧是事实来源。
四舍五入仍然存在,公式仍保持对称,但重新计算不再是循环的。
用户意图得以保留。
结论
双向字段同步加上四舍五入不可避免地破坏数学可逆性。
- 框架 并非 错误。
- 公式 并非 错误。
问题出在 UI 重新计算时将两个字段都视为同等权威。
在 Odoo 的 onchange 循环中,任何检测到的差异都会触发另一次评估。涉及四舍五入时,即使是极小的偏差也会被视为真实的更改。
唯一可靠的保持用户意图的方式是 明确控制方向。
在任何给定时刻:
- 必须将一个字段视为真实来源。
- 另一个字段必须由此派生。
- 必须在同一评估循环中抑制反向重新计算。
这不是一种变通方法;它是 UI 驱动系统中双向派生字段的架构约束。