PDFlibPas(losLab 的 Delphi PDF Library)直接以 Pascal 原生檢查 EN 16931、XRechnung 3.0.2 與 Peppol BIS Billing 3.0 的業務規則。TPDFlib.ValidateInvoiceBusinessRulesBuiltIn 讀出 Factur-X 或 ZUGFeRD PDF 內嵌的 XML,回傳帶官方規則 ID 的 JSON 報告,過程不需要 Java runtime、Saxon 或 Schematron 工具鏈。報告自我標示為一個子集,而這個標示正是報告裡最重要的欄位
背後的壓力主要來自德國。2025 年 1 月起,德國每個國內企業都必須能接收結構化 B2B 電子發票,開立義務則分階段跟進。突然間,Delphi 會計或 ERP 應用的接收端得回答一個它從沒問過的問題:這份 PDF 裡的 XML 真的是一張有效的 EN 16931 發票嗎?官方答案來自編譯成 XSLT 2.0 的 Schematron 產物,或 XRechnung 的 KoSIT validator,兩者都跑在 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 核心、共通、代碼表與國家規則
範圍是明確回報的,不是暗示。沒有 schema 檔時,schema 階段只確認 well-formedness 與根元素。每個階段都把子集外的規則列在 notCovered 欄位,GetBuiltInInvoiceRuleCatalog 則以 JSON 回傳整張對照表,所以 UI 可以告訴使用者一個綠燈結果到底涵蓋了什麼
還有一個容易漏掉的邊界。Factur-X 的 MINIMUM 與 BASIC WL 文件不是 EN 16931 發票,驗證器不對它們套 EN 16931 規則,而是加上一條說明這件事的 BuiltIn.Coverage.Profile 資訊項目——這是誠實的答案,也是一個提示:在德國規則下這兩種 profile 本來就不算電子發票
驗證混合式 PDF 內嵌的 XML
ValidateInvoiceBusinessRulesBuiltIn 只吃一個參數:內嵌檔名,一般傳空字串即可。PDFlibPas 會挑 Factur-X 偵測認出的附件;PDF 沒有 Factur-X metadata 時,退而使用唯一那個 .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')); // 這裡永遠不會是 True
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
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,因為 well-formedness 與根元素的失敗落在那裡,業務規則階段根本看不到解析不了的文件
為什麼同一張發票在 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 失敗。沒有人會故意開這種發票,但帶著捨入錯誤的開票程式恰好落在這種邊界上,只測一種語法的測試套件不會察覺
差異不止 BR-CO-17。BR-xx-08 在 CII 要求捨入到兩位小數後完全相等,UBL 則容許 ±1,而且只在該類別真的有明細、折讓或費用時才觸發;BR-O-08 兩邊都要求完全相等;CII 產物把 BR-AF-09 與 BR-AG-09 定義成 true(),所以在 CII 永不失敗,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 validator。內建報告永遠不會把 fullConformance 設為 true;那個旗標需要驗證過的規則套件、兩個階段都通過、兩個階段都由執行該套件自身規則的引擎跑過,而且 notCovered 為空
ValidateInvoiceBusinessRulesWithPackage 走的就是那條路。規則套件是一個目錄,裡面有 CreateInvoiceRulePackage 寫出的 rulepackage.xml 資訊清單,記載標準、版本、schema 與 Schematron stylesheet,並記錄每個檔案的 SHA-256。PDFlibPas 在套件載入時檢查每個摘要,在引擎讀檔前再驗一次,壓縮驗證之後被抽換的機會窗口;路徑不得離開套件目錄,原生 XSD loader 也拒絕資訊清單未列出的 import
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 起包括 facets 與 identity constraints),但業務規則退回內建子集,fullConformance 維持 false;原生 loader 不支援的 schema 建構(例如 substitution groups)會讓載入以 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 metadata 的 PDF/A-3 延伸 schema
合理的分工是讓內建檢查以 in-process 方式跑在每一張進來的發票上——它只花幾毫秒:約 1 MB、帶 base64 附件的 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 一併包含