Odoo 中的循环重新计算和舍入漂移

发布: (2026年3月1日 GMT+8 16:51)
10 分钟阅读
原文: Dev.to

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 中修改字段时:

  1. 前端将更改的值发送到服务器。
  2. Odoo 创建一个临时记录快照。
  3. 被修改的字段被标记为已更改。
  4. 所有相关的 @api.onchange@api.depends 逻辑被执行。
  5. 如果在此过程中还有其他字段发生变化,Odoo 会再次执行一次遍历。

这会一直持续,直到没有更多字段被视为“已更改”。这种行为是正确且有意的——它确保了依赖字段之间的一致性。

然而,在 双向 设置中,例如:

purchase_price      → updates purchase_price_usd
purchase_price_usd  → updates purchase_price

机制就变成了对称的。

  • 当用户更改 purchase_price 时:
    1. purchase_price_usd 被重新计算。
    2. 那次重新计算又修改了 purchase_price
    3. 系统检测到变化 → 开始另一次遍历。

即使差异仅是四舍五入的微调,也仍然被视为一次变化。框架并不知道哪个字段代表用户的原始意图;它只看到数值不同并尝试同步它们。这就是循环重新计算产生的原因。

Rounding as the Hidden Amplifier

循环重新计算本身并不总是可见的。真正的问题在涉及四舍五入时才会显现。

在双向转换中,我们隐含地假设:

f⁻¹(f(x)) = x

但一旦应用四舍五入,这一等式就不再成立。以保留两位小数为例:

round(round(x / rate, 2) * rate, 2) ≠ x

即使是 0.01 的差异也会被检测为真实的变化:

  • 从框架的角度来看:数值不同 → 必须同步。
  • 从用户的角度来看:他们输入的数值被重新写入。

四舍五入并没有创建循环;它使循环可观察到。若不进行四舍五入,微小的浮点误差仍可能存在,但通常在 UI 层面不可见。进行四舍五入后,漂移变得显式,用户意图被覆盖。这正是数学正确性与实际 ERP 行为冲突的所在。

为什么常规方法无法解决

完全可以使用 @api.dependsinverse 实现双向同步。许多 Odoo 模块在 inverse 方法中使用上下文标记来防止递归写入。在数据库层面,这种做法运行可靠。

真正的限制出现在 UI 层面。

  • compute + inverse 能确保在记录 写入 时保持一致,但 不能 保证用户编辑表单时的即时同步。

在表单视图中,更新通过 onchange 进行:

  1. Odoo 构建记录的临时快照。
  2. 标记已更改的字段。
  3. 执行相关的 onchange 与计算逻辑。
  4. 检查哪些字段被修改。
  5. 如有必要,进行额外的遍历。

所有这些都在单个 onchange 执行周期内完成——没有新的请求,也没有全新的评估上下文。相同的记录快照会被迭代处理,直至不再检测到进一步的更改。

如果两个字段相互更新且四舍五入产生了哪怕很小的差异,框架会将其视为真实的修改,循环就会继续。因此,虽然 compute + inverse 能在 写入时 保持数值一致,却 不能 解决 UI 级别的循环重新计算问题。

要在表单编辑期间保留用户意图,必须在 onchange 循环内部控制方向。

Source:

控制 Onchange 循环中的方向

核心洞见: 在任意时刻,只有一个字段代表用户意图。

  • 当用户编辑 purchase_price 时,该值必须被视为权威。
  • 同步字段(purchase_price_usd)应当被更新,但在同一次循环中原始字段不能再次被触碰

反之亦然。

onchange 循环对两个字段对称处理——它只看到值的差异并尝试保持它们一致。要打破这种对称性,我们需要:

  1. 检测是哪一个字段触发了 onchange
  2. 设置一个上下文标记,指示同步的方向。
  3. 防止在同一次循环中进行反向更新。

通过显式控制传播方向,我们可以阻止因四舍五入导致的无休止来回,并保留用户实际输入的值。

示例(简化版)

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 循环中,任何检测到的差异都会触发另一次评估。涉及四舍五入时,即使是极小的偏差也会被视为真实的更改。

唯一可靠的保持用户意图的方式是 明确控制方向

在任何给定时刻:

  1. 必须将一个字段视为真实来源。
  2. 另一个字段必须由此派生。
  3. 必须在同一评估循环中抑制反向重新计算。

这不是一种变通方法;它是 UI 驱动系统中双向派生字段的架构约束。

0 浏览
Back to Blog

相关文章

阅读更多 »

不糟糕的语义失效

缓存问题 如果你在 Web 应用上工作了一段时间,你就会了解缓存的情况。你加入缓存,一切都变快了,然后有人……

按组反转数组

问题描述:将数组按给定大小 k 分组后逆序。数组被划分为长度为 k 的连续块(窗口),每个块都被逆序。