
德国从 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 的辅助下开发,代码、测试数据和评测结果都已开源。