很多人第一次看到 Odoo 的借项通知单,会把它理解成“再开一张多收款发票”。
但 /home/ubuntu/odoo-temp/addons/account_debit_note 的实现说明,系统想表达的不是“复制一张新的发票”,而是:
基于原单据创建一张带追溯关系的正向调整单。
这也是为什么借项通知单和“随手新建一张发票”在会计语义上差很多。
一、入口第一关:不是所有 move 都能做借项通知单
default_get() 先对当前选中的 account.move 做了几层硬校验:
- 必须已经
posted - 不能已经有
debit_origin_id move_type只能是:out_invoicein_invoiceout_refundin_refund
这个判断已经说明两件事:
1)借项通知单不是草稿修正工具
原单必须先过账,说明它针对的是已经进入正式会计语义的单据。
2)它不是无限嵌套链
如果一张发票已经是某次借项链的下游,再继续对它起借项通知单会让追溯关系越来越乱,所以源码直接禁止。
二、借项通知单最关键的不是金额,而是“保留原单关联”
在 _prepare_default_values() 里,核心字段之一是:
debit_origin_id = move.id
而 account.move 上也显式加了:
debit_origin_iddebit_note_idsdebit_note_count
这代表 Odoo 对借项通知单的核心建模不是“另一张 invoice”,而是:
一张能明确回指原始单据的 adjustment document。
这样做的价值很现实:
- 表单上能追原单;
- 原单上能看所有借项通知单;
- 后续审计、客服和财务沟通都能沿链追溯。
三、为什么从 refund 发起借项通知单时,单据类型会翻回 invoice
_prepare_default_values() 有个很容易忽略的逻辑:
in_refund->in_invoiceout_refund->out_invoice- 其他情况保持原类型
这段逻辑其实非常有业务含义。
因为借项通知单的语义是“正向补回金额”。
如果你对一张 refund 再做 debit note,本质上不是“再来一张 refund”,而是把方向翻正,变回普通 invoice / bill 语义。
所以这里处理的不是技术映射,而是会计方向回正。
四、为什么默认是草稿,而且很多时候不复制行
草稿
测试里明确验证:新建出来的 debit note 是 draft。
这是合理的,因为系统只是根据原单做了一次结构化衍生,并不意味着这张调整单已经完成会计确认。
仍然要给财务一个最后检查和改写的机会。
不总是复制行
copy_lines 是个可选布尔值。
而且源码还额外规定:
- 如果不勾选
copy_lines,就清空line_ids - 即使勾选了,但原单是
in_refund/out_refund,也不会复制行
这反映了一个很重要的产品判断:
借项通知单不一定是“照原单逐行加价”,很多时候只是要保留调整关系,再让财务重新组织行项目。
所以“复制行”只是辅助,不是借项通知单的定义本身。
五、为什么 include_business_fields=True 很值得注意
create_debit() 在遍历原单时用了:
with_context(include_business_fields=True)
旁边注释也写得很直白:
- copy sale/purchase links
这说明借项通知单在 Odoo 看来不只是会计凭证复制,还希望把:
- 销售链路
- 采购链路
- 相关业务引用
尽量一起带过去。
这在落地里很重要,因为财务调整单如果跟业务单完全脱链,后续:
- 对账
- 查询来源
- 客诉追踪
- 销售/采购协同
都会变得很难看。
六、为什么还专门改了编号域和起始前缀
account_move.py 里还有两段很容易被忽视,但非常“财务系统”的实现。
_get_last_sequence_domain()
如果 journal 开启了 debit_sequence,系统会把:
debit_origin_id IS NOT NULL- 或
IS NULL
纳入序列取号域。
这意味着普通发票和借项通知单可以走并行但分隔的编号序列。
_get_starting_sequence()
在满足这些条件时:
- journal 启用
debit_sequence - 当前单据有
debit_origin_id - 类型是
in_invoice/out_invoice
起始序列会在前面加一个 D。
这相当于告诉财务和审计:
这不是普通 invoice 编号,它属于借项通知单序列。
这不是 UI 装饰,而是正式单据治理。
七、为什么消息内容也要专门改
_get_copy_message_content() 对 debit note 做了专门分支:
- 会在消息里写“这张借项通知单由哪张单据创建”。
这件事看起来小,但很符合 Odoo 的产品哲学:
- 业务链关系不只存在字段里;
- 也应该在 chatter 这种“人能直接看懂”的界面里体现。
八、测试用例把产品预期说得很清楚
test_out_debit_note.py 至少说明两件事:
1)普通客户发票 + copy_lines=True
- 会复制发票行;
- 类型保持
out_invoice; - 新单据是草稿。
2)供应商退款发起借项通知单
- 默认不复制行;
- 类型回正成
in_invoice; - 新单据仍是草稿。
这两条就把“借项通知单不是普通复制发票”讲得很透了。
九、实战里最容易踩的坑
1)把借项通知单当成“重新开票”按钮
它本质是带追溯关系的调整单,不是随手重开一张发票。
2)以为勾了复制行就什么都照搬
退款类原单就是明确不照搬。
3)忽略独立借项序列
如果公司审计要求借项单据单独编号,应该关注 journal 的 debit_sequence,而不是只看表单显示。
4)只看金额,不看原单关系
借项通知单最值钱的是“为什么它存在、它补的是哪一张”。
总结
account_debit_note 的核心不是“创建一张金额更高的发票”。
它真正做的是:
- 严格限制哪些单据能发起借项通知单;
- 把新单据和原单据通过
debit_origin_id牢牢连起来; - 在 refund 场景下把单据方向翻回 invoice / bill;
- 保持草稿审查边界;
- 在需要时使用独立借项编号序列。
如果只记一句,可以记这句:
Odoo 借项通知单不是“复制原发票再加钱”,而是“围绕原单据做一次可追溯、可审计的正向调整”。
DISCUSSION
评论区