人力资源

Odoo 报销拆分为什么不是改几行金额:split wizard、附件复制与 split origin 关系链讲透

很多团队以为报销拆分就是把一张单据拆成几行金额。Odoo 的 hr_expense 源码做得更严谨:它会先改写原单、再复制新单、再复制附件,并用 split_expense_origin_id 把整组拆分结果串回同一个来源,避免拆完以后证据链和追溯关系散掉。

人力资源
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

先说结论

Odoo 的报销拆分,不是“把总金额改成三段”这么简单。

源码真正保护的是三件事:

  1. 原始报销要继续存在,不能把来源彻底抹掉
  2. 附件证据要跟着每个拆分结果走,否则复核会失去凭证上下文
  3. 所有拆分出来的记录要能被重新认成一家人,所以需要 split_expense_origin_id

也就是说,Odoo 拆分的不是一串数字,而是一条可追溯、可审核、可继续流转的报销事实链


为什么 Odoo 不直接在原单上做“多行分摊”

很多人第一次看报销拆分,直觉是:

原单已经有金额、税、附件,那就在一张单里多加几条分摊明细不就好了?

但 Odoo 选的不是这个方向。

hr.expense.split.wizard.action_split_expense() 的主思路很清楚:

  • 先用第一条 split line 去 改写原始 expense
  • 再把剩余 split line 逐个 copy() 成新的 expense
  • 最后把原单附件复制到这些新单上

这说明官方不希望“拆分”只停留在 UI 层,而是希望拆完之后,每一份结果都还是一张完整、可独立审批、可独立记账的报销单

这和“在一张单里加几条分析分摊”完全不是同一个建模层级。


default_get() 暗示:拆分默认继承的是业务语义,不只是金额

hr.expense.split.default_get() 会从原始报销带出一整批默认值:

  • name
  • tax_ids
  • product_id
  • company_id
  • analytic_distribution
  • employee_id
  • currency_id
  • approval_state
  • approval_date
  • manager_id

这很关键。

它说明拆分并不是“重新手工录几张单”,而是把原单的大部分业务语义当成基底,再允许你只改其中少数关键差异。

最值得注意的是两类字段:

1)analytic_distribution

这里用了 deepcopy()。这说明 Odoo 很明确地知道:

  • 分析分配是结构化数据
  • 拆分后的修改不该反向污染原单

如果只做浅复制,后面很容易出现“改了其中一条拆分行,其他行也一起变”的共享引用问题。

2)审批相关字段

approval_stateapproval_datemanager_id 也会被带过来。也就是说,系统并不把 split 视为彻底重新发起一笔新业务,而是倾向于保留原有审批上下文。

这对于审计很重要,因为拆分通常是在已经有一定业务确认之后发生的。


第一条 split line 为什么要回写原单

action_split_expense() 里先取:

  • expense_split = self.expense_split_line_ids[0]

然后直接:

  • self.expense_id.write(expense_split._get_values())

这一步很容易被忽略,但它其实很有设计意味。

Odoo 不是“原单永远不动,全部 copy 出去”,而是把第一份拆分结果留在原单身上。这样做至少有三个好处:

  1. 原始主键继续保留,很多 chatter、活动、外部引用不用全部迁移
  2. 减少一次额外复制,性能和实现都更简单
  3. 原单仍然代表拆分家族中的第一份结果,不是一个被废弃的壳

所以从业务角度看,拆分不是“删除旧单再生成新单”,而是:

让旧单转身成为第一份合法结果,其余部分再派生出去。


_get_values():真正被复制的不是“拆分行”,而是一张完整 expense 所需字段

hr.expense.split._get_values() 最值得看的地方,是它没有只返回金额。

它会准备:

  • name
  • product_id
  • total_amount_currency
  • total_amount
  • tax_ids
  • analytic_distribution
  • employee_id
  • product_uom_id
  • approval_state
  • approval_date
  • manager_id
  • account_id(若产品费用科目存在)

这里暴露出一个事实:

拆分后的每一份,必须仍然能站得住。

尤其是 total_amount 不是手填,而是按:

  • expense.currency_rate * total_amount_currency
  • 再经过币种 round

这说明 Odoo 连拆分后的本位币金额都要求重新落地,而不是只保存外币值等后面再碰运气推算。

如果你的项目自己扩展了 split,却只改了 total_amount_currency,没同步好公司币金额和科目,后面入账大概率会出怪问题。


为什么附件必须复制,而不是“大家共用原附件”

action_split_expense() 在 copy 新 expense 后,会把原单的 ir.attachment 全部查出来,再对每个 copied expense 执行 attachment.copy()

这一步表面看笨,实际很稳。

因为如果不复制附件,而只是让多张 expense 指向同一个附件对象,会马上带来几个问题:

  • 某一张单据删除或迁移附件时,其他单据受影响
  • 审核人从一张拆分单进去,未必能稳定看到凭证
  • 后续若要做单据级权限或导出,证据归属会变得模糊

所以 Odoo 选择的是:

宁可复制证据引用,也不让拆分后的单据失去“自带凭证”的独立性。

这和会计凭证分录喜欢共享来源链接不同;在报销场景里,审核入口通常就是单据本身,因此附件独立跟随更重要。


split_expense_origin_id 不是备注字段,而是家谱锚点

拆分后,系统会做两步:

  • 如果原单本来就来自某个 origin,就把同源的 split expense 一并找出来
  • 然后把 self.expense_id | copied_expenses | split_expense_ids 统一挂到同一个 split_expense_origin_id

这背后表达的是一个很成熟的思路:

拆分不是一次性的。

今天一张单可以拆成三张;明天其中一张也可能继续拆。只要没有 split_expense_origin_id 这种稳定锚点,系统很快就会失去“这几张其实来自同一张最初单据”的认知。

因此它记录的不是“我的父节点是谁”,而更像:

“我们这一族最初从哪张单开始。”

这对后续筛选、统计、审核追溯都非常关键。


split_possible 为何只关心总额守恒

wizard 里 split_possible 的判断很直接:

  • 把 split line 金额汇总
  • 用币种的 compare_amounts() 去和原始总额比较

它不检查“税后是否刚好一样”这种更复杂条件,而是优先保证:

拆分前后,总金额不能凭空多也不能凭空少。

这很符合向导的定位。

向导负责的是基本算术守恒;真正的税、科目、审批和后续记账一致性,则交给完整 expense 模型继续接手。

所以别把 split wizard 误解成一个全自动会计平衡器。它只是先把最核心的边界守住:总额一致。


实施里最常见的 4 个误区

误区一:拆分后附件留在原单就够了

不够。新单独立审核时,证据链会断。

误区二:拆分只是 UI 功能

不是。它会真实改写 hr.expense 并复制新记录。

误区三:拆分家族不需要额外关系字段

一旦出现二次拆分或合规追溯,没有 split_expense_origin_id 很难讲清来源。

误区四:只要外币金额对了就行

不行。total_amountaccount_id、税和分析分配都要跟着落地。


我会怎么给业务方解释

如果财务问:“为什么不能像 Excel 一样把一张单拆成几段金额?”

我会直接说:

因为 Odoo 拆分的是可审核的报销单,不是报表里的数字切块。每一块拆出来以后,都还得带着凭证、审批语义和来源关系继续活下去。


一句话记忆

Odoo 的报销拆分不是金额切片,而是把原单改写、复制新单、复制附件,并用 split_expense_origin_id 维持整组结果可追溯。

DISCUSSION

评论区

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