Hi,我是 Boris——一位 PHP 開發者,專長領域是 TYPO3,Laravel 和
Filament 則是較新的重點領域。這些工作大多是整合介面,必須默默運作:支付、webhook、通知。

這篇文章的主題是電子發票,現在歐盟越來越多國家已強制實施,其他國家也將跟進。如果你開發的應用程式會向歐洲的 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 的意義。在我看過的每一個歐盟實作中,該文件都是 .sch 檔案——ISO Schematron——由稅務機關發布。

克羅埃西亞的 Schematron 有 62 個不同規則 ID 下的 73 項斷言,每一項都設定 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 項是四種 VAT 類別的四種變形。只有 7 項是真正困難的,全都涉及算術調和。

因此:在 PHP 中重新實作這些規則,然後繼續前進。這正是有趣的地方。

PHP 無法執行該檔案

Schematron 會編譯成 XSLT,並對你的文件執行。克羅埃西亞的檔案宣告 queryBinding="xslt2",因此需要 XSLT 2.0

PHP 的 XSLTProcessor 使用 libxslt,僅支援 XSLT 1.0。沒有任何旗標可以改變這一點。
你的選擇:

  1. 以 PECL 擴充安裝 SaxonC。 能運作,但你部署到的每一台伺服器都需要編譯好的擴充。對於用 Composer 安裝的函式庫來說,這是不切實際的。
  2. 遠端驗證服務。 在開立發票的過程中增加網路依賴。
  3. 在 PHP 中重新實作規則。 這些規則拆解後,就是算術和集合成員運算。

對正式環境而言,選項 3 是唯一合理的選擇。但它也有一個明顯的漏洞:你怎麼知道自己讀懂了?

陷阱

這裡有一條看起來完全明確的規則:

<assert test="($payableAmount > 0)
              and (exists(cbc:DueDate) or exists(cac:PaymentMeans/cbc:PaymentDueDate))
              or (($payableAmount &lt;= 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

這是錯的,而我花了很久才透過獨立檢查找出原因。

在斷言上方三十行——如果你只掃描 <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

對於銷退折讓單(credit note),金額會乘以 -1

一旦你理解這個邏輯,就會發現它有道理。銷退折讓單的應付金額以正數呈現,但資金流向相反。經過符號翻轉後,金額變成負數,> 0 條件為假,該規則完全不適用於銷退折讓單

如果你忽略這一點,你的驗證器就會對每一張沒有到期日的銷退折讓單回報錯誤。而幾乎所有銷退折讓單都會這樣——UBL 的 CreditNoteType 根本沒有 cbc:DueDate 元素,因此需要到期日的銷退折讓單必須將它放在 cac:PaymentMeans 中。

這是假陽性,而且發生在系統中最常見的文件類型之一。

這個教訓可以推廣到任何手動實作的 Schematron:斷言只是冰山一角。請閱讀 let 變數。 在這個檔案中,它們位於使用它們的規則上方,位於不同的區塊,而且它們完全改變了測試的意義。

如何確認自己做對了

這是我差點跳過的部分,卻也是最關鍵的部分。

你不需要在正式環境中使用 Saxon,只需要在測試中使用它。用 Docker 執行一次,然後與你自己的實作比對:

# 使用 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。取出這些 id,與你的程式碼回報的結果比對,任何差異在未經證實前都代表你程式碼的錯誤。

有兩件事花了我不少時間:

  • 使用 Saxon-HE 10.x。 版本 12 需要在 classpath 中加入 xmlresolver;10.x 則是單一獨立 jar。
  • 使用 XML 解析器解析 SVRL。 它會將屬性跨行漂亮列印,因此在完全正常的報告上執行 grep 會什麼都找不到,你會花二十分鐘確信管線壞掉了。

第一次執行就發現了 HR-BR-4。修正後:20 份文件,0 項差異——這些文件經過我的寫入器往返處理後,結果依然相同。

檢查發現的另一件事

稅務機關發布了 20 份參考發票。自然的做法是將它們變成固定測試資料,並斷言它們都能通過驗證。

不要這麼做。它們沒有一份能通過目前的規則:

規則 檔案數 原因
HR-BR-40 20/20 所有範例日期都是 2025 年;規則要求 2026 年起
HR-BR-9 20/20 預留的稅籍編號無法通過校驗碼檢查
HR-BR-53 19/20 另一個欄位也有相同的預留值
HR-BR-25 1/20 有一份範例缺少它不該免除的分類代碼

解釋很平凡。範例在 2025 年 12 月發布,規則在 2026 年 3 月修訂,日期下限是在這段期間加入的。沒有人更新範例。

如果你在處理任何國家 CIUS,這點值得牢記:範例和規則是不同發布週期的不同成品。 請將範例視為測試讀取器和寫入器的輸入。測量預期的驗證結果;不要假設它。

國家 CIUS 會加入 EN 16931 沒有的東西

簡要說明,因為這些是讓人意外的部分:

  • 強制性的操作者。 克羅埃西亞要求在 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 變數的問題,或者找到更乾淨的方式與已發布規則保持同步——歡迎在評論中告訴我。