很多团队第一次把 Odoo Enterprise Payroll 接到会计时,脑子里的模型通常很简单:
- 工资单算完;
- 点 Validate;
- 系统自动生成一张会计分录;
- 然后打款结束。
这个理解能描述表面结果,但解释不了很多企业现场常见现象:
- 为什么有些工资单是一批人合成一张分录,有些又是一人一张?
- 为什么同样叫 NET,真正进入会计分录的净薪金额还会被某些规则再扣掉?
- 为什么有的 salary rule 会被合并进同一行,有的却会按员工、按名称甚至按银行卡拆开?
- 为什么工资单已经 validated 了,系统还不让你直接 register payment?
- 为什么一个员工两张银行卡时,最后真的会拆成两笔付款,而不是只记一笔总额?
如果把 /home/ubuntu/odoo-temp/enterprise/hr_payroll_account 里的 models/hr_payslip.py、models/hr_salary_rule.py、models/account_payment.py、models/res_partner_bank.py 和对应测试一起看,官方真正设计的不是“工资单顺手出分录”,而是:
先把 payroll 结果翻译成可审计的会计口径,再把可支付的净薪负债,谨慎地桥接到付款动作。
这也是它比“验证即记账”复杂得多的原因。
一、先说结论:工资单入账的核心对象不是“单张 slip”,而是“按 journal + 月份组织的 posting 批次”
action_payslip_done() 确实是入口,但真正关键的是后面的 _action_create_account_move()。
源码没有简单地对每张工资单各建一张 account.move。它先把要入账的工资单收集起来,然后按两层维度分组:
- 工资结构上的 journal
- 会计日期 / 工资所属月份
如果公司启用了 batch_payroll_move_lines,那么同一 journal、同一月份的工资单会被并到一张分录里;如果没启用,就会拆成一人一张或一 slip 一张。
所以官方理解的不是:
一张工资单 = 一张会计分录
而是:
一批可以共享会计语义的工资单 = 一个 posting 容器
这点很重要,因为企业财务往往需要的是“月度工资负债”口径,而不是每个员工都独立占一张总账凭证。
二、为什么系统会把整批 payslip_run 也一起带进来
_action_create_account_move() 里有个很容易忽略的动作:
- 如果某张工资单属于
payslip_run_id - 且这个 batch
_are_payslips_ready() - 那么它会把整个 batch 的 slip 都并入
all_payslips
这说明 Odoo 不希望你在工资批次还没准备好时,零碎地记一半账、留一半工资单悬空。
也就是说,只要它判断这个 payroll batch 已经具备统一入账条件,就倾向于把整批一起推进会计。
这样做的好处很直接:
- 月度工资分录更完整;
- 批次级别的会计追踪更清晰;
- 后续 pay run 上挂
move_id也更自然。
hr_payslip_run.py 甚至专门给 run 加了 move_id 和 action_open_move(),说明官方默认就把“批次级工资入账”当成一等公民,而不是附属玩法。
三、NET 为什么不是“工资单最终金额原样进账”
最值得读的一段在 _prepare_slip_lines()。
当它遍历工资单行时,如果碰到 line.code == 'NET',不会直接把 line.total 原样拿去记账。源码会再扫一遍工资单行,把那些 salary_rule_id.not_computed_in_net 为真的规则从 NET 里剔出去。
这背后传达的是一个很重要的会计边界:
工资单上的“净薪显示口径”不一定等于会计上应该挂到 NET 负债的口径。
为什么?因为某些规则虽然会影响工资单展示,但在会计上需要独立入账,而不是混进应付净薪里。
hr_salary_rule.py 里这个字段的帮助文本也写得很直白:
- 如果勾选
Excluded from Net - 这个规则就不会计入 NET salary rule 的 journal entry
- 应该单独配置借贷科目处理
所以真正进入会计的 NET,不是“页面看到多少就是多少”,而是经过规则口径再整理过的可支付净薪。
四、为什么有些工资规则会合并,有些会拆开
这部分是 payroll-accounting 最容易被误解的地方。
在 _prepare_slip_lines() 和 _prepare_line_values() 里,系统会根据公司设置与 salary rule 设置,决定会计行是合并还是拆分。
情况 1:公司启用批量合并
如果 company.batch_payroll_move_lines = True,系统倾向于把同类会计行合并。
这更适合财务想看月度汇总、而不想凭证被员工明细刷屏的场景。
情况 2:rule 没要求挂员工维度
如果 employee_move_line = False,即使不是批量模式,某些同类行也会被 merge。
这意味着这条规则在会计上更像总额口径,而不是员工应付款口径。
情况 3:rule 要挂员工维度
如果 employee_move_line = True,并且不是 batch 模式,系统会把 partner 设成员工的 work_contact_id,这样后续 payment register 才有明确的对手方可追踪。
情况 4:rule 还要求按名称拆分
split_move_lines 会进一步要求按工资单行名称拆,而不是只按 rule 名称合并。对报销、补发、扣回这类明细化项目很有用。
所以真正控制工资分录颗粒度的,不是一个总开关,而是三层叠加:
- 公司是否批量合并
- rule 是否挂员工
- rule 是否按名称继续细拆
五、为什么多银行卡会在会计分录阶段就先拆开
_prepare_line_values() 里最企业版的一段,是对 多银行卡工资分配 的处理。
如果同时满足:
- 当前不是 batch merge
- 这条 rule 需要
employee_move_line - 员工
has_multiple_bank_accounts
那么系统不会只生成一条总额会计行,而是先调用 compute_salary_allocations(),再按每个银行账户生成多条 line,并把 employee_bank_account_id 写到行上。
也就是说,付款拆分不是到 payment wizard 才临时算的,而是在会计负债层就已经保留了“这部分净薪该去哪个银行账户”的痕迹。
这非常关键。
因为只有这样,后面的 payment register 才能顺着分录行,安全地产生多笔 outbound payment。
测试 test_hr_payroll_payment_multi_banks.py 也明确验证了这一点:
- 一个员工两张银行卡
- 分配比例 40% / 60%
- 最终真的生成 2 笔付款
- 每笔金额与对应银行卡匹配
所以多银行卡不是 UI 花活,而是从会计分录开始就被建模了。
六、为什么 system 允许“Adjustment Entry”兜底平账
工资规则和小数精度一多,借贷不平是很容易出现的边缘问题。
_prepare_adjust_line() 的做法很务实:
- 先用 Payroll 精度比较借贷合计;
- 如果不平,就往 journal 的
default_account_id补一条Adjustment Entry; - 如果 journal 没默认科目,直接报错。
这说明 Odoo 的态度不是“有 0.01 差额也照样过”,而是:
工资凭证必须严格平衡;如果业务规则和取整让它不平,就显式打到调整科目,而不是悄悄吞掉误差。
这对财务审计是好事,因为差额被明牌展示,而不是隐藏在某条工资行里。
七、为什么 validated 还不能立刻 register payment
很多实施同学最容易踩的坑,就是看到工资单已经 validated 了,就默认应该可以付款。
源码在 action_register_payment() 里连设了好几道门槛:
- 不能是已 paid 的工资单
- NET salary rule 的 credit account 必须可 reconcile
- 员工银行账户必须允许 outbound payment(可信)
- 关联的 account.move 必须已经 posted
这几个限制合起来传达的其实是同一句话:
支付动作只能建立在“已确认、可核销、可追账、且收款账户可信”的净薪负债上。
特别是第二条很容易被忽略。
如果 NET 对应的贷方科目不可核销,后续付款就无法把“应付工资”和“实际付款”正确勾上,系统干脆不让你走下去。
而银行账户上的 allow_out_payment 检查,则明确表达了企业打款的合规边界:不是员工填了个账号就能直接放款。
八、为什么 hr_payroll_account 还要改 payment register 的合法科目类型
models/account_payment.py 只做了一件很短、但非常关键的事:
- 如果上下文里有
hr_payroll_payment_register - 就把
liability_current加进 payment account type 白名单
这段看起来小,却解决了 payroll payment 和普通 AP/AR payment 的本质差异。
普通付款向导更多围绕应收应付、银行现金这些常见科目类型转;但工资净薪常常挂在 流动负债 上。
如果不放开这类科目,工资支付根本没法沿着标准 payment register 走。
所以 hr_payroll_account 做的不是另起一个“工资付款系统”,而是:
在保留 account.payment 标准框架的前提下,为 payroll 的负债型付款打开一条合法通道。
这也是 Odoo 一贯的扩展风格:复用会计主框架,而不是在旁边再造一个影子流程。
九、实战里最容易误解的 5 个点
误区 1:验证工资单就一定一人一张分录
不一定。可能按 journal + 月份合批。
误区 2:NET 行金额就是最终付款金额
不一定。not_computed_in_net 会把某些规则从可支付净薪里剔出去。
误区 3:多银行卡只是付款界面上的拆分
不是。会计分录阶段就已经写了银行账户级拆分语义。
误区 4:工资单 validated 就能付款
不是。还要看 move 是否 posted、NET 贷方是否可核销、账户是否可信。
误区 5:工资付款是 payroll 自己独立搞的一套支付逻辑
也不是。它仍然走 account.payment.register,只是对负债型工资支付做了特化放行。
十、对二开和实施最有价值的启发
如果你要扩展这套链路,最值得学的是下面几条:
1)先区分“工资结果”与“会计可支付结果”
显示给 HR 的净薪,不等于应付工资负债口径。
2)把分录颗粒度设计成可配置,而不是写死
合批、挂员工、按名称拆分,分别解决不同财务需求。
3)在会计层提前保留支付目标信息
多银行卡拆付之所以稳,就是因为分录行上已经知道钱要去哪。
4)付款入口必须卡住核销与信任边界
否则工资支付一旦放错账或打错户,代价远高于普通业务付款。
总结
把 hr_payroll_account 源码串起来以后,你会发现 Odoo 企业版真正实现的不是“工资单验证后自动记个账”。
它做的是一条完整的企业链路:
- 用 journal + 月份 组织工资入账批次;
- 用 salary rule 控制 NET 口径、借贷科目与分录颗粒度;
- 用 employee_move_line / split_move_lines / batch_payroll_move_lines 平衡汇总与明细;
- 用 salary allocations + employee_bank_account_id 支撑多银行卡拆付;
- 用 reconcile / posted / trusted bank account 守住付款边界;
- 最终再复用 account.payment.register 完成净薪支付。
所以 Odoo 企业版工资会计的本质,不是“工资单顺手生成一张凭证”,而是“先把 payroll 结果翻译成可核销、可支付、可审计的负债,再谨慎推进付款”。
这才是这套企业版源码最值得学的地方。
参考源码
- enterprise/hr_payroll_account/models/hr_payslip.py
- enterprise/hr_payroll_account/models/hr_salary_rule.py
- enterprise/hr_payroll_account/models/account_payment.py
- enterprise/hr_payroll_account/models/res_partner_bank.py
- enterprise/hr_payroll_account/models/hr_payslip_run.py
- enterprise/hr_payroll_account/tests/test_hr_payroll_payment.py
- enterprise/hr_payroll_account/tests/test_hr_payroll_payment_multi_banks.py
- enterprise/hr_payroll_account/tests/test_res_partner_bank.py
DISCUSSION
评论区