先说结论
Nilvera 接入最容易被误判成“发票出一份 XML,传上去就结束”。
但从 /home/ubuntu/odoo-temp/addons/l10n_tr_nilvera_einvoice/models/account_move.py、account_move_send.py、account_journal.py 来看,Odoo 实际在处理的是一条更复杂的链路:
- 先判断这张票应该走 e-Invoice 还是 e-Archive。
- 发送前做大量土耳其本地合规校验。
- 如果 Nilvera 侧没有 series,Odoo 会尝试自动补建并重试一次。
- 送出后靠状态轮询补齐最终结果,而不是假设 200 就等于成功入账。
- 一旦票据进入某些状态,就不允许 reset to draft,避免法律与会计完整性被破坏。
不是所有发票都走同一条通道
MOVE_TYPE_CATEGORY_MAP 很清楚地把 move type 与 invoice channel 分开了:
- 销项发票:可能走
earchive或einvoice; - 进项发票:只在某些场景走
einvoice采购链路。
而真正决定出口的是 partner 的 Nilvera 客户状态与 alias。
在 _call_web_service_before_invoice_pdf_render() 里,Odoo 会先检查:
- partner 是否已有
l10n_tr_nilvera_customer_alias_id; - 如果没有,就再次
_check_nilvera_customer(); - 有 alias,走 e-Invoice;
- 没 alias,走 e-Archive。
这说明 Odoo 没把本地发票类型简单映射成单一 API,而是承认:
同样是销项票,客户在监管网络里的身份不同,发送通道也不同。
UUID 在过账时就生成,意味着“法律身份”先于发送结果存在
_post() 里只要公司国家是 TR,且还没有 Nilvera UUID,就会先生成一个 uuid4()。
这一步很有意思。 它说明在 Odoo 眼里,土耳其电子发票的远端身份,不是等上传成功才生成,而是票据一进入正式 posted 生命周期,就要有自己稳定的 Nilvera UUID。
这样设计有两个好处:
- 之后查状态、关联附件、取回文档都有固定主键;
- 即便发送报错,也能明确这是“某张已经成立的正式票据出错”,不是草稿里的一次随手尝试。
series 不存在时为什么 Odoo会自动补建一次
_l10n_tr_nilvera_submit_document() 里有个很实战的逻辑:
- 如果 Nilvera 返回 4xx;
- 解析错误码后发现有
3009; - 就调用
_l10n_tr_nilvera_post_series(); - 把 XML 文件流 seek 回开头;
- 再重试一次,而且只重试一次。
这说明 Odoo 团队显然知道一个真实问题:
企业本地发票序列已经开始用了,但 Nilvera 平台上对应的 series 可能还没建。
如果系统只是原样报错,最终用户多半根本不知道该去哪补; 但如果无限重试,又会陷入死循环。
所以这里的处理非常稳:
- 识别特定错误;
- 自动补建;
- 单次重试;
- 再失败就明确抛错。
这就是很典型的平台化容错,不是“多写一个 if”那么简单。
发送前的大量 alert,本质是在把监管前置条件显式化
account_move_send.py 里的 _get_alerts() 非常值得读,因为它揭示了 Nilvera 集成真正依赖的数据面。
发送前会检查的内容包括:
- 公司是否在 TR,且基础地址、税号、州、省、市、街道是否齐全;
- 公司联系人是否带了 MERSISNO 或 TICARETSICILNO 等官方标签;
- partner 是否有税号、税务局、国家和地址;
- partner 的 e-invoice 格式是否是
ubl_tr; - Nilvera customer status 是否已经校验;
- 订阅类行项目起止日期是否一致;
- 是否存在 Nilvera 不接受的负数量或负单价;
- 发票编号是否满足 3 位前缀 + 年份 + 顺序号格式。
这类 alert 的价值在于:
系统没有把“合规失败”都留到远端 API 再打回,而是在 Odoo 发送前尽可能前置暴露。
为什么 sent 还不等于最后成功
发送成功时,Odoo 只会先把:
is_move_sent = Truel10n_tr_nilvera_send_status = 'sent'
并留言 “The invoice has been successfully sent to Nilvera.”
注意,这里的 sent 不是终态。
后面还要通过 _l10n_tr_nilvera_get_submitted_document_status() 继续查 /Status。
状态字段里还有:
waitingsucceederrorunknown
这说明 Nilvera 处理链路本身就是异步的,Odoo 必须承认:
上传被接收,不等于收票方已接收,也不等于监管链路已确认。
为什么 fetch 进项票据时按 CreatedDate 断点,而不是按发票日期
_l10n_tr_nilvera_get_documents() 的注释非常精彩。
它明确说明:
- 过滤使用的是 Nilvera 平台上的
CreatedDate; - 不是业务上的发票日期;
- 这样才能总是拉到“最近上传到平台”的文档;
- 也才能在分页拉取中断后,从上次成功位置继续。
而断点是按公司、invoice channel、journal type 分别存成 config param:
l10n_tr_nilvera_{invoice_channel}_{journal_type}.last_fetched_date.{company_id}
这是一种非常朴素但很有效的恢复策略。 它告诉我们:
真正可靠的对账抓取,不是“每次全量扫一遍”,而是围绕平台时间线做断点续抓。
为什么 error 或已发送后不能 reset to draft
这个模块对 button_draft() 和 _post() 都做了严格限制。
- 如果已经 sent 过,就不能 reset to draft;
- 如果是
error且已有 UUID,甚至会提示你重新创建一张新发票,而不是复用原单; - 原因写得很直白:保持 accounting integrity,并满足 legal requirements。
这点很重要。
很多团队一出错就想“回草稿改一下再发”。 但在电子发票体系里,一旦一张正式编号与外部平台发生过关系,复用原单往往比新开一张更危险。
Odoo 在这里选择的是更保守、也更合规的路线。
实施时最该记住的 4 个关键点
1. 先分清 e-Invoice 还是 e-Archive
不要以为所有销项票都是同一路由,customer alias 是真正分水岭。
2. 看到 3009,先想到 series 缺失
这不是 XML 坏了,而可能是 Nilvera 平台上没有对应序列。
3. sent 不是最终成功
一定要结合后续状态轮询,不要看到 200 就结束业务动作。
4. reset to draft 受限不是“体验差”,而是法律边界
对这类监管接口,保守一点往往才是对的。
最后一句
Nilvera 集成真正难的,不在“会不会发 XML”,而在平台路由、前置校验、远端异步状态、断点拉取与不可逆票据生命周期这五件事同时成立。
理解了这五层,你就会知道为什么土耳其电子发票模块看起来不大,实则非常讲究边界设计。
DISCUSSION
评论区