こんにちは、私はBorisです — TYPO3での経験が最も深いPHP開発者で、Laravelと
Filamentは比較的新しく、ますます注力している分野です。その多くは、支払い、Webhook、通知など、
静かに動作する必要がある統合でした。
これは電子請求書に関するもので、現在EU諸国で義務化が進んでおり、残りの国でも導入が予定されています。欧州でB2Bの請求書を発行するシステムを構築している場合、いずれはこの問題に直面することになるでしょう。
以下で説明するのはクロアチア向けのものですが、これは私が実際に構築したものです。ただし、その構造はドイツ(XRechnung)、ポーランド(KSeF)、イタリア、フランスなど他の国でも同じです:
EN 16931はセマンティックモデルを定義し、各国はその上にSchematronファイルとして独自のルールを重ねています。 私が遭遇した落とし穴は、クロアチア特有のものではなく、Schematronの特性によるものです。
セットアップ
請求書を送信します。すると却下の応答が返ってきます:
[HR-BR-9] - Račun mora sadržavati ispravan OIB operatera
Enter fullscreen mode Exit fullscreen mode
どこかにHR-BR-9の意味を定義した文書が存在します。EUのすべての実装で見たところ、その文書は.schファイル — ISO Schematron — であり、税務当局が公開しています。
クロアチアのものには62の異なるルールIDの下に73のassertionがあり、すべてがflag="fatal"です。警告は一切ありません。ルールに違反すると請求書は却下されます。
典型的なものは特に変わったものではありません:
<assert test="not(matches(/*/cbc:ID, '\s'))" flag="fatal" id="HR-BR-1">
[HR-BR-1] - The invoice number must not contain whitespace
</assert>
Enter fullscreen mode Exit fullscreen mode
PHPでは:
if (preg_match('/\s/u', $invoiceNumber) === 1) {
// broken
}
Enter fullscreen mode Exit fullscreen mode
62のうち約29はこれと同じくらい単純 — 正規表現、文字列長、日付範囲、存在チェックです。残りの16は4つのVATカテゴリにまたがる同一パターンの4バリエーションです。本当に難しいのは7つだけで、いずれも算術的な照合です。
つまり:PHPで再実装して先に進む。これが興味深いところです。
PHPではファイルを実行できない
SchematronはXSLTにコンパイルされ、文書に対して実行されます。クロアチアのものはqueryBinding="xslt2"を宣言しているため、XSLT 2.0が必要です。
PHPのXSLTProcessorはlibxsltを使用しており、これはXSLT 1.0のみです。これを変更するフラグはありません。選択肢は次のとおりです:
- PECL拡張機能としてのSaxonC。 動作しますが、すべてのデプロイ先サーバーにコンパイル済み拡張機能が必要になります。Composerでインストールするライブラリにとっては現実的ではありません。
- リモートの検証サービス。 請求書発行の途中にネットワーク依存関係が発生します。
- PHPでルールを再実装する。 ルールは分解してみると算術演算と集合所属です。
本番環境ではオプション3のみが合理的です。ただし、明らかな穴があります:正しく読み取ったことをどうやって確認するのか?
落とし穴
これは完全に明確に見えるルールです:
<assert test="($payableAmount > 0)
and (exists(cbc:DueDate) or exists(cac:PaymentMeans/cbc:PaymentDueDate))
or (($payableAmount <= 0))"
flag="fatal" id="HR-BR-4">
[HR-BR-4] - Where the payable amount (BT-115) is positive,
the payment due date (BT-9) must be given
</assert>
Enter fullscreen mode Exit fullscreen mode
支払額が正の場合、支払期限が必要です。つまり:
$payable = $invoice->totals->payableAmount->toFloat();
if ($payable > 0 && $dueDate === null) {
// broken
}
Enter fullscreen mode Exit fullscreen mode
これは誤りであり、独立したチェックによって発見されました。
assertionの30行上に、<assert>要素を探しているだけでは見逃してしまう場所に次のものがあります:
<let name="payableAmount" value="
if (/ubl-invoice:Invoice) then
cac:LegalMonetaryTotal/cbc:PayableAmount
else
cac:LegalMonetaryTotal/cbc:PayableAmount * -1"/>
Enter fullscreen mode Exit fullscreen mode
クレジットノートの場合、金額は-1倍されます。
これは理解できれば理にかなっています。クレジットノートは支払額を正の数値として保持しますが、実際のお金の流れは逆方向です。符号反転後、金額は負になり、> 0はfalseとなるため、このルールはクレジットノートには一切適用されません。
これを見逃すと、バリデータは支払期限のないすべてのクレジットノートに対してエラーを報告します。
ほぼすべてのクレジットノートが該当します — UBLのCreditNoteTypeには最初からcbc:DueDate要素が存在しないため、クレジットノートがこれを必要とする場合はcac:PaymentMeansに保持する必要があります。
システムで最も一般的な文書の1つに対する偽陽性です。
この教訓は、手動で実装する任意のSchematronに一般化できます:assertionは表層に過ぎない。let変数を読み取ること。 このファイルでは、ルールが使用する変数は別のブロックにルールより上に配置されており、テストの意味を完全に変えてしまいます。
正しく実装できたことを確認する方法
これは私がほぼスキップしかけた部分ですが、最も重要でした。
本番環境でSaxonを使用する必要はありません。テストで一度実行し、自分の実装とdiffを取ります:
# SchXsltを使用してSchematronをSVRLを出力するXSLTにコンパイルする
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
java -cp saxon.jar net.sf.saxon.Transform \
-s:rules.sch \
-xsl:schxslt/xslt/2.0/pipeline-for-svrl.xsl \
-o:compiled.xsl
# 文書に対して実行する
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
java -cp saxon.jar net.sf.saxon.Transform \
-s:invoice.xml -xsl:compiled.xsl
Enter fullscreen mode Exit fullscreen mode
出力はSVRLで、各<svrl:failed-assert>は失敗したルールのidを保持しています。それらを抽出して自分のコードが報告するものと比較し、不一致があればバグとみなします。
時間を費やした2つの点:
-
Saxon-HE 10.xを使用する。 バージョン12はクラスパスに
xmlresolverを必要としますが、10.xは単一の自己完結型jarです。 -
SVRLをXMLパーサーで解析する。 属性が複数行にまたがって整形出力されているため、
grepは完全に正常なレポートに対して何も返さず、パイプラインが壊れていると20分間思い込むことになります。
初回実行でHR-BR-4が見つかりました。修正後:20文書、0件の不一致 — さらにライターを通した往復後にも同じ結果でした。
チェックで発見されたもう1つの問題
税務当局は20の参照請求書を公開しています。自然なアプローチは、これらをフィクスチャに変換してすべて検証に通ることを主張することです。
それはやめましょう。どれも現在のルールに合格しません:
| ルール | ファイル | 理由 |
|---|---|---|
HR-BR-40 |
20/20 | すべての例の日付が2025年であり、ルールは2026年以降を要求する |
HR-BR-9 |
20/20 | プレースホルダーの税IDがチェックサムに失敗する |
HR-BR-53 |
19/20 | 別のフィールドに同じプレースホルダーがある |
HR-BR-25 |
1/20 | 1つの例が、免除されていない分類コードを省略している |
説明はありふれたものです。例は2025年12月に公開され、ルールは2026年3月に改訂され、その間に日付の下限が導入されました。誰も例を更新していません。
任意の国のCIUSを扱う場合に心に留めておくべき点です:例とルールは異なるリリースサイクルの異なる成果物です。 例を読取/書出のテスト用入力として使用してください。期待される検証結果を測定し、仮定しないでください。
EN 16931にない、各国CIUSが追加するもの
簡単にまとめます。これらは人々を驚かせるものです:
-
必須のオペレータ。 クロアチアでは、請求書を発行した個人の名前と税番号が
cac:AccountingSupplierParty/cac:SellerContactに必要です。アプリに「誰が発行したか」の概念がない場合、新たに追加する必要があります。 - 必須の発行時刻。 EN 16931には日付のみがあります。
-
空要素の禁止。
<cbc:Note></cbc:Note>は文書を不合格にします。ほとんどのXMLビルダーはnullプロパティに対して空要素を出力するため、修正はライター側で行う必要があります:値を書き込むヘルパーは、値がない場合は何も書き込まないようにします。 - すべての明細行に分類コード、3,359の許可値のリストから — これは国家統計カタログの5,828のサブセットです。カタログではなくルールファイル内のサブセットに対して検証してください。そうしないと当局が拒否するコードを受け入れてしまいます。
パッケージ
上記のすべてはstboris/laravel-eracunに含まれています
— MIT、PHP 8.3+、フレームワーク依存なし:
use Stboris\Eracun\Validation\Validator;
$result = Validator::default()->validateFile('invoice.xml');
$result->brokenCodes(); // ["HR-BR-9", "HR-BR-40"]
$result->messages(); // ["[HR-BR-9] HR-BT-5: ...", ...]
Enter fullscreen mode Exit fullscreen mode
違反は公式のルール識別子を保持するため、パッケージからのメッセージはプロバイダーから受け取った却下コードと一致します。62のルールすべてが実装されており、上記の比較ハーネスはリポジトリに含まれているため、主張は検証可能であって、単に主張されているだけではありません:
Validator::default()->coverage(); // ['ratio' => '62/62', 'missing' => []]
Enter fullscreen mode Exit fullscreen mode
これはビジネスルールバリデータであり、適合性バリデータではありません。誰かを何かに準拠させるものではありません。署名、財政化、送信は意図的に範囲外です — これらには証明書またはプロバイダーとの商業契約が必要です。
別の国向けに同じものを構築している場合、構造はまっすぐ移植できるはずです:型付けされた文書オブジェクト、ルールごとの小さなクラス、そして自分自身を確認するためのコンテナ内のSaxon。
国のCIUSを実装する際にlet変数の問題に遭遇した方、または公開ルールとの同期を保つよりクリーンな方法を見つけた方がいれば、コメントでお知らせください。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.