先说结论
在 Odoo 里,项目物料转售并不是“仓库出完货,业务手工去销售单补一行材料费”。
/home/ubuntu/odoo-temp/addons/sale_project_stock 这套实现做的是一条完整自动链路:
- 项目先挂上
reinvoiced_sale_order_id - stock move 在创建/分配 picking 时把
project_id一路带进物流对象 - picking 验证时检查销售单状态、锁定状态和 analytic_costs 开关
- 对符合
expense_policy in {'cost', 'sales_price'}的 move,系统自动算价并批量创建sale.order.line
所以正确理解不是“项目里能转售材料”,而是:项目、库存和销售三条链已经在出库确认节点被正式接起来了。
1. 入口不在销售单,而在 stock.picking.button_validate()
很多人第一反应会去找 sale.order 或 project.project 上的按钮,但真正的触发点在:
sale_project_stock/models/stock_picking.pybutton_validate()
这段逻辑的顺序非常清楚:
- 先跑标准出库校验
- 如果返回值不是
True,说明还有向导或异常,先不继续 - 逐张 picking 取
project_id - 再从项目上取
reinvoiced_sale_order_id - 只有在以下条件同时满足时才继续:
- 有关联销售单
- picking type 开启了
analytic_costs- move 的产品expense_policy是cost或sales_price
这说明 Odoo 不把“项目物料转售”当成销售手工动作,而是当成 出库完成后、满足财务条件时自动结转到销售 的流程。
2. 为什么系统要先卡销售单状态
button_validate() 里还有三个很实用的防线:
- 销售单还是
draft/sent:不允许验证出库,提示必须先确认 SO - 销售单
cancel:直接报错 - 销售单
locked:直接报错,并提示需要新建 SO 重新挂项目
这背后的业务逻辑很合理:
- 如果销售合同都没生效,项目材料不应该先生成可开票销售行
- 如果销售单已取消,继续出库就会形成脏数据
- 如果单据锁定,自动追加销售行会破坏审计一致性
所以 Odoo 不是“出库完了再想办法补账”,而是先保证 可被转售的商业单据处于合法状态。
3. 定价不是写死单价,而是按 expense_policy 分两条路
stock_move._sale_get_invoice_price(order) 是整条链里最关键的定价点。
如果产品是 sales_price
系统走销售价:
- 直接调用
order.pricelist_id._get_product_price(...) - 价格以销售订单日期、单位、价目表为准
如果产品是 cost
系统走成本价:
- 先看 move 数量是不是 0,避免无意义开票
- 默认取
product.standard_price - 如果订单币种和公司币种一致,直接 round
- 如果币种不同,再按订单日期换汇
这说明项目物料转售不是“永远按成本”也不是“永远按标价”,而是严格遵守产品的费用政策。
也因此,实施里最容易出错的地方不是出库本身,而是产品主数据:expense_policy 一旦配错,自动生成的销售行单价就会完全跑偏。
4. 销售行为什么能知道交付数量和税
_sale_prepare_sale_line_values() 负责把 stock move 变成 sale.order.line 创建值。
它写进去的核心字段包括:
name = self.referenceprice_unit = 计算后的 priceproduct_idproduct_uom_qty = self.product_uom_qtyqty_delivered = self.quantity- 税通过 fiscal position + 产品税映射得出
这一步非常关键,因为它不是只补一行金额,而是把这条销售行建立成 由库存交付驱动的可开票对象。
测试 addons/sale_project_stock/tests/test_reinvoice.py 也验证了结果:
- 成本计费产品会按
standard_price建销售行 - 销售价计费产品会按
list_price/pricelist建销售行 qty_delivered_method会是stock_move- 已交付数量与 move 数量一致
换句话说,这不是“费用补录”,而是 库存交付事实正式成为销售履约事实。
5. 为什么 project_id 能一路跟到 picking
stock_move.py 里还补了三处非常重要的 project 传递逻辑:
_get_new_picking_values():新建 picking 时带上project_id_assign_picking_values():合并/分配到已有 picking 时也带上project_id_prepare_procurement_values():补货/采购联动时继续把project_id放进 procurement values
这意味着项目上下文不是在最后一刻硬猜出来的,而是从 sale line / sale order 往后端物流对象持续传递。
这套设计非常像 Odoo 的标准风格:
- 业务主键不反复回填猜测
- 上游业务上下文沿链路自然传递
- 到真正过账或转售时再消费这些上下文
6. 落地时最值得检查的四件事
一,项目上是否配置了 reinvoiced_sale_order_id
没有目标销售单,自动转售就无从谈起。
二,拣货类型是否启用 analytic_costs
这个开关关着,button_validate() 根本不会进入自动补销售行逻辑。
三,产品 expense_policy 是否正确
这是决定按成本价还是销售价转售的核心。
四,销售单状态是否允许追加行
草稿、取消、锁定,三种都不行。
一句落地建议
如果客户说“我们想做项目材料自动转售”,你最该先查的不是报表,而是这一条链有没有闭环:
- 项目 ↔ 销售单
- 销售行 ↔ stock move
- stock move ↔ picking.project_id
- picking 验证 ↔ 自动创建 sale.order.line
这四段都通了,Odoo 才会在出库完成时把项目材料正式转成销售收入。
参考源码
/home/ubuntu/odoo-temp/addons/sale_project_stock/models/stock_picking.pybutton_validate()/home/ubuntu/odoo-temp/addons/sale_project_stock/models/stock_move.py_sale_get_invoice_price()_sale_prepare_sale_line_values()_get_new_picking_values()_assign_picking_values()_prepare_procurement_values()/home/ubuntu/odoo-temp/addons/sale_project_stock/tests/test_reinvoice.py
DISCUSSION
评论区