框架深潜

Odoo 台湾 ECPay 电子发票为什么不只是“发票送审”:载具/捐赠互斥、Allowance 同意链与作废边界讲透

台湾 ECPay 模块的复杂度,远不止生成一份 JSON。Odoo 需要先区分 B2B 与 B2C、处理列印/爱心码/载具三者的互斥关系、按税别拼装项目明细、为 B2B 先建买方、送出后再反查有效状态、退款时根据 online/offline allowance 走不同同意链,最后还要在作废与 reset to draft 之间维护严格边界。

框架
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

先说结论

台湾 ECPay 模块真正复杂的地方,不是“会不会调用 /Issue 接口”,而是 Odoo 必须同时处理:

  1. B2B 与 B2C 两套不同的数据规则。
  2. 列印、爱心码、载具之间的互斥关系。
  3. 发票、折让(allowance)、作废(invalid)三条后续生命周期。
  4. 线上同意与线下同意两种退款协商机制。
  5. ECPay 先受理、后查询有效性的异步状态差。

/home/ubuntu/odoo-temp/addons/l10n_tw_edi_ecpay/models/account_move.pyaccount_move_send.pycontrollers/main.py 与相关 wizard 来看,这个模块不是一个简单的“发送器”,而是一套围绕台湾发票法规与消费者交互设计的状态机。

B2B / B2C 分流不是小细节,而是整条 JSON 结构都会变

模块里有个很核心的判断:

l10n_tw_edi_is_b2b = rec.partner_id.commercial_partner_id.is_company

一旦进入 B2B,很多行为都会变:

  • 要求 8 位统一编号;
  • /Issue 前先 _l10n_tw_edi_send_create_buyer()
  • Item 金额、税额、TotalAmount 的计算方式不同;
  • /GetIssue/Invalid、Allowance 的字段命名也可能不同。

这说明 Odoo 没把 B2B 当成“多填一个 VAT”而已,而是承认:

企业发票与消费者发票,本来就是两套监管语义。

列印、爱心码、载具为什么是互斥关系

几个计算字段连起来看,非常有意思:

  • love_code 或特定载具时,就不该列印;
  • 选择列印或有 VAT 时,love_code 会被清空;
  • 选择列印或爱心码时,载具信息也会被清空。

这不是 UI 偷懒,而是在把法规/流程约束编码进模型:

  • 你不可能一张 B2C 电子发票既做纸本列印、又捐赠、又塞进个人载具;
  • 对消费者来说,这几种归宿本来就是互斥选项。

如果实现时把这三者都当成“可同时勾选的字段”,最后只会在 ECPay API 或报税链路上爆炸。

发票 JSON 不是随便拼字段,Item 级金额处理很讲究

_l10n_tw_edi_prepare_item_list() 是整篇源码里最值得慢读的一段。

它处理了:

  • B2B 与 B2C 不同的含税/未税口径;
  • special tax 的特殊计算;
  • 每一行的 ItemSeq / 原始序号;
  • 汇总金额和发票总额的 rounding 差异;
  • 折让单时引用原始发票汇率,处理 exchange difference。

这说明台湾电子发票最难的一层,不是 endpoint 名字,而是:

你如何把 Odoo 的会计行、税行、币种与 rounding 逻辑,压进 ECPay 能接受的 item 结构。

尤其 allowance 场景下,汇率差额还要并回最后一行金额,这就是典型的“法规接口逼你正视业务精度”。

为什么发票送出后还要再查一次状态

_l10n_tw_edi_send() 成功后,Odoo 只是把状态先写成:

  • l10n_tw_edi_ecpay_invoice_id
  • l10n_tw_edi_invoice_create_date
  • l10n_tw_edi_state = 'invoiced'

随后 _call_web_service_after_invoice_pdf_render() 还会调用 _l10n_tw_edi_update_ecpay_invoice_info(),再通过 /GetIssue 判断最终是:

  • valid
  • invalid

这就说明 invoiced 在 Odoo 里更像“已送件”,而不是“监管状态最终有效”。

因此如果你只看第一步回包,不做第二步确认,就会把“已受理”和“已生效”混为一谈。

B2B 为什么要先创建 buyer

_l10n_tw_edi_send() 里对 B2B 有一个前置动作:

  • _l10n_tw_edi_send_create_buyer()
  • ECPay 若返回“buyer already exists”也算可继续;
  • 其他错误则直接停止开票。

这意味着 B2B 不是把统一编号直接塞进 /Issue 就够了,平台侧还要求先维护买方档案。

从平台设计角度看,这很合理:

  • 企业票据需要稳定买方主体;
  • 后续查验、折让、统计都更依赖主体档;
  • 所以 buyer master data 先行。

Allowance 为什么分 offline 与 online 两条链

退款/折让在台湾场景里,不是简单开张红字单。

_l10n_tw_edi_issue_allowance() 明确区分:

  • offline:商家线下已经取得同意,再直接发 Allowance;
  • online:调用 /AllowanceByCollegiate,由 ECPay 发通知给客户,客户点链接同意后才成立。

对应地:

  • Odoo 会根据 agreement type 选择不同 endpoint;
  • online 场景会生成 ReturnURL
  • controller /invoice/ecpay/agreed_invoice_allowance/<id> 接收回调;
  • 然后把 refund_state 写成 agreeddisagreed

这条链特别重要,因为它说明 Odoo 在这里处理的不是单纯财务冲销,而是消费者同意权

为什么作废必须给理由,而且不能乱 reset to draft

l10n_tw_edi.invoice.cancel wizard 很直接:

  • 没有 reason,不让取消;
  • 有理由后,先 _l10n_tw_edi_run_invoice_invalid()
  • 然后再 button_cancel()

button_draft() 只有在某些非常受限的条件下,才允许把已经 invalid 的单据清掉相关 ECPay 字段并回草稿。

这背后逻辑很清楚:

  • 发出去的电子发票不是普通草稿对象;
  • 必须先和 ECPay 侧完成 invalid;
  • 本地状态才能跟着清理;
  • 不是你想回草稿就能直接抹掉外部痕迹。

实施时最该记住的 5 条经验

1. 先分清 B2B/B2C,再谈字段映射

这会影响 buyer、税额、JSON 结构、后续接口。

2. 不要让列印、爱心码、载具同时自由组合

它们在监管逻辑上本来就互斥。

3. invoiced 不等于 valid

一定要做二次状态确认。

4. Allowance 不是红字发票同义词

它可能需要客户显式同意,尤其是 online 路径。

5. 作废必须先走外部 invalid

不要试图只在 Odoo 本地改状态。

最后一句

台湾 ECPay 模块真正难的,不在于 API 名称,而在于消费者归档方式、企业主体建档、项目金额精度、折让同意链与作废顺序全部都要同时满足。

这正是“平台层合规”文章值得写的地方:看上去像一张发票,实际上背后是一整套规则编排。

DISCUSSION

评论区

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