欧盟电子发票(XRechnung/Peppol/ZUGFeRD)踩坑记:用 Python 不装 Java 跑官方校验规则

德国从 2027 年起要求企业间(B2B)开具电子发票,法国 2026 年 9 月开始推行,比利时 2026 年起企业间发票全部走 Peppol 网络。这里的"电子发票"不是 PDF,而是一份能让电脑自动入账的结构化 XML,要符合欧盟标准 EN 16931。

我在给账单系统做欧盟电子发票导出时踩了不少坑,最后把整套流程做成了一个开源 Python 工具 eu-einvoice (MIT 协议)。这篇文章讲两个最麻烦的技术点:金额怎么算才能过校验 ,以及怎么在 Python 里不装 Java 跑官方校验规则。

一、先搞清楚格式

EN 16931 只定义了"语义":一张发票有哪些字段(BT-1 发票号、BT-2 开票日期......约 160 个),以及字段间要满足的约 200 条业务规则(BR-xx)。落到 XML 上有两种语法:

  • UBL 2.1(OASIS 标准)
  • UN/CEFACT CII

在此基础上,各国或各网络又叠了一层规则(CIUS):

名称 语法 额外规则
XRechnung 3.0(德国政府) UBL / CII KoSIT 的 BR-DE-xx,比如必须有 Leitweg-ID、卖方电话和邮箱
Peppol BIS Billing 3.0 UBL PEPPOL-EN16931-Rxxx,比如收发双方都要有 Peppol ID
ZUGFeRD / Factur-X CII 嵌在 PDF/A-3 里 Factur-X 自己的 Schematron

二、金额:最常见的被拒原因

规则要求各个金额之间严格对得上,差一分钱就会被拒:

text 复制代码
行净额 BT-131 = 数量 × 单价 / 基准数量 + 行附加费 − 行折扣
各税率分组税额 = round(该组计税基数 × 税率 / 100, 2)        # BR-CO-17
不含税合计 BT-109 = Σ行净额 − 单据折扣 + 单据附加费         # BR-CO-13
应付金额 BT-115 = 含税合计 − 预付款 + 舍入调整               # BR-CO-16

最容易错的是逐行算税再加总 :每行四舍五入后再相加,总数常常和"按税率分组后统一算税"差一分钱,于是触发 BR-CO-15。正确做法是:发票里只保存原始数据(行、单价、税率、折扣),所有派生金额都用 Decimal 加 ROUND_HALF_UP 计算,不要手填合计。

还有一个业务上的坑:贷项通知单(退款单)上的"补偿"应该增加退款额,要作为一行或一笔附加费;如果写成折扣(allowance),反而会让退款变少。

三、不装 Java 跑官方 Schematron

德国官方校验器(KoSIT validator)是 Java 写的,但真正的规则本身是 XSLT 或 Schematron 文件:

规则集 发布形式
CEN EN 16931(UBL/CII) 已编译好的 XSLT 2.0
KoSIT XRechnung 已编译好的 XSLT
OpenPeppol BIS 3.0 只有 Schematron 源文件 .sch

saxonche 把 Saxon-HE(支持 XSLT 3.0)打包成了 Python wheel,pip 装好就能直接执行这些 XSLT:

python 复制代码
from saxonche import PySaxonProcessor

with PySaxonProcessor(license=False) as p:
    xslt = p.new_xslt30_processor().compile_stylesheet(
        stylesheet_file="EN16931-UBL-validation.xslt")
    svrl = xslt.transform_to_string(source_file="invoice.xml")

输出是 SVRL 格式的 XML,每个 svrl:failed-assert 节点就是一条违规,里面有规则编号、严重程度和出错位置。Peppol 只发布 .sch 源文件,用 SchXslt(本身也是纯 XSLT)在同一个 Saxon 实例里先编译成 XSLT 就行。

实测编译 900 KB 的 CEN 规则文件约 0.25 秒,校验一张发票只要几毫秒。规则集按版本固定,下载时做 SHA-256 校验,保证每次结果都能复现。

四、官方规则查不到的坑:编号校验位

Schematron 只校验结构和金额,不校验编号本身对不对。比如德国政府的收票编号 Leitweg-ID,最后两位是 ISO 7064 MOD 97-10 校验码。抄错一位,官方校验照样通过,但发票送不到对方。

python 复制代码
def leitweg_check(s: str) -> int:
    n = int("".join(str(int(c, 36)) for c in s.upper()) + "00")
    return 98 - n % 97

leitweg_check("0531500012345")  # -> 30,所以正确的 ID 是 05315000-12345-30

法国的 SIREN 用 Luhn 算法,比利时企业号用 mod 97,GTIN/GLN 用 GS1 校验位。工具在生成发票前会把这些都检查一遍,发现可疑的就提醒用户去核对,不会自作主张去"改正"。

五、工具用法

bash 复制代码
pip install eu-einvoice
eu-einvoice template xrechnung-b2g > invoice.json   # 拿一个示例改数据
eu-einvoice check invoice.json                      # 预览计算结果和问题
eu-einvoice create invoice.json -o invoice.xml      # 生成 XML 并跑官方三层校验
eu-einvoice validate received.xml                   # 校验别人发来的发票(也支持 Factur-X PDF)
eu-einvoice convert in.xml --to xrechnung-cii -o out.xml
eu-einvoice render received.xml -o view.pdf         # 生成可读的 PDF

校验失败时,每条问题都会给出规则编号、原文、出错位置和修改建议:

它也打包成了 Claude 的 Skill 和 Claude Code 插件,可以直接用自然语言让 Claude 开票:

text 复制代码
/plugin marketplace add uulucky/claude-skill-eu-einvoice
/plugin install eu-einvoice@uulucky-skills

六、测试情况

  • 欧盟官方的 35 份样例发票,解析后分别重新生成 UBL 和 CII 两种格式,70 次全部通过官方校验。
  • 6 个评测用例,对比了"装了工具的 Claude"和"没装的 Claude":装了的平均快约 1.8 倍;在一个故意把 Leitweg-ID 写错的用例里,只有装了工具的一组发现了问题。

七、局限

  • 只负责生成和校验,不负责发送:走 Peppol 要通过接入点服务商,德国政府平台要另外上传。
  • 不支持意大利 FatturaPA、波兰 KSeF 这类非 EN 16931 的本国格式。
  • 选什么税率、用哪种免税理由属于税务问题,请咨询会计。

项目地址:https://github.com/uulucky/claude-skill-eu-einvoice

欢迎 Star,有问题在 GitHub 提 Issue。


说明:本项目在 Claude Code 的辅助下开发,代码、测试数据和评测结果都已开源。

相关推荐
ZWZhangYu1 小时前
Hermes Agent 接入 QQ Bot 的完整实战
1024程序员节
热心网友俣先生1 小时前
2026年MathorCup大数据竞赛选题推荐
数学建模·1024程序员节
Zhu7581 小时前
供暖优化方案-济南市-2026
1024程序员节
abckingaa1 小时前
AI时代代码Review瓶颈分析
人工智能·ai编程·1024程序员节
活文字1 小时前
国家疾控局等部署2026—2027流感季:农村“一老一小”可免费接种
资讯·1024程序员节·商业·产业
Together_CZ1 小时前
Introducing Kimi K3.1 Pro——隆重推出 Kimi K3.1 Pro
llm·agent·强化学习·1024程序员节·kimi·kimi k3.1 pro·3t参数
acd120091 小时前
负载均衡的 “粘性会话”,什么时候不该开
1024程序员节
ZhengEnCi1 小时前
AI Agent工具调用防护层怎么写-一份能直接跑的Python四层拦截实现
python·llm·agent
我喜欢就喜欢1 小时前
豆包赋能抖音内容创作与运营实战指南
人工智能·1024程序员节