Technical Article

Delphi 中的 Factur-X 和 ZUGFeRD 混合发票

合规的电子发票并不是旁边钉着 XML 文件的 PDF。它是一个单一的 PDF/A-3 文档,里面带有两次发票内容:一次是供人阅读的页面,另一次是作为关联文件存储在文件内部的机器可读跨行业发票 XML。这两种表示描述的是同一张发票。这种双重性质正是欧洲指令现在要求的格式族(法国和德国的 Factur-X,德语市场的 ZUGFeRD,以及德国公共部门账单的 XRechnung)的全部意义所在。本文将演练 PDFlibPas 如何在 Delphi 中组装这种混合发票,指出标准中容易出错的地方,以及为什么目录中的某一个配置文件需要一个完全独立的 XML 构建器

混合发票实际上是什么

可见页面和嵌入的 XML 服务于不同的读者。批准付款的职员查看渲染后的页面。应付账款系统提取 XML,将总计和税收明细作为结构化字段读取,并在没有任何人手动输入的情况下记录分录。该 XML 的语义内容由 EN 16931 约束,这是定义发票数据模型的欧洲标准:存在哪些字段,它们的含义是什么,以及哪些是必填的。EN 16931 是一个语义模型,而不是文件格式。Factur-X、ZUGFeRD 2.x 和 XRechnung 均将该模型实现为 UN/CEFACT Cross Industry Invoice(跨行业发票)文档,即在网络上传输 EN 16931 字段的语法

为了使文档既可归档又具备自我描述性,容器采用了 PDF/A-3,由 ISO 19005-3 定义。PDF/A-3 是允许任意嵌入文件的合规级别,这正是发票 XML 所需要的。PDF/A-2 禁止嵌入其本身不是 PDF/A 的文件,因此 Factur-X 发票不能是 PDF/A-2。因此,选择 PDF/A-3 并不是一种偏好,而是由于想要将非 PDF 数据嵌入到归档文档中所直接产生的要求

为什么关系是 Alternative

嵌入字节是最简单的部分。ISO 32000 §7.11.4 定义了嵌入的文件流,即保存原始 XML 及其参数的对象。使文件成为有效的 associated file(关联文件)的部分是 §14.13,它增加了关联文件的概念以及 /AFRelationship 键。该键说明了嵌入数据与它所附加的内容之间的关系,而 Factur-X 强制要求的值是 Alternative

这个选择很重要,因为其他值会对文档断言一些不真实的东西。Source 将意味着 XML 是用来生成可见内容的材料,是页面派生出来的主文件。Supplement 将意味着 XML 增加了页面显示之外的信息,即渲染中不包含的额外内容。两者都不是 Factur-X 发票的实质。XML 和页面是一张发票的两个等效表达式,以两种形式承载着相同的法律内容。Alternative 恰好表达了这一点:它是可见内容的等效的替代表示。一个验证器如果在 Factur-X 文件上读取到任何其他关系,都将拒绝该文件,这是理所当然的,因为该关系是对附件用途的机器可读的声明

配置文件目录

随 PDFlibPas 提供的 E-Invoice(电子发票)示例通过在 InvoiceModel.pas 中定义为记录数组的六个配置文件驱动相同的生成路径。每个配置文件都携带着写入器需要的值:显示名称、嵌入文件名、一致性级别、/AFRelationship、版本、可选的国家代码以及 XML 在其文档上下文中声明的 GuidelineID URN

这六个是 Factur-X EN16931、Factur-X BASIC、法国的 Factur-X EXTENDED、XRechnung 3.0、ZUGFeRD 1.0 COMFORT 和 ZUGFeRD 2.0 BASIC。GuidelineID 是确切告诉接收者期望哪个配置文件的字段,且其值是特定的。Factur-X EN16931 声明 urn:cen.eu:en16931:2017。XRechnung 3.0 声明 urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0。ZUGFeRD 2.0 BASIC 声明 urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p0:basic。嵌入的文件名也是契约的一部分。Factur-X 配置文件嵌入 factur-x.xml,XRechnung 嵌入 xrechnung.xml,而 ZUGFeRD 配置文件嵌入 ZUGFeRD-invoice.xmlzugferd-invoice.xml。接收者扫描附件名称来查找发票,因此文件名并不是装饰性的

目录中的一个细节值得仔细阅读。大多数配置文件使用 Alternative 关系,但示例中的 XRechnung 3.0 条目使用 Source。这两种格式响应不同的验证器和约定,示例从目录中设置每个配置文件的关系,而不是硬编码单一值,这就是存在每个配置文件字段而不是常量的原因

ZUGFeRD 1.0 陷阱

人们很容易认为每个配置文件都是 EN 16931 跨行业发票,只是在你要填充多少可选字段上略有不同。这适用于六个中的五个。但不适用于 ZUGFeRD 1.0 COMFORT,原因在于结构而不是外观

现代配置文件发出具有命名空间版本 :100 的 UN/CEFACT Cross Industry Invoice,其根元素是 rsm:CrossIndustryInvoice。ZUGFeRD 1.0 早于该 schema。它是具有命名空间版本 :1p0 的 2014 CrossIndustryDocument,其根元素是 rsm:CrossIndustryDocument。命名空间 URN 不同,根元素不同,并且整个元素树也都不同::1p0 schema 将数据分组在 ApplicableSupplyChainTradeAgreementApplicableSupplyChainTradeDeliveryApplicableSupplyChainTradeSettlement 之下,而 :100 使用 ApplicableHeaderTradeAgreementApplicableHeaderTradeDeliveryApplicableHeaderTradeSettlement。命名足够相似以至于具有误导性,而差异又足够大以至于会导致崩溃

配置文件名称中的 COMFORT 一词描述了数据的丰富程度,一个带有完整行项目、税收明细和付款条件的自动化级别配置文件,而不是承载它的 schema。因此,你不能拿一个 :100 文档去为 ZUGFeRD 1.0 重新打标签。示例在每个配置文件记录上使用一个标志和两个独立的构建器函数来处理这个问题,在生成任何 XML 之前选择正确的函数

function BuildInvoiceXMLText(const AProfile: TeInvoiceProfile;
  const Data: TInvoiceData): string;
begin
  // XMLFamily = 1 means the legacy ZUGFeRD 1.0 :1p0 schema; every
  // other profile is the modern UN/CEFACT :100 Cross Industry Invoice.
  if AProfile.XMLFamily = 1 then
    Result := BuildZUGFeRD1Text(AProfile, Data)
  else
    Result := BuildCII100Text(AProfile, Data);
end;

这种拆分不是实现的繁文缛节。将一棵 :100 的树喂给 ZUGFeRD 1.0 接收者会产生一个在根元素 schema 验证失败的文档,因此这两个系列必须由知道自己正在编写哪一个系列的代码来构建

选择 PDF/A-3 级别

PDF/A-3 具有三个一致性级别,PDFlibPas 通过 SetPDFAMode 选择它们。模式 5 是 PDF/A-3b,这是保证可靠视觉再现的级别。模式 6 是 PDF/A-3a,它增加了级别 a 的标记结构和可访问性要求。模式 7 是 PDF/A-3u,它要求将所有文本映射到 Unicode。启用该模式还会嵌入库内置的 sRGB OutputIntent,即 PDF/A 要求的颜色特征,以便呈现的颜色是确定的而不是依赖于设备的

大多数发票流在 3b 下运行,这对于忠实的可见页面加上嵌入的 XML 来说已经足够了。如果你需要一个显式的 ICC 配置文件而不是内置的,LoadOutputIntentProfile 会在模式设置后将其换入。示例通过这种方式加载仓库的 sRGB 配置文件,并在该文件不可用时回退到内置的 intent,因此 OutputIntent 始终存在

PDF := TPDFlib.Create;
try
  // Mode 5 = PDF/A-3b, 6 = PDF/A-3a, 7 = PDF/A-3u.
  if PDF.SetPDFAMode(5) <> 1 then
    raise Exception.Create('PDF/A-3 mode could not be enabled');

  // Optional: swap the built-in sRGB intent for an explicit ICC profile.
  if PDF.LoadOutputIntentProfile(ICCFile, 'DeviceRGB') <> 1 then
    { fall back to the built-in sRGB intent that SetPDFAMode embedded };
finally
  // ... continue building the document
end;

构建混合发票

容器配置好后,其余的就是按顺序执行三个步骤:设置 PDF/A-3 模式,绘制供人阅读的页面,然后将 XML 作为关联文件附加。可见页面是普通内容。值得记住的一个限制是 PDF/A 禁止使用非嵌入的 Standard 14 字体,因此该页面必须嵌入真实的字体外观,而不是引用内置字体

附件是单次调用。AddFacturXAssociatedFileFromString 接受原始 UTF-8 XML 字节以及配置文件元数据,写入嵌入文件流,在 PDF/A-3 要求的 Catalog /AF 数组中注册它,应用 /AFRelationship,并生成标识该文档为 Factur-X、ZUGFeRD 或 XRechnung 的 XMP 电子发票元数据。它还会检查 XML 的 guideline ID 是否与你要求的一致性级别相匹配,这样构建的 XML 与命名的配置文件之间的不匹配就能被捕获,而不是悄无声息地发布

// 1. PDF/A-3 mode and output intent are already set.
// 2. Draw the visible page (embeds a real TrueType font).
DrawInvoicePage(PDF, AProfile, Data);

// 3. Build the profile-correct XML and attach it as an
//    associated file with /AFRelationship = Alternative.
InvoiceXML := BuildInvoiceXML(AProfile, Data);   // AnsiString of UTF-8 bytes
FileID := PDF.AddFacturXAssociatedFileFromString(
  InvoiceXML,
  AProfile.ConformanceLevel,   // e.g. 'EN16931'
  AProfile.FileName,           // 'factur-x.xml'
  AProfile.Description,
  AProfile.Relationship,       // 'Alternative'
  AProfile.Version,            // '1.0'
  AProfile.CountryCode);       // '' or 'DE' or 'FR'
if FileID <= 0 then
  raise Exception.Create('Invoice XML could not be attached');

PDF.SaveToFile(TargetFile);

数据路径中的一个细微之处是编码。嵌入的 XML 声明了 encoding="UTF-8",该方法将其字节作为 AnsiString 获取,因此非 ASCII 的卖方或买方名称必须作为原始 UTF-8 八位字节到达该调用。通过系统 ANSI 代码页的简单强制转换会破坏这些字符,并悄无声息地生成发票,其 XML 不再匹配它自己的声明。示例在移交字节之前显式地编码为 UTF-8,这是从 Unicode string 喂送任何面向字节的 PDF API 的安全方式

对于附加未被识别为电子发票配置文件的 XML,AddPDFA3AssociatedFileFromString 是通用的对应方法。它接受文件名、MIME 类型、描述、关系和字节,并写入普通的 PDF/A-3 关联文件,没有任何发票特定的元数据或 guideline 检查。将其用于补充数据;对发票使用 Factur-X 方法,这样就可以为你写入配置文件元数据和 guideline 匹配

一旦文档生成,接下来的问题是它是否通过了 PDF/A 和可访问性验证,以及它是否能在不破坏兼容性的情况下被签名。这些在 PDF/A 和 PDF/UA 印前检查演练以及兼容性与签名工作台中有所涉及。所有这些作为适用于 Delphi 和 C++Builder 的 PDFlibPas Delphi PDF Library 的一部分发布,与电子发票路径所构建的 PDF/A、标记和文档属性 API 一起