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。沒有任何旗標可以改變這一點。
你的選擇:
- 以 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
這是錯的,而我花了很久才透過獨立檢查找出原因。
在斷言上方三十行——如果你只掃描 <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 變數的問題,或者找到更乾淨的方式與已發布規則保持同步——歡迎在評論中告訴我。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.