FIFO 栈

Odoo FIFO 成本为什么不能只看现有库存:remaining_qty、FIFO 栈与出库估值链路

很多人以为 FIFO 成本就是“当前库存数量 × 最近单价”。但 stock_account 的实现真正依赖的是尚未耗尽的入库 move 栈:_run_fifo_get_stack 先找剩余层,_get_remaining_moves 计算 remaining_qty,出库再沿栈取值。

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

很多人学 FIFO 时,脑子里会自然生成一个很粗糙的公式:

  • 先入先出;
  • 现有库存还有多少;
  • 那就按“剩余数量 × 某个入库单价”理解成本。

但真正看 /home/ubuntu/odoo-temp/addons/stock_account/models/product.pystock_move.py,Odoo 的 FIFO 不是直接盯着一个总库存数字,而是在维护一种更接近“剩余入库层栈”的结构。

这也是为什么源码里很多关键逻辑都围着这些方法转:

  • _run_fifo_get_stack()
  • _get_remaining_moves()
  • _run_fifo()
  • stock.move._compute_remaining_qty()

一句话概括就是:

Odoo 算 FIFO,不是问“现在还有多少货”,而是问“哪些历史入库 move 还没被耗尽”。

一、remaining_qty 不是库存总量,而是某个入库 move 剩下多少价值层

stock_account/models/stock_move.py 里,remaining_qty 是个 compute 字段。

它不是对所有 move 都平均有意义,而主要是用来表达:

  • 某条 valued incoming move
  • 在 FIFO 消耗之后
  • 还剩多少数量没有被后续出库吃掉

源码里 _compute_remaining_qty() 会调用产品级的 _get_remaining_moves(),然后把结果映射回 move。

所以你看到某条入库 move 有 remaining_qty,它表达的不是“这条 move 当时收了多少”,而是:

  • 到当前时点为止,
  • 这条入库层还剩多少没被后续成本流消耗。

这正是 FIFO 的核心:

  • 数量可以看总库存;
  • 但成本层一定要知道“剩的是哪一批”。

二、_get_remaining_moves() 先求“剩余层”,不是先求“平均值”

_get_remaining_moves() 做的事情很直观:

  • 对每个产品调用 _run_fifo_get_stack()
  • 拿到一组 still-on-stack 的 move;
  • 再把每条 move 还剩的数量装进字典返回。

这里很关键的一点是:

  • 返回结果不是一个单独汇总数;
  • 而是一组 move → remaining qty 的映射。

这说明 Odoo 从设计上就不想把 FIFO 剩余层压扁成一个“产品剩余数量”指标。

因为只知道总量,不知道来源层,就没法稳定回答这些问题:

  • 下一次出库应该先吃哪一层;
  • 每层剩余价值还剩多少;
  • 某条历史入库 move 为什么还带着剩余价值。

三、_run_fifo_get_stack() 真正在构造“可消耗层栈”

这段源码非常值得细看。

它会先根据:

  • 产品
  • 公司
  • 可选 lot
  • 可选 at_date
  • 可选 location

构造 moves_domain,然后只搜索满足条件的入库型 valued moves。

接下来它不是从最早开始一条条全读,而是:

  • 先从最近 move 倒序抓一批;
  • 一直往前推,直到凑满当前 qty_available 对应的库存层;
  • 最后再 reverse(),把它恢复成真正 FIFO 消耗顺序。

这段实现说明两个很重要的事实:

1)FIFO 栈是和“当前仍在手上的数量”绑定的

它不是历史上所有入库 move 的全集。

只有那些还对当前在手库存贡献剩余量的入库 move,才会留在这条栈里。

2)第一层可能只剩一部分

源码里单独维护了 remaining_qty_on_first_stack_move

原因很简单:

  • 当前库存不一定刚好从某条入库 move 的边界开始;
  • 栈底第一层可能已经部分被历史出库消耗;
  • 所以第一层的“有效剩余量”未必等于整条 move 的 valued qty。

这是很多人手工算 FIFO 时最容易漏掉的地方。

四、_run_fifo() 出库取值时,吃的是栈层,不是看一个单价字段

真正算出库价值的是 _run_fifo(quantity, ...)

它会拿 _run_fifo_get_stack() 返回的栈,然后:

  • 从最早的剩余层开始取;
  • 逐层扣数量;
  • 按该层 value / valued_qty 拆出对应成本;
  • 不够再吃下一层;
  • 如果栈不够,才用最后已知价格或标准价做外推。

这说明 Odoo 的 FIFO 成本不是“当前产品挂了一个 cost,就按它乘数量”。

真正的逻辑是:

出库成本 = 对当前剩余层栈做逐层消耗后的价值和。

所以只要历史入库层不同、部分消耗不同、lot / location 上下文不同, 同样数量的出库,理论上就可能拿到不同的成本结果。

五、为什么 remaining_value 在 FIFO 下按比例取 move.value

stock_move.py 里的 _compute_remaining_value() 也很关键。

对于 FIFO:

  • remaining_value = ratio * move.value
  • 其中 ratio = remaining_qty / move.quantity

这背后的含义是:

  • FIFO 的价值残留仍然挂在具体入库 move 上;
  • 一条入库 move 被消耗了一部分,就按比例留下尚未消耗的价值。

所以你在后台看到某条历史入库 move 还残着 remaining_value,别把它理解成脏会计数据。

它表达的是:

  • 这条入库层的价值还没有完全流出;
  • 它仍然是当前在手库存价值的一部分来源。

六、_action_done() 为什么要在出库和入库两边分别补估值

源码里 _action_done() 的扩展顺序也很讲究:

  1. 先对 outgoing moves 调 _set_value()
  2. 再执行父类 action_done
  3. 再对 incoming / dropship moves _set_value()
  4. 最后创建会计分录

这个顺序很重要,因为 FIFO 出库要先看“当前已有栈”来取值。

如果先把本次入库也算进去,再给同批出库取值,就会污染成本时点语义。

所以 Odoo 先把出库按旧栈定价,再把新完成的入库压入价值体系。

这正是 FIFO 的时间边界。

七、实战里最容易误解的三个点

1)把 qty_available 当成 FIFO 成本来源

qty_available 只告诉你数量还有多少,不告诉你这些量来自哪些历史层。

而 FIFO 成本最关心的恰恰是来源层。

2)把 standard_price 当成 FIFO 的实时真相

在 FIFO 产品上,standard_price 不是每次出库都直接拿来乘数量的唯一依据。 真正出库成本还是优先从 FIFO 栈层取。

3)看到某条旧入库 move 还挂着 remaining_qty,就以为没做完结转

其实那往往恰好说明系统在正确维护剩余成本层。

只要后续出库还没把它彻底吃光,这条 move 就应该继续留下剩余数量和剩余价值。

八、调试 FIFO 时该先看什么

如果你怀疑 FIFO 成本不对,推荐先按这个顺序看:

  1. 当前产品 cost method 是否真是 FIFO;
  2. 当前时点的 qty_available 与 valued locations 是否一致;
  3. _run_fifo_get_stack() 理论上会抓到哪些入库 move;
  4. 栈底第一层是不是只剩部分数量;
  5. 本次 outgoing move 是否在 _action_done() 前就按当时可见栈完成取值;
  6. lot / location / at_date 上下文是否改变了可见层集合。

这样查,比只盯着一条出库单上的单价字段靠谱得多。

结语

Odoo 的 FIFO 实现真正保存的是“成本层历史”,不是一个扁平库存数字。

  • remaining_qty 说明哪条入库层还没耗尽;
  • _get_remaining_moves() 说明当前仍在栈上的层是谁;
  • _run_fifo() 说明出库如何逐层吃值;
  • _action_done() 说明时点顺序为什么不能乱。

所以以后再说“FIFO 不就是先入先出嘛”,更精确的版本应该是:

在 Odoo 里,FIFO 不是一句顺序口号,而是一套围绕剩余入库层栈展开的估值机制。

DISCUSSION

评论区

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