很多实施顾问第一次看 Odoo 会计附件上传,都会把它想得很直:
- 前端把 PDF 传上来;
- 后端存一条
ir.attachment; - 然后界面刷新一下。
但官方并不是这么做的。
从 document_file_uploader.js 和 account/models/ir_attachment.py 连起来看,Odoo 真正拆的是三层责任:
- 上传层:先把原始文件安全、独立地存成附件;
- 业务层:再让具体模型决定“这批附件该生成什么业务对象”;
- 后处理层:附件落到业务对象后,再补做解析、展开和关联修正。
这也是为什么它看起来只是“拖个文件进来”,背后却能接 OCR、文档识别、账单导入、审计轨迹等一整串能力。
一、前端第一步不是建账单,而是先建 ir.attachment
DocumentFileUploader.onFileUploaded() 做的事情很克制。
它先把浏览器拿到的文件整理成:
namemimetypedatas
然后直接调 ORM 创建 ir.attachment。
注意这里有个很关键的细节:它会先把当前搜索上下文里的 default_* 清掉,再把“干净上下文”传给 ir.attachment.create()。
这不是代码洁癖,而是很现实的防呆。
因为会计看板、列表页、过滤器上经常带着很多 default_journal_id、default_move_type 之类的上下文。如果这些默认值原封不动传给附件创建,很容易把本来只是“存文件”的动作污染成莫名其妙的业务创建失败。
所以官方明确地把“原始附件创建”从“业务默认值上下文”里隔离开了。
一句话总结就是:
先把文件变成一个中立附件,再谈业务识别。
二、真正的业务入口不是 ir.attachment.create(),而是 create_document_from_attachment
文件一条条上传完成后,onUploadComplete() 不会自己猜该建什么单据,而是统一调用业务模型上的:
create_document_from_attachment
这一步设计特别重要,因为它把职责切得很清楚:
- 上传组件只负责“收集附件 id”;
- 具体业务模型负责“解释这些附件”。
换句话说,前端上传器并不知道你现在想建的是:
- 供应商账单;
- 销售订单;
- 还是别的支持附件导入的模型。
它只知道:这批附件上传完了,请目标模型接管。
在 sale.order 里就能看到一个很典型的实现:
- 先
browse(attachment_ids); - 没附件就直接报错;
- 再调用
_create_records_from_attachments(attachments); - 最后返回一条 action,把新建出的记录打开。
这就是 Odoo 很典型的可扩展思路:
上传器不懂业务,业务模型借上传器拿到原材料。
三、为什么要分成“先附件、后业务”两步
这套分层不是为了优雅,而是为了解决现实问题。
1)上传成功不等于业务识别成功
发票文件能传上来,不代表:
- 文件格式一定受支持;
- OCR 一定识别正确;
- 能匹配到供应商;
- 能自动生成正确单据。
所以把“文件已进库”和“业务已创建”拆开,失败边界就清楚了。
2)一批附件可能对应一批业务对象
前端是逐个文件上传,但业务层经常要批处理。
例如:
- 一次拖 10 张账单;
- 统一识别;
- 统一返回通知;
- 最后跳到生成结果页。
因此 attachmentIdsToProcess 会先累积,等整批上传完成再统一交给业务模型。
3)审计和权限更好控
附件先独立落到 ir.attachment,后面无论识别成功、失败、部分成功,都还有一个清晰的原始文件留痕点。
对会计场景来说,这很重要。
四、action.context.notifications 说明“上传完成”后还有业务反馈层
onUploadComplete() 里有个容易被忽略的设计:
如果返回的 action 里带 context.notifications,前端会把这些通知逐条弹出来。
这说明官方认为批量附件导入的结果,天然就不该只有“全成”或“全错”两种。
现实里更常见的是:
- A 文件识别成功;
- B 文件格式不支持;
- C 文件重复;
- D 文件已生成草稿但有警告。
所以业务模型可以把逐文件反馈挂回 action,由上传器统一展示。
这比“抛一个总异常”对实施和操作员友好多了。
五、ir.attachment 在 account 里并不是被动文件柜
很多人把 ir.attachment 理解成一个纯存储表,但 account/models/ir_attachment.py 明显不是这个定位。
1)write() 会拦审计轨迹破坏
如果你改的是:
res_idres_model- 原始内容字段
- 公司字段
account 扩展会先尝试 _except_audit_trail()。
也就是说,在启用了严格审计轨迹的公司里,某些已入账单据上的 PDF/XML 并不能随便删、随便换、随便挪。
这正好回答了一个常见误解:
会计附件不是普通业务附件,它可能已经成为审计链的一部分。
2)unlink() 有时不是删,而是“脱钩保留”
对于像 invoice_pdf_report_file、ubl_cii_xml_file 这样的关键发票附件,unlink() 遇到限制审计轨迹时并不会简单删除。
它会:
- 把
res_field清掉; - 给附件改名,标记是谁在什么时候 detach;
- 但文件本体继续保留在数据库里。
这非常会计。
系统允许你“取消字段上的直接绑定”,但不允许你无痕抹掉审计证据。
3)_post_add_create() 说明附件创建后还会继续喂给业务对象
当附件是挂到 account.move 上时,_post_add_create() 会把附件整理成 files_data,再交给 move 去:
_to_files_data()_unwrap_attachments()_extend_with_attachments()
这说明附件落账后,仍然可能继续触发:
- 附件展开;
- 文档元数据抽取;
- 业务记录扩展。
所以附件不是终点,它本身就是业务链的一环。
六、实施时最容易踩的几个坑
误区 1:把上传器当成“建账单按钮”
其实它只是附件收集器 + 业务入口转发器。
误区 2:在上传上下文里乱塞 default_*
这很容易导致附件创建阶段就被业务默认值污染。
误区 3:以为附件删了就等于痕迹没了
在 restrictive audit trail 下,经常只是“解绑”,不是“消失”。
误区 4:上传失败只查前端
很多问题其实发生在 create_document_from_attachment 或附件后处理阶段,而不是 HTTP 上传本身。
七、排错顺序怎么更高效
如果现场出现“附件传上去了,但单据没出来”这类问题,我建议按这个顺序查:
- 浏览器侧确认文件是否真的建成了
ir.attachment; - 看
create_document_from_attachment有没有返回 action 或 notification; - 检查目标模型的附件导入实现是否支持当前文件类型;
- 如果是会计单据,再检查 audit trail / field detach / 后处理有没有拦住后续动作。
这样查,比一上来就怀疑 OCR 或前端按钮靠谱得多。
八、结论
Odoo 会计附件上传之所以能同时承接“拖拽上传、自动识别、会计留痕、审计保护”,靠的不是一个更花哨的上传按钮,而是清晰的三段式分层:
DocumentFileUploader先安全落附件;- 业务模型用
create_document_from_attachment决定生成什么; account对ir.attachment的扩展继续负责审计保护和后处理。
所以真正该记住的不是“附件怎么传”,而是:
在 Odoo 里,附件上传是业务入口的一部分,但它绝不等于业务本身。
DISCUSSION
评论区