技術文章

Delphi 原生 EN 16931 發票驗證,不必裝 Java

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 工具鏈。報告自我標示為一個子集,而這個標示正是報告裡最重要的欄位

PDFlibPas Delphi PDF 函式庫內建 EN 16931 發票驗證流程:Factur-X 偵測、schema 與 schematron 階段,以及子集涵蓋 JSON 報告
內建驗證器在同一行程內跑完 schema 與 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 失敗。沒有人會故意開這種發票,但帶著捨入錯誤的開票程式恰好落在這種邊界上,只測一種語法的測試套件不會察覺

PDFlibPas Delphi 發票驗證中 BR-CO-17 容許誤差比較:同一張含一單位捨入差異的發票在 CII 通過、在 UBL 失敗
一個捨入單位恰好落在 BR-CO-17 邊界上,所以同一張發票在 CII 通過、在 UBL 失敗

差異不止 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 分開,讓你分得清自己手上到底是哪種保證

Delphi PDFlibPas 發票規則套件完整性流程:資訊清單 SHA-256 摘要在載入時與每次讀取前重新檢查,由呼叫端釘選
檔案摘要保護套件內容,只有呼叫端釘選的摘要才保護資訊清單本身

傳入空的 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 一併包含