很多人第一次看 Odoo 的 PDF 报价功能,会以为它只是“多塞几份附件”而已。
但 sale_pdf_quote_builder 的实际思路更像是:
先把 PDF 当成一个“可填表的文档容器”,再把销售单据数据映射进去。
它不是简单拼接文件,而是在处理三件事:
- 哪些 PDF 可以被当成报价文档用;
- PDF 里的表单字段应该怎么识别和映射;
- 报价头尾、产品文档和正文之间怎么分层插入。
1. Odoo 不是把 PDF 当附件,而是把它当成一种业务文档类型
quotation.document 这个模型很关键。
它通过 _inherits = {'ir.attachment': 'ir_attachment_id'} 直接借用了附件存储能力,说明它本质上还是文件,但业务语义已经变了。
在它身上,Odoo 额外加了:
document_type:header / footerquotation_template_ids:关联报价模板form_field_ids:当前 PDF 里抽出来的表单字段add_by_default:是否默认挂到新报价
这告诉我们:
报价头尾不是“静态附件”,而是可以被模板和字段系统驱动的业务部件。
product.document 也被扩展了一个 attached_on_sale 选项:inside。
它的含义不是“附在销售单上”,而是“嵌入报价 PDF 的中间部分”。
所以整个模块真正做的是一套“报价 PDF 组装器”。
2. 上传 PDF 之前,Odoo 先检查它能不能被当成表单
上传入口在 controller:
addons/sale_pdf_quote_builder/controllers/quotation_document.py。
它接收用户上传的文件,直接创建 quotation.document 记录,并在失败时回滚。
但真正的第一道校验在模型层:
- 只能是 PDF
- 不能是加密 PDF
utils._ensure_document_not_encrypted() 会尝试用 PDF reader 解析,遇到加密或不支持格式就直接报错。
这一步很关键,因为如果 PDF 本身都不能解析,后面的字段抽取就没有意义。
换句话说,Odoo 在这里的判断不是“文件能不能上传”,而是:
这个文件能不能进入后面的“表单字段提取管线”。
3. 表单字段不是手工建的,而是从 PDF 里抽出来的
sale.pdf.form.field 是这个模块的桥梁模型。
它的职责不是保存附件,而是保存“PDF 字段名 → Odoo 路径”的映射关系。
它有两个很实用的约束:
- 字段名必须唯一,并且只在同一
document_type下比较 path必须合法,且路径中每一跳都要真实存在
比如它允许这样的路径:
partner_id.nameorder_id.amount_totaluser_id.login
但不允许你随便写一个不存在的字段。
更严格的是:
- 路径只能在关系字段上层层走
- 最后一跳才是具体值
- header/footer 里的字段名不能以
sol_id_开头
这说明 Odoo 不希望这套系统变成“谁都能随便写个模板变量”的弱约束引擎。 它要的是可验证、可回填、可维护的映射。
4. 默认映射不是空的,Odoo 先帮你铺一层常用字段
_add_basic_mapped_form_fields() 很值得看。
它预置了两套映射:
quotation_documentproduct_document
例如报价头尾里可以直接映射:
amount_totalamount_untaxedclient_order_refdelivery_dateorder_datepartner_id__nameuser_id__email
产品文档里则更多从 sale.order.line 和 product_id 取值:
descriptionprice_unitquantitytax_incl_priceuomproduct_sale_price
这一步的意义是:
Odoo 不是让你从零写模板字段,而是先给你一组“常见销售语义”的默认桥接。
这样用户在做 PDF 设计时,更多是改少量路径,而不是自己想象一整套字段体系。
5. 表单字段如何从 PDF 里生成出来
QuotationDocument._compute_form_field_ids() 和 ProductDocument._compute_form_field_ids() 的套路一致:
- 先清空旧的 field 关联
- 再过滤出有
datas的 PDF - 然后调用
sale.pdf.form.field._create_or_update_form_fields_on_pdf_records()
在这个函数里,Odoo 会:
- 用
utils._get_form_fields_from_pdf()解析 PDF 文本字段; - 如果数据库里没有同名字段,就创建新的
sale.pdf.form.field; - 如果已经存在同名字段,就直接 link 过去。
这意味着“字段抽取”其实是一个去重 + 复用过程。
Odoo 不会因为每份 PDF 都长得像就重复建一堆字段记录。 它会尽量把同一字段名复用成统一映射点。
6. 为什么这里要区分“quote 里显示”与“order confirmation 后发送”
product.document.attached_on_sale 有几个语义:
on quoteon order confirmationinside quote
这不是简单的可见性开关,而是单据生命周期中的插入时机。
比如:
- on quote:客户在报价阶段就能看到
- on order confirmation:确认订单后才发给客户
- inside quote:直接嵌入 PDF 中间正文
这三种语义对应的是三种不同的客户沟通场景。
所以它不是“附件展示策略”,而是“销售文档发布策略”。
7. 实战里最容易踩的坑
最常见的坑有三个:
-
PDF 被加密 - 系统会直接拒绝
-
路径写错 - 不是所有字段名都能直接当 path - 关系链写错会在校验期就失败
-
把 URL 当 PDF 用 -
inside模式要求是 binary file,不是外链
如果你在定制这套功能,建议先把字段设计成稳定的业务名,再把路径映射到 sale.order / sale.order.line。
这样后续改 PDF 版式,不会把业务字段也一起改崩。
结论
sale_pdf_quote_builder 不是“把附件拼进报价单”,而是把 PDF 变成一个可映射、可校验、可复用的销售文档容器。
它最有价值的地方在于:
用字段系统把静态 PDF 和动态销售数据连接起来,同时保留了头尾、正文和产品文档的语义分层。
这才是它和普通附件功能的真正区别。
DISCUSSION
评论区