很多人学 FIFO 时,脑子里会自然生成一个很粗糙的公式:
- 先入先出;
- 现有库存还有多少;
- 那就按“剩余数量 × 某个入库单价”理解成本。
但真正看 /home/ubuntu/odoo-temp/addons/stock_account/models/product.py 和 stock_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() 的扩展顺序也很讲究:
- 先对 outgoing moves 调
_set_value() - 再执行父类
action_done - 再对 incoming / dropship moves
_set_value() - 最后创建会计分录
这个顺序很重要,因为 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 成本不对,推荐先按这个顺序看:
- 当前产品 cost method 是否真是 FIFO;
- 当前时点的
qty_available与 valued locations 是否一致; _run_fifo_get_stack()理论上会抓到哪些入库 move;- 栈底第一层是不是只剩部分数量;
- 本次 outgoing move 是否在
_action_done()前就按当时可见栈完成取值; - lot / location / at_date 上下文是否改变了可见层集合。
这样查,比只盯着一条出库单上的单价字段靠谱得多。
结语
Odoo 的 FIFO 实现真正保存的是“成本层历史”,不是一个扁平库存数字。
remaining_qty说明哪条入库层还没耗尽;_get_remaining_moves()说明当前仍在栈上的层是谁;_run_fifo()说明出库如何逐层吃值;_action_done()说明时点顺序为什么不能乱。
所以以后再说“FIFO 不就是先入先出嘛”,更精确的版本应该是:
在 Odoo 里,FIFO 不是一句顺序口号,而是一套围绕剩余入库层栈展开的估值机制。
DISCUSSION
评论区