PDFlibPas(losLab 的 Delphi PDF 库)用 Pascal 原生校验 EN 16931、XRechnung 3.0.2 和 Peppol BIS Billing 3.0 业务规则。TPDFlib.ValidateInvoiceBusinessRulesBuiltIn 读取 Factur-X 或 ZUGFeRD PDF 内嵌的 XML,返回带官方规则 ID 的 JSON 报告,全程不需要 Java 运行时、Saxon 或 Schematron 工具链。这份报告自称是一个子集,而这个标注正是整份报告里最重要的字段
这股压力主要来自德国。2025 年 1 月起,德国所有国内企业都必须能够接收结构化 B2B 电子发票,开具义务随后分阶段跟上。忽然之间,Delphi 财务或 ERP 应用的接收端必须回答一个以前从没问过的问题:这份 PDF 里的 XML 真的是一张有效的 EN 16931 发票吗?官方答案来自编译成 XSLT 2.0 的 Schematron 产物,或 XRechnung 的 KoSIT 校验器,两者都跑在 JVM 上。为了拒掉一张坏发票就给每个桌面安装附带并持续修补一个 JRE,这笔成本很多团队不愿背
内置校验器到底检查什么?
原生校验器把发票 XML 映射到 EN 16931 业务术语上,运行 EN 16931 校验产物 1.3.16 中规则的一个有文档记载的子集,每条发现都按官方规则 ID 报告。两种语法都支持:Factur-X 和 ZUGFeRD 内嵌的 UN/CEFACT CII D16B,以及 OASIS UBL 2.1 的 Invoice 与 CreditNote。覆盖的范围有:
- 必填项:规格标识符、发票号、开票日期、类型代码、币种、卖方与买方名称、地址、单据合计、VAT 分解与行项目(BR-01 到 BR-16、BR-21 到 BR-28、BR-CO-04、BR-CO-18、BR-CO-26、BR-45 到 BR-48)
- 计算:行、折让、费用、税额与应付合计,以及每个 VAT 类别的应税金额与税额(BR-CO-10 到 BR-CO-17、BR-xx-08、BR-xx-09)
- S、Z、E、AE、K、G、O、L 和 M 的 VAT 类别规则:行税率、免税理由以及各类别要求的 VAT 标识符
- BR-CL-01 到 BR-CL-26 全部代码表,包括 BR-CL-23 背后 2100 多个 UN/ECE Rec 20/21 单位代码,以及两位小数规则 BR-DEC-01、-05、-09 到 -20 和 -23
- Profile 规则:CII 和 UBL 上的 XRechnung 3.0.2(BR-DE-1 到 BR-DE-31、BR-DEX-01 到 BR-DEX-14),以及 UBL 上的 Peppol BIS Billing 3.0.20 core、common、代码表与国家规则
覆盖范围是显式报告的,不是靠猜。没有 schema 文件时,schema 阶段确认良构性与根元素。每个阶段都在 notCovered 字段里列出子集之外的规则,GetBuiltInInvoiceRuleCatalog 则把整张映射表以 JSON 返回,你的 UI 可以向用户讲清楚一个绿色结果到底覆盖了什么
还有一个边界容易漏掉。Factur-X MINIMUM 和 BASIC WL 单据不是 EN 16931 发票,所以校验器不对它们套用 EN 16931 规则,而是加一条说明性的 BuiltIn.Coverage.Profile issue 把这件事讲明白——这是诚实的答案,也是一个提示:按德国规则,这两个 profile 反正也不算电子发票
校验混合 PDF 内嵌的 XML
ValidateInvoiceBusinessRulesBuiltIn 只有一个参数,即内嵌文件的名称,正常情况下传空字符串。PDFlibPas 随后选择 Factur-X 识别出来的那个附件;对没有 Factur-X 元数据的 PDF,则回退到唯一的 .xml 附件。出现多个候选 .xml 附件,或两个附件同名时,会报 Invoice.AttachmentAmbiguous 错误而不是瞎猜;解码上限 64 MB;找不到发票时 LastErrorCode 置为 401
uses
System.SysUtils, System.JSON, PDFlibrary;
procedure CheckInboundInvoice(const FileName: string);
var
PDF: TPDFlib;
Report: TJSONObject;
begin
PDF := TPDFlib.Create;
try
if PDF.LoadFromFile(FileName, '') <> 1 then
raise Exception.CreateFmt('Cannot open %s (error %d)',
[FileName, PDF.LastErrorCode]);
// 空名字:校验识别出的 Factur-X / ZUGFeRD 附件
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesBuiltIn('')) as TJSONObject;
try
Writeln('profile: ', Report.GetValue<string>('profile'));
Writeln('valid: ', Report.GetValue<Boolean>('valid'));
Writeln('coverage: ', Report.GetValue<string>('coverage')); // 即 "subset"
Writeln('full: ', Report.GetValue<Boolean>('fullConformance')); // 这里永远是 False
finally
Report.Free;
end;
finally
PDF.Free;
end;
end;
报告顶层包含 standard、version、profile、complete、valid、fullConformance、coverage,一个 schema 阶段和一个 schematron 阶段,以及内置模式下为 null 的 package 对象。每个阶段报告状态、引擎、执行规则数、自身覆盖情况和一个 issues 数组。每条 issue 都有 ruleID、fieldPath、severity、category、line 和 message
按 category 读 issue,而不是读 message
category 字段是让这份报告在真实收件箱里可用的关键,因为它告诉你该由谁去修。取值有 syntax、schema、cardinality、calculation、vat、codelist、decimal、profile、coverage、processing 和 integrity。calculation 类发现通常指向发送方的舍入代码,codelist 类指向主数据,profile 类指向 XRechnung 字段(比如买方参考号)
procedure ListIssues(Report: TJSONObject);
var
Item: TJSONValue;
Issue: TJSONObject;
begin
for Item in Report.GetValue<TJSONArray>('schematron.issues') do
begin
Issue := Item as TJSONObject;
if Issue.GetValue<string>('severity') = 'info' then
Continue; // BuiltIn.Coverage.Profile 之类的覆盖说明
Writeln(Format('[%s] %s at %s: %s', [
Issue.GetValue<string>('category'),
Issue.GetValue<string>('ruleID'),
Issue.GetValue<string>('fieldPath'),
Issue.GetValue<string>('message')]));
end;
end;
同样的循环也要跑一遍 schema.issues:良构性和根元素错误都落在那里,业务规则阶段根本见不到它解析不了的文档
为什么同一张发票 CII 能过、UBL 却不过?
官方产物在两种语法里并没有套用同样的数值容差,PDFlibPas 从各自语法的 XSLT 逐条照搬规则,而不是强行统一。BR-CO-17 是最清楚的例子:它检查 VAT 类别税额等于应税金额乘税率再保留两位小数,两种语法都允许一货币单位的差额。CII 产物写成 <= 和 >=;UBL 产物用 abs 配严格 <
取应税金额 100.00、税率 19%,预期税额 19.00,而发票声明 20.00。在 CII 里,20.00 减 1 等于 19.00,满足 <=,BR-CO-17 通过。在 UBL 里同样的比较是严格的,19.00 不小于 19.00,BR-CO-17 失败。没人会故意开这种发票,但带舍入 bug 的生成器恰好就骑在这样的边界上,而只测一种语法的测试套件根本注意不到
差异不止 BR-CO-17。BR-xx-08 在 CII 里要求四舍五入到两位后严格相等,UBL 则允许 ±1,而且只在类别实际有行、折让或费用时才触发。BR-O-08 在两边都严格。CII 产物把 BR-AF-09 和 BR-AG-09 定义成 true(),在那里永远不会失败,UBL 用 ±1 检查它们。还有,当 CII 的 VAT 分解完全没有税率时,非 O 类别规则会空洞地通过,因为 XPath 的 every $rate 在空序列上为真。原生代码必须以同样的方式跳过它们,否则对着官方样例文件就会报错
第二个坑在规则 ID 上。规则类别前缀不是 VAT 类别代码:K 映射到 BR-IC,L 映射到 BR-AF,M 映射到 BR-AG。从类别代码推导 ID 会得出 BR-K-09 和 BR-K-10 这种 EN 16931 里根本不存在的 ID。如果你的应用把规则 ID 映射到帮助文本,ID 一律取自报告,永远不要用税类别代码自己拼
XRechnung 与 Peppol 规则如何开启?
内置校验器从规格标识符(BT-24)选择 profile 规则。标识符含 xrechnung 时在 CII 或 UBL 上运行 XRechnung 3.0.2 CIUS 规则;扩展标识符 urn:xeinkauf.de:kosit:extension:xrechnung_3.0 额外启用 BR-DEX 规则。子发票行金额与 VAT 检查(BR-DEX-02、-03 和 -09 到 -14)只在 UBL 上运行;在 CII 上,BR-DEX-15 报告子发票行不可用。标识符含 peppol 时运行 Peppol BIS Billing 3.0.20 规则,而 Peppol 只为 UBL 定义了这套规则。声明 Peppol customization 的 CII 文档会得到一条说明性的 BuiltIn.Coverage.Profile 备注,说明这些规则未检查,而不是默默放行。其他 CIUS profile 请使用官方产物
一个值得知道的 UBL 细节:BR-06 和 BR-07 只接受 PartyLegalEntity/RegistrationName 作为卖方或买方名称。回退到 PartyName/Name 看似宽容,却会把真正的 BR-06 失败藏起来,所以这个检查是严格的
什么时候仍然需要官方 Schematron?
凡是结果带法律或合同效力的场合——拒收供应商发票、认证自家生成器、解决争议——都请使用官方产物:经 Saxon 运行的 EN 16931 Schematron,或 XRechnung 的 KoSIT 校验器。内置报告永远不会把 fullConformance 置为 true。这个标志要求:经过验证的规则包、两个阶段都通过、两个阶段都由执行规则包自身规则的引擎运行,且 notCovered 里一无所有
ValidateInvoiceBusinessRulesWithPackage 走的就是这条路。规则包是一个带 rulepackage.xml 清单的目录,清单由 CreateInvoiceRulePackage 生成,写明标准、版本、schema 和 Schematron 样式表,并记录每个文件的 SHA-256。PDFlibPas 在包加载时检查每个摘要,在引擎读取文件前再查一遍,把验证之后被调包的窗口压到最小。路径不得越出包目录,原生 XSD 加载器拒绝清单未列出的 import。配置了 xmllint 时,被导入的 schema 由 xmllint 自己读取,此时只有加载时那一次检查在保护它们
const
// 你审阅并批准过的 rulepackage.xml 的 SHA-256
ApprovedManifestSHA256 = '<64 hex digits from your release review>';
var
Report, Pkg: TJSONObject;
begin
// PDF:已加载发票的 TPDFlib,同第一个例子
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesWithPackage('',
'C:\Rules\EN16931-CII-1.3.16', ApprovedManifestSHA256,
'', // 空:原生 XSD 阶段
'C:\Java\bin\java.exe', 'C:\Saxon\saxon-he.jar',
0)) as TJSONObject; // 0:每个工具 30000 ms
try
if not (Report.FindValue('package') is TJSONObject) then
raise Exception.Create('No rule package in the report');
Pkg := Report.GetValue<TJSONObject>('package');
if not (Pkg.GetValue<Boolean>('pinned') and Pkg.GetValue<Boolean>('verified')) then
raise Exception.Create('Rule package failed its integrity check');
if Report.GetValue<Boolean>('fullConformance') then
Writeln('Conforms to the complete rule package');
finally
Report.Free;
end;
end;
固定摘要不是可有可无的装饰。文件摘要只能证明文件与清单一致,包内没有任何东西能证明清单本身是真的:能改写规则的攻击者就能连清单一起改写。只有由调用方固定下来的摘要——写在代码里或签过名的配置里——才能堵上这个缺口。报告把 pinned 和 verified 分开,就是让你分得清自己实际拿到了哪种保证
如果传入空的 JavaExecutable,schema 阶段仍会原生对照包内 XSD 校验(自 v3.672.0 起包括 facet 和 identity constraint),但业务规则回退到内置子集,fullConformance 保持 false。原生加载器不支持的 schema 构件(如替换组)会让加载以 XSD.Unsupported 失败,而不是被跳过
veraPDF 与 Mustang 的位置
以上函数都不看 PDF 容器。ISO 19005-3 下的 PDF/A-3 符合性、Factur-X 的 XMP 扩展 schema 和 associated-file 关系是另外的问题,veraPDF 和 Mustang 仍是这方面的参考工具,见 用 veraPDF 和 Mustang 校验电子发票。生产侧——从挂载 XML 到声明 profile——则在 Delphi 中构建 Factur-X 与 ZUGFeRD 混合发票和 Factur-X 元数据的 PDF/A-3 扩展 schema两篇文章里
合理的分工是:每张入站发票都在进程内跑内置检查,成本以毫秒计——带 base64 附件的约 1 MB UBL 发票 15 ms 校验完,带 UBL 2.1 XSD 则是 79 ms。打包的 Saxon 运行放进 CI 和拒收路径,容器交给 veraPDF。无论内置结果如何,它描述的都是被覆盖的子集而不是标准本身——coverage 字段的存在就是提醒你这个的
发票校验函数、规则目录和规则包工具随 Delphi PDF Library(面向 Delphi 与 C++Builder)一起提供,上面用到的 Factur-X、PDF/A-3 和内嵌文件 API 也在其中