项目深度

Odoo 项目物料转售为什么不是“出库后手工补一条销售行”:sale_project_stock 的定价、项目回写与交付数量链路讲透

很多团队以为项目型物料转售,只是在出库后手工往销售订单补一行材料费。Odoo 的 `sale_project_stock` 不是这么做的。本文围绕 `button_validate()`、`_sale_get_invoice_price()`、`_sale_prepare_sale_line_values()` 和 `project_id` 传递链路,讲清项目物料为何能从 stock move 自动长出销售行。

销售 项目
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

先说结论

在 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.py
  • button_validate()

这段逻辑的顺序非常清楚:

  1. 先跑标准出库校验
  2. 如果返回值不是 True,说明还有向导或异常,先不继续
  3. 逐张 picking 取 project_id
  4. 再从项目上取 reinvoiced_sale_order_id
  5. 只有在以下条件同时满足时才继续: - 有关联销售单 - picking type 开启了 analytic_costs - move 的产品 expense_policycostsales_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.reference
  • price_unit = 计算后的 price
  • product_id
  • product_uom_qty = self.product_uom_qty
  • qty_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.py
  • button_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

评论区

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