很多团队第一次看 Odoo Enterprise Payroll + Documents 时,会把它理解成一个很轻的流程:
- 工资单生成 PDF;
- 系统发员工一封邮件;
- 员工点开附件或链接查看;
- 事情结束。
这个总结只描述了表层体验,解释不了下面这些企业现场很常见的问题:
- 为什么工资单文档默认不是普通内部附件,而是“Anyone with the link”可查看?
- 为什么员工有用户账号时文档进个人空间,没有账号时却会掉进
Worker Payroll Folder? - 为什么 payroll manager 自己也不自动拥有这些文档的可见权限?
- 为什么某些 payroll declaration 不是立即发 Documents,而是先进入
pdf_to_post队列? - 为什么系统宁可异步 cron 投递,也不把所有工资单和声明都在当前事务里一次性发完?
如果把 /home/ubuntu/odoo-temp/enterprise/documents_hr_payroll 里的 models/hr_payslip.py、models/hr_payroll_employee_declaration.py、models/hr_payroll_declaration_mixin.py 和测试 tests/test_documents.py 串起来看,官方真正设计的不是“工资单 PDF 邮件发送器”,而是:
把 payroll 文档转成可长期访问、可安全隔离、且能适配离职后访问边界的 Documents 资产。
这也是它比“生成附件后发邮件”复杂得多的原因。
一、先说结论:工资单文档的核心不是附件,而是“可控访问的 Documents 对象”
在 documents_hr_payroll/models/hr_payslip.py 里,hr.payslip 继承了 documents.mixin。
这意味着工资单文档不是停留在 ir.attachment 这一层就结束,而是会被桥接成 documents.document,并围绕 Documents 的:
- owner
- partner
- folder
- access ids
- access via link
- internal access
这些语义来管理。
所以工资单 PDF 在企业版里的真正目标,不是“把文件生出来”,而是:
把工资单变成一个有明确归属、访问边界和长期链接策略的文档对象。
这就是为什么它值得单独读源码,而不是把它当成 mail attachment 功能。
二、为什么发送工资单前必须先过状态和邮箱检查
action_resend_payslips() 做的第一件事不是发邮件,而是先卡边界:
- 当前用户必须有
hr_payroll.group_hr_payroll_user - 工资单状态必须在
validated或paid - 员工必须至少有
private_email或work_email
这组限制很有意味。
官方不是把工资单发送理解成“HR 想发就发”,而是把它理解成:
只有正式完成的工资结果,且员工存在可用接收地址时,才允许对外发送文档入口。
同时,如果工资单还没有对应文档,系统不是假装发成功,而是明确给出 warning。
这说明“发邮件”在这里其实是第二层动作,第一层前提永远是:Documents 里的可访问文档已经存在。
三、为什么工资单文档要强制用 link access,而且内部访问设为 none
_get_document_vals_access_rights() 是这套设计里最值得反复读的一小段:
access_via_link = 'view'access_internal = 'none'is_access_via_link_hidden = True
注释更直接:
- 所有工资单都应通过 “Anyone with the link” 可访问
- 这样即使员工和其用户被归档,文档也仍然可访问
这一步非常关键。
很多人一看到“Anyone with the link”就条件反射地觉得不安全,但 payroll 这里的逻辑恰恰不是“放宽”,而是换一种更稳定的访问载体。
因为工资单是非常特殊的 HR 文档:
- 员工可能离职;
- 用户账号可能被归档或停用;
- 但历史工资单在某些国家和公司制度下,仍需要让员工长期取回。
如果只依赖内部用户权限,员工一旦离职,历史工资单的访问链也会一起断掉。
所以 Odoo 选择的是:
内部默认不给看,但给一个受控的永久链接入口。
这和普通内部知识库文档完全不是同一个思路。
四、为什么 payroll manager 也不天然拥有文档访问权
测试 test_payslip_document_creation() 和 test_hr_payroll_employee_declaration_document_creation_simple() 很能说明问题。
文档创建出来以后,测试明确校验:
- 文档 owner 是员工本人(如果员工有 user)
- partner 也是员工用户对应的 partner
- payroll manager、documents manager、其他用户默认都没有访问权限
这说明官方要隔离的不是“外人 vs 内部人”,而是:
连内部管理角色也不能因为自己职位高,就顺手通过 Documents 权限看到所有工资单。
换句话说,工资单的可见范围不是由 Documents 的常规内部共享思路主导,而是被 payroll 的隐私边界重新收紧了。
对很多企业来说,这一点比“能不能自动发邮件”重要得多。
五、为什么员工有账号时进个人空间,没有账号时进 Worker Payroll Folder
_get_document_folder() 的逻辑非常值得注意:
- 如果员工有
user_id,则走super(),本质上会进入员工自己的文档空间 / My Drive 语义 - 如果员工没有用户账号,则落到公司级的
Worker Payroll Folder
同时:
_get_document_partner()优先拿员工用户对应的 partner- 否则退回
work_contact_id _get_document_owner()也优先设成员工用户
这套逻辑背后的目标很清楚:
有账号的员工
让工资单作为“属于本人”的个人文档来管理。
没账号的员工
仍然要有一套公司代持的安全存放区,并通过特定 access 让当事人可看。
所以 Worker Payroll Folder 的本质不是共享资料夹,而是:
给无系统账号员工准备的 payroll 文档托管层。
这比“没有用户就不能发文档”高级得多,也更贴近真实企业人事场景。
六、为什么没有 partner 时,系统宁可不建 document
测试 test_payslip_document_creation_with_no_partner() 说明了另一条边界:
- 如果员工没有
user_id.partner_id - 也没有
work_contact_id - 那么工资单可以排队生成 PDF
- 但最终不会创建
documents.document
这说明 Odoo 明确拒绝生成“无归属对象、无访问主体”的 payroll 文档。
也就是说,文档存在的前提,不只是文件本身生成成功,而是系统知道这个文档最终属于谁、给谁看。
这对 payroll 场景特别重要,因为工资单不是公共材料,不能先随手落地,再慢慢补权限。
七、为什么 declaration 走 pdf_to_post 队列,而不是立即塞进 Documents
hr_payroll_employee_declaration.py 里给 declaration 新增了两个状态:
pdf_to_postpdf_posted
并配了字段:
pdf_to_postdocument_id
这里最值得注意的是 action_post_in_documents() 与 _cron_generate_pdf() 的配合。
动作不是立刻 _post_pdf(),而是:
- 先把记录标成
pdf_to_post=True - 触发
hr_payroll.ir_cron_generate_payslip_pdfs - 由 cron 批量抓取声明记录
- 再调用
_post_pdf()投递到 Documents
这表达的是一个很明确的工程取向:
Payroll 声明文档的生成与投递,允许异步化、批处理化,而不是强绑在当前交互事务里。
这么做的好处有三类:
- 大批量声明时不阻塞前端操作
- PDF 生成和文档创建失败时更容易重试
- 可以统一控制一次投递多少份,避免瞬时写爆 Documents
这也是企业系统处理“敏感批量文件”时非常常见的设计。
八、为什么系统要先检查重名文档,避免重复发声明
_get_posted_documents() 会按 pdf_filename 统计已有 active 文档;_post_pdf() 则会跳过那些已经投递过的文件名。
这说明 declaration 的 Documents 投递,不是简单地“每点一次就新建一个 document”,而是有明确的幂等意识。
对 payroll 声明类文件来说,这很重要:
- 避免 HR 多点几次按钮,Documents 里出现一堆重复件
- 避免员工多次收到看起来内容一样、链接不同的文档
- 避免 declaration 的状态和文档数量失去对应关系
当然,这里的去重口径主要基于文件名,不是全量内容哈希;但对声明投递这条业务链来说,已经足够实用。
九、为什么 declaration 文档也走 link access,而且 owner 可能为空
_post_pdf() 创建 documents.document 时,同样设置了:
access_via_link = 'view'access_internal = 'none'is_access_via_link_hidden = True
并且:
- 如果员工有 user,就把 owner 设给该 user
- 如果没有 user,owner 可以为空
- 但 partner/folder/access_ids 会补出可访问路径
这说明 payroll declaration 与 payslip 在访问哲学上是一致的:
优先保证“当事人长期可取回”,而不是“内部账号还活着时凑合能看”。
这也解释了为什么 mixin 层提供了 _get_posted_document_owner() 和 _get_posted_mail_template() 这些可覆写点——因为不同 payroll declaration 可能有不同 owner 语义,但访问边界模型是一致的。
十、为什么 mixin 层要单独做 documents_count 和 action_see_documents
hr_payroll_declaration_mixin.py 不是只图方便加个按钮。
它把 declaration 类对象和 Documents 的关系提升成了一个正式 UI 能力:
documents_count统计当前 sheet / declaration 下已经发布的文档数action_see_documents()直接给出 Documents 视图入口action_post_in_documents()则把下属 line 的发布动作统一代理出去
这说明官方不是把 Documents 当“附件仓库”,而是当 payroll declaration 的正式后置存储层。
用户在 payroll 声明页里看到的,不再只是一个 PDF 二进制字段,而是一个可浏览、可跳转、可计数的文档结果集。
十一、实战里最容易误解的 5 个点
误区 1:工资单发文档就是发邮件附件
不是。核心是 documents.document,邮件只是文档入口通知。
误区 2:Anyone with the link 说明权限放松了
不完全是。这里是为了应对离职、归档后的长期访问需求,同时内部权限反而被收紧到 none。
误区 3:内部 HR 或 Documents Manager 自然能看到这些文档
不是。测试明确验证很多内部角色默认无权访问。
误区 4:没用户账号的员工就没法拿到 payroll 文档
也不是。系统会用 Worker Payroll Folder + partner access 托管。
误区 5:声明文档点击发布就立刻同步完成
不一定。很多时候只是进入 pdf_to_post 队列,真正投递由 cron 异步完成。
十二、对二开和实施最有价值的启发
如果你要扩展这套能力,最值得学的是下面几条:
1)先定义 payroll 文档的长期访问策略,再谈邮件模板
工资单类文档的核心不是“怎么发”,而是“离职后谁还能安全取回”。
2)文档 owner、partner、folder 要分情况建模
有账号员工与无账号员工,不能用同一套偷懒逻辑处理。
3)敏感批量文档优先走异步投递
这样更稳,也更适合失败重试与批量控制。
4)把去重和状态机当成一等公民
pdf_to_post / pdf_posted 看似简单,实际上是 payroll 文档可靠性的关键。
总结
把 documents_hr_payroll 源码串起来以后,你会发现 Odoo 企业版真正实现的不是“工资单 PDF 发邮件”。
它做的是一条更完整的 payroll 文档链路:
- 把工资单和声明转换成 Documents 资产;
- 用 link access + internal none 处理长期访问与内部隔离;
- 用 owner / partner / folder 区分有账号员工和无账号员工;
- 用 Worker Payroll Folder 托管无系统账号场景;
- 用 pdf_to_post + cron 异步投递声明文档;
- 用 documents_count / action_see_documents 把文档结果正式嵌回 payroll UI。
所以 Odoo 企业版 payroll 发文档的本质,不是“生成 PDF 后发封邮件”,而是“把敏感薪资文档转成可长期访问、可严格隔离、且适配不同员工身份状态的文档资产”。
这才是这套企业版源码最值得学的地方。
参考源码
- enterprise/documents_hr_payroll/models/hr_payslip.py
- enterprise/documents_hr_payroll/models/hr_payroll_employee_declaration.py
- enterprise/documents_hr_payroll/models/hr_payroll_declaration_mixin.py
- enterprise/documents_hr_payroll/tests/test_documents.py
DISCUSSION
评论区