库存估值链路

Odoo 库存价值为什么挂在 move 上而不是 quant 上:_action_done、_set_value 与会计分录链路讲透

很多人能看懂库存数量,却总说不清 Odoo 为什么把价值主链挂在 stock.move 上,而不是直接挂在 quant 上。本文从 stock_account 里的 _action_done、_set_value、_create_account_move 出发,把数量流转与财务表达之间的边界讲透。

库存
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多人学 Odoo 库存,先建立的是“数量世界”:

  • quant 表示现存量;
  • move 表示流转;
  • move line 表示明细执行。

这套理解没问题,但一到估值层,很多人马上会问:

既然 quant 才像库存余额,那为什么 Odoo 不把价值主要挂在 quant 上,反而把 valueremaining_qtyremaining_value 这些核心估值语义大量放在 stock.move 这一层?

如果这个问题没想通,你读 stock_account 时就会一直有违和感。

实际上,Odoo 这样设计非常合理,因为价值跟“存量截图”不是一回事,价值首先属于“流转事件”

先分清两个世界:存量 vs 事件

quant 更像“当前余额切片”

quant 擅长表达:

  • 某产品在某库位、lot、owner、package 维度下现在还有多少;
  • 当前库存占用与可用量情况如何;
  • 仓内此刻的存量状态是什么。

它非常适合回答“现在还剩多少”。

move 更像“发生过一笔什么流转”

move 擅长表达:

  • 一笔货从哪到哪;
  • 发生在什么时候;
  • 数量是多少;
  • 它属于哪条业务链;
  • 这笔事件对价值造成了什么影响。

而估值恰恰首先是后者。

你之所以能讨论 FIFO、remaining value、出库成本,不是因为系统盯着“余额”,而是因为系统在追踪“哪一笔流入被哪一笔流出消费了多少”。

这本质上是事件配对,不是余额涂色。

stock_account_action_done() 的改写非常说明问题

stock_account 里,stock.move._action_done() 被继承后,做了几件很关键的事:

  1. 先找出 outgoing valued moves;
  2. 对 outgoing move 先 _set_value()
  3. 调用库存层的 super()._action_done() 真正确认数量流转;
  4. 再对 incoming / dropship 等 valued move _set_value()
  5. 统一 _create_account_move()
  6. 对 FIFO 产品更新 standard price,并补 analytic move。

这个顺序很重要,因为它告诉你:

  • 数量完成价值落账 既强相关,又不是同一层代码;
  • 价值不是 quant 自己“长出来”的,而是随着 move done 这个事件被计算和确认。

也就是说,Odoo 把“估值的主语”定义成了 move,而不是 quant。

为什么 outgoing 要先算值,incoming 却在 done 后算

这里恰好能看出事件视角的优势。

outgoing

出库时,系统需要参考当前可消费的价值栈,比如 FIFO 剩余层。为了在动作真正完成前拿到正确成本,源码先对 outgoing move 取值。

incoming

入库时,价值往往是这笔新流入本身要写进系统的结果。等 move done 以后,系统更容易把这笔事件正式落成新的价值来源。

这说明 Odoo 不是在做“库存余额乘单价”的简单算法, 而是在按不同事件类型组织估值时点。

_set_value() 的语义不是“算个数字”,而是给 move 定义经济意义

很多人第一次看 _set_value(),会把它理解成“填一下 value 字段”。

更准确地说,它是在回答:

这张已完成或即将完成的 move,在财务和估值语义上到底代表多少钱?

这个值一旦挂在 move 上,后面很多东西都才能顺起来:

  • 会计分录借贷;
  • remaining_qty / remaining_value 的后续追踪;
  • FIFO 余量消耗;
  • 分析分录;
  • 标准成本更新。

也就是说,move 上的 value 不是附属信息,而是整个估值链的中枢。

_create_account_move() 再次证明:分录也是围绕 move 组织的

源码里会遍历 move,判断 _should_create_account_move(),再从每张 move 提取自己的 account move line values,最后合并生成会计分录。

这说明会计表达并不是“从 quant 自动汇总成分录”,而是:

  • 先有一笔 move 的经济意义;
  • 再把这笔意义翻译成借贷行;
  • 然后汇总入账。

所以从建模上说:

  • quant 是库存状态;
  • move 是库存事件;
  • account move 是事件的会计翻译。

这条链非常清楚。

remaining_qty / remaining_value 为什么也适合挂在 move 上

如果你从 FIFO 角度想,这件事就特别顺。

所谓 remaining,不是说“系统现在某个地方还有多少库存”这么简单, 而是说:

某一笔流入 move,当初进来的数量和价值,经过后续消耗以后,还剩多少没有被后面的出库事件吃掉。

这显然是事件层属性,而不是纯余额层属性。

如果把这件事硬挂到 quant 上,你反而会失去:

  • 哪一笔入库构成了这层价值;
  • 它被后续哪些 move 消费;
  • 还剩下多少未被消费的历史价值。

所以 remaining_qty / remaining_value 挂在 move 上,是对的。

为什么“quant 更像余额表,move 更像分录事实表”

你可以把它类比成会计思维:

  • quant 更像总账余额视图的某个库存切片;
  • move 更像一条带业务语义的原始分录事实;
  • account.move 则是财务账上的正式会计表达。

当然这不是一一对应,但有助于理解 Odoo 为什么不把一切都堆到 quant。

因为余额适合回答“此刻多少”, 而估值更依赖“当时发生了什么”。

实战里最容易混淆的地方

1)看到 quant 有数量,就以为价值应该也直接挂在 quant

这会让你在看 FIFO、退货、回冲、remaining value 时越来越难受,因为这些机制都在追事件链,而不只是追结果余额。

2)把 move.value 当成展示字段

不是。value 是后续会计表达、成本更新、分析记账的关键输入。

3)把会计分录当成库存估值的起点

更准确的理解应当是:

  • move 先定义库存事件的经济意义;
  • account move 再把它记进会计系统。

推荐的源码阅读顺序

建议按这个顺序读:

  1. stock_account/models/stock_move.py 里新增的 valueremaining_qtyremaining_value 字段;
  2. _action_done() 的重写顺序;
  3. _set_value() 及其上下文;
  4. _create_account_move()_get_account_move_line_vals()
  5. 再回头联系 FIFO / SVL / standard price 更新。

这样你会先理解“为什么是 move”,再去理解“怎么算”。

最后的结论

Odoo 没把库存价值主链挂在 quant 上,不是遗漏,而是刻意为之。

因为 quant 最擅长表达的是:

  • 现在剩多少;
  • 在哪里;
  • 谁占着。

而库存估值真正需要表达的是:

  • 哪一笔流转创造了价值;
  • 哪一笔流转消耗了价值;
  • 这些历史事件还剩下多少未被消费的价值。

所以从设计上说:

quant 负责库存状态,move 负责库存事件,account move 负责财务翻译。

把这三层分清以后,你再读 Odoo 的 FIFO、remaining value、成本更新和入账逻辑,就不会再问“为什么价值不直接挂 quant”了。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。