报价模板边界

Odoo 报价模板为什么不能简单理解成“复制条款和赠品”:Quotation Template、Optional Products 与 Terms 的继承边界讲透

很多团队把报价模板当成“把历史报价复制一份”。但在官方源码里,模板真正继承到销售单的内容,和不会继承、只在模板层生效的内容,边界非常清楚。本文重点讲透条款、可选产品、公司默认模板与电商订单之间的继承边界。

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

先说结论

Odoo 的报价模板不是“复制一张旧报价”。

sale_management 里,sale.order.template 真正承担的是:

  • 给新报价提供一套受控的默认结构
  • 把条款、有效期、签署要求、在线付款要求带进 sale.order
  • 把模板行转换成正式的 sale.order.line
  • 允许一部分行标记为 is_optional,作为前台可加购选项

但它不会做这些事:

  • 不会把任意旧订单的状态、发票、消息历史一并继承
  • 不会对电商订单强行套默认模板
  • 不会把“模板里写了条款”理解成以后销售单改了客户、公司、语言还永远自动联动

所以理解模板的关键,不是“能复制什么”,而是:

哪些字段在创建报价时被灌入订单,哪些只在模板层存在,哪些又只在特定入口才会触发。


先看源码入口:模板不是在确认时生效,而是在报价编辑阶段生效

关键文件是:

  • sale_management/models/sale_order.py
  • sale_management/models/sale_order_template.py
  • sale_management/models/sale_order_template_line.py

sale.order 上,sale_order_template_id 是一个可存储、可编辑、可预计算的 Many2one。更重要的是几个 compute / onchange:

  • _compute_sale_order_template_id()
  • _compute_note()
  • _compute_require_signature()
  • _compute_require_payment()
  • _compute_prepayment_percent()
  • _compute_validity_date()
  • _compute_journal_id()
  • _onchange_sale_order_template_id()

这已经说明模板的定位:

它是报价编辑时的结构注入器,而不是确认后的装饰器。

也就是说,很多继承边界在“onchange 阶段”就决定了,而不是等到 action_confirm() 才处理。


条款为什么不是“公司默认条款 + 模板条款自动拼接”

sale.order.template 上的 note 字段就是模板层的 Terms and conditions

sale.order_compute_note() 做的事情很直接:

  • 先跑 super(),让销售单拿到原本的 note 逻辑
  • 如果订单有模板,就把模板用 partner_id.lang 语言上下文化
  • 只要模板 note 不是空 HTML,就直接把 order.note 设成 template.note

这意味着几个非常容易被误解的点:

1. 模板条款是覆盖式,不是自动拼接式

源码没有做“公司条款 + 模板条款”的串联。

所以如果你想表达:

  • 公司统一法律条款
  • 再加某个行业报价模板的补充说明

那不是天然自动合并的能力。默认语义更接近:

  • 有模板条款,就优先用模板条款
  • 模板条款为空,才退回原本订单 note 的来源

2. 条款会跟着客户语言重新取模板翻译

这里的 with_context(lang=order.partner_id.lang) 很关键。

这说明 Odoo 不是把模板 note 当原始字符串复制,而是把它当可翻译模板内容取当前语言版本。

所以模板条款的“继承”其实是:

  • 继承模板记录
  • 按当前客户语言取翻译
  • 再写到订单 note

3. 这不是永久联动

一旦销售单已经有自己的 note,后续你再改模板,不代表历史订单会自动一起更新。

模板继承是生成时/变更时的赋值语义,不是模板与订单之间的实时绑定。


可选产品为什么也不是“写几行建议文案”

SaleOrderTemplateLine 上有一个明确字段:is_optional

_prepare_order_line_values() 里,模板行会被转换成订单行,并把这些字段带过去:

  • display_type
  • product_id
  • product_uom_qty
  • product_uom_id
  • is_optional
  • sequence
  • name

这说明 Optional Products 在模板链路里不是注释,而是正式结构字段

进一步看 _onchange_sale_order_template_id()

  • fields.Command.clear() 清空现有订单行
  • 再把模板中的所有 template lines 逐条 Command.create(...) 成订单行

也就是说,模板里的可选产品不是“前台临时推荐卡片”,而是会进入销售订单明细结构,只是这些行带着 is_optional 语义。

这和很多人脑中的理解不同。很多人以为:

  • 主商品才是 order line
  • Optional 只是门户页上显示一下

但在 Odoo 里,Optional 首先是销售订单结构的一部分,然后才可能在门户/报价前台表现为“客户可加购”。


最容易踩坑的边界:换模板会清空并重建订单行

_onchange_sale_order_template_id() 最值得警惕的一句不是复制,而是:

  • order_lines_data = [fields.Command.clear()]

这意味着切换模板时,默认语义是:

把当前订单行清空,再按模板重建。

所以模板不是“向现有订单追加一些推荐项”,而是“重置当前报价结构”。

这也是为什么 Odoo 又写了一层 _onchange_partner_id() 的保护逻辑:

  • 仅在未保存订单且已有模板时考虑重载
  • 只有当当前订单行和模板行在 product_id / product_uom_id / product_uom_qty / display_type 上等价时,才重新套模板

这段逻辑很有意思,因为它实际上在回答一个业务问题:

  • 如果销售只是先选了客户,再选模板,系统可以安全重载
  • 如果销售已经手改过行内容,再因为切客户而自动重载,就会把人工编辑冲掉

所以 Odoo 采用的是:

只有在“当前行看起来仍像模板原样”时,才允许 partner 变化触发模板重载。

这就是典型的“继承边界保护”。


为什么公司默认模板不会覆盖所有订单来源

_compute_sale_order_template_id() 里有一条非常重要的特判:

  • 如果订单有 website_id,就 不要 自动应用公司默认 quotation template

源码注释写得很直白:

  • don't apply quotation template for order created via eCommerce

这背后反映的是场景边界:

后台人工报价

适合套模板,因为:

  • 需要销售流程标准化
  • 需要固定条款/签署/预付款规则
  • 需要可选产品结构

电商订单

不适合强塞后台报价模板,因为:

  • 商品、价格、加购逻辑来自网站购物流程
  • 条款和页面文案由电商前台控制
  • 电商订单不应该突然被后台默认模板改写行结构

所以“公司默认模板”不是全局统治规则,而是后台销售录单场景的默认值


签署、付款、有效期、journal 也都是模板继承,但语义不同

模板会把这些内容带入订单:

  • require_signature
  • require_payment
  • prepayment_percent
  • number_of_days -> validity_date
  • journal_id

但这些字段和 order line / note 的继承语义并不完全一样。

条款 / 订单行:更像内容复制

它们会在模板变更时直接写进订单。

签署 / 付款 / 预付款比例 / 有效期 / journal:更像配置默认值

这些字段由 compute 从模板得出,强调的是:

  • 这个报价按什么接受规则成交
  • 这个报价默认有效多久
  • 后续开票默认走哪个销售日记账

所以模板其实同时承载两层东西:

  1. 内容层:条款、行、可选产品
  2. 政策层:签署、付款、预付款比例、有效期、日记账

很多实施把模板只当内容复用工具,就会错失第二层价值。


多公司和模板产品为什么也有硬边界

sale.order.template 上有 _check_company_id() 约束。

它检查模板里的产品公司归属,防止出现这种情况:

  • 模板被多个公司共享
  • 但模板行里塞了某个特定公司的产品
  • 结果别的公司根本无权访问或不该销售这个产品

所以模板不是“随便配,反正最后录单时再说”。

Odoo 在模板层就要求:

  • 共享模板不能乱带公司专属产品
  • 限定公司模板也不能包含对该公司不可访问的产品

这也再次说明模板是正式业务对象,不是轻量草稿。


实战里最该记住的 4 条

1. 模板条款默认是覆盖,不是拼接

如果你要“统一法务条款 + 模板补充条款”,通常需要二开或明确人工编辑流程。

2. Optional Product 是结构字段,不是备注

它会随着模板行一起进入 sale.order.line,不是纯前台展示文案。

3. 切模板本质上会重建订单行

不要把模板切换操作暴露给已经大量人工编辑的报价而不加培训。

4. 网站单和后台单的模板边界是故意分开的

电商单不自动吃公司默认报价模板,这不是缺功能,而是避免链路互相污染。


一句话总结

Odoo 的 Quotation Template 真正继承的,从来不只是几条商品行。

它把:

  • 条款
  • 可选产品
  • 签署/付款策略
  • 有效期
  • 默认开票 journal

一起注入到销售订单里;但它又明确限制:

  • 电商订单不自动套模板
  • 手工改过的未保存订单不应被 partner onchange 轻易重载
  • 多公司产品边界必须在模板层就守住

所以这套设计的重点不是“复制更快”,而是:

让报价模板成为可控、可翻译、可约束的销售结构,而不是一张旧单的影子副本。

DISCUSSION

评论区

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