很多人第一次看 Odoo 的库存会计,都会产生一种很强的“割裂感”:
- 仓库收货 / 发货时已经记了一套库存分录;
- 客户发票过账时又多出一组 COGS 行;
- 供应商账单过账时还可能再来一笔 price difference。
于是直觉上会问:
- 到底哪一步才是真正成本?
- 为什么不是发货时一次记完?
- 为什么账单价格和库存价值不一致时,要在 bill 阶段补?
如果你把 /home/ubuntu/odoo-temp 里的几处源码串起来看,就会发现 Odoo 并不是“重复记账”,而是在桥接三种不同语义:
- 库存位置移动带来的资产转移;
- 销售确认时需要体现在 P&L 上的 COGS;
- 采购账单价格与标准/已估值成本不一致时的价差修正。
一、库存 move 先解决的是“资产在哪”,不是“利润何时确认”
在 /home/ubuntu/odoo-temp/addons/stock_account/models/stock_move.py 里,_action_done() 会对 valued move 做两件事:
_set_value()先算 move 自己的价值;_create_account_move()再为该库存移动创建会计分录。
而 _get_account_move_line_vals() 的核心结构很清晰:
- 一边是
stock_valuation; - 另一边是来源或目的位置的 valuation account。
这说明库存移动层真正先解决的是:
货值从哪个库存语义位置转到哪个库存语义位置。
它首先关心的是 balance sheet 上的资产去向,而不是收入成本配比。
二、为什么客户发票过账时又会补一组 COGS
在 /home/ubuntu/odoo-temp/addons/stock_account/models/account_move.py 里,account.move._post() 会在销售单据过账前创建额外的 realtime out lines,也就是大家常看到的 COGS 行。
源码里 _stock_account_prepare_realtime_out_lines_vals() 的逻辑是:
- 只对 sale document 生效;
- 只对 real_time valuation 产品生效;
- 取产品账户里的
stock_valuation和expense; - 用
_get_cogs_value()算出本次销售应确认的成本; - 再生成两行
display_type = 'cogs': - 借:费用 / COGS
- 贷:库存估值
这一步表达的不是“货又移动了一次”,而是:
前面已经发生的库存价值,现在要在销售发票语义下转成损益表上的销售成本。
所以 stock move 层和 customer invoice 层不是重复,而是各管一半:
- stock move 负责库存资产流转;
- invoice 负责把应确认的成本搬进利润表。
三、为什么 sale_stock 还要继续补充“最后一步 stock moves”
如果只看 stock_account,你会以为 invoice 直接知道对应哪些 stock move。
但在 /home/ubuntu/odoo-temp/addons/sale_stock/models/account_move.py 里,_stock_account_get_last_step_stock_moves() 又扩展了一层:
- 普通销售发票取 sale line 对应、且真正到 customer 的 done moves;
- refund 场景还要区分 reversed entry 和从 SO 直接生成的退回链。
这说明 Odoo 不是“按发票行静态算成本”,而是努力把 COGS 绑定到最后一步真正履约的库存移动。
也正因为这样,销售成本确认不是简单乘一个标准成本,而是尽量沿真实 stock move 回溯。
四、为什么供应商账单过账时还要补采购价差
采购侧又是另一种桥接。
在 /home/ubuntu/odoo-temp/addons/purchase_stock/models/account_invoice.py 里,_stock_account_prepare_anglo_saxon_in_lines_vals() 会为 vendor bill 生成额外的 price difference 行。
适用前提包括:
- 采购类单据;
- Anglo-Saxon accounting 开启;
- 标准成本产品;
- 并且相关数量已经先交付/入库。
当账单单价与此前库存层使用的估值不一致时,源码会:
- 借或贷 price difference account;
- 同时反向修正当前发票行相关金额。
它解决的问题不是“库存资产移动”,而是:
采购账单这个法律/供应商价格事实,与此前库存估值之间出现了偏差,该怎么把差额单独落账。
所以它不是重复入库成本,而是“账单事实”对“库存估值假设”的结算修正。
五、为什么这三层不能粗暴合并
很多实施时想当然地希望:
- 收货 / 发货记一套;
- 后面发票别再动库存会计。
但 Odoo 的设计并不是这么想的,因为三层回答的是不同问题:
1)库存移动层
回答:资产位置和库存价值怎么变。
2)销售发票层
回答:本次销售该确认多少 COGS 进入损益。
3)采购账单层
回答:供应商账单价格与前期估值有差时,差额怎么单独结算。
如果你把它们合并成一步,很多边界会丢失:
- 先发货后开票怎么办;
- 先收货后收到账单怎么办;
- 标准成本与账单价格差额怎么解释;
- refund / return 怎么反推真实 stock move。
六、为什么用户会误会成“记了三遍”
因为 UI 上看到的是三次不同时间点的动作:
- 库存 done;
- 客户发票 post;
- 供应商账单 post。
但会计语义上它们分别在落:
- 库存资产移动;
- 成本确认;
- 采购价差。
看起来像三次,实际上是在把同一条 supply chain 的不同会计含义拆层表达。
七、排错时别只对一张凭证看借贷
如果你要解释某个产品为什么“成本不对”,建议顺着这条链排:
- stock move 的 value 是怎么算出来的;
- 对应库存 move 是否创建了 account move;
- 销售发票是否生成了
display_type = 'cogs'的行; - sale_stock / purchase_stock 是否把正确的 last-step move 关联上;
- 是否还有 vendor bill 价差补账。
只盯客户发票,通常会越看越糊。
一句话记忆
Odoo 库存会计不是“一个动作记到底”,而是把库存资产移动、销售成本确认、采购价差修正拆成三层桥接,所以看起来分散,其实语义更清楚。
DISCUSSION
评论区