先说结论
Odoo 的报销拆分,不是“把总金额改成三段”这么简单。
源码真正保护的是三件事:
- 原始报销要继续存在,不能把来源彻底抹掉
- 附件证据要跟着每个拆分结果走,否则复核会失去凭证上下文
- 所有拆分出来的记录要能被重新认成一家人,所以需要
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() 会从原始报销带出一整批默认值:
nametax_idsproduct_idcompany_idanalytic_distributionemployee_idcurrency_idapproval_stateapproval_datemanager_id
这很关键。
它说明拆分并不是“重新手工录几张单”,而是把原单的大部分业务语义当成基底,再允许你只改其中少数关键差异。
最值得注意的是两类字段:
1)analytic_distribution
这里用了 deepcopy()。这说明 Odoo 很明确地知道:
- 分析分配是结构化数据
- 拆分后的修改不该反向污染原单
如果只做浅复制,后面很容易出现“改了其中一条拆分行,其他行也一起变”的共享引用问题。
2)审批相关字段
approval_state、approval_date、manager_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 出去”,而是把第一份拆分结果留在原单身上。这样做至少有三个好处:
- 原始主键继续保留,很多 chatter、活动、外部引用不用全部迁移
- 减少一次额外复制,性能和实现都更简单
- 原单仍然代表拆分家族中的第一份结果,不是一个被废弃的壳
所以从业务角度看,拆分不是“删除旧单再生成新单”,而是:
让旧单转身成为第一份合法结果,其余部分再派生出去。
_get_values():真正被复制的不是“拆分行”,而是一张完整 expense 所需字段
hr.expense.split._get_values() 最值得看的地方,是它没有只返回金额。
它会准备:
nameproduct_idtotal_amount_currencytotal_amounttax_idsanalytic_distributionemployee_idproduct_uom_idapproval_stateapproval_datemanager_idaccount_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_amount、account_id、税和分析分配都要跟着落地。
我会怎么给业务方解释
如果财务问:“为什么不能像 Excel 一样把一张单拆成几段金额?”
我会直接说:
因为 Odoo 拆分的是可审核的报销单,不是报表里的数字切块。每一块拆出来以后,都还得带着凭证、审批语义和来源关系继续活下去。
一句话记忆
Odoo 的报销拆分不是金额切片,而是把原单改写、复制新单、复制附件,并用 split_expense_origin_id 维持整组结果可追溯。
DISCUSSION
评论区