嗨,我是 Boris —— 一位最深入的经验在 TYPO3 的 PHP 开发者,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 并针对你的文档运行。克罗地亚的 Schematron 声明了 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
这是错误的,经过独立检查才发现问题。
在断言上方 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 为假,这条规则根本不适用于贷记凭证。
如果你遗漏了这一点,你的验证器就会对每一张没有到期日的贷记凭证报告错误。而这几乎是所有贷记凭证的共同情况 —— UBL 的 CreditNoteType 本身没有 cbc:DueDate 元素,因此需要到期日的贷记凭证必须将其放在 cac:PaymentMeans 中。
这是在系统中最常见的文档类型上出现的假阳性。
这个教训可以推广到任何手动实现 Schematron 的场景:断言只是冰山一角。请阅读 let 变量。 在这个文件中,它们位于使用它们的规则上方,位于不同的块中,完全改变了测试的含义。
如何确定你做对了
这是我差点跳过的部分,也是最重要的部分。
你不需要在生产环境中使用 Saxon,只需在测试中使用一次。在 Docker 中运行一次,然后与你自己的实现进行 diff:
# Compile the Schematron into an XSLT that emits SVRL, using SchXslt
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
# Run it over a document
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,与你的代码报告进行比较,任何不一致都意味着你的代码存在 bug,除非有证据证明否则。
有两件事浪费了我的时间:
-
使用 Saxon-HE 10.x。 版本 12 需要在类路径中包含
xmlresolver;10.x 是一个自包含的 jar。 -
使用 XML 解析器解析 SVRL。 它使用漂亮打印格式,属性分布在多行上,因此在完全正常的报告上执行
grep会返回空结果,你会花 20 分钟确信管道已损坏。
第一次运行发现了 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.