技術記事

Java なしで Delphi から EN 16931 請求書検証をネイティブに行う

losLab の Delphi PDF Library である PDFlibPas は、EN 16931、XRechnung 3.0.2、Peppol BIS Billing 3.0 のビジネスルールを Pascal でネイティブに検証します。TPDFlib.ValidateInvoiceBusinessRulesBuiltIn は Factur-X や ZUGFeRD の PDF に埋め込まれた XML を読み、公式のルール ID 付きの JSON レポートを返します。Java ランタイムも Saxon も Schematron ツールチェーンも不要です。レポート自身が自分をサブセットと名乗っており、この表示こそレポート中で最も重要なフィールドです

Delphi 用 PDFlibPas PDF ライブラリの組み込み EN 16931 請求書検証のパイプライン。Factur-X 検出、スキーマと Schematron の各段階、カバー範囲サブセットの JSON レポート
組み込みバリデータはスキーマ段階と Schematron 段階をプロセス内で実行し、サブセットがカバーするルールとしないルールを正確に報告します

この機能を後押ししているのは、主にドイツの制度です。2025 年 1 月以来、ドイツのすべての国内事業者は構造化 B2B 電子請求書を受け取れる状態でなければならず、発行義務は段階的に後から来ます。突如として、Delphi の会計・ERP アプリケーションの受取側は、それまで問われたことのない問いに答えなければならなくなりました。この PDF の中の XML は、実際に有効な EN 16931 請求書なのか? 公式の答えは XSLT 2.0 にコンパイルされた Schematron 成果物、あるいは XRechnung 向けの KoSIT バリデータから得られ、どちらも JVM 上で動きます。不正な請求書を 1 件はじくためだけに、デスクトップインストールごとに 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 の背後にある 2,100 以上の UN/ECE Rec 20/21 単位コードと、小数 2 桁ルールの BR-DEC-01、-05、-09 から -20、-23 を含みます
  • プロファイルルール: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 のコア・共通・コードリスト・国別ルール

カバー範囲は暗示ではなく明示されます。スキーマファイルがない場合、スキーマ段階は整形式とルート要素だけを確認します。各段階はサブセット外のルールを notCovered フィールドに列挙し、GetBuiltInInvoiceRuleCatalog はマップ全体を JSON で返します。つまり UI は、緑の結果が何をカバーしているのかをユーザーに正確に見せられるのです

もう 1 つ、見落としやすい境界があります。Factur-X の MINIMUM と BASIC WL 文書は EN 16931 請求書ではないため、バリデータはこれらに EN 16931 ルールを適用しません。代わりにその旨を伝える情報レベルの BuiltIn.Coverage.Profile 問題を追加します。これは正直な答えであると同時にヒントでもあります。ドイツのルールの下では、この 2 つのプロファイルはそもそも電子請求書と見なされないのです

ハイブリッド PDF に埋め込まれた XML を検証する

ValidateInvoiceBusinessRulesBuiltIn の引数は 1 つで、埋め込みファイルの名前です。通常は空文字列を渡します。PDFlibPas は Factur-X 検出が特定した添付ファイルを選び、Factur-X メタデータのない PDF では唯一の .xml 添付へフォールバックします。候補の .xml 添付が複数ある場合や、要求した名前の添付が 2 つある場合は推測ではなく 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 配列を報告します。各問題には ruleID、fieldPath、severity、category、line、message が付きます

メッセージではなくカテゴリで問題を読む

レポートを実務の受信箱で使えるようにしているのが category フィールドです。誰が問題を直すべきかを教えてくれるからです。値は syntax、schema、cardinality、calculation、vat、codelist、decimal、profile、coverage、processing、integrity です。calculation の検出はたいてい送信側の丸めコードを、codelist の検出はマスターデータを、profile の検出は buyer reference などの 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 カテゴリの税額が課税額×税率(小数 2 桁に丸め)と等しいことを検査し、両構文とも差 1 通貨単位を許します。ところが 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 が落ちます。わざわざそんな請求書を送る人はいませんが、丸めバグを抱えたジェネレータはまさにその境界線上に座っており、片方の構文しかカバーしないテストスイートでは気づけません

Delphi 用 PDFlibPas 請求書検証における BR-CO-17 の許容誤差比較。丸め差 1 単位の同じ請求書が CII では通り UBL では落ちる
丸め 1 単位がちょうど BR-CO-17 の境界に乗るため、同じ請求書が CII では通り UBL では落ちます

差は BR-CO-17 以外にもあります。BR-xx-08 は CII では小数 2 桁への丸め後の厳密な一致を要求する一方、UBL は ±1 を許し、しかもカテゴリに明細・値引き・手数料が実際に存在するときだけ発火します。BR-O-08 は両方で厳密です。CII の成果物は BR-AF-09 と BR-AG-09 を true() と定義しているため CII では決して落ちませんが、UBL は ±1 で検査します。さらに CII の VAT 内訳に税率がまったくない場合、XPath の every $rate は空シーケンスに対して真を返すため、非 O カテゴリのルールは空振りして通ります。ネイティブコードは同じようにスキップする必要があり、そうしないと公式サンプルファイルに対してエラーを報告してしまいます

2 つ目の罠はルール ID に関わります。ルールカテゴリの接頭辞は VAT カテゴリコードそのものではありません。K は BR-IC へ、L は BR-AF へ、M は BR-AG へ対応します。カテゴリコードから ID を組み立てると、EN 16931 に存在しない BR-K-09 や BR-K-10 が生まれます。アプリケーションがルール ID をヘルプテキストへマッピングしているなら、ID は必ずレポートから取り、税カテゴリコードから組み立ててはいけません

XRechnung と Peppol のルールはどう有効化されるのか

組み込みバリデータは仕様識別子(BT-24)からプロファイルルールを選びます。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 カスタマイズを主張する CII 文書には、ルールを検査しなかった旨の情報レベル BuiltIn.Coverage.Profile 注記が付き、黙って通過することはありません。それ以外の CIUS プロファイルには公式の成果物を使ってください

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 が書き出します。マニフェストは標準・バージョン・スキーマ・Schematron スタイルシートを名乗り、全ファイルの SHA-256 を記録します。PDFlibPas はパッケージのロード時に各ダイジェストを検査し、さらにエンジンがファイルを読む直前にもう一度検査します。これで検証後に入れ替えられる窓が狭まります。パスはパッケージディレクトリの外へ出てはならず、ネイティブの XSD ローダーはマニフェストに列挙されていないインポートを拒みます。xmllint を設定している場合、インポートされたスキーマは 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 を別々に保つため、自分がどちらの保証を持っているのかを区別できます

Delphi における PDFlibPas 請求書ルールパッケージの完全性フロー。マニフェスト SHA-256 ダイジェストをロード時と各読み取り前に再検査し、呼び出し側がピン留めする
ファイルのダイジェストがパッケージの中身を守り、マニフェスト自身を守れるのは呼び出し側がピン留めしたダイジェストだけです

JavaExecutable に空文字列を渡すと、スキーマ段階はパッケージの XSD に対してネイティブに検証を続けます(facets と identity constraints は v3.672.0 以降対応)。しかしビジネスルールは組み込みサブセットへフォールバックし、fullConformance は false のままです。ネイティブローダーが対応していないスキーマ構成要素、たとえば substitution groups は、スキップではなく XSD.Unsupported のロード失敗になります

veraPDF と Mustang の居場所

これらの関数はどれも PDF コンテナを見ません。ISO 19005-3 の下での PDF/A-3 適合、Factur-X の XMP 拡張スキーマ、関連ファイルのリレーションシップは別の問題であり、その参考ツールは今も veraPDF と Mustang です。veraPDF と Mustang による電子請求書の検証で説明しています。XML の添付からプロファイルの宣言までの発行側は、Delphi で Factur-X と ZUGFeRD のハイブリッド請求書を組み立てるとFactur-X メタデータ向けの PDF/A-3 拡張スキーマがカバーします

現実的な分業は、すべての受信請求書に対して組み込み検査をプロセス内で実行するものです。コストはミリ秒単位です。base64 添付付きの約 1 MB の UBL 請求書は 15 ms で検証でき、UBL 2.1 の XSD 付きでも 79 ms です。パッケージ化した Saxon 実行は CI と拒否パスへ回し、コンテナは veraPDF に任せます。組み込みの結果がどうであれ、それはカバーされるサブセットを説明するものであって標準を説明するものではありません。まさにその注意喚起のために coverage フィールドがあるのです

請求書検証関数、ルールカタログ、ルールパッケージツールは、Delphi と C++Builder 向けの Delphi PDF Library に同梱されています。ここで使った Factur-X、PDF/A-3、埋め込みファイルの API も一緒です